做大数据加工这些年,我见过的任务跑不动,大部分不是代码写得烂,而是内存计算框架的资源分配压根没给对。调内存参数这件事,表面上看是改几个配置项,实际上它牵扯到框架怎么划分堆内堆外、怎么平衡执行内存和存储内存、怎么避免和操作系统其他进程抢资源,一环扣一环。这篇文章就围绕大数据领域的内存计算,专门聊资源分配怎么落到实处,让数据处理任务既跑得快,又不容易把集群搞挂。适合自己搭过 Spark、Flink,或者正在被各类 OOM、Full GC 折磨的同学参考。
1. 内存计算为什么是数据处理的“胜负手”
1.1 磁盘 I/O 的流血教训
传统 MapReduce 时代处理一个离线任务,最直观的感受就是慢,而且慢得让人没脾气。一个几 TB 的关联查询,中间结果要反复落到磁盘,Map 端写完,Reduce 端再读,一次 join 恨不得让磁盘搬好几趟数据。磁盘顺序读写虽然还能撑一撑,但随机读写、大量小文件反复打开关闭,直接把吞吐拉到谷底。我见过一个日调度任务,原本只要十几分钟就能跑完的逻辑,改成磁盘落地的老方案后跑了快三个小时,差距就摆在那,不是算法变聪明了,而是数据根本没有必要反复进出磁盘。
内存计算的切入点很直接:能留在内存中间结果、Shuffle 数据、状态数据,就尽量减少落盘。但注意,这不是说内存计算就完全不落盘,而是把“能复用、高频复用、多轮迭代”的数据尽量放在内存里。对于迭代式计算、交互式查询、流处理状态维护,这类场景的收益最为明显。数据不需要每次计算都从磁盘翻出来,内存带宽和延迟的优势被真正用起来,整个任务的耗时就能缩短一个量级。
1.2 内存计算并不是“把数据塞进内存”
很多新手有个误解,以为内存计算就是想法子把所有数据都加载到内存里,内存不够就加内存。真正做起来完全不是这回事。内存计算的本质是计算模型的改变:算子在流水线中尽量不落盘,同一份数据在内存中被多个计算阶段复用,通过缓存、广播、列式存储等手段减少重复 I/O。数据能不能全放内存,取决于业务需求和数据规模,而不是拍脑袋决定。
比如 Spark 的 RDD/DataFrame 缓存,适合反复使用的中间结果;但如果你把一份每天新增几个 G 的全量明细无脑 cache,不仅占用大量存储内存,还可能挤压执行内存,反而拖垮后续 Shuffle。内存计算要解决的是“如何让有限的内存发挥最大计算效率”,而不是“把无限的数据塞进内存”。这也是资源分配优化的核心矛盾所在。
1.3 为什么资源分配这一环最容易被低估
同样一套代码,不同人配出来的运行表现可能差好几倍。问题往往不出在 SQL 或者 DataFrame 的写法上,而是资源分配阶段就没把内存模型吃透。比如同一个 Executor 上,执行内存和存储内存怎么切,堆外开了多大,JVM 老年代够不够用,这些参数决定了任务在高峰期是顺滑跑完,还是频繁 Full GC、疯狂溢写磁盘,甚至直接 OOM 被 Container 干掉。
我在给某套集群做优化的时候发现,任务失败记录里有一大半是“Container killed by YARN for exceeding memory limits”,但业务方第一反应是“加内存”。加完以后依然失败,因为物理内存超限的根因往往是堆外内存、网络缓冲、内存开销这几项没算进去,单纯调整堆内参数根本没解决整体预算超限的问题。资源分配最容易被低估的原因就在这里:它不是一个配置项的事,而是一整套内存模型下的预算测算。
2. 给内存算账:四个关键维度
2.1 总内存预算:别把物理内存掏空
在分布式计算里,每个节点上跑的不只是计算进程,还有操作系统、监控 Agent、HDFS 的 DataNode、可能存在的 NodeManager 等常驻进程。如果给 Executor 或 TaskManager 规划的内存总和逼近物理内存上限,系统就会开始疯狂使用 Swap,性能瞬间断崖式下跌。我一般会在节点内存预算里预留 10%~20% 给系统和其他服务,具体比例取决于节点角色和混合部署情况。
比如一台 128G 物理内存的数据节点,我通常会把计算框架可用的总内存控制在 100G~110G 左右,剩下的留给 Page Cache、系统内核和周边进程。有些同学看到 128G 就恨不得配出 120G 给 Executor,结果跑大任务时节点直接卡死,SSH 都连不上去。内存计算追求的是“用满”而不是“用绝”,留出余量才能让 JVM、堆外内存和操作系统的 Page Cache 彼此不打架。
2.2 堆内与堆外:必须想清楚的“外快钱”
堆内内存是 JVM 管理的那部分,对象创建、回收都走 GC。堆外内存则绕过 JVM 堆,直接使用操作系统内存,适用于大块缓冲区、DirectByteBuffer、以及某些框架的原生数据结构。为什么不能让所有东西都放堆内?因为堆内几十 GB 的 JVM 很容易让 GC 变成灾难,Full GC 一次卡顿好几秒,吞吐和延迟都让人自闭。堆外内存可以减少 GC 压力,但代价是需要自己管理生命周期,用不好更容易出内存泄漏。
在 Spark 里,Executor 的物理内存大致等于spark.executor.memory加上spark.executor.memoryOverhead。后者就是堆外开销,默认大概是堆内内存的 10%,下限 384M。很多线上任务跑 Shuffle、访问 HDFS、使用某些原生库时,堆外内存用量很容易超出默认值。我通常会在 Shuffle 数据量大或者启用 Kyro 之外的原生序列化时,把 overhead 调高到堆内的 15%~25%,而不是等 Container 被 YARN 杀掉了再补课。
2.3 执行内存与存储内存的跷跷板
以 Spark 的统一内存管理为例,堆内有一个统一内存池,由spark.memory.fraction划定,默认是 0.6。也就是说,Executor 堆内 24G 的话,统一内存大约 14.4G。剩余 40% 留给 JVM 自身的结构、用户代码、以及其他不便纳入统一管理的内存。统一内存内部又分执行内存和存储内存,spark.memory.storageFraction默认 0.5,它表示存储内存从统一内存中分到的保底份额。
执行内存用于 Shuffle、join、aggregation 等操作,存储内存用于缓存 RDD/DataFrame、广播变量等。两者可以互相抢占,但执行内存在被存储内存占用时可以强行驱逐存储数据,反过来存储内存不能驱逐执行内存。所以如果任务里 Shuffle 很重,我会把 storageFraction 调低一些,比如 0.3,给执行内存留更大的腾挪空间;如果任务要大量缓存复用中间结果,就调高到 0.6。这个比例没有银弹,完全取决于你的任务画像。
2.4 JVM 本身也要留口粮
很多人在调参时只盯着spark.executor.memory,却忽略了 JVM 自身需要的元空间、线程栈、字符串常量池这些内存。如果堆内内存设置过于贴近上限,统一内存还没开始用,老年代就先溢出了。实际上,默认的 0.6 这个比例就是帮 JVM 留出了 40% 的余地,但如果你在代码里创建了大量对象驻留在堆内,或者用了大量自定义类加载器,这 40% 也不一定够用。
我曾经遇到过一个任务,代码里频繁用new创建临时对象,并且缓存了一些没有被回收的大集合,结果 Executor 频繁 Full GC,任务时好时坏。后来通过调整代码,把这些大集合改成按需加载,同时把spark.memory.fraction降到 0.5,情况立刻好转。调 JVM 内存不是只看计算框架的参数,也要结合代码行为的实际情况,把用户代码、第三方库的驻留内存考虑进去。
3. 实操:Spark 内存资源分配的完整流程
3.1 起步时的保守配置
我给新任务配置内存时,不会一上来就追求极限吞吐,而是先给一套保守参数确认任务能稳定跑通。假设一个作业节点是 8 核 32G 物理内存,起步配置可以这样给。
spark.executor.cores=4 spark.executor.memory=12g spark.executor.memoryOverhead=2g spark.memory.fraction=0.6 spark.memory.storageFraction=0.5 spark.sql.shuffle.partitions=200这样单 Executor 物理内存约 14G,一台节点可以放两个 Executor,CPU 资源也能被相对均匀地分配。先跑一遍小规模数据,观察 Spark UI 里的 Shuffle Spill、GC 时间、执行内存峰值,再决定要不要加大内存、提高并行度。一上来就把spark.executor.memory配成 24G,任务确实可能会快,但一旦 Shuffle 量上来,GC 停顿和溢写风险也会同步放大。
3.2 用一道算术题拆解 Executor 内存划分
为了让大家真正理解这几个参数的关系,我举个例子:假设我们把 Executor 内存设为 24G,overhead 设为 4G,spark.memory.fraction默认 0.6,spark.memory.storageFraction默认 0.5。
先算统一内存:24G × 0.6 = 14.4G。这个 14.4G 由执行内存和存储内存共享。存储内存保底为 14.4G × 0.5 = 7.2G,执行内存理论上最多可以占满整个统一内存,但在存储内存没有数据需要缓存时,执行内存可以尽情使用。反过来,如果存储内存占了 7G,执行内存就只能用剩下的大约 7.4G,但在压力大的时候执行内存可以驱逐存储内存的数据。
再来看 overhead 的 4G。这部分不会出现在 JVM 堆内,而是用于 JVM 的 Direct Memory、线程栈、以及 Spark 内部的一些原生内存开销。为什么我要给比默认值更高的 4G?因为任务里有大量 Shuffle 和 HDFS 读写,这些操作涉及 DirectBuffer,默认的 2.4G 在实际负载下很容易触顶,索性多留一些余量。整体上,单个 Executor 的实际占用是 24G + 4G = 28G,这也是容器调度时需要向 YARN 申请的物理内存预算。
3.3 并行度与内存的配合
内存分配不是孤立的事情,它和并行度密切相关。同样一个 Executor,4 个核和 8 个核,处理同样的数据量,内存压力和 GC 表现完全不同。核数多意味着并发执行的 Task 多,每个 Task 需要的执行内存、聚合缓冲区都会放大。如果你给 Executor 配了 8 个核却只有 16G 堆内,每个 Task 分到的内存大约 2G,遇到大 Shuffle 很容易溢写或者 OOM。
我的建议是,先确定 Executor 的核数,再根据单 Task 的内存需求反推 Executor 内存。正常情况下单个 Shuffle 任务至少要有 1G~2G 的可用内存,复杂 join、聚合操作则要更多。一个 8 核 Executor,堆内内存最好别低于 20G,同时要把 overhead 也算进去。如果因为节点资源限制给不了那么大堆,宁可减少 Executor 的核数,让每个 Task 吃得更饱,也不要为了跑满 CPU 让每个 Task 饿肚子。
3.4 用 Spark UI 验证分配效果
参数配完不是就结束了,还要通过 Spark UI 的 Executor 页签观察内存使用情况。重点看两个指标:一个是 Storage Memory 的当前使用量和 RDD/DataFrame 缓存大小;另一个是 Shuffle Spill 的磁盘溢写量,包括 Spill (memory) 和 Spill (disk)。如果 Spill (disk) 很大,说明执行内存不够用,结果往磁盘吐了;如果 Shuffle 阶段频繁 GC,且 GC 时间占总运行时间的比例超过 5%,就要考虑调整内存比例或优化代码逻辑。
我还习惯看 Executor 的峰值内存(Peak JVM Memory)和 Direct Memory 相关监控。正常情况下,每个 Executor 的物理内存占用不会超过spark.executor.memory + spark.executor.memoryOverhead。一旦接近上限,任务很容易被 Container 杀掉。发现这类征兆,我会优先检查是不是 Shuffle 数据量异常、广播变量过大、或者某些代码把大量数据 collect 回 Driver,而不是盲目加内存。
4. 扩展到 Flink 的资源分配要点
4.1 TaskManager 内存划分
Flink 的内存模型和 Spark 不同,TaskManager 的进程内存包含 JVM 堆、托管内存(Managed Memory)、网络缓冲、框架内存、以及 JVM 自身开销。taskmanager.memory.process.size定义了整个进程的总内存,Flink 会根据这个值自动推导各区域的分配。实际调优时,我先看任务的状态大小和计算复杂度,再决定堆内和托管内存的比例。
和 Spark 的一个明显差异是,Flink 的托管内存主要用于排序、哈希表、状态后端等,默认taskmanager.memory.managed.fraction是 0.4。如果任务使用了 RocksDB 状态后端,状态数据其实不在 JVM 堆内,而在托管内存中,这时需要保证托管内存足够大,不然状态读写会频繁落到磁盘,性能立刻下滑。反之,如果任务主要是流式处理、状态不大,托管内存配 30% 就够用了。
4.2 状态内存与网络缓冲
流处理任务的核心是状态,状态内存给多少直接决定大状态作业能否稳定运行。RocksDB 状态后端会使用堆外内存,我需要重点关注taskmanager.memory.managed.size或taskmanager.memory.managed.fraction,并预留足够空间给 RocksDB 的 Block Cache、Write Buffer 等。如果状态增长快、并发高,托管内存不足会导致本地 RocksDB 频繁合并,严重时还会抛OutOfMemoryError。
网络缓冲内存则用于 Flink 节点间的数据交换,默认值为taskmanager.memory.network.size的 64MB。在数据 Shuffle 量大的 Window 作业,或者大量数据在节点间交叉传输时,64MB 往往不够用。调大网络缓冲可以减少反压的发生频率,但也要注意它不是越大越好,因为网络缓冲也是从进程总内存里切出来的,会给其他区域带来压力。
4.3 与反压的关系
之前帮人排查一个实时数仓任务,现象是 Source 端消费 Kafka 速率忽高忽低,Sink 端写 HBase 经常超时。看指标发现 TaskManager 的网络缓冲经常打满,背压一直往上顶。进一步分析是作业里有大窗口 join,中间数据量暴涨,而网络缓冲和算子内部缓冲池设置偏小。后来调整了网络缓冲内存,同时把窗口算子的并行度提高,反压才降下来。
反压是流处理系统的信号灯,它本质上是某个算子处理速度跟不上,让上游不再超速发送。但很多时候根因正是资源分配没跟上。内存分配合理,算子就能多缓冲一些数据,平滑流量抖动;内存分配不足,数据只能堆积、超时、失败。所以排查反压问题,一定不要只盯着业务逻辑,还要把 TaskManager 内存曲线拉出来看,特别是网络缓冲和托管内存的使用情况。
5. 常见问题与排查技巧实录
5.1 Executor OOM:别急着加内存
Spark 任务最常见的死法是 Executor 进程被 YARN 杀掉,日志里通常写着 physical memory usage 超限。很多人的第一反应是把spark.executor.memory往上提,但我遇到的大部分案例,根因都是 overhead 不足、Shuffle 数据异常、或者某处代码把不该 collect 的全量数据拉了回来。真正的做法是分几步排查:先在 Spark UI 看每个 Executor 的峰值内存是否符合预算,再分析 GC 时间和溢写情况,最后确认是不是任务的数据量超过了预期。
有一个典型例子:某任务每天全量刷新一张大表,我查日志发现每个 Executor 都以Container killed结束,但代码逻辑本身不复杂。打开 UI 一看,输入数据量比平时涨了五倍,Shuffle 溢写量大得离谱。这时候单纯加 Executor 内存只是缓解,真正该做的是优化关联逻辑、增加分区数、或提前过滤掉无用字段。加内存不是不行,但要分清到底是“预算不够”还是“效率太低”。
5.2 Driver OOM:通常和 collect 有关
Driver 端的问题也经常出现,尤其是代码里写了collect或take把所有结果拉回 Driver 再处理。小数据没问题,数据量一大,Driver 堆就被打爆了。我在一个报表任务里踩过这个坑,DataFrame 聚合结果只有几十万行,但每个 Executor 返回的中间对象体积很大,collect到 Driver 后 JVM 直接 Full GC,最后整诊断出来才发现是某字段里面存了超大字符串。
应对方案很简单:尽量不要一次性把全量结果 collect 回 Driver,改成用saveAsTable或者写入外部存储,让后续逻辑在分布式环境下处理。如果确实需要拉回一些小结果集,也要估算它的大小,并把spark.driver.memory调到一个安全水位。Driver 内存不是越大越好,但至少不能让它成为任务的瓶颈点。
5.3 频繁 Full GC 与 GC 时间过长
GC 问题很难一眼定位,但可以从监控曲线里看出端倪。Spark UI 的 Executor 页签能看到每个 Executor 的 GC Time,如果 GC 总时长占运行时长比例过高,说明堆内存使用压力大,或者代码里创建了大量生命周期长的对象。先用jstat或者 YARN 的监控面板看老年代占用,如果老年代持续上升,大概率是缓存、静态集合、或者第三方库在堆里留下了大量对象。
处理思路有三条:一是调整内存比例,给用户代码留更多余地;二是优化代码,减少对象驻留;三是把大对象挪到堆外,或者用序列化方式存储。有一回我把一个任务里的map算子中反复创建的 Java 对象改成复用同一个实例,GC 时间直接下降了 40%,效果立竿见影。所以说,面对 GC 问题,参数调整只是兜底,代码层面的瘦身才是真正的止疼药。
5.4 数据倾斜其实是资源分配失衡的放大器
数据倾斜不是一个纯粹的参数问题,但它会让资源分配问题变得更刺眼。两个表 join 的时候,某个 key 占了绝大部分数据,少数几个 Task 要处理的数据量是其他 Task 的几十倍。这些 Task 所在 Executor 的内存会被快速打满,触发溢写甚至 OOM,而其他 Executor 却闲得发慌。
处理倾斜要从数据本身下手:加盐打散、广播小表、倾斜 key 单独处理,这些手段可以在代码层面缓解。但我发现很多人做完这些操作之后,忘了回头检查内存参数是否还需要重新调整。因为加盐和单独处理会改变 Shuffle 分布,原本给执行内存的预算比例可能就不再合适了。完整流程应该是:先定位倾斜 key,再优化数据分发方式,最后根据新的 Shuffle 特征微调内存分配。
5.5 一份我自己的排查顺序清单
踩过很多坑之后,我整理出这样一套排查步骤。第一步,看任务失败时间点和日志报错,确认是 OOM、GC 还是被 YARN 杀。第二步,打开 Spark UI 或 Flink UI,观察 Executor/TaskManager 的内存曲线、GC 时间、溢写数据量。第三步,分析输入数据大小和 Shuffle 规模,判断是数据量异常还是计算模型异常。第四步,检查代码里是否有 collect、缓存、广播变量失控等情况。第五步,再回头调整参数,一次只改一个变量,避免多参数联动无法判断效果。
这套清单看着简单,但能避免很多无头苍蝇式的调试。有人一看到 OOM 就先把内存调大,跑一次还是失败,然后又调更大,最后节点资源耗尽,整个集群被拖垮。与其这样,不如先用数据说话,把内存的实际使用情况摸清楚,再做有针对性的调整。
6. 一点后话
做资源分配优化这些年,我自己最大的体会是:内存计算框架的调优不是把参数堆到最大值,而是理解内存模型之后,做一套合理的预算分配。每次接到新的任务,我会先确认它的数据规模、计算特征、Shuffle 程度、状态大小,然后才动参数。跑完一遍之后,再用监控数据验证之前的假设,而不是让任务蒙着眼跑下去。
最后再分享一个小技巧:如果你负责的集群上有很多不同类型的作业,建议给它们分别建立内存参数模板,比如离线大 Shuffle 模板、迭代计算模板、实时流处理模板。切换模板比每次手动改配置要省心得多,也能减少因为参数混用导致的隐性故障。内存计算这块门道不算浅,但只要你把内存模型和任务特征这条主线抓住,大部分资源分配问题都能顺藤摸瓜解决。