Spring Boot 服务只在高峰期漂移,该从线程池、连接池还是缓存切入?
低峰没事,一到高峰就发飘,这种问题最怕被一句“流量大了”带过去。把同一实例在高低峰的线程池、连接池、缓存命中和 readiness 放在一起比,最早失稳的那一层通常会自己露出来。
这类问题我一般不从“线程池、连接池、缓存先看哪个”开始问,而是先盯住一台在高峰期会发飘的实例。
12:03,它和旁边几台机器还是同一个版本、同一份配置;12:11,只有它的 /order/query p99 冲到 3.4s,readiness 还没掉,CPU 也只是 46%。如果只看那一刻,很容易得出任何一种看上去合理的判断:
- 线程池满了。
- Redis 抖了。
- 数据库连接不够。
- 流量大了,本来就该慢。
真正把事情说清楚的,不是横向看“坏实例 vs 好实例”,而是把同一台实例在高峰前后的变化放到一起。
| 指标 | 11:40 低峰 | 12:06 高峰刚起 | 12:11 漂移明显 | 12:15 限制回填任务后 |
|---|---|---|---|---|
/order/query p99 | 220ms | 760ms | 3.4s | 680ms |
业务线程池 active/queue | 18 / 0 | 46 / 37 | 64 / 280 | 31 / 12 |
Hikari active/pending | 14 / 0 | 21 / 0 | 39 / 9 | 19 / 0 |
| 本地缓存命中率 | 97% | 91% | 72% | 93% |
| Redis RT p95 | 3ms | 5ms | 14ms | 6ms |
| readiness | stable | stable | 2 次短抖动 | stable |
这张表有个很关键的信号:缓存命中率和线程池队列先开始变坏,连接池是后面才被拖紧的。
这就是高峰漂移和普通“接口一时变慢”最大的区别。它不是静态配置突然错了,而是某一层在高负载下先失去缓冲能力,后面几层才跟着分叉。
只在高峰才漂,先抓“高低峰差分”
如果问题只在高峰期出现,最有用的证据通常不是跨实例对照,而是同一实例的高低峰差分。
因为横向对照很容易被这些东西搅乱:
- 流量分配本来就不完全平均。
- 某台实例刚好命中不同数据集。
- 后台任务和补偿任务未必均匀落到每台机器上。
- readiness 抖动会反过来改变流量分布。
而高低峰差分更容易回答一个更关键的问题:
这台机器在流量压上来之后,最先失去稳定性的到底是执行层、访问层,还是缓存层?
我通常先抓四项:
- 线程池队列有没有先冒头。
- 连接池
pending是不是晚于线程池队列出现。 - 缓存命中率是不是在请求耗时抬头前就开始掉。
- readiness 抖动是前因,还是前面几层失稳后的放大器。
这四项如果时间顺序能排出来,排查范围就会立刻变窄很多。
高峰漂移最常见的,不止一种起点
起点在缓存:命中率先掉,后面才开始排队
这类现场的长相通常是:
- 低峰期命中率很高,看不出问题。
- 高峰一到,热点 key 失效、预热没跟上、回源突增。
- 线程池开始被更多回源请求占住。
- 连接池随后才跟着紧。
如果你看到的是这种顺序,别先把锅甩给线程池。线程池只是把“缓存先失守”这件事放大出来了。
一个很实用的验证动作是:临时暂停会打散缓存局部性的回填或批量刷新任务。如果命中率、线程池队列和 p99 一起回落,说明你抓到的就是前段。
起点在线程池:执行层先没有余量了
还有一类高峰漂移,根本不是缓存先坏,而是业务线程池在高峰下先没了余量。
比如:
- 低峰时后台异步任务和在线流量还能和平共处。
- 高峰一到,同一个池子被在线请求和回填任务一起占满。
- 队列先堆,接口耗时先长。
- 缓存刷新变慢、连接池占用时间变长,都是后续反应。
这时如果你只看到 Redis RT 比平时高一点,就误判成缓存根因,会越查越散。真正值钱的是线程池使用结构:谁把池子占住了,占了多久,在线请求和后台任务是不是混在同一个资源池里。
起点在连接池:访问层先开始丢周转能力
也有些高峰漂移,真正先松动的是数据库访问层:
- 某类高峰查询命中更大的结果集。
- 事务时间比低峰长得多。
- 锁等待在高峰窗口内成倍增加。
- 连接持有时长拉长后,线程池才被反向拖住。
这类问题经常会被说成“高峰期线程池扛不住”,因为大家最先看见的是接口变慢和队列堆积。但如果 pending 的抬头早于线程池队列,或者两者同时抬头,而事务持有时间也同步拉长,就要把连接池提到更前面。
readiness 很容易把前面的轻微分叉放大成“状态漂移”
高峰问题一旦带上 readiness,就会显得特别像“实例状态不对”。
原因通常不是实例真的变成了另一套配置,而是:
- 前面线程池、缓存或连接池已经开始波动。
- readiness 判定又用得太硬,直接盯住了瞬时失败率、瞬时 RT 或某个脆弱下游。
- 实例在接流量和摘流量之间来回切换。
- 流量重新分配后,其它实例压力再被抬高,整组实例看起来像一起漂。
所以高峰漂移里看到 readiness 抖动时,我更愿意把它先当成放大器,而不是自动把它当起点。
如果你怀疑这里已经成了主要矛盾,可以直接去看高负载下 readiness 一直抖,为什么实例会在接流量和摘流量之间来回震荡?。
真正在现场收敛时,我更信这两种对照
对照一:同一实例的低峰 / 高峰
这是为了找“谁先分叉”。
对照二:同一高峰窗口里的动作前 / 动作后
这是为了验证判断。
比如上面那次现场,12:13 暂停回填任务后,回落顺序是这样的:
- 线程池队列先从 280 掉到 90 以下。
- 缓存命中率开始回升。
- 连接池
pending归零。 - readiness 不再抖。
- p99 回到 700ms 左右。
这个回落顺序很说明问题:线程池和缓存是前段,连接池与 readiness 更像后面被牵连出来的结果。
如果你做完动作后,最先回落的是 pending 和事务时长,那故事就会完全不同,更像访问层先出问题。
哪些动作不该一上来就做
高峰漂移最容易出现的几个“看起来像在处理,实际上在遮住现场”的动作是:
- 一上来扩线程池。
- 一上来扩连接池。
- 不分场景地给 Redis、数据库、线程池一起扩容。
- 看到 readiness 抖动就只改 probe 阈值。
这些动作有时能短暂缓和症状,但它们会把真正的先后顺序抹掉。对于“只在高峰才漂”的问题,先抓谁在高峰负载下最早失稳,比先扩哪一个池子更重要。
如果你现在要继续往下挖
- 高峰之外也在漂,或者发布之后整组状态开始分叉,更该去看Spring Boot 服务发布后状态开始漂移,先看最早偏移的是配置、依赖还是运行态。
- 你已经确定主要矛盾是慢请求、线程池、连接池之间的先后关系,去看Spring Boot Actuator、线程池、连接池、慢请求这些运行态观测点,应该怎么串起来看?。
- 现在最大的麻烦是缓存被打穿后的回源放大,可以直接切去缓存专题,不必继续在这篇里兜。
高峰漂移真正麻烦的地方,从来不是它只在忙的时候出现,而是它很容易让人把“结果最明显的那一层”错认成“最先失稳的那一层”。
把同一实例的高低峰摆在一起,谁先掉出正常轨道,通常会比任何口头判断都诚实。