ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot健康检查实战:liveness/readiness探针与自定义检查器

SpringBoot健康检查实战:liveness/readiness探针与自定义检查器

1. 为什么拼力写的健康检查,到了线上却不救人

先说一句大实话:绝大多数 SpringBoot 项目的健康检查,都是“写了,但没完全写”。开发环境跑起来访问一下/actuator/health,看到{"status":"UP"}就觉得万事大吉。可这套默认机制放到线上,面对流量、依赖、容器重启这些真实场景,暴露出的盲区一个比一个致命。

1.1 默认健康检查能发现什么,不能发现什么

SpringBoot Actuator 的健康检查核心是HealthContributorRegistry,你引入spring-boot-starter-actuator之后,框架会自动注册一堆HealthIndicator:数据源、Redis、MongoDB、Elasticsearch、RabbitMQ……每个组件的健康状态会被聚合成最终的UP、DOWN或OUT_OF_SERVICE。

但注意两个致命前提。

第一,这些自动装配的健康指示器只覆盖“Spring 容器里能感知到的 Bean”。你的系统依赖了一个外部 HTTP 接口、一个自定义的 RPC 服务、一套共享文件存储,这些对 Actuator 来说完全是黑盒。数据库活着、Redis 活着,返回UP,但你的核心业务接口调用第三方服务已经超时两分钟了——线上表现是接口一个个 5xx,健康检查却还举着绿旗。

第二,默认健康检查的“检查动作”本身可能比较廉价。比如数据源健康检查,本质上就是connection.isValid(timeout),能拿到连接就认为健康。但连接池可能已经满了,新的请求在池子里排队等到超时;或者数据库 CPU 已经打满,查询 10 秒才返回,但连接依然能建立。你得到的还是一个UP。

这就解释了为什么很多团队会困惑:“我明明配置了健康检查,为什么线上出故障时,它压根没报警?”因为默认的健康检查衡量的是“进程能不能连上依赖”,而不是“系统能不能正常完成业务”。

1.2 一次“看起来很健康”的线上事故复盘

讲一个我实际经历过的案例,可能更容易理解。

某个提供文件预览服务的应用,核心链路是:浏览器请求预览接口 → 应用读取存储上的文件 → 调用转换服务生成预览图 → 返回前端展示。存储用的是自建的分布式文件系统,转换服务是独立部署的一组 worker 节点。

某天下午,存储集群的一台节点磁盘 IO 出现严重抖动,读写延迟从几毫秒飙升到几秒钟。应用这边,所有读文件请求在等待 IO 返回,线程池堆积,接口响应时间直线上升。监控面板上,redis_health是绿的,db_health是绿的,因为这两个组件确实没问题。/actuator/health返回UP,K8s 的 readiness 探针自然也是绿的,平台没有重启任何实例。

结果就是:用户看到的是页面一直转圈、接口超时、大量 5xx;运维看到的是健康检查全绿、实例数正常、CPU 也不高。排障的时候大家一度怀疑是网关出了问题,查了半天才定位到存储 IO。如果当时健康检查里包含了对存储读取延迟的探活,这个故障在用户感知之前就会被探针暴露出来,至少能把故障实例摘掉,避免流量继续灌进来。

健康检查的意义不在于让监控面板变好看,而在于:它得能反映真实业务是否可用。做不到这一点,线上的救命稻草就是摆设。

2. 两个端点两套逻辑:生存与就绪绝不能混为一谈

SpringBoot Actuator 从 2.0 开始就把健康检查分组了,但很多项目根本没用过这个特性。默认情况下/actuator/health是聚合状态,而容器编排平台(K8s、Docker Swarm 等)实际需要的是细分的探针。搞清楚liveness和readiness的区别,是健康检查落地线上最重要的一步。

2.1 生存探针:进程活着不等于业务可用

先解释两个词,因为后面所有配置都绕不开。

Liveness(存活):问的是“这个进程还活着吗?”如果进程死锁了、OOM 了、主线程卡死了,那 liveness 应该失败,容器编排平台会杀掉这个实例,重新拉一个新的。

Readiness(就绪):问的是“这个实例现在能接收流量吗?”如果实例虽然活着,但正在启动、正在做预热、依赖还没就绪、正在被摘流下线,那 readiness 应该失败,平台会停止往这个实例转发流量。

默认情况下/actuator/health返回的聚合状态,实际上同时承担了这两个语义,但用在容器环境里就会出问题。

