【背景】
一个订单查询服务,4C8G 容器(K8s,limit 4C / 8G),JDK 17,堆 -Xms6g -Xmx6g,-XX:MaxGCPauseMillis=50,RegionSize 自动(4M)。日常 QPS 2-3k,对象分配率约 60MB/s,老年代占用长期在 30-40% 波动。
【现象】
线上 P99 偶发 80-120ms(SLO 是 80ms),GC 日志显示 Mixed GC 单次停顿 40-70ms,频率约 1.5 次/分钟。于是我把 MaxGCPauseMillis 从 50 调到 20,观察了 48 小时,结果:
- 单次停顿确实降到 20-30ms ✓
- 但 Mixed GC 频率升到约 3 次/分钟(因为每次要回收更少的 region 才能压进 20ms 预算)
- 每次 Mixed GC 回收的旧代对象量变少 → 老年代回收速度跟不上分配晋升速度 → 老年代占用从 35% 爬到 55%
- 最终触发 IHOP → 一次约 450ms 的并发标记收尾 + 退化为近 Full GC 的 STW
- 总 GC 时间(wall-clock)从 50ms/min 涨到约 80ms/min,CPU 上 GC 线程占比从 8% 涨到 14%
- 业务 P99 反而从 85ms 涨到 95ms(GC 总占用更多了)
简单说:单次停顿变小了,但 GC 干活的总时间变长了,整体更慢。
【已尝试的排查与结果】
- 调大 -XX:G1HeapRegionSize=8m:humongous 对象(业务里有 3-6MB 的响应体序列化缓冲)从 2 个 region 变 1 个,减少了碎片,但没解决 Mixed GC 频率问题,老年代爬升速度没变。
- 加 -XX:G1MixedGCCountTarget=8(默认 5):想拉长每次 Mixed GC 的摊销,结果发现 G1 内部会自适应地忽略这个值(日志里实际还是按 5 次分片),效果不明显。
- 试了 -XX:InitiatingHeapOccupancyPercent=30(把并发标记提前触发):老年代爬升确实慢了,但并发标记线程和年轻代分配抢 CPU,P99 在标记期间反而有 10-15ms 的抖动。
- 考虑换 ZGC(JDK 17):理论上停顿 <1ms 能彻底解掉单次停顿问题,但 4C 机器上 ZGC 的并发线程数(默认 = CPU 核数)会不会把业务 CPU 打满,我们没地方做压测验证。
【期望得到的答案形态】
- 你们线上有没有量化过 MaxGCPauseMillis 和 GC 总耗时之间的非线性关系(比如从 20ms 到 100ms 各扫一遍,记录 GC 总时间 / 频率 / 老年代爬升速度的曲线)?如果有,最优停顿目标大概在什么范围?
- 在 4C8G、分配率 60MB/s、老年代长期 30-40% 这个量级下,你们实际用的是什么参数组合?有没有人发现 MaxGCPauseMillis 设到某个值以下后,GC 总时间就开始指数级恶化?
- 有没有比「调 MaxGCPauseMillis」更本质的手段?比如控制晋升率(-XX:MaxTenuringThreshold / -XX:G1OldGenUtilsEnabled)、或者用 -XX:G1ReservePercent 给 evacuation 留更充裕的余量?
- 4C 容器上 ZGC 的并发开销有没有人实测过?(JDK 17 的 ZGC 不是 generational 版本,并发标记和 evacuation 线程数对 CPU 的占用到底多大?)
开放问题:我怀疑对很多团队来说,「单次停顿最小化」和「GC 总耗时最小化」的最优解根本不在同一个点,但我们习惯性地只调前者。你们有没有把「GC 总时间占比」作为独立 SLO 来管的实践?