1. 卷四真题的命题口味:为什么Spark面试越往后越不好答
面到第四轮还愿意聊Spark的公司,基本已经不太考"什么是RDD"这种名词题了。卷四这类题目的典型特征是:给一段看起来能跑的代码、一个含糊的现象描述、一组似是而非的参数,让你判断瓶颈在哪、为什么会这样、改哪里。它考的不是记忆,是判断力,是你在真实集群上被OOM和倾斜反复教育过之后沉淀下来的直觉。
我这些年带过不少人准备大数据方向的面试,也自己当过被面的一方。前几轮大家往往靠背八股过关——分区、算子、宽窄依赖、缓存级别、Shuffle原理,张嘴就来。但到了卷四这个层级,面试官默认你这些都熟了,他开始关心更细的东西:你知道repartition和coalesce在什么场景下选择会直接决定任务成败吗?你说得出groupByKey和reduceByKey的差异,那你知道在数据倾斜已经发生、任务卡在99%的时候,具体该动哪一行代码吗?你能讲清楚广播变量省下的是网络还是内存,还是两者都有?
这篇东西我打算按卷四这类题的真实出题逻辑来拆。核心关键词就三个:Spark执行模型、Shuffle与数据倾斜、内存与资源调优。不管你是准备面试,还是手头正好有个跑得慢的作业要救,下面这些内容都能直接拿去用。我会把每道典型题背后的考点、面试官真正想听什么、以及实际生产中怎么落地,一层层剥开讲。基础不太牢的朋友也别急着走,关键概念我会用生活化的说法先垫一遍,再往上叠加细节。
需要先摆明一个态度:Spark调优这件事,网上流传的"黄金参数表"绝大多数是有害的。同一组配置在这个业务上是救命稻草,换个数据分布就是催命符。所以我下面讲的每个参数、每个结论,都会带上它成立的前提条件。你记结论没用,得记住结论背后的那个"因为"。
2. 执行模型类真题:从一段"能跑但慢"的代码说起
卷四最经典的开场就是甩一段代码让你找问题。比如这种:读一张几亿行的宽表,做几次过滤和聚合,中间反复用同一个DataFrame,最后写回存储。代码没有任何语法错误,跑也能跑完,就是慢得让人想砸键盘。面试官问:你怎么优化?
2.1 Transformation是惰性的,这句话背后的真实含义
先说清楚惰性求值到底意味着什么。你写的每一行filter、select、join,在Driver端执行时并不会立刻碰数据,它们只是在内存里堆出一棵逻辑计划树。直到遇到count、collect、write这类Action算子,Spark才把这棵树拿去优化、切分成Stage、生成Task、分发到Executor上跑。
这个机制的实战意义在于:你的代码行数不等于执行步骤数。我见过有人为了"减少计算",把一段逻辑拆成十几个中间DataFrame,每个都cache一下,以为这样更快。结果内存被塞满,频繁GC,反而慢了三倍。正确的思路是反过来——尽量让Catalyst优化器看到完整的链路,让它有机会做谓词下推、列裁剪、常量折叠。你拆得越碎、缓存得越多,优化器能做的事就越少。
有一个很实用的判断方法:打开Spark UI,看SQL页签里的物理计划。如果你看到多个独立的Scan节点在读同一份数据,说明你的链路被自己切断了,优化器没法合并。这时候该做的是把中间结果合并成一个链路,只在真正被多次复用的那个节点上做一次cache或persist,而不是到处撒缓存。
2.2 DAG切分Stage的判定标准,以及它为什么决定了你的网络开销
Stage的切分只认一件事:是否发生了宽依赖。窄依赖指的是父RDD的每个分区最多被一个子分区使用,比如map、filter、union,这类操作可以在同一个Stage内流水线执行,不需要落盘、不需要跨节点搬数据。宽依赖则是父分区的数据要被多个子分区使用,典型就是各种需要按key重新分组的操作,这时候Spark必须插入一个Shuffle边界,把Stage切成前后两段。
为什么这个概念在面试里被反复问?因为它直接对应着你作业的耗时结构。一个Spark作业的时间可以粗略拆成三块:读数据、算数据、Shuffle。前两块通常靠加资源能线性改善,Shuffle不行——它受限于磁盘IO、网络带宽和分区数量的匹配度。一份几百GB的中间数据在集群里洗一遍,产生几百万个小文件、几TB的磁盘写入,这个代价加机器是救不回来的。
所以卷四有个很常见的追问:这段代码里有几次Shuffle?能不能减?减Shuffle的手段无非几种——用reduceByKey替代groupByKey(前者会在map端做预聚合,把要传输的数据量压下来)、用aggregateByKey做更灵活的分段聚合、用广播变量把小表join变成map端join彻底消除Shuffle、或者调整分区键让本来要洗的数据能就地合并。每一种都有适用边界,我下面在讲join的时候会具体展开。
2.3 广播变量到底省了什么:一个容易被答错的问题
面试官问"广播变量有什么用",答"减少数据传输"只能算及格。完整的回答应该分三层。
第一层是消除Shuffle。小表被广播到每个Executor后,大表的每个分区可以在本地完成join,不需要按key重分布,宽依赖变成了窄依赖,整个Shuffle阶段直接消失。这是最大的收益。
第二层是内存复用。广播变量在每个Executor上只存一份,被该Executor上所有Task共享,而不是每个Task各自持有一份副本。这点很关键——如果你用普通变量把一张几十MB的维表传进闭包,它会跟着每个Task序列化过去,几百个并发Task就是几百份拷贝,内存瞬间爆掉。广播变量通过BlockManager统一管理,把这份开销压到了一份。
第三层是传输效率。广播走的是点对点的分发加BT式的扩散传播,比随Task一起序列化的方式效率高得多。
但这里有个坑必须提:广播的表不能太大。默认的广播阈值大概在10MB量级(通过spark.sql.autoBroadcastJoinThreshold控制),超了这个尺寸,Driver要花很长时间序列化和分发,Executor端也可能因为存不下而报错。我遇到过有人不管三七二十一就把广播阈值调到几百MB,结果Driver端序列化直接卡死。判断标准很简单:广播的那份数据,乘以Executor数量,不能明显挤压执行内存。如果小表其实有几百MB,正确做法不是硬广播,而是考虑用分桶表(bucket join)让两侧数据按相同方式预分区,一样能省掉Shuffle。
3. Shuffle与数据倾斜真题:卡在99%的任务怎么救
这是卷四出现频率最高、也最能筛人的一类题。题面通常是这样:一个groupByKey或者大表join,前面几十个Stage几分钟跑完,最后一个Stage的Task有几百个已经完成,剩下几个一直卡在99%,跑几个小时不动,甚至报FetchFailedException或者Executor OOM。问你怎么定位、怎么解决。
3.1 先别急着改代码:确认倾斜的完整排查链路
我在实际工作里形成的习惯是,看到"个别Task特别慢"这个现象,先按顺序走一遍确认流程,而不是条件反射地去加盐。
第一步,看Spark UI的Stage详情页,重点看Task的Duration分布和Shuffle Read Size分布。如果中位数Task读了20MB,而最慢的那个读了5GB,那基本可以确定是数据倾斜,而不是资源不足。这个区分很重要——如果是整体都慢,那可能是分区数太少或者资源给少了,加并行度就能解决,根本不用动业务逻辑。
第二步,确认倾斜发生在哪个key上。做法是把出问题的那一列单独抽出来做countByValue,或者先sample一小部分数据统计分布。生产上我倾向于直接跑一个轻量的聚合抽样:取1%的数据,按key计数排序取Top20。如果发现某个key的量级是其他key的成百上千倍,那这个key就是罪魁祸首。常见的元凶是什么?是空值、默认值、测试账号、爬虫流量打过来的同一个ID、或者业务上天然就是一个超大户。
第三步,判断这个倾斜是临时的还是长期的。如果是因为某天搞活动导致某个商户订单暴涨,那属于临时倾斜,加盐、拆分都能顶过去;如果这个key天生就是大头(比如全站默认分类、平台自营店铺),那你得从数据模型层面重新设计分区策略,加盐只是治标。
这个三步走的价值在于,它逼你在动手前先搞清楚问题性质。我见过太多人一上来就repartition(1000),结果把一个本来只是分区不均的作业改成了Small File灾难,性能反而更差。
3.2 加盐、拆分、广播:三种手段各自的适用边界
确认了倾斜之后,方案选择要看两边的数据规模和业务容忍度。
**加盐(给热点key打散)**适用于聚合类操作。核心思路是给热点key拼上一个随机后缀,让它被分到多个分区里先做局部聚合,再去掉后缀做一次全局聚合。这里的关键细节是:加盐的随机范围要跟目标分区数匹配,一般取目标并行度的十分之一到二分之一。范围太小打不散,范围太大第二轮聚合又会产生大量小分区。
// 第一轮:给热点key加随机后缀做局部聚合 val salted = df.withColumn("salt", (rand() * 20).cast("int")) .withColumn("salted_key", concat(col("hot_key"), lit("_"), col("salt"))) val partial = salted.groupBy("salted_key").agg(sum("amount").as("partial_sum")) // 第二轮:去掉后缀做全局聚合 val result = partial.withColumn("hot_key", split(col("salted_key"), "_")(0)) .groupBy("hot_key").agg(sum("partial_sum").as("total"))注意这段代码会让计算量变成两倍聚合,如果你的聚合逻辑本身很重,得权衡一下。另外第二轮聚合的数据量已经很小了,可以让它跑在少量分区上,避免又产生一堆碎文件。
拆分热点key适用于join场景。做法是把大表里热点key对应的数据单独抽出来,跟小表做一次广播join,剩下的非热点部分走正常的Shuffle join,最后union起来。这个方案的代价是多读一遍大表(或者多一次过滤),收益是彻底绕开了那个最慢的Task。判断能不能用,看热点key的数据量——如果它能被单独抽出来而不至于撑爆内存,就可行。
广播小表前面讲过了,但它有个前提:小表真得小。如果两边都是大表,广播这条路走不通,那就只能考虑分桶。分桶的原理是让两张表在写入时就按join key以相同的分区数做hash分区并排序,join时两侧的分区一一对应,直接把Shuffle省掉。代价是建表时要多做一步,而且分区数一旦定了就不太好改。
| 倾斜场景 | 首选方案 | 关键前提 | 主要代价 |
|---|---|---|---|
| 聚合时单个key数据量巨大 | 加盐两阶段聚合 | 聚合逻辑可拆分 | 计算量翻倍 |
| join时一侧热点集中 | 热点拆分 + 广播 | 热点数据可独立抽出且能吃进内存 | 多扫一遍大表 |
| 一侧表足够小 | 广播join | 小表能塞进Executor内存 | 大表无法广播 |
| 双大表且key分布不均 | 分桶表预分区 | 写入侧可控、分区数固定 | 建表成本高、灵活性差 |
这张表我建议直接记住框架,面试时按场景往上套,比背参数有用得多。卷四的面试官最烦的回答就是"遇到倾斜就加盐",因为他下一句一定会问"那如果两边都是大表呢",答不上来就露馅了。
3.3 卡在99%背后的另一类原因:不是倾斜,是 spill
这里必须提醒一个反直觉的点。任务卡在最后阶段,除了数据倾斜,还有一个高频原因:磁盘溢写(spill)。当单个Task处理的聚合状态或者join缓冲区超出执行内存时,Spark会把数据写到磁盘,这个过程的IO代价极高,表现出来就是Task进度条长时间不动。
区分方法很直接:看Spark UI里那个Stage的Spill (Memory)和Spill (Disk)两列。如果数值很大,说明瓶颈是内存不够而不是数据不均。这时候加盐是无效的,正确的动作是:调大Shuffle的分区数(让每个Task处理更少的数据)、适当提高执行内存占比、或者减少单个Task的聚合状态规模。
我踩过一次典型的坑:一个作业里用collect_list把某个用户的全部行为记录聚合成一个大数组,单个用户几十万条,直接导致该Task的聚合状态撑爆内存,疯狂spill。这种场景的解法不是调参数,而是改逻辑——要么限制聚合条数(用slice截断),要么放弃一次性聚合改成流式处理。这也说明一件事:很多性能问题的根在业务逻辑,参数只是遮羞布。
4. 内存与资源调优真题:参数不是越大越好
到了这一块,卷四的题目开始变得很"抠细节"。典型问法:给你一个集群规格和作业特征,让你配一套参数并说明理由。这种题最能区分出真调过优的人,因为背参数表的人会给出一个看起来完整但互相矛盾的配置。
4.1 Executor内存的分配结构,以及OOM到底O在哪
先把内存模型讲清楚,不然调参就是瞎猜。一个Executor的内存大致分几块:执行内存(用于Shuffle、join、聚合、排序这些计算过程)、存储内存(用于缓存RDD和广播变量)、以及用户内存(你自己代码里创建的集合、对象)、还有一部分是预留的不可控开销。
统一内存管理的机制是:执行内存和存储内存共享一个池子,谁空闲谁可以借用。执行内存紧张时会抢占存储内存,被抢占的缓存块直接丢掉,下次用到再重算;反过来存储内存要用时,如果执行内存空闲也能借。
这个结构解释了两类OOM的区别,面试里很爱考:
- Driver端OOM:通常发生在
collect、toPandas、broadcast大对象这些动作上。数据被拉到Driver单机内存里,几GB下去必炸。解法是换成write直接落盘,或者限制返回行数。 - Executor端OOM:可能有两种情况。一种是执行内存不足,聚合或join时状态太大,表现为频繁spill甚至直接OOM;另一种是用户内存不足,比如你在
mapPartitions里把一个分区的数据全toList了。前者调分区数和执行内存比例,后者必须改代码——用迭代器处理,别一次性加载。
我个人的经验是,遇到Executor OOM先看发生的位置:如果是Shuffle read阶段崩的,多半是单分区数据太大,优先加分区;如果是聚合阶段崩的,看spill指标;如果在你的自定义函数里崩的,直接回去改代码,调参救不了。
4.2 并行度的估算:一个能落地的算法
面试里问"分区数怎么定",很多人答"给核数的2到3倍",这个答案太笼统。我一般按这个流程算:
先估算单个分区的数据量。假设要处理的数据是500GB,目标让每个分区处理100~200MB左右(这个区间对大多数计算是舒服的,太小调度开销大,太大容易OOM)。那么500GB除以128MB,大概需要4000个分区。再对照集群资源:假设有50个Executor,每个4核,总并行度是200。4000个分区分到200个slot上,每轮跑200个Task,要跑20轮。
如果轮数太多,说明分区数超过并行度太多,调度开销上来了;如果分区数远小于并行度,那就有大量核心闲置。理想状态是分区数是总并行度的2到4倍,既有足够的并行填充,又不至于调度过载。按这个标准,上面的例子其实可以适当减少分区,让每个分区处理200MB左右,分区数降到2500,跑12轮左右更合理。
# 提交时的资源参数示例(按上面的规模估算) --num-executors 50 --executor-cores 4 --executor-memory 16g --conf spark.sql.shuffle.partitions=2500 --conf spark.memory.fraction=0.6spark.sql.shuffle.partitions这个参数的默认值是个著名的坑,它默认只有200。处理几百GB数据还留着200个分区,每个分区要扛几GB,不OOM才怪。我见过太多线上作业就是忘了改这个值。但反过来,把它无脑调到几千也不行,小文件问题会找上门。所以这个值必须跟着数据量和集群规模走,而不是抄一个固定数。
4.3 动态资源分配与推测执行:该开还是该关
这两个配置在面试里也常被问到,答案取决于你的作业类型。
动态资源分配(spark.dynamicAllocation.enabled)适合运行时间较长、负载有波动的批作业。它会根据待处理的Task数量自动申请和释放Executor,闲时把资源还回去,多作业共享集群时很有价值。但它有个副作用:Shuffle数据要保持可用,必须配合外部的Shuffle Service,否则Executor被回收后下游拿不到数据。而且短作业开了它反而增加调度开销,不值得。
推测执行(spark.speculation)听起来很美好——发现某个Task明显慢于其他Task,就启动一个副本一起跑,谁先完成用谁。但它在数据倾斜场景下是有害的。因为慢Task本来就是因为数据多,你给它起个副本,等于又拉一份同样的数据去跑,不仅没解决问题,还挤占了本来能跑正常Task的资源。所以我的建议是:确认是倾斜导致的慢,先把推测执行关掉,专心解决倾斜;如果是偶发的机器抖动导致的慢,那开着无妨。
这两个配置的判断逻辑其实是同一套:先搞清楚慢的原因,再决定开什么开关。反过来先开一堆开关再看效果,那是碰运气,生产环境碰不起。
5. 真题之外的加分项:把回答说出生产味
卷四的最后往往不以技术题收尾,而是抛一个开放性问题,比如"你做过最有挑战的性能优化是什么"或者"这个作业白天跑不完,你怎么办"。这类题的评分标准不是答案对不对,而是你有没有真实的生产视角。
我总结下来,能加分的回答都有两个特征。一是带数字。不说"优化后快了很多",而说"原来跑4小时,优化后45分钟,主要省在Shuffle,从3次降到1次,中间数据量从2TB压到300GB"。数字背后的因果链才是有价值的。二是带取舍。不说"我用加盐解决了倾斜",而说"加盐之后聚合阶段快了一倍,但第二轮聚合产生了小文件问题,我通过合并输出分区解决了,代价是多了几分钟的合并时间"。面试官想听的就是这种"你为了什么付出了什么"的判断。
还有一个容易被忽略的点:监控。真正做过生产优化的人,会主动提到怎么发现问题的——是通过Spark UI、事件日志、还是自己埋的指标上报。我个人习惯是给每个关键作业记录三项基线:各Stage的耗时占比、Shuffle读写总量、单Task的最大处理数据量。这三项一旦出现异常波动,就说明有东西变了。有了基线,问题定位从"排查"变成"对比",效率完全不是一个量级。
再补一个实操细节,关于事件日志。开启spark.eventLog.enabled之后,作业结束后日志会落到指定路径,可以用历史服务器反复回看,这点对复盘特别重要。很多人调优时只看实时UI,作业一结束页面就没了,下次再出问题还得重新复现。我一般要求自己把每次大优化的前后日志都留下来,一来方便对比,二来出问题时有个参照系。
最后说说平时怎么练。卷四这种题靠临时抱佛脚是过不了的,因为它的内核是判断力。我的建议是给自己造几个真实的场景去跑:造一份带倾斜的数据,故意用错误的参数跑一次,看UI里各指标怎么变;然后把参数调对再跑一次,对比差异。这种"故意犯错再修正"的练习,比看十篇调优文章都管用。数据不用太大,本地模式或者小集群就能复现大部分现象,成本很低。
我自己到现在还保持着这个习惯:每接一个新类型的作业,先拿小样本跑一遍,把UI里的关键指标截图存下来当基线。等数据量上来之后,任何偏离基线的表现都能第一时间被发现。这个动作看起来很笨,但它救过我很多次——很多性能问题是慢慢劣化的,等你肉眼察觉时,已经积累了几周的坏数据。