Java

Spring Boot 健康检查、就绪探针和优雅下线为什么经常让服务状态看起来不对?

很多“探针不准”的现场,真正错的不是某个接口返回值,而是三块钟没对齐:应用内部状态、平台探针结果、流量真正摘除的时机。把这三段时间线摆平,问题通常比单改阈值清楚得多。

  • Spring Boot
  • 健康检查
  • Readiness
  • 优雅下线
  • 故障排查
17 分钟阅读

我第一次真正把 healthreadiness 和优雅下线分清,不是在看配置文档的时候,而是在一次滚动发布里盯着一台本该平滑退出的 Pod。

19:32:04,应用收到 SIGTERM。 19:32:05,/actuator/health/readiness 已经返回 OUT_OF_SERVICE。 19:32:07,应用开始拒绝新任务,准备清空内部队列。 19:32:11,Ingress 访问日志里还能看到 37 个新请求继续打进来。 19:32:18,preStop 结束。 19:32:20,JVM 开始退出,留下 9 个被截断的请求。

如果只看其中任意一层,这次事故都能得出一个似是而非的结论:

  • readiness 都已经下线了,为什么还接到流量?
  • 应用明明还活着,为什么请求已经开始失败?
  • 明明配了优雅下线,为什么还是有人被断在半路?

这类问题之所以总显得别扭,是因为它们表面上都像“服务状态不对”,实际上混在一起的是三件不同的事:

  1. 进程是不是还活着。
  2. 实例此刻该不该接新流量。
  3. 外围流量系统有没有真的停止把请求送过来。

这三件事只要有一件没对齐,现场看起来就会像探针失灵。

先分清你修的是哪一种错位

我通常先把现场归到下面四类之一,而不是一上来改 probe:

现场现象真正错位的层次第一份该补的证据
服务还活着,但已经不该再接新请求readiness 语义和流量摘除没对齐readiness 变化时间、Ingress / LB 停止转发时间
服务明明只是短暂抖动,却被反复重启liveness 过早介入了可恢复问题liveness 失败原因、线程栈、是否存在真实卡死
Pod 已经收到下线信号,但仍有新流量进入外围摘流量慢于应用退出preStop、readiness、访问日志、连接关闭时间
实例还没准备好就被接上流量启动期和运行期语义混用首次 ready 时间、startup 阶段日志、探针配置

这张表的重要性在于:它把“状态不对”拆成了不同语义。否则很容易把所有问题都叫成“health 不准”。

这类现场最值钱的是三块钟

我一般会强行把问题画成三条时间线:

钟表它记录什么常见错觉
应用内部钟线程池、连接池、正在处理的请求、关闭钩子应用说自己准备好了,外面就一定能立刻感知到
平台探针钟livenessreadiness 的成功/失败时刻probe 状态变化就等于流量已经切换
流量系统钟Ingress / Service / LB 何时真正停止转发只要 preStop sleep 足够长,一切都会自动对齐

很多“readiness 都已经 false 了,怎么还来流量”的现场,本质上就是后两块钟没对齐:探针已经翻转,但上游摘流量还没完成。

而“明明接口已经开始大面积超时,readiness 还一直是绿的”,通常又是另一种错位:应用内部早就没有接流量余量,readiness 却还在回答一个过于宽松的问题。

readiness 里到底该放什么

readiness 最常被滥用的地方,是让它同时承担三种职责:

  • 判断进程是否存活。
  • 判断实例是否适合接流量。
  • 判断依赖是否一切完美。

这三件事混在一起,状态一定会难看。

我更愿意这样理解:

liveness 回答的是:这个进程是不是已经卡死到不该继续活着

它不该因为短暂下游波动、瞬时线程池紧张、数据库偶发慢查询就把进程杀掉。否则你得到的不是自愈,而是在故障里不断重启。

readiness 回答的是:此刻再给我新请求,成功率还有没有把握

它更该关心的是:

  • 关键工作线程是否还有余量。
  • 请求入口是否已进入主动拒绝或排队过长状态。
  • 下线中的实例是否已经明确不再接新流量。

它不该机械地把所有依赖健康都串进去。某个非核心下游临时抖一下,不代表整个实例此刻必须从流量池里消失。

优雅下线回答的是:既然不再接新流量,旧请求有没有被留出收尾窗口

这一步经常被误会成“配一个等待时间就行”。实际上真正要核对的是:

  • 应用什么时候宣布自己 not ready。
  • 上游什么时候真的不再送新请求。
  • 应用准备退出时,还有多少在途请求。
  • server / 线程池 / 连接池 的收尾窗口有没有覆盖住在途请求的尾巴。

一次下线现场里,哪些证据最能说明问题

还是拿开头那次发布来说,我最后看的不是更多文档,而是下面这张时间线:

时间事件说明
19:32:04Pod 收到 SIGTERM开始进入退出窗口
19:32:05readiness 变为 OUT_OF_SERVICE应用内部已经声明不接新流量
19:32:08业务线程池仍有 23 个活动请求老请求还没收完
19:32:11Ingress 还在路由新请求外围摘流量慢了一拍
19:32:17新请求数降到 0真正完成摘流量
19:32:20进程退出给老请求的收尾窗口只剩几秒

问题很清楚:不是 readiness 配错了,而是readiness 翻转和外围摘流量之间还有 6 秒空窗,而应用退出窗口又不够长

如果只盯着 /actuator/health 的返回值,是看不到这件事的。

真正容易踩坑的两种配置方式

把可恢复的抖动直接接进 liveness

比如:

  • 某个下游偶发超时。
  • 瞬时线程池排队。
  • 某个连接池短时间打满。

这些情况更适合触发降级、限流或 readiness 降级,不适合直接让 liveness 杀进程。否则你会得到“问题还没恢复,实例已经先被重启”的更糟现场。

把 readiness 设计成“依赖全绿才接流量”

这听起来很稳,实际上很容易让实例在轻微依赖抖动时来回上下线。

更稳的做法通常是:readiness 盯住接新流量是否还安全,而不是盯住“所有依赖是否完美”。如果实例还能靠降级、缓存、熔断维持主要路径,贸然摘流量反而可能把压力挤到别的实例上。

别只改阈值,先做一次真实下线演练

这类问题最怕在配置上空谈。真正有效的是做一台实例的可观测演练:

  1. 让单个 Pod 进入下线流程。
  2. 记录 readiness 翻转时间。
  3. 记录 Ingress / LB 最后一次把新请求送进来的时间。
  4. 记录应用内最后一个请求处理完成的时间。
  5. 再看进程退出时间有没有覆盖住这段尾巴。

如果你跑完一轮演练,看到的是“readiness 已经 false,但 5 到 10 秒内仍有新流量”,那就该回头看流量系统收敛时间,而不是继续在 probe 阈值里兜圈。

如果你看到的是“readiness 一直不掉,但线程、错误率和超时已经明显失控”,那就该回头改 readiness 语义本身,而不是只管优雅下线。

哪些情况该切去别的文章

探针问题之所以难看,往往不是因为某个字段不会配,而是因为我们拿一块钟去解释三件不同的事。

把“应用内部状态”“平台探针状态”“流量真正摘除的时刻”这三块钟对齐之后,很多原本像玄学的问题,会突然变成一条很普通的时间线问题。