1. 卷四的整体定位与题目分布思路拆解
整理这套 Spark 面试真题卷四,起因很简单:前几卷把 RDD 血缘、算子分类、宽窄依赖这些"基础题"扫完之后,很多读者反馈真正卡人的并不是会不会背概念,而是面试官把问题往深水区一推,人就接不住了。比如问完"什么是 Shuffle",紧接着一句"那你说说 SortShuffle 在什么情况下会退化成 Bypass",或者"AQE 的倾斜 join 是怎么判断倾斜的,阈值是多少",能顺畅答下来的人立刻少一大半。卷四就是冲着这个落差来的,题目不再停留在定义层,而是集中在机制因果、参数数值、线上现象三者之间来回切。这也是我在带人复盘时最看重的一卷,因为它最能区分"用过 Spark"和"懂 Spark"。
这套题适合什么人看?我的判断是三类。第一类是有一到两年大数据开发经验、日常写 Spark SQL 和 DataFrame 但没系统啃过内核的同学,你能从卷四里把散落的知识点串成线;第二类是准备跳槽、需要在中高级岗位上答出深度的朋友,卷四的答法可以直接迁移到面试现场;第三类是团队里做性能优化和排障的角色,卷四里的参数计算和排查路径就是我平时处理线上任务的实际顺序。至于完全没碰过 Spark 的读者,建议先把 RDD、分区、DAG 这些基础概念补齐再回来,不然卷四的题目会显得跳。
卷四的题目分布我刻意做了一个"由软到硬"的排列。开篇是 API 选型类的题,考的是判断力而不是记忆;中段进 Shuffle、倾斜、SQL 内核,考的是对执行链路和参数的理解深度;尾段是内存与 GC,考的是能不能把现象和配置对上号。这个顺序不是随手排的,它对应真实面试的节奏:面试官通常从"你平时怎么写"切入,逐步逼到"底层发生了什么",最后用一个故障场景收尾看你有没有实战经验。所以卷四不仅是一份题库,也是一条自查路线——如果你在第 2 章还能对答如流、到第 5 章就开始含糊,那说明你的短板在优化器和执行计划这一层,而不是在写代码这一层。
1.1 为什么这卷偏向机制与数值,而不是概念复述
我在多次复盘里发现一个规律:能把概念背出来的人很多,能说出"为什么是这个数值"的人很少。举个最典型的例子,问"shuffle 分区数设多少合适",绝大多数人答"200 或者调大一点"。这个答案在面试里基本拿不到分,因为它没有计算过程。真正能加分的答法是:先看 shuffle 阶段实际写出的数据量,按单分区 100 到 200MB 的目标反推分区数,再考虑集群可用核数和单 task 的处理开销做上下修正。这里面有三个变量——数据量、目标分区大小、并发度,缺一个都算没答完整。
卷四刻意往这个方向压,是因为机制和数值最能反映一个人有没有真正观察过任务运行。你如果盯过 Spark UI 的 Stages 页面,就会发现每个 stage 的 Shuffle Write 和 Shuffle Read 都有明确字节数,这些数字就是你算分区数的依据,而不是凭感觉。再比如广播 join 的阈值spark.sql.autoBroadcastJoinThreshold默认是 10MB,很多人知道这个参数存在,但不知道它衡量的是优化器估算的连接表大小、而不是磁盘上的文件大小,更不知道当表被压缩存储时,估算值可能明显偏离真实解压后内存占用,从而引发广播后 OOM。这类"数值背后的语义"才是卷四想考的东西。
所以卷四的答题原则可以概括成一句话:任何结论都要能落到一个可观测的量上。说分区数要调,就给数据量和单分区目标;说要用广播,就说小表估算大小和阈值的关系;说要防倾斜,就说怎么从 Spark UI 的 task 时长分布里读出倾斜。这条原则贯穿整卷,也是我给身边同学反复强调的一点——面试官不怕你不会某个参数,怕的是你所有判断都建立在"听说"之上。
1.2 题目与企业真实考察点的映射关系
把卷四的题目拆开看,你会发现每一类题背后都对应着岗位上的一个具体动作。API 选型题对应的是"新需求来了你怎么设计数据流程";Shuffle 题对应的是"任务跑得慢你怎么定位";倾斜题对应的是"某个 stage 卡在 99% 你怎么救";SQL 内核题对应的是"为什么加了过滤条件反而更慢";内存与 GC 题对应的是"Executor 频繁挂掉你怎么查"。这五类动作基本覆盖了大数据开发岗 80% 的日常。
我在帮人做模拟面试时会刻意按这个映射去追问。比如对方说"我会用 DataFrame 而不是 RDD",我立刻追问"那你在什么场景下会主动退回 RDD",这一问就把 API 选型题逼到了边界情况;对方说"数据倾斜我加盐",我追问"加盐之后聚合结果的正确性怎么保证,二次聚合的 key 怎么设计",这就逼到了实现细节。卷四里的每道题我都建议读者用这种方式自问一遍:先给出主答案,再自己找一个边界场景把自己问倒,能补上这个边界,才算真的掌握。
有一点需要提醒:映射关系不等于背书。同样的倾斜题,在离线批处理场景和在流式场景下的解法优先级完全不同。批处理里加盐扩容的代价可以接受,流式里加盐会带来状态膨胀的问题,往往优先考虑的是调整水位线或者改变 join 的驱动侧。卷四以批处理为主,但我在相关章节里会点出流式场景的差异,避免读者把一套解法生搬硬套到所有地方。
1.3 答题节奏与取舍:三分钟里该说什么
面试现场的时间是有限的,一道深度题通常只给你三到五分钟。我的经验是采用"结论—机制—数值—案例"四段式,比例大约 1:2:1:1。先用一句话给出结论,让对方知道你有明确判断;然后花主要篇幅讲机制,也就是底层发生了什么;接着给出一到两个关键数值或参数,证明你不是空谈;最后用一个自己处理过的场景收尾,这个场景不需要多复杂,但要具体。
举个实际用法:被问到"怎么处理 Shuffle 带来的磁盘 IO 压力",结论是"优先减少 Shuffle 数据量,其次才是调分区和换策略";机制部分讲 map 端聚合、combiner 的作用、为什么reduceByKey比groupByKey在 shuffle 写之前就完成了合并;数值部分给分区数计算和spark.shuffle.file.buffer这类写缓冲参数的量级;案例部分讲一次把groupByKey改成预聚合后 shuffle write 从几百 GB 降到几十 GB 的经历。这样答下来,即使某个细节说错了,整体的判断链条也是站得住的。
反过来,最忌讳的答法是上来就报参数名,把能想到的配置念一遍。面试官听到一串参数名但听不到因果,只会认为你在凑答案。卷四后面的章节里我会反复示范这种四段式,你在读的时候可以留意每道题我给出的"结论句"是哪一句,把它抽出来就是你在现场的骨架。
2. RDD、DataFrame、Dataset 三套 API 的取舍真题拆解
三套 API 的对比是卷四的开场题,也是最容易被答成"教科书对比表"的题。我见过太多人张口就是"DataFrame 有 Catalyst 优化、Dataset 类型安全、RDD 灵活",然后就没有然后了。这种答法的问题在于,它把三者当成了并列选项,而实际上它们在执行层面是同一套东西的三个入口——DataFrame 和 Dataset 最终都会经过 Catalyst 变成物理计划,再落到 RDD 级别的执行上。理解这一点,后面的取舍才有落脚点。
我在实际项目里的判断顺序是这样的:能用 SQL 表达的逻辑优先 SQL,需要复杂过程控制但仍然是结构化数据的用 DataFrame,需要操作非结构化数据、需要精细控制分区和内存布局、或者需要复用已有 Java/Scala 对象的才回到 RDD。这个顺序的核心逻辑是"让优化器尽可能多地接管"。Catalyst 能做的谓词下推、列裁剪、常量折叠、join 重排序,在你手写 RDD 的时候全部要自己实现,而且大概率实现得不如它好。我做过一个不算严谨的对比,同一个过滤加聚合的逻辑,DataFrame 版本因为列裁剪只读了 Parquet 里两列,RDD 版本按行读全表,IO 差了将近四倍,这个差距跟集群规模无关,纯粹是读取策略的差别。
2.1 一道高频题的完整答法:什么时候必须退回 RDD
这道题是卷四里区分度很高的一道,因为它考的是边界而不是主流。我的答法是先给三类必须退回 RDD 的场景,再补一句"除此之外都应该留在 DataFrame/Dataset 层"。
第一类是数据本身不是结构化的。比如处理一批文本日志,需要按自定义规则做多轮切分、状态机解析,这种逻辑用 SQL 表达会变成一堆嵌套的regexp_extract和case when,可读性和性能都差。第二类是需要精确控制分区和迭代顺序的场景,比如自己实现一个图迭代算法,每一轮要按分区做消息传递,DataFrame 的分区语义在这个粒度上不够用。第三类是需要复用现成的 Java/Scala 库对象,比如某个已有的特征抽取类,只接受Iterator输入,这时候mapPartitions是最自然的入口。
答完这三类,一定要补上代价说明,否则答案是不完整的。退回 RDD 意味着放弃 Catalyst 优化、放弃 Tungsten 的二进制内存布局、放弃 whole-stage codegen,序列化开销会显著上升。我在一个项目里就吃过这个亏:为了复用一段已有的文本处理逻辑,把整条链路退回到了 RDD,结果原本 40 分钟的任务跑了两个多小时。后来把那段逻辑用 UDF 包起来塞回 DataFrame,虽然 UDF 本身有调用开销,但因为前面的列裁剪和过滤下推都保住了,整体时间回落到 50 分钟左右。这个经历的教训是:退回 RDD 应该是局部行为,而不是整条链路的行为。
注意:UDF 会阻断部分优化,尤其是 Catalyst 无法穿透 UDF 做下推和折叠。如果一定要用 UDF,尽量把过滤条件放在 UDF 之外,让优化器有机会先裁剪数据。
2.2 序列化与内存开销的量化对比
这一小节是卷四里最能体现"数值感"的地方。三套 API 在内存里的表示方式完全不同,这个差异直接决定了同样的数据量下会不会 OOM。
RDD 存的是 JVM 对象。一条包含十来个字段的记录,每个字段都是独立对象,加上对象头、引用指针、对齐填充,实际占用往往是原始数据的五到十倍。我做过一次测算,一个含 12 个字段(4 个字符串、6 个数值、2 个时间戳)的记录,用 Java 序列化后约 400 字节,而在 JVM 堆里作为对象存在时占用接近 1.2KB。这就是为什么 RDD 缓存大量数据时经常出现"数据才几个 G,堆却撑爆了"的情况。
DataFrame/Dataset 走的是 Tungsten 的UnsafeRow,记录被编码成紧凑的二进制格式,字段按偏移量访问,没有对象头开销,还能利用 CPU 缓存局部性。同样的记录,UnsafeRow编码后通常只有几百字节,而且因为连续存放,遍历速度明显更快。这个差异在缓存场景下尤其重要:同样 16GB 的 Executor 内存,用MEMORY_ONLY缓存 RDD 可能只能放三四 GB 数据,换成 DataFrame 的cache()能放十几 GB。
| 对比维度 | RDD | DataFrame | Dataset |
|---|---|---|---|
| 内存表示 | JVM 对象 | UnsafeRow 二进制 | UnsafeRow 二进制 |
| 优化器介入 | 无 | Catalyst 完整介入 | Catalyst 介入,类型操作有部分限制 |
| 类型安全 | 编译期完全安全 | 运行期才校验 | 编译期安全 |
| 序列化开销 | 高,对象与字节反复转换 | 低 | 低 |
| 适合场景 | 非结构化、精细控制 | 结构化分析、SQL 化逻辑 | 强类型领域模型 |
关于 Dataset 有一点要特别说明,很多人以为 Dataset 有编译期类型安全就一定比 DataFrame 慢,这个说法不准确。Dataset 的类型化操作(比如map、filter传 lambda)确实会因为 lambda 无法被 Catalyst 完全分析而产生额外开销,但纯列式操作(select、groupBy)和 DataFrame 走的是同一条路径。Spark 3.0 之后对类型化操作的编码器做了不少优化,差距已经比 2.x 时代小很多。所以选型时不要被"Dataset 慢"这个说法吓住,关键看你的操作里有多少是类型化 lambda。
2.3 类型安全与性能之间的真实权衡
这道题面试官常用来试探你有没有做过架构层面的决策。我的观点是:类型安全的价值在大型项目和长期维护中才会显现,在一次性任务和小团队里,优先性能和维护成本更实际。
具体来说,Dataset 的编译期检查能在代码提交阶段就拦住字段名写错、类型不匹配这类错误。DataFrame 的列是字符串引用的,写错列名要到运行期才报AnalysisException,如果这个错误藏在一个跑两小时的任务的最后阶段,代价就很大。所以在字段多、链路长、多人协作的项目里,我会倾向于用 Dataset 定义领域模型,把 schema 固定下来。但如果是探索性分析、临时取数、快速验证想法,DataFrame 或者直接写 SQL 效率高得多,为了类型安全付出的编码成本并不划算。
还有一个常被忽略的点是跨语言。PySpark 里的 DataFrame 和 RDD 都能用,但 Dataset 在 Python 端是没有真正实现的,Python 的动态类型让编译期检查失去意义,所以 Python 用户实际面对的选择只有 RDD 和 DataFrame 两个。这一点在面试里说出来能加分,因为它说明你不是只在一种语言里用过 Spark。我在带 Python 团队时就是这么定的规矩:能用 DataFrame 就不用 RDD,需要复杂逻辑就写 pandas UDF,尽量不退回 RDD 层。
3. Shuffle 机制真题:从原理到参数调优的完整链路
Shuffle 是卷四的重头戏,几乎每套 Spark 面试题都绕不开,但卷四的问法更细。它不会只问"什么是 Shuffle",而是会问"SortShuffleManager 在什么条件下不走排序"、"Bypass 机制的触发条件是什么"、"Shuffle write 的临时文件什么时候被清理"。这些问题如果只背过八股,是答不出来的。
我习惯先把全流程讲清楚。Shuffle 分两端:map 端把数据按分区器写到本地磁盘,每个 map task 写出一个数据文件加一个索引文件;reduce 端通过网络拉取属于自己的那部分数据,边拉边做聚合或排序。中间这个"落盘再拉取"的动作就是 Shuffle 昂贵的原因——它同时消耗磁盘 IO、网络 IO 和序列化 CPU。Spark 之所以不能像 MapReduce 那样完全流式拼接,是因为它要支持宽依赖的容错重算,落盘的数据在某个分区丢失时可以重算而不必重跑整个上游。
Spark 3.0 之后默认的 Shuffle 实现仍然是基于排序的,但引入了 push-based shuffle 作为可选能力,在特定条件下能把 reduce 端的拉取变成 map 端的推送合并,减少小文件和不均衡拉取。这个特性需要外部 Shuffle 服务支持,开启后对超大 shuffle 场景有收益,但不是万能药,我在第 3.3 节会给出适用判断。
3.1 Shuffle Write 与 Shuffle Read 全流程拆解
写端的过程可以拆成四步,每一步都有对应的可调参数和可观测指标。第一步是分区计算,根据 key 的 hash 值对分区数取模,决定这条记录去哪个 reduce 分区。第二步是写入内存缓冲,这里用的是PartitionedPairBuffer或者PartitionedAppendOnlyMap,前者用于不需要聚合的场景,后者用于需要聚合的场景,容量上限由spark.shuffle.file.buffer(默认 32KB)和溢出阈值共同控制。第三步是溢写,内存缓冲达到阈值(由spark.shuffle.spill.numElementsForceSpillThreshold或内存压力触发)就排序后写到磁盘临时文件。第四步是合并,把所有溢写文件合并成一个大文件并生成索引。
这里有个容易被追问的点:为什么写端要先排序。原因是排序后相同分区的记录连续存放,reduce 端拉取时可以顺序读,而且合并溢写文件时可以用归并而不是全量重排。排序用的比较器在不需要聚合时只比较分区 ID,在需要聚合时比较分区 ID 加 key,后者开销明显更高,这也是为什么groupByKey和reduceByKey的写端性能差异会这么大——前者不做 map 端合并,所有记录都要参与完整排序。
读端的过程相对简单但同样有坑。reduce task 通过BlockManager向各个 map 端发起拉取请求,默认每个请求最多拉取spark.reducer.maxSizeInFlight(默认 48MB)的数据,同时并发请求数由spark.reducer.maxReqsInFlight控制。拉回来的数据先放内存,放不下就溢写磁盘。如果 map 端数量很多而单个分区数据很小,拉取请求数会非常多,网络往返开销成为瓶颈,这时候需要的是减少 map 端文件数或者开启外部 Shuffle 服务的合并能力,而不是一味调大并发。
// 写端溢写阈值的典型调整方式 // 默认 32KB 对机械盘偏小,SSD 环境可以适度调大减少系统调用次数 sparkConf.set("spark.shuffle.file.buffer", "64k") // 读端单次拉取上限,网络带宽充足时可调大,但要注意堆外内存压力 sparkConf.set("spark.reducer.maxSizeInFlight", "64m") // 溢写文件的合并阈值,文件数很多时适度调大可以减少一次合并 sparkConf.set("spark.shuffle.consolidateFiles", "true")注意:调大缓冲区不是没有代价的。
spark.shuffle.file.buffer是每个 shuffle 写任务独占的,如果单 Executor 上并发任务数多,这些缓冲会叠加。我在一个 16 核的 Executor 上把缓冲区调到 128KB,理论上占 2MB,实际因为溢写期间的临时对象,GC 压力明显上升,最后还是回调到了 64KB。
3.2 分区数怎么算:给出公式和实例
这是卷四里我最想强调的一道题,因为它有一个明确的、可以现场推导的答案,而大多数人给不出来。
计算逻辑分三步。第一步,确定 shuffle 阶段实际要处理的数据量。注意不是原始数据量,而是经过过滤和聚合之后的量,这个数字可以从 Spark UI 上一个相似任务的 Shuffle Write 里读出来,或者用一个采样任务先跑一遍估算。第二步,设定单分区目标大小。经验值是 100 到 200MB,偏小会让任务数过多、调度开销上升,偏大则单个任务内存压力大、失败后重算代价高。第三步,用总量除以目标大小得到分区数,再向集群总核数的整数倍取整,避免出现某个核多跑一轮的尾部长尾。
举个例子。一个任务 shuffle write 总量 480GB,单分区目标取 150MB,那么分区数约为 480 × 1024 ÷ 150 ≈ 3277,向上取到集群总核数的整数倍,假设集群有 400 个核,取 3200 或 3600 都合理。对比默认的 200 分区,每个分区要处理 2.4GB 数据,单个任务必然溢出磁盘甚至 OOM,这就是很多任务"看起来配置没问题但就是跑不完"的根源。
反过来的场景也要会算。如果 shuffle 总量只有 8GB,按 150MB 目标算下来是 55 个分区,远小于默认的 200。这时候如果不动参数,会启动 200 个任务处理 8GB 数据,每个任务只有 40MB,任务启动和序列化的开销占比过高,Spark UI 上会看到大量任务耗时集中在几百毫秒。Spark 3.2 之后 AQE 的合并分区能力可以自动处理这种情况,但如果版本较老,就得手动把spark.sql.shuffle.partitions调下来。
| 场景 | shuffle 数据量 | 建议分区数 | 说明 |
|---|---|---|---|
| 小规模聚合 | 5GB | 50 到 60 | 避免任务数过多导致调度开销占比高 |
| 中等规模关联 | 60GB | 400 左右 | 按 150MB 单分区估算 |
| 大规模宽表 join | 500GB | 3000 到 4000 | 需结合集群核数和单任务内存上限 |
| 存在严重倾斜 | 任意 | 先调分区再加盐 | 单纯增加分区数无法解决倾斜 |
还有一个细节值得在面试里提:spark.sql.shuffle.partitions只影响 SQL 和 DataFrame 的 shuffle 分区数,RDD 的reduceByKey等方法分区数由你显式传入的参数决定,两者不是一回事。我见过有人在 RDD 任务里调这个参数调了半天没效果,就是因为搞混了作用域。
3.3 Bypass 机制与 push-based shuffle 的适用判断
Bypass 是 SortShuffleManager 的一个优化路径,触发条件是分区数小于等于spark.shuffle.sort.bypassMergeThreshold(默认 200),且不需要 map 端聚合。满足条件时,每个 map task 直接为每个 reduce 分区写一个独立文件,最后简单拼接,跳过排序这一步。它的好处是省掉了排序的 CPU 开销,坏处是同时打开的文件句柄数等于分区数,分区数一多就会耗尽句柄。
所以这个参数的调优方向很明确:分区数少、单条记录大、不需要聚合的场景,可以适度调大阈值走 Bypass;分区数多或者需要聚合的场景,老老实实走排序路径。我在处理一批大文本记录的解析任务时用过这个优化,把阈值提到 500,写端 CPU 时间降了差不多三成,因为那些记录每条都有几 KB,排序的比较开销很可观。
push-based shuffle 则是另一个层面的优化,它把 reduce 端的拉取改成 map 端主动推送并合并,目标是减少随机读和小文件。开启的前提是集群部署了外部 Shuffle 服务,配置项是spark.shuffle.push.enabled。它的收益在大规模 shuffle 上比较明显,尤其是 map 任务数远大于 reduce 任务数的时候。但如果集群规模不大、shuffle 数据量在百 GB 以内,收益往往被额外的合并开销抵消,我一般不建议在小集群上默认开启。
4. 数据倾斜真题:识别、定位与四类解法
数据倾斜是卷四里最贴近实战的一章,因为它几乎不可能靠背题解决。面试官问这道题时,真正想听的是你有没有从头到尾处理过一次倾斜——从发现现象、定位原因、选择方案到验证效果。只答"加盐"两个字,等于告诉对方你只是听说过。
先说识别。倾斜在 Spark UI 上的表现非常直观:同一个 stage 里,绝大多数 task 在几分钟内完成,剩下几个 task 跑了半小时还没结束,而且这几个 task 的 Shuffle Read Size 明显高于平均值。如果开启了这个指标的采集,还能看到它们的 records 数量是别人的几十倍。另一个信号是 Executor 的内存曲线,倾斜的 task 往往伴随持续 GC 或者直接 OOM,因为单个分区数据量超过了内存上限。
定位原因则是看 key 的分布。常见的有三类:空值或者默认值集中,比如大量记录的关联字段是 null,全部落到同一个分区;热点 key,比如某个大客户、某个默认分类占了总量的大头;分区器本身不合理,比如用了一个分布极不均匀的字段做分区键。这三类的解法完全不同,用错了不但没效果还会引入新问题。
4.1 三种典型表现与对应判断路径
第一种是空值倾斜。表现是所有关联字段为 null 的记录挤在一个 task 里,task 的输入记录数远高于其他。这种情况最简单,处理方式是在关联前把 null 转成一个随机后缀的字符串,让它们散开,关联不上就丢弃或者单独处理。这里有个前提要确认:这些 null 记录在业务上是否真的需要保留。如果不需要,直接过滤掉最省事,比任何加盐方案都干净。
第二种是热点 key 倾斜。表现是某几个具体 key 的记录数异常多。判断方法是先做一个groupBy(key).count()的采样统计,看 top 10 的 key 占比。如果前几个 key 就占了总量的一半以上,那就是热点问题。这种情况要看业务语义:如果热点 key 可以拆分(比如按时间或者按子维度再分一层),就拆分;如果不能拆,就走加盐扩容。
第三种是分区键选择不当。表现是整体分布就不均匀,不是某几个 key 特别大,而是一批 key 集中。这种情况往往在前期设计时就应该避免,如果已经发生了,最直接的办法是换一个分布更均匀的字段做分区键,或者用repartition按一个高基数字段重分布。代价是多一次全量 shuffle,要权衡。
| 倾斜类型 | 典型现象 | 首选方案 | 代价 |
|---|---|---|---|
| 空值集中 | null 记录全落一个 task | 过滤或加随机后缀 | 极低 |
| 热点 key | top key 占比超过 50% | 加盐扩容加二次聚合 | 中,多一轮 shuffle |
| 分区键不均 | 整体分布偏斜 | 换分区键或重分区 | 中高,多一次全量 shuffle |
| 大小表关联 | 大表分区后仍倾斜 | 广播小表 | 低,但受小表大小限制 |
4.2 两阶段聚合的完整实操
两阶段聚合是解决聚合类倾斜的标准手段,它的思路是把一次聚合拆成两次:第一次给 key 加随机前缀,让原本落到同一分区的数据散到多个分区做局部聚合;第二次去掉前缀,对局部结果做最终聚合。因为局部聚合已经把数据量大幅压缩,第二次聚合的输入小得多,即使仍然按原 key 分区,压力也可控。
具体实现分四步。第一步,给每条记录的 key 拼接一个 0 到 N-1 的随机数,N 的取值看倾斜程度,一般取 10 到 100,倾斜越严重取越大。第二步,按"前缀 + 原 key"做第一次聚合,得到局部结果。第三步,去掉前缀,只保留原 key 和局部聚合值。第四步,按原 key 做第二次聚合,把局部结果合并成最终结果。注意聚合函数必须支持"先局部再全局"的结合律,求和、计数、最大值都满足,平均值要拆成 sum 和 count 两个分量分别聚合再相除。
from pyspark.sql import functions as F # 假设 df 有 key 和 amount 两列,key 存在严重倾斜 N = 50 # 第一步:加随机前缀 salted = df.withColumn("salt", (F.rand() * N).cast("int")) \ .withColumn("salted_key", F.concat_ws("_", F.col("salt"), F.col("key"))) # 第二步:局部聚合 partial = salted.groupBy("salted_key", "key") \ .agg(F.sum("amount").alias("partial_sum"), F.count("amount").alias("partial_cnt")) # 第三步与第四步:去掉前缀,按原 key 做最终聚合 final = partial.groupBy("key") \ .agg(F.sum("partial_sum").alias("total_sum"), F.sum("partial_cnt").alias("total_cnt"))这段代码里有个细节容易被忽略:局部聚合时我同时保留了salted_key和key,因为最终聚合要用key分组。如果只保留加盐后的 key,第二次聚合就无法还原。另外随机数的生成用rand()每次执行结果不同,如果任务需要可复现,应该改用基于记录内容确定性的哈希,比如对 key 做一次 hash 后取模,这样同一条记录每次跑都在同一个盐值上。
注意:加盐扩容会让第一次聚合的 shuffle 数据量变成原来的 N 倍(因为同一份数据被复制到 N 个不同前缀下)。如果原始数据量已经很大,N 取值要谨慎,我一般从 10 开始试,观察第一次聚合的耗时和第二次聚合的倾斜程度再调整。
4.3 广播 join 与倾斜 join 的自动处理
如果倾斜发生在大小表关联上,优先考虑的不是加盐,而是广播。把小表广播到每个 Executor,大表的每个分区在本地就能完成关联,完全不产生 shuffle,也就不存在倾斜。判断能不能广播看两个条件:小表估算大小在spark.sql.autoBroadcastJoinThreshold以内(默认 10MB),以及广播后的总内存占用不超过 Executor 堆的合理比例。
我遇到过不少人在这一步卡住:小表在磁盘上只有 8MB,但广播后 OOM 了。原因是这个阈值衡量的是优化器估算的逻辑大小,而广播到内存后还要经过反序列化、对象展开,实际占用可能是估算值的数倍。如果小表是 Parquet 压缩存储,解压后的膨胀比可能达到五到十倍。所以我的经验是把阈值调到 30MB 到 50MB 时一定要配合实测,先在一个小规模数据集上验证内存峰值,再决定是否放开。
Spark 3.x 的 AQE 还提供了倾斜 join 的自动处理,相关参数是spark.sql.adaptive.skewJoin.enabled。它会在运行时检测超过阈值的大分区并自动拆分,判断条件是分区大小超过中位数的skewedPartitionFactor倍(默认 5)且绝对值超过skewedPartitionThresholdInBytes(默认 256MB)。这个能力在多数场景下能省掉手写加盐,但它只处理 join,对groupBy造成的倾斜无能为力,而且拆分的粒度是分区级别,对单个超大 key 的效果有限。所以我的做法是:join 倾斜优先交给 AQE,聚合倾斜还是手工处理。
5. Spark SQL 内核真题:Catalyst、CBO 与 AQE
到了这一章,面试的难度明显上了一个台阶。前面的题还能靠项目经验应付,这里如果没读过执行计划、没调过优化器参数,很难答出深度。卷四在这一章的题目通常从"你写的 SQL 在 Spark 里是怎么执行的"开始,一路追问到"为什么这个 join 没走广播"、"为什么加了 where 条件反而变慢"。
我建议所有准备这类题的人都养成一个习惯:写完一段复杂 SQL,先explain(true)看一眼。看什么?看三个阶段:Analyzed Logical Plan 有没有把字段解析对,Optimized Logical Plan 里谓词有没有下推、列有没有裁剪、join 顺序有没有调整,Physical Plan 里最终选了什么 join 策略、有没有出现 Exchange 节点。这三个阶段对应优化器的三个阶段,看懂它们,你就能回答绝大部分内核题。
5.1 Catalyst 优化器的四个阶段拆解
Catalyst 的工作分四步。第一步是解析,把 SQL 文本或者 DataFrame 的算子链变成未解析的逻辑计划,此时列名还是字符串。第二步是分析,借助 Catalog 里的表元数据把列名解析成带类型的属性,这一步失败就会报AnalysisException,常见的"列不存在"就发生在这里。第三步是逻辑优化,基于规则的方式做谓词下推、列裁剪、常量折叠、子查询消除、join 重排序,这一步是纯逻辑变换,不涉及具体执行方式。第四步是物理计划生成,把优化后的逻辑计划转成一个或多个物理计划,再用代价模型选出最优的那个。
这四步里,第三步和第四步最能体现功力。比如谓词下推,它的意义是把过滤尽可能推到数据源,减少读取量。但下推有边界:如果过滤条件里包含 UDF,优化器无法判断这个函数是否确定,就不敢下推;如果数据源是 Parquet,下推能到行组级别利用统计信息跳过,但如果字段被嵌套在复杂结构里,下推效果会打折。这些细节在面试里说出来,对方立刻知道你不是纸上谈兵。
第四步的代价模型(CBO)容易被误用。它依赖表的统计信息,如果没跑过ANALYZE TABLE ... COMPUTE STATISTICS,优化器只能凭默认假设估算,join 顺序和策略的选择可能完全错。我见过一个三表关联的查询,因为中间表没有统计信息,优化器把最大的表当成了最小的表,选了一个错误策略,执行时间差了一个数量级。补上统计信息后自动恢复正常,这个案例我一直拿来讲 CBO 的适用前提。
5.2 AQE 的三大能力与开启方式
AQE 是 Spark 3.x 最重要的运行时优化,它把一部分原本在编译期做的决策推迟到了运行时,用真实的 shuffle 统计信息来修正计划。它有三个核心能力:合并小分区、动态切换 join 策略、自动处理倾斜 join。
合并小分区的逻辑是:shuffle 写完成后,AQE 拿到每个分区的实际大小,把相邻的小分区合并成目标大小的分区(由spark.sql.adaptive.advisoryPartitionSizeInBytes控制,默认 64MB)。这个能力直接解决了"分区数设多了任务太碎"的问题,所以开启 AQE 后spark.sql.shuffle.partitions可以设得偏大一些,让它自动合并。
动态切换 join 策略的逻辑是:原本优化器估算某张表很小选了广播,但运行时发现实际大小超过阈值,AQE 会把广播 join 改成 sort-merge join,避免广播后 OOM。反过来,如果原本选择了 sort-merge join,运行时发现某侧数据量很小,也可以转成广播。
// AQE 相关的核心配置 sparkConf.set("spark.sql.adaptive.enabled", "true") // 合并后的目标分区大小,默认 64MB sparkConf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128m") // 是否合并小分区 sparkConf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") // 是否自动处理倾斜 join sparkConf.set("spark.sql.adaptive.skewJoin.enabled", "true") // 倾斜判断:分区大小超过中位数的倍数 sparkConf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5") // 倾斜判断:分区大小的绝对下限 sparkConf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256m")这里有个版本差异必须说清楚。AQE 在 Spark 3.0 到 3.1 时期默认是关闭的,需要显式开启;3.2 开始默认开启。所以如果你在面试里被问到"为什么我的任务在 3.1 上没有自动合并分区",答案就是版本默认值不同。这个细节很小,但能答出来的人不多,属于典型的加分项。
5.3 一个真实的反直觉案例:加过滤条件反而变慢
这道题是卷四里我最喜欢的一道,因为它完全违背直觉,能答出来说明你真的看过执行计划。
现象是这样的:一个查询在加上一个where dt = '某天'的分区过滤后,执行时间从 20 分钟涨到了 50 分钟。按常理,加了过滤应该读更少的数据,怎么会变慢?
看执行计划就明白了。加过滤前,优化器估算这个查询涉及的数据量较大,选择了一个针对大数据量的 join 策略,比如 sort-merge join,两侧都做 shuffle。加过滤后,优化器基于统计信息估算过滤后的数据量很小,切换成了广播 join。问题在于它估算的"很小"是基于过时的统计信息,实际过滤后数据量仍然很大,广播这一侧把 Executor 内存占满,触发了频繁 GC 和溢写,整体反而变慢。
这个案例的教训有三层。第一层,统计信息要及时更新,尤其是分区表按天写入的场景,新分区写完应该补一次统计。第二层,优化器的估算不是事实,遇到反直觉的性能变化,第一反应应该是看执行计划而不是改参数。第三层,广播阈值不要设得过高,我在生产上一般控制在 30MB 以内,就是为了防止这种错误广播。
解决方式也很直接:要么更新统计信息让估算贴近真实,要么临时把广播阈值调低强制走 sort-merge,要么用 hint 显式指定 join 策略。我倾向于用 hint,因为它只影响这一条查询,不会波及其他任务。/*+ SHUFFLE_HASH(t1) */或者/*+ MERGE(t2) */这类写法在 SQL 里直接用,语义明确,排查时也容易定位。
6. 内存管理与 GC 调优真题
内存题在卷四里属于"必考但难答好"的类型。难点不在于背不出内存模型,而在于面试官会顺着你的答案往下追:"统一内存管理里 Execution 和 Storage 怎么互相借用"、"Storage 被驱逐后什么时候再加载"、"堆外内存什么时候用"。这些问题没有标准答案,靠的是你真的配过、真的出过问题。
我先把模型说清楚。Spark 1.6 之后用的是统一内存管理,堆内内存按spark.memory.fraction(默认 0.6)切出一块给执行和存储共用,剩余 0.4 留给用户代码和 Spark 内部元数据。这 0.6 里面,Storage 的初始占比由spark.memory.storageFraction(默认 0.5)决定,但这个边界是可以互相侵占的:Execution 需要内存时,可以驱逐 Storage 里缓存的块;反过来 Storage 需要内存时,只能借用 Execution 的空闲部分。关键区别是,Execution 的内存不可被驱逐,因为那是任务运行必需的工作区,一旦被抢就会失败;Storage 的缓存被驱逐只是重新计算,不影响正确性。
6.1 内存参数的计算实例
很多人配内存是拍脑袋,我习惯按一个固定流程算。假设一台机器 64GB 内存,跑 4 个 Executor,每个分 12GB 堆内存,剩下留给系统和堆外。
第一步算可用堆内。(12GB - 300MB) × 0.6 ≈ 7GB,这是执行加存储的总额度。减去 300MB 是预留的保留内存,防止系统对象把堆占满。第二步算 Storage 的初始额度,7GB × 0.5 = 3.5GB,也就是说缓存最多占 3.5GB 起步,Execution 可以侵占它。第三步算单个 task 的可用执行内存,7GB ÷ 单 Executor 并发 task 数。如果这个 Executor 有 4 个核,并发 4 个 task,那每个 task 平均能分到 1.75GB 的执行内存,这个数字决定了你的聚合、排序、join 缓冲区能开多大。
这个计算过程在面试里非常加分,因为它把"配多少内存"从经验变成了推导。你可以进一步说明:如果数据集需要缓存的量超过 3.5GB,就要考虑提高spark.memory.storageFraction,但代价是 Execution 的可用空间被压缩,容易在聚合阶段溢写;如果任务以聚合和 join 为主、缓存需求小,就把 storageFraction 调低,给执行多留空间。
| 参数 | 默认值 | 调整方向与代价 |
|---|---|---|
spark.memory.fraction | 0.6 | 调大给执行存储更多,但用户代码空间变小 |
spark.memory.storageFraction | 0.5 | 调大缓存更多,执行易溢写 |
spark.memory.offHeap.enabled | false | 开启可减少 GC 压力,需显式设 size |
spark.executor.memoryOverhead | 堆的 10% | 堆外和原生内存不足时调大 |
注意:
spark.executor.memoryOverhead是容器内存的一部分,不是堆内存的一部分。很多人只调spark.executor.memory却忽略了这个,结果容器被外部管理程序杀掉,退出码 137,还以为是代码问题。这个参数在序列化、网络缓冲、Python 进程场景下尤其重要,PySpark 任务建议至少给到堆的 15%。
6.2 常见 OOM 与容器被杀的区别
这两种故障在现象上很像,但原因和解法完全不同,卷四里经常放在一起考。
第一种是 JVM 堆内存溢出,报错信息是java.lang.OutOfMemoryError: Java heap space,或者频繁 Full GC 后抛出GC overhead limit exceeded。原因是堆内对象太多,通常来自几个地方:单个分区数据量过大、缓存了过多数据、聚合的 HashMap 太大、广播变量太大。解法是减小分区数据量(增加分区数)、减少缓存、调大堆或者调整内存比例。判断依据是看 GC 日志,如果 Full GC 频繁且每次回收后堆占用仍然很高,就是真溢出;如果回收后能降下来但很快又满,说明是分配速率太高。
第二种是容器被杀,退出码通常是 137,日志里看不到 Java 的 OOM 堆栈,只会看到任务被外部终止。原因是进程总内存(堆加堆外加原生)超过了容器限制。常见触发点包括堆外内存使用过多、PySpark 的 Python 进程占用、文件映射、Netty 的网络缓冲。解法是调大spark.executor.memoryOverhead、减少堆外使用、降低并发。诊断方法是看容器的内存监控曲线,如果堆内存还没到上限容器就被杀了,那一定是堆外超了。
我遇到过一次很典型的误判:一个任务频繁报容器被杀,团队里有人一直在调spark.executor.memory,越调越大,被杀得越快。后来一看,真正的原因是开了堆外内存但spark.memory.offHeap.size设得过大,加上堆本身也调大了,两者相加超过了容器上限。把堆调小、堆外设合理值之后问题消失。这个教训是:容器内存是一个总量,堆和堆外是此消彼长的关系,不能各调各的。
6.3 GC 调优的实操顺序
GC 调优我一般按四步走,顺序不能乱,否则容易做无用功。
第一步是确认 GC 是不是真的瓶颈。看 GC 日志里 GC 时间占总运行时间的比例,如果低于 5%,那 GC 不是主要问题,优化它收益有限,应该去看 IO 或者 shuffle。如果超过 10%,才值得投入。这个判断很重要,我见过太多人一上来就换垃圾回收器、调堆大小,结果真正的瓶颈是数据倾斜。
第二步是选垃圾回收器。Spark 官方在 JDK 8 时代推荐 G1,堆大于 4GB 时收益明显,因为它把堆分成多个区域,回收停顿更可控。JDK 11 及以后 G1 已经是默认,一般不需要额外指定。如果还在用 Parallel GC,大堆场景下 Full GC 的停顿可能是几十秒,任务直接被拖垮。
第三步是调堆大小和新生代比例。堆不是越大越好,堆太大会让单次 Full GC 的停顿变长,而且缓存的回收效率下降。我一般把堆控制在 8GB 到 16GB 之间,超过这个范围就增加 Executor 数量而不是加大单个 Executor。新生代比例通过-XX:NewRatio调整,Spark 场景下对象分配速率高、大部分对象朝生夕死,适当加大新生代能减少对象晋升到老年代的速度。
第四步才是调数据结构层面的东西,比如减少缓存、改用序列化存储级别、避免在闭包里捕获大对象。这一步的收益往往比调 JVM 参数更大,但需要改代码,所以放在最后做。
# 一个常见的 Executor JVM 参数组合 --conf spark.executor.extraJavaOptions="\ -XX:+UseG1GC \ -XX:InitiatingHeapOccupancyPercent=35 \ -XX:ConcGCThreads=4 \ -XX:+PrintGCDetails \ -XX:+PrintGCTimeStamps \ -XX:GCHeapFreeLimit=10"这几个参数里,InitiatingHeapOccupancyPercent控制 G1 启动并发标记的时机,默认 45,调低到 35 能让它更早开始回收,适合分配速率高的场景,代价是更多的并发线程占用 CPU。ConcGCThreads要和核数匹配,通常取核数的四分之一左右。GC 日志相关的参数在生产上建议常开,出问题时不用重新跑任务就能拿到现场。
7. 常见问题与排查技巧实录
这一章是我把卷四里所有"坑"汇总的地方,也是我认为最有价值的部分。面试题再深,最终都要落到"出问题了你怎么查"上。我下面整理的这些问题都是真实遇到过的,排查路径也是我实际操作的顺序,不是理论推演。
先说一下我的通用排查顺序,这个顺序能覆盖八成问题。第一步看 Spark UI 的 stage 列表,找出耗时最长的那个 stage,看它的 task 时长分布是均匀还是两极分化。第二步看这个 stage 的 Shuffle Read 和 Shuffle Write 大小,判断是 IO 问题还是计算问题。第三步看 GC 时间和溢写情况,判断是内存问题还是 CPU 问题。第四步才去看代码和参数。这个顺序的好处是先用数据缩小范围,而不是一上来就猜。
7.1 高频问题速查表
| 现象 | 最可能的根因 | 优先排查动作 | 常用解法 |
|---|---|---|---|
| 任务卡在 99% 长时间不动 | 数据倾斜 | 看剩余 task 的输入记录数 | 加盐、广播、拆分热点 key |
| Executor 频繁重启 | 容器内存超限 | 看退出码是否为 137 | 调大 memoryOverhead,降堆或降并发 |
| GC 时间占比过高 | 老年代对象过多 | 看 GC 日志的回收后占用 | 减少缓存,换 G1,控制单分区大小 |
| Shuffle 阶段磁盘写巨大 | map 端未预聚合 | 对比 reduceByKey 与 groupByKey | 改用带预聚合的算子 |
| 小文件数量爆炸 | 分区数过多且写入无合并 | 统计输出目录文件数 | 合并分区,控制分区数 |
| 广播 join 后 OOM | 小表实际远大于估算 | 测量广播后实际内存占用 | 调低广播阈值,改用 hint |
| 加了过滤反而变慢 | 优化器估算错误切换策略 | explain 看物理计划变化 | 更新统计信息,用 join hint |
| 任务提交后长时间无 task 启动 | 资源不足或队列排队 | 看调度器日志和队列状态 | 调整资源申请,检查队列配额 |
这张表我建议读者对着自己跑过的任务过一遍,看看能对应上几条。能对上三条以上,说明你确实踩过坑;一条都对不上,可能你的任务规模还比较小,很多问题还没暴露出来。
7.2 几个不写在文档里的避坑经验
第一个经验:不要迷信参数。我见过太多人把网上找的一整套参数贴到配置里,结果任务反而更慢。参数是有上下文的,spark.sql.shuffle.partitions设成 2000 在别人 500GB 的任务上是合理的,在你 5GB 的任务上就是灾难。任何参数在应用之前,先想清楚它解决什么问题、代价是什么。
第二个经验:优先改数据而不是改配置。倾斜的根因是数据分布,配置只能缓解不能根治。我处理过一个任务,调了两天参数收益不到 20%,后来加了十分钟把热点 key 拆开,直接快了三倍。遇到性能问题,先问"数据能不能预处理"、"逻辑能不能改写",再去动配置。
第三个经验:保留现场。任务出问题时,不要急着重启。先把 Spark UI 的页面、GC 日志、Executor 的日志保存下来,这些是排查的唯一依据。我见过有人在任务失败后直接重跑,第二次没复现,问题就永远查不清了。生产环境建议常开 event log,事后可以回放。
第四个经验:小数据量验证。任何优化方案,先在一个小数据集上跑通,确认逻辑正确、耗时符合预期,再上全量。加盐这类改动很容易引入正确性问题,比如盐值没去掉导致结果翻倍,在大任务上跑一遍才发现,代价太大。
第五个经验:关注版本差异。Spark 3.0、3.1、3.2、3.3 之间在 AQE 默认值、shuffle 实现、优化器规则上都有变化。同样的配置在不同版本上行为可能不同,面试里被问到"这个参数在哪个版本引入"时,能答出来是很大的加分。我自己的做法是维护一份版本变更笔记,遇到行为差异就记一笔,时间长了就是一份很有价值的参考资料。
7.3 一道综合题的完整作答示范
最后用一道综合题收尾,这道题是卷四里难度最高的,通常会作为终面题出现:一个任务从 30 分钟突然变成 3 小时,没有任何代码变更,你怎么查?
我的答法是这样的。第一步确认变化的时间点,对比前后两次运行的输入数据量。如果数据量涨了十倍,那可能不是故障而是正常现象,先排除这个可能。第二步看执行计划有没有变化,如果表的统计信息过期或者分区元数据变化,优化器可能选了不同的 join 策略,这是"没有代码变更但行为变了"的常见原因。第三步看资源,集群是不是在高峰期,队列排队时间是不是变长了,Executor 的分配数量是不是变少了。第四步才看数据分布,是不是新进来的数据里有异常的热点值,导致倾斜。
每一步都对应具体的观察手段:数据量看输入表的文件大小和行数,执行计划用 explain 对比,资源看调度日志和 Spark UI 的 Executor 页面,数据分布用一个采样统计看 key 的集中度。这个排查链路的价值在于它是收敛的,每一步都能排除掉一批可能性,而不是漫无目的地试。
我自己遇到过最接近这个场景的一次,最后定位到是新分区的一批数据里某个字段全是默认值,导致关联时全部落到同一个分区。代码没变,参数没变,变的只是数据。这也印证了我一直强调的那句话:大数据问题,八成是数据问题,配置和代码只占两成。把排查的重心放在数据上,往往比盯着参数表有效得多。