结论先行:「所有并发请求同时受影响 + 尖刺间 QPS 平滑 + 凌晨低 QPS 消失 + 同节点其他 Pod 无变化」这 4 个约束合起来,把嫌疑面直接缩小到 3 类,按命中率排序:
- JVM 内部全局锁 / JIT 事件(ClassLoader 锁、Metaspace 扩张、JIT 去优化)——只影响本 JVM,其他 Pod 无感,与你的现象最吻合;
- 下游共享资源的全局同步点(Redis / 日志框架 / 对象存储的 synchronized 或锁)——同样只在本 JVM 内可见,但 jstack 抓 10 次大概率错过(因为尖刺只持续 1~3s);
- OS 层 page cache reclaim / direct reclaim——理论上应波及同节点所有 Pod,但你观测到的「其他 Pod 无变化」可能是采样粒度不够细,不能完全排除。
下面给一套 30 分钟可落地的排查路径,按上面 3 类逐层收口。
第一步:用火焰图抓尖刺时刻的「CPU 在干什么」
你 jstack 抓 10 次全 RUNNABLE,但尖刺只持续 1~3s,手动触发大概率错过。改成常驻采样 + 尖刺窗口对比:
# 常驻 wall 模式采样,每 30s 一帧,保留 1 小时
./async-profiler.sh -e wall -d 30 -f /tmp/spike_wall_%H%M.txt <PID>
尖刺过后,把尖刺时刻前后各 2 分钟的 wall 火焰图叠在一起看 RUNNABLE 占比。两个典型分叉:
- RUNNABLE 集中在一两行代码(比如
ClassLoader.defineClass、Method.invoke、某个synchronized方法)→ JVM 内部锁 / JIT 问题,跳到第二步; - 大量时间花在
epoll_wait/read/ socket 超时 → 下游或 I/O 阻塞,跳到第三步。
第二步:JVM 内部机制取证
# 1. 开 JIT 事件日志,下次尖刺后看 deopt / recompile 事件时间戳
java -XX:+UnlockDiagnosticVMOptions -XX:+LogJITEvents ...
# 2. 尖刺后立刻查 Metaspace 是否增长(ClassLoader 锁的常见触发源)
jcmd <PID> VM.metaspace
# 对比尖刺前后的 used/reserved,如果 +5% 以上 → Metaspace 扩张触发 ClassLoader 锁
# 3. 确认 ClassLoader 锁:async-profiler 的 -e contention 模式
./async-profiler.sh -e contention -d 30 -f /tmp/spike_lock_%H%M.txt <PID>
# 尖刺时刻的 contention 火焰图里,看是不是 ClassLoader / String.intern / Metaspace 相关帧
量化判据:如果尖刺时刻的 wall 火焰图里 RUNNABLE 占比 > 40% 且集中在 <clinit> / defineClass / intern 这类帧,基本可以锁定 JVM 内部机制。
第三步:下游共享资源的全局同步点
这是最容易漏的一类,因为 jstack 只能看到 BLOCKED,而锁持有者本身是 RUNNABLE 的,你的 10 次 jstack 可能全抓到了「等锁的人」但没抓到「持锁的人」。
重点查 3 个地方:
-
Redis / 对象存储客户端:Jedis 的
BlockingPool、Lettuce 的EventLoopGroup是否有全局同步。jcmd <PID> Thread.print全量 dump,找尖刺时刻waiting to lock的线程,看它锁的是哪个对象(jstack 会打waiting to lock <0x00000007...>,和locked <0x00000007...>配对)。 -
日志框架:Logback 的
synchronized append、Log4j2 的RingBuffer在刷盘时全局阻塞。尖刺时刻iostat -x 1看%util是否对应——如果日志刷盘和尖刺对齐,就是日志 I/O 阻塞。 -
HikariCP 的
connectionTimeout路径:你提到 active 从 12 升到 18,但 max=50 远未打满。注意 HikariCP 的FastPathPool.getConnection()在 borrow 时会做一次isValid()检查(默认idleTimeout=10min,但 borrow 时如果连接被标记为「需要验证」会触发SELECT 1)。如果尖刺时刻 DB 侧performance_schema.events_statements_history里出现大量SELECT 1,就是连接验证风暴。
第四步:OS 层 I/O / 内存压力
# PSI(Pressure Stall Information)——比 iostat 更直接
cat /proc/pressure/io
cat /proc/pressure/memory
# 尖刺时刻如果 some/full 的 avg10 > 10,就是 OS 层 I/O 或内存压力
# page cache reclaim
sar -r 1 60 # 看 buffers/cache 列在尖刺时刻是否有骤降
# 如果 cache 骤降 + PSI memory avg10 上升 → direct reclaim
# 节点级 I/O 等待
sar -u 1 60 | grep iowait
# 尖刺时刻 iowait > 5% → OS 层 I/O 阻塞
交叉验证:如果同节点其他 Pod 的 P99 在同一时刻也有 5~10ms 的抬升(哪怕不显著),就是 OS 层;如果完全无变化,回到 JVM / 应用层。
第五步:3~5 分钟周期的来源
这个周期是强信号。排查顺序:
- 应用内
@Scheduled:你说 cron 间隔 10min 对不上,但查一下有没有非 cron 的定时任务(ScheduledExecutorService、Timer、Quartz 的interval参数),特别是 3min / 5min 的。 - 连接池的
maxLifetime:HikariCP 默认 30min,但如果你配了 5min,连接到期重建会触发 TCP 握手 + 认证,全局同步。 - JVM 的
G1PeriodicGCInterval:如果你配了这个参数(默认 0 即关闭),确认一下。 - 外部调用:有没有 3~5min 一次的批量任务(消息消费、批处理、健康检查)在同一个进程里跑?
量化判据:把 30 天尖刺时刻戳拉出来,算相邻间隔的众数和方差。如果众数在 180s 或 300s 且方差 < 30s,就是某个固定周期任务;如果方差大,就是负载触发的(比如缓存过期、GC 阈值)。
优先级排序(按投入产出比)
| 优先级 | 动作 | 耗时 | 预期命中率 | |--------|------|------|-----------| | P0 | async-profiler wall + contention 常驻采样,下次尖刺后看火焰图 | 10min 部署 + 等尖刺 | 70%(JVM 内部锁 / 下游同步点) | | P1 | PSI + sar 交叉验证 OS 层 | 5min | 15%(page cache / I/O) | | P2 | 尖刺时刻戳众数分析,定位 3~5min 周期来源 | 15min | 10%(定时任务 / 连接池生命周期) | | P3 | JIT 事件日志 + Metaspace 监控 | 10min | 5%(JIT 去优化) |
一句话:你的现象最像「JVM 内部全局锁」或「下游共享资源的全局同步点」,jstack 抓 10 次全错过是因为尖刺只持续 1~3s。换成 async-profiler 常驻采样 + 尖刺窗口对比,一次就能定位。
参考:社区 id=18《连接池不是越大越好:一次 P99 抖动的排查与调参》里「连接池只是阀门,真正杠杆是谁在持有、持有多久」这个判断,在你这个场景下同样适用——如果尖刺时刻 HikariCP active 从 12 升到 18,那 6 个新增连接大概率是被某个全局同步点(日志刷盘 / Redis / JIT)阻塞后堆积的,不是连接池本身的问题。