☰
Spark-Scala实战进阶:序列化、Shuffle调优与数据倾斜处理
2026/10/26 11:15:55 网站建设 项目流程

1. 从“能跑”到“跑得稳”:Spark-Scala 实战进阶的分水岭

写过几个 Spark 作业的人大概都有过这种体验:本地用local[*]跑个小数据集,逻辑验证通过,心里美滋滋;一上集群、数据量一放大,任务要么卡在某个 Stage 不动,要么 OOM 报错刷屏,要么跑完了结果对不上。这时候你会发现,前面十篇学的那套“RDD 转换动作、DataFrame 基本 API、简单聚合”只是入场券,真正决定你能不能把活干完的,是后面这些关于数据倾斜、序列化、分区策略、Shuffle 调优、资源参数的硬功夫。

这篇是 Spark-Scala 实战系列的第 11 篇,我不打算再重复讲map、filter怎么用,那些东西查文档比看我写更清楚。我想聊的是那些文档里写得含糊、教程里一笔带过、但实际项目里天天绊你一脚的东西。Scala 写 Spark 有个特点:语法糖多、隐式转换多、类型系统强,写起来爽,但一旦出问题,报错信息经常让人摸不着头脑。比如一个Task not serializable,新手能查一整天;再比如groupByKey和reduceByKey到底差在哪,很多人背得出“前者 shuffle 全量数据”,但真到调优时还是凭感觉选。

这篇文章面向的是已经能写出可运行 Spark 作业、但想让它跑得更快、更省资源、更少报错的开发者。我会围绕几个核心痛点展开:序列化问题的根因与解法、Shuffle 阶段的调优逻辑、数据倾斜的识别与处理、以及 Scala 特有的一些写法陷阱。每个点我都会给出“为什么”和“怎么做”,参数怎么算、代码怎么写、坑在哪里,尽量让你看完能直接抄作业。文中涉及的具体数值和集群规模,我会用虚构的模拟场景来说明,你按自己环境的实际情况换算即可。

先说个我自己的判断:Spark 调优这件事,80% 的问题集中在 Shuffle 和序列化两块。你把这两块吃透,剩下的基本是参数微调和经验积累。所以下面的内容会围绕这两条主线铺开,中间穿插 Scala 语言层面的注意事项。

2. 序列化报错:Scala 闭包捕获的那些隐形雷

2.1 为什么Task not serializable总在你想不到的地方冒出来

先讲个最常见的场景。你写了个类,里面有个方法要传给map用,代码大概长这样:

class DataProcessor(config: AppConfig) { def process(rdd: RDD[String]): RDD[String] = { rdd.map(line => config.prefix + line) } }

跑起来直接报org.apache.spark.SparkException: Task not serializable。很多人第一反应是“我传的明明是个字符串啊”,但问题不在字符串,在于map里的那个匿名函数捕获了整个this对象。Scala 在编译闭包时,如果引用了外部类的成员(这里是config),默认会把整个外部类实例一起序列化发到 Executor。而DataProcessor这个类本身可能持有不可序列化的字段(比如数据库连接、日志器、或者另一个没实现Serializable的对象),于是整个链条崩掉。

这个机制的根源在于 Spark 的分布式执行模型:Driver 端把闭包序列化成字节流,通过网络发到各个 Executor,Executor 反序列化后执行。任何被闭包“顺带”捕获的东西都必须能序列化。Scala 的闭包捕获规则比 Java 更激进,因为它支持把方法当值传递,编译器为了省事经常把外层引用整个打包。

解法有几种,我按推荐程度排:

  • 把需要的字段抽成局部变量:在方法内部先val p = config.prefix,然后rdd.map(line => p + line)。这样闭包只捕获p这个字符串,不碰this。
  • 让配置类实现Serializable:如果AppConfig本身字段都简单,加个extends Serializable最省事。但要注意它引用的其他对象也得可序列化,否则问题只是往后挪了一层。
  • 用广播变量:如果配置数据比较大、又要反复用,sc.broadcast(config)更合适,既避免重复序列化,又减少网络传输。

