IllegalConfigurationException 和 OOMKilled,这两个词之前我都单独见过,但第一次在同一个作业上连续出现,是在帮一个业务团队做 Apache Flink 内存故障排查的时候。
那次排查从"作业为什么起不来"开始,中间经历了"配置改对了还是被容器杀",最后落在一张 RSS 曲线图上。整个过程让我意识到,很多人对 Flink 内存配置的理解是断层的:知道 IllegalConfigurationException 是配置冲突,却不知道它背后是一套推演逻辑;知道 OOMKilled 是内存超限,却不知道超限的大头根本不住在 JVM 堆里。
这篇文章就按我当时排查的顺序来写。如果你也遇到过 Pod 显示 OOMKilled、TaskManager 日志却干干净净的情况,或者被 taskmanager.memory.process.size 和一堆 fraction 参数绕晕,这篇应该能帮你少走几天弯路。
1. 事故起点:一个"总账和分账混写"的配置,触发了 IllegalConfigurationException
1.1 当时的配置和报错现场
那条作业是典型的数据接入链路:Kafka 消费,经过 Flink 清洗聚合后写到下游存储,16 个并行度,状态后端用的 RocksDB,部署在 Kubernetes 上。某天负责的同学改了配置准备上线,作业一启动,TaskManager 还没拉起来,日志里就冒出一段 IllegalConfigurationException。
他当时的配置大意是这样的:
yaml复制taskmanager.memory.process.size: 3g
taskmanager.memory.jvm.heap.size: 1.5g
taskmanager.memory.managed.size: 512m
日志大意如下:
text复制Caused by: org.apache.flink.configuration.IllegalConfigurationException:
The configuration of the total process memory taskmanager.memory.process.size
contains the following manually configured fractions which are discarded:
taskmanager.memory.jvm.heap.size=1536mb
taskmanager.memory.managed.size=512mb
第一次看到这段报错的人通常会有一个直觉反应:"是不是我配置写多了?删掉几个试试。" 负责的同学也确实这么干了,他把 jvm.heap.size 和 managed.size 删掉,只留 process.size,作业果然能起来。但这不是问题的结束,而是下一个更隐蔽的坑的开始。
1.2 为什么"记得越全越容易报错":内存推演机制的记账逻辑
很多人对 Flink 内存配置的理解是"我告诉它每个部分多大,它照着跑就行",但 Flink 的实际逻辑完全不同。它的内存配置是一棵从总内存出发、倒着切分的树,而不是一个允许你随便填的清单。
这里有必要把 Flink 的内存模型摆出来。一个 TaskManager 进程的"总进程内存(Total Process Memory)"大致由这几层构成:
- JVM 堆内部分:框架堆、任务堆、托管内存(Managed Memory,在堆内分配)
- JVM 堆外部分:元空间、JVM Overhead、框架堆外、任务堆外
- JVM 之外的部分:一些 native 库、线程栈、direct buffer、RocksDB 等使用的进程内存
你可以把 taskmanager.memory.process.size 理解成"本月总预算"。一旦你给了总预算,Flink 的推演器就会按照你设置的各分项比例或绝对值,从总预算里反推每一项。这时候如果你又手写了 taskmanager.memory.jvm.heap.size 和 taskmanager.memory.managed.size,就相当于你既告诉财务"本月总支出是一万",又在报销单里写"吃饭两千、房租六千、交通五百",而这三项之和既推不回一万、又和总账对不上,推演器只能选择要么丢弃你的明细,要么直接报错说推导不了。
所以 IllegalConfigurationException 的本质不是"你的参数值非法",而是"你同时占用了两条互相冲突的推导路径"。在 1.10 之后的内存模型里,最干净的做法只有两种:要么只给 process.size 或 total.process.size,其余全部交给 Flink 按比例推导;要么完全不给总账,把每一块显式写清楚,让 Flink 自己去加总。
我当时看到这段报错时,第一反应不是去背配置项,而是去看 Flink 的日志头部。你会发现 Flink 在启动时会打印一张内存配置推导表,里面会标出每一个分项实际用了多少、哪些被丢弃、哪些是按默认值补的。这张表比任何文档都有用,排错时永远先从它读起。
1.3 报错信息里的两个关键词,分别代表什么
第一类关键词是 manually configured fractions which are discarded。这表示 Flink 已经容忍了你的混写,选择按总账优先的策略,把你手写的明细项静默丢弃了。这种情况经常只以 WARN 级别出现,很多人压根没看到,但后果非常隐蔽:你以为自己设了 1.5g 的堆,实际 Flink 按 process.size 重新推导出来的堆可能只有几 MB,等作业一跑起来,GC 和内存压力就能把你搞到怀疑人生。
第二类关键词是 Could not derive the total process memory 这类的推导失败。这才是我们常说的 IllegalConfigurationException 真正"爆"出来的形态。它意味着推演器发现"总账和分账"完全对不上,没有一种合理路径能同时满足你给出的所有约束,于是直接终止启动。
一句话总结:IllegalConfigurationException 是总账和分账打架的报警器,而不是某个具体参数写错的报错器。 排查的时候别只盯着某个数值,要去看自己到底走了哪条推导路径。这是我后来给团队做内存配置培训时反复强调的第一条。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 配置改对之后,Pod 开始被 OOMKilled:这次连一行 Java 异常都没留下
2.1 退出码 137、Last State 和毫无异常的日志
同事删掉明细配置、只留 process.size: 3g 之后,作业确实正常启动了。但跑了几个小时,TaskManager 的 Pod 开始周期性重启。第一次出现时大家都在找异常日志,结果翻遍了 TaskManager 日志,干干净净,没有任何 OutOfMemoryError,连 GC 日志里都没看到 Full GC 频繁的迹象。
真正给出关键信息的是 Kubernetes 那边的状态。用 kubectl describe pod 看,Pod 状态是 OOMKilled,退出码 137,Last State 里明确写着 Reason 为 OOMKilled。这个信息组合基本可以定性:不是 JVM 主动抛异常,而是容器被内核 OOM Killer 干掉了。
这是 Flink on Kubernetes 里最常见的"假安静"。很多只跑过 YARN 部署的同学第一次遇到这种场景会特别困惑,因为在 YARN 上如果 native memory 超了,多数时候还能看到一些 JVM 报错或者被 NodeManager 杀掉;但在 Kubernetes 里,Pod 的内存超过 limits 后,内核直接给进程发 SIGKILL,JVM 连打印异常的机会都没有。
2.2 JVM 的 OutOfMemoryError 缺席,反而是最重要的信号
很多人看到"没有 OOM 日志"就以为不是内存问题,这是大错特错。我们后来复盘时得出一个经验:TaskManager 日志里有没有 java.lang.OutOfMemoryError,是区分"堆内 OOM"和"堆外/进程级 OOM"的分水岭。
如果是堆内内存爆了,JVM 通常会给出清晰的错误栈,你还能找到 GC overhead 或者堆分配失败的日志。如果是容器级别 OOMKilled,说明超限的是整个进程的驻留内存(RSS),这里面包含堆,但更可能包含堆外那一大批 Flink 根本管不住、也未必管得到的内存。
那个 Pod 的 limits 是 4GiB,而 process.size 只配了 3g。问题来了:按道理两者之间还有 1GiB 的缓冲,为什么还是被杀?这说明进程的 RSS 在某一刻戳破了 4GiB 那条线,而 Flink 配置的 3g 只描述了"我打算用多少",并不等于"我用到的物理页只有这么多"。
2.3 4GiB 的 limit,为什么会挤出 4.3GiB 的 RSS
为了讲清楚这件事,我把当时用监控和 JVM 工具还原出来的内存构成列一下。要注意,这组数不是 Flink 文档里的标准默认值,而是那个作业在高峰期的大致分布,但它的结构和绝大多数 RocksDB 状态后端作业是吻合的:
| 内存类别 | 高峰时大致占用 | 说明 |
|---|---|---|
| Java Heap(任务堆为主) | 1.4g ~ 1.6g | 堆内对象、状态、序列化缓冲 |
| Metaspace | 250m ~ 350m | 类加载和 JIT 元数据 |
| Code Cache | 100m ~ 150m | 即时编译器生成的机器码 |
| 线程栈 | 150m ~ 250m | 并行度越高线程越多 |
| Netty Direct Buffer 等 direct/native 内存 | 800m ~ 1.2g | 网络读写、连接池、反序列化 |
| RocksDB 堆外 cache | 200m ~ 500m | block cache、write buffer |
| 其他 JVM/native overhead | 200m ~ 400m | malloc 管理结构、临时 native 分配、页缓存 |
堆只有 1.5g 左右似乎还好,但堆外那一堆加在一起就过 4GiB 了。关键问题是:Flink 的 JVM Overhead 默认额度只有 process.size 的 10%,而且有个上限(不同版本上限值略有差异,我这边大概是 1GB)。当进程里那些"说不清道不明的 native 内存"超过这 10% 时,Flink 自己不知道,JVM 也不管,内核只知道 RSS 过线了,于是出手杀人。
这就是从 IllegalConfigurationException 到 OOMKilled 的完整递进:第一次报错是配置推导冲突,你绕过去了;第二次崩溃是配置推导结果和真实物理内存消耗对不上,你在配置层面留的缓冲不够,容器层面也留的余量不够。
3. 定位复盘:三步把 TaskManager 的堆外账本完整翻开
3.1 第一步:用指标和 cgroup 把"谁超限"钉死
当时我们做的第一步不是改配置,而是确认到底是谁超限、在什么时刻超限。这里有几个命令和指标帮我快速收敛。
先看容器内 cgroup 的实际限制和当前用量。如果你的 Kubernetes 节点用 cgroup v2,可以这样看:
bash复制# cgroup v2 路径
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
如果是 cgroup v1,则路径不同:
bash复制# cgroup v1 路径
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
cat /sys/fs/cgroup/memory/memory.usage_in_bytes
再确认进程本身的 RSS:
bash复制grep VmRSS /proc/<pid>/status
同时去 Flink Web UI 上看 TaskManager 的 Memory 页签,记录 heap、non-heap、direct memory 的使用量。我从监控系统把 RSS 曲线、direct memory 曲线、以及这条作业的吞吐量曲线叠在一起,很快就发现了一个规律:每当数据量冲上来、Kafka lag 开始消退的时候,direct memory 和 RSS 都会跟着涨,而 Java Heap 始终在 70% 以下徘徊。
这里有个判断技巧:如果堆内还有明显余量,但 RSS 已经逼近甚至超过 limit,基本可以断定问题出在堆外和 native 内存,而不是堆内。接下来要做的是把堆外账本继续翻清楚。
3.2 第二步:NMT 上场,看清 native memory 的大头
堆外的账目不像堆内存那样打开 JVisualVM 就能看,最好的方式是用 JVM 自带的 Native Memory Tracking(NMT)。它需要在 TaskManager 启动参数里显式打开,我当时的做法是在 Flink 的 TaskManager Java opts 里加上:
bash复制-XX:NativeMemoryTracking=summary
然后重启作业,等它跑到高峰,再对目标进程做快照:
bash复制jcmd <pid> VM.native_memory summary
想看两次快照之间的增量可以用:
bash复制jcmd <pid> VM.native_memory summary.diff
NMT 的输出会分成 Java Heap、Class、Thread、Code、GC、Compiler、Internal、Other、Symbol 等大类。我在那次排查中重点看三个地方:
- Internal:这里藏着大量 native 内存,包括 Direct Buffer 的一部分,以及 Netty、ZGC 等组件申请的 native 块
- Thread:高峰期线程栈的总量
- Java Heap:确认堆内没有出现异常膨胀
结合 Flink 自己的 metrics 里的 Status.JVM.Memory.direct.memoryUsed 指标,基本可以印证:native 内存的增量大头在网络缓冲和连接池方向,而不是堆。这就是为什么只调 jvm.heap.size 根本没用,因为问题压根不住在堆里。
有一点要提醒,NMT 本身有一定性能开销,生产环境不建议长期开启,比较稳妥的做法是压测或者排障期间开一段,拿到数据后关掉重启。不要为了省事把它留在生产配置里跑一年。
3.3 第三步:把 RSS 曲线、direct memory 和业务流量叠在一起看
数据钉死之后,逻辑就非常清晰了:Pod 的 limit 是 4GiB,Flink 的 process.size 是 3g,但进程内存在高峰期的实际组成是"3g 的 Flink 管理范围 + 一部分 Flink 管理范围之外但依然算作 RSS 的内存",再加上我自己给 JVM 预留的缓冲不够,于是在某个峰值时刻 RSS 冲过了 4GiB,被内核杀掉。
这里也想分享一个更本质的判断视角:Flink 的 process.size 是"Flink 希望你总共只用这么多",但 RSS 是"你整个进程实际占了多少物理内存"。 两者之间永远要留出 gap。我们那次把 gap 留在容器层(4GiB limit 对 3g process)看起来够,但实际上 Flink 的 JVM Overhead 默认只有 10%,在堆外内存需求大的作业里这点缓冲根本不够,等于把 gap 偷偷吃掉了。
从那次之后,我排查这类问题不再纠结于"为什么日志没有 OOM"这种问题,而是直接回答三个问题:超出的是堆还是堆外?堆外的大头是谁申请的?我的配置里为这个大头留了多少空间?三个问题答完,方案基本就自己冒出来了。
4. 踩平这个坑:一套更稳的 Flink 内存配置模板和上线前防御清单
4.1 可以直接抄的 TaskManager 内存配置参考
下面这套不是标准答案,因为每个作业的并行度、状态大小、数据吞吐都不一样,但它体现了正确的配置思路:总账、分账、容器 limit 三层必须同时说清楚,并且互相留出余量。
| 配置项 | 建议做法 | 为什么 |
|---|---|---|
| taskmanager.memory.process.size | 设为容器 limit 的 88%~92% | 给操作系统和无法统计的 native 内存留 buffer |
| taskmanager.memory.jvm.heap.size | 显式写出,约占 process 的 50%~60% | 避免 Flink 用默认比例推出来的堆太小 |
| taskmanager.memory.managed.size | RocksDB 场景给 process 的 25%~35% | 状态后端堆外 cache 需要明确空间 |
| taskmanager.memory.task.off-heap.size | 显式给 256m~512m(或按需要调大) | 给 Netty 和外部库一条明确的堆外通道 |
| taskmanager.memory.jvm-overhead.fraction | 0.15~0.2,并显式给出 min/max | 默认 10% 对堆外需求大的作业太紧 |
| taskmanager.memory.jvm-metaspace.size | 保持默认或按需调整 | 类加载过多时再动 |
| 容器 memory limit | process.size 的 1.08~1.1 倍左右 | 留出 RSS 超出 Flink 管理范围的部分 |
我当时给那个作业最终调成的配置大约是这样:Pod limit 4GiB,process.size 配 3.6g,jvm.heap.size 显式给 1.8g,managed.size 给 768m,jvm-overhead.fraction 提到 0.15,并且用 jvm-overhead.min 和 jvm-overhead.max 把绝对值写死。关键是 tune 完之后做了一轮完整压测,盯住 RSS 曲线确实在 3.8g 以下才让上线。
还有一点要提:JobManager 的内存配置和 TaskManager 是两套逻辑,JobManager 更依赖堆内内存,不能用 TaskManager 的模板直接套。改配置的时候最好按进程分开维护,不要一份配置到处复制。
4.2 三种"看似调了其实白调"的操作
排障过程中我见过太多人做无效操作,这里整理三个最典型的。
第一种是只调 Kubernetes 的 Pod limits,不动 process.size。Flink 进程本身不会感知到你把它所在的容器变大,它永远严格按照自己配置的内存模型去申请。你外面给 6GiB,它里面还是按 3g 算,RSS 该超照样超。反过来也一样:只调 process.size 撑大却不调 Pod limits,启动时直接被杀。
第二种是直接在启动脚本里改 -Xmx。Flink 的内存推导会自己设置堆大小,你手动写一个 -Xmx 往往会和它的推导打架,轻则启动时一堆 WARN,重则直接触发 IllegalConfigurationException。正确做法是永远通过 taskmanager.memory.* 配置来表达,让 Flink 去生成 JVM 参数。
第三种是只改了某一个副本或某一个节点的配置,忘了同步其他节点。Kubernetes 里 TaskManager 经常是多个副本,你改了一个 Deployment 的配置不代表每个副本都拿到同一套。建议把 Flink 配置统一放到一个共享的配置模板里,review 时重点检查"所有副本是否一致"。
4.3 上线前的三件事:压测、监控、预案
我把这个坑踩平之后,给团队定了三条上线前硬性动作,缺一不可。
第一件是用接近业务峰值的数据量做压测,并且至少跑一个完整业务周期。很多人用小数据量压测过了就以为安全,但内存问题往往只在峰值流量下暴露。你至少要观察一次"流量从低位冲高再回落"的完整过程,看 RSS 曲线是否逼近 limit。
第二件是在压测期间开 NMT 和 GC 日志,拿到数据之后记得关掉。排障期不能省这个成本,否则你永远不知道堆外那几百 MB 是从哪来的。不用长期开,但要保证"需要看数据时手里有工具"。
第三件是监控告警的阈值要提前设好。不要等 OOMKilled 发生才收到告警,建议对 RSS 或 cgroup memory.current 设置一个 limit 的 80% 作为预警阈值。我见过太多人是被容器重启打乱了节奏之后才开始看监控,白折腾一整天。提前定好阈值,收到告警时的第一反应是看 RSS 构成,而不是盲目改 limit。
5. 那次排障之后,我养成的几个内存管理习惯
那次之后,我再看到 OOMKilled 的第一反应一定是抽 RSS 曲线,而不是先改配置。因为经验告诉我,绝大多数 Flink 内存问题不是"某一段代码写得烂"导致的,而是"配置里的总账和物理内存的真实消耗之间差了整整一道墙"。
我现在给业务同学 review Flink 配置时,喜欢强调一个词:总账与分账。你可以不记 Flink 所有内存参数,但一定要知道 process.size 是总账,jvm.heap.size、managed.size、jvm-overhead.* 是分账,Kubernetes 的 limits 是最后一道物理围墙。三者之间的关系必须清清楚楚,缺一个环节,坑就会换一个形态出现在你面前。
最后分享一个小技巧:用 jcmd 之前,先用 ps -eo pid,rss,args | grep TaskManager 找到准确的进程号,别在容器里对着 PID 列表乱猜。另外,如果你想让容器日志里直接暴露 JVM 实际生效的参数,可以在 TaskManager 启动参数里临时加一个 -XshowSettings:vm -version,日志里会打印完整的内存设置,省得一遍遍猜。
如果你现在正好卡在"日志干干净净但容器反复被杀"这件事上,希望这篇能帮你少走几天弯路。运气好的话,你的配置模板可以一年到头不用动。
