《Java 编程实战》13.3 健康检查:依赖探测与就绪/存活

数据库挂掉时 /health 该返回 200 还是 503?本节从 libraryd 真实的 5 秒探测延迟出发,讲清存活、就绪与启动探针的边界,拆解 HealthProbe 的 connection.isValid(2) 到底限制了什么,Hikari 的 connectionTimeout 如何主导探针耗时,并给出 Kubernetes 探针参数与拆分方案。

本节目标:让 libraryd 的 /health 在数据库可用时返回 200、不可用时返回 503,并解释这 5 秒延迟的来源;把同一个端点接进 Kubernetes 时,知道该用存活还是就绪探针,以及参数为什么不能拍脑袋。
适用版本:JDK 25(Temurin 25.0.5)、Javalin 7.2.4、HikariCP 7.1.0、PostgreSQL 17、Kubernetes 1.30+ 的探针语义。

一次数据库重启暴露的问题

把 libraryd 部署到 Kubernetes 后,最容易被忽略的配置是探针。很多团队会直接写:

livenessProbe:
  httpGet:
    path: /health
    port: 8080

它看起来合理:应用活着就返回 200,不活就重启。但 libraryd 的 /health 会查数据库。数据库重启的那几分钟里,所有 Pod 的 /health 都返回 503,kubelet 于是按存活探针的规则重启所有 Pod。数据库还没恢复,应用先经历一轮重启风暴;缓存被清空、连接池重建、启动日志刷屏,等数据库回来时还要重新预热。

问题不在 /health 的实现,而在「用一个端点回答了两个不同的问题」:进程还活着吗?现在能接流量吗?这两个问题的答案在数据库故障时正好相反。进程活得好好的,但不应该接流量。把两者混在一起,就会把「暂时不接流量」升级成「全部重启」。

这一节先把概念拆开,再回到 libraryd 的代码和真实输出,最后给出探针参数与拆分方案。

存活、就绪与启动:三种探针问的不是同一个问题

Kubernetes 有三种探针,失败后的动作完全不同。

探针回答的问题失败后的动作该不该查数据库
liveness进程是否还活着、是否卡死重启容器不该
readiness现在能不能处理请求从 Service Endpoints 摘除,不重启可以,且通常应该
startup第一次启动是否完成重启容器不该,或只查进程内状态

存活探针的职责是发现「重启能解决的问题」,例如死锁、事件循环卡死、线程池全部阻塞。数据库不可用不是重启能解决的:重启只会让应用丢缓存、断连接,数据库仍然是 down。更糟的是,所有副本同时重启,流量会瞬间归零。

就绪探针的职责是发现「现在不该接流量」的状态,例如数据库连接不上、迁移还没跑完、依赖的配置还没加载。失败后 Pod 只是被摘出负载均衡,进程继续活着;数据库恢复后,下一次探测成功,Pod 自动回到 Endpoints。这正是 libraryd 在数据库故障时想要的行为。

启动探针解决的是慢启动:JVM 预热、Flyway 迁移、缓存加载可能需要几十秒。启动探针成功前,liveness 和 readiness 都不会执行;它失败才会重启。没有启动探针时,liveness 的 initialDelaySeconds 必须覆盖最慢启动时间,否则应用会在启动中途被杀死。

一个常见的反模式是「liveness 查所有依赖」。依赖越多,重启条件越多,系统越脆。正确方向相反:liveness 只查进程自身,readiness 查「接流量所必需的依赖」,而且依赖检查要有明确的超时上限。

libraryd 的 /health:从路由到 HealthProbe

LibraryApi 里注册了两个运维端点:

config.routes.get("/health", this::health);
config.routes.get("/metrics", this::metrics);

health 方法本身很短:

private void health(Context context) {
    boolean up;
    try {
        up = healthProbe.databaseUp();
    } catch (RuntimeException failure) {
        log.warn("health probe failed", failure);
        up = false;
    }
    HealthResponse response = new HealthResponse(up ? "UP" : "DOWN",
            Map.of("db", up ? "UP" : "DOWN"));
    context.status(up ? 200 : 503).json(response);
}

三个细节值得注意:

  • 探针返回 boolean,不返回异常细节。HealthResponse 只有 status 和 checks 两个字段,不会把 JDBC URL、用户名、SQL 异常栈暴露给调用方。
  • healthProbe.databaseUp() 抛 RuntimeException 时也会被捕获,统一按 DOWN 处理。探针实现里已经 catch 了 SQLException,这里再兜一层,防止连接池或驱动抛出未检查异常导致 500。
  • HTTP 状态码与 status 字段同步:UP 用 200,DOWN 用 503。不会出现「HTTP 200 但 JSON 里写 DOWN」这种让负载均衡误判的组合。