提示:判断闭包到底捕获了什么,可以在报错堆栈里找WriteObject那一行,它会告诉你哪个类在序列化时失败。顺着类名往上翻,基本能定位到捕获源头。

2.2 Kryo 序列化:默认 Java 序列化的性能替代方案

Spark 默认用 Java 原生序列化,好处是兼容性强,坏处是慢、体积大。对于要频繁 shuffle 的作业,序列化开销能占到总耗时的两三成。Kryo 是官方推荐的替代方案,序列化速度快、生成的字节流小,代价是需要提前注册要序列化的类。

配置方式在SparkConf里:

val conf = new SparkConf() .set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .set("spark.kryo.registrationRequired", "false") .registerKryoClasses(Array(classOf[MyRecord], classOf[MyKey]))

这里有个实操细节:registrationRequired设成true能强制所有类都注册,性能最好,但一旦漏注册就直接报错,适合类结构稳定的生产作业;设成false则未注册的类走 Java 序列化兜底,不容易崩但性能打折。我一般开发阶段用false图省事,上线前改成true把该注册的都补齐。

还有个容易忽略的点:如果你用的是自定义的 case class 作为 key 或 value,记得把它注册进去。Scala 的 case class 默认实现了Serializable,但 Kryo 不认识它,不注册就会走兜底路径,白白损失性能。注册的时候用classOf[YourCaseClass]即可,Kryo 会自动处理伴生对象。

2.3 广播变量与累加器:别让它们变成新的序列化负担

广播变量本身是为了减少数据传输,但用错了反而添乱。我见过有人在map里对广播变量做value调用后又包了一层闭包,结果广播变量没被正确识别,退化成普通变量传输。正确姿势是:广播变量在 Driver 端创建一次,在 Executor 端通过.value读取,读取动作要发生在算子内部,而不是在 Driver 端提前展开。

累加器也有类似问题。累加器只能在 Driver 端创建、在 Executor 端的算子内累加,且只有在行动操作触发后值才可靠。如果你在transform里累加、然后只调了count之外的惰性操作,累加器的值可能是 0 或者不完整。这个坑我在早期项目里踩过,排查了半天以为是逻辑错,其实是累加器没被触发。

3. Shuffle 调优:分区数不是拍脑袋定的

3.1groupByKey与reduceByKey的本质差异,以及什么时候该用aggregateByKey

这三个算子的区别,面试常考,但实际写代码时很多人还是随手groupByKey。我用一句话概括:groupByKey把同一个 key 的所有 value 拉到一起再处理,reduceByKey在 map 端先做局部聚合再 shuffle,aggregateByKey则允许你指定不同的初始值和聚合逻辑,灵活性最高。

差异体现在网络传输量上。假设有 1 亿条记录、100 万个 key,平均每个 key 100 条 value。groupByKey要把这 1 亿条全部通过网络 shuffle 到对应分区;reduceByKey先在每个 map 分区内对相同 key 做一次合并,假设每个分区内平均能压到 10 条,那网络上传的就只有 1000 万条。这个差距在数据量大时是数量级的。

但reduceByKey有个限制:它的聚合逻辑必须满足结合律和交换律,且输入输出类型一致。如果你需要“先给每个 key 一个初始值,再按不同类型聚合”,就得用aggregateByKey。举个实际例子,统计每个用户的访问时长和访问次数,初始值是个(0L, 0)的元组:

val result = rdd.aggregateByKey((0L, 0))( (acc, v) => (acc._1 + v.duration, acc._2 + 1), (a, b) => (a._1 + b._1, a._2 + b._2) )

第一个函数是分区内聚合,第二个是分区间合并。注意两个函数的逻辑要一致,否则结果会错。我见过有人分区内用了求和、分区间用了求平均,跑出来的数完全对不上,还以为是 Spark 的 bug。

3.2 分区数怎么算:从数据量和核数反推

