Spark 作业堆内存没满,执行器却被 YARN 杀掉:先看 memoryOverhead 这 10%
某制造企业的夜间离线作业跑在 YARN 上,每天凌晨把当天的订单与生产数据做一次宽表关联。作业跑了两个多月一直正常,某天数据量涨了三成之后,开始隔三岔五失败。Spark 界面里看,失败任务的堆内存用得并不满,GC 也没有长时间停顿,可驱动器日志里反复出现同一行:
ExecutorLostFailure (executor 7 exited caused by one of the running tasks)
Reason: Container killed by YARN for exceeding memory limits.
12.4 GB of 12 GB physical memory used. Consider boosting spark.yarn.executor.memoryOverhead.
堆没满,容器却被杀了——这不是堆内存不够,而是堆外的内存超了。--executor-memory 只管 JVM 堆,YARN 给容器算的账却是另一本。
YARN 容器内存 = 堆内存 + 堆外开销
一个 Spark 执行器在 YARN 上占用的物理内存由两部分组成:JVM 堆(spark.executor.memory,默认 1g)加上堆外开销(spark.executor.memoryOverhead)。堆外这部分装的是线程栈、JIT 编译缓存、元空间、NIO 直接内存、本地库,以及 PySpark 的 Python 进程,全都不在堆里。
关键在默认值。官方配置文档里,spark.executor.memoryOverhead 不填时按下面这条规则算:
memoryOverhead = max(384 MB, executor 堆内存 × 10%)
也就是说,一个 --executor-memory 8g 的执行器,默认 overhead 只有 819 MB,YARN 分配的容器一共 8.8 GB。作业本身不怎么吃堆,但 shuffle 量一大、直接内存一涨,堆外很容易就把这 819 MB 顶穿。YARN 的 NodeManager 默认开启了物理内存检查(yarn.nodemanager.pmem-check-enabled 默认 true),一超就直接杀容器,连商量的余地都没有。被杀的执行器上正在跑的任务全部重算,失败次数一到,整个作业就挂了。
顺带注意,报错里建议的 spark.yarn.executor.memoryOverhead 是老参数名,Spark 3.0 起已经被移除,现在该用 spark.executor.memoryOverhead。Spark 3.3 之后也可以用 spark.executor.memoryOverheadFactor 按比例调,YARN 下默认值 0.10。老配置里还留着旧参数名的,先删掉再调。
先确认:到底是堆不够,还是堆外不够
加内存之前,先分清是哪一种内存爆了,两种报错长得不一样:
- 堆不够:任务日志里出现
java.lang.OutOfMemoryError: Java heap space,Spark UI 的 Executors 页面能看到堆使用率长期顶在高位; - 堆外不够:没有堆溢出堆栈,只有上面那句
Container killed by YARN for exceeding memory limits,而且被杀之前堆使用率看着还很健康。
确认的命令可以直接抄。用 YARN 的聚合日志看被杀容器的最后记录:
yarn logs -applicationId application_1731200000000_0042 \
| grep -A 2 "running beyond physical memory limits"
输出里会有一行 Current usage: 12.4 GB of 12 GB physical memory used; ... Killing container,数字超过容器上限多少、虚拟内存有没有同时超限,都写在里面。如果是虚拟内存先超(vmem-pmem-ratio 默认 2.1,虚拟内存上限是物理内存的 2.1 倍),排查方向也一样:先看堆外。
一组可以直接抄用的提交配置
下面这组配置给的是“shuffle 量大、堆看着够用、容器反复被杀”的作业。思路是把 overhead 显式抬到 2g,同时把堆从 8g 降到 6g,让单个容器占用的节点内存总量保持 8g 不变——只挪配比,不多占资源:
spark-submit \
--master yarn \
--deploy-mode cluster \
--num-executors 20 \
--executor-cores 4 \
--executor-memory 6g \
--conf spark.executor.memoryOverhead=2g \
--conf spark.sql.shuffle.partitions=400 \
--class com.example.DailyWideTableJob \
daily-job.jar
每一项的取舍都说清楚:
- overhead 从默认 819 MB 抬到 2g:给堆外留出约 1.2g 的余量,shuffle 高峰期的直接内存和线程栈不再顶到容器上限;
- 堆从 8g 降到 6g:容器总量还是 8g,节点上能跑的执行器数量不变,不会因为加了 overhead 就把队列资源挤爆。只加 overhead 不降堆,等于变相多要了资源,队列一紧张照样排不上;
spark.sql.shuffle.partitions从默认 200 调到 400:分区数翻倍后单个任务的 shuffle 数据量减半,堆外内存的峰值也跟着下来。分区数不是越大越好,任务太小会反过来拖慢调度,400 是这个量级作业的经验值;- 用 PySpark 的作业要另外注意:Python 进程的内存也出在 overhead 里,
spark.executor.pyspark.memory没配的时候它和 JVM 的堆外内存共用这一个池子,Python UDF 用得多的作业,overhead 还要再往上加一档。
改完之后跑一晚上,盯两个地方验证:Spark UI 的 Executors 页面看单个执行器的峰值内存有没有贴着容器上限走,YARN 日志里 Killing container 不再出现。如果还被杀,继续按每次 1g 的步子往上加 overhead、同步往下减堆,直到稳定。
加内存之前,先排掉两个真问题
overhead 只能兜住合理的峰值,兜不住失控的内存需求。调参之前,按顺序查这两处:
第一,看是不是数据倾斜。 某个 key 占了大半数据,单个任务的 shuffle 读写量是别人的几十倍,堆外内存跟着一起冲顶。这种情况加多少 overhead 都不够,先看 Spark UI 的 Stages 页面,找耗时最长、shuffle 读写量最大的那几个任务,确认是不是倾斜,再决定是加盐打散还是换 join 方式。
第二,看是不是缓存了不该缓存的数据。 persist() 之后一直不释放的 DataFrame,加上 spark.memory.fraction 默认 0.6 的统一内存区,会让执行内存和存储内存互相挤占。缓存只留给真正会被反复读的中间结果,用完就 unpersist()。
小结
Spark 作业堆没满却被 YARN 杀掉,多数时候不是堆不够,而是堆外开销被默认值卡住了:spark.executor.memoryOverhead 默认只有堆内存的 10%、且不低于 384 MB,--executor-memory 8g 的执行器,容器一共才 8.8 GB。先看报错区分堆溢出还是容器被杀,再把 overhead 显式抬上去、同步把堆降下来保持容器总量不变,shuffle 分区数跟着调一调。参数兜得住峰值,但兜不住倾斜和乱缓存——先排掉这两个真问题,再谈调参。
- 点赞
- 收藏
- 关注作者
评论(0)