
java.lang.OutOfMemoryError: GC overhead limit exceeded这行字只要你在 Spark 上跑过 Hive 表大概率在日志尾巴上撞见过它。它不像Java heap space那样干脆利落地告诉你“堆满了”而是绕个弯子说“GC 忙活半天什么也没捞着”。很多人第一反应是把spark.executor.memory从 8g 加到 16g加完发现任务从跑 40 分钟挂掉变成跑 55 分钟挂掉问题一点没解决。这篇内容就是围绕 Spark 读写 Hive 时出现这个报错的完整排查链路展开的它到底是什么、为什么偏偏在 SparkHive 这个组合上高频出现、怎么在不盲目加内存的前提下把根因揪出来、以及一套我自己反复用过的参数与写法模板。适合已经能跑 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:NumberOfGCLogFiles5 -XX:GCLogFileSize50MJDK 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实践中我见过的高频“元凶”有这么几类[Bbyte 数组通常是序列化缓冲区或 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.useLegacyModefalse堆内大致分三块预留内存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 或者大量 cachespark.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.7GBspark.memory.fraction取 0.6统一内存约 10.6GB再按storageFraction0.5分成 Storage 5.3GB、Execution 5.3GB。5.3GB 分给 5 个 core每个 core 约 1.06GB 的 execution 内存这正好落在“每 core 1GB 左右”的经验区间内说明这组参数是自洽的。如果你把核数提到 8 核而内存不变每 core 只剩 660MBspill 会显著增加——这就是“加核反而变慢”的常见原因。再算并行度。假设要处理 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.enabledtrue让它自动合并小分区。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.enabledtrue做堆外缓存情况更复杂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 和选错的分区键SparkHive 场景下的倾斜八成来自 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.partitionOverwriteModedynamic或者分区基数特别大比如按小时乘地域分区一天几万个分区每次写入都会产生海量小文件。写的时候不觉得下次读的时候就开始还债。治理手段我一般按这个顺序来先用前面说的文件统计脚本找出小文件聚集的分区然后对这些分区做一次合并重写。合并可以用 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.registrationRequiredfalse并注册常用类。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.memoryOverhead3g \ --conf spark.driver.memoryOverhead1g \ --conf spark.memory.fraction0.6 \ --conf spark.memory.storageFraction0.5 \ --conf spark.serializerorg.apache.spark.serializer.KryoSerializer \ --conf spark.sql.shuffle.partitions8000 \ --conf spark.sql.adaptive.enabledtrue \ --conf spark.sql.adaptive.coalescePartitions.enabledtrue \ --conf spark.sql.adaptive.advisoryPartitionSizeInBytes256m \ --conf spark.sql.adaptive.skewJoin.enabledtrue \ --conf spark.sql.adaptive.skewJoin.skewedPartitionFactor3 \ --conf spark.sql.autoBroadcastJoinThreshold32m \ --conf spark.sql.files.maxPartitionBytes256m \ --conf spark.sql.files.openCostInBytes8m \ --conf spark.reducer.maxSizeInFlight96m \ --conf spark.shuffle.file.buffer1m \ --conf spark.network.timeout600s \ --conf spark.executor.heartbeatInterval60s \ --conf spark.task.maxFailures8 \ --conf spark.executor.extraJavaOptions-XX:UseG1GC -XX:G1HeapRegionSize16m -XX:InitiatingHeapOccupancyPercent45 -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/spark-logs/gc-%p.log -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dumps \ your_app.py逐项说几个非默认项的理由。spark.reducer.maxSizeInFlight96m默认 48MB意味着每个 reduce task 同时最多拉 48MB 数据分区大了之后拉取轮次多、耗时长适度提高能减少网络往返但别超过 128MB否则单个 task 的拉取缓冲区会挤占 execution 内存。spark.shuffle.file.buffer1m默认 32KB提高到 1MB 能明显减少 map 端写 shuffle 文件的小 IO 次数代价是每个 task 多占不到 1MB 内存非常划算。spark.sql.files.openCostInBytes8m这个参数告诉 Spark “打开一个文件的开销相当于读 8MB 数据”它在计算如何打包文件到一个分区时用。小文件多的表把它调大能让 Spark 把更多小文件打包进同一个分区减少 task 数。默认是 4MB我调到 8MB 是为了在文件很多但单个不大的场景下更激进地合并。G1 的选择对 18GB 这种较大的堆G1 的停顿控制明显优于 CMSCMS 在 JDK 14 已移除。G1HeapRegionSize16m是为了减少 humongous 对象的产生——G1 里超过 region 一半大小的对象会被当成 humongous 单独分配容易造成碎片和提前触发 mixed GC。InitiatingHeapOccupancyPercent45比默认 45 略调让并发标记更早开始避免后期 Full GC。如果你的 executor 堆小于 8GBG1 的优势不明显可以继续用默认的 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中间结果可能是输入的数倍改成各自先做聚合缩小体积再 joinshuffle 数据量能降一个数量级。这个改写要保证语义等价也就是聚合的维度包含 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 写法三者组合起来的情况太多了通用最优解不存在能复用的只有你自己踩出来的那张对照表。