HealthProbe 是一个只有一个方法的函数式接口:

@FunctionalInterface
public interface HealthProbe {
    boolean databaseUp();
}

装配在 LibraryApplication 里:

HikariDataSource pool = dataSource;
HealthProbe healthProbe = () -> {
    try (Connection connection = pool.getConnection()) {
        return connection.isValid(2);
    } catch (SQLException exception) {
        return false;
    }
};

这段 lambda 做了三件事:从 Hikari 池借一个连接;用 isValid(2) 验证连接;无论成功失败都通过 try-with-resources 归还连接。catch 只接 SQLException,因为 getConnection() 和 isValid() 声明的受检异常都是它。

isValid(2) 的参数单位是秒,表示「验证这个连接是否有效,最多等 2 秒」。它只限制验证阶段,不限制从池里借连接的时间。这一点是理解 5 秒延迟的关键。

5 秒从哪来:connectionTimeout 与 isValid(2)

DataSourceFactory 里的 Hikari 配置决定了探针的上限:

hikari.setPoolName("libraryd-pool");
hikari.setMaximumPoolSize(config.poolSize());
hikari.setMinimumIdle(Math.min(2, config.poolSize()));
hikari.setConnectionTimeout(5_000);
hikari.setValidationTimeout(2_000);
hikari.setIdleTimeout(60_000);
hikari.setMaxLifetime(1_800_000);
hikari.setAutoCommit(true);

这几个超时参数经常被混为一谈,实际职责差别很大:

参数当前值管什么数据库 down 时的表现
connectionTimeout5000 ms从池里借到连接的最长等待决定 /health 的 5 秒上限
validationTimeout2000 ms验证一条已借出连接的最长等待拿到连接后才生效
idleTimeout60000 ms空闲连接多久后回收不影响本次探针
maxLifetime1800000 ms连接的最长寿命,到期强制淘汰数据库重启后旧连接仍会先验证失败

数据库 down 时,/health 的执行路径是:

  1. pool.getConnection() 发现池里的连接可能已经失效,尝试借出并验证。
  2. Hikari 对失效连接做 validation,validationTimeout=2000 限制单次验证。
  3. 池里没有可用连接,Hikari 尝试新建连接。TCP 连接被拒绝或超时,整体等待受 connectionTimeout=5000 限制。
  4. 5 秒后 getConnection() 抛 SQLException,lambda 捕获并返回 false。
  5. health 方法返回 503。

所以用户看到的 5 秒,主要是 connectionTimeout,不是 isValid(2) 的 2 秒。isValid(2) 只在「已经拿到连接」之后生效;拿不到连接时,它根本没有机会执行。把 isValid(2) 改成 isValid(1) 不会把 5 秒变成 1 秒。

这也意味着探针的超时参数必须和连接池配置对齐。如果 Kubernetes 的 timeoutSeconds 设成 1,kubelet 1 秒就放弃,但应用线程还在 getConnection() 里等 5 秒。探针频率高时,等待的线程会堆积,反而加重故障。正确关系是:

probe timeout > 依赖探测的最坏耗时

对于当前 libraryd,最坏耗时约 5 秒,所以 readiness 的 timeoutSeconds 至少设 6。若不想让探针等这么久,应该给健康检查单独的、更短的连接超时,而不是只改探针参数。

数据库恢复后,Hikari 会重新建立连接,下一次 /health 恢复 200。不需要重启应用。这正是「就绪失败只摘流量」相对于「存活失败重启」的价值。

真实故障输出:200、503 与日志

先看正常状态。本机启动 libraryd,数据库和 Redis 都在,请求 /health:

{"status":"UP","checks":{"db":"UP"}}
HTTP 200 time=0.011617s

这是本机输出,环境为 JDK 25.0.5、PostgreSQL 17(Docker)、libraryd 监听 18080。响应体只有两个字段,checks.db 与顶层 status 一致。

然后停掉 PostgreSQL 容器,再请求同一个端点:

{"status":"DOWN","checks":{"db":"DOWN"}}
HTTP 503 time=5.049340s

同样是本机输出。5.049 秒与 connectionTimeout=5000 基本吻合,多出来的几十毫秒是 HTTP 与调度开销。这个数字说明延迟主要发生在「借连接」,不是 JSON 序列化或路由。

同一时间应用日志里有 Hikari 的警告:

WARN [JettyServerThreadPool-41] com.zaxxer.hikari.pool.PoolBase requestId=03dbc397-3086-4aa0-9633-36b6d53077ad traceId=d7665fba4f57950c52952902d5fd988c - libraryd-pool - Failed to validate connection org.postgresql.jdbc.PgConnection@1eb5174b (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
WARN [JettyServerThreadPool-41] com.zaxxer.hikari.pool.PoolBase requestId=03dbc397-3086-4aa0-9633-36b6d53077ad traceId=d7665fba4f57950c52952902d5fd988c - libraryd-pool - Failed to validate connection org.postgresql.jdbc.PgConnection@4939c69f (This connection has been closed.). Possibly consider using a shorter maxLifetime value.

两条警告说明池里至少有两个旧连接已经断开。minimumIdle=2,所以池里常驻两个连接;数据库重启后它们都失效,Hikari 逐个验证失败并淘汰。

「Possibly consider using a shorter maxLifetime value」是 Hikari 的通用提示,不要看到就立刻把 maxLifetime 从 30 分钟改短。这里的根因是数据库重启,不是连接活得比数据库久。maxLifetime 应该略小于数据库或中间件的空闲连接超时,而不是为了消掉这条警告随意调整。盲目改短会增加连接重建频率,反而增加延迟。

请求结束后,HTTP 指标也记录了这次失败:

INFO [JettyServerThreadPool-41] com.plume.library.web.HttpMetrics requestId=03dbc397-3086-4aa0-9633-36b6d53077ad traceId=d7665fba4f57950c52952902d5fd988c - http_request method=GET path=/health status=503 duration_ms=5027 requestId=03dbc397-3086-4aa0-9633-36b6d53077ad

duration_ms=5027 与 curl 的 5.049 秒互相印证。requestId 和 traceId 都在,说明健康检查也走了统一的请求上下文与追踪中间件;排障时可以把一次探针失败和具体日志、span 对上。

数据库恢复后再次请求,响应回到 200,不需要重启应用。这个恢复过程没有额外的「重置」代码,靠的是 Hikari 在下次借连接时重新建立连接。

这次实验停的是本机 Docker 里的 PostgreSQL 容器,不是生产库。验证健康检查时不要直接停生产数据库,可以用网络策略临时拒绝应用到数据库的流量、把应用指向一个只读副本,或者用 Toxiproxy 注入超时。无论哪种方式,都要先确认应用没有正在写入的关键事务,并准备好恢复步骤。健康检查的故障注入应该是一次可回滚的演练,不是一次即兴操作。

把 /health 接进 Kubernetes:参数怎么定

如果暂时不拆分端点,至少不要把 /health 用作 liveness。一个可行的最小配置是:

readinessProbe:
  httpGet:
    path: /health
    port: 8080
  initialDelaySeconds: 3
  periodSeconds: 10
  timeoutSeconds: 6
  failureThreshold: 2

livenessProbe:
  httpGet:
    path: /health/live
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 3

timeoutSeconds: 6 大于 5 秒的连接超时,避免 kubelet 先放弃、应用线程还在等。failureThreshold: 2 表示连续两次失败才摘流量,配合 10 秒周期,最坏情况下 20 秒左右摘除;对数据库故障来说这个速度可以接受。若业务要求更快,缩短 periodSeconds,但不要缩短 timeoutSeconds 到 5 以下。

livenessProbe 指向 /health/live,这个端点当前参考工程没有,需要自己加。它不应该查数据库,只返回进程内状态:

config.routes.get("/health/live", context ->
        context.json(new HealthResponse("UP", Map.of())));

HealthResponse 的 checks 是 Map<String, String>,传空 Map 表示没有依赖检查。这个端点只要 Javalin 的事件循环还能处理请求就返回 200,能发现线程死锁、事件循环卡死这类「重启确实能解决」的问题。

启动慢时再加 startupProbe:

startupProbe:
  httpGet:
    path: /health/live
    port: 8080
  periodSeconds: 2
  failureThreshold: 30

failureThreshold: 30、periodSeconds: 2 给出最多 60 秒启动窗口。启动探针成功前,liveness 不会执行,所以不需要把 liveness 的 initialDelaySeconds 设得很大。启动探针失败才重启,适合迁移耗时不确定的部署。

探针参数还要和副本数一起算检测窗口。periodSeconds: 10 加 failureThreshold: 2,最坏要等约 20 秒才摘流量;如果希望 10 秒内摘除,就把周期降到 5 秒,而不是把超时压到 5 秒以下。超时、周期、失败阈值三个参数各自有边界:超时保护单次请求,周期决定采样密度,阈值过滤偶发抖动。把它们一起调,才不会出现「检测很快但请求总被中断」的矛盾配置。

/health 继续作为 readiness 的依赖检查端点,/health/live 作为 liveness。两个端点的职责清晰,数据库故障时只摘流量,不会触发重启。

还有一个容易被忽略的时机:Pod 收到 SIGTERM 后,应用应该先把 readiness 置为 DOWN,等负载均衡把流量摘干净,再关闭连接池和 Javalin。否则 kubelet 已经开始终止进程,新请求还在进来,用户会看到连接被重置。参考工程目前没有优雅停机钩子,这段需要在关闭流程里补;健康检查不只是「启动时能不能接流量」,也包括「退出时什么时候停止接流量」。

拆分与常见错误

错误一:liveness 查数据库。 这是最危险的一条。数据库故障会导致全部副本重启,重启期间缓存丢失、连接池重建,恢复后还要重新预热;数据库本身不会因为应用重启而恢复。liveness 只查进程自身,依赖检查放 readiness。

错误二:探针超时短于依赖超时。 timeoutSeconds: 1 配 connectionTimeout: 5000,kubelet 每秒超时一次,应用线程却要等 5 秒。探针频率高时线程堆积,健康检查本身成了故障源。要么把探针超时调到 6 秒以上,要么给健康检查单独的短连接超时。

错误三:HTTP 200 配 "status":"DOWN"。 负载均衡和 kubelet 只看 HTTP 状态码,不看 JSON 字段。状态码和字段必须同步:UP 200,DOWN 503。反过来「503 但字段写 UP」同样会让调用方困惑。

错误四:把异常细节返回给调用方。 /health 可能被公网或半可信网络访问。返回 JDBC URL、用户名、SQL 异常栈,等于泄露部署信息。HealthResponse 只保留 status 和 checks 是对的;详细原因写日志,用 requestId 关联。

错误五:缓存健康状态太久。 把 UP 缓存 60 秒,数据库挂了之后最多 60 秒仍在接流量;把 DOWN 缓存 60 秒,数据库恢复了还要等 60 秒才回 Endpoints。健康检查应该反映当前状态,最多做 1 到 2 秒的短缓存来挡住探针风暴,不要做分钟级缓存。

错误六:在健康检查里做重查询。 SELECT count(*) FROM loans 这类查询会随数据量变慢,健康检查从「探活」变成「压测」。connection.isValid(2) 只验证连接,代价小,是合适的粒度。需要验证表结构时,用启动时的迁移检查,不要放进每次探针。

错误七:只配 readiness,不配 liveness。 进程死锁时 readiness 也会失败,Pod 被摘流量但不会重启,故障副本一直占着资源。反过来只配 liveness 不配 readiness,数据库故障时流量仍然打进来。两个都要有,职责分开。

错误八:忽略连接池耗尽。 健康检查也要借连接。如果业务代码泄漏连接,池被占满,/health 会和其他请求一起排队,最终超时返回 503。这个 503 是真实的「不能服务」,不是误报;应该去查连接泄漏,而不是给健康检查加缓存或绕过连接池。

边界:健康检查不能证明业务正确。 /health 返回 UP 只说明连接可用。图书查询 SQL 写错、索引缺失、缓存脏数据,健康检查都发现不了。它只回答「能不能接流量」,不回答「业务是否正常」。后者要靠指标、追踪和业务级校验。

边界:单实例与多实例的差异。 单实例时,/health DOWN 意味着服务完全不可用;多实例时,只要还有一个副本 READY,流量就能继续。readiness 的价值在多副本下才充分体现。单实例部署仍然需要它,因为滚动更新时新 Pod 要在就绪后才接流量。

小结

  • liveness 发现「重启能解决」的问题,readiness 发现「现在不该接流量」的问题,startup 保护慢启动;三者不能共用一个查数据库的端点。
  • libraryd 的 /health 通过 HealthProbe.databaseUp() 借连接并调用 connection.isValid(2),UP 返回 200,DOWN 返回 503。
  • 5 秒延迟主要来自 Hikari 的 connectionTimeout=5000,不是 isValid(2) 的 2 秒;isValid 只限制拿到连接后的验证阶段。
  • Hikari 的旧连接在数据库重启后验证失败是正常现象,maxLifetime 不应为了消警告而盲目改短。
  • Kubernetes 探针的 timeoutSeconds 必须大于依赖探测的最坏耗时,否则 kubelet 先超时、应用线程仍在等待。
  • 拆分 /health/live 与 /health/ready,liveness 不查数据库,readiness 查依赖;数据库故障只摘流量,不触发重启风暴。
  • 健康检查只回答「能不能接流量」,业务正确性要靠指标、追踪和业务校验。

可观测性三件套到这里齐了:日志回答「发生了什么」,指标回答「发生得有多频繁」,追踪回答「慢在哪一段」,健康检查回答「现在能不能接流量」。下一章进入缓存,先看单机 Caffeine 如何用容量、过期和刷新策略挡住重复查询。

阅读导航:上一节:13.2 追踪:OpenTelemetry 与 W3C traceparent · 下一节:14.1 本地缓存:Caffeine 与失效策略 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发