举个例子:假设应用启动需要 30 秒,但 Spring 容器在 5 秒后就返回了UP,K8s 的 liveness 探针把UP视为“进程健康”,不会重启;readiness 探针把UP视为“可以接流量”,于是流量在应用还没完成初始化时就被打进来了。表现就是发布后头一两分钟有一批 5xx,然后慢慢恢复。

反过来,如果应用在运行期间出现了一个不影响主流程但会导致readiness失败的问题(比如某个非关键依赖断开),而配置的探针是 liveness,平台会直接重启实例。重启会造成全量流量中断,比那个依赖故障本身严重得多。

所以线上最佳实践一定是分开配置:liveness 用进程级探针,readiness 用业务级探针。SpringBoot 恰好支持这两套逻辑独立配置。

2.2 就绪探针:流量开关与发布安全的基石

SpringBoot 里对 readiness 的原生支持,核心实现是AvailabilityChangeEvent机制。应用在启动过程中会经历两个阶段:LivenessState.CORRECT和ReadinessState.ACCEPTING_TRAFFIC。你可以监听这个事件,在应用完全准备好之后再开放流量。

一个常用的配置方式是:

management: endpoint: health: group: readiness: include: readinessState,redis,db,storage liveness: include: livenessState endpoints: web: exposure: include: health,info

这是 SpringBoot 2.2 之后引入的“健康组”机制。readiness组里除了框架自带的readinessState,还额外包含redis、db、storage三类检查;liveness组则只包含livenessState(进程是否活着,不涉及外部依赖)。

这样配置的好处是:

  • 如果 Redis 故障,readiness 探针失败,平台摘掉实例流量,但不重启实例;Redis 恢复后,探针自动重新变绿,流量自动恢复。
  • 如果应用发生死锁,liveness 探针失败(框架内置的livenessState实际上不是通过健康检查返回失败来触发,而是应用完全失联后平台无法访问探针端点,触发重启),进程被拉起。

K8s 中对应的探针配置是:

livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 30 periodSeconds: 30 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 10

注意路径:/actuator/health/liveness和/actuator/health/readiness,这两个端点只有在配置了健康组之后才会暴露。

提示:不要在 liveness 里塞太多外部依赖检查。liveness 失败会导致实例被重启,如果依赖短暂抖动,你会看到实例被反复重启,这比依赖故障本身还糟糕。liveness 只关心进程存活,这是原则。

2.3 自定义分组端点与端点暴露配置

除了 readiness 和 liveness,你还可以定义一些业务维度的健康组。比如:

management: endpoint: health: group: dependencies: include: redis,db,mq,storage business: include: coreApi,priceRule

然后通过/actuator/health/dependencies、/actuator/health/business来访问。这个能力在排查问题时特别好用:当全局健康检查显示DOWN,你可以分别看依赖组和业务组,快速确认是哪一类组件出了问题。

需要注意端点暴露配置。SpringBoot 默认只暴露health和info两个端点,其他端点不会暴露。如果你在 YAML 里配置了management.endpoints.web.exposure.include: "*",那是全部暴露,生产环境不建议这样干,至少要把shutdown、env、beans这些敏感端点排除掉。

我的习惯配置是:

management: endpoints: web: exposure: include: health,info,metrics,prometheus

健康检查和监控指标通过同一个端口暴露,不额外开端口,也不开放敏感操作端点。

3. 自定义健康检查器:把“不可用”变成能被平台看见的状态

默认的HealthIndicator覆盖不了你系统的真实依赖。真正解决线上事故,必须为关键业务链路编写自定义健康检查器。这一节我会带着从一个最简单的例子开始,逐步增加复杂度。

3.1 从零实现一个数据源健康检查器

先写一个标准的自定义HealthIndicator:

import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.sql.Connection; import java.sql.Statement; @Component public class CustomDataSourceHealthIndicator implements HealthIndicator { private final DataSource dataSource; public CustomDataSourceHealthIndicator(DataSource dataSource) { this.dataSource = dataSource; } @Override public Health health() { try (Connection connection = dataSource.getConnection(2, 2)) { try (Statement statement = connection.createStatement()) { statement.setQueryTimeout(2); statement.execute("SELECT 1"); } return Health.up() .withDetail("database", "reachable") .build(); } catch (Exception e) { return Health.down() .withDetail("error", e.getClass().getSimpleName()) .withDetail("message", e.getMessage()) .build(); } } }

这里有几个关键细节值得展开。

