☰
Spark读写Hive报GC overhead limit exceeded排查
2026/10/26 3:15:38 网站建设 项目流程

java.lang.OutOfMemoryError: GC overhead limit exceeded这行字,只要你在 Spark 上跑过 Hive 表,大概率在日志尾巴上撞见过它。它不像Java heap space那样干脆利落地告诉你“堆满了”,而是绕个弯子说“GC 忙活半天什么也没捞着”。很多人第一反应是把spark.executor.memory从 8g 加到 16g,加完发现任务从跑 40 分钟挂掉变成跑 55 分钟挂掉,问题一点没解决。这篇内容就是围绕 Spark 读写 Hive 时出现这个报错的完整排查链路展开的:它到底是什么、为什么偏偏在 Spark+Hive 这个组合上高频出现、怎么在不盲目加内存的前提下把根因揪出来、以及一套我自己反复用过的参数与写法模板。适合已经能跑 Spark SQL、但被内存问题反复折磨的中级使用者,也适合刚开始接手离线任务、需要快速建立排查直觉的同学。文中涉及的数据量、集群规模、参数值都是基于常见生产形态的合理演绎,你按自己的实际情况换算即可。

1. 先把这条报错读明白:它到底在喊什么

1.1 GC overhead limit exceeded 与普通堆溢出的本质区别

GC overhead limit exceeded是 JVM 的一条“保护性自杀”策略,触发的判定条件大致是:垃圾回收器把超过 98% 的 CPU 时间花在了 GC 上,而连续多轮回收后只释放了不到 2% 的堆空间。换句话说,JVM 并不是在“申请内存失败”的那一刻才崩,而是在“我已经尽力回收了,但堆里全是不肯松手的活对象”这个状态下主动放弃。这跟Java heap space的直接区别在于:后者是“我还要,但没了”,前者是“我有,但都是我不能动的”。

这个区别决定了排查方向完全不同。遇到Java heap space,你加内存通常真能续命,因为多数情况是瞬时峰值冲高;遇到GC overhead limit exceeded,加内存往往只是把死亡时间往后推,因为占着内存不放的对象是持续存在的——可能是一个巨大的 broadcast 变量、一个超大的 shuffle 分区、或者一个把所有数据缓存进内存的cache()调用。我在实际排查中形成的判断习惯是:看到GC overhead,先不问“内存够不够”,先问“谁在长期持有对象”。

还有一个容易被忽略的点:这条报错有时会以“任务卡住不动、日志疯狂刷 GC 但最后只报个 Executor lost”的形式出现,尤其在 YARN 模式下,AM 端看到的往往是Container killed by YARN for exceeding memory limits,而真正的 GC 报错埋在 executor 的 stderr 里。所以第一步不是急着改参数,而是先把 executor 日志找全,确认真实的第一现场。

1.2 为什么 Spark 配合 Hive 时这个错特别高发

Spark on Hive 这个组合有几个天然的“内存放大器”。第一,Hive 表的文件格式多样,ORC、Parquet、TextFile、SequenceFile 混在一起,Spark 读 TextFile 时要把整行解析成String再转Row,中间对象数量是 Parquet 的几倍,堆压力天然更大。第二,Hive 表的分区设计往往是为 Hive on MR 时代准备的,分区粒度粗、单分区文件巨大,Spark 按文件切分 task 时容易切出超大 task,一个 task 处理几个 GB 的数据,execution 内存直接见底。

第三点更隐蔽:Hive 元数据里的统计信息(numRows、totalSize)经常是缺失或者过期的,Spark 的 CBO 拿不到准确的行数估计,join 顺序、join 策略就全靠猜。猜错的结果就是把该 broadcast 的小表当成了大表做 shuffle,或者把该 shuffle 的大表塞进了广播。第四,很多团队是拿 Spark 去“接管”原本跑在 Hive 上的老 SQL,这些 SQL 里大量使用collect_list、concat_ws、窗口函数、以及select *全字段扫描,单行对象特别宽,内存占用按行数乘以列数增长,很容易在几十万行时就撑不住。

