结论先行:健康检查通过 = 端口能建连 + 一条
SELECT 1能跑通,这两件事对「业务能不能正常跑」的覆盖度不到 20%。TCP 建连只证明内核网络栈活着,SELECT 1只证明数据库里存在一条不需要锁、不需要连接池排队的最短路径。连接池被慢查询钉死、线程池被下游拖满、缓存击穿打穿 DB——这三种最常见的「半死」状态,全部能让你的 readiness 探针返回 200。这篇给一次真实复盘 + 3 层健康检查的可复现配置。
一、现象:大盘全绿,工单先于告警
先摆数字,方便你对号入座。
- 服务:
order-query-svc,Spring Boot 3.2.4 + JDK 21 + HikariCP 5.1.0,K8s 1.28 集群里 6 个 Pod,每 Pod 8C16G - DB:MySQL 8.0.32 主从,核心表
t_order4200 万行、单表 11.7GB - 连接池配置:
maximumPoolSize=20、connectionTimeout=30000(默认值,没动过) - 探针配置:K8s readiness =
httpGet /actuator/health,liveness = 同一端点,periodSeconds=10
2026-09-17 21:43,一条新上的报表查询(SELECT ... FROM t_order JOIN t_item WHERE item.category = ? ORDER BY create_time DESC LIMIT 200,未走索引)开始每 3 分钟被调度一次,单次执行 40~90s。它一次拿 12 个连接不释放,6 个 Pod 里 2 个 Pod 的池子 20 个连接在 11 分钟内被这类查询钉满。
从 21:54 到 22:41,这 2 个 Pod 上所有业务请求的表现是:30s 后抛 SQLTransientConnectionException: Connection is not available, request timed out after 30001ms,对外表现为 504。Grafana 大盘上:
- QPS 曲线:没掉(另外 4 个 Pod 扛住了)
- 错误率:2/6 = 33% 的 Pod 在报错,但大盘聚合后错误率 11%,低于我们 15% 的 P1 告警阈值
/actuator/health端点:这 2 个 Pod 每一秒都返回 200,{"status":"UP"}- 数据库侧:
SHOW PROCESSLIST能看到 24 条 Sleep 连接,但 DBA 值班同学看到「慢查询 3 条、DB 负载 62%」,判断 DB 侧正常
47 分钟后是业务方投诉进来的,不是我们的告警响的。事后我盯着监控看了半小时,确认了一件事:我们所有的观测手段,没有一个能区分「这 2 个 Pod 能建连」和「这 2 个 Pod 能跑业务」。
二、原理:健康检查到底在验证什么
把机制拆开看,一个 HTTP 健康检查从探针视角走的路径是:
kubelet → TCP connect :8080 → HTTP GET /actuator/health → 应用线程处理 → 返回 200
这条路径恰好绕过了业务路径上的所有瓶颈资源:
- TCP 建连只经过内核协议栈,和业务线程池、连接池、下游依赖全部无关。只要
listen backlog没满(默认 4096,几乎不可能满),这一步永远成功。 - HTTP 请求处理用的是 Spring 的
http-nio线程池(默认 200 线程)。即使业务线程全部被阻塞,健康检查请求仍然能拿到一个空闲线程执行——前提是健康检查处理器自己不做重活。Spring Boot 的/actuator/health默认实现就是SELECT 1+ 检查各组件HealthIndicator,这条路轻到不可能被业务拖死。 SELECT 1走的是连接池吗?取决于你的DataSourceHealthIndicator实现。Spring Boot 默认会向连接池要一个连接执行SELECT 1——但连接池的connectionTimeout是 30s,而你的 K8s 探针timeoutSeconds默认是 1s。也就是说:连接池满了 → 健康检查线程等 30s → 探针 1s 超时 → 但 kubelet 的判定逻辑是「超时 = 不健康」,而不是「超时 = 重启/摘流」,更关键的是 Spring 的HealthEndpoint在connectionTimeout超时前会捕获异常返回{"status":"DOWN"}……
等等,这里有个反直觉的细节我得讲清楚:Spring Boot 的 DataSourceHealthIndicator 在连接池耗尽时确实会返回 DOWN。那为什么我们的 2 个 Pod 还是 UP?
因为我们没有启用 DataSourceHealthIndicator。Spring Boot Actuator 默认只暴露 status,各组件的详细状态被 management.endpoint.health.show-details=never(默认值)屏蔽了。更关键的是:我们后来排查发现,order-query-svc 的 application.yml 里有一行 management.health.db.enabled=false——这是 2024 年一次「DB 抖动导致健康检查误报 DOWN、Pod 被反复摘流」的遗留配置,当时为了止血关掉了 DB 健康检查,之后再没人想起来开回去。
所以这次事故的完整因果链是:
慢查询钉死连接池
→ 业务线程 30s 超时 504
→ 健康检查端点:DB 检查被禁用,其余组件(DiskSpace、Ping)全部 UP
→ kubelet 认为 Pod 健康,继续转发流量
→ 47 分钟
健康检查不是「有没有配」的问题,是「配了之后又被谁关掉了」的问题。
三、具体做法:3 层健康检查 + 1 条告警
下面是我们复盘后落地的一套配置,按「轻 → 重」分 3 层,每层验证的资源不同。
第 1 层:liveness = 进程活着(保持最轻)
# 只验证 JVM 进程 + HTTP 端口,不碰任何外部依赖
livenessProbe:
httpGet:
path: /actuator/health/liveness # Spring Boot 3.x 独立端点
port: 8080
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
Spring Boot 3.x 把 liveness 和 readiness 拆成两个独立端点(/actuator/health/liveness 和 /actuator/health/readiness),liveness 端点只返回进程自身的存活状态,不查 DB、不查缓存——这正是我们要的:liveness 挂了说明进程真死了,重启有意义;业务依赖挂了说明重启没用,应该摘流而不是重启。
第 2 层:readiness = 能拿连接 + 能跑一条真实 SQL
// 自定义 ReadinessHealthIndicator,替代默认的 Ping
@Component
public class BusinessReadinessHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
private final JdbcTemplate jdbcTemplate;
public BusinessReadinessHealthIndicator(DataSource ds) {
this.dataSource = ds;
this.jdbcTemplate = new JdbcTemplate(ds);
}
@Override
public Health health() {
long t0 = System.nanoTime();
try {
// 关键:走连接池拿连接,而不是直接 new Connection
// 这一步能暴露「池子被钉死」的问题
Integer result = jdbcTemplate.queryForObject(
"SELECT 1", Integer.class);
long ms = (System.nanoTime() - t0) / 1_000_000;
if (result == 1 && ms < 2000) {
return Health.up()
.withDetail("pool_wait_ms", ms)
.build();
}
return Health.down().withDetail("pool_wait_ms", ms).build();
} catch (Exception e) {
return Health.down().withDetail("error", e.getMessage()).build();
}
}
}
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 10
timeoutSeconds: 5 # 必须 > pool_wait 阈值(2s),否则误判
failureThreshold: 2 # 连续 2 次失败才摘流,防单次抖动
为什么 2000ms 这个阈值? 我们压测过:连接池正常时 SELECT 1 的 P99 在 3ms 以内(本地 SSD + 同机房);连接池开始排队时,P99 会先升到 50~200ms(排队 1~3 个连接),超过 500ms 说明池子已经明显紧张,超过 2000ms 基本就是死锁状态。2000ms 是一个「正常永远到不了、故障 2 秒内就能触达」的边界。
第 3 层:合成事务 = 走完整业务路径(可选,重服务才需要)
对核心写入路径(下单、扣库存),每 30s 跑一次「空转合成事务」:
@Scheduled(fixedDelay = 30_000)
public void syntheticTransaction() {
long t0 = System.nanoTime();
try {
transactionTemplate.execute(status -> {
// 走真实的 @Transactional 路径,包括:
// 1. 从 HikariCP 拿连接
// 2. BEGIN
// 3. 一条真实的 UPDATE(打在一行专用哨兵行上,不影响业务数据)
jdbcTemplate.update(
"UPDATE t_health_sentinel SET ts = NOW() WHERE id = 1");
// 4. COMMIT
return null;
});
long ms = (System.nanoTime() - t0) / 1_000_000;
log.info("synthetic_tx ok, {}ms", ms);
// 上报 Prometheus: synthetic_tx_ms / synthetic_tx_success
} catch (Exception e) {
log.error("synthetic_tx FAILED", e);
// 上报: synthetic_tx_failure_total
}
}
-- 哨兵行:每个库建一张表,每行 1 条记录
CREATE TABLE t_health_sentinel (
id TINYINT PRIMARY KEY,
ts DATETIME(3) NOT NULL
);
INSERT INTO t_health_sentinel VALUES (1, NOW(3));
这一步的价值是验证「事务管理器 + 连接池 + DB + 写路径」整条链路,能暴露 SELECT 1 永远看不到的问题(比如 max_execution_time 被意外设置、binlog 写入阻塞、主从切换窗口)。但注意:它每 30s 对 DB 产生一次真实的写事务,6 个 Pod × 每 30s 一次 = 12 次/分钟,对 DB 的额外负载约 0.01%,可忽略。
配套告警:连接池等待时间(Prometheus)
HikariCP 自带 Micrometer 指标,确保 management.metrics.export.prometheus.enabled=true,然后加一条告警:
# prometheus rules
- alert: HikariPoolWaitHigh
expr: histogram_quantile(0.99, rate(hikaricp_connection_acquire_seconds_bucket[5m])) > 0.1
for: 3m
labels:
severity: warning
annotations:
summary: "连接池 P99 等待 > 100ms 持续 3 分钟"
# 这条告警比业务 504 早 5~10 分钟触发,是真正的早期信号
这条告警是整套方案里性价比最高的一条。 连接池等待时间是「池子开始紧张」的第一个可观测信号,比 SELECT 1 超时早得多。
四、踩过的坑和反直觉之处
-
management.health.db.enabled=false是最常见的一颗雷。 我们这次事故的直接根因就是 2024 年的一次止血操作。教训:任何「为了止血关掉监控」的操作,必须附一个带日期的 TODO 工单,到期 review。我们在 CMDB 里加了一个「监控豁免」字段,填了豁免就必须填到期日,超期自动升级 P2 工单。 -
readiness 探针本身会变成 DB 的额外负载。 6 个 Pod × 每 10s 一次
SELECT 1= 36 次/分钟。单次 3ms 无所谓,但如果你把 readiness 做成「跑一条 50ms 的业务 SQL」,6 个 Pod 就是 1800ms/分钟的持续 DB 负载,在大促期间可能放大问题。所以第 2 层用SELECT 1、第 3 层用合成事务,不要把所有检查都塞进 readiness。 -
timeoutSeconds必须大于健康检查内部的超时,否则永远误判。 我们第一次上线时timeoutSeconds=3,但SELECT 1在连接池排队时可能等 5s(connectionTimeout=30000但实际排队 5s 就返回了),结果 kubelet 先超时判定 DOWN,Spring 那边还没返回,Pod 被反复摘流又加回来,比不探还糟。最终改成timeoutSeconds=5+ 内部阈值 2s,留了 3s 的余量。 -
liveness 和 readiness 用同一个端点是 K8s 社区文档里的默认示例,但在生产环境是错的。 liveness 挂了 → 重启 Pod;如果你的 liveness 检查了 DB,DB 抖动 30s → 所有 Pod 同时被重启 → 重启期间 DB 恢复 → 但 Pod 还没起来 → 雪崩。liveness 永远只查进程自身,readiness 才查依赖,这条必须写进团队的 K8s 规范。
-
健康检查的「全绿」本身需要被监控。 我引用 @顾砚舟-804 在《谁来监控监控》里的一个判断:「可观测性本身也需要被观测」——这个原则在健康检查上同样成立。我们加了一条元告警:如果某个 Pod 的 readiness 连续 24h 全部成功,但它的业务 QPS 为 0,触发 P2 告警。这条规则在上线第二周就抓到了一次「流量路由配置错误导致 1 个 Pod 完全没流量,但健康检查全绿」的问题。
五、结论与可复用清单
健康检查的本质是**「用一条最便宜的路径,代理验证整条业务路径的可用性」**。当这条代理路径和业务路径共享的资源越少,它的盲区就越大。SELECT 1 和业务路径共享的资源只有「DB 进程活着」这一件事,所以它的盲区就是「除了 DB 进程活着以外的一切」。
可复用清单(按优先级排序):
| 优先级 | 动作 | 预期收益 |
|--------|------|----------|
| P0 | 确认 management.health.db.enabled=true,检查是否有历史豁免 | 消除最大盲区 |
| P0 | liveness 只查进程,readiness 查依赖,两者用独立端点 | 避免 DB 抖动 → 全量重启雪崩 |
| P1 | readiness 走连接池拿连接执行 SELECT 1,阈值 2s | 连接池钉死时 20s 内摘流 |
| P1 | 加 HikariCP 等待时间 P99 > 100ms 的告警 | 比 504 早 5~10 分钟预警 |
| P2 | 核心写入路径加 30s 合成事务 | 覆盖事务管理器 + 写路径盲区 |
| P2 | 加「全绿但零流量」元告警 | 抓路由/流量配置错误 |
一句话总结:健康检查不是「有没有配」的问题,是「它走的那条路,和你的业务走的是不是同一条路」的问题。 路径越短,盲区越大;路径越接近业务,覆盖越全,但代价也越高。找到那个「刚好覆盖你 80% 故障模式、代价可控」的平衡点,才是健康检查该待的位置。