Java

Spring Boot 服务只在高峰期漂移,该从线程池、连接池还是缓存切入?

低峰没事,一到高峰就发飘,这种问题最怕被一句“流量大了”带过去。把同一实例在高低峰的线程池、连接池、缓存命中和 readiness 放在一起比,最早失稳的那一层通常会自己露出来。

  • Spring Boot
  • 性能排查
  • 线程池
  • 连接池
  • 缓存
18 分钟阅读

这类问题我一般不从“线程池、连接池、缓存先看哪个”开始问,而是先盯住一台在高峰期会发飘的实例。

12:03,它和旁边几台机器还是同一个版本、同一份配置;12:11,只有它的 /order/query p99 冲到 3.4s,readiness 还没掉,CPU 也只是 46%。如果只看那一刻,很容易得出任何一种看上去合理的判断:

  • 线程池满了。
  • Redis 抖了。
  • 数据库连接不够。
  • 流量大了,本来就该慢。

真正把事情说清楚的,不是横向看“坏实例 vs 好实例”,而是把同一台实例在高峰前后的变化放到一起。

指标11:40 低峰12:06 高峰刚起12:11 漂移明显12:15 限制回填任务后
/order/query p99220ms760ms3.4s680ms
业务线程池 active/queue18 / 046 / 3764 / 28031 / 12
Hikari active/pending14 / 021 / 039 / 919 / 0
本地缓存命中率97%91%72%93%
Redis RT p953ms5ms14ms6ms
readinessstablestable2 次短抖动stable

这张表有个很关键的信号:缓存命中率和线程池队列先开始变坏,连接池是后面才被拖紧的。

这就是高峰漂移和普通“接口一时变慢”最大的区别。它不是静态配置突然错了,而是某一层在高负载下先失去缓冲能力,后面几层才跟着分叉。

只在高峰才漂,先抓“高低峰差分”

如果问题只在高峰期出现,最有用的证据通常不是跨实例对照,而是同一实例的高低峰差分。

因为横向对照很容易被这些东西搅乱:

  • 流量分配本来就不完全平均。
  • 某台实例刚好命中不同数据集。
  • 后台任务和补偿任务未必均匀落到每台机器上。
  • readiness 抖动会反过来改变流量分布。

而高低峰差分更容易回答一个更关键的问题:

这台机器在流量压上来之后,最先失去稳定性的到底是执行层、访问层,还是缓存层?

我通常先抓四项:

  1. 线程池队列有没有先冒头。
  2. 连接池 pending 是不是晚于线程池队列出现。
  3. 缓存命中率是不是在请求耗时抬头前就开始掉。
  4. readiness 抖动是前因,还是前面几层失稳后的放大器。

这四项如果时间顺序能排出来,排查范围就会立刻变窄很多。

高峰漂移最常见的,不止一种起点

起点在缓存:命中率先掉,后面才开始排队

这类现场的长相通常是:

  • 低峰期命中率很高,看不出问题。
  • 高峰一到,热点 key 失效、预热没跟上、回源突增。
  • 线程池开始被更多回源请求占住。
  • 连接池随后才跟着紧。

如果你看到的是这种顺序,别先把锅甩给线程池。线程池只是把“缓存先失守”这件事放大出来了。

一个很实用的验证动作是:临时暂停会打散缓存局部性的回填或批量刷新任务。如果命中率、线程池队列和 p99 一起回落,说明你抓到的就是前段。

起点在线程池:执行层先没有余量了

还有一类高峰漂移,根本不是缓存先坏,而是业务线程池在高峰下先没了余量。

比如:

  • 低峰时后台异步任务和在线流量还能和平共处。
  • 高峰一到,同一个池子被在线请求和回填任务一起占满。
  • 队列先堆,接口耗时先长。
  • 缓存刷新变慢、连接池占用时间变长,都是后续反应。

这时如果你只看到 Redis RT 比平时高一点,就误判成缓存根因,会越查越散。真正值钱的是线程池使用结构:谁把池子占住了,占了多久,在线请求和后台任务是不是混在同一个资源池里。

起点在连接池:访问层先开始丢周转能力

也有些高峰漂移,真正先松动的是数据库访问层:

  • 某类高峰查询命中更大的结果集。
  • 事务时间比低峰长得多。
  • 锁等待在高峰窗口内成倍增加。
  • 连接持有时长拉长后,线程池才被反向拖住。

这类问题经常会被说成“高峰期线程池扛不住”,因为大家最先看见的是接口变慢和队列堆积。但如果 pending 的抬头早于线程池队列,或者两者同时抬头,而事务持有时间也同步拉长,就要把连接池提到更前面。

readiness 很容易把前面的轻微分叉放大成“状态漂移”

高峰问题一旦带上 readiness,就会显得特别像“实例状态不对”。

原因通常不是实例真的变成了另一套配置,而是:

  • 前面线程池、缓存或连接池已经开始波动。
  • readiness 判定又用得太硬,直接盯住了瞬时失败率、瞬时 RT 或某个脆弱下游。
  • 实例在接流量和摘流量之间来回切换。
  • 流量重新分配后,其它实例压力再被抬高,整组实例看起来像一起漂。

所以高峰漂移里看到 readiness 抖动时,我更愿意把它先当成放大器,而不是自动把它当起点。

如果你怀疑这里已经成了主要矛盾,可以直接去看高负载下 readiness 一直抖,为什么实例会在接流量和摘流量之间来回震荡?

真正在现场收敛时,我更信这两种对照

对照一:同一实例的低峰 / 高峰

这是为了找“谁先分叉”。

对照二:同一高峰窗口里的动作前 / 动作后

这是为了验证判断。

比如上面那次现场,12:13 暂停回填任务后,回落顺序是这样的:

  1. 线程池队列先从 280 掉到 90 以下。
  2. 缓存命中率开始回升。
  3. 连接池 pending 归零。
  4. readiness 不再抖。
  5. p99 回到 700ms 左右。

这个回落顺序很说明问题:线程池和缓存是前段,连接池与 readiness 更像后面被牵连出来的结果。

如果你做完动作后,最先回落的是 pending 和事务时长,那故事就会完全不同,更像访问层先出问题。

哪些动作不该一上来就做

高峰漂移最容易出现的几个“看起来像在处理,实际上在遮住现场”的动作是:

  • 一上来扩线程池。
  • 一上来扩连接池。
  • 不分场景地给 Redis、数据库、线程池一起扩容。
  • 看到 readiness 抖动就只改 probe 阈值。

这些动作有时能短暂缓和症状,但它们会把真正的先后顺序抹掉。对于“只在高峰才漂”的问题,先抓谁在高峰负载下最早失稳,比先扩哪一个池子更重要。

如果你现在要继续往下挖

高峰漂移真正麻烦的地方,从来不是它只在忙的时候出现,而是它很容易让人把“结果最明显的那一层”错认成“最先失稳的那一层”。

把同一实例的高低峰摆在一起,谁先掉出正常轨道,通常会比任何口头判断都诚实。