📝 摘要:Spring Boot 的 /actuator/health 默认不是免费的探活接口:它串行 ping 所有 DataSource/Redis/Kafka,每次借连接执行 SELECT 1;当心跳被 404 放大到 480 QPS 叠加 ClickHouse 慢查询 hang,按 Little 定律瞬间打爆大小仅 10 的连接池、374 线程排队。解法:关昂贵 indicator、自写空接口、探活与健康检查分离。
99% 的 Spring Boot 项目都开着/actuator/health,然后给 K8s liveness、给 Nginx、给 APISIX、给 Prometheus......当探活接口用了。看起来无害,对吧?直到某天某条 SQL 查询慢了 5 秒,接着整个 API 服务被一个心跳风暴扫平。
这篇专门讲清楚:
/actuator/health默认配置下到底做了什么、为什么贵、怎么改才正确。
📖 前作 : 本文是 《OLAP 数据库混进 OLTP 链路:一次 ClickHouse 拖死 API 101 分钟的复盘》 的专题深挖,事故现场看那篇,本文专聊"为什么/actuator/health是个坑"。
一、先看一个反直觉的现象
某次故障复盘,排查路径长这样:
text
症状: API 间断不可用,大量请求超时
↓
日志: HikariCP 连接池耗尽,374 个线程在等连接
↓
监控: Clickhouse 查询排队
↓
追问: 业务代码没调 ClickHouse 啊?
↓
答案: 是 `/actuator/health` 在调用 ClickHouse!
而且每秒 480 次。
------服务没有任何业务调用 ClickHouse ,但心跳接口替你调了 。
------心跳每秒打 480 次,每次都借走一个 ClickHouse 连接 。
------连接池上限 10。
这就是本文的核心:Spring Boot Actuator 的 /actuator/health 不是一个轻量级接口,默认它会去 ping 所有 DataSource、Redis、MongoDB、Kafka、Elasticsearch、DiskSpace......
二、/actuator/health 默认到底做了什么
2.1 它的"全家桶"心态
Spring Boot 2.x / 3.x 的 Actuator,只要在 classpath 里检测到对应组件,就会自动注册一个 HealthIndicator (官方文档:Auto-configured HealthIndicators):
| 检测条件(classpath 有) | 自动注册的 HealthIndicator | 一次心跳干啥 |
|---|---|---|
spring-boot-starter-data-jpa / JdbcTemplate |
DataSourceHealthIndicator |
借一个连接 → 执行 validation query (如 SELECT 1) → 还回去 |
spring-boot-starter-data-redis |
RedisHealthIndicator |
发一个 PING |
spring-boot-starter-data-mongodb |
MongoHealthIndicator |
跑 db.runCommand({ ping: 1 }) |
spring-kafka |
KafkaHealthIndicator |
查询集群元数据 |
spring-data-elasticsearch |
ElasticsearchHealthIndicator |
查 cluster health |
clickhouse-jdbc + 配了 DataSource |
同 DataSourceHealthIndicator | 借一个 CK 连接 + 执行 validation query |
| 无需依赖(默认开启) | DiskSpaceHealthIndicator |
调 File.getUsableSpace() 看磁盘剩余 |
这些是串行执行的。一次心跳 = ping 完所有组件 + 串行返回。
举个例子,如果你的项目挂了 5 个 DataSource(MySQL × 2、ClickHouse、Redis、Kafka),那 /actuator/health 的一次响应就是:
text
GET /actuator/health
→ DataSourceHealthIndicator (mysql-master) 借连接 + SELECT 1 ~3ms
→ DataSourceHealthIndicator (mysql-slave) 借连接 + SELECT 1 ~3ms
→ DataSourceHealthIndicator (clickhouse) 借连接 + SELECT 1 ~50ms ← 慢
→ RedisHealthIndicator PING ~1ms
→ KafkaHealthIndicator 元数据查询 ~20ms
→ DiskSpaceHealthIndicator sys call ~1ms
─────
~78ms
平时看着没事,但是:
- 任意一个慢了,整条心跳就慢(串行)
- 任意一个 hang,整条心跳就 hang(超时累加)
- 每次都借连接(健康检查频率高 → 把连接池吃光)
2.2 谁在打这个接口?
通常一台生产 API 服务器,/actuator/health 会有这些"客户"同时打:
| 来源 | 默认频率 | 用途 |
|---|---|---|
| K8s liveness probe | 每 10s 一次 | 重启决策 |
| K8s readiness probe | 每 10s 一次 | 流量摘除决策 |
| 云厂商 LB 健康检查 | 每 2~5s 一次 | LB 摘流量 |
| Nginx upstream check | 每 5~10s 一次 | upstream 节点状态 |
| APISIX / Kong 健康检查 | 每 2~10s 一次 | 上游存活探测 |
| Prometheus blackbox-exporter | 每 15~30s 一次 | 监控告警 |
| 自家的巡检脚本 | 不定 | 历史遗留 |
叠加之后, /actuator/health 的实际 QPS 可能在 5~50 之间 。如果再加上反向代理"路由 404 → 触发健康检查"这种放大效应,飙到 400+ QPS 都不奇怪。
三、当心跳遇上慢依赖:雪崩怎么发生的
3.1 数学公式
text
并发占用的连接数 = 每秒新借的连接数 × 平均借用时长 (Little 定律)
只要它 ≥ 连接池大小,连接池就被占满、后来者开始排队
每秒新借 = health QPS × DataSource 数量
平均借用 = SELECT 1 耗时 = 平时 ms 级,依赖 hang 时 → ∞
代入实际数据(平时 vs 故障当晚):
text
心跳 QPS = 平时 ~50 → 故障当晚被 404 放大到 480
CK DataSource 数 = 1
CK 单次 SELECT 1 = 平时 50ms → 磁盘满时 hang 到 30s
连接池 = 10
两种情形下,健康检查占住的连接数天差地别:
text
平时(心跳 QPS≈50,CK 单次 50ms):
in-flight = 50 × 0.05s ≈ 2.5 个连接
→ 远低于连接池 10 → 稳稳的,连钢丝都算不上
故障当晚(404 把心跳 QPS 放大到 480,CK 又从 50ms hang 到 30s):
双重放大 ------ QPS ×10、单次耗时 ×600
理论需求 = 480 × 30s ≈ 14,400 个请求同时来抢连接(而池只有 10)
→ 连接池 10 个瞬间锁死
→ 374 个线程排队等连接(Hikari 默认 connectionTimeout = 30s)
→ 30 秒后超时,业务全部 502
平时离危险还很远,是"QPS 放大"和"单次耗时放大"同时发生才把池子瞬间锁死------这也正是下面雪崩链路的可怕之处。
3.2 雪崩链路
text
某依赖变慢(慢 SQL / 磁盘满 / 网络抖动)
↓
health check 借走的连接不还
↓
连接池耗尽
↓
业务线程拿不到连接,排队
↓
请求 30s 超时
↓
上游(LB / 网关)认为服务不可用
↓
重试 + 健康检查频率上调
↓
进一步打爆连接池(放大效应)
↓
💥 服务完全不可用
关键: 健康检查从"探活手段"变成了"放大器"------依赖越慢,它越想确认"是不是真挂了",越打越凶,直到把进程打死。
四、修复方案
4.1 方案 A: 关掉昂贵的 indicator(最小改动)
application.yml:
yaml
management:
health:
db:
enabled: false # 禁用 DataSource 健康检查(包括 MySQL/CK)
diskspace:
enabled: false # 禁用磁盘空间检查
redis:
enabled: false # 禁用 Redis ping
mongo:
enabled: false
kafka:
enabled: false
elasticsearch:
enabled: false
endpoint:
health:
show-details: never # 不暴露细节,只返回 UP/DOWN
效果:/actuator/health 退化成"我还活着"------返回 UP,不查任何依赖。
适用 : 你只把 /actuator/health 当探活用,不需要它做"健康"决策。
4.2 方案 B: 自己写一个空接口替代(推荐)
更彻底:不使用 /actuator/health,自己写一个 /_alive 或 /ping:
java
@RestController
public class AliveController {
@GetMapping("/_alive")
public String alive() {
return "ok";
}
}
把所有打 /actuator/health 的下游(K8s、Nginx、APISIX、Prometheus blackbox)全部改成 /_alive。
好处:
- 完全可控,知道做了什么
- 不会因为升级 Spring Boot 引入新的 HealthIndicator
- 想加自定义检查,自己写 if-else 透明可见
坏处:
- 需要改下游配置(K8s yaml、Nginx upstream、APISIX route)
4.3 方案 C: 分组 --- 探活 vs 健康分开
Spring Boot 2.2+ 支持 health group(官方文档:Health Groups):
yaml
management:
endpoint:
health:
group:
liveness:
include: ping # 探活:只看进程
readiness:
include: db, redis # 就绪:看关键依赖
下游配置:
yaml
# K8s
livenessProbe:
httpGet:
path: /actuator/health/liveness
readinessProbe:
httpGet:
path: /actuator/health/readiness
# Nginx / APISIX
upstream backend {
server 10.0.0.1:8080 ...;
}
# 检查路径用 /actuator/health/liveness,不查依赖
适用: 想保留 actuator 生态但要精细化控制。
4.4 三种方案对比
| 方案 | 改动量 | 推荐度 | 适用 |
|---|---|---|---|
| A. 关 indicator | 小(改 yml) | ⭐⭐⭐ | 老项目快速止血 |
| B. 自写空接口 | 中(改下游) | ⭐⭐⭐⭐⭐ | 新项目 + 长期演进 |
| C. 分组 | 中(改 yml + 下游) | ⭐⭐⭐⭐ | 已深度依赖 actuator |
五、超越基础:更深层的反模式
5.1 健康检查 / 就绪检查 / 探活检查不是一回事
text
┌─────────────────────────────────────────────────────┐
│ Liveness Probe(探活) │
│ "进程还活着吗?" │
│ ✅ 只查进程本身(端口监听、内存、关键线程) │
│ ❌ 不查外部依赖(DB / Redis / 下游服务) │
│ 失败动作: K8s 重启容器 │
├─────────────────────────────────────────────────────┤
│ Readiness Probe(就绪) │
│ "进程能服务请求了吗?" │
│ ✅ 查关键依赖(主 DB、本地缓存预热完毕) │
│ ❌ 不查非关键依赖(异步队列、日志服务、可降级的下游) │
│ 失败动作: K8s 从 Service Endpoint 摘除流量 │
├─────────────────────────────────────────────────────┤
│ Health Check(健康检查) │
│ "给运维 / 监控看的诊断信息" │
│ ✅ 查所有依赖,详细返回 │
│ ❌ 不应该作为流量决策依据 │
│ 失败动作: 报警,人工介入 │
└─────────────────────────────────────────────────────┘
把这三个混在一起,就是反模式的源头。
/actuator/health 默认是第三个(健康检查),但 99% 的项目当成第一个(探活)用------这就是坑的根源。
5.2 就绪检查里不要查"非关键依赖"
很多人理解错了 readiness 的"依赖"------把所有依赖都查一遍。
正确做法:
| 依赖 | 是否进 readiness |
|---|---|
| 主 MySQL(业务必经) | ✅ 进 |
| 主 Redis(缓存必经) | ✅ 进 |
| 副 MySQL(只读) | ❌ 可降级,不进 |
| ClickHouse(报表) | ❌ 可降级,不进 |
| Kafka(异步) | ❌ 异步消息允许 lag,不进 |
| 第三方支付网关 | ❌ 不要进,会被外网抖动连累 |
| 日志服务 | ❌ 不进 |
核心原则 : 一个依赖进了 readiness,等于把整个服务的可用性跟它绑死------它挂你也挂。
5.3 健康检查频率 vs 依赖响应时间的边界
简单的数学约束:
text
心跳安全频率上限 ≈ 连接池大小 / 单次借连接时长
例: 连接池 10, 单次借连接(SELECT 1) 50ms
上限 = 10 / 0.05 = 200 req/s
如果实际 health QPS > 上限,连接池迟早爆。
线上排查时可以做这个 sanity check,如果 QPS / 池大小 / 耗时 三者关系不健康,就埋了雷。
5.4 APISIX / Nginx 的"兜底回源"放大效应
附加坑:
某些反向代理(APISIX 是典型)配了"兜底回源"------任何 404 都会触发健康检查 /**。如果你某天上线了一个没在网关配映射 的接口,客户端调用返回 404,网关会以为后端挂了 ,健康检查频率上调,直接打爆 /actuator/health。
对策: 关掉兜底回源,或者把网关路由表纳入上线 checklist。
六、自查清单
用这 6 条 audit 你现有的项目:
- 是否在用
/actuator/health作为 K8s / LB / Nginx / APISIX 的探活路径? -
management.health.<xxx>.enabled是否显式关掉了 db / diskspace / redis 等组件检查? - readiness 检查只包含"关键依赖",非关键依赖是否进了不该进的列表?
- 接 ClickHouse / MongoDB / Kafka 时,是否同时关掉了对应的 HealthIndicator?
- 连接池大小 vs 心跳 QPS 算过 sanity 数学吗?
- 反向代理是否配了兜底回源,被 404 触发健康检查?
任意一条 unchecked,都是潜在的"101 分钟卡死"等着你。
七、写在最后
/actuator/health 是 Spring Boot 给你"内置 + 开箱即用"的礼物,但礼物没有免费的------你用它做"探活",就要承担"健康检查"的成本。
记住三句话:
- 探活检查只看进程,不看依赖 ------别用
/actuator/health,自写空接口 - 健康检查别当探活用 ------再普通的
SELECT 1也是 IO,IO 累积会出事 - 任何"自动"的东西都有代价------配置默认值能用,不代表它在你的并发模型下合适
下次 code review 看到有人新接 DataSource 进 Spring,问一句:"你 actuator 关了吗?" 可能就帮团队躲过一次半夜告警。
延伸阅读:
- 完整事故复盘:《OLAP 数据库混进 OLTP 链路:一次 ClickHouse 拖死 API 101 分钟的复盘》
- 同款"连接池耗尽"现场:《arthas 5 分钟揪出 HikariCP 耗尽:从 thread 到 vmtool 的实战排查》
- 同主题不同根因:《AI 服务 502 雪崩排查:从 Nginx 超时到连接池耗尽,查了两次才找到真凶》 --- yaml 配置不生效 + 事务内调大模型
🏷️ 标签 :Spring Boot Actuator 健康检查 HikariCP 线上排障 架构反模式