我这里说的“宽”,是指Row对象里字段多、字符串字段长。一个 200 列、平均每列 50 字节的表,单行对象在 Java 堆里的实际占用可能是原始字节的 3 到 5 倍,因为每个String有对象头、字符数组、哈希缓存。十万行这样的数据全放进一个分区做排序,一个 executor 的 execution 内存瞬间就满了。

2. 从现象到根因:我常用的四层排查路径

2.1 第一层:在 Spark UI 里把“谁在吃内存”定位到 Stage 和 Task

排查永远从 Spark UI 开始,不要跳过。我关注的顺序是:先看 Jobs 页面哪个 Stage 挂了,再看这个 Stage 的 Summary Metrics 里Shuffle Read Size、Spill (Memory)、Spill (Disk)、GC Time四个指标。如果某个 Stage 的Spill (Disk)特别大,说明 execution 内存不够,数据被迫落盘,这是内存压力的直接证据;如果GC Time占 task 执行时间的比例超过 20%,基本可以确定堆内对象过多。

接着进 Stage 详情页看 task 的分布,重点比较三个数:Shuffle Read Size的中位数、最大值、以及 75 分位数。中位数 200MB 而最大值 20GB,这就是教科书级的倾斜,不用再往下猜了。反过来如果所有 task 都均匀地在 3GB 左右,那问题不在倾斜,而在单分区本身太大,需要提高并行度或者改分区策略。

还有一个我特别爱看的地方:Executor 页面的Storage Memory和Used Storage Memory。如果 storage 占满了且任务并没有显式 cache,那说明某些算子在做隐式缓存,比如 broadcast join 的广播表、或者 DataFrame 被多次引用触发的自动 cache。这部分内存是“只进不出”的,会持续挤压 execution 空间,是 GC overhead 的常见推手。

2.2 第二层:打开 GC 日志,分辨“回收不动”还是“分配太快”

光看 UI 只能知道“有内存压力”,要确诊必须上 GC 日志。在spark-submit的--conf spark.executor.extraJavaOptions里加上:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:/data/spark-logs/gc-%p-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=50M

JDK 9 以上换成-Xlog:gc*:file=/data/spark-logs/gc-%p.log:time,uptime,level,tags。日志目录一定要选本地盘且容量够,别写到 HDFS 上,会拖慢整个 executor。

拿到日志后我看两件事。一看 Full GC 的频率和耗时:如果每几分钟一次 Full GC 且每次耗时好几秒,回收后老年代占用几乎不降,那就是真有大量活对象长期驻留,方向是削减对象生命周期(减少 cache、缩小 broadcast、拆大 task)。二看 Young GC 后的晋升量:如果每次 Young GC 都有大量对象晋升到老年代,说明对象存活时间被意外拉长了,典型原因是算子链里存在跨批次的状态,或者 UDF 里持有了大集合的引用。

提示:-XX:+PrintHeapAtGC会打印每次 GC 前后各代的使用量,信息量比单纯的 GC 摘要大得多,排查阶段值得一直开着,定位完再关掉以免日志膨胀。

2.3 第三层:抽样看数据分布,验证倾斜与小文件假设

UI 给的是“症状”,数据分布才是“病灶”。我一般用一个很小的采样作业把关键维度摸清楚,比如统计 join key 的 TopN 分布:

SELECT join_key, COUNT(1) AS cnt FROM ( SELECT join_key FROM db.table_a TABLESAMPLE (5 PERCENT) ) t GROUP BY join_key ORDER BY cnt DESC LIMIT 50;

注意TABLESAMPLE在 Hive 表上的行为跟文件格式有关,分区表要确保采样覆盖到多个分区,否则结论会失真。我更稳妥的做法是直接对底层路径做一次文件级统计:按分区列出文件数量和单文件大小,找出“分区内文件数上万”或者“单文件超过 2GB”的异常分区。这两类问题在 Hive 表上极其常见,尤其是做过动态分区写入的表,分区一多、小文件一多,Spark 读取时元数据操作和 task 调度开销会急剧上升,同时每个 task 处理的数据量又极不均衡。

