184-Spark性能调优实战

01 | 性能调优的必要性：Spark本身就很快，为啥还需要我调优？

你好，我是吴磊。

在日常的开发工作中，我发现有个现象很普遍。很多开发者都认为 Spark 的执行性能已经非常强了，实际工作中只要按部就班地实现业务功能就可以了，没有必要进行性能调优。

你是不是也这么认为呢？确实，Spark 的核心竞争力就是它的执行性能，这主要得益于 Spark 基于内存计算的运行模式和钨丝计划的锦上添花，以及 Spark SQL 上的专注与发力。

但是，真如大家所说，开发者只要把业务逻辑实现了就万事大吉了吗？这样，咱们先不急于得出结论，你先跟着我一起看两个日常开发中常见的例子，最后我们再来回答这个问题。

在数据应用场景中，ETL（Extract Transform Load）往往是打头阵的那个，毕竟源数据经过抽取和转换才能用于探索和分析，或者是供养给机器学习算法进行模型训练，从而挖掘出数据深层次的价值。我们今天要举的两个例子，都取自典型 ETL 端到端作业中常见的操作和计算任务。

开发案例 1：数据抽取

第一个例子很简单：给定数据条目，从中抽取特定字段。这样的数据处理需求在平时的 ETL 作业中相当普遍。想要实现这个需求，我们需要定义一个函数 extractFields：它的输入参数是 Seq[Row]类型，也即数据条目序列；输出结果的返回类型是 Seq[(String, Int)]，也就是（String, Int）对儿的序列；函数的计算逻辑是从数据条目中抽取索引为 2 的字符串和索引为 4 的整型。

应该说这个业务需求相当简单明了，实现起来简直是小菜一碟。在实际开发中，我观察到有不少同学一上来就迅速地用下面的方式去实现，干脆利落，代码写得挺快，功能也没问题，UT、功能测试都能过。

//实现方案1 —— 反例
val extractFields: Seq[Row] => Seq[(String, Int)] = {
  (rows: Seq[Row]) => {
    var fields = Seq[(String, Int)]()
    rows.map(row => {
        fields = fields :+ (row.getString(2), row.getInt(4))
    })
  fields
  }
}

在上面这个函数体中，是先定义一个类型是 Seq[(String, Int)]的变量 fields，变量类型和函数返回类型完全一致。然后，函数逐个遍历输入参数中的数据条目，抽取数据条目中索引是 2 和 4 的字段并且构建二元元组，紧接着把元组追加到最初定义的变量 fields 中。最后，函数返回类型是 Seq[(String, Int)]的变量 fields。

乍看上去，这个函数似乎没什么问题。特殊的地方在于，尽管这个数据抽取函数很小，在复杂的 ETL 应用里是非常微小的一环，但在整个 ETL 作业中，它会在不同地方被频繁地反复调用。如果我基于这份代码把整个 ETL 应用推上线，就会发现 ETL 作业端到端的执行效率非常差，在分布式环境下完成作业需要两个小时，这样的速度难免有点让人沮丧。

想要让 ETL 作业跑得更快，我们自然需要做性能调优。可问题是我们该从哪儿入手呢？既然 extractFields 这个小函数会被频繁地调用，不如我们从它下手好了，看看有没有可能给它“减个肥、瘦个身”。重新审视函数 extractFields 的类型之后，我们不难发现，这个函数从头到尾无非是从 Seq[Row]到 Seq[(String, Int)]的转换，函数体的核心逻辑就是字段提取，只要从 Seq[Row]可以得到 Seq[(String, Int)]，目的就达到了。

要达成这两种数据类型之间的转换，除了利用上面这种开发者信手拈来的过程式编程，我们还可以用函数式的编程范式。函数式编程的原则之一就是尽可能地在函数体中避免副作用（Side effect），副作用指的是函数对于状态的修改和变更，比如上例中 extractFields 函数对于 fields 变量不停地执行追加操作就属于副作用。

基于这个想法，我们就有了第二种实现方式，如下所示。与第一种实现相比，它最大的区别在于去掉了 fields 变量。之后，为了达到同样的效果，我们在输入参数 Seq[Row]上直接调用 map 操作逐一地提取特定字段并构建元组，最后通过 toSeq 将映射转换为序列，干净利落，一气呵成。

//实现方案2 —— 正例
val extractFields: Seq[Row] => Seq[(String, Int)] = {
  (rows: Seq[Row]) =>
    rows.map(row => (row.getString(2), row.getInt(4))).toSeq
}

你可能会问：“两份代码实现无非是差了个中间变量而已，能有多大差别呢？看上去不过是代码更简洁了而已。”事实上，我基于第二份代码把 ETL 作业推上线后，就惊奇地发现端到端执行性能提升了一倍！从原来的两个小时缩短到一个小时。两份功能完全一样的代码，在分布式环境中的执行性能竟然有着成倍的差别。因此你看，在日常的开发工作中，仅仅专注于业务功能实现还是不够的，任何一个可以进行调优的小环节咱们都不能放过。

开发案例 2：数据过滤与数据聚合

你也许会说：“你这个例子只是个例吧？更何况，这个例子里的优化，仅仅是编程范式的调整，看上去和 Spark 似乎也没什么关系啊！”不要紧，我们再来看第二个例子。第二个例子会稍微复杂一些，我们先来把业务需求和数据关系交代清楚。

/**
(startDate, endDate)
e.g. ("2021-01-01", "2021-01-31")
*/
val pairDF: DataFrame = _

/**
(dim1, dim2, dim3, eventDate, value)
e.g. ("X", "Y", "Z", "2021-01-15", 12)
*/
val factDF: DataFrame = _

// Storage root path
val rootPath: String = _

在这个案例中，我们有两份数据，分别是 pairDF 和 factDF，数据类型都是 DataFrame。第一份数据 pairDF 的 Schema 包含两个字段，分别是开始日期和结束日期。第二份数据的字段较多，不过最主要的字段就两个，一个是 Event date 事件日期，另一个是业务关心的统计量，取名为 Value。其他维度如 dim1、dim2、dim3 主要用于数据分组，具体含义并不重要。从数据量来看，pairDF 的数据量很小，大概几百条记录，factDF 数据量很大，有上千万行。

对于这两份数据来说，具体的业务需求可以拆成 3 步：

对于 pairDF 中的每一组时间对，从 factDF 中过滤出 Event date 落在其间的数据条目；

从 dim1、dim2、dim3 和 Event date 4 个维度对 factDF 分组，再对业务统计量 Value 进行汇总；

将最终的统计结果落盘到 Amazon S3。

针对这样的业务需求，不少同学按照上面的步骤按部就班地进行了如下的实现。接下来，我就结合具体的代码来和你说说其中的计算逻辑。

//实现方案1 —— 反例
def createInstance(factDF: DataFrame, startDate: String, endDate: String): DataFrame = {
val instanceDF = factDF
.filter(col("eventDate") > lit(startDate) && col("eventDate") <= lit(endDate))
.groupBy("dim1", "dim2", "dim3", "event_date")
.agg(sum("value") as "sum_value")
instanceDF
}

pairDF.collect.foreach{
case (startDate: String, endDate: String) =>
val instance = createInstance(factDF, startDate, endDate)
val outPath = s"${rootPath}/endDate=${endDate}/startDate=${startDate}"
instance.write.parquet(outPath)
}

首先，他们是以 factDF、开始时间和结束时间为形参定义 createInstance 函数。在函数体中，先根据 Event date 对 factDF 进行过滤，然后从 4 个维度分组汇总统计量，最后将汇总结果返回。定义完 createInstance 函数之后，收集 pairDF 到 Driver 端并逐条遍历每一个时间对，然后以 factDF、开始时间、结束时间为实参调用 createInstance 函数，来获取满足过滤要求的汇总结果。最后，以 Parquet 的形式将结果落盘。

同样地，这段代码从功能的角度来说没有任何问题，而且从线上的结果来看，数据的处理逻辑也完全符合预期。不过，端到端的执行性能可以说是惨不忍睹，在 16 台机型为 C5.4xlarge AWS EC2 的分布式运行环境中，基于上面这份代码的 ETL 作业花费了半个小时才执行完毕。

没有对比就没有伤害，在同一份数据集之上，采用下面的第二种实现方式，仅用 2 台同样机型的 EC2 就能让 ETL 作业在 15 分钟以内完成端到端的计算任务。两份代码的业务功能和计算逻辑完全一致，执行性能却差了十万八千里。

//实现方案2 —— 正例
val instances = factDF
.join(pairDF, factDF("eventDate") > pairDF("startDate") && factDF("eventDate") <= pairDF("endDate"))
.groupBy("dim1", "dim2", "dim3", "eventDate", "startDate", "endDate")
.agg(sum("value") as "sum_value")

instances.write.partitionBy("endDate", "startDate").parquet(rootPath)

那么问题来了，这两份代码到底差在哪里，是什么导致它们的执行性能差别如此之大。我们不妨先来回顾第一种实现方式，嗅一嗅这里面有哪些不好的代码味道。

我们都知道，触发 Spark 延迟计算的 Actions 算子主要有两类：一类是将分布式计算结果直接落盘的操作，如 DataFrame 的 write、RDD 的 saveAsTextFile 等；另一类是将分布式结果收集到 Driver 端的操作，如 first、take、collect。

显然，对于第二类算子来说，Driver 有可能形成单点瓶颈，尤其是用 collect 算子去全量收集较大的结果集时，更容易出现性能问题。因此，在第一种实现方式中，我们很容易就能嗅到 collect 这里的调用，味道很差。

尽管 collect 这里味道不好，但在我们的场景里，pairDF 毕竟是一份很小的数据集，才几百条数据记录而已，全量搜集到 Driver 端也不是什么大问题。

最要命的是 collect 后面的 foreach。要知道，factDF 是一份庞大的分布式数据集，尽管 createInstance 的逻辑仅仅是对 factDF 进行过滤、汇总并落盘，但是 createInstance 函数在 foreach 中会被调用几百次，pairDF 中有多少个时间对，createInstance 就会被调用多少次。对于 Spark 中的 DAG 来说，在没有缓存的情况下，每一次 Action 的触发都会导致整条 DAG 从头到尾重新执行。

明白了这一点之后，我们再来仔细观察这份代码，你品、你细品，目不转睛地盯着 foreach 和 createInstance 中的 factDF，你会惊讶地发现：有着上千万行数据的 factDF 被反复扫描了几百次！而且，是全量扫描哟！吓不吓人？可不可怕？这么分析下来，ETL 作业端到端执行效率低下的始作俑者，是不是就暴露无遗了？

反观第二份代码，factDF 和 pairDF 用 pairDF.startDate < factDF.eventDate <= pairDF.endDate 的不等式条件进行数据关联。在 Spark 中，不等式 Join 的实现方式是 Nested Loop Join。尽管 Nested Loop Join 是所有 Join 实现方式（Merge Join，Hash Join，Broadcast Join 等）中性能最差的一种，而且这种 Join 方式没有任何优化空间，但 factDF 与 pairDF 的数据关联只需要扫描一次全量数据，仅这一项优势在执行效率上就可以吊打第一份代码实现。

小结

今天，我们分析了两个案例，这两个案例都来自数据应用的 ETL 场景。第一个案例讲的是，在函数被频繁调用的情况下，函数里面一个简单变量所引入的性能开销被成倍地放大。第二个例子讲的是，不恰当的实现方式导致海量数据被反复地扫描成百上千次。

通过对这两个案例进行分析和探讨，我们发现，对于 Spark 的应用开发，绝不仅仅是完成业务功能实现就高枕无忧了。Spark 天生的执行效率再高，也需要你针对具体的应用场景和运行环境进行性能调优。

而性能调优的收益显而易见：一来可以节约成本，尤其是按需付费的云上成本，更短的执行时间意味着更少的花销；二来可以提升开发的迭代效率，尤其是对于从事数据分析、数据科学、机器学习的同学来说，更高的执行效率可以更快地获取数据洞察，更快地找到模型收敛的最优解。因此你看，性能调优不是一件锦上添花的事情，而是开发者必须要掌握的一项傍身技能。

那么，对于 Spark 的性能调优，你准备好了吗？生活不止眼前的苟且，让我们来一场说走就走的性能调优之旅吧。来吧！快上车！扶稳坐好，系好安全带，咱们准备发车了！

每日一练

日常工作中，你还遇到过哪些功能实现一致、但性能大相径庭的案例吗？

我们今天讲的第二个案例中的正例代码，你觉得还有可能进一步优化吗？

期待在留言区看到你分享，也欢迎把你对开发案例的思考写下来，我们下节课见！

02 | 性能调优的本质：调优的手段五花八门，该从哪里入手？

你好，我是吴磊。

上节课，我们探讨了性能调优的必要性，结论是：尽管 Spark 自身运行高效，但作为开发者，我们仍然需要对应用进行性能调优。

那么问题来了，性能调优该怎么做呢？面对成百上千行应用代码、近百个 Spark 配置项，我们该从哪里入手呢？我认为，要想弄清性能调优怎么入手，必须先得搞明白性能调优的本质是什么。

所以今天这节课，咱们就从一个先入为主的调优反例入手，带你一起探讨并归纳性能调优的本质是什么，最终帮你建立起系统化的性能调优方法论。

先入为主的反例

在典型的 ETL 场景中，我们经常需要对数据进行各式各样的转换，有的时候，因为业务需求太复杂，我们往往还需要自定义 UDF（User Defined Functions）来实现特定的转换逻辑。但是，无论是 Databricks 的官方博客，还是网上浩如烟海的 Spark 技术文章，都警告我们尽量不要自定义 UDF 来实现业务逻辑，要尽可能地使用 Spark 内置的 SQL functions。

在日常的工作中，我发现这些警告被反复地用于 Code review 中，Code reviewer 在审查代码的时候，一旦遇到自定义的 UDF，就提示开发的同学用 SQL functions 去重写业务逻辑，这几乎成了一种条件反射。

甚至，开发的同学也觉得非常有道理。于是，他们花费大量时间用 SQL functions 重构业务代码。但遗憾的是，这么做之后 ETL 作业端到端的执行性能并没有什么显著的提升。这种情况就是所谓的投入时间与产出不成正比的窘境：调优的时间没少花，却没啥效果。

之所以会出现这种情况，我觉得主要原因在于 Code reviewer 对于性能调优的理解还停留在照本宣科的层次，没有形成系统化的方法论。要建立系统化的方法论，我们就必须去探究性能调优的本质到底是什么。否则，开发者就像是掉进迷宫的小仓鼠，斗志昂扬地横冲直撞，但就是找不到出口。

既然性能调优的本质这么重要，那么它到底是什么呢？

性能调优的本质

探究任何一个事物的本质，都离不开从个例观察、现象归因到总结归纳的过程。

咱们不妨先来分析分析上面的反例，为什么用 SQL functions 重构 UDF 并没有像书本上说的那么奏效？

是因为这条建议本身就不对吗？肯定不是。通过对比查询计划，我们能够明显看到 UDF 与 SQL functions 的区别。Spark SQL 的 Catalyst Optimizer 能够明确感知 SQL functions 每一步在做什么，因此有足够的优化空间。相反，UDF 里面封装的计算逻辑对于 Catalyst Optimizer 来说就是个黑盒子，除了把 UDF 塞到闭包里面去，也没什么其他工作可做的。

那么，是因为 UDF 相比 SQL functions 其实并没有性能开销吗？也不是。我们可以做个性能单元测试，从你的 ETL 应用中随意挑出一个自定义的 UDF，尝试用 SQL functions 去重写，然后准备单元测试数据，最后在单机环境下对比两种不同实现的运行时间。通常情况下，UDF 实现相比 SQL functions 会慢 3% 到 5% 不等。所以你看，UDF 的性能开销还是有的。

既然如此，我们在使用 SQL functions 优化 UDF 的时候，为什么没有显著提升端到端 ETL 应用的整体性能呢？

根据木桶理论，最短的木板决定了木桶的容量，因此，对于一只有短板的木桶，其他木板调节得再高也无济于事，最短的木板才是木桶容量的瓶颈。对于 ETL 应用这支木桶来说，UDF 到 SQL functions 的调优之所以对执行性能的影响微乎其微，根本原因在于它不是那块最短的木板。换句话说，ETL 应用端到端执行性能的瓶颈不是开发者自定义的 UDF。

结合上面的分析，性能调优的本质我们可以归纳为 4 点。

性能调优不是一锤子买卖，补齐一个短板，其他板子可能会成为新的短板。因此，它是一个动态、持续不断的过程。

性能调优的手段和方法是否高效，取决于它针对的是木桶的长板还是瓶颈。针对瓶颈，事半功倍；针对长板，事倍功半。

性能调优的方法和技巧，没有一定之规，也不是一成不变，随着木桶短板的此消彼长需要相应的动态切换。

性能调优的过程收敛于一种所有木板齐平、没有瓶颈的状态。

基于对性能调优本质的理解，我们就能很好地解释日常工作中一些常见的现象。比如，我们经常发现，明明是同样一种调优方法，在你那儿好使，在我这儿却不好使。再比如，从网上看到某位 Spark 大神呕心沥血总结的调优心得，你拿过来一条一条地试，却发现效果并没有博客中说的那么显著。这也并不意味着那位大神的最佳实践都是空谈，更可能是他总结的那些点并没有触达到你的瓶颈。

定位性能瓶颈的途径有哪些？

你可能会问：“既然调优的关键在于瓶颈，我该如何定位性能瓶颈呢？”我觉得，至少有两种途径：一是先验的专家经验，二是后验的运行时诊断。下面，我们一一来看。

所谓专家经验是指在代码开发阶段、或是在代码 Review 阶段，凭借过往的实战经验就能够大致判断哪里可能是性能瓶颈。显然，这样的专家并不好找，一名开发者要经过大量的积累才能成为专家，如果你身边有这样的人，一定不要放过他！

但你也可能会说：“我身边要是有这样的专家，就不用来订阅这个专栏了。”没关系，咱们还有第二种途径：运行时诊断。

运行时诊断的手段和方法应该说应有尽有、不一而足。比如：对于任务的执行情况，Spark UI 提供了丰富的可视化面板，来展示 DAG、Stages 划分、执行计划、Executor 负载均衡情况、GC 时间、内存缓存消耗等等详尽的运行时状态数据；对于硬件资源消耗，开发者可以利用 Ganglia 或者系统级监控工具，如 top、vmstat、iostat、iftop 等等来实时监测硬件的资源利用率；特别地，针对 GC 开销，开发者可以将 GC log 导入到 JVM 可视化工具，从而一览任务执行过程中 GC 的频率和幅度。

对于这两种定位性能瓶颈的途径来说，专家就好比是老中医，经验丰富、火眼金睛，往往通过望、闻、问、切就能一眼定位到瓶颈所在；而运行时诊断更像是西医中的各种检测仪器和设备，如听诊器、X 光、CT 扫描仪，需要通过量化的指标才能迅速定位问题。二者并无优劣之分，反而是结合起来的效率更高。就像一名医术高超、经验丰富的大夫，手里拿着你的血液化验和 B 超结果，对于病灶的判定自然更有把握。

你可能会说：“尽管有这两种途径，但是就瓶颈定位来说，我还是不知道具体从哪里切入呀！”结合过往的调优经验，我认为，从硬件资源消耗的角度切入，往往是个不错的选择。我们都知道，从硬件的角度出发，计算负载划分为计算密集型、内存密集型和 IO 密集型。如果我们能够明确手中的应用属于哪种类型，自然能够缩小搜索范围，从而更容易锁定性能瓶颈。

不过，在实际开发中，并不是所有负载都能明确划分出资源密集类型。比如说，Shuffle、数据关联这些数据分析领域中的典型场景，它们对 CPU、内存、磁盘与网络的要求都很高，任何一个环节不给力都有可能形成瓶颈。因此，就性能瓶颈定位来说，除了从硬件资源的视角出发，我们还需要关注典型场景。

性能调优的方法与手段

假设，通过运行时诊断我们成功地定位到了性能瓶颈所在，那么，具体该如何调优呢？Spark 性能调优的方法和手段丰富而又庞杂，如果一条一条地去罗列，不仅看上去让人昏昏欲睡，更不利于形成系统化的调优方法论。就像医生在治疗某个病症的时候，往往会结合内服、外用甚至是外科手术，这样多管齐下的方式来达到药到病除的目的。

因此，在我看来， Spark 的性能调优可以从应用代码和 Spark 配置项这 2 个层面展开。

应用代码层面指的是从代码开发的角度，我们该如何取舍，如何以性能为导向进行应用开发。在上一讲中我们已经做过对比，哪怕是两份功能完全一致的代码，在性能上也会有很大的差异。因此我们需要知道，开发阶段都有哪些常规操作、常见误区，从而尽量避免在代码中留下性能隐患。

Spark 配置项想必你并不陌生，Spark 官网上罗列了近百个配置项，看得人眼花缭乱，但并不是所有的配置项都和性能调优息息相关，因此我们需要对它们进行甄别、归类。

总的来说，在日常的调优工作中，我们往往从应用代码和 Spark 配置项这 2 个层面出发。所谓：“问题从代码中来，解决问题要到代码中去”，在应用代码层面进行调优，其实就是一个捕捉和移除性能 BUG 的过程。Spark 配置项则给予了开发者极大的灵活度，允许开发者通过配置项来调整不同硬件的资源利用率，从而适配应用的运行时负载。

对于应用代码和 Spark 配置项层面的调优方法与技巧，以及这些技巧在典型场景和硬件资源利用率调控中的综合运用，我会在《性能篇》逐一展开讲解，从不同层面、不同场景、不同视角出发，归纳出一套调优的方法与心得，力图让你能够按图索骥、有章可循地去开展性能调优。

性能调优的终结

性能调优的本质告诉我们：性能调优是一个动态、持续不断的过程，在这个过程中，调优的手段需要随着瓶颈的此消彼长而相应地切换。那么问题来了，性能调优到底什么时候是个头儿呢？就算是持续不断地，也总得有个收敛条件吧？总不能一直这么无限循环下去。

在我看来，性能调优的最终目的，是在所有参与计算的硬件资源之间寻求协同与平衡，让硬件资源达到一种平衡、无瓶颈的状态。

我们以大数据服务公司 Qubole 的案例为例，他们最近在 Spark 上集成机器学习框架 XGBoost 来进行模型训练，在相同的硬件资源、相同的数据源、相同的计算任务中对比不同配置下的执行性能。

从下表中我们就可以清楚地看到，执行性能最好的训练任务并不是那些把 CPU 利用率压榨到 100%，以及把内存设置到最大的配置组合，而是那些硬件资源配置最均衡的计算任务。

小结

只有理解 Spark 性能调优的本质，形成系统化的方法论，我们才能避免投入时间与产出不成正比的窘境。

性能调优的本质，我总结为 4 点：

性能调优不是一锤子买卖，补齐一个短板，其他板子可能会成为新的短板。因此，它是一个动态、持续不断的过程；

性能调优的手段和方法是否高效，取决于它针对的是木桶的长板还是瓶颈。针对瓶颈，事半功倍；针对长板，事倍功半；

性能调优的方法和技巧，没有一定之规，也不是一成不变，随着木桶短板的此消彼长需要相应地动态切换；

性能调优的过程收敛于一种所有木板齐平、没有瓶颈的状态。

系统化的性能调优方法论，我归纳为 4 条：

通过不同的途径如专家经验或运行时诊断来定位性能瓶颈；

从不同场景（典型场景）、不同视角（硬件资源）出发，综合运用不同层面（应用代码、Spark 配置项）的调优手段和方法；

随着性能瓶颈的此消彼长，动态灵活地在不同层面之间切换调优方法；

让性能调优的过程收敛于不同硬件资源在运行时达到一种平衡、无瓶颈的状态。

每日一练

你还遇到过哪些“照本宣科”的调优手段？

你认为，对于性能调优的收敛状态，即硬件资源彼此之间平衡、无瓶颈的状态，需要量化吗？如何量化呢？

关于性能调优，你还有哪些看法？欢迎在评论区留言，我们下节课见！

开篇词 | Spark性能调优，你该掌握这些“套路”

你好，我是吴磊，欢迎和我一起探索 Spark 应用的性能优化。

2020 年 6 月，Spark 正式发布了新版本，从 2.4 直接跨越到了 3.0。这次大版本升级的亮点就在于性能优化，它添加了诸如自适应查询执行（AQE）、动态分区剪裁（DPP）、扩展的 Join Hints 等新特性。

其实，在 3.0 版本发布之前，Spark 就已然成为了分布式数据处理技术的事实标准。在数据科学与机器学习魔力象限中，Gartner 更是连续 3 年（2018~2020）将 Databricks（Spark 云原生商业版本）提名为 Market Leader。

自然而然地，Spark 也成为了各大头部互联网公司的标配，在海量数据处理上，扮演着不可或缺的关键角色。比如，字节跳动基于 Spark 构建的数据仓库去服务几乎所有的产品线，包括抖音、今日头条、西瓜视频、火山视频等。再比如，百度基于 Spark 推出 BigSQL，为海量用户提供次秒级的即席查询。

可以预见的是，这次版本升级带来的新特性，会让 Spark 在未来 5 到 10 年继续雄霸大数据生态圈。

而说到我和 Spark 的缘分，可以追溯到 2014 年。一次偶然的机会，我参与到了 Spark 的调研。深入研究之后，我对 Spark 高效的执行性能深深着迷，这也让我未来的职业发展都和 Spark 紧紧地绑在了一起。就这样，我从数据分析、数据挖掘领域，转移到了现在的商业智能和机器学习领域，一直不停地尝试探索数据中蕴含的核心价值。

目前，我在 FreeWheel 带领团队负责机器学习的应用与落地。我们所有已落地和正在启动的项目，都在使用 Spark 进行数据探索、数据处理、数据分析、特征工程和样本工程。除此之外，我们还会经常基于 Spark 在海量数据上进行模型训练和模型推理。

如果你也和我一样，正沿着数据规划自己的职业路径，那么精通 Spark 也必定是你的职业目标之一。

精通 Spark，你需要一把叫做“性能调优”的万能钥匙

目前，Spark 有海量批处理、实时流计算、图计算、数据分析和机器学习这 5 大应用场景，不论你打算朝哪个方向深入，性能调优都是你职业进阶必须要跨越的一步。

为什么这么说呢？原因很简单，对于这 5 大场景来说，提升执行性能是刚需。

图计算和机器学习往往需要上百次迭代才能收敛，如果没有性能保障，这类作业不可能完成计算。流计算和数据分析对于响应实时性的要求非常高，没有高效的执行性能，不可能做到在亚秒级完成处理。

相比其他场景，批处理对于执行效率的要求是最低的，但是，在日增数据量以 TB、甚至 PB 为单位计数的当下，想要在小时级别完成海量数据处理，不做性能调优简直是天方夜谭。

因此，我认为这 5 大场景就像是 5 扇门，每扇门背后都别有洞天，而性能调优就像是一把“万能钥匙”。有了这把钥匙在手，你才能如入无人之境，去探索更广阔的世界。

为什么性能调优不能“照葫芦画瓢”？

其实，我身边很多开发人员也都意识到了这一点，他们会去网上搜集一些教程进行学习。但是，目前关于 Spark 性能调优的资料，大都不是很系统，或者只是在讲一些常规的调优技巧和方法。而对于一些大神分享的调优手段，我们往往“照葫芦画瓢”做出来的东西，也总是达不到预期的效果，比如：

明明都是内存计算，为什么我用了 RDD/DataFrame Cache，性能反而更差了？

网上吹得神乎其神的调优手段，为啥到了我这就不好使呢？

并行度设置得也不低，为啥我的 CPU 利用率还是上不去？

节点内存几乎全都划给 Spark 用了，为啥我的应用还是 OOM？

这些问题看似简单，但真不是一两句话就能说得清的。这需要我们深入 Spark 的核心原理，不断去尝试每一个 API、算子，设置不同的配置参数，最终找出最佳的排列组合。

那么问题来了，这该怎么做呢？接下来，我就和你分享一下，我是怎么学习性能调优的。

刚刚接触 Spark 那会儿，我觉得它的开发效率是真高啊！MapReduce 上千行代码才能实现的业务功能，Spark 几十行代码就搞定了！

后来，随着客户要求的不断提高，以及我个人的“较真行为”，为了让应用能跑得更快，我几乎把所有的 RDD API 查了个遍，仔细研究每一个算子的含义和运行原理，汇总不同算子的适用场景，总结哪些算子会引入 Shuffle，对比同类功能算子的差异与优劣势，比如 map 和 mapPartitions，再比如 groupByKey、reduceByKey 和 aggregateByKey。

除此之外，Spark 官网的 Configuration 页面我也查阅了无数次，汇总与性能有关的配置项，不停地做对比实验，比较不同参数配置下的执行性能。遇到与认知不符的实验结果，就再回去反复咀嚼 Spark 的核心原理，从 RDD 和调度系统，到内存管理和存储系统，再到内存计算和 Shuffle，如此往复，乐此不疲。

虽然失败的次数非常多，但成倍的性能提升带来的惊喜让我久久不能忘怀。后来，我就把我的经验分享给身边的同事，在帮助他们进行调优的过程中，我也有意识地把我接触到的案例整理了起来，从点到线、从线到面，我逐渐摸清了性能调优的脉络，最终总结出一套关于性能调优的方法论，也因此建立起了以性能为导向的开发习惯。

遵循这套方法论，开发者可以按图索骥地去开展性能调优工作，做到有的放矢、事半功倍。我希望在这个专栏里把它分享给你。

学得快，也要学得好

结合方法论，我把专栏划分为了 3 个部分：原理篇、性能篇和实战篇。

原理篇：聚焦 Spark 底层原理，打通性能调优的任督二脉

Spark 的原理非常多，但我会聚焦于那些与性能调优息息相关的核心概念，包括 RDD、DAG、调度系统、存储系统和内存管理。我会力求用最贴切的故事和类比、最少的篇幅，让你在最短的时间内掌握这 5 大概念的核心原理，为后续的性能调优打下坚实的基础。

性能篇：实际案例驱动，多角度解读，全方位解析性能调优技巧

我们刚才说了，Spark 的应用场景非常多，主要分为海量批处理、实时流计算、图计算、数据分析和机器学习这 5 个。但在所有的子框架里，Spark 对 Spark SQL 的倾斜和倚重也是有目共睹，所以性能篇我主要分两部分来讲。

一部分是讲解性能调优的通用技巧，包括应用开发的基本原则、配置项的设置、Shuffle 的优化，以及资源利用率的提升。首先，我会从常见的例子入手，教你怎么在不改变代码逻辑的情况下快速提升执行能。其次，我会带你去归纳与执行效率相关的配置项。接着，针对 Shuffle、数据关联这些典型场景，我们一起去分析有效的应对策略。最后，我们再从硬件视角出发，带你探讨如何最大化资源利用率，在整体上提升 Spark 的执行性能。

虽然，不同应用场景的开发 API 和运行原理都有所不同，但是性能调优的本质和方法论是一样的。因此，这类技巧不限定应用场景，适用于所有 Spark 子框架。

另一部分我会专注于数据分析领域，借助如 Tungsten、AQE 这样的 Spark 内置优化项和数据关联这样的典型场景，来和你聊聊 Spark SQL 中的调优方法和技巧。

首先，我会带你深入挖掘 Tungsten、Catalyst 优化器和 Spark 3.0 发布的诸多新特性，充分利用 Spark 已有的优化机制，让性能调优站到一个更高的起点上。接着，我会借助数据分析的典型场景案例，如数据清洗、数据关联、数据转换等等，带你 case-by-case 地去归纳调优的思路与方法。

值得一提的是，随着所有子框架的开发 API 陆续迁移到 DataFrame，我们在每个子框架之上开发的应用，都将受益于 Spark SQL 的性能提升。换句话说，尽管这部分调优技巧围绕数据分析领域展开，但其中的思路和方法，也同样适用于其他子框架。

实战篇：打造属于自己的分布式应用

在实战篇，为了帮助你实践我们的方法论和调优技巧。我会以 2011~2019 的《北京市汽油车摇号》数据为例，手把手教你打造一个分布式应用，带你从不同角度洞察汽油车摇号的趋势和走向。我相信，通过这个实战案例，你对性能调优技巧和思路的把控肯定会有一个“质的飞跃”。

除此之外，我还会不定期地针对一些热点话题进行加餐：比如和 Flink、Presto 相比，Spark 有哪些优势；再比如 Spark 的一些新特性，以及业界对于 Spark 的新探索。这也能帮助我们更好地面对变化，把握先机。

最后我想说，我一直希望把学习变成一件有趣又轻松的事情，所以在这个专栏里，我会用一个个小故事和实例，来帮助你理解 Spark 的核心原理，引导你建立以性能为导向的开发思维，以及从不同视角汇总性能调优的方法和技巧，让你像读小说一样去弄懂 Spark。

我也期望，你能像小说里的主人公一样，利用“调优技巧和方法论”这本武功秘籍，一路过五关、斩六将，打败现实中层出不穷的开发问题，在职业发展中更上一层楼。

最后，欢迎你在这里畅所欲言，提出你的困惑和疑问，也欢迎多多给我留言，你们的鼓励是我的动力。让我们一起拿起性能调优这把万能钥匙，去开启全新的 Spark 职业生涯吧！

03 | RDD：为什么你必须要理解弹性分布式数据集？

你好，我是吴磊。

从今天开始，我们进入原理篇的学习。我会以性能调优为导向，给你详细讲讲 Spark 中的核心概念 RDD 和 DAG，以及重要组件调度系统、存储系统和内存管理。这节课，咱们先来说说 RDD。

RDD 可以说是 Spark 中最基础的概念了，使用 Spark 的开发者想必对 RDD 都不陌生，甚至提起 RDD，你的耳朵可能都已经听出茧子了。不过，随着 Spark 开发 API 的演进和发展，现在上手开发基本都是 DataFrame 或 Dataset API。所以很多初学者会认为，“反正 RDD API 基本都没人用了，我也没必要弄明白 RDD 到底是什么。”

真的是这样的吗？当然不是。

RDD 为何如此重要

首先，RDD 作为 Spark 对于分布式数据模型的抽象，是构建 Spark 分布式内存计算引擎的基石。很多 Spark 核心概念与核心组件，如 DAG 和调度系统都衍生自 RDD。因此，深入理解 RDD 有利于你更全面、系统地学习 Spark 的工作原理。

其次，尽管 RDD API 使用频率越来越低，绝大多数人也都已经习惯于 DataFrame 和 Dataset API，但是，无论采用哪种 API 或是哪种开发语言，你的应用在 Spark 内部最终都会转化为 RDD 之上的分布式计算。换句话说，如果你想要在运行时判断应用的性能瓶颈，前提是你要对 RDD 足够了解。还记得吗？定位性能瓶颈是 Spark 性能调优的第一步。

不仅如此，对于 RDD 不求甚解还有可能带来潜在的性能隐患，接下来，我们就从一个反例入手，一起来分析一下。

还记得，我们在第 1 讲中讲过的数据过滤与聚合的反例吗？通过这个例子我们明白了性能调优的必要性。那这个例子和 RDD 有什么关系呢？

别着急，我们先来回顾一下这个案例中的代码实现，去挖掘开发者采用这种实现方式的深层原因。

  //实现方案1 —— 反例
def createInstance(factDF: DataFrame, startDate: String, endDate: String): DataFrame = {
val instanceDF = factDF
    .filter(col("eventDate") > lit(startDate) && col("eventDate") <= lit(endDate))
    .groupBy("dim1", "dim2", "dim3", "event_date")
    .agg("sum(value) as sum_value")
instanceDF
}

pairDF.collect.foreach{
case (startDate: String, endDate: String) =>
    val instance = createInstance(factDF, startDate, endDate)
    val outPath = s"${rootPath}/endDate=${endDate}/startDate=${startDate}"
    instance.write.parquet(outPath)
}

在这段代码中，createInstance 的主要逻辑是按照时间条件对 factDF 进行过滤，返回汇总的业务统计量，然后 pairDF 循环遍历每一对开始时间和结束时间，循环调用 createInstance 获取汇总结果并落盘。我们在第 1 课中分析过，这份代码的主要问题在于囊括上千万行数据的 factDF 被反复扫描了几百次，而且是全量扫描，从而拖垮了端到端的执行性能。

那么，我们不禁要问：开发者究竟为什么会想到用这种低效的方式去实现业务逻辑呢？或者说，是什么内驱因素让开发者自然而然地采用这种实现方式呢？

让我们跳出 Spark、跳出这个专栏，把自己置身于一间教室内：黑板前，老师正在讲解《XX 语言编程》，旁边是你的同学，他边听老师讲课，边翻看着桌上的课本。这个场景熟不熟悉？亲不亲切？回想一下，老师讲的、书本上教的和我们示例中的代码，是不是极其类似？

没错！我们的大脑，已经习惯了 for 循环，习惯了用函数处理变量、封装计算逻辑，习惯了面向过程的编程模式。在分布式计算出现以前，我们都是这么开发的，老师也是这么讲的，书本上也是这么教的，没毛病。

因此我认为，开发者之所以会选择上面的实现方式，根本原因在于他把 factDF 当成了一个普通变量，一个与 createInstance 函数中 startDate、endDate 同等地位的形参，他并没有意识到，factDF 实际上是一个庞大的、横跨所有计算节点的分布式数据集合，更没有意识到，在分布式运行环境中，外面的 for 循环会导致这个庞大的数据集被反复地全量扫描。

这种对于分布式计算认知方面的缺失，究其缘由，还是我们对 Spark 核心概念 RDD 的理解不够透彻。所以你看，深入理解 RDD 还是很有必要的，对于 RDD 一知半解，极有可能在应用开发的过程中，不知不觉地留下潜在的性能隐患。

深入理解 RDD

既然 RDD 如此重要，它究竟是什么呢？2010 年，在一个夜黑风高的夜晚，Matei 等人发表了一篇名为《Spark: Cluster Computing with Working Sets》的论文并首次提出了 RDD 的概念。RDD，全称 Resilient Distributed Datasets，翻译过来就是弹性分布式数据集。本质上，它是对于数据模型的抽象，用于囊括所有内存中和磁盘中的分布式数据实体。

如果就这么从理论出发、照本宣科地讲下去，未免过于枯燥、乏味、没意思！不如，我先来给你讲个故事。

从薯片的加工流程看 RDD

在很久很久以前，有个生产桶装薯片的工坊，工坊的规模较小，工艺也比较原始。为了充分利用每一颗土豆、降低生产成本，工坊使用 3 条流水线来同时生产 3 种不同尺寸的桶装薯片。3 条流水线可以同时加工 3 颗土豆，每条流水线的作业流程都是一样的，分别是清洗、切片、烘焙、分发和装桶。其中，分发环节用于区分小、中、大号 3 种薯片，3 种不同尺寸的薯片分别被发往第 1、2、3 条流水线。具体流程如下图所示。

RDD的生活化类比

看得出来，这家工坊制作工艺虽然简单，倒也蛮有章法。从头至尾，除了分发环节，3 条流水线没有任何交集。在分发环节之前，每条流水线都是专心致志、各顾各地开展工作：把土豆食材加载到流水线上，再进行清洗、切片、烘焙；在分发环节之后，3 条流水线也是各自装桶，互不干涉、互不影响。流水线的作业方式提供了较强的容错能力，如果某个加工环节出错，工人们只需要往出错的流水线上重新加载一颗新的土豆，整个流水线就能够恢复生产。

好了，故事讲完了。如果我们把每一条流水线看作是分布式运行环境的计算节点，用薯片生产的流程去类比 Spark 分布式计算，会有哪些有趣的发现呢？

仔细观察，我们发现：刚从地里挖出来的土豆食材、清洗过后的干净土豆、生薯片、烤熟的薯片，流水线上这些食材的不同形态，就像是 Spark 中 RDD 对于不同数据集合的抽象。

沿着流水线的纵深方向，也就是图中从左向右的方向，每一种食材形态都是在前一种食材之上用相应的加工方法进行处理得到的。每种食材形态都依赖于前一种食材，这就像是 RDD 中 dependencies 属性记录的依赖关系，而不同环节的加工方法，对应的刚好就是 RDD 的 compute 属性。

横看成岭侧成峰，再让我们从横向的角度来重新审视上面的土豆加工流程，也就是图中从上至下的方向，让我们把目光集中在流水线开端那 3 颗带泥的土豆上。这 3 颗土豆才从地里挖出来，是原始的食材形态，正等待清洗。如图所示，我们把这种食材形态标记为 potatosRDD，那么，这里的每一颗土豆就是 RDD 中的数据分片，3 颗土豆一起对应的就是 RDD 的 partitions 属性。

带泥土豆经过清洗、切片和烘焙之后，按照大小个儿被分发到下游的 3 条流水线上，这 3 条流水线上承载的 RDD 记为 shuffledBakedChipsRDD。很明显，这个 RDD 对于 partitions 的划分是有讲究的，根据尺寸的不同，即食薯片会被划分到不同的数据分片中。像这种数据分片划分规则，对应的就是 RDD 中的 partitioner 属性。 在分布式运行环境中，partitioner 属性定义了 RDD 所封装的分布式数据集如何划分成数据分片。

总的来说，我们发现，薯片生产的流程和 Spark 分布式计算是一一对应的，一共可以总结为 6 点：

土豆工坊的每条流水线就像是分布式环境中的计算节点；

不同的食材形态，如带泥的土豆、土豆切片、烘烤的土豆片等等，对应的就是 RDD；

每一种食材形态都会依赖上一种形态，如烤熟的土豆片依赖上一个步骤的生土豆切片。这种依赖关系对应的就是 RDD 中的 dependencies 属性；

不同环节的加工方法对应 RDD 的 compute 属性；

同一种食材形态在不同流水线上的具体实物，就是 RDD 的 partitions 属性；

食材按照什么规则被分配到哪条流水线，对应的就是 RDD 的 partitioner 属性。

不知道土豆工坊的类比，有没有帮你逐渐勾勒出 RDD 的本来面貌呢？话付前言，接下来，咱们来一本正经地聊聊 RDD。

RDD 的核心特征和属性

通过刚才的例子，我们知道 RDD 具有 4 大属性，分别是 partitions、partitioner、dependencies 和 compute 属性。正因为有了这 4 大属性的存在，让 RDD 具有分布式和容错性这两大最突出的特性。要想深入理解 RDD，我们不妨从它的核心特性和属性入手。

首先，我们来看 partitions、partitioner 属性。

在分布式运行环境中，RDD 封装的数据在物理上散落在不同计算节点的内存或是磁盘中，这些散落的数据被称“数据分片”，RDD 的分区规则决定了哪些数据分片应该散落到哪些节点中去。RDD 的 partitions 属性对应着 RDD 分布式数据实体中所有的数据分片，而 partitioner 属性则定义了划分数据分片的分区规则，如按哈希取模或是按区间划分等。

不难发现，partitions 和 partitioner 属性刻画的是 RDD 在跨节点方向上的横向扩展，所以我们把它们叫做 RDD 的“横向属性”。

然后，我们再来说说 dependencies 和 compute 属性。

在 Spark 中，任何一个 RDD 都不是凭空产生的，每个 RDD 都是基于某种计算逻辑从某个“数据源”转换而来。RDD 的 dependencies 属性记录了生成 RDD 所需的“数据源”，术语叫做父依赖（或父 RDD），compute 方法则封装了从父 RDD 到当前 RDD 转换的计算逻辑。

基于数据源和转换逻辑，无论 RDD 有什么差池（如节点宕机造成部分数据分片丢失），在 dependencies 属性记录的父 RDD 之上，都可以通过执行 compute 封装的计算逻辑再次得到当前的 RDD，如下图所示。

基于dependencies和compute属性得到当前RDD

由 dependencies 和 compute 属性提供的容错能力，为 Spark 分布式内存计算的稳定性打下了坚实的基础，这也正是 RDD 命名中 Resilient 的由来。接着观察上图，我们不难发现，不同的 RDD 通过 dependencies 和 compute 属性链接在一起，逐渐向纵深延展，构建了一张越来越深的有向无环图，也就是我们常说的 DAG。

由此可见，dependencies 属性和 compute 属性负责 RDD 在纵深方向上的延展，因此我们不妨把这两个属性称为“纵向属性”。

总的来说，RDD 的 4 大属性又可以划分为两类，横向属性和纵向属性。其中，横向属性锚定数据分片实体，并规定了数据分片在分布式集群中如何分布；纵向属性用于在纵深方向构建 DAG，通过提供重构 RDD 的容错能力保障内存计算的稳定性。

同时，为了帮助你记忆，我把这 4 大核心属性的基本概念和分类总结在了如下的表格中，你可以看一看。

除此之外，我还想再多说两句。在这节课开头的反例中，我们分析了开发者采用 foreach 语句循环遍历分布式数据集的深层次原因。这种不假思索地直入面向过程编程、忽略或无视分布式数据实体的编程模式，我将其称为单机思维模式。

在学习了 RDD 横向的 partitions 属性和纵向的 dependencies 属性之后，如果你能把它们牢记于心，那么在频繁调用或引用这个 RDD 之前，你自然会想到它所囊括的数据集合，很有可能在全节点范围内被反复扫描、反复计算。这种下意识的反思会驱使你尝试探索其他更优的实现方式，从而跳出单机思维模式。因此，深入理解 RDD，也有利于你跳出单机思维模式，避免在应用代码中留下性能隐患。

小结

今天，我带你学习了 RDD 的重要性，以及它的 2 大核心特性和 4 大属性。

首先，深入理解 RDD 对开发者来说有百利而无一害，原因有如下 3 点：

Spark 很多核心概念都衍生自 RDD，弄懂 RDD，有利于你全面地学习 Spark；

牢记 RDD 的关键特性和核心属性，有利于你在运行时更好地定位性能瓶颈，而瓶颈定位，恰恰是性能调优的前提；

深入理解 RDD 有利于你跳出单机思维模式，避免在应用代码中留下性能隐患。

关于 RDD 的特性与核心属性，只要你把如下 2 点牢记于心，我相信在不知不觉中你自然会绕过很多性能上的坑：

横向属性 partitions 和 partitioner 锚定数据分片实体，并且规定了数据分片在分布式集群中如何分布；

纵向属性 dependencies 和 compute 用于在纵深方向构建 DAG，通过提供重构 RDD 的容错能力保障内存计算的稳定性。

每日一练

在日常的开发工作中，你遇到过“单机思维模式”吗？有哪些呢？

除了我们今天讲的 4 大属性，RDD 还有个很重要的属性：preferredLocations。按照经验，你认为在哪些情况下，preferredLocations 很重要，会提升 I/O 效率，又在哪些环境中不起作用呢？为什么？

期待在留言区看到你的思考，也欢迎你分享工作中遇到过的“单机思维模式”，我们下节课见！

04 | DAG与流水线：到底啥叫“内存计算”？

你好，我是吴磊。

在日常的开发工作中，我发现有两种现象很普遍。

第一种是缓存的滥用。无论是 RDD，还是 DataFrame，凡是能产生数据集的地方，开发同学一律用 cache 进行缓存，结果就是应用的执行性能奇差无比。开发同学也很委屈：“Spark 不是内存计算的吗？为什么把数据缓存到内存里去，性能反而更差了？”

第二种现象是关于 Shuffle 的。我们都知道，Shuffle 是 Spark 中的性能杀手，在开发应用时要尽可能地避免 Shuffle 操作。不过据我观察，很多初学者都没有足够的动力去重构代码来避免 Shuffle，这些同学的想法往往是：“能把业务功能实现就不错了，费了半天劲去重写代码就算真的消除了 Shuffle，能有多大的性能收益啊。”

以上这两种现象可能大多数人并不在意，但往往这些细节才决定了应用执行性能的优劣。在我看来，造成这两种现象的根本原因就在于，开发者对 Spark 内存计算的理解还不够透彻。所以今天，我们就来说说 Spark 的内存计算都有哪些含义？

第一层含义：分布式数据缓存

一提起 Spark 的“内存计算”的含义，你的第一反应很可能是：Spark 允许开发者将分布式数据集缓存到计算节点的内存中，从而对其进行高效的数据访问。没错，这就是内存计算的第一层含义：众所周知的分布式数据缓存。

RDD cache 确实是 Spark 分布式计算引擎的一大亮点，也是对业务应用进行性能调优的诸多利器之一，很多技术博客甚至是 Spark 官网，都在不厌其烦地强调 RDD cache 对于应用执行性能的重要性。

正因为考虑到这些因素，很多开发者才会在代码中不假思索地滥用 cache 机制，也就是我们刚刚提到的第一个现象。但是，这些同学都忽略了一个重要的细节：只有需要频繁访问的数据集才有必要 cache，对于一次性访问的数据集，cache 不但不能提升执行效率，反而会产生额外的性能开销，让结果适得其反。

之所以会忽略这么重要的细节，背后深层次的原因在于，开发者对内存计算的理解仅仅停留在缓存这个层面。因此，当业务应用的执行性能出现问题时，只好死马当活马医，拼命地抓住 cache 这根救命稻草，结果反而越陷越深。

接下来，我们就重点说说内存计算的第二层含义：Stage 内部的流水线式计算模式。

在 Spark 中，内存计算有两层含义：第一层含义就是众所周知的分布式数据缓存，第二层含义是 Stage 内的流水线式计算模式。关于 RDD 缓存的工作原理，我会在后续的课程中为你详细介绍，今天咱们重点关注内存计算的第二层含义就可以了。

第二层含义：Stage 内的流水线式计算模式

很显然，要弄清楚内存计算的第二层含义，咱们得从 DAG 的 Stages 划分说起。在这之前，我们先来说说什么是 DAG。

什么是 DAG？

DAG 全称 Direct Acyclic Graph，中文叫有向无环图。顾名思义，DAG 是一种“图”。我们知道，任何一种图都包含两种基本元素：顶点（Vertex）和边（Edge），顶点通常用于表示实体，而边则代表实体间的关系。在 Spark 的 DAG 中，顶点是一个个 RDD，边则是 RDD 之间通过 dependencies 属性构成的父子关系。

从理论切入去讲解 DAG，未免枯燥乏味，所以我打算借助上一讲土豆工坊的例子，来帮助你直观地认识 DAG。上一讲，土豆工坊成功地实现了同时生产 3 种不同尺寸的桶装“原味”薯片。但是，在将“原味”薯片推向市场一段时间以后，工坊老板发现季度销量直线下滑，不由得火往上撞、心急如焚。此时，工坊的工头儿向他建议：“老板，咱们何不把流水线稍加改造，推出不同风味的薯片，去迎合市场大众的多样化选择？”然后，工头儿把改装后的效果图交给老板，老板看后甚是满意。

土豆工坊流水线效果图

不过，改造流水线可是个大工程，为了让改装工人能够高效协作，工头儿得把上面的改造设想抽象成一张施工流程图。有了这张蓝图，工头儿才能给负责改装的工人们分工，大伙儿才能拧成一股绳、劲儿往一处使。在上一讲中，我们把食材形态类比成 RDD，把相邻食材形态的关系看作是 RDD 间的依赖，那么显然，流水线的施工流程图就是 DAG。

DAG：土豆工坊流水线的设计流程图

因为 DAG 中的每一个顶点都由 RDD 构成，对应到上图中就是带泥的土豆 potatosRDD，清洗过的土豆 cleanedPotatosRDD，以及调料粉 flavoursRDD 等等。DAG 的边则标记了不同 RDD 之间的依赖与转换关系。很明显，上图中 DAG 的每一条边都有指向性，而且整张图不存在环结构。

那 DAG 是怎么生成的呢？

我们都知道，在 Spark 的开发模型下，应用开发实际上就是灵活运用算子实现业务逻辑的过程。开发者在分布式数据集如 RDD、 DataFrame 或 Dataset 之上调用算子、封装计算逻辑，这个过程会衍生新的子 RDD。与此同时，子 RDD 会把 dependencies 属性赋值到父 RDD，把 compute 属性赋值到算子封装的计算逻辑。以此类推，在子 RDD 之上，开发者还会继续调用其他算子，衍生出新的 RDD，如此往复便有了 DAG。

因此，从开发者的视角出发，DAG 的构建是通过在分布式数据集上不停地调用算子来完成的。

Stages 的划分

现在，我们知道了什么是 DAG，以及 DAG 是如何构建的。不过，DAG 毕竟只是一张流程图，Spark 需要把这张流程图转化成分布式任务，才能充分利用分布式集群并行计算的优势。这就好比土豆工坊的施工流程图毕竟还只是蓝图，是工头儿给老板画的一张“饼”，工头儿得想方设法把它转化成实实在在的土豆加工流水线，让流水线能够源源不断地生产不同风味的薯片，才能解决老板的燃眉之急。

简单地说，从开发者构建 DAG，到 DAG 转化的分布式任务在分布式环境中执行，其间会经历如下 4 个阶段：

回溯 DAG 并划分 Stages

在 Stages 中创建分布式任务

分布式任务的分发

分布式任务的执行

刚才我们说了，内存计算的第二层含义在 stages 内部，因此这一讲我们只要搞清楚 DAG 是怎么划分 Stages 就够了。至于后面的 3 个阶段更偏向调度系统的范畴，所以我会在下一讲给你讲清楚其中的来龙去脉。

如果用一句话来概括从 DAG 到 Stages 的转化过程，那应该是：以 Actions 算子为起点，从后向前回溯 DAG，以 Shuffle 操作为边界去划分 Stages。

接下来，我们还是以土豆工坊为例来详细说说这个过程。既然 DAG 是以 Shuffle 为边界去划分 Stages，我们不妨先从上帝视角出发，看看在土豆工坊设计流程图的 DAG 中，都有哪些地方需要执行数据分发的操作。当然，在土豆工坊，数据就是各种形态的土豆和土豆片儿。

DAG以Shuffle为边界划分出3个Stages

仔细观察上面的设计流程图，我们不难发现，有两个地方需要分发数据。第一个地方是薯片经过烘焙烤熟之后，把即食薯片按尺寸大小分发到下游的流水线上，这些流水线会专门处理固定型号的薯片，也就是图中从 bakedChipsRDD 到 flavouredBakedChipsRDD 的那条线。同理，不同的调料粉也需要按照风味的不同分发到下游的流水线上，用于和固定型号的即食薯片混合，也就是图中从 flavoursRDD 到 flavouredBakedChipsRDD 那条分支。

同时，我们也能发现，土豆工坊的 DAG 应该划分 3 个 Stages 出来，如图中所示。其中，Stage 0 包含四个 RDD，从带泥土豆 potatosRDD 到即食薯片 bakedChipsRDD。Stage 1 比较简单，它只有一个 RDD，就是封装调味粉的 flavoursRDD。Stage 2 包含两个 RDD，一个是加了不同风味的即食薯片 flavouredBakedChipsRDD，另一个表示组装成桶已经准备售卖的桶装薯片 bucketChipsRDD。

你可能会问：“费了半天劲，把 DAG 变成 Stages 有啥用呢？”还真有用！内存计算的第二层含义，就隐匿于从 DAG 划分出的一个又一个 Stages 之中。不过，要弄清楚 Stage 内的流水线式计算模式，我们还是得从 Hadoop MapReduce 的计算模型说起。

Stage 中的内存计算

基于内存的计算模型并不是凭空产生的，而是根据前人的教训和后人的反思精心设计出来的。这个前人就是 Hadoop MapReduce，后人自然就是 Spark。

Hadoop MapReduce的计算模型

MapReduce 提供两类计算抽象，分别是 Map 和 Reduce：Map 抽象允许开发者通过实现 map 接口来定义数据处理逻辑；Reduce 抽象则用于封装数据聚合逻辑。MapReduce 计算模型最大的问题在于，所有操作之间的数据交换都以磁盘为媒介。例如，两个 Map 操作之间的计算，以及 Map 与 Reduce 操作之间的计算都是利用本地磁盘来交换数据的。不难想象，这种频繁的磁盘 I/O 必定会拖累用户应用端到端的执行性能。

那么，这和 Stage 内的流水线式计算模式有啥关系呢？我们再回到土豆工坊的例子中，把目光集中在即食薯片分发之前，也就是刚刚划分出来的 Stage 0。这一阶段包含 3 个处理操作，即清洗、切片和烘焙。按常理来说，流水线式的作业方式非常高效，带泥土豆被清洗过后，会沿着流水线被传送到切片机，切完的生薯片会继续沿着流水线再传送到烘焙烤箱，整个过程一气呵成。如果把流水线看作是计算节点内存的话，那么清洗、切片和烘焙这 3 个操作都是在内存中完成计算的。

Stage 0包含清洗、切片、烘焙3个操作

你可能会说：“内存计算也不过如此，跟 MapReduce 相比，不就是把数据和计算都挪到内存里去了吗？”事情可能并没有你想象的那么简单。

在土豆工坊的例子里，Stage 0 中的每个加工环节都会生产出中间食材，如清洗过的土豆、土豆片、即食薯片。我们刚刚把流水线比作内存，这意味着每一个算子计算得到的中间结果都会在内存中缓存一份，以备下一个算子运算，这个过程与开发者在应用代码中滥用 RDD cache 简直如出一辙。如果你曾经也是逢 RDD 便 cache，应该不难想象，采用这种计算模式，Spark 的执行性能不见得比 MapReduce 强多少，尤其是在 Stages 中的算子数量较多的时候。

既然不是简单地把数据和计算挪到内存，那 Stage 内的流水线式计算模式到底长啥样呢？在 Spark 中，流水线计算模式指的是：在同一 Stage 内部，所有算子融合为一个函数，Stage 的输出结果由这个函数一次性作用在输入数据集而产生。这也正是内存计算的第二层含义。下面，我们用一张图来直观地解释这一计算模式。

内存计算的第二层含义

如图所示，在上面的计算流程中，如果你把流水线看作是内存，每一步操作过后都会生成临时数据，如图中的 clean 和 slice，这些临时数据都会缓存在内存里。但在下面的内存计算中，所有操作步骤如 clean、slice、bake，都会被捏合在一起构成一个函数。这个函数一次性地作用在“带泥土豆”上，直接生成“即食薯片”，在内存中不产生任何中间数据形态。

因此你看，所谓内存计算，不仅仅是指数据可以缓存在内存中，更重要的是让我们明白了，通过计算的融合来大幅提升数据在内存中的转换效率，进而从整体上提升应用的执行性能。

这个时候，我们就可以回答开头提出的第二个问题了：费劲去重写代码、消除 Shuffle，能有多大的性能收益？

由于计算的融合只发生在 Stages 内部，而 Shuffle 是切割 Stages 的边界，因此一旦发生 Shuffle，内存计算的代码融合就会中断。但是，当我们对内存计算有了多方位理解以后，就不会一股脑地只想到用 cache 去提升应用的执行性能，而是会更主动地想办法尽量避免 Shuffle，让应用代码中尽可能多的部分融合为一个函数，从而提升计算效率。

小结

这一讲，我们以两个常见的现象为例，探讨了 Spark 内存计算的含义。

在 Spark 中，内存计算有两层含义：第一层含义就是众所周知的分布式数据缓存，第二层含义是 Stage 内的流水线式计算模式。

对于第二层含义，我们需要先搞清楚 DAG 和 Stages 划分，从开发者的视角出发，DAG 的构建是通过在分布式数据集上不停地调用算子来完成的，DAG 以 Actions 算子为起点，从后向前回溯，以 Shuffle 操作为边界，划分出不同的 Stages。

最后，我们归纳出内存计算更完整的第二层含义：同一 Stage 内所有算子融合为一个函数，Stage 的输出结果由这个函数一次性作用在输入数据集而产生。

每日一练

今天的内容重在理解，我希望你能结合下面两道思考题来巩固一下。

我们今天说了，DAG 以 Shuffle 为边界划分 Stages，那你知道 Spark 是根据什么来判断一个操作是否会引入 Shuffle 的呢？

在 Spark 中，同一 Stage 内的所有算子会融合为一个函数。你知道这一步是怎么做到的吗？

期待在留言区看到你的思考和答案，如果对内存计算还有很多困惑，也欢迎你写在留言区，我们下一讲见！

05 | 调度系统：“数据不动代码动”到底是什么意思？

你好，我是吴磊。

在日常的开发与调优工作中，为了充分利用硬件资源，我们往往需要手工调节任务并行度来提升 CPU 利用率，控制任务并行度的参数是 Spark 的配置项：spark.default.parallelism。增加并行度确实能够充分利用闲置的 CPU 线程，但是，parallelism 数值也不宜过大，过大反而会引入过多的调度开销，得不偿失。

这个调优技巧可以说是老生常谈了，网上到处都可以搜得到。那你知道为什么 parallelism 数值过大调度开销会呈指数级增长吗？调度开销具体又是指什么呢？以及，如果不想一个数值一个数值的尝试，parallelism 数值究竟该怎么设置，才能以最少的时间获得最好的效果？如果你还没有答案，或者说还没有把握答对，接下来你就要好好听我讲。

这一讲，我会通过一个机器学习案例，来和你一起聊聊调度系统是什么，它是怎么工作的，从而帮助你摆脱调优总是停留在知其然、不知其所以然的尴尬境地。

案例：对用户兴趣特征做 Label Encoding

在机器学习应用中，特征工程几乎占据了算法同学 80% 的时间和精力，毕竟，一份质量优良的训练样本限定了模型效果的上限和天花板，我们要讲的案例就来自特征工程中一个典型的处理场景：Label Encoding（标签编码）。

什么是 Label encoding 呢？模型特征按照是否连续可以分为两类：连续性数值特征和离散型特征，离散型特征往往以字符串的形式存在，比如用户兴趣特征就包括体育、政治、军事和娱乐等。对于很多机器学习算法来说，字符串类型的数据是不能直接消费的，需要转换为数值才行，例如把体育、政治、军事、娱乐映射为 0、1、2、3，这个过程在机器学习领域有个术语就叫 Label encoding。

我们这一讲的案例，就是要对用户兴趣特征做 Label encoding，简单来说就是以固定的模板把字符串转换为数值，然后将千亿条样本中的用户兴趣转换为对应的索引值。固定模板是离线模型训练与线上模型服务之间的文件接口，内容仅包含用户兴趣这一列，字符串已按事先约定好的规则进行排序。我们需要注意的是，用户兴趣包含 4 个层级，因此这个模板文件较大，记录数达到万级别。

//模板文件
//用户兴趣
体育-篮球-NBA-湖人
军事-武器-步枪-AK47

那具体怎么转换呢？例如，我们可以将用户兴趣“体育 - 篮球 -NBA- 湖人”映射为 0，将兴趣“军事 - 武器 - 步枪 -AK47”映射为 1，以此类推。应该说，需求还是相当明确的，我身边的同学们拿到需求之后，奔儿都没打，以迅雷不及掩耳之势就实现了如下的处理函数。

/**
实现方式1
输入参数：模板文件路径，用户兴趣字符串
返回值：用户兴趣字符串对应的索引值
*/

//函数定义
def findIndex(templatePath: String, interest: String): Int = {
val source = Source.fromFile(filePath, "UTF-8")
val lines = source.getLines().toArray
source.close()
val searchMap = lines.zip(0 until lines.size).toMap
searchMap.getOrElse(interest, -1)
}

//Dataset中的函数调用
findIndex(filePath, "体育-篮球-NBA-湖人")

我们可以看到这个函数有两个形参，一个是模板文件路径，另一个是训练样本中的用户兴趣。处理函数首先读取模板文件，然后根据文件中排序的字符串构建一个从兴趣到索引的 Map 映射，最后在这个 Map 中查找第二个形参传入的用户兴趣，如果能找到则返回对应的索引，找不到的话则返回 -1。

这段代码看上去似乎没什么问题，同学们基于上面的函数对千亿样本做 Label encoding，在 20 台机型为 C5.4xlarge AWS EC2 的分布式集群中花费了 5 个小时。坦白说，这样的执行性能，我是不能接受的。你可能会说：“需求就是这个样子，还能有什么别的办法呢？”我们不妨来看另外一种实现方式。

/**
实现方式2
输入参数：模板文件路径，用户兴趣字符串
返回值：用户兴趣字符串对应的索引值
*/

//函数定义
val findIndex: (String) => (String) => Int = {
(filePath) =>
val source = Source.fromFile(filePath, "UTF-8")
val lines = source.getLines().toArray
source.close()
val searchMap = lines.zip(0 until lines.size).toMap
(interest) => searchMap.getOrElse(interest, -1)
}
val partFunc = findIndex(filePath)

//Dataset中的函数调用
partFunc("体育-篮球-NBA-湖人")

同学们基于第二种方式对相同的数据集做 Label encoding 之后，在 10 台同样机型的分布式集群中花了不到 20 分钟就把任务跑完了。可以说，执行性能的提升是显而易见的。那么，两份代码有什么区别呢？

我们可以看到，相比于第一份代码，第二份代码的函数体内没有任何变化，还是先读取模板文件、构建 Map 映射、查找用户兴趣，最后返回索引。最大的区别就是第二份代码对高阶函数的使用，具体来说有 2 点：

处理函数定义为高阶函数，形参是模板文件路径，返回结果是从用户兴趣到索引的函数；

封装千亿样本的 Dataset 所调用的函数，不是第一份代码中的 findIndex，而是用模板文件调用 findIndex 得到的 partFunc，partFunc 是形参为兴趣、结果为索引的普通标量函数。

那么，高阶函数真有这么神奇吗？其实，性能的提升并不是高阶函数的功劳，而是调度系统在起作用。

Spark 的调度系统是如何工作的？

Spark 调度系统的核心职责是，先将用户构建的 DAG 转化为分布式任务，结合分布式集群资源的可用性，基于调度规则依序把分布式任务分发到执行器。这个过程听上去就够复杂的了，为了方便你理解，我们还是先来讲一个小故事。

土豆工坊流水线升级

在学完了内存计算的第二层含义之后，土豆工坊的老板决定对土豆加工流水线做升级，来提高工坊的生产效率和灵活性。

这里，我们先对内存计算的第二层含义做个简单地回顾，它指的是同一 Stage 中的所有操作会被捏合为一个函数，这个函数一次性会被地应用到输入数据上，并且一次性地产生计算结果。

升级之前的土豆加工流程 DAG 被切分为 3 个执行阶段 Stage，它们分别是 Stage 0、Stage 1、Stage 2。其中，Stage 0 产出即食薯片，Stage 1 分发调味品，Stage 2 则产出不同尺寸、不同风味的薯片。我们重点关注 Stage 0，Stage 0 有 3 个加工环节，分别是清洗、切片和烘焙。这 3 个环节需要 3 种不同的设备，即清洗机、切片机和烤箱。

土豆工坊加工流程的3个执行阶段

工坊有 3 条流水线，每种设备都需要 3 套，在成本方面要花不少钱呢，因此工坊老板一直绞尽脑汁想把设备方面的成本降下来。

此时，工头儿建议：“老板，我听说市场上有一种可编程的土豆加工设备，它是个黑盒子并且只有输入口和输出口，从外面看不见里面的操作流程。不过黑盒子受程序控制，给定输入口的食材，我们可以编写程序控制黑盒子的输出。有了这个可编程设备，咱们不但省了钱，将来还可以灵活地扩充产品线。比方想生产各种风味的薯条或是土豆泥，只需要更换一份程序加载到黑盒子里就行啦！”

老板听后大喜，决定花钱购入可编程土豆加工设备，替换并淘汰现有的清洗机、切片机和烤箱。

于是，工坊的加工流水线就变成了如下的样子。工人们的工作也从按照 DAG 流程图的关键步骤，在流水线上安装相应的设备，变成了把关键步骤编写相应的程序加载到黑盒内。这样一来，这家工坊的生产力也从作坊式的生产方式，升级到了现代化流水线的作业模式。

演进的土豆加工流水线

那么，这个故事跟我们今天要讲的调度系统有什么关系呢？事实上，Spark 调度系统的工作流程包含如下 5 个步骤：

1. 将 DAG 拆分为不同的运行阶段 Stages；

2. 创建分布式任务 Tasks 和任务组 TaskSet；

3. 获取集群内可用的硬件资源情况；

4. 按照调度规则决定优先调度哪些任务 / 组；

5. 依序将分布式任务分发到执行器 Executor。

除了第 4 步以外，其他几步和土豆工坊流水线上的关键步骤都是一一对应的，它们的对应关系如下：

现在，你可能会觉得用故事来记这几个步骤好像多此一举，但当我们学完了所有的原理之后，再回过头来把故事的主线串联起来，你就会惊喜地发现，所有的原理你都能轻松地记住和理解，这可比死记硬背的效率要高得多。

调度系统中的核心组件有哪些？

接下来，我们深入到流程中的每一步去探究 Spark 调度系统是如何工作的。不过在此之前，我们得先弄清楚调度系统都包含哪些关键组件，不同组件之间如何交互，它们分别担任了什么角色，才能更好地理解流程中的每一步。

Spark 调度系统包含 3 个核心组件，分别是 DAGScheduler、TaskScheduler 和 SchedulerBackend。这 3 个组件都运行在 Driver 进程中，它们通力合作将用户构建的 DAG 转化为分布式任务，再把这些任务分发给集群中的 Executors 去执行。不过，它们的名字都包含 Scheduler，光看名字还真是丈二和尚摸不着头脑，所以我把它们和调度系统流程中 5 个步骤的对应关系总结在了下表中，你可以看一看。

1. DAGScheduler

DAGScheduler 的主要职责有二：一是把用户 DAG 拆分为 Stages，如果你不记得这个过程可以回顾一下上一讲的内容；二是在 Stage 内创建计算任务 Tasks，这些任务囊括了用户通过组合不同算子实现的数据转换逻辑。然后，执行器 Executors 接收到 Tasks，会将其中封装的计算函数应用于分布式数据分片，去执行分布式的计算过程。

不过，如果我们给集群中处于繁忙或者是饱和状态的 Executors 分发了任务，执行效果会大打折扣。因此，在分发任务之前，调度系统得先判断哪些节点的计算资源空闲，然后再把任务分发过去。那么，调度系统是怎么判断节点是否空闲的呢？

2. SchedulerBackend

SchedulerBackend 就是用来干这个事的，它是对于资源调度器的封装与抽象，为了支持多样的资源调度模式如 Standalone、YARN 和 Mesos，SchedulerBackend 提供了对应的实现类。在运行时，Spark 根据用户提供的 MasterURL，来决定实例化哪种实现类的对象。MasterURL 就是你通过各种方式指定的资源管理器，如 --master spark://ip:host（Standalone 模式）、--master yarn（YARN 模式）。

对于集群中可用的计算资源，SchedulerBackend 会用一个叫做 ExecutorDataMap 的数据结构，来记录每一个计算节点中 Executors 的资源状态。ExecutorDataMap 是一种 HashMap，它的 Key 是标记 Executor 的字符串，Value 是一种叫做 ExecutorData 的数据结构，ExecutorData 用于封装 Executor 的资源状态，如 RPC 地址、主机地址、可用 CPU 核数和满配 CPU 核数等等，它相当于是对 Executor 做的“资源画像”。

ExecutorDataMap映射表

总的来说，对内，SchedulerBackend 用 ExecutorData 对 Executor 进行资源画像；对外，SchedulerBackend 以 WorkerOffer 为粒度提供计算资源，WorkerOffer 封装了 Executor ID、主机地址和 CPU 核数，用来表示一份可用于调度任务的空闲资源。显然，基于 Executor 资源画像，SchedulerBackend 可以同时提供多个 WorkerOffer 用于分布式任务调度。WorkerOffer 这个名字起得蛮有意思，Offer 的字面意思是公司给你提供的工作机会，结合 Spark 调度系统的上下文，就变成了使用硬件资源的机会。

好了，到此为止，要调度的计算任务有了，就是 DAGScheduler 通过 Stages 创建的 Tasks；可用于调度任务的计算资源也有了，即 SchedulerBackend 提供的一个又一个 WorkerOffer。如果从供需的角度看待任务调度，DAGScheduler 就是需求端，SchedulerBackend 就是供给端。

3. TaskScheduler

左边有需求，右边有供给，如果把 Spark 调度系统看作是一个交易市场的话，那么中间还需要有个中介来帮它们对接意愿、撮合交易，从而最大限度地提升资源配置的效率。在 Spark 调度系统中，这个中介就是 TaskScheduler。TaskScheduler 的职责是，基于既定的规则与策略达成供需双方的匹配与撮合。

Spark分布式任务调度流程

显然，TaskScheduler 的核心是任务调度的规则和策略，TaskScheduler 的调度策略分为两个层次，一个是不同 Stages 之间的调度优先级，一个是 Stages 内不同任务之间的调度优先级。

首先，对于两个或多个 Stages，如果它们彼此之间不存在依赖关系、互相独立，在面对同一份可用计算资源的时候，它们之间就会存在竞争关系。这个时候，先调度谁、或者说谁优先享受这份计算资源，大家就得基于既定的规则和协议照章办事了。

对于这种 Stages 之间的任务调度，TaskScheduler 提供了 2 种调度模式，分别是 FIFO（先到先得）和 FAIR（公平调度）。 FIFO 非常好理解，在这种模式下，Stages 按照被创建的时间顺序来依次消费可用计算资源。这就好比在二手房交易市场中，两个人同时看中一套房子，不管两个人各自愿意出多少钱，谁最先交定金，中介就优先给谁和卖家撮合交易。

你可能会说：“这不合常理啊！如果第二个人愿意出更多的钱，卖家自然更乐意和他成交。”没错，考虑到开发者的意愿度，TaskScheduler 提供了 FAIR 公平调度模式。在这种模式下，哪个 Stages 优先被调度，取决于用户在配置文件 fairscheduler.xml 中的定义。

在配置文件中，Spark 允许用户定义不同的调度池，每个调度池可以指定不同的调度优先级，用户在开发过程中可以关联不同作业与调度池的对应关系，这样不同 Stages 的调度就直接和开发者的意愿挂钩，也就能享受不同的优先级待遇。对应到二手房交易的例子中，如果第二个人乐意付 30% 的高溢价，中介自然乐意优先撮合他与卖家的交易。

说完了不同 Stages 之间的调度优先级，我们再来说说同一个 Stages 内部不同任务之间的调度优先级，Stages 内部的任务调度相对来说简单得多。当 TaskScheduler 接收到来自 SchedulerBackend 的 WorkerOffer 后，TaskScheduler 会优先挑选那些满足本地性级别要求的任务进行分发。众所周知，本地性级别有 4 种：Process local < Node local < Rack local < Any。从左到右分别是进程本地性、节点本地性、机架本地性和跨机架本地性。从左到右，计算任务访问所需数据的效率越来越差。

进程本地性表示计算任务所需的输入数据就在某一个 Executor 进程内，因此把这样的计算任务调度到目标进程内最划算。同理，如果数据源还未加载到 Executor 进程，而是存储在某一计算节点的磁盘中，那么把任务调度到目标节点上去，也是一个不错的选择。再次，如果我们无法确定输入源在哪台机器，但可以肯定它一定在某个机架上，本地性级别就会退化到 Rack local。

DAGScheduler 划分 Stages、创建分布式任务的过程中，会为每一个任务指定本地性级别，本地性级别中会记录该任务有意向的计算节点地址，甚至是 Executor 进程 ID。换句话说，任务自带调度意愿，它通过本地性级别告诉 TaskScheduler 自己更乐意被调度到哪里去。

既然计算任务的个人意愿这么强烈，TaskScheduler 作为中间商，肯定要优先满足人家的意愿。这就像一名码农想要租西二旗的房子，但是房产中介 App 推送的结果都是东三环国贸的房子，那么这个中介的匹配算法肯定有问题。

由此可见，Spark 调度系统的原则是尽可能地让数据呆在原地、保持不动，同时尽可能地把承载计算任务的代码分发到离数据最近的地方，从而最大限度地降低分布式系统中的网络开销。毕竟，分发代码的开销要比分发数据的代价低太多，这也正是“数据不动代码动”这个说法的由来。

总的来说，TaskScheduler 根据本地性级别遴选出待计算任务之后，先对这些任务进行序列化。然后，交给 SchedulerBackend，SchedulerBackend 根据 ExecutorData 中记录的 RPC 地址和主机地址，再将序列化的任务通过网络分发到目的主机的 Executor 中去。最后，Executor 接收到任务之后，把任务交由内置的线程池，线程池中的多线程则并发地在不同数据分片之上执行任务中封装的数据处理函数，从而实现分布式计算。

性能调优案例回顾

知道了调度系统是如何工作的，我们就可以回过头来说说开头 Label encoding 的开发案例中，2 种实现方式的差别到底在哪儿了。我们先来回顾案例中处理函数的主要计算步骤：

读取并遍历模板文件内容，建立从字符串到数值的字典；

根据样本中的用户兴趣，查找字典并返回兴趣字符串对应的数值索引。

2 种实现方式的本质区别在于，函数中 2 个计算步骤的分布式计算过程不同。在第 1 种实现方式中，函数是一个接收两个形参的普通标量函数，Dataset 调用这个函数在千亿级样本上做 Label encoding。

在 Spark 任务调度流程中，该函数在 Driver 端交由 DAGScheduler 打包为 Tasks，经过 TaskScheduler 调度给 SchedulerBackend，最后由 SchedulerBackend 分发到集群中的 Executors 中去执行。这意味着集群中的每一个 Executors 都需要执行函数中封装的两个计算步骤，要知道，第一个步骤中遍历文件内容并建立字典的计算开销还是相当大的。

反观第 2 种实现方式，2 个计算步骤被封装到一个高阶函数中。用户代码先在 Driver 端用模板文件调用这个高阶函数，完成第一步计算建立字典的过程，同时输出一个只带一个形参的标量函数，这个标量函数内携带了刚刚建好的映射字典。最后，Dataset 将这个标量函数作用于千亿样本之上做 Label encoding。

发现区别了吗？在第 2 种实现中，函数的第一步计算只在 Driver 端计算一次，分发给集群中所有 Executors 的任务中封装的是携带了字典的标量函数。然后在 Executors 端，Executors 在各自的数据分片上调用该函数，省去了扫描模板文件、建立字典的开销。最后，我们只需要把样本中的用户兴趣传递进去，函数就能以 O(1) 的查询效率返回数值结果。

对于一个有着成百上千 Executors 的分布式集群来说，这 2 种不同的实现方式带来的性能差异还是相当可观的。因此，如果你能把 Spark 调度系统的工作原理牢记于心，我相信在代码开发或是 review 的过程中，你都能够意识到第一个计算步骤会带来的性能问题。这种开发过程中的反思，其实就是在潜移默化地建立以性能为导向的开发习惯。

小结

今天这一讲，我们先通过一个机器学的案例对比了 2 种实现方式的性能差异，知道了对于调度系统一知半解，很有可能在开发过程中引入潜在的性能隐患。为此，我梳理了调度系统工作流程的 5 个主要步骤：

将 DAG 拆分为不同的运行阶段 Stages；

创建分布式任务 Tasks 和任务组 TaskSet；

获取集群内可用硬件资源情况；

按照调度规则决定优先调度哪些任务 / 组；

依序将分布式任务分发到执行器 Executor；

结合这 5 个步骤，我们深入分析了 Spark 调度系统的工作原理，我们可以从核心职责和核心原则这两方面来归纳：

Spark 调度系统的核心职责是，先将用户构建的 DAG 转化为分布式任务，结合分布式集群资源的可用性，基于调度规则依序把分布式任务分发到执行器 Executors；

Spark 调度系统的核心原则是，尽可能地让数据呆在原地、保持不动，同时尽可能地把承载计算任务的代码分发到离数据最近的地方（Executors 或计算节点），从而最大限度地降低分布式系统中的网络开销。

每日一练

DAGScheduler 在创建 Tasks 的过程中，是如何设置每一个任务的本地性级别？

在计算与存储分离的云计算环境中，Node local 本地性级别成立吗？你认为哪些情况下成立？哪些情况下不成立？

期待在留言区看到你的思考和答案，如果你的朋友也正急需搞清楚调度系统的工作原理，也欢迎你把这一讲转发给他，我们下一讲见！

06 | 存储系统：空间换时间，还是时间换空间？

你好，我是吴磊。

今天，我们来学习 Spark 的存储系统，它和我们上一讲学过的调度系统一样，都是 Spark 分布式计算引擎的基础设施之一。

你可能会问：“在日常的开发工作中，除了业务逻辑实现，我真的需要去关心这么底层的东西吗？”确实，存储系统离开发者比较远。不过，如果把目光落在存储系统所服务的对象上，你很可能会改变这种看法。

接下来，咱们就先来看看 Spark 存储系统都为谁服务，再去探讨它有哪些重要组件，以及它是如何工作的，带你一次性摸透存储系统。

Spark 存储系统是为谁服务的？

Spark 存储系统用于存储 3 个方面的数据，分别是 RDD 缓存、Shuffle 中间文件、广播变量。我们一个一个来说。

RDD 缓存指的是将 RDD 以缓存的形式物化到内存或磁盘的过程。对于一些计算成本和访问频率都比较高的 RDD 来说，缓存有两个好处：一是通过截断 DAG，可以降低失败重试的计算开销；二是通过对缓存内容的访问，可以有效减少从头计算的次数，从整体上提升作业端到端的执行性能。

而要说起 Shuffle 中间文件，我们就不得不提 Shuffle 这个话题。在很多场景中，Shuffle 都扮演着性能瓶颈的角色，解决掉 Shuffle 引入的问题之后，执行性能往往能有立竿见影的提升。因此，凡是与 Shuffle 有关的环节，你都需要格外地重视。

关于 Shuffle 的工作原理，我们后面会详细来讲。这里，咱们先简单理解一下 Shuffle 的计算过程就可以了。它的计算过程可以分为 2 个阶段：

Map 阶段：Shuffle writer 按照 Reducer 的分区规则将中间数据写入本地磁盘；

Reduce 阶段：Shuffle reader 从各个节点下载数据分片，并根据需要进行聚合计算。

Shuffle 中间文件实际上就是 Shuffle Map 阶段的输出结果，这些结果会以文件的形式暂存于本地磁盘。在 Shuffle Reduce 阶段，Reducer 通过网络拉取这些中间文件用于聚合计算，如求和、计数等。在集群范围内，Reducer 想要拉取属于自己的那部分中间数据，就必须要知道这些数据都存储在哪些节点，以及什么位置。而这些关键的元信息，正是由 Spark 存储系统保存并维护的。因此你看，没有存储系统，Shuffle 是玩不转的。

最后，我们再来说说广播变量。在日常开发中，广播变量往往用于在集群范围内分发访问频率较高的小数据。利用存储系统，广播变量可以在 Executors 进程范畴内保存全量数据。这样一来，对于同一 Executors 内的所有计算任务，应用就能够以 Process local 的本地性级别，来共享广播变量中携带的全量数据了。

总的来说，这 3 个服务对象是 Spark 应用性能调优的有力“抓手”，而它们又和存储系统有着密切的联系，因此想要有效运用这 3 个方面的调优技巧，我们就必须要对存储系统有足够的理解。

存储系统的基本组件有哪些？

与调度系统类似，Spark 存储系统是一个囊括了众多组件的复合系统，如 BlockManager、BlockManagerMaster、MemoryStore、DiskStore 和 DiskBlockManager 等等。

不过，家有千口、主事一人，BlockManager 是其中最为重要的组件，它在 Executors 端负责统一管理和协调数据的本地存取与跨节点传输。这怎么理解呢？我们可以从 2 方面来看。

对外，BlockManager 与 Driver 端的 BlockManagerMaster 通信，不仅定期向 BlockManagerMaster 汇报本地数据元信息，还会不定时按需拉取全局数据存储状态。另外，不同 Executors 的 BlockManager 之间也会以 Server/Client 模式跨节点推送和拉取数据块。

对内，BlockManager 通过组合存储系统内部组件的功能来实现数据的存与取、收与发。

那么，对于 RDD 缓存、Shuffle 中间文件和广播变量这 3 个服务对象来说，BlockManager 又是如何存储的呢？Spark 存储系统提供了两种存储抽象：MemoryStore 和 DiskStore。BlockManager 正是利用它们来分别管理数据在内存和磁盘中的存取。

其中，广播变量的全量数据存储在 Executors 进程中，因此它由 MemoryStore 管理。Shuffle 中间文件往往会落盘到本地节点，所以这些文件的落盘和访问就要经由 DiskStore。相比之下，RDD 缓存会稍微复杂一些，由于 RDD 缓存支持内存缓存和磁盘缓存两种模式，因此我们要视情况而定，缓存在内存中的数据会封装到 MemoryStore，缓存在磁盘上的数据则交由 DiskStore 管理。

有了 MemoryStore 和 DiskStore，我们暂时解决了数据“存在哪儿”的问题。但是，这些数据该以“什么形式”存储到 MemoryStore 和 DiskStore 呢？对于数据的存储形式，Spark 存储系统支持两种类型：对象值（Object Values）和字节数组（Byte Array）。它们之间可以相互转换，其中，对象值压缩为字节数组的过程叫做序列化，而字节数组还原成原始对象值的过程就叫做反序列化。

形象点来说，序列化的字节数组就像是从宜家家具超市购买的待组装板材，对象值则是将板材根据说明书组装而成的各种桌椅板凳。显而易见，对象值这种存储形式的优点是拿来即用、所见即所得，缺点是所需的存储空间较大、占地儿。相比之下，序列化字节数组的空间利用率要高得多。不过要是你着急访问里面的数据对象，还得进行反序列化，有点麻烦。

由此可见，对象值和字节数组二者之间存在着一种博弈关系，也就是所谓的“以空间换时间”和“以时间换空间”，两者之间该如何取舍，我们还是要看具体的应用场景。核心原则就是：如果想省地儿，你可以优先考虑字节数组；如果想以最快的速度访问对象，还是对象值更直接一些。 不过，这种选择的烦恼只存在于 MemoryStore 之中，而 DiskStore 只能存储序列化后的字节数组，毕竟，凡是落盘的东西，都需要先进行序列化。

透过 RDD 缓存看 MemoryStore

知道了存储系统有哪些核心的组件，下面，我们接着来说说 MemoryStore 和 DiskStore 这两个组件是怎么管理内存和磁盘数据的。

刚刚我们提到，MemoryStore 同时支持存储对象值和字节数组这两种不同的数据形式，并且统一采用 MemoryEntry 数据抽象对它们进行封装。

MemoryEntry 有两个实现类：DeserializedMemoryEntry 和 SerializedMemoryEntry，分别用于封装原始对象值和序列化之后的字节数组。DeserializedMemoryEntry 用 Array[T]来存储对象值序列，其中 T 是对象类型，而 SerializedMemoryEntry 使用 ByteBuffer 来存储序列化后的字节序列。

得益于 MemoryEntry 对于对象值和字节数组的统一封装，MemoryStore 能够借助一种高效的数据结构来统一存储与访问数据块：LinkedHashMap[BlockId, MemoryEntry]，即 Key 为 BlockId，Value 是 MemoryEntry 的链式哈希字典。在这个字典中，一个 Block 对应一个 MemoryEntry。显然，这里的 MemoryEntry 既可以是 DeserializedMemoryEntry，也可以是 SerializedMemoryEntry。有了这个字典，我们通过 BlockId 即可方便地查找和定位 MemoryEntry，实现数据块的快速存取。

概念这么多，命名也这么相似，是不是看起来就让人“头大”？别着急，接下来，咱们以 RDD 缓存为例，来看看存储系统是如何利用这些数据结构，把 RDD 封装的数据实体缓存到内存里去。

在 RDD 的语境下，我们往往用数据分片（Partitions/Splits）来表示一份分布式数据，但在存储系统的语境下，我们经常会用数据块（Blocks）来表示数据存储的基本单元。在逻辑关系上，RDD 的数据分片与存储系统的 Block 一一对应，也就是说一个 RDD 数据分片会被物化成一个内存或磁盘上的 Block。

因此，如果用一句话来概括缓存 RDD 的过程，就是将 RDD 计算数据的迭代器（Iterator）进行物化的过程，流程如下所示。具体来说，可以分成三步走。

利用MemoryStore在内存中缓存RDD数据内容

既然要把数据内容缓存下来，自然得先把 RDD 的迭代器展开成实实在在的数据值才行。因此，第一步就是通过调用 putIteratorAsValues 或是 putIteratorAsBytes 方法，把 RDD 迭代器展开为数据值，然后把这些数据值暂存到一个叫做 ValuesHolder 的数据结构里。这一步，我们通常把它叫做“Unroll”。

第二步，为了节省内存开销，我们可以在存储数据值的 ValuesHolder 上直接调用 toArray 或是 toByteBuffer 操作，把 ValuesHolder 转换为 MemoryEntry 数据结构。注意啦，这一步的转换不涉及内存拷贝，也不产生额外的内存开销，因此 Spark 官方把这一步叫做“从 Unroll memory 到 Storage memory 的 Transfer（转移）”。

第三步，这些包含 RDD 数据值的 MemoryEntry 和与之对应的 BlockId，会被一起存入 Key 为 BlockId、Value 是 MemoryEntry 引用的链式哈希字典中。因此，LinkedHashMap[BlockId, MemoryEntry]缓存的是关于数据存储的元数据，MemoryEntry 才是真正保存 RDD 数据实体的存储单元。换句话说，大面积占用内存的不是哈希字典，而是一个又一个的 MemoryEntry。

总的来说，RDD 数据分片、Block 和 MemoryEntry 三者之间是一一对应的，当所有的 RDD 数据分片都物化为 MemoryEntry，并且所有的（Block ID, MemoryEntry）对都记录到 LinkedHashMap 字典之后，RDD 就完成了数据缓存到内存的过程。

这里，你可能会问：“如果内存空间不足以容纳整个 RDD 怎么办？”很简单，强行把大 RDD 塞进有限的内存空间肯定不是明智之举，所以 Spark 会按照 LRU 策略逐一清除字典中最近、最久未使用的 Block，以及其对应的 MemoryEntry。相比频繁的展开、物化、换页所带来的性能开销，缓存下来的部分数据对于 RDD 高效访问的贡献可以说微乎其微。

透过 Shuffle 看 DiskStore

相比 MemoryStore，DiskStore 就相对简单很多，因为它并不需要那么多的中间数据结构才能完成数据的存取。DiskStore 中数据的存取本质上就是字节序列与磁盘文件之间的转换，它通过 putBytes 方法把字节序列存入磁盘文件，再通过 getBytes 方法将文件内容转换为数据块。

不过，要想完成两者之间的转换，像数据块与文件的对应关系、文件路径等等这些元数据是必不可少的。MemoryStore 采用链式哈希字典来维护类似的元数据，DiskStore 这个狡猾的家伙并没有亲自维护这些元数据，而是请了 DiskBlockManager 这个给力的帮手。

DiskBlockManager 的主要职责就是，记录逻辑数据块 Block 与磁盘文件系统中物理文件的对应关系，每个 Block 都对应一个磁盘文件。同理，每个磁盘文件都有一个与之对应的 Block ID，这就好比货架上的每一件货物都有唯一的 ID 标识。

DiskBlockManager 在初始化的时候，首先根据配置项 spark.local.dir 在磁盘的相应位置创建文件目录。然后，在 spark.local.dir 指定的所有目录下分别创建子目录，子目录的个数由配置项 spark.diskStore.subDirectories 控制，它默认是 64。所有这些目录均用于存储通过 DiskStore 进行物化的数据文件，如 RDD 缓存文件、Shuffle 中间结果文件等。

DiskStore 中数据的存与取

接下来，我们再以 Shuffle 中间文件为例，来说说 DiskStore 与 DiskBlockManager 的交互过程。

Spark 默认采用 SortShuffleManager 来管理 Stages 间的数据分发，在 Shuffle write 过程中，有 3 类结果文件：temp_shuffle_XXX、shuffle_XXX.data 和 shuffle_XXX.index。Data 文件存储分区数据，它是由 temp 文件合并而来的，而 index 文件记录 data 文件内不同分区的偏移地址。Shuffle 中间文件具体指的就是 data 文件和 index 文件，temp 文件作为暂存盘文件最终会被删除。

在 Shuffle write 的不同阶段，Shuffle manager 通过 BlockManager 调用 DiskStore 的 putBytes 方法将数据块写入文件。文件由 DiskBlockManager 创建，文件名就是 putBytes 方法中的 Block ID，这些文件会以“temp_shuffle”或“shuffle”开头，保存在 spark.local.dir 目录下的子目录里。

在 Shuffle read 阶段，Shuffle manager 再次通过 BlockManager 调用 DiskStore 的 getBytes 方法，读取 data 文件和 index 文件，将文件内容转化为数据块，最终这些数据块会通过网络分发到 Reducer 端进行聚合计算。

小结

掌握存储系统是我们进行 Spark 性能调优的关键一步，我们可以分为三步来掌握。

第一步，我们要明确存储系统的服务对象，分别是 RDD 缓存、Shuffle 和广播变量。

RDD 缓存：一些计算成本和访问频率较高的 RDD，可以以缓存的形式物化到内存或磁盘中。这样一来，既可以避免 DAG 频繁回溯的计算开销，也能有效提升端到端的执行性能

Shuffle：Shuffle 中间文件的位置信息，都是由 Spark 存储系统保存并维护的，没有存储系统，Shuffle 是玩不转的

广播变量：利用存储系统，广播变量可以在 Executors 进程范畴内保存全量数据，让任务以 Process local 的本地性级别，来共享广播变量中携带的全量数据。

第二步，我们要搞清楚存储系统的两个重要组件：MemoryStore 和 DiskStore。其中，MemoryStore 用来管理数据在内存中的存取，DiskStore 用来管理数据在磁盘中的存取。

对于存储系统的 3 个服务对象来说，广播变量由 MemoryStore 管理，Shuffle 中间文件的落盘和访问要经由 DiskStore，而 RDD 缓存因为会同时支持内存缓存和磁盘缓存两种模式，所以两种组件都有可能用到。

最后，我们要理解 MemoryStore 和 DiskStore 的工作原理。

MemoryStore 支持对象值和字节数组，统一采用 MemoryEntry 数据抽象对它们进行封装。对象值和字节数组二者之间存在着一种博弈关系，所谓的“以空间换时间”和“以时间换空间”，两者的取舍还要看具体的应用场景。

DiskStore 则利用 DiskBlockManager 维护的数据块与磁盘文件的对应关系，来完成字节序列与磁盘文件之间的转换。

每日一练

结合 RDD 数据存储到 MemoryStore 的过程，你能推演出通过 MemoryStore 通过 getValues/getBytes 方法去访问 RDD 缓存内容的过程吗？

参考 RDD 缓存存储的过程，你能推演出广播变量存入 MemoryStore 的流程吗？

期待在留言区看到你的思考和讨论，我们下一讲见！

07 | 内存管理基础：Spark如何高效利用有限的内存空间？

你好，我是吴磊。

对于 Spark 这样的内存计算引擎来说，内存的管理与利用至关重要。业务应用只有充分利用内存，才能让执行性能达到最优。

那么，你知道 Spark 是如何使用内存的吗？不同的内存区域之间的关系是什么，它们又是如何划分的？今天这一讲，我就结合一个有趣的小故事，来和你深入探讨一下 Spark 内存管理的基础知识。

内存的管理模式

在管理方式上，Spark 会区分堆内内存（On-heap Memory）和堆外内存（Off-heap Memory）。这里的“堆”指的是 JVM Heap，因此堆内内存实际上就是 Executor JVM 的堆内存；堆外内存指的是通过 Java Unsafe API，像 C++ 那样直接从操作系统中申请和释放内存空间。

其中，堆内内存的申请与释放统一由 JVM 代劳。比如说，Spark 需要内存来实例化对象，JVM 负责从堆内分配空间并创建对象，然后把对象的引用返回，最后由 Spark 保存引用，同时记录内存消耗。反过来也是一样，Spark 申请删除对象会同时记录可用内存，JVM 负责把这样的对象标记为“待删除”，然后再通过垃圾回收（Garbage Collection，GC）机制将对象清除并真正释放内存。

JVM堆内内存的申请与释放

在这样的管理模式下，Spark 对内存的释放是有延迟的，因此，当 Spark 尝试估算当前可用内存时，很有可能会高估堆内的可用内存空间。

堆外内存则不同，Spark 通过调用 Unsafe 的 allocateMemory 和 freeMemory 方法直接在操作系统内存中申请、释放内存空间，这听上去是不是和 C++ 管理内存的方式很像呢？这样的内存管理方式自然不再需要垃圾回收机制，也就免去了它带来的频繁扫描和回收引入的性能开销。更重要的是，空间的申请与释放可以精确计算，因此 Spark 对堆外可用内存的估算会更精确，对内存的利用率也更有把握。

为了帮助你更轻松地理解这个过程，我来给你讲一个小故事。

地主招租（上）：土地划分

很久以前，燕山脚下有一个小村庄，村里有个地主，名叫黄四郎，四郎家有良田千顷，方圆数百里都是他的田地。黄四郎养尊处优，自然不会亲自下地种田，不过这么多田地也不能就这么荒着。于是，他想了个办法，既不用亲自动手又能日进斗金：收租子！

黄四郎虽然好吃懒做，但在管理上还是相当有一套的，他把田地划分为两块，一块叫“托管田”，另一块叫“自管田”。

我们知道，庄稼丰收之后，田地需要翻土、整平、晾晒，来年才能种下一茬庄稼。那么，托管田指的就是丰收之后，由黄四郎派专人帮你搞定翻土、整平这些琐事，不用你操心。相应的，自管田的意思就是庄稼你自己种，秋收之后的田地也得你自己收拾。

毫无疑问，对租户来说托管田更省心一些，自管田更麻烦。当然了，相比自管田，托管田的租金自然更高。

托管田与自管田

那么，这个故事中黄四郎的托管田就是内存管理中的堆内内存，自管田类比的则是堆外内存，田地的翻土、整平这些操作实际上就是 JVM 中的 GC。这样类比起来是不是更好理解了呢？

内存区域的划分

故事先讲到这儿，让我们暂时先回到 Spark 的内存管理上。现在，我们知道了 Spark 内存管理有堆内和堆外两种模式，那 Spark 又是怎么划分内存区域的呢？

我们先来说说堆外内存。Spark 把堆外内存划分为两块区域：一块用于执行分布式任务，如 Shuffle、Sort 和 Aggregate 等操作，这部分内存叫做 Execution Memory；一块用于缓存 RDD 和广播变量等数据，它被称为 Storage Memory。

堆内内存的划分方式和堆外差不多，Spark 也会划分出用于执行和缓存的两份内存空间。不仅如此，Spark 在堆内还会划分出一片叫做 User Memory 的内存空间，它用于存储开发者自定义数据结构。

不同内存区域的划分

除此之外，Spark 在堆内还会预留出一小部分内存空间，叫做 Reserved Memory，它被用来存储各种 Spark 内部对象，例如存储系统中的 BlockManager、DiskBlockManager 等等。

对于性能调优来说，我们在前三块内存的利用率上有比较大的发挥空间，因为业务应用主要消耗的就是它们，也即 Execution memory、Storage memory 和 User memory。而预留内存我们却动不得，因为这块内存仅服务于 Spark 内部对象，业务应用不会染指。

好了，不同内存区域的划分与计算，我也把它们总结到了下面的表格中，方便你随时查阅。

执行与缓存内存

在所有的内存区域中，最重要的无疑是缓存内存和执行内存，而内存计算的两层含义也就是数据集缓存和 Stage 内的流水线计算，对应的就是 Storage Memory 和 Execution Memory。

在 Spark 1.6 版本之前，Execution Memory 和 Storage Memory 内存区域的空间划分是静态的，一旦空间划分完毕，不同内存区域的用途就固定了。也就是说，即便你没有缓存任何 RDD 或是广播变量，Storage Memory 区域的空闲内存也不能用来执行 Shuffle 中的映射、排序或聚合等操作，因此宝贵的内存资源就被这么白白地浪费掉了。

考虑到静态内存划分潜在的空间浪费，在 1.6 版本之后，Spark 推出了统一内存管理模式。统一内存管理指的是 Execution Memory 和 Storage Memory 之间可以相互转化，尽管两个区域由配置项 spark.memory.storageFraction 划定了初始大小，但在运行时，结合任务负载的实际情况，Storage Memory 区域可能被用于任务执行（如 Shuffle），Execution Memory 区域也有可能存储 RDD 缓存。

但是，我们都知道，执行任务相比缓存任务，在内存抢占上有着更高的优先级。那你有没有想过这是为什么呢？接下来，就让我们带着“打破砂锅问到底”的精神，去探索其中更深层次的原因。

首先，执行任务主要分为两类：一类是 Shuffle Map 阶段的数据转换、映射、排序、聚合、归并等操作；另一类是 Shuffle Reduce 阶段的数据排序和聚合操作。它们所涉及的数据结构，都需要消耗执行内存。

我们可以先假设，执行任务与缓存任务在内存抢占上遵循“公正、公平和公开”的三原则。也就是说，不论谁抢占了对方的内存，当对方有需要时都会立即释放。比如说，刚开始双方的预设比例是五五开，但因为缓存任务在应用中比较靠后的位置，所以执行任务先占据了 80% 的内存空间，当缓存任务追赶上来之后，执行任务就需要释放 30% 的内存空间还给缓存任务。

这种情况下会发生什么？假设集群范围内总共有 80 个 CPU，也就是集群在任意时刻的并行计算能力是 80 个分布式任务。在抢占了 80% 内存的情况下，80 个 CPU 可以充分利用，每个 CPU 的计算负载都是比较饱满的，计算完一个任务，再去计算下一个任务。

但是，由于有 30% 的内存要归还给缓存任务，这意味着有 30 个并行的执行任务没有内存可用。也就是说会有 30 个 CPU 一直处在 I/O wait 的状态，没法干活！宝贵的 CPU 计算资源就这么白白地浪费掉了，简直是暴殄天物。

因此，相比于缓存任务，执行任务的抢占优先级一定要更高。说了这么多，我们为什么要弄清楚其中的原因呢？我认为，只有弄清楚抢占优先级的背后逻辑，我们才能理解为什么要同时调节 CPU 和内存的相关配置，也才有可能做到不同硬件资源之间的协同与平衡，这也是我们进行性能调优要达到的最终效果。

不过，即使执行任务的抢占优先级更高，但它们在抢占内存的时候一定也要遵循某些规则。那么，这些规则具体是什么呢？下面，咱们就接着以地主招租的故事为例，来说说 Execution memory 和 Storage memory 之间有哪些有趣的规则。

地主招租（下）：租地协议

黄四郎招租的告示贴出去没多久，村子里就有两个年富力强的小伙子来租种田地。一个叫黄小乙，是黄四郎的远房亲戚，前不久来投奔黄四郎。另一个叫张麻子，虽是八辈贫农，小日子过得也算是蒸蒸日上。张麻子打算把田地租过来种些小麦、玉米这样的庄稼。黄小乙就不这么想，这小子挺有商业头脑，他把田地租过来准备种棉花、咖啡这类经济作物。

两个人摩拳擦掌都想干出一番事业，恨不得把黄四郎的地全都包圆！地不愁租，黄四郎自然是满心欢喜，但烦恼也接踵而至：“既要照顾小乙这孩子，又不能打击麻子的积极性，得想个万全之策”。

于是，他眼珠一转，计上心来：“按理说呢，咱们丈量土地之后，应该在你们中间划一道实线，好区分田地的归属权。不过呢，毕竟麻子你是本村的，小乙远道而来，远来即是客嘛！咱们对小乙还是得多少照顾着点”。张麻子心生不悦：“怎么照顾？”

黄四郎接着说：“很简单，把实线改为虚线，多劳者多得。原本呢，你们应该在分界线划定的那片田地里各自劳作。不过呢，你们二人的进度各不相同嘛，所以，勤奋的人，自己的田地种满了之后，可以跨过分界线，去占用对方还在空着的田地。”

黄小乙不解地问：“四舅，这不是比谁种得快吗？也没对我特殊照顾啊！”张麻子眉间也拧了个疙瘩：“如果种得慢的人后来居上，想要把被占的田地收回去，到时候该怎么办呢？”

黄四郎得意道：“刚才说了，咱们多多照顾小乙。所以如果麻子勤快、干活也快，先占了小乙的地，种上了小麦、玉米，小乙后来居上，想要收回自己的地，那么没说的，麻子得把多占的地让出来。不管庄稼熟没熟，麻子都得把地铲平，还给人家小乙种棉花、咖啡”。

黄小乙与张麻子的占地协议

黄四郎偷眼看了看两人的反应，继续说：“反过来，如果小乙更勤快，先占了麻子的地，麻子后来居上，想要收回，这个时候，咱们就得多照顾照顾小乙。小乙有权继续占用麻子的地，直到地上种的棉花、咖啡都丰收了，再把多占的地让出来。你们二位看怎么样？”

黄小乙听了大喜。张麻子虽然心里不爽，但也清楚黄四郎和黄小乙之间的亲戚关系，也不好再多说什么，心想：“反正我勤快些，先把地种满也就是了”。于是，三方击掌为誓，就此达成协议。

好啦，地主招租的故事到这里就讲完了。不难发现，黄小乙的地类比的是 Execution Memory，张麻子的地其实就是 Storage Memory。他们之间的协议其实就是 Execution Memory 和 Storage Memory 之间的抢占规则，一共可以总结为 3 条：

如果对方的内存空间有空闲，双方就都可以抢占；

对于 RDD 缓存任务抢占的执行内存，当执行任务有内存需要时，RDD 缓存任务必须立即归还抢占的内存，涉及的 RDD 缓存数据要么落盘、要么清除；

对于分布式计算任务抢占的 Storage Memory 内存空间，即便 RDD 缓存任务有收回内存的需要，也要等到任务执行完毕才能释放。

同时，我也把这个例子中的关键内容和 Spark 之间的对应关系总结在了下面，希望能帮助你加深印象。

地主招租与Spark的类比关系

从代码看内存消耗

说完了理论，接下来，咱们再从实战出发，用一个小例子来直观地感受一下，应用中代码的不同部分都消耗了哪些内存区域。

示例代码很简单，目的是读取 words.csv 文件，然后对其中指定的单词进行统计计数。

val dict: List[String] = List(“spark”, “scala”)
val words: RDD[String] = sparkContext.textFile(“~/words.csv”)
val keywords: RDD[String] = words.filter(word => dict.contains(word))
keywords.cache
keywords.count
keywords.map((_, 1)).reduceByKey(_ + _).collect

整个代码片段包含 6 行代码，咱们从上到下逐一分析。

首先，第一行定义了 dict 字典，这个字典在 Driver 端生成，它在后续的 RDD 调用中会随着任务一起分发到 Executor 端。第二行读取 words.csv 文件并生成 RDD words。第三行很关键，用 dict 字典对 words 进行过滤，此时 dict 已分发到 Executor 端，Executor 将其存储在堆内存中，用于对 words 数据分片中的字符串进行过滤。Dict 字典属于开发者自定义数据结构，因此，Executor 将其存储在 User Memory 区域。

接着，第四行和第五行用 cache 和 count 对 keywords RDD 进行缓存，以备后续频繁访问，分布式数据集的缓存占用的正是 Storage Memory 内存区域。在最后一行代码中，我们在 keywords 上调用 reduceByKey 对单词分别计数。我们知道，reduceByKey 算子会引入 Shuffle，而 Shuffle 过程中所涉及的内部数据结构，如映射、排序、聚合等操作所仰仗的 Buffer、Array 和 HashMap，都会消耗 Execution Memory 区域中的内存。

不同代码与其消耗的内存区域，我都整理到了下面的表格中，方便你查看。

小结

深入理解内存管理的机制，有助于我们充分利用应用的内存，提升其执行性能。今天，我们重点学习了内存管理的基础知识。

首先是内存的管理方式。Spark 区分堆内内存和堆外内存：对于堆外内存来说，Spark 通过调用 Java Unsafe 的 allocateMemory 和 freeMemory 方法，直接在操作系统内存中申请、释放内存空间，管理成本较高；对于堆内内存来说，无需 Spark 亲自操刀而是由 JVM 代理。但频繁的 JVM GC 对执行性能来说是一大隐患。另外，Spark 对堆内内存占用的预估往往不够精确，高估可用内存往往会为 OOM 埋下隐患。

其次是统一内存管理，以及 Execution Memory 和 Storage Memory 之间的抢占规则。它们就像黄四郎招租故事中黄小乙和张麻子的田地，抢占规则就像他们之间的占地协议，主要可以分为 3 条：

如果对方的内存空间有空闲，那么双方都可以抢占；

对 RDD 缓存任务抢占的执行内存，当执行任务有内存需要时，RDD 缓存任务必须立即归还抢占的内存，其中涉及的 RDD 缓存数据要么落盘、要么清除；

对分布式计算任务抢占的 Storage Memory 内存空间，即便 RDD 缓存任务有收回内存的需要，也要等到任务执行完毕才能释放。

最后是不同代码对不同内存区域的消耗。内存区域分为 Reserved Memory、User Memory、Execution Memory 和 Storage Memory。其中，Reserved Memory 用于存储 Spark 内部对象，User Memory 用于存储用户自定义的数据结构，Execution Memory 用于分布式任务执行，而 Storage Memory 则用来容纳 RDD 缓存和广播变量。

好了，这些就是内存管理的基础知识。当然了，与内存相关的话题还有很多，比如内存溢出、RDD 缓存、内存利用率，以及执行内存的并行计算等等。在性能调优篇，我还会继续从内存视角出发，去和你探讨这些话题。

每日一练

你知道启用 off-heap 之后，Spark 有哪些计算环节可以利用到堆外内存？你能列举出一些例子吗？

相比堆内内存，为什么在堆外内存中，Spark 对于内存占用量的预估更准确？

结合我在下面给定的配置参数，你能分别计算不同内存区域（Reserved、User、Execution、Storage）的具体大小吗？

期待在留言区看到你的思考和答案，我们下一讲见！

08 | 应用开发三原则：如何拓展自己的开发边界？

你好，我是吴磊。

从今天开始，我们就进入通用性能调优篇的学习了。这一篇，我们会从基本的开发原则、配置项、Shuffle 以及硬件资源这四个方面，去学习一些通用的调优方法和技巧，它们会适用于所有的计算场景。

今天这一讲，我们先从应用开发的角度入手，去探讨开发阶段应该遵循的基础原则。如果能在开发阶段就打好基础、防患于未然，业务应用的执行性能往往会有个不错的起点。开发阶段就像学生时代的考卷，虽然有很难的拔高题，但只要我们稳扎稳打，答好送分的基础题，成绩往往不会太差。

这些“基础题”对应的就是工作中一些“常规操作”，比如 Filter + Coalesce 和用 mapPartitions 代替 map，以及用 ReduceByKey 代替 GroupByKey 等等。我相信，你在日常的开发工作中肯定已经积累了不少。但是据我观察，很多同学在拿到这些技巧之后，都会不假思索地“照葫芦画瓢”。不少同学反馈：“试了之后怎么没效果啊？算了，反正能试的都试了，我也实在没有别的调优思路了，就这样吧”。

那么，这种情况该怎么办呢？我认为，最重要的原因可能是你积累的这些“常规操作”还没有形成体系。结合以往的开发经验，我发现这些“常规操作”可以归纳为三类：

坐享其成

能省则省、能拖则拖

跳出单机思维

话不多说，接下来，我就来和你好好聊一聊。

原则一：坐享其成

站在巨人的肩膀上才能看得更远，所以在绞尽脑汁去尝试各种调优技巧之前，我们应该尽可能地充分利用 Spark 为我们提供的“性能红利”，如钨丝计划、AQE、SQL functions 等等。我把这类原则称作“坐享其成”，意思是说我们通过设置相关的配置项，或是调用相应的 API 去充分享用 Spark 自身带来的性能优势。

那么，我们都可以利用哪些现成的优势呢？

如何利用钨丝计划的优势？

首先，我们可以利用 Databricks 在 2015 年启动的“钨丝计划（Project Tungsten）”。它的优势是，可以通过对数据模型与算法的优化，把 Spark 应用程序的执行性能提升一个数量级。那这是怎么做到的呢？这就要从它的数据结构说起了。

在数据结构方面，Tungsten 自定义了紧凑的二进制格式。这种数据结构在存储效率方面，相比 JVM 对象存储高出好几个数量级。另外，由于数据结构本身就是紧凑的二进制形式，因此它天然地避免了 Java 对象序列化与反序列化引入的计算开销。

基于定制化的二进制数据结构，Tungsten 利用 Java Unsafe API 开辟堆外（Off Heap Memory）内存来管理对象。堆外内存有两个天然的优势：一是对于内存占用的估算更精确，二来不需要像 JVM Heap 那样反复执行垃圾回收。

最后，在运行时，Tungsten 用全阶段代码生成（Whol Stage Code Generation）取代火山迭代模型，这不仅可以减少虚函数调用和降低内存访问频率，还能提升 CPU cache 命中率，做到大幅压缩 CPU idle 时间，从而提升 CPU 利用率。

Databricks 官方对比实验显示，开启 Tungsten 前后，应用程序的执行性能可以提升 16 倍！因此你看，哪怕咱们什么都不做，只要开发的业务应用能够利用到 Tungsten 提供的种种特性，Spark 就能让应用的执行性能有所保障。对于咱们开发者来说，这么大的便宜，干吗不占呢？

如何利用 AQE 的优势？

除了钨丝计划，我们最应该关注 Spark 3.0 版本发布的新特性——AQE。AQE（Adaptive Query Execution）全称“自适应查询执行”，它可以在 Spark SQL 优化的过程中动态地调整执行计划。

我们知道，Spark SQL 的优化过程可以大致分为语法分析、语义解析、逻辑计划和物理计划这几个环节。在 3.0 之前的版本中，Spark 仅仅在编译时基于规则和策略遍历 AST 查询语法树，来优化逻辑计划，一旦基于最佳逻辑计划选定物理执行计划，Spark 就会严格遵照物理计划的步骤去机械地执行计算。

而 AQE 可以让 Spark 在运行时的不同阶段，结合实时的运行时状态，周期性地动态调整前面的逻辑计划，然后根据再优化的逻辑计划，重新选定最优的物理计划，从而调整运行时后续阶段的执行方式。

Spark SQL端到端优化流程

你可能会问：“听上去这么厉害，那 AQE 具体都有哪些改进呢？”AQE 主要带来了 3 个方面的改进，分别是自动分区合并、数据倾斜和 Join 策略调整。我们一一来看。

首先，自动分区合并很好理解，我们拿 Filter 与 Coalesce 来举例。分布式数据集过滤之后，难免有些数据分片的内容所剩无几，甚至为空，所以为了避免多余的调度开销，我们经常会用 Coalesce 去做手工的分区合并。

另外，在 Shuffle 的计算过程中，同样也存在分区合并的需求。

Spark支持AQE前后

以上图为例，我们可以看到，数据表原本有 2 个分区，Shuffle 之后在 Reduce 阶段产生 5 个数据分区。由于数据分布不均衡，其中 3 个分区的数据量很少。对 CPU 来说，这 3 个小分区产生的调度开销会是一笔不小的浪费。在 Spark 支持 AQE 以前，开发者对此无能为力。现在呢，AQE 会自动检测过小的数据分区，并对它们自动合并，根本不需要我们操心了。

其次是数据倾斜（Data Skew），它在数据分析领域中很常见，如果处理不当，很容易导致 OOM 问题。

比方说，我们要分析每一个微博用户的历史行为。那么，不论是发博量还是互动频次，普通用户与头部用户（明星、网红、大 V、媒体）会相差好几个数量级。这个时候，按照用户 ID 进行分组分析就会产生数据倾斜的问题，而且，同一 Executor 中的执行任务基本上是平均分配可用内存的。因此，一边是平均的内存供给，一边是有着数量级之差的数据处理需求，数据倾斜严重的 Task 报出 OOM 错误也就不足为怪了。

以往处理数据倾斜问题的时候，往往需要我们在应用中手动“加盐”，也就是强行给倾斜的 Key 添加随机前缀，通过把 Key 打散来均衡数据在不同节点上的分布。现在，在数据关联（Joins）的场景中，如果 AQE 发现某张表存在倾斜的数据分片，就会自动对它做加盐处理，同时对另一张表的数据进行复制。除此之外，开发者在自行盐化之前，还需要先统计每一个 Key 的倾斜情况再决定盐化的幅度。不过，自从有了 AQE，这些糟心事交给它搞定就好了。

最后，Join 策略调整也不难理解。当两个有序表要进行数据关联的时候，Spark SQL 在优化过程中总会选择 Sort Merge Join 的实现方式。但有一种情况是，其中一个表在排序前需要对数据进行过滤，过滤后的表小到足可以由广播变量容纳。这个时候，Broadcast Join 比 Sort Merge Join 的效率更高。但是，3.0 版本之前的优化过程是静态的，做不到动态切换 Join 方式。

针对这种情况，AQE 会根据运行时的统计数据，去动态地调整 Join 策略，把之前敲定的 Sort Merge Join 改为 Broadcast Join，从而改善应用的执行性能。

说了这么多，对于这些天然的优势，我们到底怎么才能利用好呢？首先，想要利用好 Tungsten 的优势，你只要抛弃 RDD API，采用 DataFrame 或是 Dataset API 进行开发就可了，是不是很简单？

不过，AQE 功能默认是关闭的，如果我们想要充分利用自动分区合并、自动数据倾斜处理和 Join 策略调整，需要把相关的配置项打开，具体的操作如下表所示。

总的来说，通过钨丝计划和 AQE，我们完全可以实现低投入、高产出，这其实就是坐享其成的核心原则。除此之外，类似的技巧还有用 SQL functions 或特征转换算子去取代 UDF 等等。我非常希望你能在开发过程中去主动探索、汇总这些可以拿来即用的技巧，如果有成果，也期待你在留言区分享。

原则二：能省则省、能拖则拖

在很多数据处理场景中，为了快速实现业务需求，我往往会对数据清洗、过滤、提取、关联和聚合等多种操作排列组合来完成开发。这些排列组合的执行性能参差不齐、有好有坏，那我们该怎么找到性能更好的实现方式呢？

这个时候，我们就可以使用第二个原则：“能省则省、能拖则拖”。省的是数据处理量，因为节省数据量就等于节省计算负载，更低的计算负载自然意味着更快的处理速度；拖的是 Shuffle 操作，因为对于常规数据处理来说，计算步骤越靠后，需要处理的数据量越少，Shuffle 操作执行得越晚，需要落盘和分发的数据量就越少，更低的磁盘与网络开销自然意味着更高的执行效率。

实现起来我们可以分 3 步进行：

尽量把能节省数据扫描量和数据处理量的操作往前推；

尽力消灭掉 Shuffle，省去数据落盘与分发的开销；

如果不能干掉 Shuffle，尽可能地把涉及 Shuffle 的操作拖到最后去执行。

接下来，我们再通过一个例子来对这个原则加深理解。

这次的业务背景很简单，我们想要得到两个共现矩阵，一个是物品、用户矩阵，另一个是物品、用户兴趣矩阵。得到这两个矩阵之后，我们要尝试用矩阵分解的方法去计算物品、用户和用户兴趣这 3 个特征的隐向量（Latent Vectors，也叫隐式向量），这些隐向量最终会用来构建机器学习模型的特征向量（Feature Vectors）。

基于这样的业务背景，代码需要实现的功能是读取用户访问日志，然后构建出这两个矩阵。访问日志以天为单位保存在 Parquet 格式的文件中，每条记录包含用户 ID、物品 ID、用户兴趣列表、访问时间、用户属性和物品属性等多个字段。我们需要读取日志记录，先用 distinct 对记录去重，然后用 explode 将兴趣列表展开为单个兴趣，接着提取相关字段，最后按照用户访问频次对记录进行过滤并再次去重，最终就得到了所需的共现矩阵。

拿到这样的业务需求之后，你会怎么实现呢？同学小 A 看完之后，二话不说就实现了如下的代码：

val dates: List[String] = List("2020-01-01", "2020-01-02", "2020-01-03")
val rootPath: String = _

//读取日志文件，去重、并展开userInterestList
def createDF(rootPath: String, date: String): DataFrame = {
val path: String = rootPath + date
val df = spark.read.parquet(path)
.distinct
.withColumn("userInterest", explode(col("userInterestList")))
df
}

//提取字段、过滤，再次去重，把多天的结果用union合并
val distinctItems: DataFrame = dates.map{
case date: String =>
val df: DataFrame = createDF(rootPath, date)
.select("userId", "itemId", "userInterest", "accessFreq")
.filter("accessFreq in ('High', 'Medium'))")
.distinct
df
}.reduce(_ union _)

我们不妨来一起分析一下这段代码，其中主要的操作有 4 个：用 distinct 去重、用 explode 做列表展开、用 select 提取字段和用 filter 过滤日志记录。因为后 3 个操作全部是在 Stage 内完成去内存计算，只有 distinct 会引入 Shuffle，所以我们要重点关注它。distinct 一共被调用了两次，一次是读取日志内容之后去重，另一次是得到所需字段后再次去重。

首先，我们把目光集中到第一个 distinct 操作上：在 createDF 函数中读取日志记录之后，立即调用 distinct 去重。要知道，日志记录中包含了很多的字段，distinct 引入的 Shuffle 操作会触发所有数据记录，以及记录中所有字段在网络中全量分发，但我们最终需要的是用户粘性达到一定程度的数据记录，而且只需要其中的用户 ID、物品 ID 和用户兴趣这 3 个字段。因此，这个 distinct 实际上在集群中分发了大量我们并不需要的数据，这无疑是一个巨大的浪费。

接着，我们再来看第二个 distinct 操作：对数据进行展开、抽取、过滤之后，再对记录去重。这次的去重和第一次大不相同，它涉及的 Shuffle 操作所分发的数据记录没有一条是多余的，记录中仅包含共现矩阵必需的那几个字段。

这个时候我们发现，两个 distinct 操作都是去重，目的一样，但是第二个 distinct 操作比第一个更精准，开销也更少，所以我们可以去掉第一个 distinct 操作。

这样一来，我们也就消灭了一个会引入全量数据分发的 Shuffle 操作，这个改进对执行性能自然大有裨益。不过，按下葫芦浮起瓢，把第一个 distinct 干掉之后，紧随其后的 explode 就浮出了水面。尽管 explode 不会引入 Shuffle，但在内存中展开兴趣列表的时候，它还是会夹带着很多如用户属性、物品属性等等我们并不需要的字段。

因此，我们得把过滤和列剪枝这些可以节省数据访问量的操作尽可能地往前推，把计算开销较大的操作如 Shuffle 尽量往后拖，从而在整体上降低数据处理的负载和开销。基于这些分析，我们就有了改进版的代码实现，如下所示。

val dates: List[String] = List("2020-01-01", "2020-01-02", "2020-01-03")
val rootPath: String = _

val filePaths: List[String] = dates.map(rootPath + _)

/**
一次性调度所有文件
先进行过滤和列剪枝
然后再展开userInterestList
最后统一去重
*/
val distinctItems = spark.read.parquet(filePaths: _*)
.filter("accessFreq in ('High', 'Medium'))")
.select("userId", "itemId", "userInterestList")
.withColumn("userInterest", explode(col("userInterestList")))
.select("userId", "itemId", "userInterest")
.distinct

在这份代码中，所有能减少数据访问量的操作如 filter、select 全部被推到最前面，会引入 Shuffle 的 distinct 算子则被拖到了最后面。经过实验对比，两版代码在运行时的执行性能相差一倍。因此你看，遵循“能省则省、能拖则拖”的开发原则，往往能帮你避开很多潜在的性能陷阱。

原则三：跳出单机思维模式

那么，开发者遵循上述的两个原则去实现业务逻辑，是不是就万事大吉、高枕无忧了呢？当然不是，我们再来看下面的例子。

为了生成训练样本，我们需要对两张大表进行关联。根据“能省则省、能拖则拖”原则，我们想把其中一张表变小，把 Shuffle Join 转换为 Broadcast Join，这样一来就可以把 Shuffle 的环节省掉了。

尽管两张表的尺寸都很大，但右表的 Payload 只有一列，其他列都是 Join keys，所以只要我们把 Join keys 干掉，右表完全可以放到广播变量里。但是，直接干掉 Join keys 肯定不行，因为左右表数据关联是刚需。那么，我们能否换个方式把它们关联在一起呢？

受 Hash Join 工作原理的启发，我们想到可以把所有的 Join keys 拼接在一起，然后用哈希算法生成一个固定长度的字节序列，把它作为新的 Join key。这样一来，右表中原始的 Join keys 就可以拿掉，右表的尺寸也可以大幅削减，小到可以放到广播变量里。同时，新的 Join key 还能保证左右表中数据的关联关系保持不变，一举两得。

为了对拼接的 Join keys 进行哈希运算，我们需要事先准备好各种哈希算法，然后再转换左、右表。接到这样的需求之后，同学小 A 立马在右表上调用了 map 算子，并且在 map 算子内通过实例化 Util 类获取哈希算法，最后在拼接的 Join keys 上进行哈希运算完成了转换。具体的代码如下所示。

import java.security.MessageDigest

class Util {
val md5: MessageDigest = MessageDigest.getInstance("MD5")
val sha256: MessageDigest = _ //其他哈希算法
}

val df: DataFrame = _
val ds: Dataset[Row] = df.map{
case row: Row =>
val util = new Util()
val s: String = row.getString(0) + row.getString(1) + row.getString(2)
val hashKey: String = util.md5.digest(s.getBytes).map("%02X".format(_)).mkString
(hashKey, row.getInt(3))
}

仔细观察，我们发现这份代码其实还有可以优化的空间。要知道，map 算子所囊括的计算是以数据记录（Data Record）为操作粒度的。换句话说，分布式数据集涉及的每一个数据分片中的每一条数据记录，都会触发 map 算子中的计算逻辑。因此，我们必须谨慎对待 map 算子中涉及的计算步骤。很显然，map 算子之中应该仅仅包含与数据转换有关的计算逻辑，与数据转换无关的计算，都应该提炼到 map 算子之外。

反观上面的代码，map 算子内与数据转换直接相关的操作，是拼接 Join keys 和计算哈希值。但是，实例化 Util 对象仅仅是为了获取哈希函数而已，与数据转换无关，因此我们需要把它挪到 map 算子之外。

只是一行语句而已，我们至于这么较真吗？还真至于，这个实例化的动作每条记录都会触发一次，如果整个数据集有千亿条样本，就会有千亿次的实例化操作！差之毫厘谬以千里，一个小小的计算开销在规模化效应之下会被放大无数倍，演化成不容小觑的性能问题。

val ds: Dataset[Row] = df.mapPartitions(iterator => {
val util = new Util()
val res = iterator.map{
case row=>{
val s: String = row.getString(0) + row.getString(1) + row.getString(2)
val hashKey: String = util.md5.digest(s.getBytes).map("%02X".format(_)).mkString
(hashKey, row.getInt(3)) }}
res
})

类似这种忽视实例化 Util 操作的行为还有很多，比如在循环语句中反复访问 RDD，用临时变量缓存数据转换的中间结果等等。这种不假思索地直入面向过程编程，忽略或无视分布式数据实体的编程模式，我们把它叫做单机思维模式。我们在 RDD 那一讲也说过，单机思维模式会让开发者在分布式环境中，无意识地引入巨大的计算开销。

但你可能会说：“单机思维模式随处可见，防不胜防，我们该怎么跳出去呢？”

冰冻三尺、非一日之寒，既然是一种思维模式，那么它自然不是一天、两天就能形成的，想要摆脱它自然也不是一件容易的事情。不过，关于跳出单机思维，我这里也有个小技巧要分享给你。当然，这可能需要一点想象力。

你还记得土豆工坊吗？每当你准备开发应用的时候，你都可以在脑海里构造一个土豆工坊，把你需要定义的分布式数据集，安置到工坊流水线上合适的位置。当你需要处理某个数据集的时候，不妨花点时间想一想，得到当前这种土豆形态都需要哪些前提。持续地在脑海里构造土豆工坊，可以持续地加深你对分布式计算过程的理解。假以时日，我相信你一定能摆脱单机思维模式！

小结

在日常的开发工作中，遵循这 3 个原则，不仅能让你的应用在性能方面有个好的起点，还能让你有意无意地去探索、拓展更多的调优技巧，从而由点及面地积累调优经验。

首先，遵循“坐享其成”的原则，你就可以通过设置相关的配置项，或是调用相应的 API 充分享用 Spark 自身带来的性能优势。比如，使用 DataFrame 或是 Dataset API 做开发，你就可以坐享 Catalyst 和 Tungsten 的各种优化机制。再比如，使用 Parquet、ORC 等文件格式，去坐享谓词下推带来的数据读取效率。

其次，如果你能够坚持“能省则省、能拖则拖”，尽量把节省数据扫描量和数据处理量的操作往前推，尽可能地把涉及 Shuffle 的操作拖延到最后去执行，甚至是彻底消灭 Shuffle，你自然能够避开很多潜在的性能陷阱。

最后，在日常的开发工作中，我们要谨防单机思维模式，摆脱单机思维模式有利于培养我们以性能为导向的开发习惯。我们可以在开发应用的过程中运用想象力，在脑海中构造一个土豆工坊。把每一个分布式数据集都安插到工坊的流水线上。在尝试获取数据集结果的时候，结合我们在原理篇讲解的调度系统、存储系统和内存管理，去进一步想象要得到计算结果，整个工坊都需要做哪些事情，会带来哪些开销。

最后的最后，我们再来说说归纳这件事的意义和价值。我们之所以把各种开发技巧归纳为开发原则，一方面是遵循这些原则，你能在不知不觉中避开很多性能上的坑。但更重要的是，从这些原则出发，向外推演，我们往往能发现更多的开发技巧，从而能拓展自己的“常规操作”边界，做到举一反三，真正避免“调优思路枯竭”的窘境。

每日一练

针对我们今天讲的 3 个原则，你还能想到哪些案例？

除了这 3 个原则外，你觉得是否还有其他原则需要开发者特别留意？

期待在留言区看到你的思考和答案，我们下一讲见！

09 | 调优一筹莫展，配置项速查手册让你事半功倍！（上）

你好，我是吴磊。

对于 Spark 性能调优来说，应用开发和配置项设置是两个最主要也最常用的入口。但在日常的调优工作中，每当我们需要从配置项入手寻找调优思路的时候，一打开 Spark 官网的 Configuration 页面，映入眼帘的就是上百个配置项。它们有的需要设置 True 或 False，有的需要给定明确的数值才能使用。这难免让我们蒙头转向、无所适从。

所以我经常在想，如果能有一份 Spark 配置项手册，上面分门别类地记录着与性能调优息息相关的配置项就好了，肯定能省去不少麻烦。

那么，接下来的两讲，我们就来一起汇总这份手册。这份手册可以让你在寻找调优思路的时候，迅速地定位可能会用到的配置项，不仅有章可循，还能不丢不漏，真正做到事半功倍！

配置项的分类

事实上，能够显著影响执行性能的配置项屈指可数，更何况在 Spark 分布式计算环境中，计算负载主要由 Executors 承担，Driver 主要负责分布式调度，调优空间有限，因此对 Driver 端的配置项我们不作考虑，我们要汇总的配置项都围绕 Executors 展开。那么，结合过往的实践经验，以及对官网全量配置项的梳理，我把它们划分为 3 类，分别是硬件资源类、Shuffle 类和 Spark SQL 大类。

为什么这么划分呢？我们一一来说。

首先，硬件资源类包含的是与 CPU、内存、磁盘有关的配置项。我们说过，调优的切入点是瓶颈，定位瓶颈的有效方法之一，就是从硬件的角度出发，观察某一类硬件资源的负载与消耗，是否远超其他类型的硬件，而且调优的过程收敛于所有硬件资源平衡、无瓶颈的状态，所以掌握资源类配置项就至关重要了。这类配置项设置得是否得当，决定了应用能否打破瓶颈，来平衡不同硬件的资源利用率。

其次，Shuffle 类是专门针对 Shuffle 操作的。在绝大多数场景下，Shuffle 都是性能瓶颈。因此，我们需要专门汇总这些会影响 Shuffle 计算过程的配置项。同时，Shuffle 的调优难度也最高，汇总 Shuffle 配置项能帮我们在调优的过程中锁定搜索范围，充分节省时间。

最后，Spark SQL 早已演化为新一代的底层优化引擎。无论是在 Streaming、Mllib、Graph 等子框架中，还是在 PySpark 中，只要你使用 DataFrame API，Spark 在运行时都会使用 Spark SQL 做统一优化。因此，我们需要梳理出一类配置项，去充分利用 Spark SQL 的先天性能优势。

我们一再强调硬件资源的平衡才是性能调优的关键，所以今天这一讲，我们就先从硬件资源类入手，去汇总应该设置的配置项。在这个过程中，我会带你搞清楚这些配置项的定义与作用是什么，以及它们的设置能解决哪些问题，让你为资源平衡打下基础。下一讲，我们再来讲 Shuffle 类和 Spark SQL 大类。

哪些配置项与 CPU 设置有关？

首先，我们先来说说与 CPU 有关的配置项，主要包括 spark.cores.max、spark.executor.cores 和 spark.task.cpus 这三个参数。它们分别从集群、Executor 和计算任务这三个不同的粒度，指定了用于计算的 CPU 个数。开发者通过它们就可以明确有多少 CPU 资源被划拨给 Spark 用于分布式计算。

为了充分利用划拨给 Spark 集群的每一颗 CPU，准确地说是每一个 CPU 核（CPU Core），你需要设置与之匹配的并行度，并行度用 spark.default.parallelism 和 spark.sql.shuffle.partitions 这两个参数设置。对于没有明确分区规则的 RDD 来说，我们用 spark.default.parallelism 定义其并行度，spark.sql.shuffle.partitions 则用于明确指定数据关联或聚合操作中 Reduce 端的分区数量。

说到并行度（Parallelism）就不得不提并行计算任务（Paralleled Tasks）了，这两个概念关联紧密但含义大相径庭，有不少同学经常把它们弄混。

并行度指的是分布式数据集被划分为多少份，从而用于分布式计算。换句话说，并行度的出发点是数据，它明确了数据划分的粒度。并行度越高，数据的粒度越细，数据分片越多，数据越分散。由此可见，像分区数量、分片数量、Partitions 这些概念都是并行度的同义词。

并行计算任务则不同，它指的是在任一时刻整个集群能够同时计算的任务数量。换句话说，它的出发点是计算任务、是 CPU，由与 CPU 有关的三个参数共同决定。具体说来，Executor 中并行计算任务数的上限是 spark.executor.cores 与 spark.task.cpus 的商，暂且记为 #Executor-tasks，整个集群的并行计算任务数自然就是 #Executor-tasks 乘以集群内 Executors 的数量，记为 #Executors。因此，最终的数值是：#Executor-tasks * #Executors。

我们不难发现，并行度决定了数据粒度，数据粒度决定了分区大小，分区大小则决定着每个计算任务的内存消耗。在同一个 Executor 中，多个同时运行的计算任务“基本上”是平均瓜分可用内存的，每个计算任务能获取到的内存空间是有上限的，因此并行计算任务数会反过来制约并行度的设置。你看，这两个家伙还真是一对相爱相杀的冤家！

至于，到底该怎么平衡并行度与并行计算任务两者之间的关系，我们留到后面的课程去展开。这里，咱们只要记住和 CPU 设置有关配置项的含义、区别与作用就行了。

与CPU有关的配置项

哪些配置项与内存设置有关？

说完 CPU，咱们接着说说与内存管理有关的配置项。我们知道，在管理模式上，Spark 分为堆内内存与堆外内存。

堆外内存又分为两个区域，Execution Memory 和 Storage Memory。要想要启用堆外内存，我们得先把参数 spark.memory.offHeap.enabled 置为 true，然后用 spark.memory.offHeap.size 指定堆外内存大小。堆内内存也分了四个区域，也就是 Reserved Memory、User Memory、Execution Memory 和 Storage Memory。

内存的基础配置项主要有 5 个，它们的含义如下表所示：

与内存有关的配置项

简单来说，这些配置项决定了我们刚才说的这些区域的大小，这很好理解。工具有了，但很多同学在真正设置内存区域大小的时候还会有各种各样的疑惑，比如说：

内存空间是有限的，该把多少内存划分给堆内，又该把多少内存留给堆外呢？

在堆内内存里，该怎么平衡 User Memory 和 Spark 用于计算的内存空间？

在统一内存管理模式下，该如何平衡 Execution Memory 和 Storage Memory？

别着急，接下来，咱们一个一个来解决。

堆外与堆内的平衡

相比 JVM 堆内内存，off heap 堆外内存有很多优势，如更精确的内存占用统计和不需要垃圾回收机制，以及不需要序列化与反序列化。你可能会说：“既然堆外内存这么厉害，那我们干脆把所有内存都划分给它不就得了？”先别急着下结论，我们先一起来看一个例子。

用户表 1 记录着用户数据，每个数据条目包含 4 个字段，整型的用户 ID、String 类型的姓名、整型的年龄和 Char 类型的性别。如果现在要求你用字节数组来存储每一条用户记录，你该怎么办呢？

用户表1：简单数据模式

我们一起来做一下。首先，除姓名外其它 3 个字段都是定长数据类型，因此可以直接安插到字节数组中。对于变长数据类型如 String，由于我们事先并不知道每个用户的名字到底有多长，因此，为了把 name 字段也用字节数组的形式存储，我们只能曲线救国：先记录 name 字段的在整个字节数组内的偏移量，再记录它的长度，最后把完整的 name 字符串安插在字节数组的末尾，如下图所示。

用字节数组存储用户记录

尽管存储 String 类型的 name 字段麻烦一些，但我们总算成功地用字节数组容纳了每一条用户记录。OK，大功告成！

你可能会问：“做这个小实验的目的是啥呢？”事实上，Spark 开辟的堆外内存就是以这样的方式来存储应用数据的。正是基于这种紧凑的二进制格式，相比 JVM 堆内内存，Spark 通过 Java Unsafe API 在堆外内存中的管理，才会有那么多的优势。

不过，成也萧何败也萧何，字节数组自身的局限性也很难突破。比如说，如果用户表 1 新增了兴趣列表字段，类型为 List[String]，如用户表 2 所示。这个时候，如果我们仍然采用字节数据的方式来存储每一条用户记录，不仅越来越多的指针和偏移地址会让字段的访问效率大打折扣，而且，指针越多，内存泄漏的风险越大，数据访问的稳定性就值得担忧了。

用户表2：复杂数据模式

因此，当数据模式（Data Schema）开始变得复杂时，Spark 直接管理堆外内存的成本将会非常高。

那么，针对有限的内存资源，我们该如何平衡 JVM 堆内内存与 off heap 堆外内存的划分，我想你心中也该有了答案。对于需要处理的数据集，如果数据模式比较扁平，而且字段多是定长数据类型，就更多地使用堆外内存。相反地，如果数据模式很复杂，嵌套结构或变长字段很多，就更多采用 JVM 堆内内存会更加稳妥。

User Memory 与 Spark 可用内存如何分配？

接下来，我们再来说说 User Memory。我们都知道，参数 spark.memory.fraction 的作用是明确 Spark 可支配内存占比，换句话说，就是在所有的堆内空间中，有多大比例的内存可供 Spark 消耗。相应地，1 - spark.memory.fraction 就是 User Memory 在堆内空间的占比。

因此，spark.memory.fraction 参数决定着两者如何瓜分堆内内存，它的系数越大，Spark 可支配的内存越多，User Memory 区域的占比自然越小。spark.memory.fraction 的默认值是 0.6，也就是 JVM 堆内空间的 60% 会划拨给 Spark 支配，剩下的 40% 划拨给 User Memory。

那么，User Memory 都用来存啥呀？需要预留那么大的空间吗？简单来说，User Memory 存储的主要是开发者自定义的数据结构，这些数据结构往往用来协助分布式数据集的处理。

举个例子，还记得调度系统那一讲 Label Encoding 的例子吗？

/**
实现方式2
输入参数：模板文件路径，用户兴趣字符串
返回值：用户兴趣字符串对应的索引值
*/

//函数定义
val findIndex: (String) => (String) => Int = {
(filePath) =>
val source = Source.fromFile(filePath, "UTF-8")
val lines = source.getLines().toArray
source.close()
val searchMap = lines.zip(0 until lines.size).toMap
(interest) => searchMap.getOrElse(interest, -1)
}
val partFunc = findIndex(filePath)

//Dataset中的函数调用
partFunc("体育-篮球-NBA-湖人")

在这个例子中，我们先读取包含用户兴趣的模板文件，然后根据模板内容构建兴趣到索引的映射字典。在对千亿样本做 Lable Encoding 的时候，这个字典可以快速查找兴趣字符串，并返回对应索引，来辅助完成数据处理。像这样的映射字典就是所谓的自定义数据结构，这部分数据都存储在 User Memory 内存区域。

因此，当在 JVM 内平衡 Spark 可用内存和 User Memory 时，你需要考虑你的应用中类似的自定义数据结构多不多、占比大不大？然后再相应地调整两块内存区域的相对占比。如果应用中自定义的数据结构很少，不妨把 spark.memory.fraction 配置项调高，让 Spark 可以享用更多的内存空间，用于分布式计算和缓存分布式数据集。

Execution Memory 该如何与 Storage Memory 平衡？

最后，咱们再来说说，Execution Memory 与 Storage Memory 的平衡。在内存管理那一讲，我给你讲了一个黄四郎地主招租的故事，并用故事中的占地协议类比了执行内存与缓存内存之间的竞争关系。执行任务与 RDD 缓存共享 Spark 可支配内存，但是, 执行任务在抢占方面有更高的优先级。

因此通常来说，在统一内存管理模式下，spark.memory.storageFraction 的设置就显得没那么紧要，因为无论这个参数设置多大，执行任务还是有机会抢占缓存内存，而且一旦完成抢占，就必须要等到任务执行结束才会释放。

不过，凡事都没有绝对，如果你的应用类型是“缓存密集型”，如机器学习训练任务，就很有必要通过调节这个参数来保障数据的全量缓存。这类计算任务往往需要反复遍历同一份分布式数据集，数据缓存与否对任务的执行效率起着决定性作用。这个时候，我们就可以把参数 spark.memory.storageFraction 调高，然后有意识地在应用的最开始把缓存灌满，再基于缓存数据去实现计算部分的业务逻辑。

但在这个过程中，你要特别注意 RDD 缓存与执行效率之间的平衡。为什么这么说呢？

首先，RDD 缓存占用的内存空间多了，Spark 用于执行分布式计算任务的内存空间自然就变少了，而且数据分析场景中常见的关联、排序和聚合等操作都会消耗执行内存，这部分内存空间变少，自然会影响到这类计算的执行效率。

其次，大量缓存引入的 GC（Garbage Collection，垃圾回收）负担对执行效率来说是个巨大的隐患。

你还记得黄四郎要招租的土地分为托管田和自管田吗？托管田由黄四郎派人专门打理土地秋收后的翻土、整平等杂务，为来年种下一茬庄稼做准备。堆内内存的垃圾回收也是一个道理，JVM 大体上把 Heap 堆内内存分为年轻代和老年代。年轻代存储生命周期较短、引用次数较低的对象；老年代则存储生命周期较长、引用次数高的对象。因此，像 RDD cache 这种一直缓存在内存里的数据，一定会被 JVM 安排到老年代。

年轻代的垃圾回收工作称为 Young GC，老年代的垃圾回收称为 Full GC。每当老年代可用内存不足时，都会触发 JVM 执行 Full GC。在 Full GC 阶段，JVM 会抢占应用程序执行线程，强行征用计算节点中所有的 CPU 线程，也就是“集中力量办大事”。当所有 CPU 线程都被拿去做垃圾回收工作的时候，应用程序的执行只能暂时搁置。只有等 Full GC 完事之后，把 CPU 线程释放出来，应用程序才能继续执行。这种 Full GC 征用 CPU 线程导致应用暂停的现象叫做“Stop the world”。

因此，Full GC 对于应用程序的伤害远大于 Young GC，并且 GC 的效率与对象个数成反比，对象个数越多，GC 效率越差。这个时候，对于 RDD 这种缓存在老年代中的数据，就很容易引入 Full GC 问题。

一般来说，为了提升 RDD cache 访问效率，很多同学都会采用以对象值的方式把数据缓存到内存，因为对象值的存储方式避免了数据存取过程中序列化与反序列化的计算开销。我们在 RDD/DataFrame/Dataset 之上调用 cache 方法的时候，默认采用的就是这种存储方式。

但是，采用对象值的方式缓存数据，不论是 RDD，还是 DataFrame、Dataset，每条数据样本都会构成一个对象，要么是开发者自定义的 Case class，要么是 Row 对象。换句话说，老年代存储的对象个数基本等于你的样本数。因此，当你的样本数大到一定规模的时候，你就需要考虑大量的 RDD cache 可能会引入的 Full GC 问题了。

基于上面的分析，我们不难发现，在打算把大面积的内存空间用于 RDD cache 之前，你需要衡量这么做可能会对执行效率产生的影响。

你可能会说：“我的应用就是缓存密集型，确实需要把数据缓存起来，有什么办法来平衡执行效率吗？”办法还是有的。

首先，你可以放弃对象值的缓存方式，改用序列化的缓存方式，序列化会把多个对象转换成一个字节数组。这样，对象个数的问题就得到了初步缓解。

其次，我们可以调节 spark.rdd.compress 这个参数。RDD 缓存默认是不压缩的，启用压缩之后，缓存的存储效率会大幅提升，有效节省缓存内存的占用，从而把更多的内存空间留给分布式任务执行。

通过这两类调整，开发者在享用 RDD 数据访问效率的同时，还能够有效地兼顾应用的整体执行效率，可谓是两全其美。不过，有得必有失，尽管这两类调整优化了内存的使用效率，但都是以引入额外的计算开销、牺牲 CPU 为代价的。这也就是我们一直强调的：性能调优的过程本质上就是不断地平衡不同硬件资源消耗的过程。

哪些配置项与磁盘设置有关？

在存储系统那一讲，我们简单提到过 spark.local.dir 这个配置项，这个参数允许开发者设置磁盘目录，该目录用于存储 RDD cache 落盘数据块和 Shuffle 中间文件。

通常情况下，spark.local.dir 会配置到本地磁盘中容量比较宽裕的文件系统，毕竟这个目录下会存储大量的临时文件，我们需要足够的存储容量来保证分布式任务计算的稳定性。不过，如果你的经费比较充裕，有条件在计算节点中配备足量的 SSD 存储，甚至是更多的内存资源，完全可以把 SSD 上的文件系统目录，或是内存文件系统添加到 spark.local.dir 配置项中去，从而提供更好的 I/O 性能。

小结

掌握硬件资源类的配置项是我们打破性能瓶颈，以及平衡不同硬件资源利用率的必杀技。具体来说，我们可以分成两步走。

第一步，理清 CPU、内存和磁盘这三个方面的性能配置项都有什么，以及它们的含义。因此，我把硬件资源类配置项的含义都汇总在了一个表格中，方便你随时查看。有了这份手册，在针对硬件资源进行配置项调优时，你就能够做到不重不漏。

第二步，重点理解这些配置项的作用，以及可以解决的问题。

首先，对于 CPU 类配置项，我们要重点理解并行度与并行计算任务数的区别。并行度从数据的角度出发，明确了数据划分的粒度，并行度越高，数据粒度越细，数据越分散，CPU 资源利用越充分，但同时要提防数据粒度过细导致的调度系统开销。

并行计算任务数则不同，它从计算的角度出发，强调了分布式集群在任一时刻并行处理的能力和容量。并行度与并行计算任务数之间互相影响、相互制约。

其次，对于内存类配置项，我们要知道怎么设置它们来平衡不同内存区域的方法。这里我们主要搞清楚 3 个问题就可以了：

在平衡堆外与堆内内存的时候，我们要重点考察数据模式。如果数据模式比较扁平，而且定长字段较多，应该更多地使用堆外内存。相反地，如果数据模式比较复杂，应该更多地利用堆内内存

在平衡可支配内存和 User memory 的时候，我们要重点考察应用中自定义的数据结构。如果数据结构较多，应该保留足够的 User memory 空间。相反地，如果数据结构较少，应该让 Spark 享有更多的可用内存资源

在平衡 Execution memory 与 Storage memory 的时候，如果 RDD 缓存是刚需，我们就把 spark.memory.storageFraction 调大，并且在应用中优先把缓存灌满，再把计算逻辑应用在缓存数据之上。除此之外，我们还可以同时调整 spark.rdd.compress 和 spark.memory.storageFraction 来缓和 Full GC 的冲击

每日一练

并行度设置过大会带来哪些弊端？

在 Shuffle 的计算过程中，有哪些 Spark 内置的数据结构可以充分利用堆外内存资源？

堆外与堆内的取舍，你还能想到其他的制约因素吗？

如果内存资源足够丰富，有哪些方式可以开辟内存文件系统，用于配置 spark.local.dir 参数？

期待在留言区看到你的思考和答案，也欢迎你把这份硬件资源配置项手册分享给更多的朋友，我们下一讲见！

10 |调优一筹莫展，配置项速查手册让你事半功倍！（下）

你好，我是吴磊。

上一讲，我们讲了硬件资源类的配置项。这一讲，我们继续说说 Shuffle 类和 Spark SQL 大类都有哪些配置项，它们的含义和作用，以及它们能解决的问题。同时，和上一讲一样，我们今天讲到的配置项也全部会围绕 Executors 展开。

Shuffle 类配置项

首先，我们来说说 Shuffle 类。纵观 Spark 官网的Configuration 页面，你会发现能调节 Shuffle 执行性能的配置项真是寥寥无几。其实这也很好理解，因为一旦 Shuffle 成为应用中不可或缺的一环，想要优化 Shuffle 本身的性能，我们能做的微乎其微。

不过，我们也不是完全束手无策。我们知道，Shuffle 的计算过程分为 Map 和 Reduce 这两个阶段。其中，Map 阶段执行映射逻辑，并按照 Reducer 的分区规则，将中间数据写入到本地磁盘；Reduce 阶段从各个节点下载数据分片，并根据需要实现聚合计算。

那么，我们就可以通过 spark.shuffle.file.buffer 和 spark.reducer.maxSizeInFlight 这两个配置项，来分别调节 Map 阶段和 Reduce 阶段读写缓冲区的大小。具体该怎么做呢？我们一一来看。

缓冲区相关配置项

首先，在 Map 阶段，计算结果会以中间文件的形式被写入到磁盘文件系统。同时，为了避免频繁的 I/O 操作，Spark 会把中间文件存储到写缓冲区（Write Buffer）。这个时候，我们可以通过设置 spark.shuffle.file.buffer 来扩大写缓冲区的大小，缓冲区越大，能够缓存的落盘数据越多，Spark 需要刷盘的次数就越少，I/O 效率也就能得到整体的提升。

其次，在 Reduce 阶段，因为 Spark 会通过网络从不同节点的磁盘中拉取中间文件，它们又会以数据块的形式暂存到计算节点的读缓冲区（Read Buffer）。缓冲区越大，可以暂存的数据块越多，在数据总量不变的情况下，拉取数据所需的网络请求次数越少，单次请求的网络吞吐越高，网络 I/O 的效率也就越高。这个时候，我们就可以通过 spark.reducer.maxSizeInFlight 配置项控制 Reduce 端缓冲区大小，来调节 Shuffle 过程中的网络负载。

事实上，对 Shuffle 计算过程的优化牵扯到了全部的硬件资源，包括 CPU、内存、磁盘和网络。因此，我们上一讲汇总的关于 CPU、内存和硬盘的配置项，也同样可以作用在 Map 和 Reduce 阶段的内存计算过程上。

除此之外，Spark 还提供了一个叫做 spark.shuffle.sort.bypassMergeThreshold 的配置项，去处理一种特殊的 Shuffle 场景。

Reduce端相关配置项

自 1.6 版本之后，Spark 统一采用 Sort shuffle manager 来管理 Shuffle 操作，在 Sort shuffle manager 的管理机制下，无论计算结果本身是否需要排序，Shuffle 计算过程在 Map 阶段和 Reduce 阶段都会引入排序操作。

这样的实现机制对于 repartition、groupBy 这些操作就不太公平了，这两个算子一个是对原始数据集重新划分分区，另一个是对数据集进行分组，压根儿就没有排序的需求。所以，Sort shuffle manager 实现机制引入的排序步骤反而变成了一种额外的计算开销。

因此，在不需要聚合，也不需要排序的计算场景中，我们就可以通过设置 spark.shuffle.sort.bypassMergeThreshold 的参数，来改变 Reduce 端的并行度（默认值是 200）。当 Reduce 端的分区数小于这个设置值的时候，我们就能避免 Shuffle 在计算过程引入排序。

Spark SQL 大类配置项

接下来，我们再来说说 Spark SQL 的相关配置项。在官网的Configuration 页面中，Spark SQL 下面的配置项还是蛮多的，其中对执行性能贡献最大的，当属 AQE（Adaptive query execution，自适应查询引擎）引入的那 3 个特性了，也就是自动分区合并、自动数据倾斜处理和 Join 策略调整。因此，关于 Spark SQL 的配置项，咱们围绕着这 3 个特性去汇总。

首先我们要知道，AQE 功能默认是禁用的，想要使用这些特性，我们需要先通过配置项 spark.sql.adaptive.enabled 来开启 AQE，具体的操作如下：

启用AQE的配置项

因为这 3 个特性的原理我们在开发原则那一讲说过，这里我会先带你简单回顾一下，然后我们重点来讲，这些环节对应的配置项有哪些。

哪些配置项与自动分区合并有关？

分区合并的场景用一句概括就是，在 Shuffle 过程中，因为数据分布不均衡，导致 Reduce 阶段存在大量的小分区，这些小分区的数据量非常小，调度成本很高。

那么问题来了，AQE 是如何判断某个分区是不是足够小，到底需不需要合并的呢？另外，既然是对多个分区进行合并，自然就存在一个收敛条件的问题，如果一直不停地合并下去，整个分布式数据集最终就会合并为一个超级大的分区。简单来说，就是：“分区合并从哪里开始，又到哪里结束呢？”

分区合并示意图

我们一起来看一下 AQE 分区合并的工作原理。如上图所示，对于所有的数据分区，无论大小，AQE 按照分区编号从左到右进行扫描，边扫描边记录分区尺寸，当相邻分区的尺寸之和大于“目标尺寸”时，AQE 就把这些扫描过的分区进行合并。然后，继续向右扫描，并采用同样的算法，按照目标尺寸合并剩余分区，直到所有分区都处理完毕。

总的来说就是，AQE 事先并不判断哪些分区足够小，而是按照分区编号进行扫描，当扫描量超过“目标尺寸”时，就合并一次。我们发现，这个过程中的关键就是“目标尺寸”的确定，它的大小决定了合并之后分布式数据集的分散程度。

那么，“目标尺寸”由什么来决定的呢？Spark 提供了两个配置项来共同决定分区合并的“目标尺寸”，它们分别是 spark.sql.adaptive.advisoryPartitionSizeInBytes 和 spark.sql.adaptive.coalescePartitions.minPartitionNum。

AQE自动分区合并相关配置项

其中，第一个参数 advisoryPartitionSizeInBytes 是开发者建议的目标尺寸，第二个参数 minPartitionNum 的含义是合并之后的最小分区数，假设它是 200，就说明合并之后的分区数量不能小于 200。这个参数的目的就是避免并行度过低导致 CPU 资源利用不充分。

结合 Shuffle 后的数据集尺寸和最小分区数限制，我们可以反推出来每个分区的平均大小，咱们暂且把它记为 #partitionSize。分区合并的目标尺寸取 advisoryPartitionSizeInBytes 与 #partitionSize 之间的最小值。

这么说比较抽象，我们来举个例子。假设，Shuffle 过后数据大小为 20GB，minPartitionNum 设置为 200，反推过来，每个分区的尺寸就是 20GB / 200 = 100MB。再假设，advisoryPartitionSizeInBytes 设置为 200MB，最终的目标分区尺寸就是取（100MB，200MB）之间的最小值，也就是 100MB。因此你看，并不是你指定了 advisoryPartitionSizeInBytes 是多少，Spark 就会完全尊重你的意见，我们还要考虑 minPartitionNum 的设置。

哪些配置项与自动数据倾斜处理有关？

再来说说数据倾斜，在数据关联（Data Joins）的场景中，当 AQE 检测到倾斜的数据分区时，会自动进行拆分操作，把大分区拆成多个小分区，从而避免单个任务的数据处理量过大。不过，Spark 3.0 版本发布的 AQE，暂时只能在 Sort Merge Join 中自动处理数据倾斜，其他的 Join 实现方式如 Shuffle Join 还有待支持。

那么，AQE 如何判定数据分区是否倾斜呢？它又是怎么把大分区拆分成多个小分区的？

AQE数据倾斜处理相关配置项

首先，分区尺寸必须要大于 spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes 参数的设定值，才有可能被判定为倾斜分区。然后，AQE 统计所有数据分区大小并排序，取中位数作为放大基数，尺寸大于中位数一定倍数的分区会被判定为倾斜分区，中位数的放大倍数也是由参数 spark.sql.adaptive.skewJoin.skewedPartitionFactor 控制。

接下来，我们还是通过一个例子来理解。假设数据表 A 有 3 个分区，分区大小分别是 80MB、100MB 和 512MB。显然，这些分区按大小个排序后的中位数是 100MB，因为 skewedPartitionFactor 的默认值是 5 倍，所以大于 100MB * 5 = 500MB 的分区才有可能被判定为倾斜分区。在我们的例子中，只有最后一个尺寸是 512MB 的分区符合这个条件。

这个时候，Spark 还不能完全判定它就是倾斜分区，还要看 skewedPartitionThresholdInBytes 配置项，这个参数的默认值是 256MB。对于那些满足中位数条件的分区，必须要大于 256MB，Saprk 才会把这个分区最终判定为倾斜分区。假设 skewedPartitionThresholdInBytes 设定为 1GB，那在我们的例子中，512MB 那个大分区，Spark 也不会把它看成是倾斜分区，自然也就不能享受到 AQE 对于数据倾斜的优化处理。

检测到倾斜分区之后，接下来就是对它拆分，拆分的时候还会用到 advisoryPartitionSizeInBytes 参数。假设我们将这个参数的值设置为 256MB，那么，刚刚那个 512MB 的倾斜分区会以 256MB 为粒度拆分成多份，因此，这个大分区会被拆成 2 个小分区（ 512MB / 256MB =2）。拆分之后，原来的数据表就由 3 个分区变成了 4 个分区，每个分区的尺寸都不大于 256MB。

哪些配置项与 Join 策略调整有关？

最后，咱们再来说说数据关联（Joins）。数据关联可以说是数据分析领域中最常见的操作，Spark SQL 中的 Join 策略调整，它实际上指的是，把会引入 Shuffle 的 Join 方式，如 Hash Join、Sort Merge Join，“降级”（Demote）为 Broadcast Join。

Broadcast Join 的精髓在于“以小博大”，它以广播的方式将小表的全量数据分发到集群中所有的 Executors，大表的数据不需要以 Join keys 为基准去 Shuffle，就可以与小表数据原地进行关联操作。Broadcast Join 以小表的广播开销为杠杆，博取了因消除大表 Shuffle 而带来的巨大性能收益。可以说，Broadcast Join 把“杠杆原理”应用到了极致。

在 Spark 发布 AQE 之前，开发者可以利用 spark.sql.autoBroadcastJoinThreshold 配置项对数据关联操作进行主动降级。这个参数的默认值是 10MB，参与 Join 的两张表中只要有一张数据表的尺寸小于 10MB，二者的关联操作就可以降级为 Broadcast Join。为了充分利用 Broadcast Join“以小博大”的优势，你可以考虑把这个参数值调大一些，2GB 左右往往是个不错的选择。

AQE推出之前Join策略相关配置项

不过，autoBroadcastJoinThreshold 这个参数虽然好用，但是有两个让人头疼的短板。

一是可靠性较差。尽管开发者明确设置了广播阈值，而且小表数据量在阈值以内，但 Spark 对小表尺寸的误判时有发生，导致 Broadcast Join 降级失败。

二来，预先设置广播阈值是一种静态的优化机制，它没有办法在运行时动态对数据关联进行降级调整。一个典型的例子是，两张大表在逻辑优化阶段都不满足广播阈值，此时 Spark SQL 在物理计划阶段会选择 Shuffle Joins。但在运行时期间，其中一张表在 Filter 操作之后，有可能出现剩余的数据量足够小，小到刚好可以降级为 Broadcast Join。在这种情况下，静态优化机制就是无能为力的。

AQE 很好地解决了这两个头疼的问题。首先，AQE 的 Join 策略调整是一种动态优化机制，对于刚才的两张大表，AQE 会在数据表完成过滤操作之后动态计算剩余数据量，当数据量满足广播条件时，AQE 会重新调整逻辑执行计划，在新的逻辑计划中把 Shuffle Joins 降级为 Broadcast Join。再者，运行时的数据量估算要比编译时准确得多，因此 AQE 的动态 Join 策略调整相比静态优化会更可靠、更稳定。

AQE推出之后Join策略相关配置项

不过，启用动态 Join 策略调整还有个前提，也就是要满足 nonEmptyPartitionRatioForBroadcastJoin 参数的限制。这个参数的默认值是 0.2，大表过滤之后，非空的数据分区占比要小于 0.2，才能成功触发 Broadcast Join 降级。

这么说有点绕，我们来举个例子。假设，大表过滤之前有 100 个分区，Filter 操作之后，有 85 个分区内的数据因为不满足过滤条件，在过滤之后都变成了没有任何数据的空分区，另外的 15 个分区还保留着满足过滤条件的数据。这样一来，这张大表过滤之后的非空分区占比是 15 / 100 = 15%，因为 15% 小于 0.2，所以这个例子中的大表会成功触发 Broadcast Join 降级。

相反，如果大表过滤之后，非空分区占比大于 0.2，那么剩余数据量再小，AQE 也不会把 Shuffle Joins 降级为 Broadcast Join。因此，如果你想要充分利用 Broadcast Join 的优势，可以考虑把这个参数适当调高。

小结

今天这一讲，我们深入探讨了 Shuffle 类和 Spark SQL 大类两类配置项，以及每个配置项可以解决的问题。

对于 Shuffle 类我们要知道，在 Shuffle 过程中，对于不需要排序和聚合的操作，我们可以通过控制 spark.shuffle.sort.bypassMergeThreshold 参数，来避免 Shuffle 执行过程中引入的排序环节，从而避免没必要的计算开销。

对于 Spark SQL 大类我们首先要知道，AQE 默认是禁用状态，要充分利用 AQE 提供的 3 个特性，就是自动分区合并、数据倾斜处理和 Join 策略调整，我们需要把 spark.sql.adaptive.enabled 置为 true。

除此之外，AQE 的 3 个特性各自都有相对应的配置项，需要我们单独调整。

AQE 中的自动分区合并过程与我们预想的不太一样。QE 事先并不判断哪些分区足够小，而是按照分区编号进行扫描，当扫描量超过“目标尺寸”时就合并一次。目标尺寸由 advisoryPartitionSizeInBytes 和 coalescePartitions.minPartitionNum 两个参数共同决定。

AQE 能够自动处理 Sort Merge Join 场景中的数据倾斜问题。首先根据所有分区大小的中位数，以及放大倍数 skewedPartitionFactor 来检测倾斜分区，然后以 advisoryPartitionSizeInBytes 为粒度对倾斜分区进行拆分。

AQE 动态 Join 策略调整可以在运行时将 Shuffle Joins 降级为 Broadcast Join，同时，运行时的数据量估算要比编译时准确得多，因此相比静态优化会更可靠。不过，需要我们注意的是，Shuffle 过后非空分区占比要小于 nonEmptyPartitionRatioForBroadcastJoin 才能触发 Broadcast Join 的降级优化。

好啦，经过这两讲的学习，我们一起汇总出了 Spark 中与性能调优息息相关的所有配置项，为了方便你快速查阅，我把它们合并在了一张文稿的表格中，希望你能在工作中好好利用起来。

每日一练

AQE 的分区合并算法略显简单粗暴，如果让你来重新实现分区合并特性的话，你都有哪些思路呢？

AQE 中数据倾斜的处理机制，你认为有哪些潜在的隐患？

期待在留言区看到你的思考和答案，也欢迎你把这份调优手册分享给你的朋友们，我们下一讲见！

11 | Shuffle的工作原理：为什么说Shuffle是一时无两的性能杀手？

你好，我是吴磊。

一提到 Shuffle，你能想到什么？我想很多人的第一反应都是应用中最顽固、最难解决的性能瓶颈。

在之前的课程中，我们也不止一次地提到 Shuffle，尤其是在开发原则那一讲，我还建议你遵循“能省则省、能拖则拖”的原则，在应用中尽量去避免 Shuffle，如果受业务逻辑所限确实不能避免，就尽可能地把 Shuffle 往后拖。

那么，为什么我们一谈 Shuffle 就色变，提到它就避之唯恐不及呢？今天这一讲，我就通过实现一个仙女散花游戏的过程，来和你深入探讨 Shuffle 是如何工作的，说说它为什么是分布式应用一时无两的性能杀手。毕竟，只有了解 Shuffle 的工作原理，我们才能更好地避免它。

如何理解 Shuffle？

假设，你的老板今天给你安排一个开发任务，让你用 Spark 去实现一个游戏需求。这个实现需求来自一个小故事：仙女散花。

很久以前，燕山脚下有个小村庄，村子里有所“七色光”小学，方圆百里的孩子都来这里上学。这一天，一年级 2 班的黄老师和班里的五个孩子正在做一个游戏，叫做“仙女散花”。

黄老师的背包里装满了五种不同颜色的花朵，五种颜色分别是红、橙、黄、紫、青。她把背包里的花朵随机地分发给五个小同学：小红、橙橙、黄小乙、阿紫和小青。花朵发完之后，每个同学分到的花朵数量差不多，颜色各有不同。

接着，黄老师开始宣布游戏规则：“你们要一起协作，在最短的时间内，把花朵按照颜色收集在一起。游戏完成的标志是，课桌上有 5 堆花朵，每堆花朵的颜色都是一样的。”

仙女散花的游戏

大家跃跃欲试，黄小乙说：“先别急，我们来制定个策略。先在前面摆上 5 张课桌，然后每个人需要做两件事情，先把自己手里的花朵按照颜色归类分为 5 堆，再把分好颜色的花朵，分别放到相应的课桌上！”于是，几个小同学按照黄小乙说的策略，不一会儿就完成了游戏。

事实上，仙女散花的游戏过程和 Shuffle 的工作流程大同小异。当然，Shuffle 过程中涉及的步骤和环节，要比“仙女散花”复杂一些。

Shuffle 的本意是“洗牌”，在分布式计算环境中，它有两个阶段。一般来说，前一个阶段叫做“Map 阶段”，后一个阶段叫做“Reduce 阶段”。当然，也有人把它们叫做 Shuffle Write 阶段和 Shuffle Read 阶段。

仙女散花中的Map和Reduce阶段

在仙女散花的游戏中，从老师分发花朵，到 5 个小同学把花朵按照颜色归类，对应的是 Shuffle 的 Map 阶段，而大家把归类的花朵分发到相应的课桌，这个过程类似于 Shuffle 的 Reduce 阶段。

接下来，我们就借助这个故事来深入探讨 Shuffle 的两个阶段：Map 阶段和 Reduce 阶段。

自 2.0 版本之后，Spark 将 Shuffle 操作统一交由 Sort shuffle manager 来管理。因此，今天这一讲，我们专注于 Sort shuffle manager 实现的 Shuffle 分发过程。

Map 阶段是如何输出中间文件的？

以终为始、以结果为导向的学习往往效率更高，在深入探讨 Map 阶段如何生产数据之前，我们不妨先来明确：Map 阶段的输出到底是什么？

之前我们也说过，Map 阶段最终生产的数据会以中间文件的形式物化到磁盘中，这些中间文件就存储在 spark.local.dir 设置的文件目录里。中间文件包含两种类型：一类是后缀为 data 的数据文件，存储的内容是 Map 阶段生产的待分发数据；另一类是后缀为 index 的索引文件，它记录的是数据文件中不同分区的偏移地址。这里的分区是指 Reduce 阶段的分区，因此，分区数量与 Reduce 阶段的并行度保持一致。

Map阶段输出的数据文件和索引文件

这样一来，我们就可以把问题进一步聚焦在，Spark 在 Map 阶段是如何生产这些中间文件的。不过，我们首先需要明确的是，Map 阶段每一个 Task 的执行流程都是一样的，每个 Task 最终都会生成一个数据文件和一个索引文件。因此，中间文件的数量与 Map 阶段的并行度保持一致。换句话说，有多少个 Task，Map 阶段就会生产相应数量的数据文件和索引文件。

接下来，我带你用 Spark 来实现“仙女散花”的游戏，咱们一边做游戏，一边来分析 Map 阶段的中间文件是如何产生的。

用 groupByKey 实现“仙女散花”

在“仙女散花”的游戏中，黄老师要求大家把同一种花色的花朵收集到一起。那么，在 Spark 的分布式开发框架内，与这个游戏最相仿的计算过程非 groupByKey 莫属，所以我们不妨用 groupByKey 来实现游戏。

首先是 flowers.txt 文件：

黄色花朵
紫色花朵
红色花朵
橙色花朵
青色花朵
黄色花朵
紫色花朵
橙色花朵
青色花朵
......

其次是同学小 A 接到需求后，用 groupByKey 实现“仙女散花”游戏的代码：

val flowers = spark.sparkContext.textFile("flowers.txt")
//黄老师给5个小同学分发花朵
val flowersForKids = flowers.coalesce(5)
val flowersKV = flowersForKids.map((_, 1))
//黄小乙的两个步骤：大家先各自按颜色归类，然后再把归类后的花朵放到相应的课桌上
flowersKV.groupByKey.collect

我们可以看到，代码步骤与游戏过程基本上一一对应。但是，读取完花朵文件之后，由于 groupByKey 是 pairRDD 算子，需要消费（Key，Value）形式的数据，因此我们需要对原始花朵数据做一次转换。以数据分区 0 为例，数据的转换过程如下图所示，你不妨把数据分区 0 理解为是黄老师分发给小红的花朵。

将原始数据转换为pairRDD

基于 pairRDD 的 Key，也就是花朵的颜色，Map Task 就可以计算每条数据记录在 Reduce 阶段的目标分区，目标分区也就是游戏中的课桌。在黄小乙制定的策略中，哪种花放到哪张桌子是大家事先商定好的，但在 Spark 中，每条数据记录应该分发到哪个目标分区，是由 Key 的哈希值决定的。

目标分区计算好之后，Map Task 会把每条数据记录和它的目标分区，放到一个特殊的数据结构里，这个数据结构叫做“PartitionedPairBuffer”，它本质上就是一种数组形式的缓存结构。它是怎么存储数据记录的呢？

每条数据记录都会占用数组中相邻的两个元素空间，第一个元素是（目标分区，Key），第二个元素是 Value。假设 PartitionedPairBuffer 的大小是 4，也就是最多只能存储 4 条数据记录。那么，如果我们还以数据分区 0 为例，小红的前 4 枚花朵在 PartitionedPairBuffer 中的存储状态就会如下所示。

PartitionedPairBuffer存储小红的前4枚花朵

对我们来说，最理想的情况当然是 PartitionedPairBuffer 足够大，大到足以容纳 Map Task 所需处理的所有数据。不过，现实总是很骨感，每个 Task 分到的内存空间是有限的，PartitionedPairBuffer 自然也不能保证能容纳分区中的所有数据。因此，Spark 需要一种计算机制，来保障在数据总量超出可用内存的情况下，依然能够完成计算。这种机制就是：排序、溢出、归并。

就拿大小为 4 的 PartitionedPairBuffer 来说，数据分区 0 里面有 16 朵花，对应着 16 条数据记录，它们至少要分 4 批才能依次完成处理。在处理下一批数据之前，Map Task 得先把 PartitionedPairBuffer 中已有的数据腾挪出去，腾挪的方式简单粗暴，Map Task 直接把数据溢出到磁盘中的临时文件。

不过，在溢出之前，对于 PartitionedPairBuffer 中已有的数据，Map Task 会先按照数据记录的第一个元素，也就是目标分区 + Key 进行排序。也就是说，尽管数据暂时溢出到了磁盘，但是临时文件中的数据也是有序的。

就这样，PartitionedPairBuffer 腾挪了一次又一次，数据分区 0 里面的花朵处理了一批又一批，直到所有的花朵都被处理完。分区 0 有 16 朵花，PartitionedPairBuffer 的大小是 4，因此，PartitionedPairBuffer 总共被腾挪了 3 次，生成了 3 个临时文件，每个临时文件中包含 4 条数据记录。16 条数据，有 12 条分散在 3 个文件中，还有 4 条缓存在 PartitionedPairBuffer 里。

到此为止，我们离 Map 阶段生产的、用于在网络中分发数据的中间文件仅有一步之遥了。还记得吗？Map 阶段生产的中间文件有两类，一类是数据文件，另一类是索引文件。分散在 3 个临时文件和 PartitionedPairBuffer 里的数据记录，就是生成这两类文件的输入源。最终，Map Task 用归并排序的算法，将 4 个输入源中的数据写入到数据文件和索引文件中去，如下图所示。

归并临时文件，生成最终的中间文件

好了，到目前为止，我们用 groupByKey 实现了“仙女散花”的游戏，详细讲解了 Map 阶段生产中间文件的过程。虽然 Map 阶段的计算步骤很多，但其中最主要的环节可以归结为 4 步：

1. 对于分片中的数据记录，逐一计算其目标分区，并将其填充到 PartitionedPairBuffer；

2. PartitionedPairBuffer 填满后，如果分片中还有未处理的数据记录，就对 Buffer 中的数据记录按（目标分区 ID，Key）进行排序，将所有数据溢出到临时文件，同时清空缓存；

3. 重复步骤 1、2，直到分片中所有的数据记录都被处理；

4. 对所有临时文件和 PartitionedPairBuffer 归并排序，最终生成数据文件和索引文件。

不难发现，仙女散花其实就是个分组、收集的游戏。应该说，用 Spark 来实现分组、收集类的游戏还是比较简单的，那么，如果把仙女散花变成是“分组、统计”的游戏，我们该怎么做呢？

“仙女散花”游戏升级

5 个小同学完成游戏之后，离下课时间还早。因此，黄老师调整了游戏规则：“你们五个人还是一起协作，这次要在最短的时间内，统计不同花色花朵的数量。”

小红迫不及待地说：“很简单！还是按照刚才的策略，先把花朵分好堆，然后我们五个人分别去课桌上数数就好啦！”

小红的主意

黄小乙皱了皱眉，说道：“别急，新的游戏规则也是有时间限制的，我想了一个和你差不多的办法，一共分三步：第一步，每个人把手里不同花色花朵的数量先算出来；第二步，我们只需要把花朵的数量写到相应的桌子上；第三步，我们分别对五张课桌上的数字求和。这样就能完成得更快了”

黄小乙的主意

用 reduceByKey 实现升级后的仙女散花

如果我们想用 Spark 来实现升级后的游戏，该怎么办呢？其实很简单，只要把 groupByKey 换成 reduceByKey 就好了。

val flowers = spark.sparkContext.textFile("flowers.txt")
//黄老师给5个小同学分发花朵
val flowersForKids = flowers.coalesce(5)
val flowersKV = flowersForKids.map((_, 1))
//黄小乙的两个步骤：大家先各自按颜色计数，然后再按照课桌统一计数
flowersKV.reduceByKey(_ + _).collect

接下来，我们来分析一下 reduceByKey 的 Map 阶段计算，相比 groupByKey 有何不同。就 Map 端的计算步骤来说，reduceByKey 与刚刚讲的 groupByKey 一样，都是先填充内存数据结构，然后排序溢出，最后归并排序。

区别在于，在计算的过程中，reduceByKey 采用一种叫做 PartitionedAppendOnlyMap 的数据结构来填充数据记录。这个数据结构是一种 Map，而 Map 的 Value 值是可累加、可更新的。因此，PartitionedAppendOnlyMap 非常适合聚合类的计算场景，如计数、求和、均值计算、极值计算等等。

大小为4的PartitionedAppendOnlyMap

在上图中，4 个 KV 对的 Value 值，是扫描到数据分区 0 当中青色花朵之前的状态。在 PartitionedAppendOnlyMap 中，由于 Value 是可累加、可更新的，因此这种数据结构可以容纳的花朵数量一定比 4 大。因此，相比 PartitionedPairBuffer，PartitionedAppendOnlyMap 的存储效率要高得多，溢出数据到磁盘文件的频率也要低得多。

以此类推，最终合并的数据文件也会小很多。依靠高效的内存数据结构、更少的磁盘文件、更小的文件尺寸，我们就能大幅降低了 Shuffle 过程中的磁盘和网络开销。

事实上，相比 groupByKey、collect_list 这些收集类算子，聚合类算子（reduceByKey、aggregateByKey 等）在执行性能上更占优势。因此，我们要避免在聚合类的计算需求中，引入收集类的算子。虽然这种做法不妨碍业务逻辑实现，但在性能调优上可以说是大忌。

Reduce 阶段是如何进行数据分发的？

最后，我们再来说说 Reduce 阶段，在“仙女散花”的游戏里，每个人把自己的花朵归好类之后，主动地把不同颜色的花朵放到相应的课桌上，这个过程实际上就是 Shuffle 过程中的数据分发。不过，与课桌被动地接收花朵不同的是，Shuffle 在 Reduce 阶段是主动地从 Map 端的中间文件中拉取数据。

Map阶段输出的数据文件和索引文件

刚刚讲过，每个 Map Task 都会生成如上图所示的中间文件，文件中的分区数与 Reduce 阶段的并行度一致。换句话说，每个 Map Task 生成的数据文件，都包含所有 Reduce Task 所需的部分数据。因此，任何一个 Reduce Task 要想完成计算，必须先从所有 Map Task 的中间文件里去拉取属于自己的那部分数据。索引文件正是用于帮助判定哪部分数据属于哪个 Reduce Task。

Reduce Task 通过网络拉取中间文件的过程，实际上就是不同 Stages 之间数据分发的过程。在“仙女散花”的游戏中，5 个孩子与 5 张课桌之间，需要往返 25 人次。如果让 100 个孩子把 100 种颜色的花朵，分别收集到 100 张课桌上，那么这 100 个孩子与 100 张课桌之间，就需要 10000 人次的往返！显然，Shuffle 中数据分发的网络开销，会随着 Map Task 与 Reduce Task 的线性增长，呈指数级爆炸。

Reduce Task 将拉取到的数据块填充到读缓冲区，然后按照任务的计算逻辑不停地消费、处理缓冲区中的数据记录，如下图所示。

Reduce阶段的计算过程

我们可以看到，Reduce 阶段用圆圈标记的 1、2、3、4 与 Map 阶段的四个步骤一模一样。没错，因即是果、果即是因，当我们说某个 Stage 是 Map 阶段或是 Reduce 阶段的时候，我们的出发点或者说锚点就是 Shuffle。对于上图的 Shuffle 0 来说，Stage 0 是 Map 阶段，Stage 1 是 Reduce 阶段。但是，对于后面的 Shuffle 1 来说，Stage 1 就变成了 Map 阶段。因此你看，当我们把视角拉宽，Map 和 Reduce 这两个看似对立的东西，其实有很多地方都是相通的。

性能杀手

想必经过上面两个阶段的分析，你已经对 Shuffle 为何会成为性能瓶颈，有了比较直观的感受。这里，我再带你总结一下。

首先，对于 Shuffle 来说，它需要消耗所有的硬件资源：

无论是 PartitionedPairBuffer、PartitionedAppendOnlyMap 这些内存数据结构，还是读写缓冲区，都会消耗宝贵的内存资源；

由于内存空间有限，因此溢出的临时文件会引入大量磁盘 I/O，而且，Map 阶段输出的中间文件也会消耗磁盘；

呈指数级增长的跨节点数据分发，带来的网络开销更是不容小觑。

其次，Shuffle 消耗的不同硬件资源之间很难达到平衡。磁盘和网络的消耗是 Shuffle 中必需的环节。但是，磁盘与网络的处理延迟相比 CPU 和内存要相差好几个数量级。以下表为例，如果以 CPU L1 缓存的处理延迟为基准，把单位从纳秒校准到秒，我们就会惊讶地发现，当 CPU、内存以秒为单位处理数据时，磁盘和网络的处理延迟是以天、月为单位的！

正是基于 Shuffle 的这些特点，我们才会“谈虎色变”，一提到 Shuffle 就避之唯恐不及，强调能省则省、能拖则拖。

小结

这一讲，我借助实现仙女散花这个游戏的需求，带你直观地认识 Shuffle 的计算过程。Shuffle 有两个计算阶段，Map 阶段和 Reduce 阶段。我们要重点掌握 Map 阶段的计算流程，我把它总结为 4 步：

对于分片中的数据记录，逐一计算其目标分区，然后填充内存数据结构（PartitionedPairBuffer 或 PartitionedAppendOnlyMap）；

当数据结构填满后，如果分片中还有未处理的数据记录，就对结构中的数据记录按（目标分区 ID，Key）排序，将所有数据溢出到临时文件，同时清空数据结构；

重复前 2 个步骤，直到分片中所有的数据记录都被处理；

对所有临时文件和内存数据结构中剩余的数据记录做归并排序，最终生成数据文件和索引文件。

在 Reduce 阶段我们要注意，Reduce Task 通过网络拉取中间文件的过程，实际上就是不同 Stages 之间数据分发的过程。并且，Shuffle 中数据分发的网络开销，会随着 Map Task 与 Reduce Task 的线性增长，呈指数级爆炸。

最后，从硬件资源的角度来看，Shuffle 对每一种硬件资源都非常地渴求，尤其是内存、磁盘和网络。由于不同硬件资源之间的处理延迟差异巨大，我们很难在 Shuffle 过程中平衡 CPU、内存、磁盘和网络之间的计算开销。因此，对于 Shuffle 我们避之唯恐不及，要能省则省、能拖则拖。

每日一练

以小红分到的花朵（数据分区 0）为例，你能推导出 reduceByKey 中 Map 阶段的每个环节吗？（提示：PartitionedAppendOnlyMap 需要多少次溢出到磁盘临时文件？每一个临时文件中的内容是什么？最终生成的中间文件，内容分别是什么？和 groupByKey 生成的中间文件一样吗？）

Map 阶段和 Reduce 阶段有不少环节都涉及数据缓存、数据存储，结合上一讲介绍的 Spark 配置项，你能把相关的配置项对号入座吗？

期待在留言区看到你的思考和讨论，我们下一讲见！

12 | 广播变量（一）：克制Shuffle，如何一招制胜！

你好，我是吴磊。

在数据分析领域，数据关联（Joins）是 Shuffle 操作的高发区，二者如影随从。可以说，有 Joins 的地方，就有 Shuffle。

我们说过，面对 Shuffle，开发者应当“能省则省、能拖则拖”。我们已经讲过了怎么拖，拖指的就是，把应用中会引入 Shuffle 的操作尽可能地往后面的计算步骤去拖。那具体该怎么省呢？

在数据关联场景中，广播变量就可以轻而易举地省去 Shuffle。所以今天这一讲，我们就先说一说广播变量的含义和作用，再说一说它是如何帮助开发者省去 Shuffle 操作的。

如何理解广播变量？

接下来，咱们借助一个小例子，来讲一讲广播变量的含义与作用。这个例子和 Word Count 有关，它可以说是分布式编程里的 Hello world 了，Word Count 就是用来统计文件中全部单词的，你肯定已经非常熟悉了，所以，我们例子中的需求增加了一点难度，我们要对指定列表中给定的单词计数。

val dict = List(“spark”, “tune”)
val words = spark.sparkContext.textFile(“~/words.csv”)
val keywords = words.filter(word => dict.contains(word))
keywords.map((_, 1)).reduceByKey(_ + _).collect

按照这个需求，同学小 A 实现了如上的代码，一共有 4 行，我们逐一来看。第 1 行在 Driver 端给定待查单词列表 dict；第 2 行以 textFile API 读取分布式文件，内容包含一列，存储的是常见的单词；第 3 行用列表 dict 中的单词过滤分布式文件内容，只保留 dict 中给定的单词；第 4 行调用 reduceByKey 对单词进行累加计数。

数据结构dict随着Task一起分发到Executors

学习过调度系统之后，我们知道，第一行代码定义的 dict 列表连带后面的 3 行代码会一同打包到 Task 里面去。这个时候，Task 就像是一架架小飞机，携带着这些“行李”，飞往集群中不同的 Executors。对于这些“行李”来说，代码的“负重”较轻，可以忽略不计，而数据的负重占了大头，成了最主要的负担。

你可能会说：“也还好吧，dict 列表又不大，也没什么要紧的”。但是，如果我们假设这个例子中的并行度是 10000，那么，Driver 端需要通过网络分发总共 10000 份 dict 拷贝。这个时候，集群内所有的 Executors 需要消耗大量内存来存储这 10000 份的拷贝，对宝贵的网络和内存资源来说，这已经是一笔不小的浪费了。更何况，如果换做一个更大的数据结构，Task 分发所引入的网络与内存开销会更可怕。

换句话说，统计计数的业务逻辑还没有开始执行，Spark 就已经消耗了大量的网络和存储资源，这简直不可理喻。因此，我们需要对示例中的代码进行优化，从而跳出这样的窘境。

但是，在着手优化之前，我们不妨先来想一想，现有的问题是什么，我们要达到的目的是什么。结合刚刚的分析，我们不难发现，Word Count 的核心痛点在于，数据结构的分发和存储受制于并行，并且是以 Task 为粒度的，因此往往频次过高。痛点明确了，调优的目的也就清晰了，我们需要降低数据结构分发的频次。

要达到这个目的，我们首先想到的就是降低并行度。不过，牵一发而动全身，并行度一旦调整，其他与 CPU、内存有关的配置项都要跟着适配，这难免把调优变复杂了。实际上，要降低数据结构的分发频次，我们还可以考虑广播变量。

广播变量是一种分发机制，它一次性封装目标数据结构，以 Executors 为粒度去做数据分发。换句话说，在广播变量的工作机制下，数据分发的频次等同于集群中的 Executors 个数。通常来说，集群中的 Executors 数量都远远小于 Task 数量，相差两到三个数量级是常有的事。那么，对于第一版的 Word Count 实现，如果我们使用广播变量的话，会有哪些变化呢？

代码的改动很简单，主要有两个改动：第一个改动是用 broadcast 封装 dict 列表，第二个改动是在访问 dict 列表的地方改用 broadcast.value 替代。

val dict = List(“spark”, “tune”)
val bc = spark.sparkContext.broadcast(dict)
val words = spark.sparkContext.textFile(“~/words.csv”)
val keywords = words.filter(word => bc.value.contains(word))
keywords.map((_, 1)).reduceByKey(_ + _).collect

你可能会说：“这个改动看上去也没什么呀！”别着急，我们先来分析一下，改动之后的代码在运行时都有哪些变化。

在广播变量的运行机制下，封装成广播变量的数据，由 Driver 端以 Executors 为粒度分发，每一个 Executors 接收到广播变量之后，将其交给 BlockManager 管理。由于广播变量携带的数据已经通过专门的途径存储到 BlockManager 中，因此分发到 Executors 的 Task 不需要再携带同样的数据。

这个时候，你可以把广播变量想象成一架架专用货机，专门为 Task 这些小飞机运送“大件行李”。Driver 与每一个 Executors 之间都开通一条这样的专用货机航线，统一运载负重较大的“数据行李”。有了专用货机来帮忙，Task 小飞机只需要携带那些负重较轻的代码就好了。等这些 Task 小飞机在 Executors 着陆，它们就可以到 Executors 的公用仓库 BlockManager 里去提取它们的“大件行李”。

用广播变量封装dict列表

总之，在广播变量的机制下，dict 列表数据需要分发和存储的次数锐减。我们假设集群中有 20 个 Executors，不过任务并行度还是 10000，那么，Driver 需要通过网络分发的 dict 列表拷贝就会由原来的 10000 份减少到 20 份。同理，集群范围内所有 Executors 需要存储的 dict 拷贝，也由原来的 10000 份，减少至 20 份。这个时候，引入广播变量后的开销只是原来 Task 分发的 1/500！

广播分布式数据集

那在刚刚的示例代码中，广播变量封装的是 Driver 端创建的普通变量：字符串列表。除此之外，广播变量也可以封装分布式数据集。

我们来看这样一个例子。在电子商务领域中，开发者往往用事实表来存储交易类数据，用维度表来存储像物品、用户这样的描述性数据。事实表的特点是规模庞大，数据体量随着业务的发展不断地快速增长。维度表的规模要比事实表小很多，数据体量的变化也相对稳定。

假设用户维度数据以 Parquet 文件格式存储在 HDFS 文件系统中，业务部门需要我们读取用户数据并创建广播变量以备后用，我们该怎么做呢？很简单，几行代码就可以搞定！

val userFile: String = “hdfs://ip:port/rootDir/userData”
val df: DataFrame = spark.read.parquet(userFile)
val bc_df: Broadcast[DataFrame] = spark.sparkContext.broadcast(df)

首先，我们用 Parquet API 读取 HDFS 分布式数据文件生成 DataFrame，然后用 broadcast 封装 DataFrame。从代码上来看，这种实现方式和封装普通变量没有太大差别，它们都调用了 broadcast API，只是传入的参数不同。

但如果不从开发的视角来看，转而去观察运行时广播变量的创建过程的话，我们就会发现，分布式数据集与普通变量之间的差异非常显著。

从普通变量创建广播变量，由于数据源就在 Driver 端，因此，只需要 Driver 把数据分发到各个 Executors，再让 Executors 把数据缓存到 BlockManager 就好了。

但是，从分布式数据集创建广播变量就要复杂多了，具体的过程如下图所示。

从分布式数据集创建广播变量的过程

与普通变量相比，分布式数据集的数据源不在 Driver 端，而是来自所有的 Executors。Executors 中的每个分布式任务负责生产全量数据集的一部分，也就是图中不同的数据分区。因此，步骤 1 就是 Driver 从所有的 Executors 拉取这些数据分区，然后在本地构建全量数据。步骤 2 与从普通变量创建广播变量的过程类似。 Driver 把汇总好的全量数据分发给各个 Executors，Executors 将接收到的全量数据缓存到存储系统的 BlockManager 中。

不难发现，相比从普通变量创建广播变量，从分布式数据集创建广播变量的网络开销更大。原因主要有二：一是，前者比后者多了一步网络通信；二是，前者的数据体量通常比后者大很多。

如何用广播变量克制 Shuffle？

你可能会问：“Driver 从 Executors 拉取 DataFrame 的数据分片，揉成一份全量数据，然后再广播出去，抛开网络开销不说，来来回回得费这么大劲，图啥呢？”这是一个好问题，因为以广播变量的形式缓存分布式数据集，正是克制 Shuffle 杀手锏。

Shuffle Joins

为什么这么说呢？我还是拿电子商务场景举例。有了用户的数据之后，为了分析不同用户的购物习惯，业务部门要求我们对交易表和用户表进行数据关联。这样的数据关联需求在数据分析领域还是相当普遍的。

val transactionsDF: DataFrame = _
val userDF: DataFrame = _
transactionsDF.join(userDF, Seq(“userID”), “inner”)

因为需求非常明确，同学小 A 立即调用 Parquet 数据源 API，读取分布式文件，创建交易表和用户表的 DataFrame，然后调用 DataFrame 的 Join 方法，以 userID 作为 Join keys，用内关联（Inner Join）的方式完成了两表的数据关联。

在分布式环境中，交易表和用户表想要以 userID 为 Join keys 进行关联，就必须要确保一个前提：交易记录和与之对应的用户信息在同一个 Executors 内。也就是说，如果用户黄小乙的购物信息都存储在 Executor 0，而个人属性信息缓存在 Executor 2，那么，在分布式环境中，这两种信息必须要凑到同一个进程里才能实现关联计算。

在不进行任何调优的情况下，Spark 默认采用 Shuffle Join 的方式来做到这一点。Shuffle Join 的过程主要有两步。

第一步就是对参与关联的左右表分别进行 Shuffle，Shuffle 的分区规则是先对 Join keys 计算哈希值，再把哈希值对分区数取模。由于左右表的分区数是一致的，因此 Shuffle 过后，一定能够保证 userID 相同的交易记录和用户数据坐落在同一个 Executors 内。

Shuffle Join中左右表的数据分发

Shuffle 完成之后，第二步就是在同一个 Executors 内，Reduce task 就可以对 userID 一致的记录进行关联操作。但是，由于交易表是事实表，数据体量异常庞大，对 TB 级别的数据进行 Shuffle，想想都觉得可怕！因此，上面对两个 DataFrame 直接关联的代码，还有很大的调优空间。我们该怎么做呢？话句话说，对于分布式环境中的数据关联来说，要想确保交易记录和与之对应的用户信息在同一个 Executors 中，我们有没有其他办法呢？

克制 Shuffle 的方式

还记得之前业务部门要求我们把用户表封装为广播变量，以备后用吗？现在它终于派上用场了！

import org.apache.spark.sql.functions.broadcast

val transactionsDF: DataFrame = _
val userDF: DataFrame = _

val bcUserDF = broadcast(userDF)
transactionsDF.join(bcUserDF, Seq(“userID”), “inner”)

Driver 从所有 Executors 收集 userDF 所属的所有数据分片，在本地汇总用户数据，然后给每一个 Executors 都发送一份全量数据的拷贝。既然每个 Executors 都有 userDF 的全量数据，这个时候，交易表的数据分区待在原地、保持不动，就可以轻松地关联到一致的用户数据。如此一来，我们不需要对数据体量巨大的交易表进行 Shuffle，同样可以在分布式环境中，完成两张表的数据关联。

Broadcast Join将小表广播，避免大表Shuffle

利用广播变量，我们成功地避免了海量数据在集群内的存储、分发，节省了原本由 Shuffle 引入的磁盘和网络开销，大幅提升运行时执行性能。当然，采用广播变量优化也是有成本的，毕竟广播变量的创建和分发，也是会带来网络开销的。但是，相比大表的全网分发，小表的网络开销几乎可以忽略不计。这种小投入、大产出，用极小的成本去博取高额的性能收益，真可以说是“四两拨千斤”！

小结

在数据关联场景中，广播变量是克制 Shuffle 的杀手锏。掌握了它，我们就能以极小的成本，获得高额的性能收益。关键是我们要掌握两种创建广播变量的方式。

第一种，从普通变量创建广播变量。在广播变量的运行机制下，普通变量存储的数据封装成广播变量，由 Driver 端以 Executors 为粒度进行分发，每一个 Executors 接收到广播变量之后，将其交由 BlockManager 管理。

第二种，从分布式数据集创建广播变量，这就要比第一种方式复杂一些了。第一步，Driver 需要从所有的 Executors 拉取数据分片，然后在本地构建全量数据；第二步，Driver 把汇总好的全量数据分发给各个 Executors，Executors 再将接收到的全量数据缓存到存储系统的 BlockManager 中。

结合这两种方式，我们在做数据关联的时候，把 Shuffle Joins 转换为 Broadcast Joins，就可以用小表广播来代替大表的全网分发，真正做到克制 Shuffle。

每日一练

Spark 广播机制现有的实现方式是存在隐患的，在数据量较大的情况下，Driver 可能会成为瓶颈，你能想到更好的方式来重新实现 Spark 的广播机制吗？（提示：SPARK-17556）

在什么情况下，不适合把 Shuffle Joins 转换为 Broadcast Joins？

期待在留言区看到你的思考和答案，我们下一讲见！

13 | 广播变量（二）：有哪些途径让Spark SQL选择Broadcast Joins？

你好，我是吴磊。

上一讲我们说到，在数据关联场景中，广播变量是克制 Shuffle 的杀手锏，用 Broadcast Joins 取代 Shuffle Joins 可以大幅提升执行性能。但是，很多同学只会使用默认的广播变量，不会去调优。那么，我们该怎么保证 Spark 在运行时优先选择 Broadcast Joins 策略呢？

今天这一讲，我就围绕着数据关联场景，从配置项和开发 API 两个方面，帮你梳理出两类调优手段，让你能够游刃有余地运用广播变量。

利用配置项强制广播

我们先来从配置项的角度说一说，有哪些办法可以让 Spark 优先选择 Broadcast Joins。在 Spark SQL 配置项那一讲，我们提到过 spark.sql.autoBroadcastJoinThreshold 这个配置项。它的设置值是存储大小，默认是 10MB。它的含义是，对于参与 Join 的两张表来说，任意一张表的尺寸小于 10MB，Spark 就在运行时采用 Broadcast Joins 的实现方式去做数据关联。另外，AQE 在运行时尝试动态调整 Join 策略时，也是基于这个参数来判定过滤后的数据表是否足够小，从而把原本的 Shuffle Joins 调整为 Broadcast Joins。

为了方便你理解，我来举个例子。在数据仓库中，我们经常会看到两张表：一张是订单事实表，为了方便，我们把它记成 Fact；另一张是用户维度表，记成 Dim。事实表体量很大在 100GB 量级，维度表很小在 1GB 左右。两张表的 Schema 如下所示：

//订单事实表Schema
orderID: Int
userID: Int
trxDate: Timestamp
productId: Int
price: Float
volume: Int

//用户维度表Schema
userID: Int
name: String
age: Int
gender: String

当 Fact 表和 Dim 表基于 userID 做关联的时候，由于两张表的尺寸大小都远超 spark.sql.autoBroadcastJoinThreshold 参数的默认值 10MB，因此 Spark 不得不选择 Shuffle Joins 的实现方式。但如果我们把这个参数的值调整为 2GB，因为 Dim 表的尺寸比 2GB 小，所以，Spark 在运行时会选择把 Dim 表封装到广播变量里，并采用 Broadcast Join 的方式来完成两张表的数据关联。

显然，对于绝大多数的 Join 场景来说，autoBroadcastJoinThreshold 参数的默认值 10MB 太低了，因为现在企业的数据体量都在 TB，甚至 PB 级别。因此，想要有效地利用 Broadcast Joins，我们需要把参数值调大，一般来说，2GB 左右是个不错的选择。

现在我们已经知道了，使用广播阈值配置项让 Spark 优先选择 Broadcast Joins 的关键，就是要确保至少有一张表的存储尺寸小于广播阈值。

但是，在设置广播阈值的时候，不少同学都跟我抱怨：“我的数据量明明小于 autoBroadcastJoinThreshold 参数设定的广播阈值，为什么 Spark SQL 在运行时并没有选择 Broadcast Joins 呢？”

详细了解后我才知道，这些同学所说的数据量，其实指的是数据表在磁盘上的存储大小，比如用ls或是du -sh等系统命令查看文件得到的结果。要知道，同一份数据在内存中的存储大小往往会比磁盘中的存储大小膨胀数倍，甚至十数倍。这主要有两方面原因。

一方面，为了提升存储和访问效率，开发者一般采用 Parquet 或是 ORC 等压缩格式把数据落盘。这些高压缩比的磁盘数据展开到内存之后，数据量往往会翻上数倍。

另一方面，受限于对象管理机制，在堆内内存中，JVM 往往需要比数据原始尺寸更大的内存空间来存储对象。

我们来举个例子，字符串“abcd”按理说只需要消耗 4 个字节，但是，JVM 在堆内存储这 4 个字符串总共需要消耗 48 个字节！那在运行时，一份看上去不大的磁盘数据展开到内存，翻上个 4、5 倍并不稀奇。因此，如果你按照磁盘上的存储大小去配置 autoBroadcastJoinThreshold 广播阈值，大概率也会遇到同样的窘境。

那么问题来了，有什么办法能准确地预估一张表在内存中的存储大小呢？

首先，我们要避开一个坑。我发现，有很多资料推荐用 Spark 内置的 SizeEstimator 去预估分布式数据集的存储大小。结合多次实战和踩坑经验，咱们必须要指出，SizeEstimator 的估算结果不准确。因此，你可以直接跳过这种方法，这也能节省你调优的时间和精力。

我认为比较靠谱的办法是：第一步，把要预估大小的数据表缓存到内存，比如直接在 DataFrame 或是 Dataset 上调用 cache 方法；第二步，读取 Spark SQL 执行计划的统计数据。这是因为，Spark SQL 在运行时，就是靠这些统计数据来制定和调整执行策略的。

val df: DataFrame = _
df.cache.count

val plan = df.queryExecution.logical
val estimated: BigInt = spark
.sessionState
.executePlan(plan)
.optimizedPlan
.stats
.sizeInBytes

你可能会说：“这种办法虽然精确，但是这么做，实际上已经是在运行时进行调优了。把数据先缓存到内存，再去计算它的存储尺寸，当然更准确了。”没错，采用这种计算方式，调优所需花费的时间和精力确实更多，但在很多情况下，尤其是 Shuffle Joins 的执行效率让你痛不欲生的时候，这样的付出是值得的。

利用 API 强制广播

既然数据量的预估这么麻烦，有没有什么办法，不需要配置广播阈值，就可以让 Spark SQL 选择 Broadcast Joins？还真有，而且办法还不止一种。

开发者可以通过 Join Hints 或是 SQL functions 中的 broadcast 函数，来强制 Spark SQL 在运行时采用 Broadcast Joins 的方式做数据关联。下面我就来分别讲一讲它们的含义和作用，以及该如何使用。必须要说明的是，这两种方式是等价的，并无优劣之分，只不过有了多样化的选择之后，你就可以根据自己的偏好和习惯来灵活地进行开发。

用 Join Hints 强制广播

Join Hints 中的 Hints 表示“提示”，它指的是在开发过程中使用特殊的语法，明确告知 Spark SQL 在运行时采用哪种 Join 策略。一旦你启用了 Join Hints，不管你的数据表是不是满足广播阈值，Spark SQL 都会尽可能地尊重你的意愿和选择，使用 Broadcast Joins 去完成数据关联。

我们来举个例子，假设有两张表，一张表的内存大小在 100GB 量级，另一张小一些，2GB 左右。在广播阈值被设置为 2GB 的情况下，并没有触发 Broadcast Joins，但我们又不想花费时间和精力去精确计算小表的内存占用到底是多大。在这种情况下，我们就可以用 Join Hints 来帮我们做优化，仅仅几句提示就可以帮我们达到目的。

val table1: DataFrame = spark.read.parquet(path1)
val table2: DataFrame = spark.read.parquet(path2)
table1.createOrReplaceTempView("t1")
table2.createOrReplaceTempView("t2")

val query: String = “select /*+ broadcast(t2) */ * from t1 inner join t2 on t1.key = t2.key”
val queryResutls: DataFrame = spark.sql(query)

你看，在上面的代码示例中，只要在 SQL 结构化查询语句里面加上一句/*+ broadcast(t2) */提示，我们就可以强制 Spark SQL 对小表 t2 进行广播，在运行时选择 Broadcast Joins 的实现方式。提示语句中的关键字，除了使用 broadcast 外，我们还可以用 broadcastjoin 或者 mapjoin，它们实现的效果都一样。

如果你不喜欢用 SQL 结构化查询语句，尤其不想频繁地在 Spark SQL 上下文中注册数据表，你也可以在 DataFrame 的 DSL 语法中使用 Join Hints。

table1.join(table2.hint(“broadcast”), Seq(“key”), “inner”)

在上面的 DSL 语句中，我们只要在 table2 上调用 hint 方法，然后指定 broadcast 关键字，就可以同样达到强制广播表 2 的效果。

总之，Join Hints 让开发者可以灵活地选择运行时的 Join 策略，对于熟悉业务、了解数据的同学来说，Join Hints 允许开发者把专家经验凌驾于 Spark SQL 的优化引擎之上，更好地服务业务。

不过，Join Hints 也有个小缺陷。如果关键字拼写错误，Spark SQL 在运行时并不会显示地抛出异常，而是默默地忽略掉拼写错误的 hints，假装它压根不存在。因此，在使用 Join Hints 的时候，需要我们在编译时自行确认 Debug 和纠错。

用 broadcast 函数强制广播

如果你不想等到运行时才发现问题，想让编译器帮你检查类似的拼写错误，那么你可以使用强制广播的第二种方式：broadcast 函数。这个函数是类库 org.apache.spark.sql.functions 中的 broadcast 函数。调用方式非常简单，比 Join Hints 还要方便，只需要用 broadcast 函数封装需要广播的数据表即可，如下所示。

import org.apache.spark.sql.functions.broadcast
table1.join(broadcast(table2), Seq(“key”), “inner”)

你可能会问：“既然开发者可以通过 Join Hints 和 broadcast 函数强制 Spark SQL 选择 Broadcast Joins，那我是不是就可以不用理会广播阈值的配置项了？”其实还真不是。我认为，以广播阈值配置为主，以强制广播为辅，往往是不错的选择。

广播阈值的设置，更多的是把选择权交给 Spark SQL，尤其是在 AQE 的机制下，动态 Join 策略调整需要这样的设置在运行时做出选择。强制广播更多的是开发者以专家经验去指导 Spark SQL 该如何选择运行时策略。二者相辅相成，并不冲突，开发者灵活地运用就能平衡 Spark SQL 优化策略与专家经验在应用中的比例。

广播变量不是银弹

不过，虽然我们一直在强调，数据关联场景中广播变量是克制 Shuffle 的杀手锏，但广播变量并不是银弹。

就像有的同学会说：“开发者有这么多选项，甚至可以强制 Spark 选择 Broadcast Joins，那我们是不是可以把所有 Join 操作都用 Broadcast Joins 来实现？”答案当然是否定的，广播变量不能解决所有的数据关联问题。

首先，从性能上来讲，Driver 在创建广播变量的过程中，需要拉取分布式数据集所有的数据分片。在这个过程中，网络开销和 Driver 内存会成为性能隐患。广播变量尺寸越大，额外引入的性能开销就会越多。更何况，如果广播变量大小超过 8GB，Spark 会直接抛异常中断任务执行。

其次，从功能上来讲，并不是所有的 Joins 类型都可以转换为 Broadcast Joins。一来，Broadcast Joins 不支持全连接（Full Outer Joins）；二来，在所有的数据关联中，我们不能广播基表。或者说，即便开发者强制广播基表，也无济于事。比如说，在左连接（Left Outer Join）中，我们只能广播右表；在右连接（Right Outer Join）中，我们只能广播左表。在下面的代码中，即便我们强制用 broadcast 函数进行广播，Spark SQL 在运行时还是会选择 Shuffle Joins。

import org.apache.spark.sql.functions.broadcast
broadcast (table1).join(table2, Seq(“key”), “left”)
table1.join(broadcast(table2), Seq(“key”), “right”)

小结

这一讲，我们总结了 2 种方法，让 Spark SQL 在运行时能够选择 Broadcast Joins 策略，分别是设置配置项和用 API 强制广播。

首先，设置配置项主要是设置 autoBroadcastJoinThreshold 配置项。开发者通过这个配置项指示 Spark SQL 优化器。只要参与 Join 的两张表中，有一张表的尺寸小于这个参数值，就在运行时采用 Broadcast Joins 的实现方式。

为了让 Spark SQL 采用 Broadcast Joins，开发者要做的，就是让数据表在内存中的尺寸小于 autoBroadcastJoinThreshold 参数的设定值。

除此之外，在设置广播阈值的时候，因为磁盘数据展开到内存的时候，存储大小会成倍增加，往往导致 Spark SQL 无法采用 Broadcast Joins 的策略。因此，我们在做数据关联的时候，还要先预估一张表在内存中的存储大小。一种精确的预估方法是先把 DataFrame 缓存，然后读取执行计划的统计数据。

其次，用 API 强制广播有两种方法，分别是设置 Join Hints 和用 broadcast 函数。设置 Join Hints 的方法就是在 SQL 结构化查询语句里面加上一句“/*+ broadcast(某表) */”的提示就可以了，这里的 broadcast 关键字也可以换成 broadcastjoin 或者 mapjoin。另外，你也可以在 DataFrame 的 DSL 语法中使用调用 hint 方法，指定 broadcast 关键字，来达到同样的效果。设置 broadcast 函数的方法非常简单，只要用 broadcast 函数封装需要广播的数据表就可以了。

总的来说，不管是设置配置项还是用 API 强制广播都有各自的优缺点，所以，以广播阈值配置为主、强制广播为辅，往往是一个不错的选择。

最后，不过，我们也要注意，广播变量不是银弹，它并不能解决所有的数据关联问题，所以在日常的开发工作中，你要注意避免滥用广播。

每日一练

除了 broadcast 关键字外，在 Spark 3.0 版本中，Join Hints 还支持哪些关联类型和关键字？

DataFrame 可以用 sparkContext.broadcast 函数来广播吗？它和 org.apache.spark.sql.functions.broadcast 函数之间的区别是什么？

期待在留言区看到你的思考和答案，我们下一讲见！

14 | CPU视角：如何高效地利用CPU？

你好，我是吴磊。

在日常的开发与调优工作中，总有同学向我抱怨：“为什么我的应用 CPU 利用率这么低？偌大的集群，CPU 利用率才 10%！”确实，较低的 CPU 利用率不仅对宝贵的硬件资源来说是一种非常大的浪费，也会让应用端到端的执行性能很难达到令人满意的效果。那么，在分布式应用开发中，我们到底该如何高效地利用 CPU？

我们说过，性能调优的最终目的，是在所有参与计算的硬件资源之间寻求协同与平衡，让硬件资源达到一种平衡、无瓶颈的状态。对于 CPU 来说，最需要协同和平衡的硬件资源非内存莫属。原因主要有两方面：一方面，在处理延迟方面，只有内存能望其项背；另一方面，在主板上内存通过数据总线直接向 CPU 寄存器供给数据。因此，理顺它们之间的关系，可以为性能调优奠定更好的基础。

那么，今天这一讲，我们就从硬件资源平衡的角度入手，去分析 CPU 与内存到底该如何合作。

CPU 与内存的平衡本质上是什么？

我们知道，Spark 将内存分成了 Execution Memory 和 Storage Memory 两类，分别用于分布式任务执行和 RDD 缓存。其中，RDD 缓存虽然最终占用的是 Storage Memory，但在 RDD 展开（Unroll）之前，计算任务消耗的还是 Execution Memory。因此，Spark 中 CPU 与内存的平衡，其实就是 CPU 与执行内存之间的协同与配比。

要想平衡 CPU 与执行内存之间的协同和配比，我们需要使用 3 类配置参数，它们分别控制着并行度、执行内存大小和集群的并行计算能力。只有它们设置得当，CPU 和执行内存才能同时得到充分利用。否则 CPU 与执行内存之间的平衡就会被打破，要么 CPU 工作不饱和，要么 OOM 内存溢出。

想要知道这 3 类参数都包含哪些具体的配置项，以及它们到底是怎么配置的，我们需要先弄清楚一些基础知识，也就是并行计算的线程之间是如何瓜分执行内存的。为了帮助你理解，我先来给你讲个故事。

黄小乙的如意算盘：并行计算的线程如何瓜分执行内存？

还记得地主招租的故事吗？与张麻子签订占地协议之后，黄小乙就开始盘算，自己分得的那块田地怎么经营才最划算。

他心想：“这么一大块地，我亲自种肯定划不来。一，我没有张麻子勤快；二，不管是种棉花还是咖啡都很耗时、费力，面朝黄土背朝天，我可耽误不起那功夫！不如，我把土地转让出去，让别人来种，我只管收购、销售，赚到的差价也够我吃穿不愁了！”

于是，他打定主意，贴出了一张告示。

黄小乙的告示

告示贴出去不到三天，十里八村的人都赶来承让土地，他们大部分都是吃苦耐劳的庄稼汉，一心想凭借这次机会改善生活，所以每个人恨不能把 500 顷的田地全都承让过来。

黄小乙见状，心中大喜，认为不仅自己的土地很快就可以被种满，还能名正言顺地去抢占张麻子的那块地。不过，也不能光图规模，还要确保棉花、咖啡的产出质量，更重要的是得想个办法让这种运作模式可持续。

于是，黄小乙追加了一项补充条款：“鉴于老乡们参与热情高涨，公平起见，我又制定了新的土地转让规则：首先，每位老乡能够获得的土地面积有上下限，它的具体数值由可耕种土地总面积和申请人数共同决定；其次，土地转让权的有效时间与农作物生长周期一致，一旦作物丰收，承让人需让出土地，如有意愿再次耕种需重新申请。”

土地转让规则

比如说，现阶段可耕种土地总面积已由 500 顷扩张为 800 顷（这是黄小乙就抢占了张麻子的地之后的土地总面积），如果有 400 位老乡申请土地转让权，那么每位老乡最高可得 2 顷（800/400）的土地，最低可得 1 顷（800/400/2）土地。也就是说，如果老乡人数为 N，那么每位老乡能够获得的土地面积会在（1/N/2，1/N）之间浮动。

这个规定大伙儿都心服口服，没过多久，800 顷土地就全部转让完了。一笔多赢的买卖让大伙都能各取所需，也让老谋深算的黄四郎都不禁心挑大指，感叹道“真是长江水后浪催前浪，一代新人换旧人！”

好啦，故事到这里暂时告一段落，但是黄小乙这份如意算盘和今天要讲的内容有什么关系呢？

我们讲过，黄小乙租赁的土地类比的是内存区域中的 Execution Memory。在今天的故事里，黄小乙招募的棉农和咖啡农对应的就是，Executor 线程池中一个又一个执行分布式任务的线程。土地出让规则对应的就是，任务并发过程中多个线程抢占内存资源时需要遵循的基本逻辑。

那么，执行内存抢占规则就是，在同一个 Executor 中，当有多个（记为 N）线程尝试抢占执行内存时，需要遵循 2 条基本原则：

执行内存总大小（记为 M）为两部分之和，一部分是 Execution Memory 初始大小，另一部分是 Storage Memory 剩余空间

每个线程分到的可用内存有一定的上下限，下限是 M/N/2，上限是 M/N，也就是均值

三足鼎立：并行度、并发度与执行内存

理清了线程与执行内存的关系之后，我们再来说说与并发度、执行内存和并行度这三者对应的 3 类配置项分别是什么，以及它们如何影响 CPU 与计算内存之间的平衡。

3 类配置项

我们讲过，并行度指的是为了实现分布式计算，分布式数据集被划分出来的份数。并行度明确了数据划分的粒度：并行度越高，数据的粒度越细，数据分片越多，数据越分散。

并行度可以通过两个参数来设置，分别是 spark.default.parallelism 和 spark.sql.shuffle.partitions。前者用于设置 RDD 的默认并行度，后者在 Spark SQL 开发框架下，指定了 Shuffle Reduce 阶段默认的并行度。

那什么是并发度呢？我们在配置项那一讲提到过，Executor 的线程池大小由参数 spark.executor.cores 决定，每个任务在执行期间需要消耗的线程数由 spark.task.cpus 配置项给定。两者相除得到的商就是并发度，也就是同一时间内，一个 Executor 内部可以同时运行的最大任务数量。又因为，spark.task.cpus 默认数值为 1，并且通常不需要调整，所以，并发度基本由 spark.executor.cores 参数敲定。

就 Executor 的线程池来说，尽管线程本身可以复用，但每个线程在同一时间只能计算一个任务，每个任务负责处理一个数据分片。因此，在运行时，线程、任务与分区是一一对应的关系。

分布式任务由 Driver 分发到 Executor 后，Executor 将 Task 封装为 TaskRunner，然后将其交给可回收缓存线程池（newCachedThreadPool）。线程池中的线程领取到 TaskRunner 之后，向 Execution Memory 申请内存，然后开始执行任务。

如果我们把棉农、咖啡农类比 CPU 线程，那么 TaskRunner 就可以理解为锄具，Task 要处理的数据分片可以理解为作物种子。有了锄具和种子之后，老乡们得去黄小乙那儿申请块地，才能开始耕种。

最后，我们再来说说执行内存。黄小乙的地就是执行内存，堆内执行内存的初始值由很多参数共同决定，具体的计算公式是：spark.executor.memory * spark.memory.fraction * (1 - spark.memory.storageFraction)。相比之下，堆外执行内存的计算稍微简单一些：spark.memory.offHeap.size * (1 - spark.memory.storageFraction)。

除此之外，在统一内存管理模式下，在 Storage Memory 没有被 RDD 缓存占满的情况下，执行任务可以动态地抢占 Storage Memory。因此，在计算可用于分配给执行任务的内存总量时，还要把有希望抢占过来的这部分内存空间考虑进来。这也是为什么黄小乙的可耕种土地总面积，会从最开始的 500 顷逐渐扩展到 800 顷。

由此可见，可分配的执行内存总量会随着缓存任务和执行任务的此消彼长，而动态变化。但无论怎么变，可用的执行内存总量，都不会低于配置项设定的初始值。

好啦，搞明白并行度、并发度和执行内存的概念，以及各自的配置项之后，我们再通过两个经常影响 CPU 利用率的例子，来说说它们是怎么影响 CPU 与计算内存之间的平衡的，由此总结出提升 CPU 利用率的办法。

CPU 低效原因之一：线程挂起

在给定执行内存总量 M 和线程总数 N 的情况下，为了保证每个线程都有机会拿到适量的内存去处理数据，Spark 用 HashMap 数据结构，以（Key，Value）的方式来记录每个线程消耗的内存大小，并确保所有的 Value 值都不超过 M/N。在一些极端情况下，有些线程申请不到所需的内存空间，能拿到的内存合计还不到 M/N/2。这个时候，Spark 就会把线程挂起，直到其他线程释放了足够的内存空间为止。

你可能会问：“既然每个线程能拿到的内存上限是 M/N，也就是内存总量对线程数取平均值，为什么还会出现有的线程连 M/N/2 都拿不到呢？这在数学上也不成立呀！”这是个好问题。这种情况的出现，源于 3 方面的变化和作用：

动态变化的执行内存总量 M

动态变化的并发度 N~

分布式数据集的数据分布

首先，动态变化的执行内存总量 M 我们刚刚已经说过了。M 的下限是 Execution Memory 初始值，上限是 spark.executor.memory * spark.memory.fraction 划定的所有内存区域。在应用刚刚开始执行的时候，M 的取值就是这个上限，但随着 RDD 缓存逐渐填充 Storage Memory，M 的取值也会跟着回撤。

另外，到目前为止，（1/N/2，1/N）上下限的计算我们用的都是线程总数 N，线程总数 N 是固定的。N 的取值含义是一个 Executor 内最大的并发度，更严格的计算公式是 spark.executor.cores 除以 spark.task.cpus。但实际上，上下限公式的计算用的不是 N，而是 N~。N~ 的含义是 Executor 内当前的并发度，也就是 Executor 中当前并行执行的任务数。显然 N~ <= N。

换句话说，尽管一个 Executor 中有 N 个 CPU 线程，但这 N 个线程不一定都在干活。在 Spark 任务调度的过程中，这 N 个线程不见得能同时拿到分布式任务，所以先拿到任务的线程就有机会申请到更多的内存。在某些极端的情况下，后拿到任务的线程甚至连一寸内存都申请不到。不过，随着任务执行和任务调度的推进，N~ 会迅速地趋近于 N，CPU 线程挂起和内存分配的情况也会逐渐得到改善。

就像黄小乙的补充条款中举的那个例子一样，当可耕种土地总面积为 800 顷的时候，如果有 400 位老乡申请土地转让权，那么每位老乡最多可得 800/400=2 顷土地，最低可得 800/400/2=1 顷土地。

但如果这 400 位老乡不是同时来的，而是分两批来的，每批来 200 人的话，就会出现问题。按照他的规则，先来的这 200 位老乡，每人最多可得 800/200 = 4 顷土地。咱们前面说了，每个申请的老乡都想通过这次机会发点小财，于是这 200 位老乡每人都申请了 4 顷地，黄小乙的地一下子就被分光了！后来的 200 位老乡就没地可种了，他们只能等到第一批老乡的棉花和咖啡丰收了，再重新一起申请土地转让权。

假设第一批老乡同时大丰收，按照黄小乙转让规则的第一条，第一批老乡要交出土地使用权，如果想要继续耕种的话，就得和第二批老乡一起重新申请。在这种情况下，上下限的计算才是黄小乙最开始举例的那种算法。

第三个影响任务并发度和内存分配的因素，是分布式数据集的分布情况。在刚才的例子中，如果第一批老乡每人只申请 2 顷土地，那么第二批老乡来了之后依然有地可种。每人申请多大的土地，取决于他手里有多少农作物种子，我们之前把每个 Task 需要处理的数据分片比作是作物种子，那么，数据分片的数据量决定了执行任务需要申请多少内存。如果分布式数据集的并行度设置得当，因任务调度滞后而导致的线程挂起问题就会得到缓解。

CPU 低效原因之二：调度开销

线程挂起的问题得到缓解，CPU 利用率就会有所改善。既然如此，是不是把并行度设置到最大，每个数据分片就都能足够小，小到每个 CPU 线程都能申请到内存，线程不再挂起就万事大吉了呢？

当然不是，并行度足够大，确实会让数据分片更分散、数据粒度更细，因此，每个执行任务所需消耗的内存更少。但是，数据过于分散会带来严重的副作用：调度开销骤增。

对于每一个分布式任务，Dirver 会将其封装为 TaskDescription，然后分发给各个 Executor。TaskDescription 包含着与任务运行有关的所有信息，如任务 ID、尝试 ID、要处理的数据分片 ID、开发者添加的本地文件和 Jar 包、任务属性、序列化的任务代码等等。Executor 接收到 TaskDescription 之后，首先需要对 TaskDescription 反序列化才能读取任务信息，然后将任务代码再反序列化得到可执行代码，最后再结合其他任务信息创建 TaskRunner。

因此你看，每个任务的调度与执行都需要 Executor 消耗 CPU 去执行上述一系列的操作步骤。数据分片与线程、执行任务一一对应，当数据过于分散，分布式任务数量会大幅增加，但每个任务需要处理的数据量却少之又少，就 CPU 消耗来说，相比花在数据处理上的比例，任务调度上的开销几乎与之分庭抗礼。显然，在这种情况下，CPU 的有效利用率也是极低的。

如何优化 CPU 利用率？

你可能会说：“这也太尴尬了，并行度低了不行，容易让 CPU 线程挂起；高了也不行，调度开销太大，CPU 有效利用率也不高。高也不行、低也不行，那我该怎么办呢？”

咱们不妨来算笔账。我们还是拿黄小乙的如意算盘来举例，如果 400 个老乡同时来申请他的 800 顷地，那么每个老乡能分到 1 到 2 顷土地不等。相应地，每位老乡需要购买的种子应该刚好够种满 1 到 2 顷地。因为，买多了种不下，买少了还亏。假设洼子村农产品交易市场的种子总量刚好够种 1000 顷地，从卖家的视角出发，这些种子应该售卖 1000/2 =500 到 1000/1 = 1000 次，才能赚到最多的钱。

因此，在给定 Executor 线程池和执行内存大小的时候，我们可以参考上面的算法，去计算一个能够让数据分片平均大小在（M/N/2, M/N）之间的并行度，这往往是个不错的选择。

总的来说，对 CPU 利用率来说，并行度、并发度与执行内存的关系就好像是一尊盛满沸水的三足鼎，三足齐平则万事大吉，但凡哪一方瘸腿儿，鼎内的沸水就会倾出伤及无辜。

三足鼎立

小结

今天这一讲，我们从 CPU 与执行内存平衡的角度，通过梳理 Executor 并行度、并发度和执行内存之间的关系，以及它们对 CPU 利用率的影响，总结出了有效提升 CPU 利用率的方法。

首先，在一个 Executor 中，每个 CPU 线程能够申请到的内存比例是有上下限的，最高不超过 1/N，最低不少于 1/N/2，其中 N 代表线程池大小。

其次，在给定线程池大小和执行内存的时候，并行度较低、数据分片较大容易导致 CPU 线程挂起，线程频繁挂起不利于提升 CPU 利用率，而并行度过高、数据过于分散会让调度开销更显著，也利于提升 CPU 利用率。

最后，在给定执行内存 M、线程池大小 N 和数据总量 D 的时候，想要有效地提升 CPU 利用率，我们就要计算出最佳并行度 P，计算方法是让数据分片的平均大小 D/P 坐落在（M/N/2, M/N）区间。这样，在运行时，我们的 CPU 利用率往往不会太差。

每日一练

从 Executor 并发度、执行内存大小和分布式任务并行度出发，你认为在什么情况下会出现 OOM 的问题？

由于执行内存总量 M 是动态变化的，并发任务数 N~ 也是动态变化的，因此每个线程申请内存的上下限也是动态调整的，你知道这个调整周期以什么为准？

期待在留言区看到你的思考和答案，如果你的朋友也在为提高 CPU 利用率苦恼，欢迎你把这一讲转发给他，我们下一讲见！

15 | 内存视角（一）：如何最大化内存的使用效率？

你好，我是吴磊。

上一讲我们说，想要提升 CPU 利用率，最重要的就是合理分配执行内存，但是，执行内存只是 Spark 内存分区的一部分。因此，想要合理分配执行内存，我们必须先从整体上合理划分好 Spark 所有的内存区域。

可在实际开发应用的时候，身边有不少同学向我抱怨：“Spark 划分不同内存区域的原理我都知道，但我还是不知道不同内存区域的大小该怎么设置，纠结来、纠结去。最后，所有跟内存有关的配置项，我还是保留了默认值。”

这种不能把原理和实践结合起来的情况很常见，所以今天这一讲，我就从熟悉的 Label Encoding 实例出发，一步步带你去分析不同情况下，不同内存区域的调整办法，帮你归纳出最大化内存利用率的常规步骤。这样，你在调整内存的时候，就能结合应用的需要，做到有章可循、有的放矢。

从一个实例开始

我们先来回顾一下第 5 讲中讲过的 Label Encoding。在 Label Encoding 的业务场景中，我们需要对用户兴趣特征做 Encoding。依据模板中兴趣字符串及其索引位置，我们的任务是把千亿条样本中的用户兴趣转换为对应的索引值。模板文件的内容示例如下所示。

//模板文件
//用户兴趣
体育-篮球-NBA-湖人
军事-武器-步枪-AK47

实现的代码如下所示，注意啦，这里的代码是第 5 讲中优化后的版本。

/**
输入参数：模板文件路径，用户兴趣字符串
返回值：用户兴趣字符串对应的索引值
*/
//函数定义
val findIndex: (String) => (String) => Int = {
(filePath) =>
val source = Source.fromFile(filePath, "UTF-8")
val lines = source.getLines().toArray
source.close()
val searchMap = lines.zip(0 until lines.size).toMap
(interest) => searchMap.getOrElse(interest, -1)
}
val partFunc = findIndex(filePath)

//Dataset中的函数调用
partFunc("体育-篮球-NBA-湖人")

下面，咱们先一起回顾一下代码实现思路，再来分析它目前存在的性能隐患，最后去探讨优化它的方法。

首先，findIndex 函数的主体逻辑比较简单，就是读取模板文件和构建 Map 映射，以及查找用户兴趣并返回索引。不过，findIndex 函数被定义成了高阶函数。这样一来，当以模板文件为实参调用这个高阶函数的时候，我们会得到一个内置了 Map 查找字典的标量函数 partFunc，最后在千亿样本上调用 partFunc 完成数据转换。利用高阶函数，我们就避免了让 Executor 中的每一个 Task 去读取模板文件，以及从头构建 Map 字典这种执行低效的做法。

在运行时，这个函数在 Driver 端会被封装到一个又一个的 Task 中去，随后 Driver 把这些 Task 分发到 Executor，Executor 接收到任务之后，交由线程池去执行（调度系统的内容可以回顾第 5 讲）。这个时候，每个 Task 就像是一架架小飞机，携带着代码“乘客”和数据“行李”，从 Driver 飞往 Executor。Task 小飞机在 Executor 机场着陆之后，代码“乘客”乘坐出租车或是机场大巴，去往 JVM stack；数据“行李”则由专人堆放在 JVM Heap，也就是我们常说的堆内内存。

回顾 Label encoding 中的 findIndex 函数不难发现，其中大部分都是代码“乘客”，唯一的数据“行李”是名为 searchMap 的 Map 字典。像这样用户自定义的数据结构，消耗的内存区域就是堆内内存的 User Memory（Spark 对内存区域的划分内容可以回顾一下第 7 讲）。

User Memory 性能隐患

回顾到这里，你觉得 findIndex 函数有没有性能隐患呢？你可以先自己思考一下，有了答案之后再来看我下面的分析。

答案当然是“有”。首先，每架小飞机都携带这么一份数据“大件行李”，自然需要消耗更多的“燃油”，这里的“燃油”指的是 Task 分发过程中带来的网络开销。其次，因为每架小飞机着陆之后，都会在 Executor 的“旅客行李专区”User Memory 寄存上这份同样的数据“行李”，所以，User Memory 需要确保有足够的空间可以寄存所有旅客的行李，也就是大量的重复数据。

那么，User Memory 到底需要准备出多大的内存空间才行呢？我们不妨来算一算。这样的计算并不难，只需要用飞机架次乘以行李大小就可以了。

用户自定义的数据结构往往是用于辅助函数完成计算任务的，所以函数执行完毕之后，它携带的数据结构的生命周期也就告一段落。因此，在 Task 的数量统计上，我们不必在意一个 Executor 总共需要处理多少个 Task，只需要关注它在同一时间可以并行处理的 Task 数量，也就是 Executor 的线程池大小即可。

我们说过，Executor 线程池大小由 spark.executor.cores 和 spark.task.cpus 这两个参数的商（spark.executor.cores/spark.task.cpus）决定，我们暂且把这个商记作 #threads。

接下来是估算数据“行李”大小，由于 searchMap 并不是分布式数据集，因此我们不必采用先 Cache，再提取 Spark 执行计划统计信息的方式。对于这样的 Java 数据结构，我们完全可以在 REPL 中，通过 Java 的常规方法估算数据存储大小，估算得到的 searchMap 大小记为 #size。

好啦！现在，我们可以算出，User Memory 至少需要提供 #threads * #size 这么大的内存空间，才能支持分布式任务完成计算。但是，对于 User Memory 内存区域来说，使用 #threads * #size 的空间去重复存储同样的数据，本身就是降低了内存的利用率。那么，我们该怎么省掉 #threads * #size 的内存消耗呢？

性能调优

学习过广播变量之后，想必你头脑中已经有了思路。没错，咱们可以尝试使用广播变量，来对示例中的代码进行优化。

仔细观察 findIndex 函数，我们不难发现，函数的核心计算逻辑有两点。一是读取模板文件、创建 Map 映射字典；二是以给定字符串对字典进行查找，并返回查找结果。显然，千亿样本转换的核心需求是其中的第二个环节。既然如此，我们完全可以把创建好的 Map 字典封装成广播变量，然后分发到各个 Executors 中去。

有了广播变量的帮忙，凡是发往同一个 Executor 的 Task 小飞机，都无需亲自携带数据“行李”，这些大件行李会由“联邦广播快递公司”派货机专门发往各个 Executors，Driver 和每个 Executors 之间，都有一班这样的货运专线。思路说完了，优化后的代码如下所示。

/**
广播变量实现方式
*/
//定义广播变量
val source = Source.fromFile(filePath, "UTF-8")
val lines = source.getLines().toArray
source.close()
val searchMap = lines.zip(0 until lines.size).toMap
val bcSearchMap = sparkSession.sparkContext.broadcast(searchMap)

//在Dataset中访问广播变量
bcSearchMap.value.getOrElse("体育-篮球-NBA-湖人", -1)

上面代码的实现思路很简单：第一步还是读取模板文件、创建 Map 字典；第二步，把 Map 字典封装为广播变量。这样一来，在对千亿样本进行转换时，我们直接通过 bcSearchMap.value 读取广播变量内容，然后，通过调用 Map 字典的 getOrElse 方法来获取用户兴趣对应的索引值。

相比最开始的第一种实现方式，第二种实现方式的代码改动还是比较小的，那这一版代码对内存的消耗情况有什么改进呢？

我们发现，Task 小飞机的代码“乘客”换人了！小飞机之前需要携带函数 findIndex，现在则换成了一位“匿名的乘客”：一个读取广播变量并调用其 getOrElse 方法的匿名函数。由于这位“匿名的乘客”将大件行李托运给了“联邦广播快递公司”的专用货机，因此，Task 小飞机着陆后，没有任何“行李”需要寄存到 User Memory。换句话说，优化后的版本不会对 User Memory 内存区域进行占用，所以第一种实现方式中 #threads * #size 的内存消耗就可以省掉了。

Storage Memory 规划

这样一来，原来的内存消耗转嫁到了广播变量身上。但是，广播变量也会消耗内存，这会不会带来新的性能隐患呢？那我们就来看看，广播变量消耗的具体是哪块内存区域。

回顾存储系统那一讲，我们说过，Spark 存储系统主要有 3 个服务对象，分别是 Shuffle 中间文件、RDD 缓存和广播变量。它们都由 Executor 上的 BlockManager 进行管理，对于数据在内存和磁盘中的存储，BlockManager 利用 MemoryStore 和 DiskStore 进行抽象和封装。

那么，广播变量所携带的数据内容会物化到 MemoryStore 中去，以 Executor 为粒度为所有 Task 提供唯一的一份数据拷贝。MemoryStore 产生的内存占用会被记入到 Storage Memory 的账上。因此，广播变量消耗的就是 Storage Memory 内存区域。

接下来，我们再来盘算一下，第二种实现方式究竟需要耗费多少内存空间。由于广播变量的分发和存储以 Executor 为粒度，因此每个 Executor 消耗的内存空间，就是 searchMap 一份数据拷贝的大小。searchMap 的大小我们刚刚计算过就是 #size。

明确了 Storage Memory 内存区域的具体消耗之后，我们自然可以根据公式：（spark.executor.memory – 300MB）* spark.memory.fraction * spark.memory.storageFraction 去有针对性地调节相关的内存配置项。

内存规划两步走

现在，咱们在两份不同的代码实现下，分别定量分析了不同内存区域的消耗与占用。对于这些消耗做到心中有数，我们自然就能够相应地去调整相关的配置项参数。基于这样的思路，想要最大化内存利用率，我们需要遵循两个步骤：

预估内存占用

调整内存配置项

我们以堆内内存为例，来讲一讲内存规划的两步走具体该如何操作。我们都知道，堆内内存划分为 Reserved Memory、User Memory、Storage Memory 和 Execution Memory 这 4 个区域。预留内存固定为 300MB，不用理会，其他 3 个区域需要你去规划。

预估内存占用

首先，我们来说内存占用的预估，主要分为三步。

第一步，计算 User Memory 的内存消耗。我们先汇总应用中包含的自定义数据结构，并估算这些对象的总大小 #size，然后用 #size 乘以 Executor 的线程池大小，即可得到 User Memory 区域的内存消耗 #User。

第二步，计算 Storage Memory 的内存消耗。我们先汇总应用中涉及的广播变量和分布式数据集缓存，分别估算这两类对象的总大小，分别记为 #bc、#cache。另外，我们把集群中的 Executors 总数记作 #E。这样，每个 Executor 中 Storage Memory 区域的内存消耗的公式就是：#Storage = #bc + #cache / #E。

第三步，计算执行内存的消耗。学习上一讲，我们知道执行内存的消耗与多个因素有关。第一个因素是 Executor 线程池大小 #threads，第二个因素是数据分片大小，而数据分片大小取决于数据集尺寸 #dataset 和并行度 #N。因此，每个 Executor 中执行内存的消耗的计算公式为：#Execution = #threads * #dataset / #N。

调整内存配置项

得到这 3 个内存区域的预估大小 #User、#Storage、#Execution 之后，调整相关的内存配置项就是一件水到渠成的事情（由公式（spark.executor.memory – 300MB）* spark.memory.fraction * spark.memory.storageFraction）可知），这里我们也可以分为 3 步。

首先，根据定义，spark.memory.fraction 可以由公式（#Storage + #Execution）/（#User + #Storage + #Execution）计算得到。

同理，spark.memory.storageFraction 的数值应该参考（#Storage）/（#Storage + #Execution）。

最后，对于 Executor 堆内内存总大小 spark.executor.memory 的设置，我们自然要参考 4 个内存区域的总消耗，也就是 300MB + #User + #Storage + #Execution。不过，我们要注意，利用这个公式计算的前提是，不同内存区域的占比与不同类型的数据消耗一致。

总的来说，在内存规划的两步走中，第一步预估不同区域的内存占比尤为关键，因为第二步中参数的调整完全取决于第一步的预估结果。如果你按照这两个步骤去设置相关的内存配置项，相信你的应用在运行时就能够充分利用不同的内存区域，避免出现因参数设置不当而导致的内存浪费现象，从而在整体上提升内存利用率。

小结

合理划分 Spark 所有的内存区域，是同时提升 CPU 与内存利用率的基础。因此，掌握内存规划很重要，在今天这一讲，我们把内存规划归纳为两步走。

第一步是预估内存占用。

求出 User Memory 区域的内存消耗，公式为：#User=#size 乘以 Executor 线程池的大小。

求出每个 Executor 中 Storage Memory 区域的内存消耗，公式为：#Storage = #bc + #cache / #E。

求出执行内存区域的内存消耗，公式为：#Execution = #threads * #dataset / #N。

第二步是调整内存配置项：根据公式得到的 3 个内存区域的预估大小 #User、#Storage、#Execution，去调整（spark.executor.memory – 300MB）* spark.memory.fraction * spark.memory.storageFraction 公式中涉及的所有配置项。

spark.memory.fraction 可以由公式（#Storage + #Execution）/（#User + #Storage + #Execution）计算得到。

spark.memory.storageFraction 的数值应该参考（#Storage）/（#Storage + #Execution）。

spark.executor.memory 的设置，可以通过公式 300MB + #User + #Storage + #Execution 得到。

这里，我还想多说几句，内存规划两步走终归只是手段，它最终要达到的效果和目的，是确保不同内存区域的占比与不同类型的数据消耗保持一致，从而实现内存利用率的最大化。

每日一练

你知道估算 Java 对象存储大小的方法有哪些吗？不同的方法又有哪些优、劣势呢？

对于内存规划的第一步来说，要精确地预估运行时每一个区域的内存消耗，很费时、费力，调优的成本很高。如果我们想省略掉第一步的精确计算，你知道有哪些方法能够粗略、快速地预估不同内存区域的消耗占比吗？

期待在留言区看到你的思考和答案，我们下一讲见！

16 | 内存视角（二）：如何有效避免Cache滥用？

你好，我是吴磊。

在 Spark 的应用开发中，有效利用 Cache 往往能大幅提升执行性能。

但某一天，有位同学却和我说，自己加了 Cache 之后，执行性能反而变差了。仔细看了这位同学的代码之后，我吓了一跳。代码中充斥着大量的.cache，无论是 RDD，还是 DataFrame，但凡有分布式数据集的地方，后面几乎都跟着个.cache。显然，Cache 滥用是执行性能变差的始作俑者。

实际上，在有些场景中，Cache 是灵丹妙药，而在另一些场合，大肆使用 Cache 却成了饮鸩止渴。那 Cache 到底该在什么时候用、怎么用，都有哪些注意事项呢？今天这一讲，我们先一起回顾 Cache 的工作原理，再来回答这些问题。

Cache 的工作原理

在存储系统那一讲，我们其实介绍过 RDD 的缓存过程，只不过当时的视角是以 MemoryStore 为中心，目的在于理解存储系统的工作原理，今天咱们把重点重新聚焦到缓存上来。

Spark 的 Cache 机制主要有 3 个方面需要我们掌握，它们分别是：

缓存的存储级别：它限定了数据缓存的存储介质，如内存、磁盘等

缓存的计算过程：从 RDD 展开到分片以 Block 的形式，存储于内存或磁盘的过程

缓存的销毁过程：缓存数据以主动或是被动的方式，被驱逐出内存或是磁盘的过程

下面，我们一一来看。

存储级别

Spark 中的 Cache 支持很多种存储级别，比如 MEMORY_AND_DISK_SER_2、MEMORY_ONLY 等等。这些长得差不多的字符串我们该怎么记忆和区分呢？其实，每一种存储级别都包含 3 个基本要素。

存储介质：内存还是磁盘，或是两者都有。

存储形式：对象值还是序列化的字节数组，带 SER 字样的表示以序列化方式存储，不带 SER 则表示采用对象值。

副本数量：存储级别名字最后的数字代表拷贝数量，没有数字默认为 1 份副本。

Cache存储级别

当我们对五花八门的存储级别拆解之后就会发现，它们不过是存储介质、存储形式和副本数量这 3 类不同基本元素的排列组合而已。我在上表中列出了目前 Spark 支持的所有存储级别，你可以通过它加深理解。

尽管缓存级别多得让人眼花缭乱，但实际上最常用的只有两个：MEMORY_ONLY 和 MEMORY_AND_DISK，它们分别是 RDD 缓存和 DataFrame 缓存的默认存储级别。在日常的开发工作中，当你在 RDD 和 DataFrame 之上调用.cache函数时，Spark 默认采用的就是 MEMORY_ONLY 和 MEMORY_AND_DISK。

缓存的计算过程

在 MEMORY_AND_DISK 模式下，Spark 会优先尝试把数据集全部缓存到内存，内存不足的情况下，再把剩余的数据落盘到本地。MEMORY_ONLY 则不管内存是否充足，而是一股脑地把数据往内存里塞，即便内存不够也不会落盘。不难发现，这两种存储级别都是先尝试把数据缓存到内存。数据在内存中的存储过程我们在第 6 讲中讲过了，这里我们再一起回顾一下。

分布式数据集缓存到内存

无论是 RDD 还是 DataFrame，它们的数据分片都是以迭代器 Iterator 的形式存储的。因此，要把数据缓存下来，我们先得把迭代器展开成实实在在的数据值，这一步叫做 Unroll，如步骤 1 所示。展开的对象值暂时存储在一个叫做 ValuesHolder 的数据结构里，然后转换为 MemoryEntry。转换的实现方式是 toArray，因此它不产生额外的内存开销，这一步转换叫做 Transfer，如步骤 2 所示。最终，MemoryEntry 和与之对应的 BlockID，以 Key、Value 的形式存储到哈希字典（LinkedHashMap）中，如图中的步骤 3 所示。

当分布式数据集所有的数据分片都从 Unroll 到 Transfer，再到注册哈希字典之后，数据在内存中的缓存过程就宣告完毕。

缓存的销毁过程

但是很多情况下，应用中数据缓存的需求会超过 Storage Memory 区域的空间供给。虽然缓存任务可以抢占 Execution Memory 区域的空间，但“出来混，迟早是要还的”，随着执行任务的推进，缓存任务抢占的内存空间还是要“吐”出来。这个时候，Spark 就要执行缓存的销毁过程。

你不妨把 Storage Memory 想象成一家火爆的网红餐厅，待缓存的数据分片是一位又一位等待就餐的顾客。当需求大于供给，顾客数量远超餐位数量的时候，Spark 自然要制定一些规则，来合理地“驱逐”那些尸位素餐的顾客，把位置腾出来及时服务那些排队等餐的人。

那么问题来了，Spark 基于什么规则“驱逐”顾客呢？接下来，我就以同时缓存多个分布式数据集的情况为例，带你去分析一下在内存受限的情况下会发生什么。

我们用一张图来演示这个过程，假设 MemoryStore 中存有 4 个 RDD/Data Frame 的缓存数据，这 4 个分布式数据集各自缓存了一些数据分片之后，Storage Memory 区域就被占满了。当 RDD1 尝试把第 6 个分片缓存到 MemoryStore 时，却发现内存不足，塞不进去了。

这种情况下，Spark 就会逐一清除一些“尸位素餐”的 MemoryEntry 来释放内存，从而获取更多的可用空间来存储新的数据分片。这个过程叫做 Eviction，它的中文翻译还是蛮形象的，就叫做驱逐，也就是把 MemoryStore 中那些倒霉的 MemoryEntry 驱逐出内存。

多个分布式数据集同时缓存到内存

回到刚才的问题，Spark 是根据什么规则选中的这些倒霉蛋呢？这个规则叫作 LRU（Least Recently Used），基于这个算法，最近访问频率最低的那个家伙就是倒霉蛋。因为LRU是比较基础的数据结构算法，笔试、面试的时候经常会考，所以它的概念我就不多说了。

我们要知道的是，Spark 是如何实现 LRU 的。这里，Spark 使用了一个巧妙的数据结构：LinkedHashMap，这种数据结构天然地支持 LRU 算法。

LinkedHashMap 使用两个数据结构来维护数据，一个是传统的 HashMap，另一个是双向链表。HashMap 的用途在于快速访问，根据指定的 BlockId，HashMap 以 O(1) 的效率返回 MemoryEntry。双向链表则不同，它主要用于维护元素（也就是 BlockId 和 MemoryEntry 键值对）的访问顺序。凡是被访问过的元素，无论是插入、读取还是更新都会被放置到链表的尾部。因此，链表头部保存的刚好都是“最近最少访问”的元素。

如此一来，当内存不足需要驱逐缓存的数据块时，Spark 只利用 LinkedHashMap 就可以做到按照“最近最少访问”的原则，去依次驱逐缓存中的数据分片了。

除此之外，在存储系统那一讲，有同学问 MemoryStore 为什么使用 LinkedHashMap，而不用普通的 Map 来存储 BlockId 和 MemoryEntry 的键值对。我刚才说的就是答案了。

回到图中的例子，当 RDD1 试图缓存第 6 个数据分片，但可用内存空间不足时，Spark 会对 LinkedHashMap 从头至尾扫描，边扫描边记录 MemoryEntry 大小，当倒霉蛋的总大小超过第 6 个数据分片时，Spark 停止扫描。

有意思的是，倒霉蛋的选取规则遵循“兔子不吃窝边草”，同属一个 RDD 的 MemoryEntry 不会被选中。就像图中的步骤 4 展示的一样，第一个蓝色的 MemoryEntry 会被跳过，紧随其后打叉的两个 MemoryEntry 被选中。

因此，总结下来，在清除缓存的过程中，Spark 遵循两个基本原则：

LRU：按照元素的访问顺序，优先清除那些“最近最少访问”的 BlockId、MemoryEntry 键值对

兔子不吃窝边草：在清除的过程中，同属一个 RDD 的 MemoryEntry 拥有“赦免权”

退化为 MapReduce

尽管有缓存销毁这个环节的存在，Storage Memory 内存空间也总会耗尽，MemoryStore 也总会“驱无可驱”。这个时候，MEMORY_ONLY 模式就会放弃剩余的数据分片。比如，在 Spark UI 上，你时常会看到 Storage Tab 中的缓存比例低于 100%。而我们从 Storage Tab 也可以观察到，在 MEMORY_AND_DISK 模式下，数据集在内存和磁盘中各占一部分比例。

这是因为对于 MEMORY_AND_DISK 存储级别来说，当内存不足以容纳所有的 RDD 数据分片的时候，Spark 会把尚未展开的 RDD 分片通过 DiskStore 缓存到磁盘中。DiskStore 的工作原理，我们在存储系统那一讲有过详细介绍，你可以回去看一看，我建议你结合 DiskStore 的知识把 RDD 分片在磁盘上的缓存过程推导出来。

因此，相比 MEMORY_ONLY，MEMORY_AND_DISK 模式能够保证数据集 100% 地物化到存储介质。对于计算链条较长的 RDD 或是 DataFrame 来说，把数据物化到磁盘也是值得的。但是，我们也不能逢 RDD、DataFrame 就调用.cache，因为在最差的情况下，Spark 的内存计算就会退化为 Hadoop MapReduce 根据磁盘的计算模式。

比如说，你用 DataFrame API 开发应用，计算过程涉及 10 次 DataFrame 之间的转换，每个 DataFrame 都调用.cache进行缓存。由于 Storage Memory 内存空间受限，MemoryStore 最多只能容纳两个 DataFrame 的数据量。因此，MemoryStore 会有 8 次以 DataFrame 为粒度的换进换出。最终，MemoryStore 存储的是访问频次最高的 DataFrame 数据分片，其他的数据分片全部被驱逐到了磁盘上。也就是说，平均下来，至少有 8 次 DataFrame 的转换都会将计算结果落盘，这不就是 Hadoop 的 MapReduce 计算模式吗？

当然，咱们考虑的是最差的情况，但这也能让我们体会到滥用 Cache 可能带来的隐患和危害了。

Cache 的用武之地

既然滥用 Cache 危害无穷，那在什么情况下适合使用 Cache 呢？我建议你在做决策的时候遵循以下 2 条基本原则：

如果 RDD/DataFrame/Dataset 在应用中的引用次数为 1，就坚决不使用 Cache

如果引用次数大于 1，且运行成本占比超过 30%，应当考虑启用 Cache

第一条很好理解，我们详细说说第二条。这里咱们定义了一个新概念：运行成本占比。它指的是计算某个分布式数据集所消耗的总时间与作业执行时间的比值。我们来举个例子，假设我们有个数据分析的应用，端到端的执行时间为 1 小时。应用中有个 DataFrame 被引用了 2 次，从读取数据源，经过一系列计算，到生成这个 DataFrame 需要花费 12 分钟，那么这个 DataFrame 的运行成本占比应该算作：12 * 2 / 60 = 40%。

你可能会说：“作业执行时间好算，直接查看 Spark UI 就好了，DataFrame 的运行时间怎么算呢？”这里涉及一个小技巧，我们可以从现有应用中 把 DataFrame 的计算逻辑单拎出来，然后利用 Spark 3.0 提供的 Noop 来精确地得到 DataFrame 的运行时间。假设 df 是那个被引用 2 次的 DataFrame，我们就可以把 df 依赖的所有代码拷贝成一个新的作业，然后在 df 上调用 Noop 去触发计算。Noop 的作用很巧妙，它只触发计算，而不涉及落盘与数据存储，因此，新作业的执行时间刚好就是 DataFrame 的运行时间。

//利用noop精确计算DataFrame运行时间
df.write
.format(“noop”)
.save()

你可能会觉得每次计算占比会很麻烦，但只要你对数据源足够了解、对计算 DataFrame 的中间过程心中有数了之后，其实不必每次都去精确地计算运行成本占比，尝试几次，你就能对分布式数据集的运行成本占比估摸得八九不离十了。

Cache 的注意事项

弄清楚了应该什么时候使用 Cache 之后，我们再来说说 Cache 的注意事项。

首先，我们都知道，.cache是惰性操作，因此在调用.cache之后，需要先用 Action 算子触发缓存的物化过程。但是，我发现很多同学在选择 Action 算子的时候很随意，first、take、show、count 中哪个顺手就用哪个。

这肯定是不对的，这 4 个算子中只有 count 才会触发缓存的完全物化，而 first、take 和 show 这 3 个算子只会把涉及的数据物化。举个例子，show 默认只产生 20 条结果，如果我们在.cache 之后调用 show 算子，它只会缓存数据集中这 20 条记录。

选择好了算子之后，我们再来讨论一下怎么 Cache 这个问题。你可能会说：“这还用说吗？在 RDD、DataFrame 后面调用.cache不就得了”。还真没这么简单，我出一道选择题来考考你，如果给定包含数十列的 DataFrame df 和后续的数据分析，你应该采用下表中的哪种 Cache 方式？

val filePath: String = _
val df: DataFrame = spark.read.parquet(filePath)

//Cache方式一
val cachedDF = df.cache
//数据分析
cachedDF.filter(col2 > 0).select(col1, col2)
cachedDF.select(col1, col2).filter(col2 > 100)

//Cache方式二
df.select(col1, col2).filter(col2 > 0).cache
//数据分析
df.filter(col2 > 0).select(col1, col2)
df.select(col1, col2).filter(col2 > 100)

//Cache方式三
val cachedDF = df.select(col1, col2).cache
//数据分析
cachedDF.filter(col2 > 0).select(col1, col2)
cachedDF.select(col1, col2).filter(col2 > 100)

我们都知道，由于 Storage Memory 内存空间受限，因此 Cache 应该遵循最小公共子集原则，也就是说，开发者应该仅仅缓存后续操作必需的那些数据列。按照这个原则，实现方式 1 应当排除在外，毕竟 df 是一张包含数十列的宽表。

我们再来看第二种 Cache 方式，方式 2 缓存的数据列是col1和col2，且col2数值大于 0。第一条分析语句只是把filter和select调换了顺序；第二条语句filter条件限制col2数值要大于 100，那么，这个语句的结果就是缓存数据的子集。因此，乍看上去，两条数据分析语句在逻辑上刚好都能利用缓存的数据内容。

但遗憾的是，这两条分析语句都会跳过缓存数据，分别去磁盘上读取 Parquet 源文件，然后从头计算投影和过滤的逻辑。这是为什么呢？究其缘由是，Cache Manager 要求两个查询的 Analyzed Logical Plan 必须完全一致，才能对 DataFrame 的缓存进行复用。

Analyzed Logical Plan 是比较初级的逻辑计划，主要负责 AST 查询语法树的语义检查，确保查询中引用的表、列等元信息的有效性。像谓词下推、列剪枝这些比较智能的推理，要等到制定 Optimized Logical Plan 才会生效。因此，即使是同一个查询语句，仅仅是调换了select和filter的顺序，在 Analyzed Logical Plan 阶段也会被判定为不同的逻辑计划。

因此，为了避免因为 Analyzed Logical Plan 不一致造成的 Cache miss，我们应该采用第三种实现方式，把我们想要缓存的数据赋值给一个变量，凡是在这个变量之上的分析操作，都会完全复用缓存数据。你看，缓存的使用可不仅仅是调用.cache那么简单。

除此之外，我们也应当及时清理用过的 Cache，尽早腾出内存空间供其他数据集消费，从而尽量避免 Eviction 的发生。一般来说，我们会用.unpersist 来清理弃用的缓存数据，它是.cache 的逆操作。unpersist 操作支持同步、异步两种模式：

异步模式：调用 unpersist() 或是 unpersist(False)

同步模式：调用 unpersist(True)

在异步模式下，Driver 把清理缓存的请求发送给各个 Executors 之后，会立即返回，并且继续执行用户代码，比如后续的任务调度、广播变量创建等等。在同步模式下，Driver 发送完请求之后，会一直等待所有 Executors 给出明确的结果（缓存清除成功还是失败）。各个 Executors 清除缓存的效率、进度各不相同，Driver 要等到最后一个 Executor 返回结果，才会继续执行 Driver 侧的代码。显然，同步模式会影响 Driver 的工作效率。因此，通常来说，在需要主动清除 Cache 的时候，我们往往采用异步的调用方式，也就是调用 unpersist() 或是 unpersist(False)。

小结

想要有效避免 Cache 的滥用，我们必须从 Cache 的工作原理出发，先掌握 Cache 的 3 个重要机制，分别是存储级别、缓存计算和缓存的销毁过程。

对于存储级别来说，实际开发中最常用到的有两个，MEMORY_ONLY 和 MEMORY_AND_DISK，它们分别是 RDD 缓存和 DataFrame 缓存的默认存储级别。

对于缓存计算来说，它分为 3 个步骤，第一步是 Unroll，把 RDD 数据分片的 Iterator 物化为对象值，第二步是 Transfer，把对象值封装为 MemoryEntry，第三步是把 BlockId、MemoryEntry 价值对注册到 LinkedHashMap 数据结构。

另外，当数据缓存需求远大于 Storage Memory 区域的空间供给时，Spark 利用 LinkedHashMap 数据结构提供的特性，会遵循 LRU 和兔子不吃窝边草这两个基本原则来清除内存空间：

LRU：按照元素的访问顺序，优先清除那些“最近最少访问”的 BlockId、MemoryEntry 键值对

兔子不吃窝边草：在清除的过程中，同属一个 RDD 的 MemoryEntry 拥有“赦免权”

其次，我们要掌握使用 Cache 的一般性原则和注意事项，我把它们总结为 3 条：

如果 RDD/DataFrame/Dataset 在应用中的引用次数为 1，我们就坚决不使用 Cache

如果引用次数大于 1，且运行成本占比超过 30%，我们就考虑启用 Cache（其中，运行成本占比的计算，可以利用 Spark 3.0 推出的 noop 功能）

Action 算子要选择 count 才能完全物化缓存数据，以及在调用 Cache 的时候，我们要把待缓存数据赋值给一个变量。这样一来，只要是在这个变量之上的分析操作都会完全复用缓存数据。

每日一练

你能结合 DiskStore 的知识，推导出 MEMORY_AND_DISK 模式下 RDD 分片缓存到磁盘的过程吗？

你觉得，为什么 Eviction 规则要遵循“兔子不吃窝边草”呢？如果允许同一个 RDD 的 MemoryEntry 被驱逐，有什么危害吗？

对于 DataFrame 的缓存复用，Cache Manager 为什么没有采用根据 Optimized Logical Plan 的方式，你觉得难点在哪里？如果让你实现 Cache Manager 的话，你会怎么做？

期待在留言区看到你的思考和答案，如果你的朋友也正在为怎么使用 Cache 而困扰，也欢迎你把这一讲转发给他。我们下一讲见！

17 | 内存视角（三）：OOM都是谁的锅？怎么破？

你好，我是吴磊。

无论是批处理、流计算，还是数据分析、机器学习，只要是在 Spark 作业中，我们总能见到 OOM（Out Of Memory，内存溢出）的身影。一旦出现 OOM，作业就会中断，应用的业务功能也都无法执行。因此，及时处理 OOM 问题是我们日常开发中一项非常重要的工作。

但是，Spark 报出的 OOM 问题可以说是五花八门，常常让人找不到头绪。比如，我们经常遇到，数据集按照尺寸估算本该可以完全放进内存，但 Spark 依然会报 OOM 异常。这个时候，不少同学都会参考网上的做法，把 spark.executor.memory 不断地调大、调大、再调大，直到内心崩溃也无济于事，最后只能放弃。

那么，当我们拿到 OOM 这个“烫手的山芋”的时候该怎么办呢？我们最先应该弄清楚的是“到底哪里出现了 OOM”。只有准确定位出现问题的具体区域，我们的调优才能有的放矢。具体来说，这个“哪里”，我们至少要分 3 个方面去看。

发生 OOM 的 LOC（Line Of Code），也就是代码位置在哪？

OOM 发生在 Driver 端，还是在 Executor 端？

如果是发生在 Executor 端，OOM 到底发生在哪一片内存区域？

定位出错代码的位置非常重要但也非常简单，我们只要利用 Stack Trace 就能很快找到抛出问题的 LOC。因此，更关键的是，我们要明确出问题的到底是 Driver 端还是 Executor 端，以及是哪片内存区域。Driver 和 Executor 产生 OOM 的病灶不同，我们自然需要区别对待。

所以今天这一讲，我们就先来说说 Driver 端的 OOM 问题和应对方法。由于内存在 Executor 端被划分成了不同区域，因此，对于 Executor 端怪相百出的 OOM，我们还要结合案例来分类讨论。最后，我会带你整理出一套应对 OOM 的“武功秘籍”，让你在面对 OOM 的时候，能够见招拆招、有的放矢！

Driver 端的 OOM

我们先来说说 Driver 端的 OOM。Driver 的主要职责是任务调度，同时参与非常少量的任务计算，因此 Driver 的内存配置一般都偏低，也没有更加细分的内存区域。

因为 Driver 的内存就是囫囵的那么一块，所以 Driver 端的 OOM 问题自然不是调度系统的毛病，只可能来自它涉及的计算任务，主要有两类：

创建小规模的分布式数据集：使用 parallelize、createDataFrame 等 API 创建数据集

收集计算结果：通过 take、show、collect 等算子把结果收集到 Driver 端

因此 Driver 端的 OOM 逃不出 2 类病灶：

创建的数据集超过内存上限

收集的结果集超过内存上限

第一类病灶不言自明，咱们不细说了。看到第二类病灶，想必你第一时间想到的就是万恶的 collect。确实，说到 OOM 就不得不提 collect。collect 算子会从 Executors 把全量数据拉回到 Driver 端，因此，如果结果集尺寸超过 Driver 内存上限，它自然会报 OOM。

由开发者直接调用 collect 算子而触发的 OOM 问题其实很好定位，比较难定位的是间接调用 collect 而导致的 OOM。那么，间接调用 collect 是指什么呢？还记得广播变量的工作原理吗？

广播变量的创建与分发

广播变量在创建的过程中，需要先把分布在所有 Executors 的数据分片拉取到 Driver 端，然后在 Driver 端构建广播变量，最后 Driver 端把封装好的广播变量再分发给各个 Executors。第一步的数据拉取其实就是用 collect 实现的。如果 Executors 中数据分片的总大小超过 Driver 端内存上限也会报 OOM。在日常的调优工作中，你看到的表象和症状可能是：

java.lang.OutOfMemoryError: Not enough memory to build and broadcast

但实际的病理却是 Driver 端内存受限，没有办法容纳拉取回的结果集。找到了病因，再去应对 Driver 端的 OOM 就很简单了。我们只要对结果集尺寸做适当的预估，然后再相应地增加 Driver 侧的内存配置就好了。调节 Driver 端侧内存大小我们要用到 spark.driver.memory 配置项，预估数据集尺寸可以用“先 Cache，再查看执行计划”的方式，示例代码如下。

val df: DataFrame = _
df.cache.count
val plan = df.queryExecution.logical
val estimated: BigInt = spark
.sessionState
.executePlan(plan)
.optimizedPlan
.stats
.sizeInBytes

Executor 端的 OOM

我们再来说说 Executor 端的 OOM。我们知道，执行内存分为 4 个区域：Reserved Memory、User Memory、Storage Memory 和 Execution Memory。这 4 个区域中都有哪些区域会报 OOM 异常呢？哪些区域压根就不存在 OOM 的可能呢？

在 Executors 中，与任务执行有关的内存区域才存在 OOM 的隐患。其中，Reserved Memory 大小固定为 300MB，因为它是硬编码到源码中的，所以不受用户控制。而对于 Storage Memory 来说，即便数据集不能完全缓存到 MemoryStore，Spark 也不会抛 OOM 异常，额外的数据要么落盘（MEMORY_AND_DISK）、要么直接放弃（MEMORY_ONLY）。

因此，当 Executors 出现 OOM 的问题，我们可以先把 Reserved Memory 和 Storage Memory 排除，然后锁定 Execution Memory 和 User Memory 去找毛病。

User Memory 的 OOM

在内存管理那一讲，我们说过 User Memory 用于存储用户自定义的数据结构，如数组、列表、字典等。因此，如果这些数据结构的总大小超出了 User Memory 内存区域的上限，你可能就会看到下表示例中的报错。

java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf

java.lang.OutOfMemoryError: Java heap space at java.lang.reflect.Array.newInstance

如果你的数据结构是用于分布式数据转换，在计算 User Memory 内存消耗时，你就需要考虑 Executor 的线程池大小。还记得下面的这个例子吗？

val dict = List(“spark”, “tune”)
val words = spark.sparkContext.textFile(“~/words.csv”)
val keywords = words.filter(word => dict.contains(word))
keywords.map((_, 1)).reduceByKey(_ + _).collect

自定义的列表 dict 会随着 Task 分发到所有 Executors，因此多个 Task 中的 dict 会对 User Memory 产生重复消耗。如果把 dict 尺寸记为 #size，Executor 线程池大小记为 #threads，那么 dict 对 User Memory 的总消耗就是：#size * #threads。一旦总消耗超出 User Memory 内存上限，自然就会产生 OOM 问题。

用户数据在任务中的分发

那么，解决 User Memory 端 OOM 的思路和 Driver 端的并无二致，也是先对数据结构的消耗进行预估，然后相应地扩大 User Memory 的内存配置。不过，相比 Driver，User Memory 内存上限的影响因素更多，总大小由 spark.executor.memory * （ 1 - spark.memory.fraction）计算得到。

Execution Memory 的 OOM

要说 OOM 的高发区，非 Execution Memory 莫属。久行夜路必撞鬼，在分布式任务执行的过程中，Execution Memory 首当其冲，因此出错的概率相比其他内存区域更高。关于 Execution Memory 的 OOM，我发现不少同学都存在这么一个误区：只要数据量比执行内存小就不会发生 OOM，相反就会有一定的几率触发 OOM 问题。

实际上，数据量并不是决定 OOM 与否的关键因素，数据分布与 Execution Memory 的运行时规划是否匹配才是。这么说可能比较抽象，你还记得黄小乙的如意算盘吗？为了提高老乡们种地的热情和积极性，他制定了个转让协议，所有老乡申请的土地面积介于 1/N/2 和 1/N 之间。因此，如果有的老乡贪多求快，买的种子远远超过 1/N 上限能够容纳的数量，这位老乡多买的那部分种子都会被白白浪费掉。

同样的，我们可以把 Execution Memory 看作是

土地，把分布式数据集看作是种子，一旦分布式任务的内存请求超出 1/N 这个上限，Execution Memory 就会出现 OOM 问题。而且，相比其他场景下的 OOM 问题，Execution Memory 的 OOM 要复杂得多，它不仅仅与内存空间大小、数据分布有关，还与 Executor 线程池和运行时任务调度有关。

抓住了引起 OOM 问题最核心的原因，对于 Execution Memory OOM 的诸多表象，我们就能从容应对了。下面，我们就来看两个平时开发中常见的实例：数据倾斜和数据膨胀。为了方便说明，在这两个实例中，计算节点的硬件配置是一样的，都是 2 个 CPU core，每个 core 有两个线程，内存大小为 1GB，并且 spark.executor.cores 设置为 3，spark.executor.memory 设置为 900MB。

根据配置项那一讲我们说过的不同内存区域的计算公式，在默认配置下，我们不难算出 Execution Memory 和 Storage Memory 内存空间都是 180MB。而且，因为我们的例子里没有 RDD 缓存，所以 Execution Memory 内存空间上限是 360MB。

实例 1：数据倾斜

我们先来看第一个数据倾斜的例子。节点在 Reduce 阶段拉取数据分片，3 个 Reduce Task 对应的数据分片大小分别是 100MB 和 300MB。显然，第三个数据分片存在轻微的数据倾斜。由于 Executor 线程池大小为 3，因此每个 Reduce Task 最多可获得 360MB * 1 / 3 = 120MB 的内存空间。Task1、Task2 获取到的内存空间足以容纳分片 1、分片 2，因此可以顺利完成任务。

数据倾斜导致OOM

Task3 的数据分片大小远超内存上限，即便 Spark 在 Reduce 阶段支持 Spill 和外排，120MB 的内存空间也无法满足 300MB 数据最基本的计算需要，如 PairBuffer 和 AppendOnlyMap 等数据结构的内存消耗，以及数据排序的临时内存消耗等等。

这个例子的表象是数据倾斜导致 OOM，但实质上是 Task3 的内存请求超出 1/N 上限。因此，针对以这个案例为代表的数据倾斜问题，我们至少有 2 种调优思路：

消除数据倾斜，让所有的数据分片尺寸都不大于 100MB

调整 Executor 线程池、内存、并行度等相关配置，提高 1/N 上限到 300MB

每一种思路都可以衍生出许多不同的方法，就拿第 2 种思路来说，要满足 1/N 的上限，最简单地，我们可以把 spark.executor.cores 设置成 1，也就是 Executor 线程池只有一个线程“并行”工作。这个时候，每个任务的内存上限都变成了 360MB，容纳 300MB 的数据分片绰绰有余。

当然，线程池大小设置为 1 是不可取的，刚刚只是为了说明调优的灵活性。延续第二个思路，你需要去平衡多个方面的配置项，在充分利用 CPU 的前提下解决 OOM 的问题。比如：

维持并发度、并行度不变，增大执行内存设置，提高 1/N 上限到 300MB

维持并发度、执行内存不变，使用相关配置项来提升并行度将数据打散，让所有的数据分片尺寸都缩小到 100MB 以内

关于线程池、内存和并行度之间的平衡与设置，我在 CPU 视角那一讲做过详细的介绍，你可以去回顾一下。至于怎么消除数据倾斜，你可以好好想想，再把你的思路分享出来。

实例 2：数据膨胀

我们再来看第二个数据膨胀的例子。节点在 Map 阶段拉取 HDFS 数据分片，3 个 Map Task 对应的数据分片大小都是 100MB。按照之前的计算，每个 Map Task 最多可获得 120MB 的执行内存，不应该出现 OOM 问题才对。

数据膨胀导致OOM

尴尬的地方在于，磁盘中的数据进了 JVM 之后会膨胀。在我们的例子中，数据分片加载到 JVM Heap 之后翻了 3 倍，原本 100MB 的数据变成了 300MB，因此，OOM 就成了一件必然会发生的事情。

在这个案例中，表象是数据膨胀导致 OOM，但本质上还是 Task2 和 Task3 的内存请求超出 1/N 上限。因此，针对以这个案例为代表的数据膨胀问题，我们还是有至少 2 种调优思路：

把数据打散，提高数据分片数量、降低数据粒度，让膨胀之后的数据量降到 100MB 左右

加大内存配置，结合 Executor 线程池调整，提高 1/N 上限到 300MB

小结

想要高效解决五花八门的 OOM 问题，最重要的就是准确定位问题出现的区域，这样我们的调优才能有的放矢，我建议你按照两步进行。

首先，定位 OOM 发生的代码位置，你通过 Stack Trace 就能很快得到答案。

其次，定位 OOM 是发生在 Driver 端还是在 Executor 端。如果是发生在 Executor 端，再定位具体发生的区域。

发生在 Driver 端的 OOM 可以归结为两类：

创建的数据集超过内存上限

收集的结果集超过内存上限

应对 Driver 端 OOM 的常规方法，是先适当预估结果集尺寸，然后再相应增加 Driver 侧的内存配置。

发生在 Executors 侧的 OOM 只和 User Memory 和 Execution Memory 区域有关，因为它们都和任务执行有关。其中，User Memory 区域 OOM 的产生的原因和解决办法与 Driver 别无二致，你可以直接参考。

而 Execution Memory 区域 OOM 的产生的原因是数据分布与 Execution Memory 的运行时规划不匹配，也就是分布式任务的内存请求超出了 1/N 上限。解决 Execution Memory 区域 OOM 问题的思路总的来说可以分为 3 类：

消除数据倾斜，让所有的数据分片尺寸都小于 1/N 上限

把数据打散，提高数据分片数量、降低数据粒度，让膨胀之后的数据量降到 1/N 以下

加大内存配置，结合 Executor 线程池调整，提高 1/N 上限

每日一练

数据膨胀导致 OOM 的例子中，为什么 Task1 能获取到 300MB 的内存空间？（提示：可以回顾 CPU 视角那一讲去寻找答案。）

在日常开发中，你还遇到过哪些 OOM 表象？你能把它们归纳到我们今天讲的分类中吗？

期待在留言区看到你的思考和分享，我们下一讲见！

18 | 磁盘视角：如果内存无限大，磁盘还有用武之地吗？

你好，我是吴磊。

我们都知道，Spark 的优势在于内存计算。一提到“内存计算”，我们的第一反应都是：执行效率高！但如果听到“基于磁盘的计算”，就会觉得性能肯定好不到哪儿去。甚至有的人会想，如果 Spark 的内存无限大就好了，这样我们就可以把磁盘完全抛弃掉。当然，这个假设大概率不会成真，而且这种一刀切的思维也不正确。

如果内存无限大，我们确实可以通过一些手段，让 Spark 作业在执行的过程中免去所有的落盘动作。但是，无限大内存引入的大量 Full GC 停顿（Stop The World），很有可能让应用的执行性能，相比有磁盘操作的时候更差。这就不符合我们一再强调的，调优的最终目的是在不同的硬件资源之间寻求平衡了。

所以今天这一讲，我们就来说说磁盘在 Spark 任务执行的过程中都扮演哪些重要角色，它功能方面的作用，以及性能方面的价值。掌握它们可以帮助我们更合理地利用磁盘，以成本优势平衡不同硬件资源的计算负载。

磁盘在功能上的作用

在 Spark 当中，磁盘都用在哪些地方呢？在 Shuffle 那一讲我们说过，在 Map 阶段，Spark 根据计算是否需要聚合，分别采用 PartitionedPairBuffer 和 PartitionedAppendOnlyMap 两种不同的内存数据结构来缓存分片中的数据记录。分布式计算往往涉及海量数据，因此这些数据结构通常都没办法装满分区中的所有数据。在内存受限的情况下，溢出机制可以保证任务的顺利执行，不会因为内存空间不足就立即报 OOM 异常。

溢出数据到磁盘，避免频繁的OOM

以“仙女散花”的游戏为例，我们用 groupByKey 去收集不同花色的花朵。在 PartitionedPairBuffer 大小为 4 的情况下，当小红拿到的花朵数量超过 4 朵的时候，其余花朵要想进入内存，Spark 就必须把 PartitionedPairBuffer 中的内容暂时溢出到临时文件，把内存空间让出来才行。这就是磁盘在功能上的第一个作用：溢出临时文件。

当分区中的最后一批数据加载到 PartitionedPairBuffer 之后，它会和之前溢出到磁盘的临时文件一起做归并计算，最终得到 Shuffle 的数据文件和索引文件也会存储到磁盘上，也就是我们常说的 Shuffle 中间文件。这就是磁盘的在功能上的第二个作用：存储 Shuffle 中间文件。

除此之外，磁盘的第三个作用就是缓存分布式数据集。也就是说，凡是带DISK字样的存储模式，都会把内存中放不下的数据缓存到磁盘。这些缓存数据还有刚刚讲的临时文件、中间文件，都会存储到 spark.local.dir 参数对应的文件系统目录中。

性能上的价值

在配置项那一讲我们说过，把 spark.local.dir 这个参数配置到 SDD 或者其他访问效率更高的存储系统中可以提供更好的 I/O 性能。除此之外，磁盘复用还能给执行性能带来更好的提升。所谓磁盘复用，它指的是 Shuffle Write 阶段产生的中间文件被多次计算重复利用的过程。下面，我就通过两个例子给你详细讲讲，磁盘复用的常见应用和它的收益。

失败重试中的磁盘复用

我们经常说，在没有 RDD Cache 的情况下，一旦某个计算环节出错，就会触发整条 DAG 从头至尾重新计算，这个过程又叫失败重试。严格来说，这种说法是不准确的。因为，失败重试的计算源头并不是整条 DAG 的“头”，而是与触发点距离最新的 Shuffle 的中间文件。

磁盘复用与蓄水池

我们以文稿示意图中的 DAG 为例子，HDFS 源数据经过两次转换之后，分别得到 RDD1 和 RDD2。RDD2 在 Shuffle 之后再进行两次计算，分成得到 RDD3 和 RDD4。

不幸的是，在计算 RDD4 的过程中有些任务失败了。在失败重试的时候，Spark 确实会从 RDD4 向前回溯，但是有了磁盘复用机制的存在，它并不会一直回溯到 HDFS 源数据，而是直接回溯到已经物化到节点的 RDD3 的“数据源”，也就是 RDD2 在 Shuffle Write 阶段输出到磁盘的中间文件。因此，磁盘复用的收益之一就是缩短失败重试的路径，在保障作业稳定性的同时提升执行性能。

为了方便你理解，我们不妨把 DAG 中的流水线计算想象成是干渠灌溉，黄土高坡上的麦田一年到头也喝不到几滴雨水，完全依靠人工干渠进行灌溉。当水电站开闸放水的时候，水会沿着干渠一路向东流进支渠去滋养如饥似渴的麦苗。

一个水电站往往服务方圆百里大大小小的村子，如果每次灌溉都等着水电站开闸放水，遇上大旱的年头，水还没流到支渠，麦苗就都旱死了。要是能沿着干渠，每隔一段距离就修建一座蓄水池，那么附近的村民就能就近灌溉了。在这个干渠灌溉的类比中，水电站的水是 HDFS 数据源头，蓄水池就是 Shuffle 中间文件，就近取水、就近灌溉就是磁盘复用机制。

ReuseExchange 机制下的磁盘复用

你可能会说：“磁盘复用也没什么嘛，无非是在失败重试的时候，抄个近道、少走些弯路。在任务不出错的情况下是利用不到这项优势的。”没错，所以我们再来说说磁盘复用的另一种形式：ReuseExchange 机制。ReuseExchange 是 Spark SQL 众多优化策略中的一种，它指的是相同或是相似的物理计划可以共享 Shuffle 计算的中间结果，也就是我们常说的 Shuffle 中间文件。ReuseExchange 机制可以帮我们削减 I/O 开销，甚至节省 Shuffle，来大幅提升执行性能。

那我们该怎么有效利用 ReuseExchange 机制呢？在数据仓库场景下，为了得到数据报表或是可视化图表，用户往往需要执行多个相似的查询，甚至会把同样的查询语句执行多次。在这种情况下，ReuseExchange 策略在执行效率方面会带来非常大的收益。

同样或相似的查询利用ReuseExchange缩短执行路径

即便是在没有 DataFrame Cache 的情况下，相同或是相似的查询也可以利用 ReuseExchange 策略，在缩短执行路径的同时，消除额外的 Shuffle 计算。从数据复用的角度来说，ReuseExchange 和 DISK_ONLY 模式的 DataFrame Cache 能起到的作用完全等价。

咱们来举个例子。现在有这样一个业务需求：给定用户访问日志，分别统计不同用户的 PV（Page Views，页面浏览量）、UV（Unique Views，网站独立访客），然后再把两项统计结果合并起来，以备后用。其中，用户日志包含用户 ID、访问时间、页面地址等主要字段。业务需求不仅明确也很简单，我们很快就能把代码写出来。

//版本1：分别计算PV、UV，然后合并
// Data schema (userId: String, accessTime: Timestamp, page: String)

val filePath: String = _
val df: DataFrame = spark.read.parquet(filePath)

val dfPV: DataFrame = df.groupBy("userId").agg(count("page").alias("value")).withColumn("metrics", lit("PV"))
val dfUV: DataFrame = df.groupBy("userId").agg(countDistinct("page").alias("value")).withColumn("metrics ", lit("UV"))

val resultDF: DataFrame = dfPV.Union(dfUV)

// Result样例
| userId | metrics | value |
| user0  | PV      | 25 |
| user0  | UV      | 12 |

代码逻辑是先读取用户日志，然后在同一个 DataFrame 之上分别调用 count 和 countDistinct 计算 PV、UV，最后把 PU、UV 对应的两个 DataFrame 合并在一起。

虽然代码实现起来简单直接，但是，如果我们在 resultDF 之上调用 explain 或是通过 Spark UI 去查看物理计划就会发现，尽管 count 和 countDistinct 是基于同一份数据源计算的，但这两个操作的执行路径是完全独立的。它们各自扫描 Parquet 源文件，并且通过 Shuffle 完成计算，在 Shuffle 之前会先在 Map 端做本地聚合，Shuffle 之后会在 Reduce 端再进行全局聚合。

Parquet文件扫描两次、Shuffle两次

对于绝大多数的合并场景来说，计算流程大抵如此。显然，这样的做法是极其低效的，尤其是在需要合并多个数据集的时候，重复的数据扫描和分发就会引入更多的性能开销。那么，有没有什么办法，让同一份数据源的多个算子只读取一次 Parquet 文件，且只做一次 Shuffle 呢？

做了这么半天铺垫，答案自然是“有”。针对版本 1 中的代码，我们稍作调整就可以充分利用 ReuseExchange 策略来做优化。

//版本2：分别计算PV、UV，然后合并
// Data schema (userId: String, accessTime: Timestamp, page: String)

val filePath: String = _
val df: DataFrame = spark.read.parquet(filePath).repartition($"userId")

val dfPV: DataFrame = df.groupBy("userId").agg(count("page").alias("value")).withColumn("metrics", lit("PV"))
val dfUV: DataFrame = df.groupBy("userId").agg(countDistinct("page").alias("value")).withColumn("metrics ", lit("UV"))

val resultDF: DataFrame = dfPV.Union(dfUV)

// Result样例
| userId | metrics | value |
| user0  | PV      | 25 |
| user0  | UV      | 12 |

需要调整的部分仅仅是数据源读取，其他部分的代码保持不变。在用 Parquet API 读取用户日志之后，我们追加一步重分区操作，也就是以 userId 为分区键调用 repartition 算子。

经过这个微小的改动之后，我们重新在 resultDF 之上调用 explain 或是查看 Spark UI 会发现，在新的物理计划中，count 或是 countDistinct 分支出现了 ReuseExchange 字样，也就是其中一方复用了另一方的 Exchange 结果。

ReuseExchange

通过观察执行计划不难发现，ReuseExchange 带来的收益相当可观，不仅是数据源只需要扫描一遍，而且作为“性能瓶颈担当”的 Shuffle 也只发生了一次。

另外，你可能也会发现，复用 Shuffle 中间结果的是两个不完全相同的查询，一个是用 count 做统计计数，另一个是用 countDistinct 做去重计数。你看，两个相似的查询，通过 ReuseExchange 数据复用，达到了使用 DISK_ONLY 缓存的等价效果。换句话说，你不需要手动调用 persist(DISK_ONLY)，也不需要忍受磁盘缓存的计算过程，就可以享受它带来的收益。这惊不惊喜、意不意外？

你可能会问：“既然 ReuseExchange 机制这么好用，满足什么条件才能触发 Spark SQL 去选择这个执行策略呢？”事实上，触发条件至少有 2 个：

多个查询所依赖的分区规则要与 Shuffle 中间数据的分区规则保持一致

多个查询所涉及的字段（Attributes）要保持一致

对于第一个条件，我们在案例中已经演示过了，两个查询都用 userId 分组，这就要求所依赖的数据必须要按照 userId 做分区。这也是为什么我们在版本 2 的代码中，会添加以 userId 为分区键的 repartition 算子，只有这样，Shuffle 中间结果的分区规则才能和查询所需的分区规则保持一致。

仔细观察 count 和 countDistinct 两个查询所涉及的字段，我们会发现它们完全一致。实际上，如果我们把 count 语句中的count("page")改为count("*")也并不影响 PV 的计算，但是，看似无关痛痒的改动会导致第二个条件不能满足，从而无法利用 ReuseExchange 机制来提升执行性能。版本 2 中的count("page")改为count("*")之后，物理计划会回退到版本 1，我把其中的变化留给你作为课后作业去对比。

小结

磁盘虽然在处理延迟上远不如内存，但在性能调优中依然不可或缺。理解磁盘在功能上和性能上的价值，可以帮助我们更合理地利用磁盘，以成本优势平衡不同硬件资源的计算负载。

从功能上看，磁盘在 Spark 中主要有 3 方面的作用，分别是溢出临时文件、缓存分布式数据集和存储 Shuffle 中间文件。这 3 方面功能在提升作业稳定性的同时，也为执行效率的提升打下了基础。

从性能上看，利用好磁盘复用机制，可以极大地提高应用的执行性能。磁盘复用指的是 Shuffle Write 阶段产生的中间文件被多次计算重复利用的过程。磁盘复用有两种用途，一个是失败重试，另一个是 ReuseExchange 机制。其中，失败重试指的就是任务失败之后尝试重头计算。这个过程中，磁盘复用缩短了失败重试的路径，在保障作业稳定性的同时，提升执行性能。

ReuseExchange 策略指的是，相同或是相似的物理计划可以共享 Shuffle 计算的中间结果。ReuseExchange 对于执行性能的贡献相当可观，它可以让基于同一份数据源的多个算子只读取一次 Parquet 文件，并且只做一次 Shuffle，来大幅削减磁盘与网络开销。

不过，要想让 Spark SQL 在优化阶段选择 ReuseExchange，业务应用必须要满足 2 个条件：

多个查询所依赖的分区规则要与 Shuffle 中间数据的分区规则保持一致

多个查询所涉及的字段要保持一致

每日一练

请你把 count 计算中的count("page")改为count("*")，以此来观察物理计划的变化，并在留言区说出你的观察

为了触发 ReuseExchange 机制生效，我们按照 userId 对数据集做重分区，结合这一点，你不妨想一想，在哪些情况下，不适合采用 ReuseExchange 机制？为什么？

期待在留言区看到你的思考和答案，我们下一讲见！

19 | 网络视角：如何有效降低网络开销？

你好，我是吴磊。

在平衡不同硬件资源的时候，相比 CPU、内存、磁盘，网络开销无疑是最拖后腿的那一个，这一点在处理延迟上表现得非常明显。

下图就是不同硬件资源的处理延迟对比结果，我们可以看到最小的处理单位是纳秒。你可能对纳秒没什么概念，所以为了方便对比，我把纳秒等比放大到秒。这样，其他硬件资源的处理延迟也会跟着放大。最后一对比我们会发现，网络延迟是以天为单位的！

不同硬件资源处理延迟对比

因此，要想维持硬件资源之间的平衡，尽可能地降低网络开销是我们在性能调优中必须要做的。今天这一讲，我就按照数据进入系统的时间顺序，也就是数据读取、数据处理和数据传输的顺序，带你去分析和总结数据生命周期的不同阶段有效降低网络开销的方法。

数据读写

对于绝大多数应用来说，第一步操作都是从分布式文件系统读取数据源。Spark 支持的数据源种类非常丰富，涉及的存储格式和存储系统可以说是五花八门。

存储格式与存储系统

这么多存储格式和外部存储系统交叉在一起又会有无数种组合，并且每一种组合都有它的应用场景。那么，我们该怎么判断网络开销会出现在哪些场景下呢？其实，不管是什么文件格式，也不管是哪种存储系统，访问数据源是否会引入网络开销，取决于任务与数据的本地性关系，也就是任务的本地性级别，它一共有 4 种：

PROCESS_LOCAL：任务与数据同在一个 JVM 进程中

NODE_LOCAL：任务与数据同在一个计算节点，数据可能在磁盘上或是另一个 JVM 进程中

RACK_LOCAL：任务与数据不在同一节点，但在同一个物理机架上

ANY：任务与数据是跨机架、甚至是跨 DC（Data Center，数据中心）的关系

根据定义我们很容易判断出，不同本地性级别下的计算任务是否会引入磁盘或网络开销，结果如下表所示。从表格中我们不难发现，从 PROCESS_LOCAL 到 ANY，数据访问效率是逐级变差的。在读取数据源阶段，数据还未加载到内存，任务没有办法调度到 PROCESS_LOCAL 级别。因此，这个阶段我们能够调度的最佳级别是 NODE_LOCAL。

不同本地性级别与磁盘、网络开销的关系

根据 NODE_LOCAL 的定义，在这个级别下，调度的目标节点至少在磁盘上存有 Spark 计算任务所需的数据分片。这也就意味着，在集群部署上，Spark 集群与外部存储系统在物理上是紧紧耦合在一起的。相反，如果 Spark 集群与存储集群在物理上是分开的，那么任务的本地性级别只能退化到 RACK_LOCAL，甚至是 ANY，来通过网络获取所需的数据分片。

因此，对于 Spark 加 HDFS 和 Spark 加 MongoDB 来说，是否会引入网络开销完全取决于它们的部署模式。物理上紧耦合，在 NODE_LOCAL 级别下，Spark 用磁盘 I/O 替代网络开销获取数据；物理上分离，网络开销就无法避免。

除此之外，物理上的隔离与否同样会影响数据的写入效率。当数据处理完毕，需要将处理结果落盘到外部存储的时候，紧耦合模式下的数据写入会把数据分片落盘到本地节点，避免网络开销。

值得一提的是，在企业的私有化 DC 中更容易定制化集群的部署方式，大家通常采用紧耦合的方式来提升数据访问效率。但是在公有云环境中，计算集群在物理上往往和存储系统隔离，因此数据源的读取只能走网络。

通过上面的分析，对于数据读写占比较高的业务场景，我们就可以通过在集群的部署模式上做规划，从而在最开始部署 Spark 集群的时候就提前做好准备。

数据处理

数据读取完成后，就进入数据处理环节了。那在数据处理的过程中，都有哪些技巧能够帮助减少网络开销呢？

能省则省

说起数据处理中的网络开销，我猜你最先想到的操作就是 Shuffle。Shuffle 作为大多数计算场景的“性能瓶颈担当”，确实是网络开销的罪魁祸首。根据“能省则省”的开发原则，我们自然要想尽办法去避免 Shuffle。在数据关联的场景中，省去 Shuffle 最好的办法，就是把 Shuffle Joins 转化为 Broadcast Joins。关于这方面的调优技巧，我们在广播变量那几讲有过详细的讲解，你可以翻回去看一看。尽管广播变量的创建过程也会引入网络传输，但是，两害相权取其轻，相比 Shuffle 的网络开销，广播变量的开销算是小巫见大巫了。

遵循“能省则省”的原则，把 Shuffle 消除掉自然是最好的。如果实在没法避免 Shuffle，我们要尽可能地在计算中多使用 Map 端聚合，去减少需要在网络中分发的数据量。这方面的典型做法就是用 reduceByKey、aggregateByKey 替换 groupByKey，不过在 RDD API 使用频率越来越低的当下，这个调优技巧实际上早就名存实亡了。但是，Map 端聚合的思想并不过时。为什么这么说呢？下面，我通过一个小例子来你详细讲一讲。

在绝大多数 2C（To Consumer）的业务场景中，我们都需要刻画用户画像。我们的小例子就是“用户画像”中的一环，：给定用户表，按照用户群组统计兴趣列表，要求兴趣列表内容唯一，也就是不存在重复的兴趣项，用户表的 Schema 如下表所示。

用户表Schema示例

要获取群组兴趣列表，我们应该先按照 groupId 分组，收集群组内所有用户的兴趣列表，然后再把列表中的兴趣项展平，最后去重得到内容唯一的兴趣列表。应该说思路还是蛮简单的，我们先来看第一版实现代码。

val filePath: String = _
val df = spark.read.parquent(filePath)
df.groupBy(“groupId”)
.agg(array_distinct(flatten(collect_list(col(“interestList”)))))

这版实现分别用 collect_list、flatten 和 array_distinct，来做兴趣列表的收集、展平和去重操作，它完全符合业务逻辑。不过，见到“收集”类的操作，比如 groupByKey，以及这里的 collect_list，我们应该本能地提高警惕。因为这类操作会把最细粒度的全量数据在全网分发。相比其他算子，这类算子引入的网络开销最大。

那我们是不是可以把它们提前到 Map 端，从而减少 Shuffle 中需要分发的数据量呢？当然可以。比如，对于案例中的收集操作，我们可以在刚开始收集兴趣列表的时候就在 Map 端做一次去重，然后去查找 DataFrame 开发 API，看看有没有与 collect_list 对应的 Map 端聚合算子。

因此，在数据处理环节，我们要遵循“能省则省”的开发原则，主动削减计算过程中的网络开销。对于数据关联场景，我们要尽可能地把 Shuffle Joins 转化为 Broadcast Joins 来消除 Shuffle。如果确实没法避免 Shuffle，我们可以在计算中多使用 Map 端聚合，减少需要在网络中分发的数据量。

除了 Shuffle 之外，还有个操作也会让数据在网络中分发，这个操作很隐蔽，我们经常注意不到它，它就是多副本的 RDD 缓存。

比如说，在实时流处理这样的场景下，对于系统的高可用性，应用的要求比较高，因此你可能会用“_2”甚至是“_3”的存储模式，在内存和磁盘中缓存多份数据拷贝。当数据副本数大于 1 的时候，本地数据分片就会通过网络被拷贝到其他节点，从而产生网络开销。虽然这看上去只是存储模式字符串的一个微小改动，但在运行时，它会带来很多意想不到的开销。因此，如果你的应用对高可用特性没有严格要求，我建议你尽量不要滥用多副本的 RDD 缓存，

数据传输

最后就到了数据传输的环节。我们知道，在落盘或是在网络传输之前，数据都是需要先进行序列化的。在 Spark 中，有两种序列化器供开发者选择，分别是 Java serializer 和 Kryo Serializer。Spark 官方和网上的技术博客都会推荐你使用 Kryo Serializer 来提高效率，通常来说，Kryo Serializer 相比 Java serializer，在处理效率和存储效率两个方面都会胜出数倍。因此，在数据分发之前，使用 Kryo Serializer 对其序列化会进一步降低网络开销。

不过，经常有同学向我抱怨：“为什么我用了 Kryo Serializer，序列化之后的数据尺寸反而比 Java serializer 的更大呢？”注意啦，这里我要提醒你：对于一些自定义的数据结构来说，如果你没有明确把这些类型向 Kryo Serializer 注册的话，虽然它依然会帮你做序列化的工作，但它序列化的每一条数据记录都会带一个类名字，这个类名字是通过反射机制得到的，会非常长。在上亿的样本中，存储开销自然相当可观。

那该怎么向 Kryo Serializer 注册自定义类型呢？其实非常简单，我们只需要在 SparkConf 之上调用 registerKryoClasses 方法就好了，代码示例如下所示。

//向Kryo Serializer注册类型
val conf = new SparkConf().setMaster(“”).setAppName(“”)
conf.registerKryoClasses(Array(
classOf[Array[String]],
classOf[HashMap[String, String]],
classOf[MyClass]
))

另外，与 Kryo Serializer 有关的配置项，我也把它们汇总到了下面的表格中，方便你随时查找。其中，spark.serializer 可以明确指定 Spark 采用 Kryo Serializer 序列化器。而 spark.kryo.registrationRequired 就比较有意思了，如果我们把它设置为 True，当 Kryo Serializer 遇到未曾注册过的自定义类型的时候，它就不会再帮你做序列化的工作，而是抛出异常，并且中断任务执行。这么做的好处在于，在开发和调试阶段，它能帮我们捕捉那些忘记注册的类型。

与Kryo Serializer有关的配置项

为了方便你理解，我们不妨把 Java serializer 和 Kryo Serializer 比作是两个不同的搬家公司。

Java Serializer 是老牌企业，市场占有率高，而且因为用户体验很好，所以非常受欢迎。只要你出具家庭住址，Java Serializer 会派专人到家里帮你打包，你并不需要告诉他们家里都有哪些物件，他们对不同种类的物品有一套自己的打包标准，可以帮你省去很多麻烦。不过，Java Serializer 那套打包标准过于刻板，不仅打包速度慢，封装出来的包裹往往个头超大、占地儿，你必须租用最大号的货车才能把家里所有的物品都装下。

Kryo Serializer 属于市场新贵，在打包速度和包裹尺寸方面都远胜 Java Serializer。Kryo Serializer 会以最紧凑的方式打包，一寸空间也不浪费，因此，所有包裹用一辆小货车就能装下。但是，订阅 Kryo Serializer 的托管服务之前，用户需要提供详尽的物品明细表，因此，很多用户都嫌麻烦，Kryo Serializer 市场占有率也就一直上不去。

好啦，现在你是不是对 Java Serializer 和 Kryo Serializer 有了更深的理解了？由此可见，如果你想要不遗余力去削减数据传输过程中的网络开销，就可以尝试使用 Kryo Serializer 来做数据的序列化。相反，要是你觉得开发成本才是核心痛点，那采用默认的 Java Serializer 也未尝不可。

小结

这一讲，面对数据处理不同阶段出现的网络开销，我带你总结出了有效降低它的办法。

首先，在数据读取阶段，要想获得 NODE_LOCAL 的本地级别，我们得让 Spark 集群与外部存储系统在物理上紧紧耦合在一起。这样，Spark 就可以用磁盘 I/O 替代网络开销获取数据，否则，本地级别就会退化到 RACK_LOCAL 或者 ANY，那网络开销就无法避免。

其次，在数据处理阶段，我们应当遵循“能省则省”的开发原则，在适当的场景用 Broadcast Joins 来避免 Shuffle 引入的网络开销。如果确实没法避免 Shuffle，我们可以在计算中多使用 Map 端聚合，减少需要在网络中分发的数据量。另外，如果应用对于高可用的要求不高，那我们应该尽量避免副本数量大于 1 的存储模式，避免副本跨节点拷贝带来的额外开销。

最后，在数据通过网络分发之前，我们可以利用 Kryo Serializer 序列化器，提升序列化字节的存储效率，从而有效降低在网络中分发的数据量，整体上减少网络开销。需要注意的，为了充分利用 Kryo Serializer 序列化器的优势，开发者需要明确注册自定义的数据类型，否则效果可能适得其反。

每日一练

对于文中 Map 端聚合的示例，你知道和 collect_list 对应的 Map 端聚合算子是什么吗？

你还能想到哪些 Map 端聚合的计算场景？

对于不同的数据处理阶段，你还知道哪些降低网络开销的办法吗？

期待在留言区看到你的思考和答案，也欢迎你把这一讲转发出去，我们下一讲见！

20 | RDD和DataFrame：既生瑜、何生亮

你好，我是吴磊。

从今天开始，我们进入 Spark SQL 性能调优篇的学习。在这一篇中，我会先带你学习 Spark SQL 已有的优化机制，如 Catalyst、Tungsten 这些核心组件，以及 AQE、DPP 等新特性。深入理解这些内置的优化机制，会让你在开发应用之初就有一个比较高的起点。然后，针对数据分析中的典型场景，如数据关联，我们再去深入探讨性能调优的方法和技巧。

今天这一讲，我们先来说说 RDD 和 DataFrame 的渊源。这也是面试的时候，面试官经常会问的。比如说：“Spark 3.0 大版本发布，Spark SQL 的优化占比将近 50%；而像 PySpark、Mllib 和 Streaming 的优化占比都不超过 10%，Graph 的占比几乎可以忽略不计。这是否意味着 Spark 社区逐渐放弃了其他计算领域，只专注于数据分析？”

这个问题的标准答案是：“Spark SQL 取代 Spark Core，成为新一代的引擎内核，所有其他子框架如 Mllib、Streaming 和 Graph，都可以共享 Spark SQL 的性能优化，都能从 Spark 社区对于 Spark SQL 的投入中受益。”不过，面试官可没有那么好对付，一旦你这么说，他 / 她可能会追问：“为什么需要 Spark SQL 这个新一代引擎内核？Spark Core 有什么问题吗？Spark SQL 解决了 Spark Core 的哪些问题？怎么解决的？”

面对这一连串“箭如雨发”的追问，你还能回答出来吗？接下来，我就从 RDD 的痛点说起，一步一步带你探讨 DataFrame 出现的必然性，Spark Core 的局限性，以及它和 Spark SQL 的关系。

RDD 之痛：优化空间受限

自从 Spark 社区在 1.3 版本发布了 DataFrame，它就开始代替 RDD，逐渐成为开发者的首选。我们知道，新抽象的诞生一定是为了解决老抽象不能搞定的问题。那么，这些问题都是什么呢？下面，我们就一起来分析一下。

在 RDD 的开发框架下，我们调用 RDD 算子进行适当的排列组合，就可以很轻松地实现业务逻辑。我把这些使用频繁的 RDD 算子总结到了下面的表格里，你可以看一看。

表格中高亮显示的就是 RDD 转换和聚合算子，它们都是高阶函数。高阶函数指的是形参包含函数的函数，或是返回结果包含函数的函数。为了叙述方便，我们把那些本身是高阶函数的 RDD 算子，简称“高阶算子”。

对于这些高阶算子，开发者需要以 Lambda 函数的形式自行提供具体的计算逻辑。以 map 为例，我们需要明确对哪些字段做映射，以什么规则映射。再以 filter 为例，我们需要指明以什么条件在哪些字段上过滤。

但这样一来，Spark 只知道开发者要做 map、filter，但并不知道开发者打算怎么做 map 和 filter。也就是说，在 RDD 的开发模式下，Spark Core 只知道“做什么”，而不知道“怎么做”。这会让 Spark Core 两眼一抹黑，除了把 Lambda 函数用闭包的形式打发到 Executors 以外，实在是没有什么额外的优化空间。

对于 Spark Core 来说，优化空间受限最主要的影响，莫过于让应用的执行性能变得低下。一个典型的例子，就是相比 Java 或者 Scala，PySpark 实现的应用在执行性能上相差悬殊。原因在于，在 RDD 的开发模式下，即便是同一个应用，不同语言实现的版本在运行时也会有着天壤之别。

不同语言的运行时计算过程

当我们使用 Java 或者 Scala 语言做开发时，所有的计算都在 JVM 进程内完成，如图中左侧的 Spark 计算节点所示。

而当我们在 PySpark 上做开发的时候，只能把由 RDD 算子构成的计算代码，一股脑地发送给 Python 进程。Python 进程负责执行具体的脚本代码，完成计算之后，再把结果返回给 Executor 进程。由于每一个 Task 都需要一个 Python 进程，如果 RDD 的并行度为 #N，那么整个集群就需要 #N 个这样的 Python 进程与 Executors 交互。不难发现，其中的任务调度、数据计算和数据通信等开销，正是 PySpark 性能低下的罪魁祸首。

DataFrame 应运而生

针对优化空间受限这个核心问题，Spark 社区痛定思痛，在 2013 年在 1.3 版本中发布了 DataFrame。那么，DataFrame 的特点是什么，它和 RDD 又有什么不同呢？

首先，用一句话来概括，DataFrame 就是携带数据模式（Data Schema）的结构化分布式数据集，而 RDD 是不带 Schema 的分布式数据集。因此，从数据表示（Data Representation）的角度来看，是否携带 Schema 是它们唯一的区别。带 Schema 的数据表示形式决定了 DataFrame 只能封装结构化数据，而 RDD 则没有这个限制，所以除了结构化数据，它还可以封装半结构化和非结构化数据。

其次，从开发 API 上看，RDD 算子多是高阶函数，这些算子允许开发者灵活地实现业务逻辑，表达能力极强。

DataFrame 的表达能力却很弱。一来，它定义了一套 DSL（Domain Specific Language）算子，如 select、filter、agg、groupBy 等等。由于 DSL 语言是为解决某一类任务而专门设计的计算机语言，非图灵完备，因此，语言表达能力非常有限。二来，DataFrame 中的绝大多数算子都是标量函数（Scalar Functions），它们的形参往往是结构化的数据列（Columns），表达能力也很弱。

你可能会问：“相比 RDD，DataFrame 的表示和表达能力都变弱了，那它是怎么解决 RDD 优化空间受限的核心痛点呢？”

当然，仅凭 DataFrame 在 API 上的改动就想解决 RDD 的核心痛点，比登天还难。DataFrame API 最大的意义在于，它为 Spark 引擎的内核优化打开了全新的空间。

首先，DataFrame 中 Schema 所携带的类型信息，让 Spark 可以根据明确的字段类型设计定制化的数据结构，从而大幅提升数据的存储和访问效率。其次，DataFrame 中标量算子确定的计算逻辑，让 Spark 可以基于启发式的规则和策略，甚至是动态的运行时信息去优化 DataFrame 的计算过程。

Spark SQL 智能大脑

那么问题来了，有了 DataFrame API，负责引擎内核优化的那个幕后英雄是谁？为了支持 DataFrame 开发模式，Spark 从 1.3 版本开始推出 Spark SQL。Spark SQL 的核心组件有二，其一是 Catalyst 优化器，其二是 Tungsten。关于 Catalyst 和 Tungsten 的特性和优化过程，我们在后面的两讲再去展开，今天这一讲，咱们专注在它们和 DataFrame 的关系。

Catalyst：执行过程优化

我们先来说说 Catalyst 的优化过程。当开发者通过 Actions 算子触发 DataFrame 的计算请求时，Spark 内部会发生一系列有趣的事情。

首先，基于 DataFrame 确切的计算逻辑，Spark 会使用第三方的 SQL 解析器 ANTLR 来生成抽象语法树（AST，Abstract Syntax Tree）。既然是树，就会有节点和边这两个基本的构成元素。节点记录的是标量算子（如 select、filter）的处理逻辑，边携带的是数据信息：关系表和数据列，如下图所示。这样的语法树描述了从源数据到 DataFrame 结果数据的转换过程。

AST语法树示意图

在 Spark 中，语法树还有个别名叫做“Unresolved Logical Plan”。它正是 Catalyst 优化过程的起点。之所以取名“Unresolved”，是因为边上记录的关系表和数据列仅仅是一些字符串，还没有和实际数据对应起来。举个例子，Filter 之后的那条边，输出的数据列是 joinKey 和 payLoad。这些字符串的来源是 DataFrame 的 DSL 查询，Catalyst 并不确定这些字段名是不是有效的，更不知道每个字段都是什么类型。

因此，Catalyst 做的第一步优化，就是结合 DataFrame 的 Schema 信息，确认计划中的表名、字段名、字段类型与实际数据是否一致。这个过程也叫做把“Unresolved Logical Plan”转换成“Analyzed Logical Plan”。

Spark SQL的端到端优化过程

基于解析过后的“Analyzed Logical Plan”，Catalyst 才能继续做优化。利用启发式的规则和执行策略，Catalyst 最终把逻辑计划转换为可执行的物理计划。总之，Catalyst 的优化空间来源 DataFrame 的开发模式。

Tungsten：数据结构优化

说完 Catalyst，我接着再来说说 Tungsten。在开发原则那一讲，我们提到过 Tungsten 使用定制化的数据结构 Unsafe Row 来存储数据，Unsafe Row 的优点是存储效率高、GC 效率高。Tungsten 之所以能够设计这样的数据结构，仰仗的也是 DataFrame 携带的 Schema。Unsafe Row 我们之前讲过，这里我再带你简单回顾一下。

结构化的二维表

Tungsten 是用二进制字节序列来存储每一条用户数据的，因此在存储效率上完胜 Java Object。比如说，如果我们要存储上表中的数据，用 Java Object 来存储会消耗 100 个字节数，而使用 Tungsten 仅需要不到 20 个字节，如下图所示。

二进制字节序列

但是，要想实现上图中的二进制序列，Tungsten 必须要知道数据条目的 Schema 才行。也就是说，它需要知道每一个字段的数据类型，才能决定在什么位置安放定长字段、安插 Offset，以及存放变长字段的数据值。DataFrame 刚好能满足这个前提条件。

我们不妨想象一下，如果数据是用 RDD 封装的，Tungsten 还有可能做到这一点吗？当然不可能。这是因为，虽然 RDD 也带类型，如 RDD[Int]、RDD[(Int, String)]，但如果 RDD 中携带的是开发者自定义的数据类型，如 RDD[User]或是 RDD[Product]，Tungsten 就会两眼一抹黑，完全不知道你的 User 和 Product 抽象到底是什么。成也萧何、败也萧何，RDD 的通用性是一柄双刃剑，在提供开发灵活性的同时，也让引擎内核的优化变得无比困难。

总的来说，基于 DataFrame 简单的标量算子和明确的 Schema 定义，借助 Catalyst 优化器和 Tungsten，Spark SQL 有能力在运行时构建起一套端到端的优化机制。这套机制运用启发式的规则与策略，以及运行时的执行信息，将原本次优、甚至是低效的查询计划转换为高效的执行计划，从而提升端到端的执行性能。因此，在 DataFrame 的开发框架下，不论你使用哪种开发语言，开发者都能共享 Spark SQL 带来的性能福利。

不同开发模式下的分布式执行过程

最后，我们再来回顾最开始提到的面试题：“从 2.0 版本至今，Spark 对于其他子框架的完善与优化，相比 Spark SQL 占比很低。这是否意味着，Spark 未来的发展重心是数据分析，其他场景如机器学习、流计算会逐渐边缘化吗？”

最初，Spark SQL 确实仅仅是运行 SQL 和 DataFrame 应用的子框架，但随着优化机制的日趋完善，Spark SQL 逐渐取代 Spark Core，演进为新一代的引擎内核。到目前为止，所有子框架的源码实现都已从 RDD 切换到 DataFrame。因此，和 PySpark 一样，像 Streaming、Graph、Mllib 这些子框架实际上都是通过 DataFrame API 运行在 Spark SQL 之上，它们自然可以共享 Spark SQL 引入的种种优化机制。

形象地说，Spark SQL 就像是 Spark 的智能大脑，凡是通过 DataFrame 这双“眼睛”看到的问题，都会经由智能大脑这个指挥中心，统筹地进行分析与优化，优化得到的行动指令，最终再交由 Executors 这些“四肢”去执行。

小结

今天，我们围绕 RDD 的核心痛点，探讨了 DataFrame 出现的必然性，Spark Core 的局限性，以及它和 Spark SQL 的关系，对 Spark SQL 有了更深刻的理解。

RDD 的核心痛点是优化空间有限，它指的是 RDD 高阶算子中封装的函数对于 Spark 来说完全透明，因此 Spark 对于计算逻辑的优化无从下手。

相比 RDD，DataFrame 是携带 Schema 的分布式数据集，只能封装结构化数据。DataFrame 的算子大多数都是普通的标量函数，以消费数据列为主。但是，DataFrame 更弱的表示能力和表达能力，反而为 Spark 引擎的内核优化打开了全新的空间。

根据 DataFrame 简单的标量算子和明确的 Schema 定义，借助 Catalyst 优化器和 Tungsten，Spark SQL 有能力在运行时，构建起一套端到端的优化机制。这套机制运用启发式的规则与策略和运行时的执行信息，将原本次优、甚至是低效的查询计划转换为高效的执行计划，从而提升端到端的执行性能。

在 DataFrame 的开发模式下，所有子框架、以及 PySpark，都运行在 Spark SQL 之上，都可以共享 Spark SQL 提供的种种优化机制，这也是为什么 Spark 历次发布新版本、Spark SQL 占比最大的根本原因。

每日一练

Java Object 在对象存储上为什么会有比较大的开销？JVM 需要多少个字节才能存下字符串“abcd”？

在 DataFrame 的开发框架下， PySpark 中还有哪些操作是“顽固分子”，会导致计算与数据在 JVM 进程与 Python 进程之间频繁交互？(提示：参考 RDD 的局限性，那些对 Spark 透明的计算逻辑，Spark 是没有优化空间的)

期待在留言区看到你的思考和答案，我们下一讲见！

21 | Catalyst逻辑计划：你的SQL语句是怎么被优化的？（上）

你好，我是吴磊。

上一讲我们说，Spark SQL 已经取代 Spark Core 成为了新一代的内核优化引擎，所有 Spark 子框架都能共享 Spark SQL 带来的性能红利，所以在 Spark 历次发布的新版本中，Spark SQL 占比最大。因此，Spark SQL 的优化过程是我们必须要掌握的。

Spark SQL 端到端的完整优化流程主要包括两个阶段：Catalyst 优化器和 Tungsten。其中，Catalyst 优化器又包含逻辑优化和物理优化两个阶段。为了把开发者的查询优化到极致，整个优化过程的运作机制设计得都很精密，因此我会用三讲的时间带你详细探讨。

下图就是这个过程的完整图示，你可以先通过它对优化流程有一个整体的认知。然后随着我的讲解，逐渐去夯实其中的关键环节、重要步骤和核心知识点，在深入局部优化细节的同时，把握全局优化流程，做到既见树木、也见森林。

Spark SQL的优化过程

今天这一讲，我们先来说说 Catalyst 优化器逻辑优化阶段的工作原理。

案例：小 Q 变身记

我们先来看一个例子，例子来自电子商务场景，业务需求很简单：给定交易事实表 transactions 和用户维度表 users，统计不同用户的交易额，数据源以 Parquet 的格式存储在分布式文件系统。因此，我们要先用 Parquet API 读取源文件。

val userFile: String = _
val usersDf = spark.read.parquet(userFile)
usersDf.printSchema
/**
root
|-- userId: integer (nullable = true)
|-- name: string (nullable = true)
|-- age: integer (nullable = true)
|-- gender: string (nullable = true)
|-- email: string (nullable = true)
*/
val users = usersDf
.select("name", "age", "userId")
.filter($"age" < 30)
.filter($"gender".isin("M"))

val txFile: String = _
val txDf = spark.read.parquet(txFile)
txDf.printSchema
/**
root
|-- itemId: integer (nullable = true)
|-- userId: integer (nullable = true)
|-- price: float (nullable = true)
|-- quantity: integer (nullable = true)
*/

val result = txDF.select("price", "volume", "userId")
.join(users, Seq("userId"), "inner")
.groupBy(col("name"), col("age")).agg(sum(col("price") * col("volume")).alias("revenue"))

result.write.parquet("_")

代码示例如上图所示，为了实现业务逻辑，我们对过滤之后的用户表与交易表做内关联，然后再按照用户分组去计算交易额。不难发现，这个计算逻辑实际上就是星型数仓中典型的关联查询。为了叙述方便，我们给这个关联查询起个名字：小 Q。小 Q 的计算需要两个输入源，一个是交易表，另一个是过滤之后的用户表。今天这一讲，我们就去追随小 Q，看看它在 Catalyst 的逻辑优化阶段都会发生哪些变化。

小Q的计算逻辑

Catalyst 逻辑优化阶段分为两个环节：逻辑计划解析和逻辑计划优化。在逻辑计划解析中，Catalyst 把“Unresolved Logical Plan”转换为“Analyzed Logical Plan”；在逻辑计划优化中，Catalyst 基于一些既定的启发式规则（Heuristics Based Rules），把“Analyzed Logical Plan”转换为“Optimized Logical Plan”。

Catalyst逻辑优化阶段

因为“Unresolved Logical Plan”是 Catalyst 优化的起点，所以在进入 Catalyst 优化器之前，小 Q 先是改头换面，从代码中的查询语句，摇身变成了“Unresolved Logical Plan”。

小Q启程：Unresolved Logical Plan

逻辑计划解析

小 Q 成功进入 Catalyst 优化器之后，就要开始执行逻辑计划解析，也就是要从“Unresolved Logical Plan”转换为“Analyzed Logical Plan”。那么，具体该怎么做呢？

从“小 Q 启程”那张图我们不难发现，“Unresolved Logical Plan”携带的信息相当有限，它只包含查询语句从 DSL 语法变换成 AST 语法树的信息。需要说明的是，不论是逻辑计划还是物理计划，执行的次序都是自下向上。因此，图中逻辑计划的计算顺序是从全表扫描到按性别过滤，每个步骤的含义都是准备“做什么”。

例如，在计划的最底层，Relation 节点“告诉”Catalyst：“你需要扫描一张表，这张表有 4 个字段，分别是 ABCD，文件格式是 Parquet”。但这些信息对于小 Q 的优化还远远不够，我们还需要知道这张表的 Schema 是啥？字段的类型都是什么？字段名是否真实存在？数据表中的字段名与计划中的字段名是一致的吗？

因此，在逻辑计划解析环节，Catalyst 就是要结合 DataFrame 的 Schema 信息，来确认计划中的表名、字段名、字段类型与实际数据是否一致。完成确认之后，Catalyst 会生成“Analyzed Logical Plan”。这个时候，小 Q 就会从“Unresolved Logical Plan”转换成“Analyzed Logical Plan”。

从下图中我们能够看到，逻辑计划已经完成了一致性检查，并且可以识别两张表的字段类型，比如 userId 的类型是 int，price 字段的类型是 double 等等。

小Q再变身：Analyzed Logical Plan

逻辑计划优化

对于现在的小 Q 来说，如果我们不做任何优化，直接把它转换为物理计划也可以。但是，这种照搬开发者的计算步骤去制定物理计划的方式，它的执行效率往往不是最优的。

为什么这么说呢？在运行时，Spark 会先全量扫描 Parquet 格式的用户表，然后遴选出 userId、name、age、gender 四个字段，接着分别按照年龄和性别对数据进行过滤。

对于这样的执行计划来说，最开始的全量扫描显然是一种浪费。原因主要有两方面：一方面，查询实际上只涉及 4 个字段，并不需要 email 这一列数据；另一方面，字段 age 和 gender 上带有过滤条件，我们完全可以利用这些过滤条件减少需要扫描的数据量。

由此可见，对于同样一种计算逻辑，实现方式可以有多种，按照不同的顺序对算子做排列组合，我们就可以演化出不同的实现方式。最好的方式是，我们遵循“能省则省、能拖则拖”的开发原则，去选择所有实现方式中最优的那个。

同样，在面对这种“选择题”的时候，Catalyst 也有一套自己的“原则”和逻辑。因此，生成“Analyzed Logical Plan”之后，Catalyst 并不会止步于此，它会基于一套启发式的规则，把“Analyzed Logical Plan”转换为“Optimized Logical Plan”。

逻辑优化环节

那么问题来了，Catalyst 都有哪些既定的规则和逻辑呢？基于这些规则，Catalyst 又是怎么做转换的呢？别着急，我们一个一个来解答，咱们先来说说 Catalyst 的优化规则，然后再去探讨逻辑计划的转换过程。

Catalyst 的优化规则

和 Catalyst 相比，咱们总结出的开发原则简直就是小巫见大巫，为什么这么说呢？在新发布的 Spark 3.0 版本中，Catalyst 总共有 81 条优化规则（Rules），这 81 条规则会分成 27 组（Batches），其中有些规则会被收纳到多个分组里。因此，如果不考虑规则的重复性，27 组算下来总共会有 129 个优化规则。

对于如此多的优化规则，我们该怎么学呢？实际上，如果从优化效果的角度出发，这些规则可以归纳到以下 3 个范畴：

谓词下推（Predicate Pushdown）

列剪裁（Column Pruning）

常量替换 （Constant Folding）

首先，我们来说说谓词下推谓词下推主要是围绕着查询中的过滤条件做文章。其中，“谓词”指代的是像用户表上“age < 30”这样的过滤条件，“下推”指代的是把这些谓词沿着执行计划向下，推到离数据源最近的地方，从而在源头就减少数据扫描量。换句话说，让这些谓词越接近数据源越好。

不过，在下推之前，Catalyst 还会先对谓词本身做一些优化，比如像 OptimizeIn 规则，它会把“gender in ‘M’”优化成“gender = ‘M’”，也就是把谓词 in 替换成等值谓词。再比如，CombineFilters 规则，它会把“age < 30”和“gender = ‘M’”这两个谓词，捏合成一个谓词：“age != null AND gender != null AND age <30 AND gender = ‘M’”。

完成谓词本身的优化之后，Catalyst 再用 PushDownPredicte 优化规则，把谓词推到逻辑计划树最下面的数据源上。对于 Parquet、ORC 这类存储格式，结合文件注脚（Footer）中的统计信息，下推的谓词能够大幅减少数据扫描量，降低磁盘 I/O 开销。

再来说说列剪裁。列剪裁就是扫描数据源的时候，只读取那些与查询相关的字段。以小 Q 为例，用户表的 Schema 是（userId、name、age、gender、email），但是查询中压根就没有出现过 email 的引用，因此，Catalyst 会使用 ColumnPruning 规则，把 email 这一列“剪掉”。经过这一步优化，Spark 在读取 Parquet 文件的时候就会跳过 email 这一列，从而节省 I/O 开销。

不难发现，谓词下推与列剪裁的优化动机，其实和“能省则省”的原则一样。核心思想都是用尽一切办法，减少需要扫描和处理的数据量，降低后续计算的负载。

最后一类优化是常量替换，它的逻辑比较简单。假设我们在年龄上加的过滤条件是“age < 12 + 18”，Catalyst 会使用 ConstantFolding 规则，自动帮我们把条件变成“age < 30”。再比如，我们在 select 语句中，掺杂了一些常量表达式，Catalyst 也会自动地用表达式的结果进行替换。

到此为止，咱们从功用和效果的角度，探讨了 Catalyst 逻辑优化规则的 3 大范畴。你可能说：“拢共就做了这么 3 件事，至于兴师动众地制定 81 条规则吗？”我们划分这 3 大范畴，主要是为了叙述和理解上的方便。实际上，对于开发者写出的五花八门、千奇百怪的查询语句，正是因为 Catalyst 不断丰富的优化规则，才让这些查询都能够享有不错的执行性能。如果没有这些优化规则的帮忙，小 Q 的执行性能一定会惨不忍睹。

最终，被 Catalyst 优化过后的小 Q，就从“Analyzed Logical Plan”转换为“Optimized Logical Plan”，如下图所示。我们可以看到，谓词下推和列剪裁都体现到了 Optimized Logical Plan 中。

小Q再变身：Optimized Logical Plan

Catalys 的优化过程

接下来，我继续来回答刚刚提出的第二个问题：基于这么多优化规则，Catalyst 具体是怎么把“Analyzed Logical Plan”转换成“Optimized Logical Plan”的呢？其实，不管是逻辑计划（Logical Plan）还是物理计划（Physical Plan），它们都继承自 QueryPlan。

QueryPlan 的父类是 TreeNode，TreeNode 就是语法树中对于节点的抽象。TreeNode 有一个名叫 children 的字段，类型是 Seq[TreeNode]，利用 TreeNode 类型，Catalyst 可以很容易地构建一个树结构。

除了 children 字段，TreeNode 还定义了很多高阶函数，其中最值得关注的是一个叫做 transformDown 的方法。transformDown 的形参，正是 Catalyst 定义的各种优化规则，方法的返回类型还是 TreeNode。另外，transformDown 是个递归函数，参数的优化规则会先作用（Apply）于当前节点，然后依次作用到 children 中的子节点，直到整棵树的叶子节点。

总的来说，从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换，就是从一个 TreeNode 生成另一个 TreeNode 的过程。Analyzed Logical Plan 的根节点，通过调用 transformDown 方法，不停地把各种优化规则作用到整棵树，直到把所有 27 组规则尝试完毕，且树结构不再发生变化为止。这个时候，生成的 TreeNode 就是 Optimized Logical Plan。

为了把复杂问题简单化，我们使用 Expression，也就是表达式来解释一下这个过程。因为 Expression 本身也继承自 TreeNode，所以明白了这个例子，TreeNode 之间的转换我们也就清楚了。

//Expression的转换
import org.apache.spark.sql.catalyst.expressions._
val myExpr: Expression = Multiply(Subtract(Literal(6), Literal(4)), Subtract(Literal(1), Literal(9)))
val transformed: Expression = myExpr transformDown {
  case BinaryOperator(l, r) => Add(l, r)
  case IntegerLiteral(i) if i > 5 => Literal(1)
  case IntegerLiteral(i) if i < 5 => Literal(0)
}

首先，我们定义了一个表达式：（（6 - 4）*（1 - 9）），然后我们调用这个表达式的 transformDown 高阶函数。在高阶函数中，我们提供了一个用 case 定义的匿名函数。显然，这是一个偏函数（Partial Functions），你可以把这个匿名函数理解成“自定义的优化规则”。在这个优化规则中，我们仅考虑 3 种情况：

对于所有的二元操作符，我们都把它转化成加法操作

对于所有大于 5 的数字，我们都把它变成 1

对于所有小于 5 的数字，我们都把它变成 0

虽然我们的优化规则没有任何实质性的意义，仅仅是一种转换规则而已，但是这并不妨碍你去理解 Catalyst 中 TreeNode 之间的转换。当我们把这个规则应用到表达式（（6 - 4）*（1 - 9））之后，得到的结果是另外一个表达式（（1 + 0）+（0 + 1）），下面的示意图直观地展示了这个过程。

自顶向下对执行计划进行转换

从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换，与示例中表达式的转换过程如出一辙。最主要的区别在于，Catalyst 的优化规则要复杂、精密得多。

Cache Manager 优化

从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换，Catalyst 除了使用启发式的规则以外，还会利用 Cache Manager 做进一步的优化。

这里的 Cache 指的就是我们常说的分布式数据缓存。想要对数据进行缓存，你可以调用 DataFrame 的.cache 或.persist，或是在 SQL 语句中使用“cache table”关键字。

Cache Manager 其实很简单，它的主要职责是维护与缓存有关的信息。具体来说，Cache Manager 维护了一个 Mapping 映射字典，字典的 Key 是逻辑计划，Value 是对应的 Cache 元信息。

当 Catalyst 尝试对逻辑计划做优化时，会先尝试对 Cache Manager 查找，看看当前的逻辑计划或是逻辑计划分支，是否已经被记录在 Cache Manager 的字典里。如果在字典中可以查到当前计划或是分支，Catalyst 就用 InMemoryRelation 节点来替换整个计划或是计划的一部分，从而充分利用已有的缓存数据做优化。

小结

今天这一讲，我们主要探讨了 Catalyst 优化器的逻辑优化阶段。这个阶段包含两个环节：逻辑计划解析和逻辑计划优化。

在逻辑计划解析环节，Catalyst 结合 Schema 信息，对于仅仅记录语句字符串的 Unresolved Logical Plan，验证表名、字段名与实际数据的一致性。解析后的执行计划称为 Analyzed Logical Plan。

在逻辑计划优化环节，Catalyst 会同时利用 3 方面的力量优化 Analyzed Logical Plan，分别是 AQE、Cache Manager 和启发式的规则。它们当中，Catalyst 最倚重的是启发式的规则。

尽管启发式的规则多达 81 项，但我们把它们归纳为 3 大范畴：谓词下推、列剪裁和常量替换。我们要重点掌握谓词下推和列剪裁，它们的优化动机和“能省则省”的开发原则一样，核心思想都是用尽一切办法，减少需要扫描和处理的数据量，降低后续计算的负载。

针对所有的优化规则，Catalyst 优化器会通过调用 TreeNode 中的 transformDown 高阶函数，分别把它们作用到逻辑计划的每一个节点上，直到逻辑计划的结构不再改变为止，这个时候生成的逻辑计划就是 Optimized Logical Plan。

最后，Cache Manager 的作用是提供逻辑计划与数据缓存的映射关系，当现有逻辑计划或是分支出现在 Cache Manager 维护的映射字典的时候，Catalyst 可以充分利用已有的缓存数据来优化。

每日一练

既然 Catalyst 在逻辑优化阶段有 81 条优化规则，我们还需要遵循“能省则省、能拖则拖”的开发原则吗？

你能说说 Spark 为什么用偏函数，而不是普通函数来定义 Catalyst 的优化规则吗？

期待在留言区看到你的思考和答案，我们下一讲见！

22 | Catalyst物理计划：你的SQL语句是怎么被优化的（下）？

你好，我是吴磊。

上一讲我们说了，Catalyst 优化器的逻辑优化过程包含两个环节：逻辑计划解析和逻辑计划优化。逻辑优化的最终目的就是要把 Unresolved Logical Plan 从次优的 Analyzed Logical Plan 最终变身为执行高效的 Optimized Logical Plan。

但是，逻辑优化的每一步仅仅是从逻辑上表明 Spark SQL 需要“做什么”，并没有从执行层面说明具体该“怎么做”。因此，为了把逻辑计划交付执行，Catalyst 还需要把 Optimized Logical Plan 转换为物理计划。物理计划比逻辑计划更具体，它明确交代了 Spark SQL 的每一步具体该怎么执行。

物理计划阶段

今天这一讲，我们继续追随小 Q 的脚步，看看它经过 Catalyst 的物理优化阶段之后，还会发生哪些变化。

优化 Spark Plan

物理阶段的优化是从逻辑优化阶段输出的 Optimized Logical Plan 开始的，因此我们先来回顾一下小 Q 的原始查询和 Optimized Logical Plan。

val userFile: String = _
val usersDf = spark.read.parquet(userFile)
usersDf.printSchema
/**
root
|-- userId: integer (nullable = true)
|-- name: string (nullable = true)
|-- age: integer (nullable = true)
|-- gender: string (nullable = true)
|-- email: string (nullable = true)
*/
val users = usersDf
.select("name", "age", "userId")
.filter($"age" < 30)
.filter($"gender".isin("M"))

val txFile: String = _
val txDf = spark.read.parquet(txFile)
txDf.printSchema
/**
root
|-- txId: integer (nullable = true)
|-- userId: integer (nullable = true)
|-- price: float (nullable = true)
|-- volume: integer (nullable = true)
*/

val result = txDF.select("price", "volume", "userId")
.join(users, Seq("userId"), "inner")
.groupBy(col("name"), col("age")).agg(sum(col("price") * col("volume")).alias("revenue"))

result.write.parquet("_")

两表关联的查询语句经过转换之后，得到的 Optimized Logical Plan 如下图所示。注意，在逻辑计划的根节点，出现了“Join Inner”字样，Catalyst 优化器明确了这一步需要做内关联。但是，怎么做内关联，使用哪种 Join 策略来进行关联，Catalyst 并没有交代清楚。因此，逻辑计划本身不具备可操作性。

小Q变身：Optimized Logical Plan

为了让查询计划（Query Plan）变得可操作、可执行，Catalyst 的物理优化阶段（Physical Planning）可以分为两个环节：优化 Spark Plan 和生成 Physical Plan。

在优化 Spark Plan 的过程中，Catalyst 基于既定的优化策略（Strategies），把逻辑计划中的关系操作符一一映射成物理操作符，生成 Spark Plan。

在生成 Physical Plan 过程中，Catalyst 再基于事先定义的 Preparation Rules，对 Spark Plan 做进一步的完善、生成可执行的 Physical Plan。

那么问题来了，在优化 Spark Plan 的过程中，Catalyst 都有哪些既定的优化策略呢？从数量上来说，Catalyst 有 14 类优化策略，其中有 6 类和流计算有关，剩下的 8 类适用于所有的计算场景，如批处理、数据分析、机器学习和图计算，当然也包括流计算。因此，我们只需了解这 8 类优化策略。

Catalyst物理优化阶段的14个优化策略

所有优化策略在转换方式上都大同小异，都是使用基于模式匹配的偏函数（Partial Functions），把逻辑计划中的操作符平行映射为 Spark Plan 中的物理算子。比如，BasicOperators 策略直接把 Project、Filter、Sort 等逻辑操作符平行地映射为物理操作符。其他策略的优化过程也类似，因此，在优化 Spark Plan 这一环节，咱们只要抓住一个“典型”策略，掌握它的转换过程即可。

那我们该抓谁做“典型”呢？我觉得，这个“典型”至少要满足两个标准：一，它要在我们的应用场景中非常普遍；二，它的取舍对于执行性能的影响最为关键。以这两个标准去遴选上面的 8 类策略，我们分分钟就能锁定 JoinSelection。接下来，我们就以 JoinSelection 为例，详细讲解这一环节的优化过程。

如果用一句话来概括 JoinSelection 的优化过程，就是结合多方面的信息，来决定在物理优化阶段采用哪种 Join 策略。那么问题来了，Catalyst 都有哪些 Join 策略？

Catalyst 都有哪些 Join 策略？

结合 Joins 的实现机制和数据的分发方式，Catalyst 在运行时总共支持 5 种 Join 策略，分别是 Broadcast Hash Join（BHJ）、Shuffle Sort Merge Join（SMJ）、Shuffle Hash Join（SHJ）、Broadcast Nested Loop Join（BNLJ）和 Shuffle Cartesian Product Join（CPJ）。

5种Join策略及其含义

通过上表中 5 种 Join 策略的含义，我们知道，它们是来自 2 种数据分发方式（广播和 Shuffle）与 3 种 Join 实现机制（Hash Joins、Sort Merge Joins 和 Nested Loop Joins）的排列组合。那么，在 JoinSelection 的优化过程中，Catalyst 会基于什么逻辑，优先选择哪种 Join 策略呢？

数据分发方式与Join实现机制的排列组合

JoinSelection 如何决定选择哪一种 Join 策略？

逻辑其实很简单，Catalyst 总会尝试优先选择执行效率最高的策略。具体来说，在选择 join 策略的时候，JoinSelection 会先判断当前查询是否满足 BHJ 所要求的先决条件：如果满足就立即选中 BHJ；如果不满足，就继续判断当前查询是否满足 SMJ 的先决条件。以此类推，直到最终选无可选，用 CPJ 来兜底。

那么问题来了，这 5 种 Join 策略都需要满足哪些先决条件呢？换句话说，JoinSelection 做决策时都要依赖哪些信息呢？

总的来说，这些信息分为两大类，第一类是“条件型”信息，用来判决 5 大 Join 策略的先决条件。第二类是“指令型”信息，也就是开发者提供的 Join Hints。

我们先来说“条件型”信息，它包含两种。第一种是 Join 类型，也就是是否等值、连接形式等，这种信息的来源是查询语句本身。第二种是内表尺寸，这些信息的来源就比较广泛了，可以是 Hive 表之上的 ANALYZE TABLE 语句，也可以是 Spark 对于 Parquet、ORC、CSV 等源文件的尺寸预估，甚至是来自 AQE 的动态统计信息。

5 大 Join 策略对于这些信息的要求，我都整理到了下面的表格里，你可以看一看。

5种Join策略的先决条件

指令型信息也就是 Join Hints，它的种类非常丰富，它允许我们把个人意志凌驾于 Spark SQL 之上。比如说，如果我们对小 Q 的查询语句做了如下的调整，JoinSelection 在做 Join 策略选择的时候就会优先尊重我们的意愿，跳过 SMJ 去选择排序更低的 SHJ。具体的代码示例如下：

val result = txDF.select("price", "volume", "userId")
.join(users.hint("shuffle_hash"), Seq("userId"), "inner")
.groupBy(col("name"), col("age")).agg(sum(col("price") *
col("volume")).alias("revenue"))

熟悉了 JoinSelection 选择 Join 策略的逻辑之后，我们再来看小 Q 是怎么选择的。小 Q 是典型的星型查询，也就是事实表与维度表之间的数据关联，其中维表还带过滤条件。在决定采用哪种 Join 策略的时候，JoinSelection 优先尝试判断小 Q 是否满足 BHJ 的先决条件。

显然，小 Q 是等值的 Inner Join，因此表格中 BHJ 那一行的前两个条件小 Q 都满足。但是，内表 users 尺寸较大，超出了广播阈值的默认值 10MB，不满足 BHJ 的第三个条件。因此，JoinSelection 不得不忍痛割爱、放弃 BHJ 策略，只好退而求其次，沿着表格继续向下，尝试判断小 Q 是否满足 SMJ 的先决条件。

SMJ 的先决条件很宽松，查询语句只要是等值 Join 就可以。小 Q 自然是满足这个条件的，因此 JoinSelection 最终给小 Q 选定的 Join 策略就是 SMJ。下图是小 Q 优化过后的 Spark Plan，从中我们可以看到，查询计划的根节点正是 SMJ。

小Q再变身：Spark Plan

现在我们知道了 Catalyst 都有哪些 Join 策略，JoinSelection 如何对不同的 Join 策略做选择。小 Q 也从 Optimized Logical Plan 摇身一变，转换成了 Spark Plan，也明确了在运行时采用 SMJ 来做关联计算。不过，即使小 Q 在 Spark Plan 中已经明确了每一步该“怎么做”，但是，Spark 还是做不到把这样的查询计划转化成可执行的分布式任务，这又是为什么呢？

生成 Physical Plan

原来，Shuffle Sort Merge Join 的计算需要两个先决条件：Shuffle 和排序。而 Spark Plan 中并没有明确指定以哪个字段为基准进行 Shuffle，以及按照哪个字段去做排序。

因此，Catalyst 需要对 Spark Plan 做进一步的转换，生成可操作、可执行的 Physical Plan。具体怎么做呢？我们结合 Catalyst 物理优化阶段的流程图来详细讲讲。

物理计划阶段

从上图中我们可以看到，从 Spark Plan 到 Physical Plan 的转换，需要几组叫做 Preparation Rules 的规则。这些规则坚守最后一班岗，负责生成 Physical Plan。那么，这些规则都是什么，它们都做了哪些事情呢？我们一起来看一下。

Preparation Rules

Preparation Rules 有 6 组规则，这些规则作用到 Spark Plan 之上就会生成 Physical Plan，而 Physical Plan 最终会由 Tungsten 转化为用于计算 RDD 的分布式任务。

小 Q 的查询语句很典型，也很简单，并不涉及子查询，更不存在 Python UDF。因此，在小 Q 的例子中，我们并不会用到子查询、数据复用或是 Python UDF 之类的规则，只有 EnsureRequirements 和 CollapseCodegenStages 这两组规则会用到小 Q 的 Physical Plan 转化中。

实际上，它们也是结构化查询中最常见、最常用的两组规则。今天，我们先来重点说说 EnsureRequirements 规则的含义和作用。至于 CollapseCodegenStages 规则，它实际上就是 Tungsten 的 WSCG 功能，我们下一讲再详细说。

EnsureRequirements 规则

EnsureRequirements 翻译过来就是“确保满足前提条件”，这是什么意思呢？对于执行计划中的每一个操作符节点，都有 4 个属性用来分别描述数据输入和输出的分布状态。

描述输入、输出要求的4个属性

EnsureRequirements 规则要求，子节点的输出数据要满足父节点的输入要求。这又怎么理解呢？

Project没有满足SortMergeJoin的Requirements

我们以小 Q 的 Spark Plan 树形结构图为例，可以看到：图中左右两个分支分别表示扫描和处理 users 表和 transactions 表。在树的最顶端，根节点 SortMergeJoin 有两个 Project 子节点，它们分别用来表示 users 表和 transactions 表上的投影数据。这两个 Project 的 outputPartitioning 属性和 outputOrdering 属性分别是 Unknow 和 None。因此，它们输出的数据没有按照任何列进行 Shuffle 或是排序。

但是，SortMergeJoin 对于输入数据的要求很明确：按照 userId 分成 200 个分区且排好序，而这两个 Project 子节点的输出显然并没有满足父节点 SortMergeJoin 的要求。这个时候，EnsureRequirements 规则就要介入了，它通过添加必要的操作符，如 Shuffle 和排序，来保证 SortMergeJoin 节点对于输入数据的要求一定要得到满足，如下图所示。

EnsureRequirements规则添加Exchange和Sort操作

在两个 Project 节点之后，EnsureRequirements 规则分别添加了 Exchange 和 Sort 节点。其中 Exchange 节点代表 Shuffle 操作，用来满足 SortMergeJoin 对于数据分布的要求；Sort 表示排序，用于满足 SortMergeJoin 对于数据有序的要求。

添加了必需的节点之后，小 Q 的 Physical Plan 已经相当具体了。这个时候，Spark 可以通过调用 Physical Plan 的 doExecute 方法，把结构化查询的计算结果，转换成 RDD[InternalRow]，这里的 InternalRow，就是 Tungsten 设计的定制化二进制数据结构，这个结构我们在内存视角（一）有过详细的讲解，你可以翻回去看看。通过调用 RDD[InternalRow]之上的 Action 算子，Spark 就可以触发 Physical Plan 从头至尾依序执行。

最后，我们再来看看小 Q 又发生了哪些变化。

小Q再变身：Physical Plan

首先，我们看到 EnsureRequirements 规则在两个分支的顶端分别添加了 Exchange 和 Sort 操作，来满足根节点 SortMergeJoin 的计算需要。其次，如果你仔细观察的话，会发现 Physical Plan 中多了很多星号“*”，这些星号的后面还带着括号和数字，如图中的“*（3）”、“*（1）”。这种星号“*”标记表示的就是 WSCG，后面的数字代表 Stage 编号。因此，括号中数字相同的操，最终都会被捏合成一份“手写代码”，也就是我们下一讲要说的 Tungsten 的 WSCG。

至此，小 Q 从一个不考虑执行效率的“叛逆少年”，就成长为了一名执行高效的“专业人士”，Catalyst 这位人生导师在其中的作用功不可没。

小结

为了把逻辑计划转换为可以交付执行的物理计划，Spark SQL 物理优化阶段包含两个环节：优化 Spark Plan 和生成 Physical Plan。

在优化 Spark Plan 这个环节，Catalyst 基于既定的策略把逻辑计划平行映射为 Spark Plan。策略很多，我们重点掌握 JoinSelection 策略就可以，它被用来在运行时选择最佳的 Join 策略。JoinSelection 按照 BHJ > SMJ > SHJ > BNLJ > CPJ 的顺序，依次判断查询语句是否满足每一种 Join 策略的先决条件进行“择优录取”。

如果开发者不满足于 JoinSelection 默认的选择顺序，也就是 BHJ > SMJ > SHJ > BNLJ > CPJ，还可以通过在 SQL 或是 DSL 语句中引入 Join hints，来明确地指定 Join 策略，从而把自己的意愿凌驾于 Catalyst 之上。不过，需要我们注意的是，要想让指定的 Join 策略在运行时生效，查询语句也必须要满足其先决条件才行。

在生成 Physical Plan 这个环节，Catalyst 基于既定的几组 Preparation Rules，把优化过后的 Spark Plan 转换成可以交付执行的物理计划，也就是 Physical Plan。在这些既定的 Preparation Rules 当中，你需要重点掌握 EnsureRequirements 规则。

EnsureRequirements 用来确保每一个操作符的输入条件都能够得到满足，在必要的时候，会把必需的操作符强行插入到 Physical Plan 中。比如对于 Shuffle Sort Merge Join 来说，这个操作符对于子节点的数据分布和顺序都是有明确要求的，因此，在子节点之上，EnsureRequirements 会引入新的操作符如 Exchange 和 Sort。

每日一练

3 种 Join 实现方式和 2 种网络分发模式，明明应该有 6 种 Join 策略，为什么 Catalyst 没有支持 Broadcast Sort Merge Join 策略？

期待在留言区看到你的思考和答案，我们下一讲见！

23 | 钨丝计划：Tungsten给开发者带来了哪些福报？

你好，我是吴磊。

通过前两讲的学习，我们知道在 Spark SQL 这颗智能大脑中，“左脑”Catalyst 优化器负责把查询语句最终转换成可执行的 Physical Plan。但是，把 Physical Plan 直接丢给 Spark 去执行并不是最优的选择，最优的选择是把它交给“右脑”Tungsten 再做一轮优化。

Tungsten 又叫钨丝计划，它主要围绕内核引擎做了两方面的改进：数据结构设计和全阶段代码生成（WSCG，Whole Stage Code Generation）。

今天这一讲，我们就来说说 Tungsten 的设计初衷是什么，它的两方面改进到底解决了哪些问题，以及它给开发者到底带来了哪些性能红利。

Tungsten 在数据结构方面的设计

相比 Spark Core，Tungsten 在数据结构方面做了两个比较大的改进，一个是紧凑的二进制格式 Unsafe Row，另一个是内存页管理。我们一个一个来说。

Unsafe Row：二进制数据结构

Unsafe Row 是一种字节数组，它可以用来存储下图所示 Schema 为（userID，name，age，gender）的用户数据条目。总的来说，所有字段都会按照 Schema 中的顺序安放在数组中。其中，定长字段的值会直接安插到字节中，而变长字段会先在 Schema 的相应位置插入偏移地址，再把字段长度和字段值存储到靠后的元素中。更详细的例子我们在第 9 讲说过，你可以去看看。

二进制字节数组

那么，这种存储方式有什么优点呢？我们不妨用逆向思维来思考这个问题，如果采用 JVM 传统的对象方式来存储相同 Schema 的数据条目会发生什么。

JVM 至少需要 6 个对象才能存储一条用户数据。其中，GenericMutableRow 用于封装一条数据，Array 用于存储实际的数据值。Array 中的每个元素都是一个对象，如承载整型的 BoxedInteger、承载字符串的 String 等等。这样的存储方式有两个明显的缺点。

首先，存储开销大。我们拿类型是 String 的 name 来举例，如果一个用户的名字叫做“Mike”，它本应该只占用 4 个字节，但在 JVM 的对象存储中，“Mike”会消耗总共 48 个字节，其中包括 12 个字节的对象头信息、8 字节的哈希编码、8 字节的字段值存储和另外 20 个字节的其他开销。从 4 个字节到 48 个字节，存储开销可见一斑。

其次，在 JVM 堆内内存中，对象数越多垃圾回收效率越低。因此，一条数据记录用一个对象来封装是最好的。但是，我们从下图中可以看到，JVM 需要至少 6 个对象才能存储一条数据记录。如果你的样本数是 1 千亿的话，这意味着 JVM 需要管理 6 千亿的对象，GC 的压力就会陡然上升。

JVM存储数据条目

我们反过来再看 UnsafeRow，字节数组的存储方式在消除存储开销的同时，仅用一个数组对象就能轻松完成一条数据的封装，显著降低 GC 压力，可以说是一举两得。由此可见，Unsafe Row 带来的潜在性能收益还是相当可观的。不过，Tungsten 并未止步于此，为了统一堆外与堆内内存的管理，同时进一步提升数据存储效率与 GC 效率，Tungsten 还推出了基于内存页的内存管理模式。

基于内存页的内存管理

为了统一管理 Off Heap 和 On Heap 内存空间，Tungsten 定义了统一的 128 位内存地址，简称 Tungsten 地址。Tungsten 地址分为两部分：前 64 位预留给 Java Object，后 64 位是偏移地址 Offset。但是，同样是 128 位的 Tungsten 地址，Off Heap 和 On Heap 两块内存空间在寻址方式上截然不同。

对于 On Heap 空间的 Tungsten 地址来说，前 64 位存储的是 JVM 堆内对象的引用或者说指针，后 64 位 Offset 存储的是数据在该对象内的偏移地址。而 Off Heap 空间则完全不同，在堆外的空间中，由于 Spark 是通过 Java Unsafe API 直接管理操作系统内存，不存在内存对象的概念，因此前 64 位存储的是 null 值，后 64 位则用于在堆外空间中直接寻址操作系统的内存空间。

显然，在 Tungsten 模式下，管理 On Heap 会比 Off Heap 更加复杂。这是因为，在 On Heap 内存空间寻址堆内数据必须经过两步：第一步，通过前 64 位的 Object 引用来定位 JVM 对象；第二步，结合 Offset 提供的偏移地址在堆内内存空间中找到所需的数据。

JVM 对象地址与偏移地址的关系，就好比是数组的起始地址与数组元素偏移地址之间的关系。给定起始地址和偏移地址之后，系统就可以迅速地寻址到数据元素。因此，在上面的两个步骤中，如何通过 Object 引用来定位 JVM 对象就是关键了。接下来，我们就重点解释这个环节。

堆外、堆内不同的寻址方式

如上图所示，Tungsten 使用一种叫做页表（Page Table）的数据结构，来记录从 Object 引用到 JVM 对象地址的映射。页表中记录的是一个又一个内存页（Memory Page），内存页实际上就是一个 JVM 对象而已。只要给定 64 位的 Object 引用，Tungsten 就能通过页表轻松拿到 JVM 对象地址，从而完成寻址。

那么，Tungsten 使用这种方式来管理内存有什么收益呢？我们不妨以常用的 HashMap 数据结构为例，来对比 Java 标准库（java.util.HashMap）和 Tungsten 模式下的 HashMap。

java.util.HashMap

Java 标准库采用数组加链表的方式来实现 HashMap，如上图所示，数组元素存储 Hash code 和链表头。链表节点存储 3 个元素，分别是 Key 引用、Value 引用和下一个元素的地址。一般来说，如果面试官要求你实现一个 HashMap，我们往往也会采用这种实现方式。

但是，这种实现方式会带来两个弊端。

首先是存储开销和 GC 负担比较大。结合上面的示意图我们不难发现，存储数据的对象值只占整个 HashMap 一半的存储空间，另外一半的存储空间用来存储引用和指针，这 50% 的存储开销还是蛮大的。而且我们发现，图中每一个 Key、Value 和链表元素都是 JVM 对象。假设，我们用 HashMap 来存储一百万条数据条目，那么 JVM 对象的数量至少是三百万。由于 JVM 的 GC 效率与对象数量成反比，因此 java.util.HashMap 的实现方式对于 GC 并不友好。

其次，在数据访问的过程中，标准库实现的 HashMap 容易降低 CPU 缓存命中率，进而降低 CPU 利用率。链表这种数据结构的特点是，对写入友好，但访问低效。用链表存储数据的方式确实很灵活，这让 JVM 可以充分利用零散的内存区域，提升内存利用率。但是，在对链表进行全量扫描的时候，这种零散的存储方式会引入大量的随机内存访问（Random Memory Access）。相比顺序访问，随机内存访问会大幅降低 CPU cache 命中率。

Tungsten HashMap

那么，针对以上几个弊端，Tungsten 又是怎么解决的呢？我们从存储开销、GC 效率和 CPU cache 命中率分别来看。

首先，Tungsten 放弃了链表的实现方式，使用数组加内存页的方式来实现 HashMap。数组中存储的元素是 Hash code 和 Tungsten 内存地址，也就是 Object 引用外加 Offset 的 128 位地址。Tungsten HashMap 使用 128 位地址来寻址数据元素，相比 java.util.HashMap 大量的链表指针，在存储开销上更低。

其次，Tungsten HashMap 的存储单元是内存页，内存页本质上是 Java Object，一个内存页可以存储多个数据条目。因此，相比标准库中的 HashMap，使用内存页大幅缩减了存储所需的对象数量。比如说，我们需要存储一百万条数据记录，标准库的 HashMap 至少需要三百万的 JVM 对象才能存下，而 Tungsten HashMap 可能只需要几个或是十几个内存页就能存下。对比下来，它们所需的 JVM 对象数量可以说是天壤之别，显然，Tungsten 的实现方式对于 GC 更加友好。

再者，内存页本质上是 JVM 对象，其内部使用连续空间来存储数据，内存页加偏移量可以精准地定位到每一个数据元素。因此，在需要扫描 HashMap 全量数据的时候，得益于内存页中连续存储的方式，内存的访问方式从原来的随机访问变成了顺序读取（Sequential Access）。顺序内存访问会大幅提升 CPU cache 利用率，减少 CPU 中断，显著提升 CPU 利用率。

如何理解 WSCG？

接下来，我们再说说 WSCG。首先，WSCG 到底是什么？这就要提到内存计算的第二层含义了，它指的是在同一个 Stage 内部，把多个 RDD 的 compute 函数捏合成一个，然后把这一个函数一次性地作用在输入数据上。不过，这种捏合方式采用的是迭代器嵌套的方式。例如，土豆工坊中对于 Stage0 的处理，也就是下图中的 fuse 函数。它仅仅是 clean、slice、bake 三个函数的嵌套，并没有真正融合为一个函数。

内存计算的第二层含义

WSCG 指的是基于同一 Stage 内操作符之间的调用关系，生成一份“手写代码”，真正把所有计算融合为一个统一的函数。

什么是火山迭代模型？

那么，我们真的有必要把三个函数体融合成一个函数，甚至生成一份“手写代码”吗？迭代器嵌套的函数调用难道还不够吗？坦白说，迭代器嵌套还真不够。原因在于，迭代器嵌套的计算模式会涉及两种操作，一个是内存数据的随机存取，另一个是虚函数调用（next）。这两种操作都会降低 CPU 的缓存命中率，影响 CPU 的工作效率。这么说比较抽象，我们来举个小例子。

SQL查询与语法树

假设，现在有一张市民表，我们要从中统计在北京的人数。对应的语法树非常简单，从左到右，分别是数据扫描、过滤、投影和聚合。语法树先是经过“左脑”Catalyst 优化器转换为 Physical Plan，然后交付执行。Tungsten 出现以前，Spark 在运行时采用火山迭代模型来执行计算。这里，咱们需要先简单地介绍一下火山迭代模型（Volcano Iteration Model，以下简称 VI 模型）。

VI 模型这种计算模式依托 AST 语法树，对所有操作符（如过滤、投影）的计算进行了统一封装，所有操作符都要实现 VI 模型的迭代器抽象。简单来说就是，所有操作符都需要实现 hasNext 和 next 方法。因此，VI 模型非常灵活、扩展能力很强，任何一个算子只要实现了迭代器抽象，都可以加入到语法树当中参与计算。另外，为了方便操作符之间的数据交换，VI 模型对所有操作符的输出也做了统一的封装。

那么，如果上面的查询使用 VI 模型去执行计算的话，都需要经过哪些步骤呢？对于数据源中的每条数据条目，语法树当中的每个操作符都需要完成如下步骤：

从内存中读取父操作符的输出结果作为输入数据

调用 hasNext、next 方法，以操作符逻辑处理数据，如过滤、投影、聚合等等

将处理后的结果以统一的标准形式输出到内存，供下游算子消费

因此，任意两个操作符之间的交互都会涉及我们最开始说的两个步骤，也就是内存数据的随机存取和虚函数调用，而它们正是 CPU 有效利用率低下的始作俑者。

WSCG 的优势是什么？

Tungsten 引入 WSCG 机制，正是为了消除 VI 模型引入的计算开销。这是怎么做到的呢？接下来，咱们还是以市民表的查询为例，先来直观地感受一下 WSCG 的优势。

手写代码示例

对于刚刚的查询语句，WSCG 会结合 AST 语法树中不同算子的调用关系，生成如上图所示的“手写代码”。在这份手写代码中，我们把数据端到端的计算逻辑（过滤、投影、聚合）一次性地进行了实现。

这样一来，我们利用手写代码的实现方式不仅消除了操作符，也消除了操作符的虚函数调用，更没有不同算子之间的数据交换，计算逻辑完全是一次性地应用到数据上。而且，代码中的每一条指令都是明确的，可以顺序加载到 CPU 寄存器，源数据也可以顺序地加载到 CPU 的各级缓存中，从而大幅提升了 CPU 的工作效率。

当然，WSCG 在运行时生成的代码和我们这里举例的手写代码在形式上还有差别。不过，这也并不影响我们对于 WSCG 特性和优势的理解。看到这里，你可能会问：“WSCG 不就是运行时的代码重构吗？”没错，本质上，WSCG 机制的工作过程就是基于一份“性能较差的代码”，在运行时动态地（On The Fly）重构出一份“性能更好的代码”。

WSCG 是如何在运行时动态生成代码的？

问题来了，WSCG 是怎么在运行时动态生成代码的呢？

我们还是以刚刚市民表的查询为例，语法树从左到右有 Scan、Filter、Project 和 Aggregate4 个节点。不过，因为 Aggregate 会引入 Shuffle、切割 Stage，所以这 4 个节点会产生两个 Stage。又因为 WSCG 是在一个 Stage 内部生成手写代码，所以，我们把目光集中到前三个操作符 Scan、Filter 和 Project 构成的 Stage。

语法树的第一个Stage

上一讲中我们说了，Spark Plan 在转换成 Physical Plan 之前，会应用一系列的 Preparation Rules。这其中很重要的一环就是 CollapseCodegenStages 规则，它的作用正是尝试为每一个 Stages 生成“手写代码”。

总的来说，手写代码的生成过程分为两个步骤：

从父节点到子节点，递归调用 doProduce，生成代码框架

从子节点到父节点，递归调用 doConsume，向框架填充每一个操作符的运算逻辑

这么说比较抽象，咱们以上面的第一个 Stage 为例，来直观地看看这个代码生成的过程。

WSCG时序图

首先，在 Stage 顶端节点也就是 Project 之上，添加 WholeStageCodeGen 节点。WholeStageCodeGen 节点通过调用 doExecute 来触发整个代码生成过程的计算。doExecute 会递归调用子节点的 doProduce 函数，直到遇到 Shuffle Boundary 为止。这里，Shuffle Boundary 指的是 Shuffle 边界，要么是数据源，要么是上一个 Stage 的输出。在叶子节点（也就是 Scan）调用的 doProduce 函数会先把手写代码的框架生成出来，如图中右侧蓝色部分的代码。

然后，Scan 中的 doProduce 会反向递归调用每个父节点的 doConsume 函数。不同操作符在执行 doConsume 函数的过程中，会把关系表达式转化成 Java 代码，然后把这份代码像做“完形填空”一样，嵌入到刚刚的代码框架里。比如图中橘黄色的 doConsume 生成的 if 语句，其中包含了判断地区是否为北京的条件，以及紫色的 doConsume 生成了获取必需字段 userId 的 Java 代码。

就这样，Tungsten 利用 CollapseCodegenStages 规则，经过两层递归调用把 Catalyst 输出的 Spark Plan 加工成了一份“手写代码”，并把这份手写代码会交付给 DAGScheduler。拿到代码之后，DAGScheduler 再去协调自己的两个小弟 TaskScheduler 和 SchedulerBackend，完成分布式任务调度。

小结

Tungsten 是 Spark SQL 的“右脑”，掌握它的特性和优势对 SparkSQL 的性能调优来说至关重要。具体来说，我们可以从它对内核引擎的两方面改进入手：数据结构设计和 WSCG。

在数据结构方面，我们要掌握 Tungsten 的两项改进。

首先，Tungsten 设计了 UnsafeRow 二进制字节序列来取代 JVM 对象的存储方式。这不仅可以提升 CPU 的存储效率，还能减少存储数据记录所需的对象个数，从而改善 GC 效率。

其次，为了统一管理堆内与堆外内存，Tungsten 设计了 128 位的内存地址，其中前 64 位存储 Object 引用，后 64 位为偏移地址。

在堆内内存的管理上，基于 Tungsten 内存地址和内存页的设计机制，相比标准库，Tungsten 实现的数据结构（如 HashMap）使用连续空间来存储数据条目，连续内存访问有利于提升 CPU 缓存命中率，从而提升 CPU 工作效率。由于内存页本质上是 Java Object，内存页管理机制往往能够大幅削减存储数据所需的对象数量，因此对 GC 非常友好的。

对于 Tungsten 的 WSCG，我们要掌握它的概念和优势。

首先，WSCG 指的是基于同一 Stage 内操作符之间的调用关系，生成一份“手写代码”，来把所有计算融合为一个统一的函数。本质上，WSCG 机制的工作过程，就是基于一份“性能较差的代码”，在运行时动态地重构出一份“性能更好的代码”。

更重要的是，“手写代码”解决了 VI 计算模型的两个核心痛点：操作符之间频繁的虚函数调用，以及操作符之间数据交换引入的内存随机访问。手写代码中的每一条指令都是明确的，可以顺序加载到 CPU 寄存器，源数据也可以顺序地加载到 CPU 的各级缓存中，因此，CPU 的缓存命中率和工作效率都会得到大幅提升。

每日一练

针对排序操作，你认为 Tungsten 在数据结构方面有哪些改进呢？

你认为表达式代码生成（Expression Codegen）和全阶段代码生成（Whole Stage Codegen）有什么区别和联系呢？

期待在留言区看到你的思考和答案，我们下一讲见！

24 | Spark 3.0（一）：AQE的3个特性怎么才能用好？

你好，我是吴磊。

目前，距离 Spark 3.0 版本的发布已经将近一年的时间了，这次版本升级添加了自适应查询执行（AQE）、动态分区剪裁（DPP）和扩展的 Join Hints 等新特性。利用好这些新特性，可以让我们的性能调优如虎添翼。因此，我会用三讲的时间和你聊聊它们。今天，我们先来说说 AQE。

我发现，同学们在使用 AQE 的时候总会抱怨说：“AQE 的开关打开了，相关的配置项也设了，可应用性能还是没有提升。”这往往是因为我们对于 AQE 的理解不够透彻，调优总是照葫芦画瓢，所以这一讲，我们就先从 AQE 的设计初衷说起，然后说说它的工作原理，最后再去探讨怎样才能用好 AQE。

Spark 为什么需要 AQE？

在 2.0 版本之前，Spark SQL 仅仅支持启发式、静态的优化过程，就像我们在第 21、22、23 三讲介绍的一样。

启发式的优化又叫 RBO（Rule Based Optimization，基于规则的优化），它往往基于一些规则和策略实现，如谓词下推、列剪枝，这些规则和策略来源于数据库领域已有的应用经验。也就是说，启发式的优化实际上算是一种经验主义。

经验主义的弊端就是不分青红皂白、胡子眉毛一把抓，对待相似的问题和场景都使用同一类套路。Spark 社区正是因为意识到了 RBO 的局限性，因此在 2.2 版本中推出了 CBO（Cost Based Optimization，基于成本的优化）。

CBO 的特点是“实事求是”，基于数据表的统计信息（如表大小、数据列分布）来选择优化策略。CBO 支持的统计信息很丰富，比如数据表的行数、每列的基数（Cardinality）、空值数、最大值、最小值和直方图等等。因为有统计数据做支持，所以 CBO 选择的优化策略往往优于 RBO 选择的优化规则。

但是，CBO 也面临三个方面的窘境：“窄、慢、静”。窄指的是适用面太窄，CBO 仅支持注册到 Hive Metastore 的数据表，但在大量的应用场景中，数据源往往是存储在分布式文件系统的各类文件，如 Parquet、ORC、CSV 等等。

慢指的是统计信息的搜集效率比较低。对于注册到 Hive Metastore 的数据表，开发者需要调用 ANALYZE TABLE COMPUTE STATISTICS 语句收集统计信息，而各类信息的收集会消耗大量时间。

静指的是静态优化，这一点与 RBO 一样。CBO 结合各类统计信息制定执行计划，一旦执行计划交付运行，CBO 的使命就算完成了。换句话说，如果在运行时数据分布发生动态变化，CBO 先前制定的执行计划并不会跟着调整、适配。

AQE 到底是什么？

考虑到 RBO 和 CBO 的种种限制，Spark 在 3.0 版本推出了 AQE（Adaptive Query Execution，自适应查询执行）。如果用一句话来概括，AQE 是 Spark SQL 的一种动态优化机制，在运行时，每当 Shuffle Map 阶段执行完毕，AQE 都会结合这个阶段的统计信息，基于既定的规则动态地调整、修正尚未执行的逻辑计划和物理计划，来完成对原始查询语句的运行时优化。

从定义中，我们不难发现，AQE 优化机制触发的时机是 Shuffle Map 阶段执行完毕。也就是说，AQE 优化的频次与执行计划中 Shuffle 的次数一致。反过来说，如果你的查询语句不会引入 Shuffle 操作，那么 Spark SQL 是不会触发 AQE 的。对于这样的查询，无论你怎么调整 AQE 相关的配置项，AQE 也都爱莫能助。

对于 AQE 的定义，我相信你还有很多问题，比如，AQE 依赖的统计信息具体是什么？既定的规则和策略具体指什么？接下来，我们一一来解答。

首先，AQE 赖以优化的统计信息与 CBO 不同，这些统计信息并不是关于某张表或是哪个列，而是 Shuffle Map 阶段输出的中间文件。学习过 Shuffle 的工作原理之后，我们知道，每个 Map Task 都会输出以 data 为后缀的数据文件，还有以 index 为结尾的索引文件，这些文件统称为中间文件。每个 data 文件的大小、空文件数量与占比、每个 Reduce Task 对应的分区大小，所有这些基于中间文件的统计值构成了 AQE 进行优化的信息来源。

其次，结合 Spark SQL 端到端优化流程图我们可以看到，AQE 从运行时获取统计信息，在条件允许的情况下，优化决策会分别作用到逻辑计划和物理计划。

AQE在Spark SQL中的位置与作用

AQE 既定的规则和策略主要有 4 个，分为 1 个逻辑优化规则和 3 个物理优化策略。我把这些规则与策略，和相应的 AQE 特性，以及每个特性仰仗的统计信息，都汇总到了如下的表格中，你可以看一看。

如何用好 AQE？

那么，AQE 是如何根据 Map 阶段的统计信息以及这 4 个规则与策略，来动态地调整和修正尚未执行的逻辑计划和物理计划的呢？这就要提到 AQE 的三大特性，也就是 Join 策略调整、自动分区合并，以及自动倾斜处理，我们需要借助它们去分析 AQE 动态优化的过程。它们的基本概念我们在第 9 讲说过，这里我再带你简单回顾一下。

Join 策略调整：如果某张表在过滤之后，尺寸小于广播变量阈值，这张表参与的数据关联就会从 Shuffle Sort Merge Join 降级（Demote）为执行效率更高的 Broadcast Hash Join。

自动分区合并：在 Shuffle 过后，Reduce Task 数据分布参差不齐，AQE 将自动合并过小的数据分区。

自动倾斜处理：结合配置项，AQE 自动拆分 Reduce 阶段过大的数据分区，降低单个 Reduce Task 的工作负载。

接下来，我们就一起来分析这 3 个特性的动态优化过程。

Join 策略调整

我们先来说说 Join 策略调整，这个特性涉及了一个逻辑规则和一个物理策略，它们分别是 DemoteBroadcastHashJoin 和 OptimizeLocalShuffleReader。

DemoteBroadcastHashJoin 规则的作用，是把 Shuffle Joins 降级为 Broadcast Joins。需要注意的是，这个规则仅适用于 Shuffle Sort Merge Join 这种关联机制，其他机制如 Shuffle Hash Join、Shuffle Nested Loop Join 都不支持。对于参与 Join 的两张表来说，在它们分别完成 Shuffle Map 阶段的计算之后，DemoteBroadcastHashJoin 会判断中间文件是否满足如下条件：

中间文件尺寸总和小于广播阈值 spark.sql.autoBroadcastJoinThreshold

空文件占比小于配置项 spark.sql.adaptive.nonEmptyPartitionRatioForBroadcastJoin

只要有任意一张表的统计信息满足这两个条件，Shuffle Sort Merge Join 就会降级为 Broadcast Hash Join。说到这儿，你可能会问：“既然 DemoteBroadcastHashJoin 逻辑规则可以把 Sort Merge Join 转换为 Broadcast Join，那同样用来调整 Join 策略的 OptimizeLocalShuffleReader 规则又是干什么用的呢？看上去有些多余啊！”

不知道你注意到没有，我一直强调，AQE 依赖的统计信息来自于 Shuffle Map 阶段生成的中间文件。这意味什么呢？这就意味着 AQE 在开始优化之前，Shuffle 操作已经执行过半了！

我们来举个例子，现在有两张表：事实表 Order 和维度表 User，它们的查询语句和初始的执行计划如下。

//订单表与用户表关联
select sum(order.price * order.volume), user.id
from order inner join user
on order.userId = user.id
where user.type = ‘Head Users’
group by user.id

由于两张表大都到超过了广播阈值，因此 Spark SQL 在最初的执行计划中选择了 Sort Merge Join。AQE 需要同时结合两个分支中的 Shuffle（Exchange）输出，才能判断是否可以降级为 Broadcast Join，以及用哪张表降级。这就意味着，不论大表还是小表都要完成 Shuffle Map 阶段的计算，并且把中间文件落盘，AQE 才能做出决策。

Sort Merge Join执行计划（左侧是事实表的执行分支，右侧是维度表的执行分支）

你可能会说：“根本不需要大表做 Shuffle 呀，AQE 只需要去判断小表 Shuffle 的中间文件就好啦”。可问题是，AQE 可分不清哪张是大表、哪张是小表。在 Shuffle Map 阶段结束之前，数据表的尺寸大小对于 AQE 来说是“透明的”。因此，AQE 必须等待两张表都完成 Shuffle Map 的计算，然后统计中间文件，才能判断降级条件是否成立，以及用哪张表做广播变量。

在常规的 Shuffle 计算流程中，Reduce 阶段的计算需要跨节点访问中间文件拉取数据分片。如果遵循常规步骤，即便 AQE 在运行时把 Shuffle Sort Merge Join 降级为 Broadcast Join，大表的中间文件还是需要通过网络进行分发。这个时候，AQE 的动态 Join 策略调整也就失去了实用价值。原因很简单，负载最重的大表 Shuffle 计算已经完成，再去决定切换到 Broadcast Join 已经没有任何意义。

在这样的背景下，OptimizeLocalShuffleReader 物理策略就非常重要了。既然大表已经完成 Shuffle Map 阶段的计算，这些计算可不能白白浪费掉。采取 OptimizeLocalShuffleReader 策略可以省去 Shuffle 常规步骤中的网络分发，Reduce Task 可以就地读取本地节点（Local）的中间文件，完成与广播小表的关联操作。

不过，需要我们特别注意的是，OptimizeLocalShuffleReader 物理策略的生效与否由一个配置项决定。这个配置项是 spark.sql.adaptive.localShuffleReader.enabled，尽管它的默认值是 True，但是你千万不要把它的值改为 False。否则，就像我们刚才说的，AQE 的 Join 策略调整就变成了形同虚设。

说到这里，你可能会说：“这么看，AQE 的 Join 策略调整有些鸡肋啊！毕竟 Shuffle 计算都已经过半，Shuffle Map 阶段的内存消耗和磁盘 I/O 是半点没省！”确实，Shuffle Map 阶段的计算开销是半点没省。但是，OptimizeLocalShuffleReader 策略避免了 Reduce 阶段数据在网络中的全量分发，仅凭这一点，大多数的应用都能获益匪浅。因此，对于 AQE 的 Join 策略调整，我们可以用一个成语来形容：“亡羊补牢、犹未为晚”。

自动分区合并

接下来，我们再来说说自动分区合并。分区合并的原理比较简单，在 Reduce 阶段，当 Reduce Task 从全网把数据分片拉回，AQE 按照分区编号的顺序，依次把小于目标尺寸的分区合并在一起。目标分区尺寸由以下两个参数共同决定。这部分我们在第 10 讲详细讲过，如果不记得，你可以翻回去看一看。

spark.sql.adaptive.advisoryPartitionSizeInBytes，由开发者指定分区合并后的推荐尺寸。

spark.sql.adaptive.coalescePartitions.minPartitionNum，分区合并后，分区数不能低于该值。

除此之外，我们还要注意，在 Shuffle Map 阶段完成之后，AQE 优化机制被触发，CoalesceShufflePartitions 策略“无条件”地被添加到新的物理计划中。读取配置项、计算目标分区大小、依序合并相邻分区这些计算逻辑，在 Tungsten WSCG 的作用下融合进“手写代码”于 Reduce 阶段执行。

自动倾斜处理

与自动分区合并相反，自动倾斜处理的操作是“拆”。在 Reduce 阶段，当 Reduce Task 所需处理的分区尺寸大于一定阈值时，利用 OptimizeSkewedJoin 策略，AQE 会把大分区拆成多个小分区。倾斜分区和拆分粒度由以下这些配置项决定。关于它们的含义与作用，我们在第 10 讲说过，你可以再翻回去看一看。

spark.sql.adaptive.skewJoin.skewedPartitionFactor，判定倾斜的膨胀系数

spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes，判定倾斜的最低阈值

spark.sql.adaptive.advisoryPartitionSizeInBytes，以字节为单位，定义拆分粒度

自动倾斜处理的拆分操作也是在 Reduce 阶段执行的。在同一个 Executor 内部，本该由一个 Task 去处理的大分区，被 AQE 拆成多个小分区并交由多个 Task 去计算。这样一来，Task 之间的计算负载就可以得到平衡。但是，这并不能解决不同 Executors 之间的负载均衡问题。

我们来举个例子，假设有个 Shuffle 操作，它的 Map 阶段有 3 个分区，Reduce 阶段有 4 个分区。4 个分区中的两个都是倾斜的大分区，而且这两个倾斜的大分区刚好都分发到了 Executor 0。通过下图，我们能够直观地看到，尽管两个大分区被拆分，但横向来看，整个作业的主要负载还是落在了 Executor 0 的身上。Executor 0 的计算能力依然是整个作业的瓶颈，这一点并没有因为分区拆分而得到实质性的缓解。

Executors之间的负载平衡还有待优化

另外，在数据关联的场景中，对于参与 Join 的两张表，我们暂且把它们记做数据表 1 和数据表 2，如果表 1 存在数据倾斜，表 2 不倾斜，那在关联的过程中，AQE 除了对表 1 做拆分之外，还需要对表 2 对应的数据分区做复制，来保证关联关系不被破坏。

数据关联场景中的自动倾斜处理

在这样的运行机制下，如果两张表都存在数据倾斜怎么办？这个时候，事情就开始变得逐渐复杂起来了。对于上图中的表 1 和表 2，我们假设表 1 还是拆出来两个分区，表 2 因为倾斜也拆出来两个分区。这个时候，为了不破坏逻辑上的关联关系，表 1、表 2 拆分出来的分区还要各自复制出一份，如下图所示。

两边倾斜

如果现在问题变得更复杂了，左表拆出 M 个分区，右表拆出 N 各分区，那么每张表最终都需要保持 M x N 份分区数据，才能保证关联逻辑的一致性。当 M 和 N 逐渐变大时，AQE 处理数据倾斜所需的计算开销将会面临失控的风险。

总的来说，当应用场景中的数据倾斜比较简单，比如虽然有倾斜但数据分布相对均匀，或是关联计算中只有一边倾斜，我们完全可以依赖 AQE 的自动倾斜处理机制。但是，当我们的场景中数据倾斜变得复杂，比如数据中不同 Key 的分布悬殊，或是参与关联的两表都存在大量的倾斜，我们就需要衡量 AQE 的自动化机制与手工处理倾斜之间的利害得失。关于手工处理倾斜，我们留到第 28 讲再去展开。

小结

AQE 是 Spark SQL 的一种动态优化机制，它的诞生解决了 RBO、CBO，这些启发式、静态优化机制的局限性。想要用好 AQE，我们就要掌握它的特点，以及它支持的三种优化特性的工作原理和使用方法。

如果用一句话来概括 AQE 的定义，就是每当 Shuffle Map 阶段执行完毕，它都会结合这个阶段的统计信息，根据既定的规则和策略动态地调整、修正尚未执行的逻辑计划和物理计划，从而完成对原始查询语句的运行时优化。也因此，只有当你的查询语句会引入 Shuffle 操作的时候，Spark SQL 才会触发 AQE。

AQE 支持的三种优化特性分别是 Join 策略调整、自动分区合并和自动倾斜处理。

关于 Join 策略调整，我们首先要知道 DemoteBroadcastHashJoin 规则仅仅适用于 Shuffle Sort Merge Join 这种关联机制，对于其他 Shuffle Joins 类型，AQE 暂不支持把它们转化为 Broadcast Joins。其次，为了确保 AQE 的 Join 策略调整正常运行，我们要确保 spark.sql.adaptive.localShuffleReader.enabled 配置项始终为开启状态。

关于自动分区合并，我们要知道，在 Shuffle Map 阶段完成之后，结合分区推荐尺寸与分区数量限制，AQE 会自动帮我们完成分区合并的计算过程。

关于 AQE 的自动倾斜处理我们要知道，它只能以 Task 为粒度缓解数据倾斜，并不能解决不同 Executors 之间的负载均衡问题。针对场景较为简单的倾斜问题，比如关联计算中只涉及单边倾斜，我们完全可以依赖 AQE 的自动倾斜处理机制。但是，当数据倾斜问题变得复杂的时候，我们需要衡量 AQE 的自动化机制与手工处理倾斜之间的利害得失。

每日一练

我们知道，AQE 依赖的统计信息来源于 Shuffle Map 阶段输出的中间文件。你觉得，在运行时，AQE 还有其他渠道可以获得同样的统计信息吗？

AQE 的自动倾斜处理机制只能以 Task 为粒度来平衡工作负载，如果让你重新实现这个机制，你有什么更好的办法能让 AQE 以 Executors 为粒度做到负载均衡吗？

期待在留言区看到你的思考和答案，我们下一讲见！

25 | Spark 3.0（二）：DPP特性该怎么用？

你好，我是吴磊。

DPP（Dynamic Partition Pruning，动态分区剪裁）是 Spark 3.0 版本中第二个引人注目的特性，它指的是在星型数仓的数据关联场景中，可以充分利用过滤之后的维度表，大幅削减事实表的数据扫描量，从整体上提升关联计算的执行性能。

今天这一讲，我们就通过一个电商场景下的例子，来说说什么是分区剪裁，什么是动态分区剪裁，它的作用、用法和注意事项，让你一次就学会怎么用好 DPP。

分区剪裁

我们先来看这个例子。在星型（Start Schema）数仓中，我们有两张表，一张是订单表 orders，另一张是用户表 users。显然，订单表是事实表（Fact），而用户表是维度表（Dimension）。业务需求是统计所有头部用户贡献的营业额，并按照营业额倒序排序。那这个需求该怎么实现呢？

首先，我们来了解一下两张表的关键字段，看看查询语句应该怎么写。

// 订单表orders关键字段
userId, Int
itemId, Int
price, Float
quantity, Int

// 用户表users关键字段
id, Int
name, String
type, String //枚举值，分为头部用户和长尾用户

给定上述数据表，我们只需把两张表做内关联，然后分组、聚合、排序，就可以实现业务逻辑，具体的查询语句如下。

select (orders.price * order.quantity) as income, users.name
from orders inner join users on orders.userId = users.id
where users.type = ‘Head User’
group by users.name
order by income desc

看到这样的查询语句，再结合 Spark SQL 那几讲学到的知识，我们很快就能画出它的逻辑执行计划。

逻辑计划

由于查询语句中事实表上没有过滤条件，因此，在执行计划的左侧，Spark SQL 选择全表扫描的方式来投影出 userId、price 和 quantity 这些字段。相反，维度表上有过滤条件 users.type = ‘Head User’，因此，Spark SQL 可以应用谓词下推规则，把过滤操作下推到数据源之上，来减少必需的磁盘 I/O 开销。

虽然谓词下推已经很给力了，但如果用户表支持分区剪裁（Partition Pruning），I/O 效率的提升就会更加显著。那什么是分区剪裁呢？实际上，分区剪裁是谓词下推的一种特例，它指的是在分区表中下推谓词，并以文件系统目录为单位对数据集进行过滤。分区表就是通过指定分区键，然后使用 partitioned by 语句创建的数据表，或者是使用 partitionBy 语句存储的列存文件（如 Parquet、ORC 等）。

相比普通数据表，分区表特别的地方就在于它的存储方式。对于分区键中的每一个数据值，分区表都会在文件系统中创建单独的子目录来存储相应的数据分片。拿用户表来举例，假设用户表是分区表，且以 type 字段作为分区键，那么用户表会有两个子目录，前缀分别是“Head User”和“Tail User”。数据记录被存储于哪个子目录完全取决于记录中 type 字段的值，比如：所有 type 字段值为“Head User”的数据记录都被存储到前缀为“Head User”的子目录。同理，所有 type 字段值为“Tail User”的数据记录，全部被存放到前缀为“Tail User”的子目录。

不难发现，如果过滤谓词中包含分区键，那么 Spark SQL 对分区表做扫描的时候，是完全可以跳过（剪掉）不满足谓词条件的分区目录，这就是分区剪裁。例如，在我们的查询语句中，用户表的过滤谓词是“users.type = ‘Head User’”。假设用户表是分区表，那么对于用户表的数据扫描，Spark SQL 可以完全跳过前缀为“Tail User”的子目录。

谓词下推与分区剪裁

通过与谓词下推作对比，我们可以直观地感受分区剪裁的威力。如图所示，上下两行分别表示用户表在不做分区和做分区的情况下，Spark SQL 对于用户表的数据扫描。在不做分区的情况下，用户表所有的数据分片全部存于同一个文件系统目录，尽管 Parquet 格式在注脚（Footer) 中提供了 type 字段的统计值，Spark SQL 可以利用谓词下推来减少需要扫描的数据分片，但由于很多分片注脚中的 type 字段同时包含‘Head User’和‘Tail User’（第一行 3 个浅绿色的数据分片），因此，用户表的数据扫描仍然会涉及 4 个数据分片。

相反，当用户表本身就是分区表时，由于 type 字段为‘Head User’的数据记录全部存储到前缀为‘Head User’的子目录，也就是图中第二行浅绿色的文件系统目录，这个目录中仅包含两个 type 字段全部为‘Head User’的数据分片。这样一来，Spark SQL 可以完全跳过其他子目录的扫描，从而大幅提升 I/O 效率。

你可能会说：“既然分区剪裁这么厉害，那么我是不是也可以把它应用到事实表上去呢？毕竟事实表的体量更大，相比维度表，事实表上 I/O 效率的提升空间更大。”没错，如果事实表本身就是分区表，且过滤谓词中包含分区键，那么 Spark SQL 同样会利用分区剪裁特性来大幅减少数据扫描量。

不过，对于实际工作中的绝大多数关联查询来说，事实表都不满足分区剪裁所需的前提条件。比如说，要么事实表不是分区表，要么事实表上没有过滤谓词，或者就是过滤谓词不包含分区键。就拿电商场景的例子来说，查询中压根就没有与订单表相关的过滤谓词。因此，即便订单表本身就是分区表，Spark SQL 也没办法利用分区剪裁特性。

对于这样的关联查询，我们是不是只能任由 Spark SQL 去全量扫描事实表呢？要是在以前，我们还没什么办法。不过，有了 Spark 3.0 推出的 DPP 特性之后，情况就大不一样了。

动态分区剪裁

我们刚才说了，DPP 指的是在数据关联的场景中，Spark SQL 利用维度表提供的过滤信息，减少事实表中数据的扫描量、降低 I/O 开销，从而提升执行性能。那么，DPP 是怎么做到这一点的呢？它背后的逻辑是什么？为了方便你理解，我们还用刚刚的例子来解释。

DPP背后的实现逻辑

首先，过滤条件 users.type = ‘Head User’会帮助维度表过滤一部分数据。与此同时，维度表的 ID 字段也顺带着经过一轮筛选，如图中的步骤 1 所示。经过这一轮筛选之后，保留下来的 ID 值，仅仅是维度表 ID 全集的一个子集。

然后，在关联关系也就是 orders.userId = users.id 的作用下，过滤效果会通过 users 的 ID 字段传导到事实表的 userId 字段，也就是图中的步骤 2。这样一来，满足关联关系的 userId 值，也是事实表 userId 全集中的一个子集。把满足条件的 userId 作为过滤条件，应用（Apply）到事实表的数据源，就可以做到减少数据扫描量，提升 I/O 效率，如图中的步骤 3 所示。

DPP 正是基于上述逻辑，把维度表中的过滤条件，通过关联关系传导到事实表，从而完成事实表的优化。虽然 DPP 的运作逻辑非常清晰，但并不是所有的数据关联场景都可以享受到 DPP 的优化机制，想要利用 DPP 来加速事实表数据的读取和访问，数据关联场景还要满足三个额外的条件。

首先，DPP 是一种分区剪裁机制，它是以分区为单位对事实表进行过滤。结合刚才的逻辑，维度表上的过滤条件会转化为事实表上 Join Key 的过滤条件。具体到我们的例子中，就是 orders.userId 这个字段。显然，DPP 生效的前提是事实表按照 orders.userId 这一列预先做好了分区。因此，事实表必须是分区表，而且分区字段（可以是多个）必须包含 Join Key。

其次，过滤效果的传导，依赖的是等值的关联关系，比如 orders.userId = users.id。因此，DPP 仅支持等值 Joins，不支持大于、小于这种不等值关联关系。

此外，DPP 机制得以实施还有一个隐含的条件：维度表过滤之后的数据集要小于广播阈值。

拿维度表 users 来说，满足过滤条件 users.type = ‘Head User’的数据集，要能够放进广播变量，DPP 优化机制才能生效。为什么会这样呢？这就要提到 DPP 机制的实现原理了。

结合刚才对于 DPP 实现逻辑的分析和推导，我们不难发现，实现 DPP 机制的关键在于，我们要让处理事实表的计算分支，能够拿到满足过滤条件的 Join Key 列表，然后用这个列表来对事实表做分区剪裁。那么问题来了，用什么办法才能拿到这个列表呢？

Spark SQL 选择了一种“一箭双雕”的做法：使用广播变量封装过滤之后的维度表数据。具体来说，在维度表做完过滤之后，Spark SQL 在其上构建哈希表（Hash Table），这个哈希表的 Key 就是用于关联的 Join Key。在我们的例子中，Key 就是满足过滤 users.type = ‘Head User’条件的 users.id；Value 是投影中需要引用的数据列，在之前订单表与用户表的查询中，这里的引用列就是 users.name。

DPP的物理计划

哈希表构建完毕之后，Spark SQL 将其封装到广播变量中，这个广播变量的作用有二。第一个作用就是给事实表用来做分区剪裁，如图中的步骤 1 所示，哈希表中的 Key Set 刚好可以用来给事实表过滤符合条件的数据分区。

第二个作用就是参与后续的 Broadcast Join 数据关联，如图中的步骤 2 所示。这里的哈希表，本质上就是 Hash Join 中的 Build Table，其中的 Key、Value，记录着数据关联中所需的所有字段，如 users.id、users.name，刚好拿来和事实表做 Broadcast Hash Join。

因此你看，鉴于 Spark SQL 选择了广播变量的实现方式，要想有效利用 DPP 优化机制，我们就必须要确保，过滤后的维度表刚好能放到广播变量中去。也因此，我们必须要谨慎对待配置项 spark.sql.autoBroadcastJoinThreshold。

小结

这一讲，我们围绕动态分区剪裁，学习了谓词下推和分区剪裁的联系和区别，以及动态分区剪裁的定义、特点和使用方法。

相比于谓词下推，分区剪裁往往能更好地提升磁盘访问的 I/O 效率。

这是因为，谓词下推操作往往是根据文件注脚中的统计信息完成对文件的过滤，过滤效果取决于文件中内容的“纯度”。分区剪裁则不同，它的分区表可以把包含不同内容的文件，隔离到不同的文件系统目录下。这样一来，包含分区键的过滤条件能够以文件系统目录为粒度对磁盘文件进行过滤，从而大幅提升磁盘访问的 I/O 效率。

而动态分区剪裁这个功能主要用在星型模型数仓的数据关联场景中，它指的是在运行的时候，Spark SQL 利用维度表提供的过滤信息，来减少事实表中数据的扫描量、降低 I/O 开销，从而提升执行性能。

动态分区剪裁运作的背后逻辑，是把维度表中的过滤条件，通过关联关系传导到事实表，来完成事实表的优化。在数据关联的场景中，开发者要想利用好动态分区剪裁特性，需要注意 3 点：

事实表必须是分区表，并且分区字段必须包含 Join Key

动态分区剪裁只支持等值 Joins，不支持大于、小于这种不等值关联关系

维度表过滤之后的数据集，必须要小于广播阈值，因此，开发者要注意调整配置项 spark.sql.autoBroadcastJoinThreshold

每日一练

如果让你重写 DPP 实现机制，你有可能把广播阈值的限制去掉吗？（提示：放弃使用 Broadcast Hash Join 的关联方式，但仍然用广播变量来做分区剪裁。）

要让事实表拿到满足条件的 Join Key 列表，除了使用广播变量之外，你觉得还有其他的方法和途径吗？

期待在留言区看到你的思考和答案，我们下一讲见！

26 | Join Hints指南：不同场景下，如何选择Join策略？

你好，我是吴磊。

在数据分析领域，数据关联可以说是最常见的计算场景了。因为使用的频率很高，所以 Spark 为我们准备了非常丰富的关联形式，包括 Inner Join、Left Join、Right Join、Anti Join、Semi Join 等等。

搞懂不同关联形式的区别与作用，可以让我们快速地实现业务逻辑。不过，这只是基础，要想提高数据关联场景下 Spark 应用的执行性能，更为关键的是我们要能够深入理解 Join 的实现原理。

所以今天这一讲，我们先来说说，单机环境中 Join 都有哪几种实现方式，它们的优劣势分别是什么。理解了这些实现方式，我们再结合它们一起探讨，分布式计算环境中 Spark 都支持哪些 Join 策略。对于不同的 Join 策略，Spark 是怎么做取舍的。

Join 的实现方式详解

到目前为止，数据关联总共有 3 种 Join 实现方式。按照出现的时间顺序，分别是嵌套循环连接（NLJ，Nested Loop Join ）、排序归并连接（SMJ，Shuffle Sort Merge Join）和哈希连接（HJ，Hash Join）。接下来，我们就借助一个数据关联的场景，来分别说一说这 3 种 Join 实现方式的工作原理。

假设，现在有事实表 orders 和维度表 users。其中，users 表存储用户属性信息，orders 记录着用户的每一笔交易。两张表的 Schema 如下：

// 订单表orders关键字段
userId, Int
itemId, Int
price, Float
quantity, Int

// 用户表users关键字段
id, Int
name, String
type, String //枚举值，分为头部用户和长尾用户

我们的任务是要基于这两张表做内关联（Inner Join），同时把用户名、单价、交易额等字段投影出来。具体的 SQL 查询语句如下表：

//SQL查询语句
select orders.quantity, orders.price, orders.userId, users.id, users.name
from orders inner join users on orders.userId = users.id

那么，对于这样一个关联查询，在 3 种不同的 Join 实现方式下，它是如何完成计算的呢？

NLJ 的工作原理

对于参与关联的两张数据表，我们通常会根据它们扮演的角色来做区分。其中，体量较大、主动扫描数据的表，我们把它称作外表或是驱动表；体量较小、被动参与数据扫描的表，我们管它叫做内表或是基表。那么，NLJ 是如何关联这两张数据表的呢？

NLJ 是采用“嵌套循环”的方式来实现关联的。也就是说，NLJ 会使用内、外两个嵌套的 for 循环依次扫描外表和内表中的数据记录，判断关联条件是否满足，比如例子中的 orders.userId = users.id，如果满足就把两边的记录拼接在一起，然后对外输出。

Nested Loop Join示意图

在这个过程中，外层的 for 循环负责遍历外表中的每一条数据，如图中的步骤 1 所示。而对于外表中的每一条数据记录，内层的 for 循环会逐条扫描内表的所有记录，依次判断记录的 Join Key 是否满足关联条件，如步骤 2 所示。假设，外表有 M 行数据，内表有 N 行数据，那么 NLJ 算法的计算复杂度是 O(M * N)。不得不说，尽管 NLJ 实现方式简单而又直接，但它的执行效率实在让人不敢恭维。

SMJ 的工作原理

正是因为 NLJ 极低的执行效率，所以在它推出之后没多久之后，就有人用排序、归并的算法代替 NLJ 实现了数据关联，这种算法就是 SMJ。SMJ 的思路是先排序、再归并。具体来说，就是参与 Join 的两张表先分别按照 Join Key 做升序排序。然后，SMJ 会使用两个独立的游标对排好序的两张表完成归并关联。

Sort Merge Join示意图

SMJ 刚开始工作的时候，内外表的游标都会先锚定在两张表的第一条记录上，然后再对比游标所在记录的 Join Key。对比结果以及后续操作主要分为 3 种情况：

外表 Join Key 等于内表 Join Key，满足关联条件，把两边的数据记录拼接并输出，然后把外表的游标滑动到下一条记录

外表 Join Key 小于内表 Join Key，不满足关联条件，把外表的游标滑动到下一条记录

外表 Join Key 大于内表 Join Key，不满足关联条件，把内表的游标滑动到下一条记录

SMJ 正是基于这 3 种情况，不停地向下滑动游标，直到某张表的游标滑到头，即宣告关联结束。对于 SMJ 中外表的每一条记录，由于内表按 Join Key 升序排序，且扫描的起始位置为游标所在位置，因此 SMJ 算法的计算复杂度为 O(M + N)。

不过，SMJ 计算复杂度的降低，仰仗的是两张表已经事先排好序。要知道，排序本身就是一项非常耗时的操作，更何况，为了完成归并关联，参与 Join 的两张表都需要排序。因此，SMJ 的计算过程我们可以用“先苦后甜”来形容。苦的是要先花费时间给两张表做排序，甜的是有序表的归并关联能够享受到线性的计算复杂度。

HJ 的工作原理

考虑到 SMJ 对排序的要求比较苛刻，所以后来又有人提出了效率更高的关联算法：HJ。HJ 的设计初衷非常明确：把内表扫描的计算复杂度降低至 O(1)。把一个数据集合的访问效率提升至 O(1)，也只有 Hash Map 能做到了。也正因为 Join 的关联过程引入了 Hash 计算，所以它叫 HJ。

HJ 的计算分为两个阶段，分别是 Build 阶段和 Probe 阶段。在 Build 阶段，基于内表，算法使用既定的哈希函数构建哈希表，如上图的步骤 1 所示。哈希表中的 Key 是 Join Key 应用（Apply）哈希函数之后的哈希值，表中的 Value 同时包含了原始的 Join Key 和 Payload。

在 Probe 阶段，算法遍历每一条数据记录，先是使用同样的哈希函数，以动态的方式（On The Fly）计算 Join Key 的哈希值。然后，用计算得到的哈希值去查询刚刚在 Build 阶段创建好的哈希表。如果查询失败，说明该条记录与维度表中的数据不存在关联关系；如果查询成功，则继续对比两边的 Join Key。如果 Join Key 一致，就把两边的记录进行拼接并输出，从而完成数据关联。

分布式环境下的 Join

掌握了这 3 种最主要的数据关联实现方式的工作原理之后，在单机环境中，无论是面对常见的 Inner Join、Left Join、Right Join，还是不常露面的 Anti Join、Semi Join，你都能对数据关联的性能调优做到游刃有余了。

不过，你也可能会说：“Spark 毕竟是个分布式系统，光学单机实现有什么用呀？”

所谓万变不离其宗，实际上，相比单机环境，分布式环境中的数据关联在计算环节依然遵循着 NLJ、SMJ 和 HJ 这 3 种实现方式，只不过是增加了网络分发这一变数。在 Spark 的分布式计算环境中，数据在网络中的分发主要有两种方式，分别是 Shuffle 和广播。那么，不同的网络分发方式，对于数据关联的计算又都有哪些影响呢？

如果采用 Shuffle 的分发方式来完成数据关联，那么外表和内表都需要按照 Join Key 在集群中做全量的数据分发。因为只有这样，两个数据表中 Join Key 相同的数据记录才能分配到同一个 Executor 进程，从而完成关联计算，如下图所示。

如果采用广播机制的话，情况会大有不同。在这种情况下，Spark 只需要把内表（基表）封装到广播变量，然后在全网进行分发。由于广播变量中包含了内表的全量数据，因此体量较大的外表只要“待在原地、保持不动”，就能轻松地完成关联计算，如下图所示。

不难发现，结合 Shuffle、广播这两种网络分发方式和 NLJ、SMJ、HJ 这 3 种计算方式，对于分布式环境下的数据关联，我们就能组合出 6 种 Join 策略，如下图所示。

这 6 种 Join 策略，对应图中 6 个青色圆角矩形，从上到下颜色依次变浅，它们分别是 Cartesian Product Join、Shuffle Sort Merge Join 和 Shuffle Hash Join。也就是采用 Shuffle 机制实现的 NLJ、SMJ 和 HJ，以及 Broadcast Nested Loop Join、Broadcast Sort Merge Join 和 Broadcast Hash Join。

从执行性能来说，6 种策略从上到下由弱变强。相比之下，CPJ 的执行效率是所有实现方式当中最差的，网络开销、计算开销都很大，因而在图中的颜色也是最深的。BHJ 是最好的分布式数据关联机制，网络开销和计算开销都是最小的，因而颜色也最浅。此外，你可能也注意到了，Broadcast Sort Merge Join 被标记成了灰色，这是因为 Spark 并没有选择支持 Broadcast + Sort Merge Join 这种组合方式。

那么问题来了，明明是 6 种组合策略，为什么 Spark 偏偏没有支持这一种呢？要回答这个问题，我们就要回过头来对比 SMJ 与 HJ 实现方式的差异与优劣势。

相比 SMJ，HJ 并不要求参与 Join 的两张表有序，也不需要维护两个游标来判断当前的记录位置，只要基表在 Build 阶段构建的哈希表可以放进内存，HJ 算法就可以在 Probe 阶段遍历外表，依次与哈希表进行关联。

当数据能以广播的形式在网络中进行分发时，说明被分发的数据，也就是基表的数据足够小，完全可以放到内存中去。这个时候，相比 NLJ、SMJ，HJ 的执行效率是最高的。因此，在可以采用 HJ 的情况下，Spark 自然就没有必要再去用 SMJ 这种前置开销比较大的方式去完成数据关联。

Spark 如何选择 Join 策略？

那么，在不同的数据关联场景中，对于这 5 种 Join 策略来说，也就是 CPJ、BNLJ、SHJ、SMJ 以及 BHJ，Spark 会基于什么逻辑取舍呢？我们来分两种情况进行讨论，分别是等值 Join，和不等值 Join。

等值 Join 下，Spark 如何选择 Join 策略？

等值 Join 是指两张表的 Join Key 是通过等值条件连接在一起的。在日常的开发中，这种 Join 形式是最常见的，如 t1 inner join t2 on t1.id = t2.id。

在等值数据关联中，Spark 会尝试按照 BHJ > SMJ > SHJ 的顺序依次选择 Join 策略。在这三种策略中，执行效率最高的是 BHJ，其次是 SHJ，再次是 SMJ。其中，SMJ 和 SHJ 策略支持所有连接类型，如全连接、Anti Join 等等。BHJ 尽管效率最高，但是有两个前提条件：一是连接类型不能是全连接（Full Outer Join）；二是基表要足够小，可以放到广播变量里面去。

那为什么 SHJ 比 SMJ 执行效率高，排名却不如 SMJ 靠前呢？这是个非常好的问题。我们先来说结论，相比 SHJ，Spark 优先选择 SMJ 的原因在于，SMJ 的实现方式更加稳定，更不容易 OOM。

回顾 HJ 的实现机制，在 Build 阶段，算法根据内表创建哈希表。在 Probe 阶段，为了让外表能够成功“探测”（Probe）到每一个 Hash Key，哈希表要全部放进内存才行。坦白说，这个前提还是蛮苛刻的，仅这一点要求就足以让 Spark 对其望而却步。要知道，在不同的计算场景中，数据分布的多样性很难保证内表一定能全部放进内存。

而且在 Spark 中，SHJ 策略要想被选中必须要满足两个先决条件，这两个条件都是对数据尺寸的要求。首先，外表大小至少是内表的 3 倍。其次，内表数据分片的平均大小要小于广播变量阈值。第一个条件的动机很好理解，只有当内外表的尺寸悬殊到一定程度时，HJ 的优势才会比 SMJ 更显著。第二个限制的目的是，确保内表的每一个数据分片都能全部放进内存。

和 SHJ 相比，SMJ 没有这么多的附加条件，无论是单表排序，还是两表做归并关联，都可以借助磁盘来完成。内存中放不下的数据，可以临时溢出到磁盘。单表排序的过程，我们可以参考 Shuffle Map 阶段生成中间文件的过程。在做归并关联的时候，算法可以把磁盘中的有序数据用合理的粒度，依次加载进内存完成计算。这个粒度可大可小，大到以数据分片为单位，小到逐条扫描。

正是考虑到这些因素，相比 SHJ，Spark SQL 会优先选择 SMJ。事实上，在配置项 spark.sql.join.preferSortMergeJoin 默认为 True 的情况下，Spark SQL 会用 SMJ 策略来兜底，确保作业执行的稳定性，压根就不会打算去尝试 SHJ。开发者如果想通过配置项来调整 Join 策略，需要把这个参数改为 False，这样 Spark SQL 才有可能去尝试 SHJ。

不等值 Join 下，Spark 如何选择 Join 策略？

接下来，我们再来说说不等值 Join，它指的是两张表的 Join Key 是通过不等值条件连接在一起的。不等值 Join 其实我们在以前的例子中也见过，比如像查询语句 t1 inner join t2 on t1.date > t2.beginDate and t1.date <= t2.endDate，其中的关联关系是依靠不等式连接在一起的。

由于不等值 Join 只能使用 NLJ 来实现，因此 Spark SQL 可选的 Join 策略只剩下 BNLJ 和 CPJ。在同一种计算模式下，相比 Shuffle，广播的网络开销更小。显然，在两种策略的选择上，Spark SQL 一定会按照 BNLJ > CPJ 的顺序进行尝试。当然，BNLJ 生效的前提自然是内表小到可以放进广播变量。如果这个条件不成立，那么 Spark SQL 只好委曲求全，使用笨重的 CPJ 策略去完成关联计算。

开发者能做些什么？

最后，我们再来聊聊，面对上述的 5 种 Join 策略，开发者还能做些什么呢？通过上面的分析，我们不难发现，Spark SQL 对于这些策略的取舍也基于一些既定的规则。所谓计划赶不上变化，预置的规则自然很难覆盖多样且变化无常的计算场景。因此，当我们掌握了不同 Join 策略的工作原理，结合我们对于业务和数据的深刻理解，完全可以自行决定应该选择哪种 Join 策略。

在最新发布的 3.0 版本中，Spark 为开发者提供了多样化的 Join Hints，允许你把专家经验凌驾于 Spark SQL 的选择逻辑之上。在满足前提条件的情况下，如等值条件、连接类型、表大小等等，Spark 会优先尊重开发者的意愿，去选取开发者通过 Join Hints 指定的 Join 策略。关于 Spark 3.0 支持的 Join Hints 关键字，以及对应的适用场景，我把它们总结到了如上的表格中，你可以直接拿来参考。

简单来说，你可以使用两种方式来指定 Join Hints，一种是通过 SQL 结构化查询语句，另一种是使用 DataFrame 的 DSL 语言，都很方便。至于更全面的讲解，你可以去第 13 讲看看，这里我就不多说了。

小结

这一讲，我们从数据关联的实现原理，到 Spark SQL 不同 Join 策略的适用场景，掌握这些关键知识点，对于数据关联场景中的性能调优至关重要。

首先，你需要掌握 3 种 Join 实现机制的工作原理。为了方便你对比，我把它们总结在了下面的表格里。

掌握了 3 种关联机制的实现原理，你就能更好地理解 Spark SQL 的 Join 策略。结合数据的网络分发方式（Shuffle 和广播），Spark SQL 支持 5 种 Join 策略，按照执行效率排序就是 BHJ > SHJ > SMJ > BNLJ > CPJ。同样，为了方便对比，你也可以直接看下面的表格。

最后，当你掌握了不同 Join 策略的工作原理，结合对于业务和数据的深刻理解，实际上你可以自行决定应该选择哪种 Join 策略，不必完全依赖 Spark SQL 的判断。

Spark 为开发者提供了多样化的 Join Hints，允许你把专家经验凌驾于 Spark SQL 的选择逻辑之上。比如，当你确信外表比内表大得多，而且内表数据分布均匀，使用 SHJ 远比默认的 SMJ 效率高得多的时候，你就可以通过指定 Join Hints 来强制 Spark SQL 按照你的意愿去选择 Join 策略。

每日一练

如果关联的场景是事实表 Join 事实表，你觉得我们今天讲的 Sort Merge Join 实现方式还适用吗？如果让你来设计算法的实现步骤，你会怎么做？

你觉得，不等值 Join 可以强行用 Sort Merge Join 和 Hash Join 两种机制来实现吗？为什么？

期待在留言区看到你的思考和答案，我们下一讲见！

27 | 大表Join小表：广播变量容不下小表怎么办？

你好，我是吴磊。

在数据分析领域，大表 Join 小表的场景非常普遍。不过，大小是个相对的概念，通常来说，大表与小表尺寸相差 3 倍以上，我们就将其归类为“大表 Join 小表”的计算场景。因此，大表 Join 小表，仅仅意味着参与关联的两张表尺寸悬殊。

对于大表 Join 小表这种场景，我们应该优先考虑 BHJ，它是 Spark 支持的 5 种 Join 策略中执行效率最高的。BHJ 处理大表 Join 小表时的前提条件是，广播变量能够容纳小表的全量数据。但是，如果小表的数据量超过广播阈值，我们又该怎么办呢？

今天这一讲，我们就结合 3 个真实的业务案例，来聊一聊这种情况的解决办法。虽然这 3 个案例不可能覆盖“大表 Join 小表”场景中的所有情况，但是，分析并汇总案例的应对策略和解决办法，有利于我们在调优的过程中开阔思路、发散思维，从而避免陷入“面对问题无所适从”的窘境。

案例 1：Join Key 远大于 Payload

在第一个案例中，大表 100GB、小表 10GB，它们全都远超广播变量阈值（默认 10MB）。因为小表的尺寸已经超过 8GB，在大于 8GB 的数据集上创建广播变量，Spark 会直接抛出异常，中断任务执行，所以 Spark 是没有办法应用 BHJ 机制的。那我们该怎么办呢？先别急，我们来看看这个案例的业务需求。

这个案例来源于计算广告业务中的流量预测，流量指的是系统中一段时间内不同类型用户的访问量。这里有三个关键词，第一个是“系统”，第二个是“一段时间”，第三个是“用户类型”。时间粒度好理解，就是以小时为单位去统计流量。用户类型指的是采用不同的维度来刻画用户，比如性别、年龄、教育程度、职业、地理位置。系统指的是流量来源，比如平台、站点、频道、媒体域名。

在系统和用户的维度组合之下，流量被细分为数以百万计的不同“种类”。比如，来自 XX 平台 XX 站点的在校大学生的访问量，或是来自 XX 媒体 XX 频道 25-45 岁女性的访问量等等。

我们知道，流量预测本身是个时序问题，它和股价预测类似，都是基于历史、去预测未来。在我们的案例中，为了预测上百万种不同的流量，咱们得先为每种流量生成时序序列，然后再把这些时序序列喂给机器学习算法进行模型训练。

统计流量的数据源是线上的访问日志，它记录了哪类用户在什么时间访问了哪些站点。要知道，我们要构建的，是以小时为单位的时序序列，但由于流量的切割粒度非常细致，因此有些种类的流量不是每个小时都有访问量的，如下图所示。

某种流量在过去24小时的记录情况

我们可以看到，在过去的 24 小时中，某种流量仅在 20-24 点这 5 个时段有数据记录，其他时段无记录，也就是流量为零。在这种情况下，我们就需要用“零”去补齐缺失时段的序列值。那么我们该怎么补呢？

因为业务需求是填补缺失值，所以在实现层面，我们不妨先构建出完整的全零序列，然后以系统、用户和时间这些维度组合为粒度，用统计流量去替换全零序列中相应位置的“零流量”。这个思路描述起来比较复杂，用图来理解会更直观、更轻松一些。

首先，我们生成一张全零流量表，如图中左侧的“负样本表”所示。这张表的主键是划分流量种类的各种维度，如性别、年龄、平台、站点、小时时段等等。表的 Payload 只有一列，也即访问量，在生成“负样本表”的时候，这一列全部置零。

然后，我们以同样的维度组合统计日志中的访问量，就可以得到图中右侧的“正样本表”。不难发现，两张表的 Schema 完全一致，要想获得完整的时序序列，我们只需要把外表以“左连接（Left Outer Join）”的形式和内表做关联就好了。具体的查询语句如下：

//左连接查询语句
select t1.gender, t1.age, t1.city, t1.platform, t1.site, t1.hour, coalesce(t2.access, t1.access) as access
from t1 left join t2 on
t1.gender = t2.gender and
t1.age = t2.age and
t1.city = t2.city and
t1.platform = t2.platform and
t1.site = t2.site and
t1.hour = t2.hour

使用左连接的方式，我们刚好可以用内表中的访问量替换外表中的零流量，两表关联的结果正是我们想要的时序序列。“正样本表”来自访问日志，只包含那些存在流量的时段，而“负样本表”是生成表，它包含了所有的时段。因此，在数据体量上，负样本表远大于正样本表，这是一个典型的“大表 Join 小表”场景。尽管小表（10GB）与大表（100GB）相比，在尺寸上相差一个数量级，但两者的体量都不满足 BHJ 的先决条件。因此，Spark 只好退而求其次，选择 SMJ（Shuffle Sort Merge Join）的实现方式。

我们知道，SMJ 机制会引入 Shuffle，将上百 GB 的数据在全网分发可不是一个明智的选择。那么，根据“能省则省”的开发原则，我们有没有可能“省去”这里的 Shuffle 呢？要想省去 Shuffle，我们只有一个办法，就是把 SMJ 转化成 BHJ。你可能会说：“都说了好几遍了，小表的尺寸 10GB 远超广播阈值，我们还能怎么转化呢？”

办法总比困难多，我们先来反思，关联这两张表的目的是什么？目的是以维度组合（Join Keys）为基准，用内表的访问量替换掉外表的零值。那么，这两张表有哪些特点呢？首先，两张表的 Schema 完全一致。其次，无论是在数量、还是尺寸上，两张表的 Join Keys 都远大于 Payload。那么问题来了，要达到我们的目的，一定要用那么多、那么长的 Join Keys 做关联吗？

答案是否定的。在上一讲，我们介绍过 Hash Join 的实现原理，在 Build 阶段，Hash Join 使用哈希算法生成哈希表。在 Probe 阶段，哈希表一方面可以提供 O(1) 的查找效率，另一方面，在查找过程中，Hash Keys 之间的对比远比 Join Keys 之间的对比要高效得多。受此启发，我们为什么不能计算 Join Keys 的哈希值，然后把生成的哈希值当作是新的 Join Key 呢？

我们完全可以基于现有的 Join Keys 去生成一个全新的数据列，它可以叫“Hash Key”。生成的方法分两步：

把所有 Join Keys 拼接在一起，把性别、年龄、一直到小时拼接成一个字符串，如图中步骤 1、3 所示

使用哈希算法（如 MD5 或 SHA256）对拼接后的字符串做哈希运算，得到哈希值即为“Hash Key”，如上图步骤 2、4 所示

在两张表上，我们都进行这样的操作。如此一来，在做左连接的时候，为了把主键一致的记录关联在一起，我们不必再使用数量众多、冗长的原始 Join Keys，用单一的生成列 Hash Key 就可以了。相应地，SQL 查询语句也变成了如下的样子。

//调整后的左连接查询语句
select t1.gender, t1.age, t1.city, t1.platform, t1.site, t1.hour, coalesce(t2.access, t1.access) as access
from t1 left join t2 on
t1.hash_key = t2. hash_key

添加了这一列之后，我们就可以把内表，也就是“正样本表”中所有的 Join Keys 清除掉，大幅缩减内表的存储空间，上图中的步骤 5 演示了这个过程。当内表缩减到足以放进广播变量的时候，我们就可以把 SMJ 转化为 BHJ，从而把 SMJ 中的 Shuffle 环节彻底省掉。

这样一来，清除掉 Join Keys 的内表的存储大小就变成了 1.5GB。对于这样的存储量级，我们完全可以使用配置项或是强制广播的方式，来完成 Shuffle Join 到 Broadcast Join 的转化，具体的转化方法你可以参考广播变量那一讲（第 13 讲）。

案例 1 说到这里，其实已经基本解决了，不过这里还有一个小细节需要我们特别注意。案例 1 优化的关键在于，先用 Hash Key 取代 Join Keys，再清除内表冗余数据。Hash Key 实际上是 Join Keys 拼接之后的哈希值。既然存在哈希运算，我们就必须要考虑哈希冲突的问题。

哈希冲突我们都很熟悉，它指的就是不同的数据源经过哈希运算之后，得到的哈希值相同。在案例 1 当中，如果我们为了优化引入的哈希计算出现了哈希冲突，就会破坏原有的关联关系。比如，本来两个不相等的 Join Keys，因为哈希值恰巧相同而被关联到了一起。显然，这不是我们想要的结果。

消除哈希冲突隐患的方法其实很多，比如“二次哈希”，也就是我们用两种哈希算法来生成 Hash Key 数据列。两条不同的数据记录在两种不同哈希算法运算下的结果完全相同，这种概率几乎为零。

案例 2：过滤条件的 Selectivity 较高

除了 Join Keys 远大于 Payload 的情况会导致我们无法选择 BHJ，还有一种情况是过滤条件的 Selectivity 较高。这个案例来源于电子商务场景，在星型（Start Schema）数仓中，我们有两张表，一张是订单表 orders，另一张是用户表 users。订单表是事实表（Fact），而用户表是维度表（Dimension）。

这个案例的业务需求很简单，是统计所有头部用户贡献的营业额，并按照营业额倒序排序。订单表和用户表的 Schema 如下表所示。

// 订单表orders关键字段
userId, Int
itemId, Int
price, Float
quantity, Int

// 用户表users关键字段
id, Int
name, String
type, String //枚举值，分为头部用户和长尾用户

给定上述数据表，我们只需把两张表做内关联，然后分组、聚合、排序，就可以实现业务逻辑，具体的查询语句如下所示。

//查询语句
select (orders.price * order.quantity) as revenue, users.name
from orders inner join users on orders.userId = users.id
where users.type = ‘Head User’
group by users.name
order by revenue desc

在这个案例中，事实表的存储容量在 TB 量级，维度表是 20GB 左右，也都超过了广播阈值。其实，这样的关联场景在电子商务、计算广告以及推荐搜索领域都很常见。

对于两张表都远超广播阈值的关联场景来说，如果我们不做任何调优的，Spark 就会选择 SMJ 策略计算。在 10 台 C5.4xlarge AWS EC2 的分布式环境中，SMJ 要花费将近 5 个小时才完成两张表的关联计算。这样的执行效率，我们肯定无法接受，但我们又能做哪些优化呢？你不妨先花上两分钟去想一想，然后再来一起跟我去分析。

仔细观察上面的查询语句，我们发现这是一个典型的星型查询，也就是事实表与维度表关联，且维表带过滤条件。维表上的过滤条件是 users.type = ‘Head User’，即只选取头部用户。而通常来说，相比普通用户，头部用户的占比很低。换句话说，这个过滤条件的选择性（Selectivity）很高，它可以帮助你过滤掉大部分的维表数据。在我们的案例中，由于头部用户占比不超过千分之一，因此过滤后的维表尺寸很小，放进广播变量绰绰有余。

这个时候我们就要用到 AQE 了，我们知道 AQE 允许 Spark SQL 在运行时动态地调整 Join 策略。我们刚好可以利用这个特性，把最初制定的 SMJ 策略转化为 BHJ 策略（千万别忘了，AQE 默认是关闭的，要想利用它提供的特性，我们得先把 spark.sql.adaptive.enabled 配置项打开）。

不过，即便过滤条件的选择性很高，在千分之一左右，过滤之后的维表还是有 20MB 大小，这个尺寸还是超过了默认值广播阈值 10MB。因此，我们还需要把广播阈值 spark.sql.autoBroadcastJoinThreshold 调高一些，比如 1GB，AQE 才会把 SMJ 降级为 BHJ。做了这些调优之后，在同样的集群规模下，作业的端到端执行时间从之前的 5 个小时缩减为 30 分钟。

让作业的执行性能提升了一个数量级之后，我们的调优就结束了吗？在调优的本质那一讲，我们一再强调，随着木桶短板的此消彼长，调优是一个不断持续的过程。在这个过程中，我们需要因循瓶颈的变化，动态地切换调优方法，去追求一种所有木板齐平、没有瓶颈的状态。

那么，当我们用动态 Join 策略，把 SMJ 策略中 Shuffle 引入的海量数据分发这块短板补齐之后，还有没有“新晋”的短板需要修理呢？

对于案例中的这种星型关联，我们还可以利用 DPP 机制来减少事实表的扫描量，进一步减少 I/O 开销、提升性能。和 AQE 不同，DPP 并不需要开发者特别设置些什么，只要满足条件，DPP 机制会自动触发。

但是想要使用 DPP 做优化，还有 3 个先决条件需要满足：

DPP 仅支持等值 Joins，不支持大于或者小于这种不等值关联关系

维表过滤之后的数据集，必须要小于广播阈值，因此开发者要注意调整配置项 spark.sql.autoBroadcastJoinThreshold

事实表必须是分区表，且分区字段（可以是多个）必须包含 Join Key

我们可以直接判断出查询满足前两个条件，满足第一个条件很好理解。满足第二个条件是因为，经过第一步 AQE 的优化之后，广播阈值足够大，足以容纳过滤之后的维表。那么，要想利用 DPP 机制，我们必须要让 orders 成为分区表，也就是做两件事情：

创建一张新的订单表 orders_new，并指定 userId 为分区键

把原订单表 orders 的全部数据，灌进这张新的订单表 orders_new

//查询语句
select (orders_new.price * orders_new.quantity) as revenue, users.name
from orders_new inner join users on orders_new.userId = users.id
where users.type = ‘Head User’
group by users.name
order by revenue desc

用 orders_new 表替换 orders 表之后，在同样的分布式环境下，查询时间就从 30 分钟进一步缩短到了 15 分钟。

你可能会说：“为了利用 DPP，重新建表、灌表，这样也需要花费不少时间啊！这不是相当于把运行时间从查询转嫁到建表、灌数了吗？”你说的没错，确实是这么回事。如果为了查询效果，临时再去修改表结构、迁移数据确实划不来，属于“临时抱佛脚”。因此，为了最大限度地利用 DPP，在做数仓规划的时候，开发者就应该结合常用查询与典型场景，提前做好表结构的设计，这至少包括 Schema、分区键、存储格式等等。

案例 3：小表数据分布均匀

在上面的两个案例中，我们都是遵循“能省则省”的开发原则，想方设法地把 Shuffle Joins 切换为 Broadcast Joins，从而消除 Shuffle。但是，总有那么一些“顽固”的场景，无论我们怎么优化，也没办法做到这一点。那么对于这些“顽固分子”，我们该怎么办呢？

我们知道，如果关联场景不满足 BHJ 条件，Spark SQL 会优先选择 SMJ 策略完成关联计算。但是在上一讲我们说到，当参与 Join 的两张表尺寸相差悬殊且小表数据分布均匀的时候，SHJ 往往比 SMJ 的执行效率更高。原因很简单，小表构建哈希表的开销要小于两张表排序的开销。

我们还是以上一个案例的查询为例，不过呢，这次我们把维表的过滤条件去掉，去统计所有用户贡献的营业额。在 10 台 C5.4xlarge AWS EC2 的分布式环境中，去掉过滤条件的 SMJ 花费了将近 7 个小时才完成两张表的关联计算。

//查询语句
select (orders.price * order.quantity) as revenue, users.name
from orders inner join users on orders.userId = users.id
group by users.name
order by revenue desc

由于维表的查询条件不复存在，因此案例 2 中的两个优化方法，也就是 AQE Join 策略调整和 DPP 机制，也都失去了生效的前提。这种情况下，我们不妨使用 Join Hints 来强制 Spark SQL 去选择 SHJ 策略进行关联计算，调整后的查询语句如下表所示。

//添加Join hints之后的查询语句
select /*+ shuffle_hash(orders) */ (orders.price * order.quantity) as revenue, users.name
from orders inner join users on orders.userId = users.id
group by users.name
order by revenue desc

将 Join 策略调整为 SHJ 之后，在同样的集群规模下，作业的端到端执行时间从之前的 7 个小时缩减到 5 个小时，相比调优前，我们获得了将近 30% 的性能提升。

需要注意的是，SHJ 要想成功地完成计算、不抛 OOM 异常，需要保证小表的每个数据分片都能放进内存。这也是为什么，我们要求小表的数据分布必须是均匀的。如果小表存在数据倾斜的问题，那么倾斜分区的 OOM 将会是一个大概率事件，SHJ 的计算也会因此而中断。

小结

今天这一讲，我们从 3 个案例出发，探讨并解锁了不同场景下“大表 Join 小表”的优化思路和应对方法。

首先，我们定义了什么是“大表 Join 小表”。一般来说，参与 Join 的两张表在尺寸上相差 3 倍以上，就可以看作是“大表 Join 小表”的计算场景。

其次，我们讲了 3 个大表 Join 小表场景下，无法选择 BHJ 的案例。

第一个案例是 Join Keys 远大于 Payload 的数据关联，我们可以使用映射方法（如哈希运算），用较短的字符串来替换超长的 Join Keys，从而大幅缩减小表的存储空间。如果缩减后的小表，足以放进广播变量，我们就可以将 SMJ 转换为 BHJ，= 来消除繁重的 Shuffle 计算。需要注意的是，映射方法要能够有效地避免“映射冲突”的问题，避免出现不同的 Join Keys 被映射成同一个数值。

第二个案例是，如果小表携带过滤条件，且过滤条件的选择性很高，我们可以通过开启 AQE 的 Join 策略调整特性，在运行时把 SMJ 转换为 BHJ，从而大幅提升执行性能。这里有两点需要我们特别注意：一是，为了能够成功完成转换，我们需要确保过滤之后的维表尺寸小于广播阈值；二是，如果大表本身是按照 Join Keys 进行分区的话，那么我们还可以充分利用 DPP 机制，来进一步缩减大表扫描的 I/O 开销，从而提升性能。

第三个案例是，如果小表不带过滤条件，且尺寸远超广播阈值。如果小表本身的数据分布比较均匀，我们可以考虑使用 Join hints 强行要求 Spark SQL 在运行时选择 SHJ 关联策略。一般来说，在“大表 Join 小表”的场景中，相比 SMJ，SHJ 的执行效率会更好一些。背后的原因在于，小表构建哈希表的开销，要小于两张表排序的开销。

每日一练

对于案例 1，我们的核心思路是用哈希值来替代超长的 Join Keys，除了用哈希值以外，你觉得还有其他的思路或是办法，去用较短的字符串来取代超长的 Join Keys 吗？

对于案例 2，利用 AQE Join 策略调整和 DDP 机制的关键，是确保过滤后的维表小于广播阈值。你能说说，都有哪些方法可以用来计算过滤后的维表大小吗？

对于案例 3，假设 20GB 的小表存在数据倾斜，强行把 SMJ 转化为 SHJ 会抛 OOM 异常。这个时候，你认为还有可能继续优化吗？

期待在留言区看到你的思考和答案，我们下一讲见！

28 | 大表Join大表（一）：什么是“分而治之”的调优思路？

你好，我是吴磊。

上一讲，我们探讨了“大表 Join 小表”场景的调优思路和应对方法。那么，除了大表 Join 小表的场景，数据分析领域有没有“大表 Join 大表”的场景呢？确实是有的，它指的是参与 Join 的两张体量较大的事实表，尺寸相差在 3 倍以内，且全部无法放进广播变量。

但是通常来说，在数据分析领域，用一张大表去关联另一张大表，这种做法在业内是极其不推荐的。甚至毫不客气地说，“大表 Join 大表”是冒天下之大不韪，犯了数据分析的大忌。如果非要用“大表 Join 大表”才能实现业务逻辑、完成数据分析，这说明数据仓库在设计之初，开发者考虑得不够完善、看得不够远。

不过，你可能会说：“我刚入职的时候，公司的数仓就已经定型了，这又不是我的锅，我也只能随圆就方。”为了应对这种情况，今天这一讲我们就来说说，当你不得不面对“大表 Join 大表”的时候，还有哪些调优思路和技巧。

要应对“大表 Join 大表”的计算场景，我们主要有两种调优思路。一种叫做“分而治之”，另一种我把它统称为“负隅顽抗”。今天这一讲，我们先来说说“分而治之”，“负隅顽抗”我们留到下一讲再去展开。

值得一提的是，即便你不需要去应对“大表 Join 大表”这块烫手的山芋，“分而治之”与“负隅顽抗”所涉及的调优思路与方法，也非常值得我们花时间去深入了解，因为这些思路与方法的可迁移性非常强，学习过后你会发现，它们完全可以拿来去应对其他的应用场景。

话不多说，我们直接开始今天的课程吧！

如何理解“分而治之”？

“分而治之”的调优思路是把“大表 Join 大表”降级为“大表 Join 小表”，然后使用上一讲中“大表 Join 小表”的调优方法来解决性能问题。它的核心思想是，先把一个复杂任务拆解成多个简单任务，再合并多个简单任务的计算结果。那么，“大表 Join 大表”的场景是如何应用“分而治之”的计算思想的呢？

首先，我们要根据两张表的尺寸大小区分出外表和内表。一般来说，内表是尺寸较小的那一方。然后，我们人为地在内表上添加过滤条件，把内表划分为多个不重复的完整子集。接着，我们让外表依次与这些子集做关联，得到部分计算结果。最后，再用 Union 操作把所有的部分结果合并到一起，得到完整的计算结果，这就是端到端的关联计算。整个“分而治之”的计算过程如下：

大表Join大表的“分而治之”

如何保证内表拆分的粒度足够细？

采用“分而治之”的核心目的在于，将“大表 Join 大表”转化为“大表 Join 小表”，因此“分而治之”中一个关键的环节就是内表拆分，我们要求每一个子表的尺寸相对均匀，且都小到可以放进广播变量。只有这样，原本的 Shuffle Join 才能转化成一个又一个的 Broadcast Joins，原本的海量数据 Shuffle 才能被消除，我们也才能因此享受到性能调优的收益。相反，如果内表拆分不能满足上述条件，我们就“白忙活”了。

拆分的关键在于拆分列的选取，为了让子表足够小，拆分列的基数（Cardinality）要足够大才行。这么说比较抽象，我们来举几个例子。假设内表的拆分列是“性别”，性别的基数是 2，取值分别是“男”和“女”。我们根据过滤条件 “性别 = 男”和“性别 = 女”把内表拆分为两份，显然，这样拆出来的子表还是很大，远远超出广播阈值。

你可能会说：“既然性别的基数这么低，不如我们选择像身份证号这种基数大的数据列。”身份证号码的基数确实足够大，就是全国的人口数量。但是，身份证号码这种基数比较大的字符串充当过滤条件有两个缺点：一，不容易拆分，开发成本太高；二，过滤条件很难享受到像谓词下推这种 Spark SQL 的内部优化机制。

既然基数低也不行、高也不行，那到底什么样的基数合适呢？通常来说，在绝大多数的数仓场景中，事实表上都有与时间相关的字段，比如日期或是更细致的时间戳。这也是很多事实表在建表的时候，都是以日期为粒度做分区存储的原因。因此，选择日期作为拆分列往往是个不错的选择，既能享受到 Spark SQL 分区剪裁（Partition Pruning）的性能收益，同时开发成本又很低。

如何避免外表的重复扫描？

内表拆分之后，外表就要分别和所有的子表做关联，尽管每一个关联都变成了“大表 Join 小表”并转化为 BHJ，但是在 Spark 的运行机制下，每一次关联计算都需要重新、重头扫描外表的全量数据。毫无疑问，这样的操作是让人无法接受的。这就是“分而治之”中另一个关键的环节：外表的重复扫描。

外表的重复扫描

我们以上图为例，内表被拆分为 4 份，原本两个大表的 Shuffle Join，被转化为 4 个 Broadcast Joins。外表分别与 4 个子表做关联，所有关联的结果集最终通过 Union 合并到一起，完成计算。对于这 4 个关联来说，每一次计算都需要重头扫描一遍外表。换句话说，外表会被重复扫描 4 次。显然，外表扫描的次数取决于内表拆分的份数。

我们刚刚说到，内表的拆分需要足够细致，才能享受到性能调优带来的收益，而这往往意味着，内表拆分的份数成百上千、甚至成千上万。在这样的数量级之下，重复扫描外表带来的开销是巨大的。

要解决数据重复扫描的问题，办法其实不止一种，我们最容易想到的就是 Cache。确实，如果能把外表的全量数据缓存到内存中，我们就不必担心重复扫描的问题，毕竟内存的计算延迟远低于磁盘。但是，我们面临的情况是外表的数据量非常地庞大，往往都是 TB 级别起步，想要把 TB 体量的数据全部缓存到内存，这要求我们的计算集群在资源配置上要足够的强悍，再说直白一点，你要有足够的预算去配置足够大的内存。

要是集群没这么强悍，老板也不给批预算去扩容集群内存，我们该怎么办呢？

我们还是要遵循“分而治之”的思想，既然内表可以“分而治之”，外表为什么不可以呢？对于外表参与的每一个子关联，在逻辑上，我们完全可以只扫描那些与内表子表相关的外表数据，并不需要每次都扫描外表的全量数据。如此一来，在效果上，外表的全量数据仅仅被扫描了一次。你可能会说：“说得轻巧，逻辑上是没问题，但是具体怎么做到外表的“分而治之”呢？”

这事要是搁到以前还真是没什么操作空间，但是，学习过 Spark 3.0 的 DPP 机制之后，我们就可以利用 DPP 来对外表进行“分而治之”。

外表的“分而治之”

假设外表的分区键包含 Join Keys，那么，每一个内表子表都可以通过 DPP 机制，帮助与之关联的外表减少数据扫描量。如上图所示，步骤 1、2、3、4 分别代表外表与 4 个不同子表的关联计算。以步骤 1 为例，在 DPP 机制的帮助下，要完成关联计算，外表只需要扫描与绿色子表对应的分区数据即可，如图中的两个绿色分区所示。同理，要完成步骤 4 的关联计算，外表只需要扫描与紫色子表对应的分区即可，如图中左侧用紫色标记的两个数据分区。

不难发现，每个子查询只扫描外表的一部分、一个子集，所有这些子集加起来，刚好就是外表的全量数据。因此，利用“分而治之”的调优技巧，端到端的关联计算仅需对外表做一次完整的全量扫描即可。如此一来，在把原始的 Shuffle Join 转化为多个 Broadcast Joins 之后，我们并没有引入额外的性能开销。毫无疑问，查询经过这样的调优过后，执行效率一定会有较大幅度的提升。

但是，你可能会说：“说了半天，都是一些思路和理论，要实现“分而治之”，代码该怎么写呢？”接下来，我们就结合一个小例子一起去实战一下“分而治之”的优化思路。

“分而治之”调优思路实战

这个实战例子来自于一个跨境电商，这家电商在全球范围内交易大型组装设备，这些设备的零部件来自于全球不同地区的不同供货商，因此一个设备订单往往包含多个零部件明细。这家电商使用 orders 表和 transactions 表来分别记录订单和交易明细，两张表的关键字段如下表所示。

//orders表的关键字段
orderId: Int
customerId: Int
status: String
date: Date //分区键

//lineitems表的关键字段
orderId: Int //分区键
txId: Int
itemId: Int
price: Float
quantity: Int

orders 和 transactions 都是事实表，体量都在 TB 级别。基于这两张事实表，这家电商每隔一段时间，就会计算上一个季度所有订单的交易额，业务代码如下所示。

//统计订单交易额的代码实现
val txFile: String = _
val orderFile: String = _

val transactions: DataFrame = spark.read.parquent(txFile)
val orders: DataFrame = spark.read.parquent(orderFile)

transactions.createOrReplaceTempView("transactions")
orders.createOrReplaceTempView("orders")

val query: String = "
select sum(tx.price * tx.quantity) as revenue, o.orderId
from transactions as tx inner join orders as o
on tx.orderId = o.orderId
where o.status = 'COMPLETE'
and o.date between '2020-01-01' and '2020-03-31'
group by o.orderId
"

val outFile: String = _
spark.sql(query).save.parquet(outFile)

不难发现，在两张表的关联计算中，transactions 的角色是外表，自然 orders 的角色就是内表。需要指出的是，即便内表中有不少过滤条件，如订单状态为“完成”且成交日期满足一定范围，但过滤之后的内表仍然在百 GB 量级，难以放入广播变量。因此，这两张大表的关联计算，自然会退化到 Shuffle Joins 的实现机制。

那么，如果用“分而治之”的思路来做优化，代码应该怎么改呢？“分而治之”有两个关键因素，也就是内表拆分和外表重复扫描。我们不妨从这两个因素出发来调整原来的代码。

首先，内表拆分是否合理完全取决于拆分列的选取，而候选拆分列要同时满足基数适中、子表分布均匀，并且子表尺寸小于广播阈值等多个条件。纵观 orders 表的所有关键字段，只有 date 字段能够同时满足这些条件。因此，我们可以使用 date 字段，以天为单位对 orders 表做拆分，那么原代码中的查询语句需要作如下调整。

//以date字段拆分内表
val query: String = "
select sum(tx.price * tx.quantity) as revenue, o.orderId
from transactions as tx inner join orders as o
on tx.orderId = o.orderId
where o.status = 'COMPLETE'
and o.date = '2020-01-01'
group by o.orderId
"

你可能会说：“这不对吧，业务需求是计算一个季度的交易额，查询这么改不是只计算一天的量吗？”别着急，代码的调整还差一步：外表重复扫描。内表拆分之后，外表自然要依次与所有的子表做关联，最终把全部子关联的结果合并到一起，才算是完成“分而治之”的实现。

//循环遍历dates、完成“分而治之”的计算
val dates: Seq[String] = Seq("2020-01-01", "2020-01-02", … "2020-03-31")

for (date <- dates) {

val query: String = s"
select sum(tx.price * tx.quantity) as revenue, o.orderId
from transactions as tx inner join orders as o
on tx.orderId = o.orderId
where o.status = 'COMPLETE'
and o.date = ${date}
group by o.orderId
"

val file: String = s"${outFile}/${date}"
spark.sql(query).save.parquet(file)
}

再次调整后的代码如上表所示，我们利用一个简单的 for 循环来遍历日期，从而让外表依次与子表做关联，并把子关联的计算结果直接写到 outFile 根目录下的子目录。代码的改动还是很简单的。不过，细心的你可能会发现：“这种写法，不是我们一直要极力避免的单机思维模式吗？”没错，单纯从写法上来看，这份代码的“单机思维”味道非常浓厚。

不过，对于“单机思维模式”的理解，我们不能仅仅停留在形式或是表面上。所谓单机思维模式，它指的是开发者不假思索地直入面向过程编程，忽略或无视分布式数据实体的编程模式。但在刚刚整理调优思路的过程中，我们一直把外表的重复扫描牢记于心，并想到通过利用 DPP 机制来避免它。因此，虽然我们使用了 for 循环，但并不会在运行时引入分布式数据集的重复扫描。

总的来说，在这个案例中，利用“分而治之”的调优方法，我们可以把所有“大表 Join 大表”的关联查询转化为“大表 Join 小表”，把原始的 Shuffle Join 转化为多个 Broadcast Joins，而且 Broadcast Joins 又可以有效应对关联中的数据倾斜问题，可以说是一举两得。

小结

“大表 Join 大表”的第一种调优思路是“分而治之”，我们要重点掌握它的调优思路以及两个关键环节的优化处理。

“分而治之”的核心思想是通过均匀拆分内表的方式 ，把一个复杂而又庞大的 Shuffle Join 转化为多个 Broadcast Joins，它的目的是，消除原有 Shuffle Join 中两张大表所引入的海量数据分发，大幅削减磁盘与网络开销的同时，从整体上提升作业端到端的执行性能。

在“分而治之”的调优过程中，内表的拆分最为关键，因为它肩负着 Shuffle Join 能否成功转化为 Broadcast Joins 的重要作用。而拆分的关键在于拆分列的选取。为了兼顾执行性能与开发效率，拆分列的基数要足够大，这样才能让子表小到足以放进广播变量，但同时，拆分列的基数也不宜过大，否则实现“分而治之”的开发成本就会陡然上升。通常来说，日期列往往是个不错的选择。

为了避免在调优的过程中引入额外的计算开销，我们要特别注意外表的重复扫描问题。针对外表的重复扫描，我们至少有两种应对方法。第一种是将外表全量缓存到内存，不过这种方法对于内存空间的要求较高，不具备普适性。第二种是利用 Spark 3.0 版本推出的 DPP 特性，在数仓设计之初，就以 Join Key 作为分区键，对外表做分区存储。

当我们做好了内表拆分，同时也避免了外表的重复扫描，我们就可以把原始的 Shuffle Join 转化为多个 Broadcast Joins，在消除海量数据在全网分发的同时，避免引入额外的性能开销。那么毫无疑问，查询经过“分而治之”的调优过后，作业端到端的执行性能一定会得到大幅提升。

每日一练

在大表数据分布均匀的情况下，如果我们采用“分而治之”的调优技巧，要避免外表的重复扫描，除了采用缓存或是 DPP 机制以外，还有哪些其他办法？

期待在留言区看到你的思考和答案，我们下一讲见！

29 | 大表Join大表（二）：什么是负隅顽抗的调优思路？

你好，我是吴磊。

在上一讲，我们说了应对“大表 Join 大表”的第一种调优思路是分而治之，也就是把一个庞大而又复杂的 Shuffle Join 转化为多个轻量的 Broadcast Joins。这一讲，我们接着来讲第二种调优思路：负隅顽抗。

负隅顽抗指的是，当内表没法做到均匀拆分，或是外表压根就没有分区键，不能利用 DPP，只能依赖 Shuffle Join，去完成大表与大表的情况下，我们可以采用的调优方法和手段。这类方法比较庞杂，适用的场景各不相同。从数据分布的角度出发，我们可以把它们分两种常见的情况来讨论，分别是数据分布均匀和数据倾斜。

我们先来说说，在数据分布均匀的情况下，如何应对“大表 Join 大表”的计算场景。

数据分布均匀

在第 27 讲的最后，我们说过，当参与关联的大表与小表满足如下条件的时候，Shuffle Hash Join 的执行效率，往往比 Spark SQL 默认的 Shuffle Sort Merge Join 更好。

两张表数据分布均匀。

内表所有数据分片，能够完全放入内存。

实际上，这个调优技巧同样适用于“大表 Join 大表”的场景，原因其实很简单，这两个条件与数据表本身的尺寸无关，只与其是否分布均匀有关。不过，为了确保 Shuffle Hash Join 计算的稳定性，我们需要特别注意上面列出的第二个条件，也就是内表所有的数据分片都能够放入内存。

那么问题来了，我们怎么确保第二个条件得以成立呢？其实，只要处理好并行度、并发度与执行内存之间的关系，我们就可以让内表的每一个数据分片都恰好放入执行内存中。简单来说，就是先根据并发度与执行内存，计算出可供每个 Task 消耗的内存上下限，然后结合分布式数据集尺寸与上下限，倒推出与之匹配的并行度。更详细的内容你可以去看看第 14 讲。

那我们该如何强制 Spark SQL 在运行时选择 Shuffle Hash Join 机制呢？答案就是利用 Join Hints。这个技巧我们讲过很多次了，所以这里，我直接以上一讲中的查询为例，把它的使用方法写在了下面，方便你复习。

//查询语句中使用Join hints
select /*+ shuffle_hash(orders) */ sum(tx.price * tx.quantity) as revenue, o.orderId
from transactions as tx inner join orders as o
on tx.orderId = o.orderId
where o.status = ‘COMPLETE’
and o.date between ‘2020-01-01’ and ‘2020-03-31’
group by o.orderId

数据倾斜

接下来，我们再说说，当参与 Join 的两张表存在数据倾斜问题的时候，我们该如何应对“大表 Join 大表”的计算场景。对于“大表 Join 大表”的数据倾斜问题，根据倾斜位置的不同，我们可以分为 3 种情况来讨论。

大表Join大表数据倾斜的3种情况

其实，不管哪种表倾斜，它们的调优技巧都是类似的。因此，我们就以第一种情况为例，也就是外表倾斜、内表分布均匀的情况，去探讨数据倾斜的应对方法。

以 Task 为粒度解决数据倾斜

学过 AQE 之后，要应对数据倾斜，想必你很快就会想到 AQE 的特性：自动倾斜处理。给定如下配置项参数，Spark SQL 在运行时可以将策略 OptimizeSkewedJoin 插入到物理计划中，自动完成 Join 过程中对于数据倾斜的处理。

spark.sql.adaptive.skewJoin.skewedPartitionFactor，判定倾斜的膨胀系数。

spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes，判定倾斜的最低阈值。

spark.sql.adaptive.advisoryPartitionSizeInBytes，以字节为单位定义拆分粒度。

AQE中的自动倾斜处理

Join 过程中的自动倾斜处理如上图所示，当 AQE 检测到外表存在倾斜分区之后，它会以 spark.sql.adaptive.advisoryPartitionSizeInBytes 配置的数值为拆分粒度，把倾斜分区拆分为多个数据分区。与此同时，AQE 还需要对内表中对应的数据分区进行复制，来保护两表之间的关联关系。

有了 AQE 的自动倾斜处理特性，在应对数据倾斜问题的时候，我们确实能够大幅节省开发成本。不过，天下没有免费的午餐，AQE 的倾斜处理是以 Task 为粒度的，这意味着原本 Executors 之间的负载倾斜并没有得到根本改善。这到底是什么意思呢？

以Task为粒度的负载均衡

我们来举个例子，假设某张表在 Shuffle 过后有两个倾斜分区如上图，它们又刚好都被 Shuffle 到了同一个执行器：Executor 0。在 AQE 的自动倾斜处理机制下，两个倾斜分区分别被拆分变成了 4 个尺寸适中的数据分区。如此一来，Executor 0 中所有 Task 的计算负载都得到了平衡。但是，相比 Executor 1，Executor 0 整体的计算负载还是那么多，并没有因为 AQE 的自动处理而得到任何缓解。

以 Executor 为粒度解决数据倾斜

你也许会说：“哪会那么凑巧，倾斜的分区刚好全都落在同一个 Executor 上？”确实，刚才的例子主要是为了帮你解释清楚倾斜粒度这个概念，如果实际应用中倾斜分区在集群中的分布比较平均的话，AQE 的自动倾斜处理机制确实就是开发者的“灵丹妙药”。

然而，凡事总有个万一，我们在探讨调优方案的时候，还是要考虑周全：如果你的场景就和咱们的例子一样，倾斜分区刚好落在集群中少数的 Executors 上，你该怎么办呢？答案是：“分而治之”和“两阶段 Shuffle”。

这里的分而治之与上一讲的分而治之在思想上是一致的，都是以任务分解的方式来解决复杂问题。区别在于我们今天要讲的，是以 Join Key 是否倾斜为依据来拆解子任务。具体来说，对于外表中所有的 Join Keys，我们先按照是否存在倾斜把它们分为两组。一组是存在倾斜问题的 Join Keys，另一组是分布均匀的 Join Keys。因为给定两组不同的 Join Keys，相应地我们把内表的数据也分为两份。

分而治之：按照Join Keys是否倾斜对数据进行分组

那么，分而治之的含义就是，对于内外表中两组不同的数据，我们分别采用不同的方法做关联计算，然后通过 Union 操作，再把两个关联计算的结果集做合并，最终得到“大表 Join 大表”的计算结果，整个过程如上图所示。

对于 Join Keys 分布均匀的数据部分，我们可以沿用把 Shuffle Sort Merge Join 转化为 Shuffle Hash Join 的方法。对于 Join Keys 存在倾斜问题的数据部分，我们就需要借助“两阶段 Shuffle”的调优技巧，来平衡 Executors 之间的工作负载。那么，什么是“两阶段 Shuffle”呢？

如何理解“两阶段 Shuffle”？

用一句话来概括，“两阶段 Shuffle”指的是，通过“加盐、Shuffle、关联、聚合”与“去盐化、Shuffle、聚合”这两个阶段的计算过程，在不破坏原有关联关系的前提下，在集群范围内以 Executors 为粒度平衡计算负载 。

两阶段Shuffle

我们先来说说第一阶段，也就是“加盐、Shuffle、关联、聚合”的计算过程。显然，这个阶段的计算分为 4 个步骤，其中最为关键的就是第一步的加盐。加盐来源于单词 Salting，听上去挺玄乎，实际上就是给倾斜的 Join Keys 添加后缀。加盐的核心作用就是把原本集中倾斜的 Join Keys 打散，在进行 Shuffle 操作的时候，让原本应该分发到某一个 Executor 的倾斜数据，均摊到集群中的多个 Executors 上，从而以这种方式来消除倾斜、平衡 Executors 之间的计算负载。

对于加盐操作，我们首先需要确定加盐的粒度，来控制数据打散的程度，粒度越高，加盐后的数据越分散。由于加盐的初衷是以 Executors 为粒度平衡计算负载，因此通常来说，取 Executors 总数 #N 作为加盐粒度往往是一种不错的选择。其次，为了保持内外表的关联关系不被破坏，外表和内表需要同时做加盐处理，但处理方法稍有不同。

外表的处理称作“随机加盐”，具体的操作方法是，对于任意一个倾斜的 Join Key，我们都给它加上 1 到 #N 之间的一个随机后缀。以 Join Key = ‘黄小乙’来举例，假设 N = 5，那么外表加盐之后，原先 Join Key = ‘黄小乙’的所有数据记录，就都被打散成了 Join Key 为（‘黄小乙 _1’，‘黄小乙 _2’，‘黄小乙 _3’，‘黄小乙 _4’，‘黄小乙 _5’）的数据记录。

外表加盐

内表的处理称为“复制加盐”，具体的操作方法是，对于任意一个倾斜的 Join Key，我们都把原数据复制（#N – 1）份，从而得到 #N 份数据副本。对于每一份副本，我们为其 Join Key 追加 1 到 #N 之间的固定后缀，让它与打散后的外表数据保持一致。对于刚刚 Join Key = ‘黄小乙’的例子来说，在内表中，我们需要把‘黄小乙’的数据复制 4 份，然后依次为每份数据的 Join Key 追加 1 到 5 的固定后缀，如下图所示。

内表加盐

内外表分别加盐之后，数据倾斜问题就被消除了。这个时候，我们就可以使用常规优化方法，比如，将 Shuffle Sort Merge Join 转化为 Shuffle Hash Join，去继续执行 Shuffle、关联和聚合操作。到此为止，“两阶段 Shuffle” 的第一阶段执行完毕，我们得到了初步的聚合结果，这些结果是以打散的 Join Keys 为粒度进行计算得到的。

一阶段Shuffle、关联、聚合

我们刚刚说，第一阶段加盐的目的在于将数据打散、平衡计算负载。现在我们已经得到了数据打散之后初步的聚合结果，离最终的计算结果仅有一步之遥。不过，为了还原最初的计算逻辑，我们还需要把之前加上的“盐粒”再去掉。

二阶段Shuffle、聚合

第二阶段的计算包含“去盐化、Shuffle、聚合”这 3 个步骤。首先，我们把每一个 Join Key 的后缀去掉，这一步叫做“去盐化”。然后，我们按照原来的 Join Key 再做一遍 Shuffle 和聚合计算，这一步计算得到的结果，就是“分而治之”当中倾斜部分的计算结果。

经过“两阶段 Shuffle”的计算优化，我们终于得到了倾斜部分的关联结果。将这部分结果与“分而治之”当中均匀部分的计算结果合并，我们就能完成存在倾斜问题的“大表 Join 大表”的计算场景。

以 Executors 为粒度的调优实战

应该说，以 Executors 为粒度平衡计算负载的优化过程，是我们学习过的调优技巧中最复杂的。因此，咱们有必要结合实际的应用案例，来详细讲解具体的实现方法。为了方便你对不同的调优方法做对比，我们不妨以上一讲跨境电商的场景为例来讲。

咱们先来回顾一下这家电商的业务需求，给定 orders 和 transactions 两张体量都在 TB 级别的事实表，每隔一段时间就计算一次上一个季度所有订单的交易额，具体的业务代码如下所示。

//统计订单交易额的代码实现
val txFile: String = _
val orderFile: String = _

val transactions: DataFrame = spark.read.parquent(txFile)
val orders: DataFrame = spark.read.parquent(orderFile)

transactions.createOrReplaceTempView(“transactions”)
orders.createOrReplaceTempView(“orders”)

val query: String = “
select sum(tx.price * tx.quantity) as revenue, o.orderId
from transactions as tx inner join orders as o
on tx.orderId = o.orderId
where o.status = ‘COMPLETE’
and o.date between ‘2020-01-01’ and ‘2020-03-31’
group by o.orderId
”

val outFile: String = _
spark.sql(query).save.parquet(outFile)

对于这样一个查询语句，我们该如何实现刚刚说过的优化过程呢？首先，我们先遵循“分而治之”的思想，把内外表的数据分为两个部分。第一部分包含所有存在倾斜问题的 Join Keys 及其对应的 Payloads，第二部分保留的是分布均匀的 Join Keys 和相应的 Payloads。假设我们把所有倾斜的 orderId，也就是 Join Key 保存在数组 skewOrderIds 中，而把分布均匀的 orderId 保持在数组 evenOrderIds 中，我们就可以使用这两个数组，把内外表各自拆分为两部分。

//根据Join Keys是否倾斜、将内外表分别拆分为两部分
import org.apache.spark.sql.functions.array_contains

//将Join Keys分为两组，存在倾斜的、和分布均匀的
val skewOrderIds: Array[Int] = _
val evenOrderIds: Array[Int] = _

val skewTx: DataFrame = transactions.filter(array_contains(lit(skewOrderIds),$"orderId"))
val evenTx: DataFrame = transactions.filter(array_contains(lit(evenOrderIds),$"orderId"))

val skewOrders: DataFrame = orders.filter(array_contains(lit(skewOrderIds),$"orderId"))
val evenOrders: DataFrame = orders.filter(array_contains(lit(evenOrderIds),$"orderId"))

拆分完成之后，我们就可以延续“分而治之”的思想，分别对这两部分应用不同的调优技巧。对于分布均匀的部分，我们把 Shuffle Sort Merge Join 转化为 Shuffle Hash Join。

//将分布均匀的数据分别注册为临时表
evenTx.createOrReplaceTempView(“evenTx”)
evenOrders.createOrReplaceTempView(“evenOrders”)

val evenQuery: String = “
select /*+ shuffle_hash(orders) */ sum(tx.price * tx.quantity) as revenue, o.orderId
from evenTx as tx inner join evenOrders as o
on tx.orderId = o.orderId
where o.status = ‘COMPLETE’
and o.date between ‘2020-01-01’ and ‘2020-03-31’
group by o.orderId
”
val evenResults: DataFrame = spark.sql(evenQuery)

对于存在倾斜的部分，我们要祭出“两阶段 Shuffle”的杀手锏。首先，在第一阶段，我们需要给两张表分别加盐，对外表（交易表）做“随机加盐”，对内表（订单表）做“复制加盐”。

import org.apache.spark.sql.functions.udf

//定义获取随机盐粒的UDF
val numExecutors: Int = _
val rand = () => scala.util.Random.nextInt(numExecutors)
val randUdf = udf(rand)

//第一阶段的加盐操作。注意：保留orderId字段，用于后期第二阶段的去盐化

//外表随机加盐
val saltedSkewTx = skewTx.withColumn(“joinKey”, concat($“orderId”, lit(“_”), randUdf()))

//内表复制加盐
var saltedskewOrders = skewOrders.withColumn(“joinKey”, concat($“orderId”, lit(“_”), lit(1)))
for (i <- 2 to numExecutors) {
saltedskewOrders = saltedskewOrders union skewOrders.withColumn(“joinKey”, concat($“orderId”, lit(“_”), lit(i)))
}

两张表分别做完加盐处理之后，我们就可以使用与之前类似的查询语句，对它们执行后续的 Shuffle、关联与聚合等操作。

//将加盐后的数据分别注册为临时表
saltedSkewTx.createOrReplaceTempView(“saltedSkewTx”)
saltedskewOrders.createOrReplaceTempView(“saltedskewOrders”)

val skewQuery: String = “
select /*+ shuffle_hash(orders) */ sum(tx.price * tx.quantity) as initialRevenue, o.orderId, o.joinKey
from saltedSkewTx as tx inner join saltedskewOrders as o
on tx.joinKey = o.joinKey
where o.status = ‘COMPLETE’
and o.date between ‘2020-01-01’ and ‘2020-03-31’
group by o.joinKey
”
//第一阶段加盐、Shuffle、关联、聚合后的初步结果
val skewInitialResults: DataFrame = spark.sql(skewQuery)

得到第一阶段的初步结果之后，我们就可以开始执行第二阶段的计算了，也就是“去盐化、Shuffle 与聚合”这三个操作。去盐化的目的实际上就是把计算的粒度，从加盐的 joinKey 恢复为原来的 orderId。由于在最初加盐的时候，我们对 orderId 字段进行了保留，因此在第二阶段的计算中，我们只要在 orderId 字段之上执行聚合操作，就能达到我们想要的“去盐化”效果。

val skewResults: DataFrame = skewInitialResults.select(“initialRevenue”, “orderId”)
.groupBy(col(“orderId”)).agg(sum(col(“initialRevenue”)).alias(“revenue”))

在完成了第二阶段的计算之后，我们拿到了“两阶段 Shuffle”的计算结果。最终，只需要把这份结果与先前均匀部分的关联结果进行合并，我们就能实现以 Executors 为粒度平衡计算负载的优化过程。

evenResults union skewResults

执行性能与开发成本的博弈

你可能会说：“我的天呐！为了优化这个场景的计算，这得花多大的开发成本啊！又是分而治之，又是两阶段 Shuffle 的，这么大的开发投入真的值得吗？”

这个问题非常好。我们要明确的是，分而治之外加两阶段 Shuffle 的调优技巧的初衷，是为了解决 AQE 无法以 Executors 为粒度平衡计算负载的问题。因此，这项技巧取舍的关键就在于，Executors 之间的负载倾斜是否构成整个关联计算的性能瓶颈。如果这个问题的答案是肯定的，我们的投入就是值得的。

小结

今天这一讲，你需要掌握以 Shuffle Join 的方式去应对“大表 Join 大表”的计算场景。数据分布不同，应对方法也不尽相同。

当参与 Join 的两张表数据分布比较均匀，而且内表的数据分片能够完全放入内存，Shuffle Hash Join 的计算效率往往高于 Shuffle Sort Merge Join，后者是 Spark SQL 默认的关联机制。你可以使用关键字“shuffle_hash”的 Join Hints，强制 Spark SQL 在运行时选择 Shuffle Hash Join 实现机制。对于内表数据分片不能放入内存的情况，你可以结合“三足鼎立”的调优技巧，调整并行度、并发度与执行内存这三类参数，来满足这一前提条件。

当参与 Join 的两张表存在数据倾斜时，如果倾斜的情况在集群内的 Executors 之间较为均衡，那么最佳的处理方法就是，利用 AQE 提供的自动倾斜处理机制。你只需要设置好以下三个参数，剩下的事情交给 AQE 就好了。

spark.sql.adaptive.skewJoin.skewedPartitionFactor，判定倾斜的膨胀系数。

spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes，判定倾斜的最低阈值。

spark.sql.adaptive.advisoryPartitionSizeInBytes，以字节为单位，定义拆分粒度。

但是，如果倾斜问题仅集中在少数的几个 Executors 中，而且这些负载过高的 Executors 已然成为性能瓶颈，我们就需要采用“分而治之”外加“两阶段 Shuffle”的调优技巧去应对。“分而治之”指的是根据 Join Keys 的倾斜与否，将内外表的数据分为两部分分别处理。其中，均匀的部分可以使用 Shuffle Hash Join 来完成计算，倾斜的部分需要用“两阶段 Shuffle”进行处理。

两阶段 Shuffle 的关键在于加盐和去盐化。加盐的目的是打散数据分布、平衡 Executors 之间的计算负载，从而消除 Executors 单点瓶颈。去盐化的目的是还原原始的关联逻辑。尽管两阶段 Shuffle 的开发成本较高，但只要获得的性能收益足够显著，我们的投入就是值得的。

每日一练

当尝试将 Join Keys 是否倾斜作为“分而治之”的划分依据时，你觉得我们该依据什么标准把 Join Keys 划分为倾斜组和非倾斜组呢？

无论是 AQE 的自动倾斜处理，还是开发者的“两阶段 Shuffle”，本质上都是通过“加盐”与“去盐化”的两步走，在维持关联关系的同时平衡不同粒度下的计算负载。那么，这种“加盐”与“去盐化”的优化技巧，是否适用于所有的关联场景？如果不是，都有哪些场景没办法利用 AQE 的自动倾斜处理，或是我们的“两阶段 Shuffle”呢？

期待在留言区看到你的思考和答案，我们下一讲见！

30| 应用开发：北京市小客车（汽油车）摇号趋势分析

你好，我是吴磊。

如果你也在北京生活，那小汽车摇号这件事大概率也和你息息相关。我身边很多人也一直都和我抱怨说：“小汽车摇号这件事太难了，遥遥无期，完全看不到希望，感觉还没买彩票靠谱呢”。

实不相瞒，我自己也在坚持小汽车摇号，在享受 8 倍概率的情况下，还是没能中签。因此，包括我在内的很多人都想知道，为什么摇号这么费劲？一个人平均需要参与多少次摇号才会中签？中签率的变化趋势真的和官方宣布的一致吗？倍率这玩意儿，真的能提高中签的概率吗？

这些问题，我们都能通过开发一个北京市小汽车摇号趋势分析的应用来解答。我会用两讲的时间带你完成这个应用的开发，在这个过程中，我们可以把前面学过的原理篇、通用调优篇和 Spark SQL 调优篇的大部分知识都上手实践一遍，是不是一听就很期待呢？

话不多说，我们赶紧开始吧！

课前准备

既然是做开发，那我们就需要做一些准备工作。准备工作分为 3 部分，分别是准备数据、准备开发环境和准备运行环境。

准备数据

应用所需的数据，我已经帮你准备好，也上传到了网盘，你可以点击这个地址，输入提取码 ajs6 进行下载。

数据的文件名是“2011-2019 小汽车摇号数据.tar.gz”，解压之后的目录结构如下图所示。根目录下有 apply 和 lucky 两个子目录，apply 的目录内容是 2011-2019 年各个批次参与摇号的申请编号，lucky 目录包含的是各个批次中签的申请编号。方便起见，我们把参与过摇号的人叫“申请者”，把中签的人叫“中签者”。apply 和 lucky 的下一级子目录是各个摇号批次，而摇号批次目录下包含的是 Parquet 格式的数据分片。

数据的文件目录结构

apply 和 lucky 两个子目录，在逻辑上分别对应着事实表和维度表，也可以叫做“申请者表”和“中签者表”。两张表的 Schema 都是（batchNum，carNum），也就是（摇号批次，申请编号）。总之，事实表和维度表在存储方式上都做了分区设计，且分区键都是 batchNum。

准备开发环境

数据下载、解压完成之后，然后我们再来准备开发环境。首先，我们来说说开发语言。要完成“趋势分析应用”的开发，你可以结合个人偏好，使用 Python、Java、Scala 三种语言中的任意一种。由于我本人习惯使用 Scala 做开发，因此整个项目的代码都是用 Scala 实现的。如果你是 Java 或是 Python 开发者，也完全不必担心，结合后续应用逻辑的讲解与 Scala 版本的参考实现，我相信你也很快能完成应用的开发。

“趋势分析应用”非常轻量，Scala 版本的参考实现不超过 200 行代码。因此，只使用 Sublime 甚至是 VI 这样的纯文本编辑器，我们也能很快实现。不过，为了提高开发效率，以及方便后续应用的打包和部署，我还是推荐你使用集成式的 IDE，比如 IntelliJ IDEA、Eclipse 、IntelliJ PyCharm 等等。IDE 的选取原则和开发语言一样，只要选择自己最顺手的就行了。

准备运行环境

最后是运行环境，由于咱们的应用比较轻量，而且数据量较小，解压之后的 Parquet 文件总大小还不超 4GB，因此，你甚至可以用手里的笔记本电脑或是台式 PC，就可以把应用从头到尾地跑通。选择“轻装上阵”主要是考虑到，不少同学可能不方便搭建分布式的物理集群，我们要确保这部分同学不会因为硬件的限制而不能参与实战。

不过，毕竟咱们这两讲的初衷和重点是性能调优实践，网络开销优化是其中关键的一环。因此，有条件的同学，我还是鼓励你搭建分布式的物理集群，或是采用云原生的分布式环境。一来这样的分布式环境更接近实际工作中的真实情况，二来调优前后的性能差异会更加地显著，有利于你加深理解不同调优技巧的作用和效果。

我这边选择了 3 台物理节点，它们的资源配置分别如下。其实，为了跑通应用和做性能对比，你并不需要这么强悍的机器配置。我这么做主要是贪图执行效率，因为想要说明不同调优技巧的作用与功效，我只需要拿到调优前后的对比结果就可以了，这样的配置可以减少我的等待时间。

硬件资源配置

应用开发

准备好了数据、开发环境和执行环境之后，我们就步入正题，开始进行“趋势分析应用”的开发。为了解答大家关于小汽车摇号的种种困惑，在这个应用中，我们主要分析如下几个案例：

2011 到 2019 年底，总共有多少人参与摇号

摇号次数的总分布情况，以及申请者的分布情况和中签者的分布情况分别是什么

中签率的变化趋势是什么

中签率是否发生过较大变化，怎么对它做局部洞察

倍率高的人是否更容易中签

接下来，我们就来一一厘清这些案例的计算逻辑，并进行代码实现。

案例 1：人数统计

首先，我们需要对数据有一个基本的认知。我们先从最简单的统计计数开始，也就是统计一下截至到 2019 年底，参与摇号的总人次和幸运的中签者人数。应该说，这样的需求非常简单，我们只需使用 Parquet API 读取源文件、创建 DataFrame，然后调用 count 就可以了。

val rootPath: String = _

// 申请者数据（因为倍率的原因，每一期，同一个人，可能有多个号码）
val hdfs_path_apply = s"${rootPath}/apply"
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
applyNumbersDF.count
// 中签者数据
val hdfs_path_lucky = s"${rootPath}/lucky"
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
luckyDogsDF.count

把这段代码丢到 spark-shell，或是打包部署到分布式环境去运行，我们很快就能够得到计算结果。截至到 2019 年底，摇号总人次为 381972118，也就是 3.8 亿人次；中签的人数是 1150828，也就是 115 万人。你可能会好奇：“摇号总人次为什么会有这么高的数量级？”

这其实并不奇怪。首先，同一个人可能参与多个批次的摇号，比如我就至少参加了 60 个批次的摇号（苦啊！）。再者，从 2016 年开始，小汽车摇号有了倍率这个概念。倍率的设计初衷，是让申请者的中签概率随着参与批次数量的增加而成比例地增加。也就是说，参与了 60 次摇号的人比仅参与 10 次摇号的人的中签概率更高。不过，官方对于倍率的实现略显简单粗暴。如果你去观察 apply 目录下 2016 年以后的批次文件就会发现，所谓的倍率实际上就是申请编号的副本数量。

正是出于以上两个原因，摇号总人次的体量才会有 3.8 亿人次。如果我们把倍率这个因素去掉，实际的摇号体量会是什么量级呢？

val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
applyDistinctDF.count

以（batchNum，carNum）为粒度进行去重计数，我们就能得到实际的摇号体量是 135009819，也就是 1.35 亿人次。这意味着，从 2011 年到 2019 年这 9 年的时间里，有 1.35 亿人次参与了一项“抽奖游戏”，但是仅有 115 万人幸运中奖，摇号之难可见一斑。

案例 2：摇号次数分布

接下来，我们进一步向下追踪（Drill Down），挖掘一下不同人群摇号次数的分布，也就是统计所有申请者累计参与了多少次摇号，所有中签者摇了多少次号才能幸运地摇中签。对于这两个统计计算，我们需要消除倍率的影响。也就是说，同一个申请编号在同一个批次中应该只保留一份副本。因此，我们需要使用去重之后的“申请者表”：applyDistinctDF。

场景 1：参与摇号的申请者

首先，我们先来分析所有申请者的分布情况，当然也包括中签者。根据刚刚介绍的“业务需求”，我们很快就能写出相应的查询语句。

val result02_01 = applyDistinctDF
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis"))
.agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")

result02_01.write.format("csv").save("_")

将上述代码付诸执行，我们会得到如下图所示的计算结果。其中，横坐标代表申请者参与过的摇号批次次数，纵坐标是对应的参与人数。从 2011 年到 2013 年，摇号是每月一次的。而从 2014 开始，摇号是每两个月一次的。因此，截至到 2019 年底，总共有 72（12 * 3 + 6 * 6）次摇号。所以，我们看到横坐标的值域是从 1 到 72，1 表示摇过 1 次的人，72 就比较惨了，它表示摇过 72 次的人。

从图中我们不难发现，随着摇号次数的逐级递增，人数分布基本上呈现出了逐级递减的趋势。那这意味着什么呢？这意味着每隔两个月就会有新人从驾校毕业，加入到庞大的摇号大军中来。仔细观察上图的左半部分我们会发现，摇号次数凡是遇到 3 的倍数，对应的人数往往比其“左邻右舍”多出甚至两倍，这是为什么呢？

我们刚刚说过，从 2014 年开始，摇号是每两个月进行一次。因此，摇号次数相差 3 则意味着两次摇号之间的时间差是半年左右。比如说，摇了 3 次的人就比摇了 6 次的人晚半年加入摇号大军。那么，半年意味着什么呢？我们不妨脑洞一下，尽管每个月都有从驾校毕业的学员，但是，寒暑假往往是大批量“生产”学员的高峰时期，而寒暑假恰好相差半年左右。你觉得我这个推测合理吗？

场景 2：幸运的中签者

接下来，我们再来看看，那些中签的幸运儿们到底有多幸运？要想得到中签者的摇号次数，我们需要把 applyDistinctDF 和 luckyDogsDF 两张表做内关联，然后再做分组、聚合，代码实现如下表所示。

val result02_02 = applyDistinctDF
.join(luckyDogsDF.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("carNum")).agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis")).agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")

result02_02.write.format("csv").save("_")

将上述代码付诸执行，我们会得到如下图所示的计算结果，其中横纵坐标的含义与场景 1 一样，分别是摇号批次数和对应的人数分布。我们发现，随着摇号次数的逐级递增，人数的分布完全是单调递减的。也就是说，摇号的次数越多，中签者的数量越少。我能想到的一个原因是，摇号的次数越高，对应的参与人数就越少，这一点在场景 1 已经得到了验证。这个其实也不难理解，能一直坚持摇 60 次以上的玩家，真的都是骨灰级玩家。那么，参与的人基数小，中签者的数量自然就更少。

不过，如果假设申请者两个月摇一次号，那么我们就会得出一个非常扎心的结论：摇号中签的人往往不需要等待太长的时间，绝大多数都是在 2-3 年内摇中了购车资格，因为前半部分的总数占到了绝大多数。而等待 3 年以上才摇上号的人，反而成了幸运儿群体中的“少数派”。这不禁让我想起了当年大家开玩笑的那句话：“你要是人品够用，早就该摇上了。超过 3 年还没摇上，就说明你人品余额不足，摇号这件事以后也就不用指望了”。

案例 3：中签率的变化趋势

从摇号次数的分布来看，申请者和中签者的变化趋势是一致的，那这是否意味着二者相除之后的比例是稳定的呢？二者的商实际上就是中签率。接下来，我们就去探究一下中签率的变化趋势。要计算中签率，我们需要分别统计每一个摇号批次中的申请者和中签者人数，然后再把它们做关联、聚合，代码实现如下所示。

// 统计每批次申请者的人数
val apply_denominator = applyDistinctDF
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("denominator"))

// 统计每批次中签者的人数
val lucky_molecule = luckyDogsDF
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))

val result03 = apply_denominator
.join(lucky_molecule, Seq("batchNum"), "inner")
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
.orderBy("batchNum")

result03.write.format("csv").save("_")

我们得到的中签率示意图如下所示。其中，横坐标为各个摇号批次，从 201101 到 201906，也就是从 2011 年的第一批到 2019 年的第 72 批，纵坐标就是中签率。从中我们可以很直观地看到，随着摇号批次的推进，中签率呈锐减的趋势。201101 批次的中签率在 9.4% 左右，不到 10%。而 201906 批次的中签率为 1.9‰，也就是千分之一点九。这么看来，1000 个人里面能摇上号的还凑不够两个人，这也难怪摇号如此之难了。

案例 4：中签率局部洞察

第 4 个案例与案例 3 的区别在于，我们只关注 2018 年的中签率变化趋势。这样做的原因有二：一来，通过计算和对比，我发现 2018 年的中签率相比 2017 年几乎经历了“断崖式”的下跌，因此我想给 2018 年一个特写；二来，只关注 2018 年的数据，可以让我们有机会对比启用 AQE Join 策略调整前后的性能差异。

基于案例 3 的代码实现，要关注 2018 年，我们只需要在 batchNum 之上添加个过滤条件就好了。

// 筛选出2018年的中签数据，并按照批次统计中签人数
val lucky_molecule_2018 = luckyDogsDF
.filter(col("batchNum").like("2018%"))
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))与

// 通过与筛选出的中签数据按照批次做关联，计算每期的中签率
val result04 = apply_denominator
.join(lucky_molecule_2018, Seq("batchNum"), "inner")
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
.orderBy("batchNum")

result04.write.format("csv").save("_")

结合案例 3 与案例 4 的执行结果，我们至少有两点发现。第一点，2018 年内各批次中签率下降较为平缓，从 201801 批次的 2.3‰下降至 201806 批次的 2.1‰，整体下降幅度不超过 10%。第二点，2017 年最后一个批次，也就是 201706 批次的中签率在 4.9‰左右，而 201801 批次的中签率为 2.3‰，在短短两个月之内，中签率惨遭“腰斩”，并在接下来的两年里，一路阴跌，最终在 201906 批次破掉 2‰。

案例 5：倍率分析

那么，在中签率如此之低的情况下，倍率这玩意还有意义吗？接下来，我们先去探索倍率的分布情况，然后再去观察，不同倍率的人群，他们的中签比例是怎样分布的。

场景 1：不同倍率下的中签人数

我们先来统计一下，那些有幸中签的人分别是在多大的倍率下中签的。从 2016 年开始，才有倍率这个概念，因此，对于倍率的统计，我们只需要关注 2016 年以后的摇号数据即可。对于同一个中签者，他在不同批次的倍率可能是不同的，我们只需要拿到其中最大的倍率参与统计就可以了。原因很简单，最大的倍率就是她 / 他中签之前的倍率。

另外，倍率的计算需要依赖原始的多副本摇号数据，所以这里我们不能再使用去重的摇号数据，而应该用包含重复申请编号的 applyNumbersDF 表。基于这样的逻辑，我们的代码实现如下。

val result05_01 = applyNumbersDF
.join(luckyDogsDF.filter(col("batchNum") >= "201601")
.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")

result05_01.write.format("csv").save("_")

中签者的倍率分布如下图所示。其中，横坐标为中签者的倍率，更准确地说，是中签者在参与的摇号批次中最大的副本数量，纵坐标是人数分布。通过观察执行结果我们不难发现，中签者的倍率呈现明显的正态分布。因此，从这张图我们可以得到初步结论：要想摇中车牌号，你并不需要很高的倍率。换句话说，对于中签这件事来说，倍率的作用和贡献并不是线性递增的。

不过，和案例 2 类似，这里同样存在一个基数的问题。也就是说，倍率高的人本来就少，其中的中签者数量自然也少。因此，我们还要结合申请者的倍率分布，去计算不同倍率下的中签比例，才能更加完备地对倍率的作用下结论。

场景 2：不同倍率下的中签比例

对倍率分布有了初步认知之后，我们再来计算不同倍率人群的中签比例，去探究倍率本身对于中签的贡献究竟有多大。有了场景 1 中签者的倍率分布，我们只需要去计算申请者的倍率分布，然后把两份数据做关联、聚合，就可以得到我们想要的结果。

// Step01: 过滤出2016-2019申请者数据，统计出每个申请者在每一期内的倍率，并在所有批次中选取最大的倍率作为申请者的最终倍率，最终算出各个倍率下的申请人数
val apply_multiplier_2016_2019 = applyNumbersDF
.filter(col("batchNum") >= "201601")
.groupBy(col("batchNum"), col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
.groupBy("multiplier")
.agg(count(lit(1)).alias("apply_cnt"))

// Step02: 将各个倍率下的申请人数与各个倍率下的中签人数左关联，并求出各个倍率下的中签率
val result05_02 = apply_multiplier_2016_2019
.join(result05_01.withColumnRenamed("cnt", "lucy_cnt"), Seq("multiplier"), "left")
.na.fill(0)
.withColumn("ratio", round(col("lucy_cnt")/col("apply_cnt"), 5))
.orderBy("multiplier")

result05_02.write.format("csv").save("_")

不同倍率下的中签比例如下图所示。其中横坐标为倍率，纵坐标有两个。蓝色柱状图体代表中签人数，它的分布与场景 1 的分布是一致的；绿色柱状条表示的是中签比例，它表示在同一个倍率下，中签人数与申请人数的比值。

与中签人数一样，中签比例在不同的倍率下，也呈现出了正态分布。有了这份数据做补充，我们可以夯实场景 1 中得出的结论。也就是，倍率对中签的贡献极其有限。这个结论很好地解释了，为什么摇号很久，倍率很高的人也难以中签。

到此为止，通过以上几个案例的分析，我们就对摇号次数分布、中签率变化趋势、倍率分布与中签比例有了答案。

小结

今天这一讲，我们重点开发了一个趋势分析应用，来解答北京市小汽车摇号的各个问题。这个应用主要实现了 5 个案例，分别是摇号次数分布、中签率变化趋势、中签率的大变动、倍率分布与中签比例。为了方便理解，我把它们要解决的问题、答案、主要的实现思路都总结在了下面的脑图中，你可以看一看。

至于这 5 个案例的代码实现和执行结果，我把它们都上传到了公用的 GitHub 仓库，你可以从这个地址获取完整内容。

当然，目前的代码肯定存在很多可以优化的地方，至于怎么优化，我先卖个关子，下一讲再详细来说。

每日一练

如果让你来实现小汽车摇号的倍率机制，你觉得怎么实现才更严谨呢？

基于这份 2011-2019 的小汽车摇号数据，你还能想到哪些有意思的洞察、视角和案例，值得我们进一步去探索呢？

你认为，倍率对于中签的贡献和作用微乎其微的原因是什么呢？

期待在留言区看到你的思考和答案，我们下一讲见！

31 | 性能调优：手把手带你提升应用的执行性能

你好，我是吴磊。

在上一讲，我们一起完成了小汽车摇号趋势分析的应用开发，解决了 5 个案例。今天这一讲，我们逐一对这 5 个案例做性能调优，一起把专栏中学过的知识和技巧应用到实战中去。

由于趋势分析应用中的案例较多，为了方便对比每一个案例调优前后的性能效果，我们先来对齐并统一性能对比测试的方法论。

首先，我们的性能对比测试是以案例为粒度的，也就是常说的 Case By Case。然后，在每一个案例中，我们都有对比基准（Baseline）。对比基准的含义是，在不采取任何调优方法的情况下，直接把代码交付执行得到的运行时间。之后，对于每一个案例，我们会采取一种或多种调优方法做性能优化，每一种调优方法都有与之对应的运行时间。最终，我们将不同调优方法的运行时间与对比基准做横向比较，来观察调优前后的性能差异，并分析性能提升 / 下降的背后原因。

话不多说，我们直接开始今天的课程吧！

运行环境

既然调优效果主要由执行时间来体现，那在开始调优之前，我们有必要先来交代一下性能测试采用的硬件资源和配置项设置。硬件资源如下表所示。

硬件资源配置

为了避免因为实验本身而等待太长的时间，我使用了比较强悍的机器资源。实际上，为了跑通应用，完成性能对比测试，你使用笔记本也可以。而且为了给后续调优留出足够空间，除了必需的运行资源设置以外，其他配置项全部保留了默认值，具体的资源配置如下表所示。

资源配置项设置

另外，由于调优方法中涉及 AQE 和 DPP 这些 Spark 3.0 新特性，因此，我建议你使用 3.0 及以上的 Spark 版本来部署运行环境，我这边采用的版本号是 Spark 3.1.1。

接下来，我们就 Case By Case 地去回顾代码实现，分别分析 5 个案例的优化空间、可能的调优方法、方法的效果，以及它们与对比基准的性能差异。

案例 1 的性能调优：人数统计

首先，我们先来回顾案例 1。案例 1 的意图是统计摇号总人次、中签者人数，以及去掉倍率影响之后的摇号总人次，代码如下所示。

val rootPath: String = _

// 申请者数据（因为倍率的原因，每一期，同一个人，可能有多个号码）
val hdfs_path_apply = s"${rootPath}/apply"
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
applyNumbersDF.count
// 中签者数据
val hdfs_path_lucky = s"${rootPath}/lucky"
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
luckyDogsDF.count

// 申请者数据（去掉倍率的影响）
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
applyDistinctDF.count

从上面的代码实现中，我们不难发现，短短的几行代码共有 3 个 Actions，也就是 3 个不同数据集上的 count 操作，这 3 个 Actions 会触发 3 个 Spark Jobs。其中，前 2 个 Jobs 都是读取数据源之后立即计数，没什么优化空间。第 3 个 Job 是在 applyNumbersDF 之上做去重，然后再统计计数。结合上一讲对于不同案例的讲解，我们知道，applyNumbersDF、luckyDogsDF 和 applyDistinctDF 这 3 个数据集，在后续的案例中会被反复引用。

因为上述 3 个数据集的引用次数过于频繁，所以我们甚至都不用去计算“运行成本占比”，就可以判定：利用 Cache 一定有利于提升执行性能。

使用 Cache 的一般性原则：

如果 RDD/DataFrame/Dataset 在应用中的引用次数为 1，那么坚决不使用 Cache

如果引用次数大于 1，且运行成本占比超过 30%，应当考虑启用 Cache

因此，对于第 3 个 Job，我们可以利用 Cache 机制来提升执行性能。调优方法很简单，我们只需在 applyNumbersDF.count 之前添加一行代码：applyNumbersDF.cache。

由于这个案例中性能对比测试的关注点是第 3 个 Job，那为了方便横向对比，我们先把不相干的 Jobs 和代码去掉，整理之后的对比基准和调优代码如下表所示。

对比基准与调优代码

然后，我们把这两份代码分别打包、部署和执行，并记录 applyDistinctDF.count 作业的执行时间，来完成性能对比测试，我把执行结果记录到了下面的表格中。

性能对比

从中我们可以看到，相较于对比基准，调优之后的执行性能提升了 20%。坦白地说，这样的提升是我们意料之中的。毕竟前者消耗的是磁盘 I/O，而调优之后计数作业直接从内存获取数据。

案例 2 的性能调优：摇号次数分布

接下来，我们再来分析案例 2。案例 2 分为两个场景，第一个场景用于统计申请者摇号批次数量的分布情况，第二个场景也是类似，不过它的出发点是中签者，主要用来解答“中签者通常需要摇多少次号才能中签”这类问题。

场景 1：参与摇号的申请者

我们先来回顾一下场景 1 的代码实现。仔细研读代码，我们不难发现，场景 1 是典型的单表 Shuffle，而且是两次 Shuffle。第一次 Shuffle 操作是以数据列“carNum”为基准做分组计数，第二次 Shuffle 是按照“x_axis”列再次做分组计数。

val result02_01 = applyDistinctDF
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis"))
.agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")

result02_01.write.format("csv").save("_")

因此，场景 1 的计算实际上就是 2 次 Word Count 而已，只不过第一次的 Word 是“carNum”，而第二次的 Word 是“x_axis”。那么，对于这样的“Word Count”，我们都有哪些调优思路呢？

在配置项调优那一讲，我们专门介绍了 Shuffle 调优的一些常规方法，比如调整读写缓冲区大小、By Pass 排序操作等等。除此之外，我们的脑子里一定要有一根弦：Shuffle 的本质是数据的重新分发，凡是有 Shuffle 操作的地方都需要关注数据分布。所以对于过小的数据分片，我们要有意识地对其进行合并。再者，在案例 1 中我们提到，applyNumbersDF、luckyDogsDF 和 applyDistinctDF 在后续的案例中会被反复引用，因此给 applyDistinctDF 加 Cache 也是一件顺理成章的事情。

调优的思路这么多，那为了演示每一种调优方法的提升效果，我会从常规操作、数据分区合并、加 Cache 这 3 个方向出发，分别对场景 1 进行性能调优。不过，需要说明的是，咱们这么做的目的，一来是为了开阔调优思路，二来是为了复习之前学习过的调优技巧。

当然了，在实际工作中，我们一般没有时间和精力像现在这样，一个方法、一个方法去尝试。那么，效率最高的做法应该是遵循我们一直强调的调优方法论，也就是先去应对木桶的短板、消除瓶颈，优先解决主要矛盾，然后在时间、精力允许的情况下，再去应对次短的木板。

那么问题来了，你认为上述 3 种调优思路分别应对的是哪些“木板”？这些“木板”中哪一块是最短的？你又会优先采用哪种调优技巧？接下来，我们就带着这些问题，依次对场景 1 做调优。

思路 1：Shuffle 常规优化

刚刚咱们提到，Shuffle 的常规优化有两类：一类是 By Pass 排序操作，一类是调整读写缓冲区。而 By Pass 排序有两个前提条件：一是计算逻辑不涉及聚合或排序；二是 Reduce 阶段的并行度要小于参数 spark.shuffle.sort.bypassMergeThreshold 的设置值。显然，场景 1 不符合要求，计算逻辑既包含聚合也包含排序。所以，我们就只有调整读写缓冲区这一条路可走了。

实际上，读写缓冲区的调优也是有前提的，因为这部分内存消耗会占用 Execution Memory 内存区域，所以提高缓冲区大小的前提是 Execution Memory 内存比较充裕。由于咱们使用的硬件资源比较强劲，而且小汽车摇号数据整体体量偏小，因此咱们还是有一些“资本”对读写缓冲区做调优的。具体来说，我们需要调整如下两个配置项：

spark.shuffle.file.buffer，Map 阶段写入缓冲区大小

spark.reducer.maxSizeInFlight，Reduce 阶段读缓冲区大小

由于读写缓冲区都是以 Task 为粒度进行设置的，因此调整这两个参数的时我们要小心一点，一般来说 50% 往往是个不错的开始，对比基准与优化设置如下表所示。

对比基准与优化设置

两组对比实验的运行时间，我记录到了下面的表格中。从中我们不难发现，上述两个参数的调整，对于作业端到端执行性能的影响不大。不过，这种参数调了半天，执行效率并没有显著提升的场景，肯定让你似曾相识。这个时候，最好的办法就是我们继续借助“木桶短板”“瓶颈”以及“调优方法论”，去尝试其他的调优思路。

性能对比

思路 2：数据分区合并

接着，我们再来说第二个思路，数据分区合并。首先，咱们先来一起分析一下，场景 1 到底存不存在数据分片过小的问题。为了方便分析，我们再来回顾一遍代码。因为场景 1 的计算基于数据集 applyDistinctDF，所以要回答刚刚的问题，我们需要结合数据集 applyDistinctDF 的存储大小，以及 Shuffle 计算过后 Reduce 阶段的并行度一起来看。

val result02_01 = applyDistinctDF
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))

.groupBy(col("x_axis"))
.agg(count(lit(1)).alias("y_axis"))

.orderBy("x_axis")

result02_01.write.format("csv").save("_")

并行度由配置项 spark.sql.shuffle.partitions 决定，其默认大小为 200，也就是 200 个数据分区。而对于数据集存储大小的估算，我们需要用到下面的函数。

def sizeNew(func: => DataFrame, spark: => SparkSession): String = {

  val result = func

  val lp = result.queryExecution.logical

  val size = spark.sessionState.executePlan(lp).optimizedPlan.stats.sizeInBytes

  "Estimated size: " + size/1024 + "KB"

}

给定 DataFrame，sizeNew 函数可以返回该数据集在内存中的精确大小。把 applyDistinctDF 作为实参，调用 sizeNew 函数，返回的估算尺寸为 2.6 GB。将数据集尺寸除以并行度，我们就能得到 Reduce 阶段每个数据分片的存储大小，也就是 13 MB（也就是 2.6 GB / 200）。通常来说，数据分片的尺寸大小在 200 MB 左右为宜，13 MB 的分片尺寸显然过小。

在调度系统那一讲（第 5 讲），我们说过，如果需要处理的数据分片过小，相较于数据处理，Task 调度开销将变得异常显著，而这样会导致 CPU 利用降低，执行性能变差。因此，为了提升 CPU 利用率进而提升整体的执行效率，我们需要对过小的数据分片做合并。这个时候，AQE 的自动分区合并特性就可以帮我们做这件事情。

不过，要想充分利用 AQE 的自动分区合并特性，我们还需要对相关的配置项进行调整。这里，你直接看场景 1 是怎么设置这些配置项的就可以了。

对比基准与优化设置

一旦开启 AQE 机制，自动分区合并特性会自动生效。表格中的配置项有两个需要我们特别注意，一个是最小分区数 minPartitionNum，另一个是合并之后的目标尺寸 advisoryPartitionSizeInBytes。

我们先来看最小分区数，也就是 minPartitionNum。minPartitionNum 的含义，指的是分区合并之后的分区数量，不能低于这个参数设置的数值。由于我们计算集群配置的 Executors 个数为 6，为了保证每个 CPU 都不闲着、有活儿干，我们不妨把 minPartitionNum 也设置为 6。

接下来是分区合并的目标尺寸，我们刚刚说过，分区大小的经验值在 200 MB 左右，因此我们不妨把 advisoryPartitionSizeInBytes 设置为 200 MB。不过，为了对比不同分区大小对于执行性能的影响，我们可以多做几组实验。

配置项调整前后的几组实验效果对比如下，可以看到，调优后的运行时间有所缩短，这说明分区合并对于提升 CPU 利用率和作业的整体执行性能是有帮助的。仔细观察下表，我们至少有 3 点洞察。

性能对比

并行度过高、数据分片过小，CPU 调度开销会变大，执行性能也变差。

分片粒度划分在 200 MB 左右时，执行性能往往是最优的。

并行度过低、数据分片过大，CPU 数据处理开销也会过大，执行性能会锐减。

思路 3：加 Cache

最后一个思路是加 Cache，这个调优技巧使用起来非常简单，我们在案例 1 已经做过演示，因此，这里直接给出优化代码和运行结果。

对比基准与调优代码

可以看到，利用 Cache 机制做优化，作业执行性能提升得非常显著。

性能对比

到此为止，我们尝试了 3 种调优方法来对场景 1 做性能优化，分别是 Shuffle 读写缓冲区调整、数据分区合并，以及加 Cache。第 1 种方法针对的是，Shuffle 过程中磁盘与网络的请求次数；第 2 种方法的优化目标，是提升 Reduce 阶段的 CPU 利用率；第 3 种方法针对的是，数据集在磁盘中的重复扫描与重复计算。

实际上，根本不需要做定量分析，仅从定性我们就能看出，数据集的重复扫描与计算的开销最大。因此，在实际工作中，对于类似的“多选题”，我们自然要优先选择能够消除瓶颈的第 3 种方法。

场景 2：幸运的中签者

完成了场景 1 单表 Shuffle 的优化之后，接下来，我们再来看看场景 2，场景 2 的业务目标是获取中签者的摇号次数分布。我们先来回顾场景 2 的代码实现，场景 2 的计算涉及一次数据关联，两次分组、聚合，以及最终的排序操作。不难发现，除了关联计算外，其他计算步骤与场景 1 如出一辙。因此，对于场景 2 的优化，我们专注在第一步的数据关联，后续优化沿用场景 1 的调优方法即可。

val result02_02 = applyDistinctDF
.join(luckyDogsDF.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("carNum")).agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis")).agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")

result02_02.write.format("csv").save("_")

参与关联的两张表分别是 applyDistinctDF 和 luckyDogsDF，其中 applyDistinctDF 是去重之后的摇号数据，luckyDogsDF 包含的是中签者的申请编号与批次号。applyDistinctDF 包含 1.35 条数据记录，而 luckyDogsDF 仅仅包含 115 万条数据记录。很显然，二者之间的数据关联属于数仓中常见的“大表 Join 小表”。

遇到“大表 Join 小表”的计算场景，我们最先应该想到的调优技巧一定是广播变量。毕竟，我们一直都在不遗余力地强调 Broadcast Joins 的优势与收益。在这里，我再强调一次，你一定要掌握使用广播变量优化数据关联的调优技巧。毫不夸张地说，广播变量是“性价比”最高的调优技巧，且没有之一。

要利用广播变量来优化 applyDistinctDF 与 luckyDogsDF 的关联计算，我们需要做两件事情。第一件，估算 luckyDogsDF 数据表在内存中的存储大小。第二件，设置广播阈值配置项 spark.sql.autoBroadcastJoinThreshold。

对于分布式数据集的尺寸预估，我们还是使用 sizeNew 函数，把 luckyDogsDF 作为实参，调用 sizeNew 函数，返回的估算尺寸为 18.5MB。有了这个参考值，我们就可以设置广播阈值了。要把 applyDistinctDF 与 luckyDogsDF 的关联计算转化为 Broadcast Join，只要让广播阈值大于 18.5MB 就可以，我们不妨把这个参数设置为 20MB。

对比基准与优化设置

我把配置项调整前后的实验结果记录到了如下表格，显然，相比默认的 Shuffle Sort Merge Join 实现机制，Broadcast Join 的执行性能更胜一筹。

性能对比

案例 3 的性能调优：中签率的变化趋势

案例 3 的业务目标是洞察中签率的变化趋势，我们先来回顾代码。要计算中签率，我们需要分两步走。第一步，按照摇号批次，也就是 batchNum 分别对 applyDistinctDF 和 luckyDogsDF 分组，然后分别对分组内的申请者和中签者做统计计数。第二步，通过数据关联将两类计数做除法，最终得到每个批次的中签率。

// 统计每批次申请者的人数
val apply_denominator = applyDistinctDF
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("denominator"))

// 统计每批次中签者的人数
val lucky_molecule = luckyDogsDF
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))

val result03 = apply_denominator
.join(lucky_molecule, Seq("batchNum"), "inner")
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
.orderBy("batchNum")

result03.write.format("csv").save("_")

由于 2011 年到 2019 年总共有 72 个摇号批次，因此第一步计算得到结果集，也就是 apply_denominator 和 lucky_molecule 各自有 72 条数据记录。显然，两个如此之小的数据集做关联不存在什么调优空间。

因此，对于案例 3 来说，调优的关键在于第一步涉及的两个单表 Shuffle。关于单表 Shuffle 的调优思路与技巧，我们在案例 2 的场景 1 做过详细的分析与讲解，因此，applyDistinctDF 和 luckyDogsDF 两张表的 Shuffle 优化就留给你作为课后练习了。

案例 4 的性能调优：中签率局部洞察

与案例 3 不同，案例 4 只关注 2018 年的中签率变化趋势，我们先来回顾案例 4 的代码实现。

// 筛选出2018年的中签数据，并按照批次统计中签人数
val lucky_molecule_2018 = luckyDogsDF
.filter(col("batchNum").like("2018%"))
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))

// 通过与筛选出的中签数据按照批次做关联，计算每期的中签率
val result04 = apply_denominator
.join(lucky_molecule_2018, Seq("batchNum"), "inner")
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
.orderBy("batchNum")

result04.write.format("csv").save("_")

从代码实现来看，案例 4 相比案例 3 唯一的改动，就是在 luckyDogsDF 做统计计数之前增加了摇号批次的过滤条件，也就是 filter(col("batchNum").like("2018%"))。你可能会说：“案例 4 的改动可以说是微乎其微，它的调优空间和调优方法应该和案例 3 没啥区别”。还真不是，还记得 Spark 3.0 推出的 DPP 新特性吗？添加在 luckyDogsDF 表上的这个不起眼的过滤谓词，恰恰让 DPP 有了用武之地。

在 DPP 那一讲，我们介绍过开启 DPP 的 3 个前提条件：

事实表必须是分区表，且分区字段（可以是多个）必须包含 Join Key

DPP 仅支持等值 Joins，不支持大于、小于这种不等值关联关系

维表过滤之后的数据集，必须要小于广播阈值，因此，你要注意调整配置项 spark.sql.autoBroadcastJoinThreshold

那么，这 3 个前提条件是怎么影响案例 4 的性能调优的呢？

首先，在上一讲，我们介绍过摇号数据的目录结构，apply 和 lucky 目录下的数据都按照 batchNum 列做了分区存储。因此，案例 4 中参与关联的数据表 applyDistinctDF 和 luckyDogsDF 都是分区表，且分区键 batchNum 刚好是二者做关联计算的 Join Key。其次，案例 4 中的关联计算显然是等值 Join。

最后，我们只要保证 lucky_molecule_2018 结果集小于广播阈值就可以触发 DPP 机制。2018 年只有 6 次摇号，也就是说，分组计数得到的 lucky_molecule_2018 只有 6 条数据记录，这么小的“数据集”完全可以放进广播变量。

如此一来，案例 4 满足了 DPP 所有的前提条件，利用 DPP 机制，我们就可以减少 applyDistinctDF 的数据扫描量，从而在整体上提升作业的执行性能。

对比基准与DPP优化

DPP 的核心作用在于降低事实表 applyDistinctDF 的磁盘扫描量，因此案例 4 的调优办法非常简单，只要把最初加在 applyDistinctDF 之上的 Cache 去掉即可，如上表右侧所示。同时，为了公平起见，对比基准不应该仅仅是让 DPP 失效的测试用例，而应该是 applyDistinctDF 加 Cache 的测试用例。与此同时，我们直接对比 DPP 的磁盘读取效率与 Cache 的内存读取效率，也能加深对 DPP 机制的认知与理解。

把上述两个测试用例交付执行，运行结果如下。可以看到，相较对比基准，在 DPP 机制的作用下，案例 4 端到端的执行性能有着将近 5 倍的提升。由此可见，数据集加 Cache 之后的内存读取，远不如 DPP 机制下的磁盘读取更高效。

性能对比

案例 5 的性能调优：倍率分析

案例 5 也包含两个场景，场景 1 的业务目标是计算不同倍率下的中签人数，场景 2 与场景 1 相比稍有不同，它的目的是计算不同倍率下的中签比例。

尽管两个场景的计算逻辑有区别，但是调优思路与方法是一致的。因此，在案例 5 中，我们只需要对场景 1 的性能优化进行探讨、分析与对比，我们先来回顾一下场景 1 的代码实现。

val result05_01 = applyNumbersDF
.join(luckyDogsDF.filter(col("batchNum") >= "201601")
.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")

result05_01.write.format("csv").save("_")

仔细研读代码之后，我们发现场景 1 的计算分为如下几个环节：

大表与小表的关联计算，且小表带过滤条件

按 batchNum 列做统计计数

按 carNum 列取最大值

按 multiplier 列做统计计数

在这 4 个环节当中，关联计算涉及的数据扫描量和数据处理量最大。因此，这一环节是案例 5 执行效率的关键所在。另外，除了关联计算环节，其他 3 个环节都属于单表 Shuffle 优化的范畴，这 3 个环节的优化可以参考案例 2 场景 1 的调优思路与技巧，咱们也不多说了。因此，对于案例 5 的性能优化，我们重点关注第一个环节，也就是 applyNumbersDF 与 luckyDogsDF 的关联计算。

仔细观察第一个环节的关联计算，我们发现关联条件中的 Join Key 是 carNum，而 carNum 并不是 applyNumbersDF 与 luckyDogsDF 两张表的分区键，因此，在这个关联查询中，我们没有办法利用 DPP 机制去做优化。

不过，applyNumbersDF 与 luckyDogsDF 的内关联是典型的“大表 Join 小表”，对于这种场景，我们至少有两种方法可以将低效的 SMJ 转化为高效的 BHJ。

第一种办法是计算原始数据集 luckyDogsDF 的内存存储大小，确保其小于广播阈值，从而利用 Spark SQL 的静态优化机制将 SMJ 转化为 BHJ。第二种方法是确保过滤后的 luckyDogsDF 小于广播阈值，这样我们就可以利用 Spark SQL 的 AQE 机制来动态地将 SMJ 转化为 BHJ。

接下来，我们分别使用这两种方法来做优化，比较它们之间，以及它们与对比基准之间的性能差异。在案例 2 场景 2 中，我们计算过 luckyDogsDF 在内存中的存储大小是 18.5MB，因此，通过适当调节 spark.sql.autoBroadcastJoinThreshold，我们就可以灵活地在两种调优方法之间进行切换。

对比基准与优化设置

将 3 种测试用例付诸执行，在执行效率方面，SMJ 毫无悬念是最差的，而 AQE 的动态优化介于 SMJ 与 Spark SQL 的静态转化之间。毕竟，AQE 的 Join 策略调整是一种“亡羊补牢、犹未为晚”的优化机制，在把 SMJ 调整为 BHJ 之前，参与 Join 的两张表的 Shuffle 计算已经执行过半。因此，它的执行效率一定比 Spark SQL 的静态优化更差。尽管如此，AQE 动态调整过后的 BHJ 还是比默认的 SMJ 要高效得多，而这也体现了 AQE 优化机制的价值所在。

性能对比

小结

今天这一讲，我们结合以前学过的知识点与调优技巧，以小汽车摇号为例 Case By Case 地做性能优化。涉及的优化技巧有 Shuffle 读写缓冲区调整、加 Cache、预估数据集存储大小、Spark SQL 静态优化、AQE 动态优化（自动分区合并与 Join 策略调整）以及 DPP 机制。为了方便你对比，我把它们总结在了一张脑图里。不过，我更希望你能自己总结一下，这样才能记得更好。

最后我想说，很遗憾我们没能在这个实战里，把专栏中所有的调优技巧付诸实践，这主要是因为小汽车摇号应用相对比较简单，很难覆盖所有的计算与优化场景。对于那些未能付诸实战的调优技巧，只能靠你在平时的工作中去实践了。

不过，专栏的留言区和咱们的读者群，会一直为你敞开，尽管我不能做到立即回复，但我可以承诺的是，对于你的留言，我只会迟到、绝不缺席！

每日一练

你能参考案例 2 场景 1，完成案例 3 中 applyDistinctDF 和 luckyDogsDF 两张表的单表 Shuffle 优化吗？

你能参考案例 5 场景 1，综合运用 AQE、Broadcast Join 等调优技巧，对案例 5 场景 2 做性能优化吗？

期待在留言区看到你的优化结果，也欢迎你随时提问，我们下一讲见！

结束语 | 在时间面前，做一个笃定学习的人

你好，我是吴磊。

时间犹如白驹过隙，不知不觉，就到了要和你说再见的时候。当编辑对我说：“老师，这周要把结束语赶出来哟”，我忽然愣住了，觉得有些恍惚和不真实，并没有像往常那样脱口而出：“好嘞，没问题！”因为我似乎已经习惯了赶稿子，也习惯了回答评论区的问题，习惯了和大家互动，更习惯了在群里插科打诨……

但是，无论再怎么习惯，我们的专栏也在今天正式结束了。在这里，我想借着这最后的机会，由衷地感谢屏幕前的你、感谢在听音频的你。感谢你一直以来的陪伴和督促，感谢你参与评论区的讨论，感谢你及时地纠正专栏中的错误，更感谢你对于专栏的包容和理解。

同时，我也希望在这个专栏中，我们不仅能共同探讨快速、高效掌握 Spark 性能的方法，还能把同样的学习技巧迁移到其他的领域。毕竟，高效的学习效率，永远可以让我们快人一步。

我们到底该如何学习？

说起学习这件事，我们听到最多的，就是“学习是一件反人类的事情”，或是“学习的过程就是成长，而成长本来就是痛苦的”。似乎一提到学习，我们就要随时准备“头悬梁，锥刺股”。那真是这样吗？

坦白地说，关于学习的“痛苦学说”，我不是很认同。原因很简单，我们有很多的方法与技巧可以让学习这件事变得有趣，而且事半功倍。那么，对我来说，3 个提升学习效率最有效的方法，分别是生产导向的思维方式、“235”的学习原则和“生活化联想”的学习技巧。

生产者思维

我们先来说说生产者思维。如果把形形色色的信息、知识、原理、理论看作是商品的话，那么毫无疑问，热爱学习的我们就是这些“商品”的消费者，我们花费大量的时间、精力和注意力，尝试从这些商品中获取价值。

然而，商品的消费是单向而又被动的，这就好比你从收音机或是电视中被动地收听、收看那些提前烧制好的节目。无论节目的设计与制作多么精良，作为消费者，我们能够消化和吸收的程度都是有限的。

这个时候，我们可以采用以产出内容为导向的学习方式，它会比被动式的学习方式效率更高。就拿技术博客来说，输出内容的过程，其实是帮你发现漏洞和不足的过程，写着写着发现卡住了、进行不下去了，往往意味着这部分知识点或是原理你掌握得不够牢固，需要回炉另造。因此，带着目的、有针对性地学习，往往会事半功倍。以输出为导向去获取输入，就是我们常说的生产者思维。

不过你可能会说：“就算生产者思维确实能够帮助我提升学习效率，可是我真的不知道，生产这件事该从哪里下手”。如果你已经很久没有动过笔了，那么不妨从做笔记开始。做笔记至少有两点收益：一来，它可以强化记忆，也就是俗话常说的“好记性不如烂笔头”；二来，笔记本身就是一种产品形态，或者说是产品的初级形态，它就是最原始的生产资料。

在我们有了生产资料的原始积累之后，生产的过程就会变得很轻松，只要对它们稍作加工，比如调整上下文排列、理顺逻辑关系、变换呈现形式，我们就可以“换着花样”地交付各式各样的产品，比如技术博客、公众号图文，甚至是极客时间上的专栏课程等等。这些产品形态，依然没有逃脱笔记的范畴，它们都是笔记的“变形”。

因此，关于生产这件事，如果你暂时还没有思绪，那么我强烈推荐你从做笔记下手。古人云：“读书破万卷，下笔如有神”。对于生产，我们也可以说“笔记做到家，输出如有神”。

“235”原则

“235”原则指的是要想充分掌握一项技能或是本领，理论学习占两成，交流讨论占三成，动手实践占五成。按照“235”的指导原则，讨论和实践要比理论学习更重要。

实践对于学习的贡献不言自明，“实践出真知”“实践是检验真理的唯一标准”，这些老生常谈的道理强调的都是实践的重要性。不过，在这三个学习要素当中，要说投入产出比最高的，当属交流讨论。为什么这么说呢？这就要说到学习这件事情的本质了。你可能会说：“学习自然是获取知识、习得技能、掌握本领。”但在我看来，学习的本质是一个不断做熵减、消除不确定性的过程。

熵减听上去过于玄乎，我们来举个例子。假设有两个房间，每个房间都有 5 个男生和 5 个女生，男女比例都是 1：1，如果按照性别来统计的话，那么这两个房间的信息熵都是 1。然后，我们让第一个房间中所有的女生都移步到第二个房间，同时让第二个房间里所有的男生都移步到第一个房间。交换完成之后，每个房间的信息熵都降低至 0。这就是一个熵减的过程，它消除了房间内“性别”这个属性的不确定性。

学习的过程也类似。对于一项新技术，它是什么？谁创造了它？它解决了什么问题？它的组成要素都有哪些？它是如何工作的？它的适用场景都有哪些？每一个问题都是一个未知变量，所有这些未知变量交织在一起，让这项技术的熵无限趋近于 1。而学习的过程，正是以蚕食鲸吞的方式，将一个个未知变量转化为已知变量的过程。在这个过程中，新技术的熵逐级递减，不断趋近于零。

在学习的过程中，不同的方法对熵减的贡献有所不同。交流讨论对于熵减的贡献是指数级的，它是最高效的熵减途径。而这，也是我为什么一直鼓励你参与课后讨论。通过参与讨论，你可以把一些工作中未曾遇到的问题和解决办法，内化为自己的一部分，在拓展认知边界的同时，加深对于技术本身的理解。

而另外两种途径，也就是理论学习和动手实践，它们对于熵减的贡献都是线性而平缓的。你可能会说：“动手实操才应该是贡献最高的呀！”实践固然重要，但你不妨回想一下过往的工作经历，对于某一项技术成百上千条的 Code Paths 来说，你真正触达到的，总共能有几条呢？

无论我们多么投入、多么勤奋，我们的认知边界总是有限的。而要拓展认知边界，只有通过不断地分享、交流和讨论，才能够做到，这也就是所谓的“他山之石，可以攻玉”。

生活化联想

生活化联想指的是把技术中的原理和知识点映射到生活中来，也就是运用生活化的类比、拟人、比喻等手法，让那些枯燥的原理和知识与生活产生连接。利用“生活化联想”的学习技巧，我们能够很快地吸收新知识。更重要的是，这个技巧可以延长我们的记忆曲线，让我们记得更久。

《人月神话》的作者 Frederick Brooks 曾经说过：“任何一种软件架构的设计，都逃不脱人类社会的组织结构”。同理，对于层出不穷的技术、琳琅满目的知识点，我们也都能在生活中找到它们的投影。因此，在日常的学习中，我们就可以放心大胆地大开脑洞，勇敢地去尝试把你的所见所学，与生活中有意思的场景建立连接。一旦你开始这么做了，你就会发现，相比同龄人，上帝似乎为你多开了一扇门，那扇门的后面就是绚烂多彩而又有趣的新世界。

而这，也是我为什么会不遗余力、绞尽脑汁、挖空心思地，尝试用一个又一个类比、一个又一个故事，去为你讲解 Spark 核心概念、关键知识点以及它的工作原理。我的初衷朴素而又简单，就是想让你既能轻松、迅速地消化、吸收，又能把那些枯燥的理论知识记得更牢固、更持久。

建立技术与生活这二者之间的连接，自然少不了要消耗精力和能量，去激活大脑中更多的神经元。但是，你千万别觉得这个过程很痛苦，相反，它其实会让你有一种莫名的期待与兴奋。因为你知道，一旦连接建立完成，那么这条连接两端的东西，技术也好、生活也罢，就都内化成为你的一部分了。

有了生产者思维，遵循“235”原则，同时结合“生活化联想”的学习技巧，我相信，你的学习效率一定会与日俱增。

不见不散

最后，我还是想发自内心地和你说一声：“谢谢！”谢谢你陪我走完了这趟 Spark 调优之旅。对我来说，陪伴即是最大的认可。

你知道，我既不是 Spark Committer，也不是 Spark Contributor，只不过是一个对 Spark 有着无限热情和多年实战经验的开发老兵。我能做的也 仅仅是把我的所学、所见、经验、积累、思路与技巧，像榨汁一样从我的脑海中一滴不剩、毫无保留地压榨出来，再用专栏的形式呈现给你。

才浅学疏，我个人的能力与视野是极其有限的，专栏的内容难免有所疏漏。我真的觉得，就这个专栏来说，我不过是个发起者，就像是一个 GitHub 项目的创建者，而专栏的勘误、丰富、打磨与完善，实际上仰仗的是每一个像你一样参与订阅的开发者。

因此，尽管我们的专栏结束了，但是，我们学习并不会止步于此。在接下来的一段时间，我会在 GitHub 中创建一个名为 potatoes，也就是“土豆”的项目，把专栏中涉及的代码、数据、结果，以及常见问题等内容汇总到这个项目中去。

不仅如此，结合你的需要，我还会持续不断地向其中添加诸如笔试面试题、工作机会、职业发展等内容，把 potatoes 项目打造成我们共有的 Spark 私塾。通过这个私塾，我们不仅能进一步深入学习 Spark，还可以建立更多的连接、拓展人脉，并触达更多的机会与可能。项目的地址是：https://github.com/wulei-bj-cn/potatoes.git，我在那里等你，不见不散！

天行健，君子以自强不息。持续学习，与君共勉。

最后的最后，我还为你准备了一份毕业调查问卷，题目不多，希望你能花两分钟的时间填一下。一起走过了这些时间，期待听到你对我和这个课程的反馈和建议！