getConnection(2, 2)的意思是:获取连接最多等 2 秒。默认的DataSourceHealthIndicator用的事务超时可能比较宽松,在高负载场景下,连接池耗尽时你还能拿到连接吗?不一定。但getConnection(2, 2)强制要求连接池在 2 秒内返回连接,否则直接判定DOWN。这就是把“连接池资源耗尽”纳入健康检查的手段。

statement.setQueryTimeout(2)是为了兜底:即使拿到了连接,查询超过 2 秒也算失败。这个超时时间需要结合你自己的业务来定,如果 SQL 查询本身就需要 5 秒,那健康检查超时设 2 秒会导致误报。

Health.down()时携带error和message,不是给用户看的,而是给排查的人看的。健康检查端点返回的详情默认不展示,但可以通过management.endpoint.health.show-details: when_authorized给运维人员开放。

3.2 缓存组件健康检查与懒加载陷阱

再讲一个实战中最常见的坑:Redis 健康检查在 Spring Boot 中的默认实现。

RedisHealthIndicator发送PING,看返回是不是PONG。看起来没问题,但分两种情况:

  • Lettuce 连接池正常时,这个检查很快。
  • 连接池满了或者 Redis 阻塞了,PING也能 PONG,但实际读写命令已经被堵住了。

所以如果你的业务大量依赖 Redis,默认检查是不够的。更贴近实际的做法:写一个RedisTemplateHealthIndicator,真正执行一次GET操作,而不是只PING。

@Component public class RedisTemplateHealthIndicator implements HealthIndicator { private static final String HEALTH_CHECK_KEY = "health:check"; private final StringRedisTemplate redisTemplate; public RedisTemplateHealthIndicator(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @Override public Health health() { try { long start = System.currentTimeMillis(); String value = redisTemplate.opsForValue().get(HEALTH_CHECK_KEY); long cost = System.currentTimeMillis() - start; if (cost > 1000) { return Health.down() .withDetail("reason", "response too slow") .withDetail("costMs", cost) .build(); } return Health.up() .withDetail("cache", "ok") .withDetail("costMs", cost) .build(); } catch (Exception e) { return Health.down() .withDetail("error", e.getMessage()) .build(); } } }

这里有一个非常容易被忽略的坑:Redis 客户端是懒加载连接的。你访问一个不存在的 key,或者连接池空闲时执行GET,如果 Redis 节点本身没挂,这个操作会很快返回。但如果 Redis 节点网络分区了,连接池里的连接已经失效,GET会抛异常,健康状态才会变DOWN。问题是 Lettuce 默认的连接池如果配置了max-wait,而且业务代码里的操作都经过了超时控制,健康检查这步可能踩不到那个异常,你看到的还是UP。

所以在自定义 Redis 健康检查时,务必给操作设置短路超时。上面代码里我用的是cost > 1000来判定慢操作,更严格的做法是使用RedisTemplate配置好的连接工厂,设置timeout。也可以在application.yml里给 Lettuce 设置连接超时和读取超时:

spring: data: redis: timeout: 800ms lettuce: pool: max-active: 50 max-wait: 800ms

这样健康检查里的GET操作在 800ms 内要么成功,要么抛出超时异常,进入DOWN分支。

3.3 用HealthIndicator状态构建k8s探针

自定义的健康检查器默认会参与全局聚合状态,但你未必想让所有检查都影响同一组探针。

比如你的应用依赖一个内部计费系统,计费系统短时间不可用,你希望应用实例继续运行、继续接收一部分不需要计费的流量,只是计费相关的接口返回错误。这时如果计费健康检查失败导致全局DOWN,K8s readiness 探针直接摘掉所有流量,这可能比你预期的范围更大。

解决办法:把健康检查器分成两组,一组是“核心依赖组”,另一组是“非核心依赖组”。可以利用 SpringBoot 的健康组配置:

management: endpoint: health: group: core: include: db,redis,coreApi nonCore: include: billing,recommend

然后在 K8s readiness 探针里指向/actuator/health/core,而不是/actuator/health/readiness。这样计费系统挂掉不会影响整体流量,核心依赖挂了才会摘流量。

一个容易踩的细节:include里用到的名字,必须是 Actuator 暴露出来的健康指标的 id。自定义的HealthIndicator组件名默认是类名去掉HealthIndicator后,首字母小写。比如CustomDataSourceHealthIndicator的 id 就是customDataSource。如果你想让 id 更清晰,实现HealthIndicator时可以通过@Component("storageHealth")这种方式给 Bean 设置名字,健康指标的 id 会跟随 Bean 名。

4. 接入容器编排平台后:探针参数是一道认真算的算术题

健康检查本身配置对了之后,另一个高频事故点出现在探针参数上。K8s 的initialDelaySeconds、periodSeconds、timeoutSeconds、failureThreshold,每一个参数都可能决定发布是否顺畅、故障时恢复是否及时。

4.1 liveness与readiness的配置示例

一个典型的 SpringBoot 应用在 K8s 中的探针配置:

livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 scheme: HTTP initialDelaySeconds: 30 periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 scheme: HTTP initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3

这里每个参数我建议都按照首屏加载时间、JIT 预热时间、外部依赖握手时间来算。

