结论先行
P50 稳定、P99 间歇性飙 3 倍以上,CPU 不高、慢日志干净、连接池没打满——如果这几个条件同时满足,第一嫌疑不是数据库,是 GC。我 3 年里被「GC 毛刺」坑过两次,两次都是 G1 在 4G 堆上做的 Mixed GC,单次停顿 280–420ms,而我们的 SLO 是 P99 < 500ms。换句话说,一次 GC 就能吃掉整个 SLO 预算,还让所有在途请求集体超时。
一、现象:P50 12ms,P99 间歇飙到 380ms
一个商品详情接口,QPS 稳定在 1200 左右,P50 一直 12–15ms,但每天 2–3 次出现 P99 飙到 350–420ms 的尖刺,持续 500ms–1.2s。尖刺期间:
- CPU 使用率从 40% 升到 65%(GC 线程在跑);
- HikariCP active 连接从 40 涨到 80(线程被 GC 挂住,连接没还);
- 下游 DB 侧 active 会话同步上涨,但没有慢查询;
- 告警系统报「P99 超 SLO」,但没有任何一条日志指向具体原因。
关键特征:P50 完全没动。如果是代码问题、索引问题、连接池问题,P50 会跟着涨。只有 GC 这种「全局停顿」才会让 P50 不动、P99 单独飙——因为停顿期间所有请求都被卡住,但停顿结束后它们瞬间完成,平均值被拉不动。
二、原理:为什么 GC 停顿会放大成 P99 毛刺
2.1 STW 不是「暂停一下」,是「所有工作线程集体冻结」
G1 的 Mixed GC 会触发 STW(Stop-The-World),期间:
- 所有业务线程被挂起(不是被调度走,是真冻结);
- 已经拿到 DB 连接的线程不会释放连接;
- 正在等下游响应的线程不会触发超时;
- 新请求进来后在 Tomcat 线程池排队。
假设一次 Mixed GC 停顿 350ms,期间有 80 个在途请求,这 80 个请求的延迟全部 +350ms。如果 P50 是 12ms,那 P99 直接跳到 362ms——一次 GC 就能让 1% 的请求超 SLO。
2.2 为什么是 P99 而不是 P50
P50 是「一半请求的中位数」,GC 停顿 350ms 只影响那一瞬间的 80 个请求。如果 QPS 是 1200,350ms 窗口内正常完成的请求约 420 个,加上被挂住的 80 个,总共 500 个,中位数还在 12ms 附近。但 P99 取的是最慢的 1%,恰好落在被 GC 挂住的那批请求里。
2.3 G1 为什么在 4G 堆上容易出毛刺
G1 的 Mixed GC 触发条件是「heap occupancy > IHOP」。默认 IHOP 是 45%,4G 堆意味着老年代用到 1.8G 就开始 Mixed。但 4G 堆的 region 只有 16MB(-XX:G1HeapRegionSize=16m),一次 Mixed 要回收的 region 数取决于「需要回收多少」,如果老年代碎片多,一次 Mixed 可能要回收 30–50 个 region,STW 时间自然长。
三、排查与调参:5 步把 P99 毛刺压回 200ms 以内
3.1 第一步:确认是 GC,不是别的
# 开 GC 日志(JDK 11+)
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=50m
# 或用 JDK 9+ 统一日志
-Xlog:gc*,gc+heap=debug:file=gc.log:time,uptime,level,tags:filecount=5,filesize=50m
看 gc.log 里 Mixed GC 的 Pause 行,对照 P99 尖刺的时间戳。如果时间戳对齐(误差 < 100ms),基本锁定是 GC。
3.2 第二步:量化每次 GC 的代价
从 gc.log 里提取:
- Mixed GC 频率:我们的是 1 次 / 8–12 分钟;
- 单次停顿:280–420ms(平均 350ms);
- 回收后堆占用:从 78% 降到 35%(说明回收量太大,一次干太多活);
- 回收的 region 数:35–50 个。
关键指标:单次停顿 × 频率 = 每分钟 GC 占用时间。我们的是 350ms × 5 次/分钟 ≈ 1.75s/分钟,占比 2.9%。看似不多,但每次停顿都打穿 SLO。
3.3 第三步:调 G1 参数(按收益排序)
# 1. 提高 IHOP,减少 Mixed 频率(代价:堆占用更高,可能触发 Full GC)
-XX:InitiatingHeapOccupancyPercent=60
# 2. 限制每次 Mixed 回收的 region 数,把一次大停顿拆成两次小停顿
-XX:G1MixedGCCountTarget=8 # 默认 8,可以调到 16 让每次回收更少
# 3. 调大 region 大小,减少 region 数量(4G 堆建议 32m)
-XX:G1HeapRegionSize=32m
# 4. 开启并发标记,减少 STW 时间
-XX:G1ConcRefinementThreads=4 # 默认 = CPU 核数,4 核机器就 4
# 5. 如果 JDK 11+,开启 ZGC 或 Shenandoah 替代 G1(见 3.4)
调完 1–4 后,Mixed GC 频率从 1 次/8 分钟降到 1 次/15 分钟,单次停顿从 350ms 降到 120–180ms,P99 毛刺从 380ms 降到 150–200ms。
3.4 第四步:如果 P99 要求 < 100ms,直接换 ZGC
JDK 15+ 的 ZGC 把 STW 压到 < 1ms(只有根扫描阶段),4G 堆上 P99 毛刺基本消失。代价是吞吐降 5–10%(ZGC 的并发标记和重定位有额外开销),但对我们这种「P99 是 SLO、吞吐不是瓶颈」的场景,完全值得。
# JDK 17+
-XX:+UseZGC
-XX:ZCollectionInterval=5 # 每 5 分钟主动触发一次 ZGC,避免一次干太多
3.5 第五步:加 GC 监控,别等 P99 告警才反应
# Prometheus 采集
- job_name: jvm-gc
metrics_path: /actuator/prometheus
# 关注这几个指标:
# - jvm_gc_pause_seconds{action="end of major GC", cause="Metadata GC Threshold"}
# - jvm_gc_pause_seconds{action="end of minor GC"}
# - jvm_memory_used_bytes{area="heap"}
# 告警规则:GC pause > 200ms 且频率 > 1 次/分钟 → P2 告警
四、踩过的坑与反直觉
- 「CPU 不高」不代表没 GC。GC 线程是后台线程,CPU 只升 20–30%,但 STW 期间业务线程全冻结。看 CPU 判断「系统不忙」会被骗。
- 连接池打满是 GC 的下游症状,不是根因。第一次排查时我把连接池从 80 加到 150,P99 毛刺没改善,反而 DB 侧 active 会话翻倍,锁争用更严重。先查 GC,再查连接池。
- G1 的 IHOP 默认值 45% 对 4G 堆太小。4G 堆的老年代只有 ~2.5G,45% 意味着用到 1.1G 就开始 Mixed,频率太高。调到 60% 后频率降了一半。
- ZGC 不是银弹。ZGC 的 STW 确实短,但它的并发重定位会在高分配速率下产生 CPU 开销。我们实测 1200 QPS 下 CPU 从 40% 升到 52%,可接受;如果是 5000 QPS 的纯 CPU 密集服务,ZGC 的额外开销可能吃掉吞吐收益。
- GC 日志的
Pause时间不等于用户感知延迟。STW 350ms 期间,用户请求的延迟 = 350ms + 停顿后排队时间 + 正常处理时间。如果 Tomcat 线程池已经满了,排队时间可能再加 100–200ms。所以「GC 停顿 350ms」对应的 P99 可能是 500–600ms。
五、结论与可复用清单
结论:P99 长尾毛刺、P50 不动、CPU 不高、慢日志干净——这四个条件同时满足时,先查 GC,再查其他。GC 是全局停顿,它的影响模式和其他瓶颈完全不同,不能靠「看 CPU」或「看慢日志」来排除。
可复用清单(按优先级):
- 开 GC 日志,P99 尖刺时对照时间戳,5 分钟确认是不是 GC;
- 调 G1 参数:IHOP 45%→60%、region 16m→32m、MixedGCCountTarget 8→16,P99 毛刺通常降 50%+;
- P99 要求 < 100ms 直接上 ZGC(JDK 15+),STW < 1ms;
- 加 GC pause 告警,别等 P99 超 SLO 才反应;
- 别用「CPU 不高」排除 GC,GC 的 CPU 特征和其他负载完全不同;
- 连接池打满可能是 GC 的下游症状,先查 GC 再调池子。
一句话:GC 不是「调参问题」,是「SLO 预算问题」。一次 350ms 的停顿就吃掉整个 P99 预算,你的所有超时、重试、熔断设计都建立在这个预算上——如果预算被 GC 吃掉,整个容错体系就失效了。