一次 Flink Full GC 频繁问题排查:真正的根因竟是 CPU Throttling
问题现象
某个 Flink 流式任务里,少数 TaskManager 的 Full GC 比其他实例频繁得多,一度接近每分钟一次。
从 GC 日志看,每次 Young GC 都会晋升一部分存活对象到老年代,老年代占用持续往上走,最终触发 Full GC。第一反应很自然:
- 分区或任务负载是不是不均衡?
- 新生代设太小了,对象过早晋升?
- JVM 堆不够?
- 有内存泄漏或长生命周期对象?
这些方向都没错,但它们只能解释”GC 为什么发生”,解释不了一个更关键的现象:为什么那几个异常实例的对象分配速率会在特定时段突然飙高?
从 GC 日志往上游追
对比正常和异常的 GC 日志,异常实例在高峰阶段有两个特点:
- 对象分配速率更高;
- 老年代涨更快,Full GC 间隔越来越短。
如果只停在 JVM 层,可以调大新生代或整体堆。这些调整可能降低 GC 频率,但解释不了负载为什么突然变化,也消除不了外部因素。
继续查 Flink 的消费指标,发现异常实例的输入吞吐先明显降了一波,然后又突然拉高:
- 前一阶段消费能力下降,消息积压;
- 后一阶段计算能力恢复,任务开始追 backlog;
- 追赶期间处理量和对象分配量一起暴增;
- GC 压力集中释放,老年代更快填满。
所以 Full GC 更像是下游症状,真正的问题还得往前追:消费能力为什么先降了?
反常信号:有积压,但 CPU 使用率不高
通常流式任务出现积压时,CPU 使用率会接近容器配额上限。但异常实例没有——明明消费积压很严重,CPU 使用率却偏低。
这很容易让人误判为”任务没什么可做的”或者”瓶颈在 I/O”。但在容器环境里,CPU 使用率低不代表 CPU 够用,还可能意味着进程被 CFS 带宽控制机制限流,拿不到运行时间。
换句话说:
不是任务不想用 CPU,是调度器不让你用。
用 cpu.stat 验证 CPU Throttling
cgroup v1 环境,进目标容器对应的 CPU cgroup 目录看 cpu.stat:
1
cat /sys/fs/cgroup/cpu/<cgroup-path>/cpu.stat
典型输出:
1
2
3
nr_periods 1000141
nr_throttled 2629
throttled_time 171947791164
nr_periods:经历的 CPU 带宽控制周期数;nr_throttled:因耗尽配额被限流的次数;throttled_time:累计被限流的时间(cgroup v1 是纳秒)。
别光看 nr_throttled 的绝对值,长时间运行的容器本来就会累积。更靠谱的方法是连续采样看增量:
1
2
3
cat /sys/fs/cgroup/cpu/<cgroup-path>/cpu.stat
sleep 10
cat /sys/fs/cgroup/cpu/<cgroup-path>/cpu.stat
重点看:
nr_throttled / nr_periods的增量比是不是持续偏高;throttled_time是不是在快速增长;- 指标增长和消费速率下降、lag 上升的时间窗口对不对得上。
cgroup v2 的话看:
1
cat /sys/fs/cgroup/<cgroup-path>/cpu.stat
字段类似但单位可能不同(throttled_usec),以当前系统文档为准。
这个 case 里,异常实例的 throttling 计数和限流时间在问题窗口内明显增长,时间线也和 Flink 消费能力下降吻合——证据链闭合了。
完整因果链
整个故障过程串起来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
内核 / CFS 带宽控制异常或 CPU 配额受限
↓
容器持续 CPU throttling
↓
TaskManager 实际可运行时间减少
↓
消费吞吐下降,消息积压
↓
CPU 限流缓解后开始追赶 backlog
↓
短时间内处理量和对象分配速率上升
↓
Young GC 更频繁,老年代晋升增多
↓
老年代更快到阈值,Full GC 频率升高
最容易搞混的就是因果方向:Full GC 不是消费积压的起因,而是 CPU 限流 + 追赶积压共同放大的结果。
当然,频繁或停顿长的 Full GC 也会进一步拉低吞吐,形成反馈回路:
1
CPU throttling → lag → 追赶积压 → 分配压力 → GC → 吞吐进一步下降
排查的时候得靠时间线判断第一个异常信号在哪,不能光看指标相关性就下结论。
修复与缓解
1. 优先修 CPU throttling 的根因
这个 case 和旧内核的 CPU 带宽控制问题有关,升级到包含修复的内核版本后异常限流消失了。
别机械套用某个版本号——不同发行版可能把同一补丁回移到不同版本。更稳妥的做法:
- 确认宿主机内核是否包含对应的 CFS bandwidth 修复;
- 对比正常节点和异常节点的内核版本、配置;
- 检查容器的 CPU quota、period 和实际部署规格;
- 升级或切到已验证的内核版本后,观察
cpu.stat增量。
如果 throttling 是合理的配额限制而非内核 bug,那应该重新评估容器 CPU 配额、任务并行度和单个 TaskManager 的 workload,而不是只升级内核。
2. 适当增大 JVM 堆,降低追赶阶段的 GC 风险
增大堆可以给 backlog 追赶期间的对象分配突增多留些缓冲,降低老年代快速耗尽的概率。但要注意:
- 堆越大,Full GC 的最坏停顿可能越长;
- CPU 限流如果还在,消费能力不足的问题不会消失;
- 如果对象生命周期或状态管理本身有问题,扩大堆只是推迟故障。
调整堆大小前后,对比分配速率、晋升速率、老年代增长趋势、GC 停顿和业务 lag,别只看 Full GC 次数。
3. 必要时再调 GC 参数
新生代太小确实可能增加晋升压力,但只有 GC 日志能证明这一点时才值得调。关注:
- Young GC 前后的存活量;
- 对象晋升速率;
- Survivor 区溢出或过早晋升;
- 老年代占用增长速度;
- GC 停顿和应用吞吐的时间关系。
GC 参数优化应该建立在负载稳定、CPU 资源问题已经排除的基础上。否则很容易用 JVM 参数掩盖系统层故障。
一套可复用的排查路径
碰到”部分 Flink 实例频繁 Full GC”,建议按这个顺序来。
第一步:确认 GC 是不是真的异常
Full GC 发生了不等于故障。问自己:
- Full GC 频率、平均停顿、最大停顿是多少?
- GC 吞吐率有没有明显下降?
- Full GC 后老年代有没有显著回落?
- 是容量问题、晋升压力、还是疑似泄漏?
结合停顿、吞吐率和业务影响判断严重程度。
第二步:对比正常和异常实例
至少对比这些:
- 输入输出吞吐;
- 消费 lag;
- 对象分配与晋升速率;
- Young GC / Full GC 频率和停顿;
- CPU 使用率和 CPU 配额;
- 堆内存、进程 RSS、容器内存;
- 节点内核版本和容器运行时配置。
单实例的绝对值很难说明问题,同任务下的正常实例才是最好的基线。
第三步:建立时间线
把关键指标放在同一时间窗口:
1
2
3
4
5
6
CPU throttling 增长
→ 输入吞吐下降
→ lag 上升
→ 吞吐恢复并超常
→ 分配速率上升
→ GC 压力增大
时间顺序不成立的话,重新检查因果假设。
第四步:跨层验证
别把问题限制在 JVM 里。至少同时看:
- JVM:GC 日志、堆布局、对象分配和晋升;
- Flink:backpressure、busy time、吞吐、checkpoint、lag;
- 容器:CPU quota、throttling、内存限制;
- 操作系统:内核版本、调度器、cgroup 行为;
- 节点:是否只有特定宿主机或节点池复现。
经验总结
这次排查最有价值的经验不是某个 GC 参数,而是三点。
1. GC 往往是结果,不一定是起点
JVM 指标最显眼,很容易成为第一个怀疑对象。但对象分配速率、晋升速率和 GC 频率都受业务吞吐影响。看到 GC 异常,继续追问:是什么改变了工作负载?
2. CPU 使用率低,不代表 CPU 没成瓶颈
在容器环境里,CPU 使用率、CPU 配额、CPU throttling 是三个不同概念。进程可能因为被限流表现出低使用率,同时又因为拿不到运行时间而积压。
3. 根因分析需要闭合证据链
一个完整结论至少该回答:
- 第一个异常信号是什么?
- 它怎么影响下一层指标?
- 时间顺序对不对?
- 有没有系统级计数器或实验能验证?
- 修完后整条链路上的指标是不是同步恢复?
“现象、机制、时间线、验证、修复结果”都对上了,才能把相关性升级为可信的根因。
结语
这起问题表面是 Full GC 频繁,实际却始于容器 CPU throttling。JVM 堆扩容和 GC 调参能缓解症状,但替代不了对 CPU 配额、cgroup 和内核行为的检查。
跑在容器平台上的 JVM 服务,排查边界早就不止于 Java 堆。把 GC、业务吞吐、容器资源控制和操作系统调度放进同一条时间线,往往比反复调 JVM 参数更接近真正的答案。
相关阅读:使用 jemalloc 定位 Java Native Memory 泄漏、Netty 堆外内存没涨,RSS 却一直涨?记一次 glibc Arena 排查——同样是内存排查,但根因分别在代码逻辑层、分配器层、操作系统层,三个不同维度。