2023年冬天某个凌晨,我负责的 Flink Session 集群突然炸了:二十多个实时作业同时重启,重启之后再挂,集群状态像在跳机械舞。监控面板上一看,CPU 和网络都平静得不像话,TaskManager 的堆内存也健康,唯独 JobManager 进程在半小时前消失过一次,然后被资源管理器拉起来,跑了几分钟又没了。
最后捞 Yarn 日志,倒数几行写着 java.lang.OutOfMemoryError: Metaspace。那一刻我才意识到,在实时计算集群里,最容易被当作“不用管”的角色,恰恰是那个很少出现在内存告警面板上的 JobManager。很多人觉得它只是控制面,不搬数据、不存状态,内存给个 1GB 就够意思了,但现实是:当 Flink 作业数量一多、作业拓扑一复杂、上线下线一频繁,控制面会先于所有跑数据的节点倒下,而且是整个 Session 集群一起陪葬。
这篇文章不绕弯子,直接聊 JobManager 内存配置。内容包括 JobManager 到底把内存花在了哪里、Flink 1.11 之后的内存模型怎么拆、Session 和 Application 模式分别怎么给配置、怎么从一次 Metaspace OOM 里完整排查出根因,最后是一份我压过多次生产环境后总结的避坑清单。
1. 为什么“控制面”也会先挂:JobManager 的真实内存压力在哪
1.1 一场典型的“无声下线”故障复盘
那次故障的 Session 集群里有 40 多个作业,提交方来自不同业务组。JobManager 运行在 Yarn 的一个 4GB 容器里,-Xmx 只有 1GB,Metaspace 上限更低。最开始阶段一切正常,但随着业务组不断上线新作业、下线旧作业,集群上的作业存量慢慢堆到 40 个以上。
故障当天的导火索是某个数据团队一次性提交了 6 个 SQL 作业。从监控上看,作业提交后 JobManager 的 CPU 瞬间打满,三四秒后进程直接消失。这种场景我后来见过很多次:JobManager 的 OOM 往往不会像 TaskManager 那样先反压、再告警、最后才崩溃,它更像是“本来还在正常呼吸,下一秒直接没了”。如果告警只监控了 Yarn 的 App 状态,你可能只看到作业重启,根本不会第一时间联想到是 JobManager 的内存配置问题。
1.2 不搬数据不等于不吃内存
这里有个普遍误解:TaskManager 管数据、管状态、管网络 Buffer,所以它应该吃内存;JobManager 只是做调度和协调,内存压力应该很小。这个判断在单作业、小并行度的场景下勉强成立,但在多租户 Session 集群里完全不成立。
JobManager 虽然不搬业务数据,但它要做几件非常吃内存的事。第一,接收作业提交后,要把 JobGraph 转成 ExecutionGraph。这个转换过程会把一个算子的每个并行子任务都展开成独立的 Runtime 对象。一个只有几十个算子的作业,在 ExecutionGraph 层可能展开成几万个对象,对象之间还有复杂的父子引用关系。Session 集群里几十个作业的 ExecutionGraph 同时驻留在内存中,堆内存的消耗很可观。
第二,JobManager 负责所有任务的调度。每一次 failover、重启、重新调度,都会在堆上创建大量的调度请求、Slot 分配记录、部署描述对象。如果有多个作业同时 failover,短时间内的对象叠加十分惊人。
第三,CheckpointCoordinator 也在 JobManager 里。每个作业的 Checkpoint 进度、参与方列表、待确认的 Ack 状态都要在内存中维护。Sink 端写外部存储慢,或者反压导致 Checkpoint 迟迟无法完成,Pending 的 Checkpoint 会跟着堆积。
1.3 控制面内存的三个主要去向
我习惯把 JobManager 的内存去向总结成三个方向:
- 常驻作业元数据:JobGraph、ExecutionGraph、作业的 Class 信息、UDF 类加载器引用。这部分和“作业数量 × 作业复杂度”成正比,不随作业运行周期释放。
- 瞬时调度和协调对象:作业提交、任务部署、Checkpoint 协调、资源申请、RPC 消息处理。这部分和“作业重启频率、并发提交数量、TaskManager 节点规模”强相关。
- 类加载器和 JVM 元数据:每次提交一个新作业,如果带了不同的用户 JAR,JobManager 就需要创建对应的 ClassLoader 来加载用户代码。SQL 作业还会加载 Calcite 的元数据、表达式类。作业被下线后,如果 ClassLoader 被某些静态引用意外持有,Metaspace 就会泄漏。
理解这三部分来源后,再看 JobManager 的内存配置就清楚多了:不能只盯着堆大小,Metaspace 和堆外开销同样会导致进程死亡,而且它们在表面症状上很不一样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拆明白 JobManager 内存模型:Heap、Metaspace、Overhead 各管什么
2.1 JobManager 的内存分区和 TaskExecutor 有何不同
Flink 1.11 之后,TaskExecutor 的内存模型分得非常细:Framework Heap、Task Heap、Managed Memory、Network Memory、JVM Overhead、Metaspace。很多人下意识觉得 JobManager 也应该有类似的模块化配置,但实际并不是。
JobManager 的内存模型要简单得多,官方把它划分成三块:
- JVM Heap:堆内存,主要存放 ExecutionGraph、作业状态、调度器对象、Checkpoint 协调器对象。
- JVM Metaspace:JVM 的类元数据区,主要存放类定义、方法元数据、类加载器信息。作业频繁上线下线、加载大量用户 JAR 时,这里是主要压力点。
- JVM Overhead:留给 JVM 自身和 Native 代码的内存,包括线程栈、Direct Buffer、JNI 引用、JVM 内部数据结构等,不受 JVM 堆大小管控。
换句话说,JobManager 没有 TaskManager 那套 Managed Memory 和 Network Memory 的独立计算模块,这反而让很多人在配置时掉以轻心。TaskManager 的内存配置哪怕拍脑袋给个值,至少还有反压机制兜底;JobManager 的内存一旦不够,就是全局性故障。
2.2 核心配置项对应关系与检查清单
以下是 JobManager 内存相关的核心配置项,生产环境值得逐项确认:
| 配置项 | 作用区域 | 说明 |
|---|---|---|
jobmanager.memory.heap.size |
JVM Heap | JobManager 堆内存大小,决定能存多少作业的 ExecutionGraph 和调度状态 |
jobmanager.memory.process.size |
整个进程 | 从容器维度规划 JobManager 总内存时使用,包含堆、Metaspace、Overhead |
jobmanager.memory.jvm-metaspace.size |
JVM Metaspace | Metaspace 的初始与上限,多作业场景建议单独加大 |
jobmanager.memory.jvm-overhead.min |
JVM Overhead | Overhead 的下限 |
jobmanager.memory.jvm-overhead.max |
JVM Overhead | Overhead 的上限 |
jobmanager.memory.jvm-overhead.fraction |
JVM Overhead | Overhead 按比例分配时的计算系数 |
不同版本的 Flink 对默认值处理有差异,所以我不建议背任何“官方默认值”。最可靠的做法是:启动 JobManager 后访问 Web UI,找到 JobManager 的 Metrics 标签页,里面会直接显示当前进程的堆、Metaspace、Overhead 实际使用量。在压测或不压测的状态下各截一次图,比任何文档都更能说明问题。
2.3 配置推算逻辑:设了 Total 还是设了 Heap,结果不一样
一个常见的困惑是:JobManager 内存是通过 jobmanager.memory.process.size 统一指定好,还是分别指定 jobmanager.memory.heap.size、jobmanager.memory.jvm-metaspace.size、jobmanager.memory.jvm-overhead.*?
这两条路在 Flink 内部走的是不同推算逻辑。如果只设 jobmanager.memory.process.size,Flink 会根据 JVM Overhead 的比例和 Metaspace 的默认值反算出堆大小。如果只设 jobmanager.memory.heap.size,那 Flink 会在堆的基础上把 Overhead 和 Metaspace 按比例叠加成总进程内存。
在同一个配置文件里,尽量避免同时设置 process.size 和各个分项,否则容易把推算逻辑搞乱,结果和预期不一致。我自己在容器化部署时习惯用“总分项策略”:先根据实际需要确定 heap.size,再单独给 metaspace 和 overhead 留出明确空间,不依赖 Flink 的自动推算。这样更可控,出问题时也容易判断到底是哪个区域不够。
提示:Flink 官方文档对“同一组配置不要混着设”这一点说过很多次,但实践中还是经常看到有人今天加了
heap.size,明天又为了迁就容器限制加了process.size,最后内存区域互相挤压。配置变更后建议查看启动日志里的 Memory 配置段落,确认推算结果没有偏离预期。
3. 可直接抄的配置模板:Session 与 Application 模式分别怎么给内存
3.1 Session 集群配置示例与计算逻辑
运行在 Session 模式时,一份 JobManager 配置需要服务成百上千次作业提交,内存配置必须按“存量作业 + 最大并发提交峰值”来估,而不是按单个作业的规模来估。
我目前维护的一个中大规模 Session 集群,JobManager 的配置大致如下:
yaml复制jobmanager.memory.heap.size: 4096m
jobmanager.memory.jvm-metaspace.size: 1024m
jobmanager.memory.jvm-overhead.min: 512m
jobmanager.memory.jvm-overhead.max: 1024m
jobmanager.memory.jvm-overhead.fraction: 0.1
堆给 4GB,是因为这个集群的存量作业稳定在 60 个左右,所有 ExecutionGraph 全部驻留在堆上,1GB 级别的堆肯定不够。Metaspace 给 1GB,是考虑到高频 SQL 作业提交会反复加载 Calcite 和用户 JAR 类。Overhead 留给 512MB 到 1GB 区间,给线程栈和 Netty 等 Native 开销留余量。
如果只从 process.size 角度反算,上面的配置算下来总进程内存差不多在 6GB 上下。Yarn 容器资源、Kubernetes Pod 的 memory Limit 都要按这个总值申请,不能只看堆大小。
3.2 单作业 Application 模式怎么收敛
如果作业跑在 Application 或 Per-Job 模式,JobManager 只需服务当前一个作业,配置可以收敛很多。单个中等规模作业(并行度几百到一两千)的 JobManager 堆给 1GB 到 2GB 通常够用,Metaspace 给 512MB 左右一般也能覆盖。
yaml复制jobmanager.memory.heap.size: 2048m
jobmanager.memory.jvm-metaspace.size: 512m
jobmanager.memory.jvm-overhead.min: 256m
jobmanager.memory.jvm-overhead.max: 512m
要注意一个例外:如果单个作业的拓扑非常庞大,比如几千个算子、上万并行度,那么即使只有这一个作业,JobManager 堆也可能需要在 8GB 以上。判断逻辑很简单:ExecutionGraph 的展开规模和“算子数 × 并行度”正相关,而 Application 模式的隔离只能避免其他作业的干扰,不能减少本作业自身的元数据开销。
3.3 容器资源与 Flink 配置对不上,系统会从“内存超卖”演变成“进程被杀”
这个话题值得单独拎出来讲。在 Yarn 上运行时,Flink 会按照 JobManager 内存配置去申请容器资源,整体还算自动。但在 Kubernetes 上经常出现一个问题:容器 Limit 写的是 4Gi,但 Flink 配置里只写了 jobmanager.memory.heap.size: 4096m,忘了加 Metaspace 和 Overhead。最终进程实际需要 5GB 以上,容器在达到 Limit 时被内核直接 Kill,表现是“进程消失,日志里不一定有 OOM 字样”,往往要靠看 K8s 事件里的 OOMKilled 才能发现。
正确的做法是:在 K8s 里显式把 JobManager 的 Pod Request/Limit 设成和 Flink 推算出的总进程内存一致。如果你用的是 Flink Kubernetes Operator,可以直接通过资源配置字段管理,但即便如此,也必须在提交前确认 jobmanager.memory.process.size 与容器内存限制之间的关系。
3.4 大作业、多作业场景下的参数调整方向
没有一套模板能适配所有场景,我一般会根据以下信号做定向调整:
- 集群作业数量增长:优先加
jobmanager.memory.heap.size。每个作业的 ExecutionGraph 一旦被提交,会一直占着堆空间直到作业结束。作业数翻倍时,堆压力大概率也翻倍。 - 作业频繁上线/下线,且 UDF 很多:重点观察
jvm-metaspace.size。如果 Metaspace 使用率持续走高而且 GC 无法回收,说明大概率有类加载器泄漏,单纯的扩容只是推迟爆炸时间。 - 节点规模大、TaskManager 数量多:加大 Overhead 范围。每新增一个 TaskManager,JobManager 就需要对应维护 RPC 连接、心跳和 Slot 状态,这些很多走的是堆外和线程资源。
4. 一次 JobManager OOM 的完整排查实录:从日志到堆转储
4.1 先分清三类“内存吃不消”的现象
JobManager OOM 不是一个统一表现,不同内存区域耗尽的症状和定位路径差别很大。我根据经验整理了一个速查表:
| 现象 | 通常指向 | 初步动作 |
|---|---|---|
java.lang.OutOfMemoryError: Java heap space |
JVM 堆不足 | 分析堆转储中的大对象,确认是 ExecutionGraph 还是调度缓存导致 |
java.lang.OutOfMemoryError: Metaspace |
类元数据区不足或泄漏 | 观察 Metaspace 使用趋势,检查 ClassLoader 是否泄漏 |
java.lang.OutOfMemoryError: Direct buffer memory 或 unable to create new native thread |
堆外内存不足或线程数超限 | 检查 Overhead 规划、线程数量、Native 内存使用 |
实际生产里最常遇到的两种是 Java heap space 和 Metaspace。前者通常是“存量太大”,后者往往是“增量不停涨”。
4.2 在容器里用 jstat、jcmd 和 jmap 捡漏
那次 Metaspace OOM 发生之后,我先把 JobManager 重新拉起,然后用 jstat 观察 GC 和元数据区变化:
bash复制jstat -gcutil <pid> 5000
重点看输出中的 M 列,也就是 Metaspace 的使用占比。一开始 M 列在 60% 左右徘徊,作业正常跑的时候不涨,但只要有人提交一个新作业,M 列就会往上跳一截。更让人警觉的是,即使作业下线了,M 列也不会回落。这说明 Metaspace 里的类并没有随作业结束被回收。
接下来用 jcmd 查看类的直方图,确认是不是有大量重复加载的类:
bash复制jcmd <pid> GC.class_histogram | head -50
从输出里能看到典型的用户 JAR 中的类出现大量实例,而且同类名的 Class 对象数量几乎和历史上提交过的作业数量一样。这个信息基本可以判断,每次作业提交都创建了新 ClassLoader,而旧 ClassLoader 没有被回收。
如果还需要看线程里有没有持有 ClassLoader 的引用,可以用:
bash复制jcmd <pid> Thread.print
如果看下来确认是类加载器泄漏,再做一次堆转储,用于精确定位引用链。堆转储对运行中的进程有一定影响,我一般会放在业务低峰期或者故障复现时执行:
bash复制jmap -dump:live,format=b,file=/tmp/jm.hprof <pid>
同时建议提前在 env.java.opts.jobmanager 里加上自动 dump 参数,省得下次还要手动操作:
bash复制-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/flink/logs
4.3 堆转储后,MAT 告诉我的真相
那次 dump 出来的 hprof 文件有 3GB 多,我用 MAT 打开后,Leak Suspects 报告里出现了一个非常显眼的模式:大量同一个业务 JAR 包里的用户自定义 Function 类被重复加载,而且这些 Class 对象被一个全局单例对象里的静态 Map 引用着。Map 的 key 是 ClassLoader,value 是某个自定义连接器的客户端实例。
也就是说,业务方在自己的 UDF 里写了一个静态缓存,用来复用外部系统的连接客户端。这个设计本身不算错,问题在于静态缓存的 key 用了 ClassLoader。作业正常提交时,JobManager 为每个作业创建独立的 ClassLoader;当作业下线,理应释放 ClassLoader,但由于静态 Map 持有引用,ClassLoader 永远无法被 JVM 回收。作业提交得越多,Metaspace 里残存的类元数据就越多,最终撑满。
这类问题单看堆转储中的 Java 堆大对象往往找不到答案,因为泄漏的对象主要占据 Metaspace。我第一次排查时盯着堆里的字符串和 byte[] 看了半天,后来用 MAT 的 ClassLoader 视图按“被引用对象大小”排序,才把根因定位到业务 UDF 的静态引用链上。
4.4 修复根因后,验证不再反弹
修复方案是让业务方去掉静态 Map 里的 ClassLoader 引用:改成用 ThreadLocal 管理连接,或者在作业结束生命周期中显式清理缓存。代码改动不大,但对 Metaspace 的释放效果立竿见影。
验证时我做了一组压测:连续提交 100 个测试作业,每个作业运行 30 秒后下线,持续观察 M 列曲线。修复之前,每提交一个作业,M 列都会涨一点,100 个作业跑完,Metaspace 使用率已经超过 90%。修复之后,同样跑完 100 个作业,M 列在作业下线的几秒内就开始回落,最终稳定在一个比较低的水平。
那次之后我也意识到,Metaspace OOM 并不适合靠无限调大 jobmanager.memory.jvm-metaspace.size 来解决。加大容量只是给泄漏争取时间,等容量又被打满,故障依然会复现。正确顺序是:先通过趋势判断是“类真的太多”还是“类无法回收”,再决定扩容还是修代码。
5. 容易被忽略的 JobManager 内存细节和长期维护手段
5.1 相同配置项在不同角色下的含义不同
Flink 中很多内存配置项在 JobManager 和 TaskManager 下的默认值、计算方式并不一样。最常见的问题是有人从网上复制一份 TaskManager 内存模板,直接改个进程名就用到 JobManager 上,结果框架提示配置无效或者内存区域分配不合理。
例如 TaskManager 里的 taskmanager.memory.managed.size、taskmanager.memory.network.* 在 JobManager 里根本不存在。JobManager 的内存模型没有 Framework Heap、Managed Memory、Network Memory 这些细分区域。如果一份 flink-conf.yaml 同时被 TaskManager 和 JobManager 使用,建议把 JobManager 关心的配置单独抽出来,或者至少在变更后通过启动日志确认 JM 实际生效的内存布局。
5.2 OOM 之后不要只调内存,也别把 JM 堆调成“越大越好”
这里有一个很实在的建议:JobManager 的堆内存并不是越大越好。堆调大之后,Full GC 的时间会变长,调度延迟也会上升。如果堆里堆了几万个作业对象,每次 Full GC 动辄几秒钟,对实时作业的 Checkpoint 协调会造成明显抖动。
我自己见过一个极端案例:某团队把 JobManager 堆从 4GB 调到 16GB,OOM 倒是没了,但作业重启时 JobManager 频繁出现长时间的 GC 停顿,Checkpoint 超时次数反而暴增。所以在调整堆大小时,要同时关注 GC 时间。如果老年代 GC 频率和耗时都在上升,说明堆里的常驻对象已经过多,这时更该做减法:拆集群、收敛作业数、清理无用的作业记录,而不是一味加内存。
5.3 给 JM 内存设置水位线:监控告警怎么做
JobManager 内存监控不能只盯“进程是否还活着”,至少要看三个指标:
- JVM Heap 的 Used/Old Gen 曲线:注意是否呈阶梯式上涨,不随作业下线而回落。
- Metaspace 使用率:尤其关注作业批量提交和下线期间的变化,是否能够回到基线。
- Full GC 频率和耗时:Full GC 超过 1 秒且频繁出现,就需要排查堆内对象规模。
告警阈值我喜欢设置两级:使用率达到 70% 时预警,开始人工排查;达到 85% 时就要进入介入流程,而不是等 95% 甚至 OOM 才反应。因为 JobManager 内存故障是全局性的,一旦 OOM,整个 Session 集群的所有作业都会跟着受影响,提前量比什么都重要。
5.4 我踩过的最后一个坑:JVM Overhead 被忽略导致 Native 内存被打满
前几年有一段时间,我负责的集群 JobManager 频繁被 Yarn 判定为“超过物理内存限制”而杀掉,日志里并没有任何 Java heap OOM 的报错。查看容器监控时,进程的 RSS 一直在缓慢上涨,直到碰触容器上限后被强制 Kill。
那一次折腾了很久才意识到问题出在 Overhead 设置上。当时配置里的 Overhead 区间范围太窄,而集群的 TaskManager 数量接近 200 个,JobManager 上维护的 RPC 连接和任务状态回调的 Native 内存开销远超预留值。最终把 jobmanager.memory.jvm-overhead.max 和 fraction 调大后,进程的 RSS 才稳定下来。
这类问题最迷惑人的地方在于:JVM 堆是健康状态,Metaspace 也不高,但整个进程的内存就是涨。以后遇到这种“堆里明明还有大量剩余,进程却被杀”的情况,优先去看 Overhead 和 Direct Buffer 的实际用量,而不是反复调堆大小。
如果现在让我接手一套新的 Flink 集群,我会先做这样一组检查:确认 JobManager 的堆配置能覆盖存量作业的 ExecutionGraph 占用;确认 Metaspace 在批量作业上线下线后能回到基线;确认 Overhead 为 Native 资源和线程栈留了足够的余量;确认容器内存限制大于 Flink 推算出来的总进程内存。四个点都验证完,控制面才算真的稳了。
