上个月陪一位朋友做模拟面试Spark 那部分他答得飞快spark.sql.shuffle.partitions、spark.memory.fraction、Kryo 序列化这些参数一个不落可真到面试官顺着问“那你这个并行度为什么设成 220 而不是 200”他停了三秒然后开始重复刚才那句话。这三秒大概就是大数据开发岗面试复习里最典型的分水岭——30 天冲刺的前二十多天我们都在拼命往脑子里塞结论到了 Day26 这个阶段真正该练的是把结论倒着推回去的能力。这篇围绕某数据科技公司一道 Spark 面试题的复盘说的就是每日五题这种节奏怎么用、Spark 机制类问题怎么答出层次、调优题怎么避开“背参数”的坑适合已经刷过一轮题库、正在做二轮深化的大数据开发同学参考也适合刚开始准备、想少走弯路的朋友对照着建立自己的答题框架。1. 把每日五题当成小型模拟面试而不是刷题打卡1.1 为什么“五题”是刻意定下的上限很多人复习喜欢定“每天二十题”的目标前两天热血沸腾第四天开始只看题干不看解析第七天连题干都懒得点开。五题这个数字看着保守实际是经过验证的一道机制题完整讲清楚需要 15 到 20 分钟一道调优题要把因果链说圆需要 10 分钟左右再留出时间写代码片段、翻官方文档确认版本差异五题刚好填满一个半小时的高质量复习窗口。超过这个量人的注意力就开始滑向“我见过这道题”的错觉而不是“我能讲清楚这道题”。我自己的做法是把五题拆成固定配比两道机制题、两道调优题、一道场景设计题。机制题负责把底层链路打通调优题负责把参数和现象连起来场景题负责检验前四题是不是真的消化了。这个配比不是拍脑袋而是对应了面试里三类完全不同的提问方式——面试官问“Stage 怎么划分”考的是机制问“任务跑得慢怎么查”考的是调优问“给你一个日增 500G 的表怎么设计导入”考的是场景判断。1.2 每道题必须留下“推导痕迹”不能只留答案刷题和复习最大的区别在于留痕。我要求自己每道题答完以后在笔记里补三行这题的关键判据是什么、我第一遍答漏了什么、如果面试官追问一层我该怎么接。比如 Shuffle 这道题关键判据不是“宽依赖会 Shuffle”而是“父 RDD 的每个分区是否可能被多个子分区依赖”这一句判据能覆盖groupByKey、未预分区的join、repartition等一大票算子比死记算子清单牢靠得多。1.3 Day26 在 Spark13 专题里的位置从“会用”转向“能解释”按 30 天冲刺的排布前二十天基本把 Spark 的算子、RDD、DataFrame、SQL、流处理扫了一遍到第 26 天、第 13 个 Spark 专题重心已经从“这个 API 怎么调”彻底转到“这个行为背后的机制是什么”。这个阶段再看面试题会发现题目本身没变变的是答题深度要求。同一道“RDD 和 DataFrame 的区别”一轮复习时答“有没有 Schema、能不能走 Catalyst 优化器”就过关了二轮再这么答面试官一句“那 Dataset 的类型安全具体体现在哪、编码器解决了什么问题”就能把你问回原形。2. Stage 划分这道题答到算子链层面才算过关2.1 从一条窄依赖链开始推导比背定义有效十倍面试里问“Spark 怎么划分 Stage”标准答案谁都会背遇到宽依赖切一刀每个 Stage 内部是一串可以流水线执行的窄依赖算子。但真正拉开差距的是能不能当场推一遍。我通常这样讲假设有一条read → filter → map → groupByKey → map → save的链路filter和map对父分区的依赖是一对一的父分区 A 的数据只会进子分区 A不需要跨节点搬数据所以这三个算子可以串成一条流水线在同一个 Task 里连续执行中间结果不落盘。到groupByKey这里子分区的数据来自所有父分区必须等上游全部写完才能开始读这个等待点就是 Shuffle也是 Stage 的边界。再往下推一层groupByKey之前的所有算子构成一个 ShuffleMapStage之后到save构成 ResultStage。如果一个作业里有多次 Shuffle中间的 Stage 既是上游的 ResultStage 又是下游的 ShuffleMapStage这就是为什么 DAG 图里中间节点会同时挂着两种角色。这段话说完面试官基本能判断你是真跑过还是只背过。2.2 宽依赖的判据是“是否重分区”不是算子名字有个很容易踩的坑很多人把“宽依赖”和“某个具体算子”绑死结果被join问住。join到底宽不宽取决于父 RDD 有没有用相同的分区器预先分区。如果两个 RDD 都按 join key 做过partitionBy那join可以只做本地合并是窄依赖如果没有就得走 Shuffle。同样coalesce在shufflefalse时是窄依赖改成shuffletrue立刻变成宽依赖。所以判据只能落在“子分区的数据是否来自多个父分区”这一条上算子名只是表象。2.3 三层追问Stage 多了、Task 数怎么定、DAG 和物理计划的关系第一层追问通常是“我这个作业 Stage 数量比预期多可能是什么原因”。常见原因有三个代码里不小心触发了多次 Shuffle比如连续两次repartition用了distinct这类内部会做shuffle的算子还有就是join两侧没有统一分区器导致本可避免的 Shuffle。第二层追问是“Task 数量怎么来的”答案要落到“每个 Stage 的 Task 数等于该 Stage 最后一个 RDD 的分区数”ShuffleMapStage 的 Task 数由上游分区数决定ResultStage 的 Task 数由 Shuffle 后的分区数决定而 Shuffle 后的分区数在 Spark SQL 里默认由spark.sql.shuffle.partitions控制。第三层追问最狠“DAG 图和物理执行计划是一回事吗”。不是。DAG 是逻辑上的 Stage 依赖关系物理计划是 Catalyst 优化器把逻辑计划转成具体算子后、再经过prepareForExecution插入 Shuffle 和广播等物理操作的结果两者描述的是不同层面的东西。能把这层说清楚面试官通常会认为你对执行引擎有过实际阅读。3. 内存与序列化调优题里最容易被追问穿的一段3.1 统一内存管理下 Execution 和 Storage 的借用关系Spark 1.6 之后统一了内存模型堆内内存按spark.memory.fraction默认 0.6划出一块统一区域这块区域里 Execution Memory 和 Storage Memory 共享初始时 Storage 占spark.memory.storageFraction默认 0.5。关键点在于这个比例不是硬隔离Execution 需要内存时可以驱逐 Storage 里缓存的数据但反过来 Storage 不能驱逐正在被 Execution 使用的内存。这个不对称性是理解很多现象的关键——为什么缓存了一堆数据之后反而频繁 GC因为缓存被挤掉了重算又触发新的内存申请。面试里如果只答“默认 0.6、默认 0.5”是拿不到分的必须补上驱逐规则的因果Execution 优先因为它关系到当前 Task 能不能跑完而 Storage 的数据丢了可以重算代价是时间不是正确性。3.2 Kryo 上线的完整操作和踩过的坑序列化这块标准动作是三步// 1. 切换序列化器 spark.conf.set(spark.serializer, org.apache.spark.serializer.KryoSerializer) // 2. 注册自定义类避免每个对象写全类名 spark.conf.set(spark.kryo.registrationRequired, true) spark.conf.set(spark.kryo.classesToRegister, com.example.Order,com.example.UserTag) // 3. 缓冲区上限类结构复杂时默认 64m 可能不够 spark.conf.set(spark.kryoserializer.buffer.max, 256m)坑主要在两个地方。一是开了registrationRequiredtrue之后凡是没注册的类直接抛异常如果项目里用了大量第三方类注册成本很高很多团队索性不开这个开关接受“类名写全”带来的额外体积。二是 Kryo 对 Scala 的某些集合类型序列化结果依赖版本跨版本升级时缓存数据可能读不出来所以用 Kryo 持久化到外部存储的场景要格外小心最好是序列化成明确的结构再落盘。3.3 OOM 的五步定位顺序先排除倾斜再动内存参数遇到 Executor OOM我自己的排查顺序是固定的因为调参数的收益往往排在最后。步骤检查项判断依据1是否存在数据倾斜Stage 里个别 Task 耗时是其他的十倍以上2单个分区是否过大输入文件是否不可切分、repartition是否用错3是否缓存了超大对象Storage 占用是否接近上限、GC 日志是否频繁 Full GC4是否 collect 或广播了大数据Driver 内存是否异常上涨5内存比例是否合理最后才考虑调memory.fraction和 executor 内存把倾斜排在第一是因为倾斜导致的 OOM 就算你把 executor 内存翻倍也只是把爆炸点往后推一推。我见过一个作业从 8G 调到 32G 照样挂最后发现是某个 key 占了整个分区 60% 的数据量两阶段聚合改完内存回落到 8G 反而更稳。4. Shuffle 和数据倾斜面试官要的是排查链路不是结论4.1 SortShuffleManager 三种 Writer 的选择逻辑Spark 2.0 之后默认spark.shuffle.managersort内部有三种 Writer选择逻辑值得说清楚。BypassMergeSortShuffleWriter适用于分区数小于spark.shuffle.sort.bypassMergeThreshold默认 200且 map 端没有聚合的场景它直接把每个分区的数据写成独立文件再合并省掉排序开销。UnsafeShuffleWriter要求序列化器支持重定位、分区数不超过 1677 万左右、且没有 map 端聚合它在序列化的二进制上直接操作省掉反序列化成本。剩下的情况走SortShuffleWriter做分区内排序后写一个数据文件加一个索引文件。面试里能说出“三种 Writer 分别对应什么条件”比只说“SortShuffleManager 会合并小文件”有说服力得多因为这直接关系到 Shuffle 性能瓶颈在哪。4.2 倾斜的三种外在表现和对应的日志特征倾斜在界面上通常有三种样子。第一种是某个 Stage 里绝大部分 Task 秒完剩一两个 Task 拖了几个小时这是最典型的热 key。第二种是 Task 耗时分布还算均匀但整体 Shuffle Read 数据量远超预期多半是分区数设置过小每个分区都很大。第三种是 Executor 频繁重启加 GC 时间飙升这是分区过大导致单 Task 内存不够的连锁反应。日志上的特征也不一样。热 key 通常伴随FetchFailedException重试和个别 Task 的spill记录暴涨分区过小则表现为 Shuffle Read 阶段每个 Task 读入几百兆甚至上 G内存问题看 GC 日志里 Full GC 的间隔和耗时。这三种要分开对待用错方法就白折腾。4.3 两阶段聚合、广播 Join、拆热 key 的适用边界两阶段聚合的写法核心是给 key 加随机前缀局部聚合完再摘掉前缀做全局聚合val salt 16 val salted rdd.map { case (k, v) (s${scala.util.Random.nextInt(salt)}_$k, v) }.reduceByKey(_ _) .map { case (k, v) (k.split(_, 2)(1), v) } .reduceByKey(_ _)它的代价是 Shuffle 数据量翻 salt 倍只适合聚合类操作join场景用不了。广播 Join 适合大表 join 小表阈值由spark.sql.autoBroadcastJoinThreshold控制默认 10MB这个值不是越大越好广播数据会复制到每个 Executor超过几百兆反而拖垮内存。拆热 key 适合热 key 数量少且可枚举的情况把热 key 单独抽出来处理再 union 回去工程上比较重除非确实没有别的办法。5. 场景设计题从一道存量加增量合并看工程判断5.1 口径和幂等要排在性能前面Day26 这类题目里经常会有一道开放场景题比如“每天要把增量数据和存量宽表合并怎么设计”。很多人上来就讲分区、讲并行度、讲压缩格式其实面试官更想听的是口径。增量和存量按什么键合并、同一条记录在两边都存在时以谁为准、删除操作怎么表达、重复跑同一天的数据会不会导致结果翻倍——这四个问题回答不清楚再漂亮的性能方案都是空中楼阁。我一般会在答题开头明确一句这个作业必须是幂等的同一个批次重跑多次结果一致。实现方式通常是按日期分区覆盖写配合insert overwrite的动态分区模式而不是追加写。5.2 资源参数从数据量反推而不是套经验值被问到参数怎么定最好的答法是给出推算过程。举个例子日增量 200G Parquet压缩比按 3 倍估算原始数据约 600G按每个 Task 处理 128MB 到 256MB 的经验区间需要大约 2400 到 4800 个 Task。如果每个 Executor 配 4 核集群有 50 个 Executor那并发是 200 个 Task需要跑 12 到 24 轮。这个推算不一定精确但能展示你是从数据量出发而不是从“别人说设 200”出发。spark.sql.shuffle.partitions的设定同理它应该跟 Shuffle 后的数据量挂钩而不是死守默认 200。数据量小的时候 200 个分区每个只有几兆调度开销占比过高数据量大的时候 200 个分区每个几百兆直接 OOM。5.3 “数据量再涨十倍”这类追问怎么接这是场景题的标准收尾追问考的是你有没有跳出当前方案的思维定势。可以从三个方向接其一架构层面从单作业全量合并改成按主题域拆分减少单次处理的数据宽度其二存储层面考虑分区裁剪和列式存储的进一步优化比如按业务日期加主键做二级分区其三计算层面把能增量算的指标改成增量更新避免每天全量重算。三个方向按成本从低到高排列回答时说明优先级比一口气全倒出来更有条理。6. 复盘环节Day26 这类题目最容易丢分的三个地方6.1 只报参数不报推导这是最普遍的问题。spark.sql.shuffle.partitions200是默认值不是最优值面试官问“你为什么用 200”的时候如果你答“默认就是 200”基本就暴露了没做过针对性调优。正确的答法是给出这个值的确定依据Shuffle 数据总量除以单分区目标大小再考虑 executor 的核数和内存。哪怕你实际项目里就是用默认值也要说清楚“当时数据量下这个默认值刚好合适因为算下来每个分区大约 100MB”。6.2 版本差异必须心里有数Spark 2.x 和 3.x 在面试里经常被混着问。AQE 是 3.0 引入、3.2 开始默认开启的它的三个能力——合并小分区、转换 SortMergeJoin 为 BroadcastJoin、处理倾斜 Join——在 2.x 里都没有。如果你在简历上写的是 2.4面试官问 AQE 你怎么处理倾斜你要能说清楚“我们那个版本没有 AQE用的是手动加盐加广播的方案”而不是硬答 AQE 参数。动态分区裁剪也是 3.0 之后才成熟的特性这些版本边界说清楚反而显得你踏实。6.3 反问环节其实是加分项面试尾声的反问很多人浪费掉了。问“团队用什么版本”比问“加不加班”有价值得多因为版本直接决定了你进去之后的技术方案选型空间。我通常会问两个当前集群的 Spark 版本和是否已经开启 AQE数据规模大概在什么量级、最大的单表有多少分区。这两个问题既显得你关心实际工作也能帮自己判断这个岗位的技术栈是不是你想去的方向。最后分享一个我自己用了很久的小习惯每答完一道题用手机录一段 60 秒的语音复述回头听一遍。你会发现很多自以为讲清楚的地方其实中间有断裂有“然后然后”的卡顿有突然跳到结论的跳跃。这个动作比再看十道题管用因为它检验的不是你知不知道而是你能不能在那个紧张的环境里把它组织出来。Day26 这个阶段练的就是这个。