spark.sql.shuffle.partitions默认是 200,这个值对中小作业够用,但对大作业要么太少(每个分区数据量过大导致 OOM),要么太多(大量小任务调度开销压垮 Driver)。合理的分区数我一般按这个思路估:

  • 先看 shuffle 阶段要处理的数据总量,假设是D(单位 GB)。
  • 目标让每个分区的数据量落在 100MB 到 200MB 之间,这是经验值,太小调度开销大,太大容易 OOM。
  • 分区数 ≈D / 0.128(按 128MB 算),再向上取整到核数的整数倍。

举个例子,shuffle 数据量 50GB,那分区数大概 400 左右。如果集群有 100 个核,400 是 4 的倍数,比较均衡。这个算法不精确,但比拍脑袋强。实际调的时候可以先用这个值跑一遍,看 Spark UI 里每个 task 的 shuffle read size,如果普遍偏大就加分区,普遍只有几 MB 就减。

还有个细节:repartition和coalesce都能改分区数,但前者会全量 shuffle,后者只能减不能增且尽量少 shuffle。如果是要增加分区,只能用repartition;如果只是合并小文件、减少分区,用coalesce更划算。但注意coalesce减分区时可能导致数据分布不均,如果后续还有聚合操作,最好配合repartition重新打散。

3.3 Shuffle 文件管理:spark.shuffle.file.buffer和spark.reducer.maxSizeInFlight

这两个参数是 Shuffle 性能的隐形推手。spark.shuffle.file.buffer控制 map 端写 shuffle 文件时的缓冲区大小,默认 32KB,适当调大到 64KB 或 128KB 能减少磁盘 IO 次数。spark.reducer.maxSizeInFlight控制 reduce 端一次拉取多少数据,默认 48MB,调大到 96MB 能减少网络请求次数,但会占用更多内存。

调这两个参数的逻辑是:如果你的作业 shuffle 量大、磁盘 IO 是瓶颈,优先调大 buffer;如果网络延迟高、拉取次数多,优先调大 maxSizeInFlight。但别一次调太猛,内存不够会引发 GC 问题。我一般以 2 倍为步长往上试,观察 Spark UI 里 shuffle write 和 fetch 的耗时变化。

4. 数据倾斜:识别、定位、处理的完整链路

4.1 怎么判断作业是不是遇到了数据倾斜

数据倾斜的典型症状是:绝大多数 task 几分钟就跑完,个别 task 卡几十分钟甚至几小时;或者某个 Stage 的 shuffle read size 分布极不均匀,最大值是最小值的几十上百倍。在 Spark UI 的 Stage 详情页里,看 task 的 Duration 和 Shuffle Read Size 两列,如果明显有几个 task 的数值远超中位数,基本可以确诊。

还有一种隐蔽的倾斜:task 耗时看起来均匀,但 GC time 特别高。这往往是因为某些 key 对应的数据量太大,单个 task 内存吃紧,频繁 Full GC。这种情况光看耗时看不出来,得点进 task 详情看 GC 占比。

定位到倾斜后,下一步是找出是哪些 key 导致的。可以在倾斜的 Stage 前加一段采样代码,统计 key 的分布:

val sampled = rdd.sample(false, 0.1) val topKeys = sampled.map(r => (r.key, 1)).reduceByKey(_ + _) .takeOrdered(20)(Ordering.by(-_._2))

采样比例根据数据量调整,10% 一般够用。拿到 top key 后,结合业务逻辑判断为什么这些 key 数据量大——是某个默认值(比如 null、空字符串、0)占了绝大多数,还是某个真实业务实体本身记录就多。

4.2 空值倾斜与热点 key 倾斜:两种不同病因,两套解法

空值倾斜是最容易处理的。如果倾斜是因为大量记录的 key 是 null 或某个默认值,而这些记录本来就不需要参与聚合,直接在聚合前过滤掉即可。如果业务上必须保留,可以给空值加随机后缀打散:

val random = new scala.util.Random() val salted = rdd.map { r => if (r.key == null || r.key.isEmpty) { (s"${random.nextInt(100)}_null", r.value) } else (r.key, r.value) }

打散后先做一次局部聚合,再把后缀去掉做第二次聚合。这个套路叫“加盐”,本质是用随机前缀把热点 key 拆成多个子 key,让它们分散到不同分区并行处理。

