Spring Boot 的 `/actuator/health` 不是免费的:健康检查打爆连接池的反面教材

📝 摘要: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

平时看着没事,但是:

  1. 任意一个慢了,整条心跳就慢(串行)
  2. 任意一个 hang,整条心跳就 hang(超时累加)
  3. 每次都借连接(健康检查频率高 → 把连接池吃光)

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 给你"内置 + 开箱即用"的礼物,但礼物没有免费的------你用它做"探活",就要承担"健康检查"的成本。

记住三句话:

  1. 探活检查只看进程,不看依赖 ------别用 /actuator/health,自写空接口
  2. 健康检查别当探活用 ------再普通的 SELECT 1 也是 IO,IO 累积会出事
  3. 任何"自动"的东西都有代价------配置默认值能用,不代表它在你的并发模型下合适

下次 code review 看到有人新接 DataSource 进 Spring,问一句:"你 actuator 关了吗?" 可能就帮团队躲过一次半夜告警。


延伸阅读


🏷️ 标签 :Spring Boot Actuator 健康检查 HikariCP 线上排障 架构反模式

相关推荐
摇滚侠7 小时前
《SpringBoot 3:入门与应用实战》第 15 章 生产级特性 管理 Spring Boot 应用 阅读笔记
java·spring boot·笔记
XWalnut8 小时前
SpringBoot快速入门
java·spring boot·后端
就改了9 小时前
SpringBoot 自定义线程池 + 实时监控指标
java·spring boot·后端
摇滚侠11 小时前
《SpringBoot 3:入门与应用实战》第 15 章 生产级特性 监控指标 Metrics 阅读笔记
java·spring boot·笔记
龙亘川12 小时前
【客户定制更新】能源充电(汽车充电管理模块)版本更新详情
spring boot·汽车·系统安全·智慧城市·能源
Jul1en_13 小时前
【Java 脚手架】封装通用工具类-1
java·spring boot·分布式·spring·spring cloud
IKUN家族14 小时前
Spring MVC(三)
运维·服务器·spring boot·spring·servlet·java-ee
用户31268748772014 小时前
Spring Boot 启动流程到底做了什么?从 main 到就绪的全链路源码拆解
spring boot
花生了什么事o15 小时前
Docker 部署 SpringBoot:从镜像构建到服务器运行
服务器·spring boot·docker