Java

高负载下 readiness 一直抖时,实例其实不是“坏了”,而是在容量边缘来回掉线

高负载下 readiness 一直抖,往往不是探针接口自己坏了,而是实例的接流量能力反复跨过阈值:线程池、连接池、下游等待和探针判定一起顶到了边缘。先看谁最早失稳,再决定要不要动探针。

  • Spring Boot
  • Readiness
  • 高负载
  • 健康检查
  • 故障排查
18 分钟阅读

readiness 抖动最烦的地方,不是它让实例直接死掉,而是它让实例处在一种很难看的半失控状态:

  • 一会儿 ready,一会儿 unready
  • 刚接回一点流量,又被摘掉
  • 集群副本数看着还在,用户体验却越来越差

这类现场最容易被一句话带偏:

探针太敏感了,调宽一点就好了。

这句话有时候只说对了一半。因为高负载下 readiness 抖动,很多时候不是探针本身出问题,而是实例的接流量能力已经跑在容量边缘,探针只是碰巧把这个事实暴露出来。

我会先分清:它是真的坏了,还是“不适合继续接新流量”

这两个状态很像,但处理方式不一样。

真的坏了

比如:

  • 进程卡死
  • JVM 已经 OOM 或持续 full GC
  • 关键依赖彻底不可用
  • 应用线程几乎完全拿不到执行机会

这种情况下 readiness 失败只是表象,本体是实例已经不可服务。

还活着,但一接新流量就更糟

这种更常见。

实例并没有完全死,但只要继续加流量,下面某一层就会先跨过阈值:

  • Tomcat / Netty 工作线程先满
  • 业务线程池 queue 开始堆
  • 连接池归还速度下降
  • 下游 RT 拉长,健康检查也跟着超时

于是实例被摘流;摘流后压力稍微缓一口气,它又恢复 ready;一恢复又接流量,然后再次失稳。你看到的就是“来回震荡”。

为什么会震荡,本质上是接流量能力反复跨过阈值

我更习惯把 readiness flap 理解成一个容量问题,而不是一个布尔状态问题。

想象一个实例的安全承载范围大概是每秒 800 请求;但现在集群在高峰下把它推到 750 到 900 之间来回波动。只要再叠加一点点下游变慢、连接归还变慢、GC 抖一下,探针判定就会从“还能接”切到“别再接了”。

于是现场常常长这样:

14:01  qps 上升
14:03  tomcat.busy 接近上限
14:04  hikari.pending 开始出现
14:05  readiness probe timeout
14:05  pod 被摘流
14:07  压力下降,probe 恢复成功
14:08  pod 重新接流
14:10  再次超时,被摘流

如果这条时间线是成立的,那探针并不是根因,它只是站在最末端宣布“这台机器现在不适合继续接单了”。

我最常看到的三条震荡链

第一条:入口线程先满,探针请求自己也被堵住

这是最直接的一种。

高峰流量把 Web 容器工作线程占满以后,健康检查请求本身也排不到执行机会,于是你看见的是 readiness 超时。但真实根因在入口线程已经顶满。

这时我会优先看:

  • tomcat.threads.busy
  • http.server.requests 的高分位 RT
  • 入口层 reject / timeout

第二条:业务线程池或连接池先失稳,探针只是后知后觉

有些项目的健康检查接口很轻,本身并不耗资源;但业务线程池和数据库连接池一旦开始排队,应用整体响应能力就会急剧下降,探针也会跟着变慢。

这种现场里,最早冒头的通常不是 health endpoint,而是:

  • 自定义线程池 queue / active
  • Hikari pending / acquire time
  • 下游数据库或 RPC RT

第三条:readiness 依赖了过重、过敏的检查项

也有些项目确实把 probe 设计得太“认真”了:

  • readiness 里直接打数据库
  • readiness 里调用外部依赖
  • readiness 里塞进很重的自定义检查

这会导致实例稍微有点抖动,probe 就立刻跟着一起抖。但即使如此,我也不会直接停在“探针写坏了”这层,而是仍然会先问:它为什么在高负载时更容易暴露这个问题?

为什么我通常不先改 probe 阈值

因为阈值调宽,很容易只是把告警和摘流时间往后拖几秒。

如果真正的问题是线程池、连接池或者某个下游先失稳,那你把 timeoutSecondsfailureThreshold 调大,最多只是让实例在更差的状态里多接一会儿流量。短期看波动可能少一点,长期看却可能把失败做得更深。

所以我更愿意先找“谁最早变坏”,再决定 probe 要不要动。

真到线上,我会先看哪几类证据

先看事件和时间线

kubectl describe pod <pod-name>
kubectl get events --sort-by=.lastTimestamp

我要确认的是:

  • probe fail 是从什么时候开始
  • fail 前后有没有扩容、摘流、重启、节点抖动
  • fail 和业务指标抬头谁在前谁在后

再看入口、执行层、数据库访问层谁先抬头

我通常会把下面这些指标放一起看:

  • Web 容器 busy thread
  • 自定义业务线程池 queue / active
  • Hikari active / pending
  • 下游数据库或 RPC RT
  • health endpoint 自身 RT

如果入口线程最早打满,方向就很清楚;如果连接池 pending 先涨,就别只盯 probe 本身。

最后才看 readiness 判定条件本身

这时才去确认:

  • readiness health group 里到底挂了什么检查项
  • 超时阈值是不是明显偏紧
  • 成功 / 失败阈值有没有滞后空间
  • 恢复 ready 后是否马上就重新接满流量

震荡为什么会越来越严重

因为实例被摘流再接回时,往往不是“满血回归”,而是“半冷状态回归”。

  • 本地缓存可能还没热
  • 连接池刚刚恢复
  • JIT / 热路径状态没稳定
  • 剩余实例刚经历过额外压力

于是每次恢复 ready,集群都像是在把一个刚喘过气的实例重新推回高压线。只要负载还在边缘,下一次掉线几乎是注定的。

如果这时自动扩缩容的节奏又和摘流节奏打架,抖动会更明显。

我更习惯的止血顺序

第一步:先减轻继续放大的那部分流量

能限流、降级、关重路径、停回填任务的,先做。先把继续把实例往阈值外推的力量减下来。

第二步:确认健康检查是不是背了不该背的责任

如果 readiness 依赖了过重的检查项,当然要改;但这一步是在知道主链之后做,而不是一上来就当成唯一动作。

第三步:给恢复留一点缓冲,而不是立刻重新接满

有些系统的问题不是 fail 太快,而是恢复后重新接流过快。没有缓冲带,实例只会再次被立刻打回阈值外。

第四步:最后再决定 probe 参数是否需要调整

这时候调参数才是有依据的,不是在拿阈值碰运气。

最容易误判的地方

误判一:readiness 抖动就等于探针配置错了

不一定。更多时候是探针把容量边缘的问题照出来了。

误判二:实例能恢复 ready,说明问题不严重

恰恰相反,能恢复又立刻再掉,往往说明系统正在边缘震荡。

误判三:只要把阈值调宽,就能解决问题

很多时候只是把故障做得更隐蔽,而不是更轻。

最后收一句

高负载下 readiness 一直抖,真正要找的不是“为什么健康检查不稳定”,而是:到底是哪一层先把实例的接流量能力打穿了。

先把这个问题答出来,再去决定是改 probe、改隔离、改路由还是先止血,顺序才不会倒。