文件级统计用一段简单的 Python 脚本就能做,通过 Hadoop FileSystem API 遍历目录,把(分区路径, 文件数, 总大小, 最大文件大小)输出成 CSV。这个方法比在 Spark 里跑统计快得多,也不会额外消耗集群资源。

2.4 第四层:堆 dump 与对象直方图,抓真正的“大对象”

前面三步还定位不了,就得上对象视角了。在 executor 启动参数里加-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps,让它在崩溃瞬间自动 dump。同时可以用jmap -histo:live <pid>快速看对象直方图,这个命令很轻,对线上进程影响可控,能立刻告诉你哪类对象占了最多堆。

# 先找到 executor 的 pid jps -l | grep CoarseGrainedExecutorBackend # 对象直方图,只看前 30 行 jmap -histo:live 12345 | head -30

实践中我见过的高频“元凶”有这么几类:[B(byte 数组,通常是序列化缓冲区或 Parquet 的 page)、java.lang.String、org.apache.spark.sql.catalyst.expressions.UnsafeRow(超过 64KB 会退化成GenericRow,内存效率骤降)、scala.collection.mutable.HashMap(常见于 UDF 里做聚合缓存)。看到UnsafeRow占比异常,基本可以判定存在超宽行或者超长字符串字段。

拿到 dump 文件后用 MAT 或者 JProfiler 打开,看 Dominator Tree,找 retained size 最大的那个对象及其引用链。这一步能直接指认出是某个 broadcast 变量、某个缓存 RDD、还是某个静态集合在作祟。代价是 dump 文件很大(跟堆大小相当),要预留磁盘空间,且 dump 期间进程会 STW,生产环境要挑低峰做。

3. 内存账本:Executor 那点内存到底怎么被分掉的

3.1 统一内存管理模型下,堆被切成了哪几块

很多人调参靠感觉,加内存加到 32g 还是挂,原因是不清楚 Spark 的堆内布局。从 Spark 1.6 起默认使用统一内存管理(spark.memory.useLegacyMode=false),堆内大致分三块:预留内存(Reserved Memory,固定 300MB,防止 OOM 的保险丝)、用户内存(User Memory,约占(堆 - 300MB) × (1 - spark.memory.fraction),放用户数据结构、UDF 里的临时对象、元数据)、统一内存(Unified Memory,剩下的部分)。

统一内存内部又分 Storage 和 Execution 两块,前者放 cache 和 broadcast,后者放 shuffle、join、sort、aggregation 的中间数据。关键点是这两块可以互相借用:Storage 空闲时 Execution 可以占用,反之亦然。但这种借用是“软”的——当 Execution 需要空间而 Storage 占着不放时,Spark 会尝试驱逐 Storage 里的 block,驱逐不掉(比如 broadcast 变量不能被驱逐)就会触发 spill 甚至 OOM。

由此得出一个很实用的判断:如果你的任务里有大 broadcast 或者大量 cache,spark.memory.storageFraction保留默认 0.5 往往不够,因为 broadcast 占住的部分是不可驱逐的,实际可用给 Execution 的内存会远小于账面数字。我见过一个任务广播了一张 800MB 的维表,账面 execution 内存 6GB,实际可用只有 2GB 出头,跑起来不 OOM 才怪。

3.2 一套可落地的参数推导过程,别靠拍脑袋

调参要有账本。假设集群 10 台节点,每台 64 核 256GB 内存,我通常这样起 executor:每台 3 个 executor,每个 5 核、18GB 堆内存、2GB overhead,总共 30 个 executor、150 个并发 task。为什么是 5 核而不是 16 核?因为 HDFS 客户端在高并发下容易成为瓶颈,而且单 executor 核数太多会让单 JVM 内的 GC 压力集中,5 核是实践下来比较均衡的点。

单 executor 18GB 堆的内存账这样算:先扣掉 300MB 预留,剩约 17.7GB;spark.memory.fraction取 0.6,统一内存约 10.6GB;再按storageFraction=0.5分成 Storage 5.3GB、Execution 5.3GB。5.3GB 分给 5 个 core,每个 core 约 1.06GB 的 execution 内存,这正好落在“每 core 1GB 左右”的经验区间内,说明这组参数是自洽的。如果你把核数提到 8 核而内存不变,每 core 只剩 660MB,spill 会显著增加——这就是“加核反而变慢”的常见原因。

再算并行度。假设要处理 2TB 的 Parquet 数据,目标单分区 256MB,理论分区数约 8000。150 个并发槽位意味着要跑 53 波,每波的开销乘以 53 就是总调度成本。如果分区切到 128MB,分区数翻倍到 16000,波数到 107,调度和 task 启动开销会明显上升。所以并行度不是越大越好,256MB 到 512MB 的单分区是一个比较稳的甜点区,除非你的数据倾斜严重到必须靠细粒度拆分来摊平。

还有两个参数必须一起调:spark.sql.shuffle.partitions默认 200,对 2TB 数据严重不足,每个分区 10GB,必挂。我一般按总shuffle数据量 / 256MB估算,2TB 的话设到 8000 左右,同时配合 AQE 的spark.sql.adaptive.coalescePartitions.enabled=true让它自动合并小分区。AQE 的spark.sql.adaptive.advisoryPartitionSizeInBytes建议设成 256MB,与实际目标一致。

3.3 堆外与 overhead:最容易被忽略的那 10%

spark.executor.memoryOverhead默认是max(384MB, 0.1 × executorMemory),18GB 堆对应 1.8GB 左右,我习惯直接给到 2GB 甚至 3GB。为什么?因为 Spark 在堆外还有大量开销:Netty 的 direct buffer 做 shuffle 传输、JVM 自身的元空间和线程栈、Python UDF 的 worker 进程、以及 native 库分配的内存。这些都不在-Xmx管辖范围内,一旦超了,YARN 会直接 kill 容器,报的还不是 GC 错误,而是Container killed for exceeding memory limits,排查方向完全跑偏。

如果你开了spark.memory.offHeap.enabled=true做堆外缓存,情况更复杂:spark.memory.offHeap.size是独立于堆的一整块内存,需要从 overhead 里扣。很多人开了 offHeap 但没同步调大 overhead,结果容器被杀,还以为是堆内问题,来回折腾。我的做法是:除非有明确的堆外缓存需求,否则不开 offHeap,把复杂度留给确定性的场景。

另外一个常被忽略的是 driver 内存。spark.driver.memory默认 1GB,在 client 模式下往往还受限于提交机的物理内存。一旦你在 driver 端做collect()、toPandas()、show()大结果,或者在foreachPartition外累积集合,driver 会先崩,而报错信息往往是指向 executor 的,因为 AM 看不到 driver 的完整日志。这种情况我的处理习惯是先把 driver 调到 4GB 到 8GB,同时把 driver 的 GC 日志也开起来。

4. 高频根因逐个击破:Spark + Hive 场景下的真实病灶

4.1 数据倾斜:空值、热点 key 和选错的分区键

Spark+Hive 场景下的倾斜,八成来自 join key 或 group by key 的分布不均。最常见的三个来源:一是关联键存在大量 NULL 或空字符串,这些行在 hash 分区时会全部落到同一个 task;二是业务上有超级热点,比如某个默认店铺 ID 占了 40% 的订单;三是分区键选择不当,按天分区但某天的数据量是其他天的几十倍。

NULL 值的处理最简单也最有效:join 前先过滤,或者给 NULL 赋一个随机后缀打散。具体做法是在关联前把空值替换成concat('null_', cast(rand() * 100 as int)),这样原本挤在一个分区的数据会被打散到 100 个分区里,join 不上的行本来也不影响结果。这个技巧我用了很多年,几乎零成本。

热点 key 的处理要看它是否必须精确关联。如果热点维表可以广播,那直接 broadcast 掉,倾斜问题消失;如果不能广播,就要做“热点分离”:把热点 key 的行单独抽出来处理,剩下的走正常 join,最后 union。分离阈值通过前面的 TopN 采样确定,比如取累计占比超过 30% 的前若干个 key。

还有一个偏门但很有效的做法:如果倾斜发生在 aggregation 而不是 join,可以先用两阶段聚合,第一阶段给 key 加随机前缀做局部聚合,第二阶段去掉前缀做全局聚合。代价是多一次 shuffle,收益是把一个巨型 task 变成上千个正常 task。

还有一点:Spark 3.x 的 AQE skew join 处理(spark.sql.adaptive.skewJoin.enabled,默认开启)能自动拆分过大分区,但它的判定依赖skewedPartitionFactor(默认 5 倍中位数)和skewedPartitionThresholdInBytes(默认 256MB)。如果倾斜分区的绝对大小没到 256MB,或者倍数没到 5 倍,它不会动手。所以 AQE 不是万能药,参数要按实际分布调。

4.2 小文件与分区爆炸:问题往往出在 Hive 表那一侧

我在排查这类 OOM 时,有相当一部分根因不在 Spark 作业本身,而在要读的那张 Hive 表。典型症状是:spark.sql.files.maxPartitionBytes设的 128MB,但实际 task 数远多于理论值,且每个 task 处理的数据量很小、元数据开销很大。原因是表底下有几万个几十 KB 的小文件,Spark 按文件切分时每个文件至少一个 task,文件太小反而导致 task 数爆炸。

更麻烦的是动态分区写入。INSERT OVERWRITE ... PARTITION(dt)如果没开spark.sql.sources.partitionOverwriteMode=dynamic,或者分区基数特别大(比如按小时乘地域分区,一天几万个分区),每次写入都会产生海量小文件。写的时候不觉得,下次读的时候就开始还债。

治理手段我一般按这个顺序来:先用前面说的文件统计脚本找出小文件聚集的分区,然后对这些分区做一次合并重写。合并可以用 Spark 读进来repartition后覆盖写,也可以用 Hive 的ALTER TABLE ... CONCAT(仅 ORC)。合并时注意目标文件大小,我一般设spark.sql.files.maxRecordsPerFile或者用repartition(n)控制输出文件数,n 按分区总大小 / 256MB算。

注意:合并小文件是个重写操作,会消耗集群资源和产生大量临时数据,务必挑低峰期做,并且先在单个分区上验证,别一上来就全表重写。

长期治理则要改写入端:开启 AQE 的coalescePartitions,让写入前自动合并分区;对分区基数做评估,超过一万的分区设计要重新考虑;文件格式统一到 Parquet 或 ORC,别留 TextFile 混在里面。

4.3 broadcast join 的广播阈值:一把双刃剑

spark.sql.autoBroadcastJoinThreshold默认 10MB,很多团队为了加速小表 join 会把它调到 100MB 甚至 1GB。这么做在表真的很小时效果拔群,但一旦统计信息失真,Spark 把一张实际 2GB 的表当成 50MB 广播出去,driver 端做 collect 时就崩了,或者 executor 端每个 task 都持有一份副本,堆直接被撑爆——这正是GC overhead limit exceeded的经典成因之一。

判断广播是否成了负担,看 Spark UI 的 SQL 页面里 join 节点是否标记为BroadcastHashJoin,再看 Executor 页面的 Storage Memory 里 broadcast block 的大小总和。如果单个 broadcast 超过 500MB,且 executor 有几十个,那总占用就是几十 GB,非常恐怖。

我的做法是:广播阈值保守设在 30MB 到 50MB,配合spark.sql.adaptive.autoBroadcastJoinThreshold让 AQE 在运行时根据实际 shuffle 统计动态调整。同时给广播加一道保险:在 SQL 里对小表显式加/*+ BROADCAST(t) */提示,大表绝不加,避免优化器误判。如果确实需要广播一张中等大小的维表,可以考虑用broadcast()函数但先做列裁剪,只保留 join 需要的字段,往往能砍掉一半体积。

顺带提一句,广播变量在 executor 上是常驻内存且不可驱逐的,这是它跟 cache 最大的区别。所以“能不能广播”的判据不是“表大不大”,而是“表大小 × executor 数”这个总量扛不扛得住。

4.4 driver 端:collect、toPandas、show 这几个隐形杀手

有一类 OOM 特别冤:executor 跑得好好的,driver 突然挂了。追根溯源,多半是df.collect()把几百万行拉回 driver,或者toPandas()把整个结果集转成 pandas DataFrame。pandas 的内存效率比 Spark 的Row还差,字符串列默认是object类型,一个 100 万行 20 列的结果集,Spark 端可能只占 200MB,转到 pandas 后能干到 3GB 以上。

show()也容易踩坑。show()默认只取 20 行,看起来无害,但如果你写了df.show(1000000),或者df.orderBy(...).show(),前者会把百万行拉到 driver 再截断显示,后者会触发一次全局排序,排序数据全压在 driver 的单个 task 上。

规避原则很朴素:driver 端永远只处理“元信息”级别的小数据,比如count()、first()、take(100),绝不collect()全量。确实需要导出大结果时,走df.write直接落 HDFS 或者数据库,别经过 driver 中转。如果必须看数据分布,用approxQuantile、summary()、或者采样后toPandas()。

我在项目里立过一条规矩:代码 review 时看到collect()就要问一句“这个集合的上界是多少”。没有明确上界的,一律改成写文件或者聚合后输出。这条规矩帮我们挡掉了相当一部分莫名其妙的 driver OOM。

4.5 序列化与对象膨胀:从 Row 到 UnsafeRow 的那道坎

Spark 内部对行的表示有两套:UnsafeRow和GenericRow。前者是二进制紧凑格式,字段连续存储,没有对象头开销;后者就是普通 Java 对象数组。当单行数据的序列化后大小超过 64KB 时,Spark 会从UnsafeRow退化成GenericRow路径,内存效率直线下降。触发这个退化的常见场景是超长字符串字段——比如把一整段 JSON 或者一篇文章正文塞进一个字段。

如果你的表里有这类字段,且查询里根本用不到它,第一件事就是列裁剪,select明确列出需要的字段,别用select *。这一条对内存的改善往往比调参明显得多,因为减少了序列化、反序列化和对象分配的全链路开销。我在一个宽表任务上做过对比:select *改成分字段后,单 task 的 GC 时间从 35% 降到 8%,任务总耗时降了四成。

序列化框架也要检查。spark.serializer默认是JavaSerializer,换成KryoSerializer后,shuffle 数据和广播变量的体积通常能降 30% 到 50%。但 Kryo 需要注册自定义类,否则会用全限定名做 key,反而让序列化体积变大。对于只用内置类型的作业,Kryo 几乎是零成本收益;对于有大量自定义 case class 的作业,记得配spark.kryo.registrationRequired=false并注册常用类。

UDF 是另一个膨胀点。Scala UDF 在 catalyst 里是黑盒,无法被优化器下推或折叠;Python UDF 还要经过 JVM 与 Python 进程之间的序列化管道,每一行都要来回搬运。如果一个 UDF 内部还持有了大集合(比如把维表加载成 map 做查找),那每个 task 都会持有一份副本。这种写法我见过不少,改进方式是改用 broadcast join 或者mapPartitions里做一次性加载。

5. 一套可以直接抄的调优模板

5.1 spark-submit 参数模板与逐项解释

下面这套是我在 10 节点、每节点 64 核 256GB 的形态下常用的模板,按前面算过的账本来的,你可以按比例缩放:

spark-submit \ --master yarn \ --deploy-mode cluster \ --num-executors 30 \ --executor-cores 5 \ --executor-memory 18g \ --driver-memory 8g \ --conf spark.executor.memoryOverhead=3g \ --conf spark.driver.memoryOverhead=1g \ --conf spark.memory.fraction=0.6 \ --conf spark.memory.storageFraction=0.5 \ --conf spark.serializer=org.apache.spark.serializer.KryoSerializer \ --conf spark.sql.shuffle.partitions=8000 \ --conf spark.sql.adaptive.enabled=true \ --conf spark.sql.adaptive.coalescePartitions.enabled=true \ --conf spark.sql.adaptive.advisoryPartitionSizeInBytes=256m \ --conf spark.sql.adaptive.skewJoin.enabled=true \ --conf spark.sql.adaptive.skewJoin.skewedPartitionFactor=3 \ --conf spark.sql.autoBroadcastJoinThreshold=32m \ --conf spark.sql.files.maxPartitionBytes=256m \ --conf spark.sql.files.openCostInBytes=8m \ --conf spark.reducer.maxSizeInFlight=96m \ --conf spark.shuffle.file.buffer=1m \ --conf spark.network.timeout=600s \ --conf spark.executor.heartbeatInterval=60s \ --conf spark.task.maxFailures=8 \ --conf spark.executor.extraJavaOptions="-XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:InitiatingHeapOccupancyPercent=45 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/spark-logs/gc-%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps" \ your_app.py

逐项说几个非默认项的理由。spark.reducer.maxSizeInFlight=96m:默认 48MB,意味着每个 reduce task 同时最多拉 48MB 数据,分区大了之后拉取轮次多、耗时长,适度提高能减少网络往返,但别超过 128MB,否则单个 task 的拉取缓冲区会挤占 execution 内存。spark.shuffle.file.buffer=1m:默认 32KB,提高到 1MB 能明显减少 map 端写 shuffle 文件的小 IO 次数,代价是每个 task 多占不到 1MB 内存,非常划算。

spark.sql.files.openCostInBytes=8m:这个参数告诉 Spark “打开一个文件的开销相当于读 8MB 数据”,它在计算如何打包文件到一个分区时用。小文件多的表把它调大,能让 Spark 把更多小文件打包进同一个分区,减少 task 数。默认是 4MB,我调到 8MB 是为了在文件很多但单个不大的场景下更激进地合并。

G1 的选择:对 18GB 这种较大的堆,G1 的停顿控制明显优于 CMS(CMS 在 JDK 14 已移除)。G1HeapRegionSize=16m是为了减少 humongous 对象的产生——G1 里超过 region 一半大小的对象会被当成 humongous 单独分配,容易造成碎片和提前触发 mixed GC。InitiatingHeapOccupancyPercent=45比默认 45 略调,让并发标记更早开始,避免后期 Full GC。如果你的 executor 堆小于 8GB,G1 的优势不明显,可以继续用默认的 Parallel Scavenge + Parallel Old,吞吐更好。

5.2 SQL 层的写法改造:比调参更有效的几招

同样是解决 GC overhead,改 SQL 的收益经常大于改参数。第一招是列裁剪,前面说过,不再重复。第二招是谓词下推要生效:Hive 表如果是 Parquet 或 ORC,过滤条件写在WHERE里能被下推到文件扫描层,但如果你把过滤逻辑包在 UDF 里,下推就失效了,全表扫描的后果是读进来的数据量翻几倍。检查下推是否生效,看 SQL 页面的Scan parquet节点的number of output rows和PushedFilters。

第三招是 join 前先聚合。两张大表 join 后再 group by,中间结果可能是输入的数倍;改成各自先做聚合缩小体积再 join,shuffle 数据量能降一个数量级。这个改写要保证语义等价,也就是聚合的维度包含 join key,或者聚合本身是幂等的。

第四招是避免distinct和orderBy滥用。全局distinct会强制一次全量 shuffle 加去重,orderBy会强制全局排序,两者在大数据量下都是内存黑洞。distinct通常可以用group by替代并配合预聚合,全局orderBy如果只是为了看 TopN,改成sortWithinPartitions加limit的写法能省掉大部分开销。

第五招,窗口函数要限定分区大小。row_number() over (partition by user_id order by ts)在 user_id 分布极不均时会生成超大分区,一个用户几百万条记录就会撑爆内存。这类场景要么先按时间窗口切分,要么改用group by加聚合函数实现等价逻辑。

5.3 Hive 表侧的治理:从源头减少读取压力

写入端的三条硬规矩:分区基数控制在合理范围、单文件目标 128MB 到 256MB、统一列式格式。这三条做到了,读取端的内存压力会小一半。

具体落地时,写入前先估算分区数:SELECT COUNT(DISTINCT 分区键) FROM 源表,如果超过一万,就要重新审视分区设计,考虑加粗粒度或者改用分桶。写文件数用repartition(分区数)或者 AQE 的coalescePartitions控制,别让spark.sql.shuffle.partitions的默认值决定输出文件数。

对于已经存在的小文件问题,除了重写合并,还有一个成本更低的过渡方案:在读取时用spark.sql.files.maxPartitionBytes和openCostInBytes让 Spark 尽量把多个小文件打进一个分区。这不解决存储侧的问题,但能止住当前的 OOM。

6. 常见问题速查表与踩坑记录

6.1 症状对照表:从现象直接跳到怀疑对象

症状表现最可能的原因优先验证手段
某个 Stage 少数 task 极慢、GC 时间超高数据倾斜UI 看 Shuffle Read Size 中位数与最大值之比
所有 task 均匀地慢、spill 巨大单分区过大、execution 内存不足看 Spill (Memory) 与分区大小
BroadcastHashJoin 节点后 OOM广播表过大、阈值设置过高看 Storage Memory 里 broadcast 总大小
driver 端报 OOM、executor 正常collect / toPandas / show 拉全量检查代码里的 driver 端收集操作
只在读 TextFile 表时报错行解析对象膨胀换列式格式或做列裁剪
加入 UDF 后开始 OOMUDF 持大集合、无法下推改 broadcast join 或 mapPartitions
任务写 Hive 时 OOM动态分区过多、输出小文件看分区基数和输出文件数
报错信息是 Container killedoverhead 不足而非堆不足调大 memoryOverhead,开堆外监控

这张表我在团队里贴在排查文档最上面,新人遇到问题先对号入座,能省掉大量试错时间。要注意的是它给的是“最可能”,不是“一定”,实际排查还是要按前面说的四层路径走一遍。

6.2 几个反直觉的坑,都是真金白银换来的

第一个坑:内存加到 32GB 反而更容易 OOM。原因是堆越大,GC 的扫描成本越高,G1 在大堆下的 mixed GC 周期变长,对象在堆里存活时间被拉长,晋升到老年代的比例反而上升。而且大堆的单个 executor 处理的数据更多,task 粒度更粗,倾斜的影响被放大。我的经验是单 executor 堆不要超过 24GB,超过了不如多起几个 executor。

第二个坑:spark.sql.shuffle.partitions 设得太大也会 OOM。分区太多,每个 task 的固定开销(序列化缓冲区、task 对象、shuffle writer 的 buffer)累加起来很可观,150 并发下每个 task 占 10MB 固定开销就是 1.5GB。更麻烦的是小分区多了之后,文件句柄和元数据也会膨胀。所以这个值要跟着数据量走,别一味求大。

第三个坑:开了 cache 但忘了 unpersist。长生命周期的 SparkSession 里,多次复用的 DataFrame 如果被 cache 且没有释放,storage 会一点点被吃满,直到挤占 execution 触发 GC overhead。我在一个流批混跑的任务里踩过这个坑,缓存的表在 batch 切换时没清理,跑了十几个批次后必挂。解决办法是给 cache 加上明确的生命周期管理,用完立刻unpersist()。

第四个坑:以为 GC overhead 一定是堆内问题。有一次排查了两天,最后发现是 Netty 的 direct memory 泄漏——某个自定义的 shuffle 插件在异常路径下没释放 buffer,堆外内存持续增长,触发了 JVM 的 direct memory 上限,报错却伪装成堆内 GC 问题。这类问题的识别方法是看 GC 日志里堆内使用率其实不高,但进程 RSS 远超-Xmx加上 overhead 的总和。

第五个坑:只看 executor 日志不看 AM 日志。YARN 模式下,AM 日志里有容器的完整生命周期信息,包括被 kill 的原因、退出码、以及资源申请失败的记录。我遇到过资源队列的 vcore 不够导致 executor 起不来、任务一直 pending 的情况,报错信息在 AM 里,executor 日志里什么都没有。

最后分享一个小习惯:每次调完参数,我都会把“改了什么、预期改善什么、实际指标变化”记在一个简单的表格里,用 Spark UI 的 Stage 耗时、GC 时间占比、spill 大小三个指标做前后对比。这个方法看起来笨,但半年下来积累的参数直觉比任何文档都可靠。因为集群形态、数据分布、SQL 写法三者组合起来的情况太多了,通用最优解不存在,能复用的只有你自己踩出来的那张对照表。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询