文章

一次 Flink Full GC 频繁问题排查:真正的根因竟是 CPU Throttling

一次 Flink Full GC 频繁问题排查:真正的根因竟是 CPU Throttling

问题现象

某个 Flink 流式任务里,少数 TaskManager 的 Full GC 比其他实例频繁得多,一度接近每分钟一次。

从 GC 日志看,每次 Young GC 都会晋升一部分存活对象到老年代,老年代占用持续往上走,最终触发 Full GC。第一反应很自然:

  • 分区或任务负载是不是不均衡?
  • 新生代设太小了,对象过早晋升?
  • JVM 堆不够?
  • 有内存泄漏或长生命周期对象?

这些方向都没错,但它们只能解释”GC 为什么发生”,解释不了一个更关键的现象:为什么那几个异常实例的对象分配速率会在特定时段突然飙高?

从 GC 日志往上游追

对比正常和异常的 GC 日志,异常实例在高峰阶段有两个特点:

  1. 对象分配速率更高;
  2. 老年代涨更快,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 排查——同样是内存排查,但根因分别在代码逻辑层、分配器层、操作系统层,三个不同维度。

本文由作者按照 CC BY 4.0 进行授权