热点 key 倾斜(某个真实 key 数据量特别大)处理起来更麻烦。如果这个 key 的数据可以拆分(比如是某个大客户的订单,可以按订单号再分),就用两阶段聚合:第一阶段给 key 加随机前缀做局部聚合,第二阶段去掉前缀做全局聚合。如果数据不可拆,就得考虑单独把这个 key 拎出来处理,用广播变量或者单独的小作业算完再合并。

注意:加盐方案会增加一次 shuffle,网络开销变大。如果倾斜不严重(比如最大 task 只是中位数的三五倍),用spark.sql.adaptive.enabled=true开自适应执行,让 Spark 自动合并小分区、拆分大分区,往往就够了,不必上手动加盐。

4.3 自适应查询执行(AQE):让 Spark 自己处理倾斜

Spark 3.x 之后 AQE 逐渐成熟,开起来很简单:

conf.set("spark.sql.adaptive.enabled", "true") conf.set("spark.sql.adaptive.skewJoin.enabled", "true") conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")

AQE 在运行时根据实际 shuffle 数据量动态调整分区数、自动处理 join 倾斜。它的倾斜处理逻辑是:检测到某个分区数据量超过阈值(默认是平均值的 5 倍且大于 256MB),就把这个大分区拆成多个小分区并行处理。这个机制对 SQL 和 DataFrame API 都生效,但对 RDD API 支持有限,因为 RDD 没有 schema 信息,AQE 拿不到统计元数据。

所以如果你主要用 DataFrame,强烈建议开 AQE,很多倾斜问题它能自动兜住。如果用的是 RDD,还是得靠手动方案。这也是我近两年更倾向用 DataFrame API 写业务逻辑的原因之一——调优的自动化程度高太多。

5. Scala 写 Spark 的几个特有陷阱

5.1 隐式转换带来的意外行为

Scala 的隐式转换让代码简洁,但在 Spark 里可能埋雷。最典型的是RDD到DataFrame的隐式转换,需要import spark.implicits._。这个 import 一旦漏了,编译报错还算好的;更麻烦的是某些隐式转换在分布式环境下行为不一致,比如把 Scala 集合转成 Dataset 时,如果集合里有可变对象,序列化后可能出问题。

还有个常见坑是Seq和Array的混用。Array在 Scala 里是可变且不实现Serializable的(严格说 JVM 数组可序列化,但 Scala 的Array包装在某些场景下会有问题),如果把它放进闭包传给 Executor,可能报序列化错。稳妥做法是用Seq或List,它们是不可变且可序列化的。

5.2var在闭包里的陷阱

Scala 允许在闭包里引用var,但 Spark 的分布式模型下,Executor 端对var的修改不会回传 Driver。也就是说:

var counter = 0 rdd.foreach(_ => counter += 1) println(counter) // 输出还是 0

这段代码不会报错,但结果完全不对。正确做法是用累加器。这个坑的隐蔽性在于它“能跑通”,只是结果错,如果没做结果校验根本发现不了。我建议在 Spark 作业里尽量用val,需要跨 Executor 统计就用累加器,需要共享只读数据就用广播变量,别用var做任何跨节点的状态维护。

5.3 case class 作为 key 时的 equals 与 hashCode

用 case class 做 key 做聚合时,Scala 自动生成的equals和hashCode是基于所有字段的。这通常没问题,但如果你在 case class 里放了浮点数或者数组,equals的行为可能不符合预期(浮点精度、数组引用比较)。更稳妥的做法是显式重写equals和hashCode,或者用字符串拼接出一个稳定的 key。

另外,case class 字段顺序变化会影响hashCode,如果同一个 case class 在不同版本的代码里字段顺序不一致,跨版本的数据聚合可能出问题。生产环境里我一般给 key 类加个显式的keyString方法,聚合时用字符串做 key,避免依赖自动生成的哈希。

6. 资源参数与作业稳定性:那些上线前必须过一遍的检查项

6.1 Executor 内存与核数的配比逻辑

