首页 / 资讯中心 / 文章详情

Spark Core面试核心链路:RDD、DAG、Shuffle与调优

Spark Core面试核心链路:RDD、DAG、Shuffle与调优 ★ FEATURED ARTICLE
“Spark Core 面试知识点”这几个字我在准备面试和当面试官的两边都刷过很多遍。绝大多数人的准备方式是把“RDD 五大特性”“宽窄依赖”“DAG 切 Stage”背成顺口溜面试官只要往深里追一层“为什么”回答立刻就开始打转——嘴上说着“窄依赖不需要 Shuffle”被问到“那 union 为什么是窄依赖”就卡住了。我自己被面过几轮也坐在桌子对面问过别人最深的体会是Spark Core 的知识点总量其实不大但彼此联系得很紧真正拉分的地方不是记住结论而是能不能把一条链路从头到尾讲清楚——数据从哪来、怎么分片、什么时候落到磁盘上、某个 Executor 挂掉之后谁负责重算。这篇内容我想按“链路”而不是按“目录”来组织把 RDD、依赖、Stage、任务调度、持久化、Shuffle、内存与倾斜这几块串起来讲。适合正在准备大数据方向面试的同学也适合已经用 Spark 写过业务、但一直停留在调 API 层面、想搞清楚底层发生了什么的人。文中涉及具体参数的地方我都会给出默认值和取值逻辑涉及取舍的地方我会说明代价在哪能直接拿去对照自己手上的任务。1. RDD 的弹性、血缘、分区三个词对应三种问题1.1 先把“分布式不可变集合”这句话拆成五条面试里最常听到的开场白是“RDD 是一个分布式的不可变数据集”。这句话没错但它把五个互相独立的设计决策压缩成了一个词被追问时很容易散架。我更习惯把它拆成下面五条来讲每一条都对应一个具体的实现位置一组分区Partition数据被切成若干块每块就是一个分区分区是 Spark 并行计算的最小数据单位。分区集合决定了这份数据在集群上被分成几份。一个作用在每个分区上的计算函数computecompute(split, context)这个方法负责把“父分区”算成“当前分区”所有 Transformation 最终都落到这个方法的不同实现上。一组依赖关系dependencies当前 RDD 依赖哪些父 RDD以及是分区一对一还是需要跨分区拉数据这个信息决定了能不能流水线执行。一个可选的分区器Partitioner只有Key-Value类型的 RDD 才有常见的是HashPartitioner和RangePartitioner。它记录的是“某个 key 应该落在哪个分区”这是避免重复 Shuffle 的关键。一组优先位置preferredLocations每个分区优先在哪台机器上算这就是数据本地性的来源。读 HDFS 时它来自数据块所在节点读 Kafka 时来自分区副本所在 broker。我带人时通常让人把这五条对应的源码位置找一遍找完基本就不会再把“分区器”和“分区数”混为一谈了。分区数是数量分区器是规则一个 RDD 可以是partitionBy(new HashPartitioner(200))之后既有了规则又有了数量但数量本身并不携带规则信息——repartition(200)之后的分区器是NonepartitionBy之后才是确定的。这个区别在面试里被问到“repartition 和 partitionBy 有什么区别”时是必答点。1.2 血缘是容错手段不是省空间的手段很多人把血缘理解成“Spark 不存副本所以省磁盘”这个因果是反的。真实的设计动机是分布式系统里做数据副本的成本高而很多计算本身是可重放的与其为每一份中间结果存多副本不如记住“这份结果是怎么从源头算出来的”出错时按血缘重算。代价是恢复时间长所以 Spark 才另外提供了checkpoint来主动截断血缘。从血缘能直接推出两个结论面试里非常值得主动讲第一个结论是窄依赖的恢复代价小。窄依赖下每个子分区只依赖一个父分区某个分区丢了只需要重算它对应的那一个父分区其他分区不受影响。map、filter、flatMap、union、mapPartitions都属于这一类如果上游还带着确定的分区器join在分区器一致时也能走窄依赖。第二个结论是宽依赖的恢复代价可能是整条上游链。宽依赖ShuffleDependency下每个子分区依赖所有父分区丢了一个下游分区就要把上游所有分区的输出重新拉一遍运气不好还要把上游的 Shuffle 写也重做一次。这就是为什么在宽依赖之后做cache收益往往比在开头做更大——缓存的位置选错了等于给一条随时会被截断的链子加了一个随时会失效的锚点。一个容易被忽略的点血缘重算依赖的是“父 RDD 还能被重新计算出来”。如果上游数据源在这期间发生了变化比如读的是不断追加的表、或者读的是流式数据重算出来的结果和原来并不一致。做精确一次语义的场景里这个前提要先确认清楚。1.3 分区数决定 Task 数也决定并行度的上限这一条是面试里最容易被问出破绽的地方。分区数和并行度的关系可以这么表述在一个 Stage 内部Task 数量等于该 Stage 最后一个 RDD 的分区数而并行度受限于集群可用的 CPU core 总数所以实际并行执行的 Task 数不会超过总 core 数多出来的 Task 排队。由此可以推出一组实用判断情况现象常见原因分区数远小于 core 数大部分 core 空闲Stage 耗时接近单机读小文件、repartition(1)误用分区数远大于 core 数Task 调度开销明显UI 上每个 Task 只有几十毫秒分区过碎spark.default.parallelism设得过大分区数适中但分布不均少数 Task 跑很久其余迅速完成数据倾斜不是分区数问题我一般的经验值是让分区数落在“总 core 数的 2 到 3 倍”这个区间既能让调度有回旋余地也不至于让 Task 启动开销占掉大头。另外要注意读文件时分区数的来源和 RDD 转换时不一样读 HDFS 时一个 block 对应一个分区如果文件本身有多个 blocktextFile可以传第二个参数指定最小分区数读 Parquet 时按文件切分小文件多的时候分区数会异常膨胀这种场景先做合并比直接调大并行度有用得多。spark.default.parallelism这个参数也要说清楚来源如果不是显式设置它在 Standalone 模式下默认取“所有 Executor 的 core 总数”和 2 中的较大值在 YARN 模式下默认取max(2, 每个 Executor 的 core 数)。所以它有值不代表它是合理的值很多“并行度不够”的问题其实是这个默认值在起作用。2. 依赖类型的判定标准与 Stage 切分的完整推导2.1 判定标准只有一条父分区是否被多个子分区共用宽窄依赖的判定标准其实一句话就能说完如果每个子分区只依赖一个父分区就是窄依赖如果存在一个子分区需要读取多个父分区的数据就是宽依赖。注意这里的主语是“子分区”而不是“父分区”。很多人的混淆点在于一个父分区被多个子分区读取仍然是窄依赖。拿union举例子最清楚rdd1.union(rdd2)产生的新 RDD第 i 个分区只由rdd1的第 i 个分区或者rdd2的第 i 个分区构成取决于索引落在哪一段全程没有父分区被拆散重组所以是窄依赖具体实现是RangeDependency。相反groupByKey、reduceByKey、join分区器不一致时、distinct、repartition都要跨分区搬运数据是宽依赖。窄依赖在实现上还分几个子类面试里偶尔会问到OneToOneDependency分区严格一对一map、filter走这个。RangeDependency区间映射union走这个。PruningDependency带裁剪的映射部分mapPartitions场景会用到。有个反直觉的点值得单独强调coalesce默认是窄依赖。它减少分区时只是把多个父分区的数据合并到一个子分区里读不需要跨节点搬运所以是窄依赖但如果shuffletrue它就变成了宽依赖。同理repartition等价于coalesce(numPartitions, shuffletrue)一定是宽依赖。这个细节被问到“coalesce 和 repartition 的区别”时说出来是很加分的。2.2 DAGScheduler 从后往前推遇到 Shuffle 就切Stage 划分的过程我用一句话概括从触发 Action 的那个 RDD 开始往前倒推一旦发现当前 RDD 和父 RDD 之间是宽依赖就在边界处断开把断开之后的部分划成一个 Stage。整个过程是反向遍历因为只有 Action 才能触发一次完整的计算链路推导。切开之后整个 DAG 由两类 Stage 组成ResultStage整个 Job 只有一个对应最后一个 Stage它的输出直接交给了 Action 里定义的计算比如count、collect、foreachPartition。ShuffleMapStage中间的那些 Stage每个负责产出一份 Shuffle 数据供下游的 Stage 去拉。一个ShuffleMapStage可能被多个下游 Stage 复用这正是 Shuffle 结果能被重复消费的原因。有一个推论很多人答不上来为什么 Stage 数量等于宽依赖数量加一。因为每遇到一个宽依赖就切一刀N 个宽依赖切出 N1 段最后一段是ResultStage前面 N 段是ShuffleMapStage。Stage 里还有一个隐含概念叫Pipeline指的是同一个 Stage 内所有窄依赖的算子会被串在一起在一个 Task 里依次执行中间不落盘。这也是 Spark 相比 MapReduce 在迭代计算上快的地方——MapReduce 每个 Map 输出都要落盘再由 Reduce 拉Spark 在 Stage 内是纯内存流水线。面试如果被问到“Spark 比 MapReduce 快在哪”只说“内存计算”是标准答案但很薄加上“Stage 内的 Pipeline 减少了中间落盘次数”才是完整的。2.3 Task 数量与 Stage 的对应关系Stage 切完任务就生成了。每个 Stage 会生成一批 Task数量等于该 Stage 最后一个 RDD 的分区数。注意这里有个容易说错的地方ShuffleMapStage的 Task 数量取的是该 Stage 输出 RDD也就是下游要去读的那份 Shuffle 数据的分区数而不是上游输入的分区数。如果上游写完reduceByKey(200)那么下游ShuffleMapStage的 Task 数就是 200而不是读取源文件时的分区数。Task 分两种类型和 Stage 是对应的ShuffleMapTask负责写 Shuffle 数据ResultTask负责最终结果计算。一个 Job 里ResultTask的数量就等于ResultStage的分区数——collect到 Driver 的结果总量也因此受spark.driver.maxResultSize约束默认 1G超了会直接报错。我在排查过一次collect报错的案例中问题就是在ResultStage分区数过大、单次收集的数据量超出阈值改成先写 HDFS 再分批读取解决的。3. 从 spark-submit 到 Task 执行中间经过了哪些角色3.1 Driver、Master、Worker、Executor 的职责边界这四个词经常被混着用尤其在 Standalone 模式下。我用一句话区分Driver 是逻辑指挥官Master 是资源登记处Worker 是节点上的代理人Executor 是真正干活的人。Driver跑main函数的那一端内部包含SparkContext、DAGScheduler、TaskScheduler、SchedulerBackend。它负责把代码转成 DAG、切 Stage、生成 Task、决定 Task 派发给谁。Driver 一旦挂掉整个 Application 就结束了所以它的内存参数spark.driver.memory要留够。MasterStandalone 模式下才有负责接收 Worker 的注册、管理资源、按需给 Application 分配 Executor。换成 YARN 之后这个角色由 ResourceManager 承担。Worker也是 Standalone 模式特有的跑在每个节点上的守护进程负责按 Master 的指令启动或杀掉 Executor 进程。Executor每个 Application 独立的一套进程内部维护一个线程池每个 Task 在一个线程里跑同时也承担 BlockManager 的角色负责缓存和 Shuffle 数据的读写。这里最关键的一条边界是Executor 是为单个 Application 独占的Application 之间互相看不到对方的缓存数据。所以同一个数据集被三个任务反复用时三次的缓存是三份这个在资源规划时经常被忽略。换成 Spark 3.x 的spark.sql.warehouse或者外部缓存层才有可能跨任务复用。3.2 提交链路逐段拆开看把一条链路完整说出来是面试里体现熟悉度的有效方式。我按顺序列一遍spark-submit启动 Driver 进程执行main里的代码SparkContext初始化向 Master或 RM注册 Application。Master 按资源要求在各 Worker 上分配 Executor。Worker 拉取 Executor 的启动命令起进程CoarseGrainedExecutorBackend向 Driver 的SchedulerBackend注册。代码里遇到 ActionSparkContext.runJob提交 Job 给DAGScheduler。DAGScheduler切 Stage为每个 Stage 生成 TaskSet交给TaskScheduler。TaskSchedulerImpl把 TaskSet 放进任务池按调度模式FIFO 或 FAIR出队结合数据本地性挑选合适的 Executor。SchedulerBackend通过 RPC 把序列化后的 Task 发给目标 Executor。Executor 收到后反序列化用TaskRunner包装成Runnable丢进线程池执行执行过程中按需读 Shuffle 数据、写缓存。执行结束结果分两路回传小于阈值的结果直接 RPC 回传超过阈值由spark.rpc.message.maxSize和结果大小共同决定的通过 BlockManager 写回 Driver 再读避免单条 RPC 消息过大。所有 Task 完成Stage 标记完成推进到下一个 Stage最后一个 Stage 完成后 Job 结束SparkContext在 Application 结束时关闭。这条链路里被追问最多的两处是Task 是怎么被序列化的Task对象本身要能序列化闭包捕获的外部变量也必须可序列化这就是Task not serializable报错的来源以及结果回传的两条路径。前者我可以给一个非常实用的判断方法看到Task not serializable先检查闭包里有没有捕获 Driver 端的非序列化对象比如某个SparkContext之外的连接池、某个外部服务的客户端实例。正确做法是把这些对象放到mapPartitions内部按分区创建或者用broadcast分发只读配置用完关闭。3.3 调度模式与本地性Task 放到哪台机器不是随机的TaskSchedulerImpl支持两种调度模式通过spark.scheduler.mode配置模式行为适用场景FIFO默认先提交的 TaskSet 优先拿资源前一个占满所有 core 时后面的排队单用户、批处理任务FAIR多个 TaskSet 按权重轮转分资源避免长任务饿死短任务多用户共享、交互式查询FAIR 模式需要额外写一个fairscheduler.xml定义池和权重配置了spark.scheduler.modeFAIR但不写池文件时会落到 default 池。很多团队为了“提高并发”无脑改成 FAIR结果发现长任务被切得更碎、总耗时反而上升原因就是 FAIR 只是让短任务别等太久不提升总吞吐。本地性级别从高到低是PROCESS_LOCAL数据就在同一个 Executor 的缓存里、NODE_LOCAL同一节点、NO_PREF、RACK_LOCAL同机架、ANY。TaskScheduler会优先按最高级别去匹配如果等不到在spark.locality.wait默认 3s超时后逐级下降。这个参数调大能提升本地性、降低网络传输代价是 Task 等待时间变长数据量大且网络带宽紧张时值得调小任务调大只会让 Task 排队时间变长。我在一个 Shuffle 读特别重的场景里把它从 3s 调到 8sStage 总耗时下降了大约 15%但同时启动延迟变长了所以这是个需要按场景试的参数。4. cache、persist、checkpoint 三个近似 API 的分工4.1 存储级别的取舍逻辑cache()和persist()的关系经常被答成“cache 是 persist 的简写”这话在 2.x 之后要加个限定cache()等价于persist(MEMORY_AND_DISK)而 1.x 时代等价于persist(MEMORY_ONLY)这个变化很多人没更新。存储级别的选择可以从两个维度切是否序列化、内存放不下时是否落盘。我把常见级别列成表级别内存磁盘序列化适用判断MEMORY_ONLY是否否数据量小、反序列化开销敏感、能承受重算MEMORY_AND_DISK是是否通用默认放不下时溢写最稳MEMORY_ONLY_SER是否是内存紧张但 CPU 宽裕序列化后体积能小 2~5 倍MEMORY_AND_DISK_SER是是是大数据量场景的性价比选择DISK_ONLY否是是内存实在不够接受 IO 慢OFF_HEAP堆外否是需要绕开 JVM GC要开spark.memory.offHeap.enabled带_2后缀按前面按前面按前面多一份副本可靠性换空间选择逻辑我一般这么讲给新人先看数据量能不能塞进内存能塞就MEMORY_ONLY塞不下又不想重算就MEMORY_AND_DISK或带 SER 的版本GC 压力大且频繁 Full GC才考虑堆外。序列化的代价要提前知道MEMORY_ONLY_SER存的是字节每次读都要反序列化CPU 会成为新瓶颈我在一个反序列化占比很高的任务里把 SER 换成非 SER 版本CPU 时间下降了近三成但内存占用涨了一倍最后还是换回了 SER 并调大了内存。MEMORY_AND_DISK有个容易误解的地方它不是“内存和磁盘各存一份”而是内存优先放不下的分区才写到磁盘读取时先找内存再找磁盘。所以它表现为“数据不会丢”但性能会有明显的长尾——最后那几个被溢写到磁盘的分区会拖慢整个 Stage。看到 UI 上某个 Stage 的 Task 有明显长尾且伴随大量 spill基本可以判断是缓存放不下。4.2 checkpoint 真正做的事情是切断血缘persist只是把数据放在内存/磁盘里血缘关系仍然完整保留所以一旦缓存丢失Spark 依然可以往上重算。checkpoint则是把数据写到一个可靠存储通常是 HDFS写完之后把 RDD 的依赖关系清空让它直接指向一个CheckpointRDD此后的重算就从这份 checkpoint 开始不再往上追。这个区别带来一个非常实操的结论checkpoint 之前必须 persist否则会多算一次。原因是 checkpoint 本身是一个独立的 Job触发时会先把 RDD 完整算一遍如果没缓存计算完成后数据就没了后续真正使用这份 RDD 时又要从头算一遍等于全程算了两遍。正确写法是先persist再checkpoint等 checkpoint 写完后可以unpersist掉内存副本因为血缘已经切断不再需要保留。还有一种轻量的localCheckpoint2.3 之后提供数据写到 Executor 的本地磁盘不走可靠存储速度快得多但可靠性依赖 Executor 存活Executor 挂掉之后这份数据就没了需要从上游重新算。它适合中间结果临时落盘、且能接受重算的场景不适合作为最终结果的持久化手段。4.3 三个被反复踩的误用我在代码评审里最常抓到的三类问题第一类是在 Transformation 链的开头就 cache。开头缓存的是原始数据看起来能省掉重复读但如果原始数据只被消费一次这个缓存纯属浪费内存真正该缓存的是被多个 Action 或多次下游引用、且计算代价高的中间结果。第二类是cache 之后从不 unpersist。缓存占用的是 storage 内存长期不释放会挤压 execution 内存导致 Shuffle 阶段频繁 spill表现就是“明明加了很多内存任务反而更慢了”。用Storage页签看一眼就能确认缓存块长期驻留且没被引用就是该清的时候。第三类是用 checkpoint 替代 cache。checkpoint 的写入是同步的、落的是分布式存储比内存缓存慢一个数量级。为了“保险”把所有中间结果都 checkpoint结果是 IO 成为瓶颈任务耗时翻倍。checkpoint 的合理场景只有两类迭代计算链太长、血缘恢复成本过高或者血缘里包含不确定的数据源无法可靠重算。5. Shuffle 的落地实现与 bypass 开关的边界5.1 HashShuffle 被淘汰的原因算一笔文件数Shuffle 的本质是按 key 重新分发数据写端要按下游分区号把数据分桶读端再按分区号去拉。HashShuffleManager就是最直白的实现每个 map task 为每个下游 reduce task 各开一个缓冲区写成一个独立文件。假设有 M 个 map task、R 个 reduce task那么一次 Shuffle 产生的文件数就是 M × R。这个数量在实际集群里非常吓人1000 个 map task 对 1000 个 reduce task就是 100 万个文件每个文件都要维持文件句柄、打开关闭、刷盘光是文件系统的元数据操作就能把节点拖垮。这也是早期 Spark 在大规模 Shuffle 场景下经常出现Too many open files的根本原因。后来有个过渡方案叫 consolidatespark.shuffle.consolidateFilestrue思路是复用缓冲区同一个 core 上先后执行的多个 map task 共用一组输出文件文件数从 M × R 降到 “core 数 × R”。这已经是一个数量级的改善但本质上还是每个 reduce 一个文件文件数依然随 R 线性增长而且依赖 core 数这个不稳定的量。5.2 SortShuffle 把文件数压到 2MSortShuffleManager从 1.2 开始引入1.6 之后成为默认。它的核心变化是每个 map task 的输出最终只落成两个文件——一个数据文件和一个索引文件。写的过程是先按分区号把数据写到内存缓冲区缓冲区满了溢写到磁盘溢写文件内部按分区号有序、分区内按 key 有序最后把所有溢写文件和内存里剩下的数据做一次归并合并成一个大文件同时生成一个索引文件记录每个分区在这份大文件里的偏移量。读端不需要知道文件怎么分布只要拿到索引文件按偏移量顺序读自己那一段就行。文件数从 M × R 变成 2M这是一个质变1000 个 map task 只有 2000 个文件和 reduce 数量完全解耦。代价是多了一次排序但排序是顺序 IO比维护百万文件句柄便宜太多。这也是为什么“Shuffle 一定要排序吗”这个问题的答案是不一定需要但排序换来的文件数收敛非常划算。5.3 bypass 模式不是万能加速SortShuffle 里还有一个 bypass 分支触发条件是下面几个同时满足Shuffle 的分区数小于spark.shuffle.sort.bypassMergeThreshold默认 200不需要在 map 端做聚合mapSideCombinefalse比如直接repartition、或者groupByKey这类不做 map 端合并的操作。满足后map task 不再走“排序 归并”而是直接为每个分区开一个临时文件写完最后合并成一个数据文件加一个索引。省掉了排序写路径更短在小分区数场景下确实能快一截。但这里有两个坑值得单独说。一是阈值调大不等于性能变好把阈值从 200 调到 2000分区数 1500 的 Shuffle 全部走 bypass看起来省了排序实际上每个 map task 要同时开 1500 个临时文件句柄和缓冲区压力陡增反而可能触发文件数问题。二是bypass 和 map 端聚合是互斥的如果你的逻辑里本来有reduceByKey这种 map 端预聚合走 bypass 会退化成不做预聚合网络传输量直接上涨。所以调这个参数之前先确认自己的操作是不是带聚合语义。我在一个分区数 180 的 Shuffle 场景下试过不调参数走普通 SortShuffle和调大阈值走 bypass前者耗时反而更短因为分区数不大时排序开销本身很低bypass 带来的临时文件创建开销占了上风。经验是分区数在几十以内可以考虑 bypass上百之后收益不明显别把它当通用开关。6. 内存模型与数据倾斜两类高频故障的排查路径6.1 execution 和 storage 互相借用的规则Spark 1.6 之后统一了内存管理堆内内存先按spark.memory.fraction默认 0.6切出一块“统一内存区”剩下的 0.4 留给用户代码里的普通对象和其他开销。统一内存区再按spark.memory.storageFraction默认 0.5划分成 storage 和 execution 两块但这两块的边界是软边界可以互相借用。借用规则是不对称的这点经常被问execution 可以抢占 storage当 Shuffle 需要内存而 storage 有空闲时execution 会把 storage 里缓存的部分 block 挤出去被挤掉的 block 如果存储级别允许落盘就写到磁盘如果不允许比如MEMORY_ONLY就直接丢弃下次用到时重算。storage 不能抢占 executionstorage 想要更多内存只能等 execution 主动释放。原因是 execution 内存里放的是 Shuffle 聚合的中间数据一旦被挤掉只能溢写磁盘性能损失比缓存丢失大得多。从这个不对称规则能推出一个很实用的调优方向如果任务里缓存的数据很重要、不能丢就不能只依赖MEMORY_ONLY让 Spark 自己挤而要调高spark.memory.storageFraction或者干脆把缓存级别改成带磁盘的版本。反过来如果 Shuffle 阶段频繁 spill说明 execution 内存不够可以调低 storageFraction 或者减少缓存量。堆外内存是另一条路通过spark.memory.offHeap.enabledtrue和spark.memory.offHeap.size开启默认是关闭的。它的价值在于绕开 JVM 的对象头和 GC 扫描序列化后的数据放在堆外Full GC 的频率会明显下降。代价是要自己管大小且堆外内存溢出不会像堆内那样有清晰的 OOM 日志排查难度更高。6.2 倾斜的三种识别信号数据倾斜不是靠猜的UI 上有三个非常明确的信号我基本上是顺着看第一个信号是同 Stage 内 Task 耗时的极值差。正常分布的 Task 耗时都比较接近如果出现一个 Task 跑了 20 分钟、其余都在 1 分钟内完成基本可以确定倾斜。判断标准我一般用“最慢 Task 耗时超过中位数的 5 倍”。第二个信号是Shuffle Read Size 的极值差。倾斜的 Task 读的数据量会远大于其他 Task通常是某个或某几个 key 的数据量特别大导致的。第三个信号是Spill 和 GC 的相关指标。倾斜的 Task 因为数据量大往往伴随大量 spill 和长时间 GC在 Task 详情页里能看到 spill 的字节数远超平均值。这三个信号要一起看只看一个容易误判。比如某个 Task 慢也可能是它所在的节点磁盘 IO 被别的任务占满了这时候各个 Task 的 Shuffle Read Size 是接近的只是执行环境差。6.3 倾斜处理的几条路和各自的代价处理倾斜的方案我按“改动成本”排序从低到高调并行度。提高 Shuffle 分区数让原本落在同一个分区的数据被拆到更多分区里。但这招对大 key 基本无效——如果某个 key 单独就占了几个 G无论分多少个区它还是只能落在同一个分区里。所以这招只对“分区数太少导致的伪倾斜”有用。广播小表。如果是 join 导致的倾斜且其中一张表足够小小于spark.sql.autoBroadcastJoinThreshold默认 10MB把广播阈值调高让 Spark 直接广播整个 join 就没有 Shuffle 了倾斜自然消失。这是性价比最高的方案前提是小表真的小。给 key 加随机前缀做两阶段聚合。针对groupByKey这类聚合先给大 key 加上随机前缀打散到多个分区做局部聚合再去掉前缀做一次全局聚合。代价是要写两遍聚合逻辑且只适用于聚合类操作join 场景要另外处理。对大 key 单独采样处理。先sample出倾斜的 key把这些 key 对应的数据单独拿出来处理比如广播到每个分区做 map 端 join剩下的数据走正常流程最后 union 回来。这个方案灵活但代码复杂度高维护成本不小。开启 AQE 的倾斜处理。Spark 3.x 的spark.sql.adaptive.enabledtrue配合spark.sql.adaptive.skewJoin.enabledtrue能在运行时检测到倾斜的分区并自动拆分。这是目前最省事的方案但只对 Spark SQL / DataFrame 的 join 生效RDD API 用不上而且检测和拆分本身有开销小任务开了反而变慢。选择哪个方案先看操作类型聚合类优先考虑加盐两阶段join 类优先考虑广播都不是再考虑采样拆分。别一上来就改并行度那是最常见也最没用的第一反应。7. 面试里把这些点串成回答的方式7.1 按链路答题而不是按目录答题我当过几次面试官之后发现候选人之间的差距往往不在知识点覆盖而在组织方式。被问到“讲讲 Spark 的 Shuffle”按目录答题的人会从“Shuffle 分 HashShuffle 和 SortShuffle”开始背背到一半被追问“文件数具体是多少”就断了按链路答题的人会从“为什么要 Shuffle宽依赖导致跨分区拉数据”讲到“写端怎么落文件、读端怎么拉、文件数怎么收敛、什么时候会出问题”中间每一步都能自然衔接被追问也不慌。我建议的准备方式是把知识切成四条链路每条都练到能一口气讲完数据链路数据源 → RDD 分区 → 窄依赖流水线 → 宽依赖断点 → Shuffle 写读 → 结果回传。调度链路Action → Job → Stage → TaskSet → Task → Executor 线程。容错链路分区丢失 → 血缘回溯 → 重算范围 → checkpoint 截断。资源链路内存划分 → 借用规则 → spill → 倾斜 → 调参。练这四条链路的好处是面试官从任何一个点切进来你都能顺着往下讲而不是停在那里回忆下一个要点是什么。7.2 主动交代边界条件比多背十个结论有用我面人时最看重的不是答对了多少而是有没有主动说“这个结论在什么条件下成立”。举几个我经常用来判断的例子被问到cache和persist的区别只说“cache 是 persist 的简写”只得一半分补一句“2.x 之后 cache 等价于 MEMORY_AND_DISK1.x 是 MEMORY_ONLY源码里 cache 就是调 persist 传默认级别”说明真翻过源码。被问到宽窄依赖答完定义后补一句“coalesce默认是窄依赖shuffletrue才变宽union是RangeDependency所以也是窄依赖”说明不是死记结论。被问到调优参数不要只报默认值要说清楚“调大这个参数能解决什么、代价是什么、什么情况下不该调”。比如spark.locality.wait调大能提升本地性但增加等待bypassMergeThreshold调大能省排序但增加文件句柄压力spark.memory.storageFraction调高能保住缓存但挤压 Shuffle 内存。能说清代价的人通常是真在生产环境上调过参的。我自己准备这类面试时有个习惯每个知识点都准备一个“我遇到过的具体现象”。比如讲倾斜我会说某个按天跑批的统计任务里某个维度的数据量是其他维度的几百倍导致那一个 Task 跑了四十分钟而其余 Task 两分钟就结束最后是用广播小表解决的。讲缓存误用我会说加了 cache 之后任务反而变慢最后在 Storage 页签里发现缓存块长期驻留、挤掉了 Shuffle 内存。有现象支撑的答案比背诵定义的可信度高得多。最后一个我觉得值得分享的习惯是面完之后把自己没答上来的点记下来然后去源码里找到对应的位置读一遍。Spark Core 的源码量并不夸张DAGScheduler的submitMissingTasks、ShuffleMapTask的runTask、SortShuffleWriter的write这几个方法读完前面讲的所有链路都会变得具体。我自己的体会是面试准备最容易陷入的误区是把八股文当成终点其实它只是个索引真正的理解来自顺着索引点进去看一遍实现看完之后那些“为什么”就不再需要背了。
阅读完成 · 觉得有帮助?
咨询建站