  • initialDelaySeconds(liveness):给应用启动预留的时间。SpringBoot 应用如果启动要 25 秒(包括 JVM 冷启动、Spring 上下文初始化、连接池创建),那 liveness 的 initialDelay 至少 30 秒。设置太短,应用还在初始化,探针访问/actuator/health/liveness返回 404 或连接拒绝,容器会被当成不健康然后被杀掉重启,陷入重启循环。
  • periodSeconds:探针轮询周期。liveness 不用太频繁,30 秒一次足够;readiness 可以密一些,10 秒一次,这样发布摘流和恢复更快。
  • timeoutSeconds:单次探针请求的超时。健康检查端点如果内部执行了外部依赖检查,比如上面自定义的 RedisGET超时 800ms,那探针 timeout 设 3 秒是合理的。如果 timeout 比健康检查自身的超时还短,探针会因为“响应超时”被判失败,但健康检查实际返回的是UP,这会误导排查。
  • failureThreshold:连续失败多少次才把实例标记为不健康。readiness 设为 3 次,代表 30 秒(3 * periodSeconds 10s)的持续不健康才摘流。如果网络抖动导致健康检查偶尔失败,也不至于频繁摘流。

一个我在多个团队都见到过的错误:把 liveness 探针和 readiness 探针配成了同一个路径/actuator/health。这等于把两组语义完全混在一起,外部依赖抖动时,实例会被反复重启。健康的做法是路径分别指向两个健康组。

4.2 调参心法与踩坑实录

探针参数调优没有银弹,但我有一套比较好用的推导逻辑。

先用一个公式估算健康检查端点的最坏响应时间。假设健康检查里最慢的一步来自外部依赖检查,例如 RedisGET超时 800ms,数据库SELECT 1超时 2 秒。如果这组检查是串行执行的,最坏情况健康检查端点需要 2.8 秒才返回。那么探针timeoutSeconds一定要大于这个最坏值,留 30% 的冗余,比如设 4 秒或 5 秒。

再看failureThreshold与故障恢复速度的平衡。假设 readiness 探针的 period 是 10 秒,failureThreshold 是 3,从第一次失败到摘流需要 30 秒。这 30 秒内流量还会继续打到这个故障实例上,用户会感知到错误。如果你希望更快摘流,可以把 period 调到 5 秒,failureThreshold 调到 2,这样从失败到摘流只需要 10 秒。代价是更频繁的健康检查请求,给应用带来额外压力。SpringBoot 健康检查本身很轻量,只要没在健康检查里做重量级动作(比如查全表),这个代价通常可接受。

发布过程中的一个经典问题:readiness 探针判定通过时,服务是不是已经能处理流量?不一定。如果应用在 readiness 变绿之前还有预热逻辑(比如加载规则、填充本地缓存),那这段时间流量已经进来了,请求依然会超时。我的做法是:在应用启动流程中,把预热逻辑放进ApplicationRunner,等预热完成后再把 Spring 容器切换为ACCEPTING_TRAFFIC状态。具体可以通过AvailabilityChangeEvent.publish(this, ReadinessState.ACCEPTING_TRAFFIC)来控制。

另一个隐蔽的坑:配置了management.server.port把 Actuator 暴露在独立端口时,K8s 探针的port要指向那个独立端口,而不是业务端口。我见过有人业务端口和监控端口混在一起,或者探针配置的端口和实际监听端口不一致,导致探针一直失败,整个 Deployment 被反复重启。

5. 健康检查的失效兜底:不依赖探针的优雅上下线

健康检查是线上防御的核心,但它不是银弹。探针探测是有周期的,两次探测之间的空窗期里,故障可能已经被用户感知了。所以要真正“避免线上事故”,还需要一套不依赖健康检查的兜底机制:优雅停机、流量摘除与恢复联动。

5.1 优雅停机与线程池收尾

SpringBoot 2.3 之后内置了优雅停机支持:

server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s

这个配置的含义是:当应用收到 SIGTERM 信号时,停止接收新请求,等待处理中的请求执行完,超过 30 秒的请求会被强制结束。如果没有这个配置,K8s 在滚动发布时发送 SIGTERM,SpringBoot 会立即关闭 Web 容器,正在处理的请求直接断掉,用户的请求就是一个 5xx。

但光有这个还不够,你的线程池必须配合优雅停机。比如你有一个ThreadPoolTaskExecutor,如果停机时线程池立即被销毁,等待中的任务全部丢失。正确做法是监听停机事件,给线程池一个 shutdown 和等待周期。

@Component public class GracefulShutdownListener { private final ExecutorService bizExecutor; public GracefulShutdownListener(@Qualifier("bizExecutor") ExecutorService bizExecutor) { this.bizExecutor = bizExecutor; } @EventListener(ContextClosedEvent.class) public void onShutdown() { bizExecutor.shutdown(); try { if (!bizExecutor.awaitTermination(30, TimeUnit.SECONDS)) { bizExecutor.shutdownNow(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); bizExecutor.shutdownNow(); } } }

这样发布时,K8s 先摘掉 readiness(通过 preStop hook 或者服务网格配置),再发 SIGTERM,SpringBoot 优雅停机,线程池等待任务跑完再销毁。用户几乎无感知。

5.2 灰度发布期间的健康检查联动

灰度发布时,健康检查的意义不只是“实例是否可用”,还包括“新版本实例是否达到了可承接流量的标准”。

举个例子:你发布了一个新版本,旧的健康检查只检查数据库和 Redis,新版本启动之后 10 秒就探针全绿、开始接流量。但新版本有一个本地规则缓存,需要从配置中心拉取完规则后才算真正可用,这个拉取需要 20 秒。结果就是,新版本在规则未就绪时承接了流量,返回错误,用户报障。

针对这种场景,我习惯在健康检查器里加一个“预热完成标记”:

@Component @Slf4j public class WarmupHealthIndicator implements HealthIndicator { private volatile boolean warmedUp = false; @EventListener(ApplicationReadyEvent.class) public void onReady() { // 执行预热:拉取规则、填充缓存、预创建连接池 try { warmUp(); warmedUp = true; } catch (Exception e) { log.error("Warmup failed", e); } } @Override public Health health() { return warmedUp ? Health.up().build() : Health.down().withDetail("reason", "not warmed up").build(); } }

把warmup加进 readiness 组,这样探针只有在预热完成之后才会变绿,流量才会进入。如果预热中途失败,readiness 保持失败,实例不会被摘流(本来就没接流量),也不会被杀掉(liveness 不受影响),等运维处理。

5.3 线上事故复盘清单

把这套体系搭建完之后,我建议每个团队做一次故障演练,模拟几种场景,看健康检查和探针是否真的“救人”:

  • 模拟数据库连接池耗尽:把连接池最大连接数调小,压入高并发请求,观察/actuator/health/readiness是否能在 30 秒内变DOWN,K8s 是否摘流。
  • 模拟 Redis 节点故障:直接停掉一个 Redis 节点,观察健康检查是否变DOWN,应用是否被重新拉起(预期是不拉,只摘流)。
  • 模拟发布中途新版本启动失败:看 liveness 探针能否在合理时间内杀掉失败实例,滚动发布是否被阻塞。
  • 模拟优雅停机:在请求处理中执行滚动发布,观察老实例是否把存量请求处理完再退出。

每一项都值得记录具体数值:探针从失败到摘流用了多少秒、用户错误率在那个窗口内是多少、恢复之后流量是否平滑切回。这些数据反过来会帮你调整探针参数。

我在实际落地中遇到最多的一个共性问题:健康检查配置在了,但没接监控告警。健康组端点也需要有配套的监控指标,比如把/actuator/health的status作为自定义指标打到 Prometheus,状态为DOWN时触发告警。否则健康检查只是仪表盘上的一个数字,故障发生时该没人看到还是没人看到。

这个体系跑顺之后,线上发布和故障处理确实会稳很多。但也要提醒一句:健康检查和探针不是一次性配置,每次依赖变更、每次新接入外部组件,都要重新审视健康组该加什么、超时该怎么调。它需要持续维护,而不是写完就丢在那不管。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进