spark.executor.memory和spark.executor.cores的配比没有万能公式,但有个经验区间:每个核分配 2GB 到 4GB 内存比较常见。核数太多(比如单 Executor 8 核以上)会导致 HDFS 读写竞争和 GC 压力,核数太少则并行度不够。我一般从 4 核 16GB 起步,根据 Spark UI 里的 GC 时间和 CPU 利用率调整。

内存分配里还有个细节:spark.memory.fraction默认 0.6,表示 60% 的堆内存给执行和存储共用,剩下 40% 留给用户代码和元数据。如果你的作业有大量缓存操作(persist),可以适当调高;如果主要是 shuffle 密集,保持默认或略调低都行。spark.memory.storageFraction控制存储内存占执行内存的比例,默认 0.5,缓存需求大就调高。

6.2 动态资源分配:省资源还是添乱

spark.dynamicAllocation.enabled=true能让 Spark 根据负载自动增减 Executor,理论上省资源。但它有个前提:集群得支持外部 shuffle 服务(spark.shuffle.service.enabled=true),否则 Executor 被回收时 shuffle 数据丢失,任务会重算。在共享集群上开动态分配要谨慎,因为 Executor 回收有延迟,可能占着资源不放,反而影响其他作业。

我的建议是:独占集群、作业时长波动大的场景开动态分配;共享集群、作业时长稳定的场景用固定资源更可控。开的时候把spark.dynamicAllocation.minExecutors和maxExecutors设好边界,别让它无限扩。

6.3 上线前的检查清单

每次作业上线前,我会过一遍这几项:

检查项关注点常见问题
序列化方式是否启用 Kryo 并注册类漏注册导致性能退化
Shuffle 分区数是否按数据量估算默认 200 不够用
数据倾斜采样看 key 分布空值或热点 key 未处理
资源配比内存核数是否合理单核内存过小引发 OOM
结果校验关键指标是否对得上累加器未触发导致统计错
日志级别是否关掉 DEBUG日志刷屏拖慢速度

这张表看着简单,但每一条背后都是踩过的坑。尤其是结果校验那条,Spark 作业的惰性求值特性让很多统计逻辑“看起来跑了其实没跑”,上线前一定要用count或collect触发一次完整执行,核对关键数字。

7. 我在实际项目里沉淀的几条经验

聊了这么多技术点,最后说几条偏“手感”的东西,这些是文档里不会写、但实际干活时特别管用的。

第一,先用小数据验证逻辑,再用采样数据验证性能。逻辑对不对,几千条数据就能看出来;性能有没有问题,得用接近真实分布的采样数据跑。我习惯在开发阶段准备两份数据:一份 1000 条的“逻辑测试集”,一份 1% 采样的“性能测试集”,前者快速迭代,后者提前暴露倾斜和 OOM。

第二,Spark UI 是最好的老师,但要会看。很多人只看 Stage 的耗时,其实更有价值的是 task 级别的分布、GC 时间、spill 情况。一个 Stage 如果 spill(磁盘溢写)量很大,说明内存不够或者分区太大,这时候调内存或加分区比调算子更有效。

第三,别迷信参数调优,先看代码逻辑。我见过太多人一上来就调spark.default.parallelism,结果代码里一个collect把全量数据拉到 Driver,调什么参数都没用。调优的顺序应该是:先看有没有全量收集、有没有不必要的 shuffle、有没有重复计算,这些逻辑问题解决了,再谈参数。

第四,Scala 的强类型是优势,别用Any把它废了。有些人为图省事,RDD 里全用Any或者Row,结果运行时各种类型转换异常。用 case class 定义好数据结构,编译期就能挡掉大部分错误,这比运行时调试省太多时间。

第五,作业的幂等性比性能更重要。一个能重跑、结果稳定的作业,比一个跑得快但偶尔出错的作业有价值得多。写输出的时候尽量用覆盖模式或者带时间戳的路径,避免重跑时数据重复。这个习惯在出故障需要补数据时能救命。

这套东西没有终点,每次遇到新场景都会有新坑。但把序列化、Shuffle、倾斜这三块的基本盘打牢,大部分问题都能在可控时间内定位和解决。剩下的就是多看 UI、多采样、多验证,手感自然就上来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询