服务一直返回200,K8s却说它活着,直到业务全挂了
去年双十一前一天,凌晨3点,被叫起来复盘一次诡异的事故。
事故的表现很魔幻:K8s 集群里所有 Pod 状态都是 Running,健康检查全部通过,Grafana 大盘一片绿。但用户反馈说下单按钮点不动,支付页面转圈圈,客诉量每分钟十几条。
运维同学第一个反应是"网络问题"。查了半小时,网络一切正常。第二个反应是"数据库挂了",DBA 看了一眼连接池——没问题。
最后顺着日志一层层摸,发现 Redis Cluster 里有一个分片的 Master 选举失败,变成了只读模式。订单服务在查 Redis 缓存的时候拿不到写入权限,但它的 /health 接口还在正常返回 200。
因为健康检查只做了 ping——一个什么都没验证的空壳。
一、返回 200 不等于活着
大多数 Spring Boot 项目配健康检查都是这样:
GET /actuator/health
→ {"status":"UP"}
加个 Spring Security 的登录校验就算完事了。看起来挺稳的,直到某天业务挂了,你才发现这玩意儿根本没用。
为什么?因为 Spring Boot Actuator 默认的健康检查,只验证了三件事:
- 应用上下文加载成功
- JVM 没 OOM
- 端口监听正常
它不在乎 Redis 能不能读写,不在乎数据库连接池有没有打满,不在乎消息队列消费有没有卡住。只要应用进程还活着,它就返回 UP。
就像一个心电监护仪只看你有没有心跳,不管你脑子里在想什么。
二、一次真实故障的排查路径
回到凌晨那次事故,我们从头复盘了健康检查的盲区:
| 依赖组件 | 实际状态 | 健康检查感知 | 影响 |
|---|---|---|---|
| MySQL | 正常,连接池 80/100 | UP | 暂无 |
| Redis Cluster | 一个分片只读 | UP | 所有写入缓存的操作失败 |
| RabbitMQ | 正常 | UP | — |
| Elasticsearch | 正常 | UP | — |
因为健康检查根本没查 Redis 的读写能力,K8s 无法感知到故障,没有触发 Pod 重启,没有告警,没有熔断。
用户端的表现就是:订单创建请求发过去,服务收了,走到缓存写入这一步直接挂了——连重试都没做。
三、健康检查应该查什么?
想清楚一个原则:业务跑得通,才叫活着。 所以健康检查不只查"应用有没有心跳",还要查"依赖链路通不通"。
分层来设计:
┌──────────────────────────────────┐
│ 第一层:Liveness │ ← 简单的应用存活检查
│ JVM / 端口 / 内存 │
├──────────────────────────────────┤
│ 第二层:Readiness │ ← 依赖组件可用性
│ DB 连接 / Redis 读写 / │
│ MQ 心跳 / ES 查询 │
├──────────────────────────────────┤
│ 第三层:Business │ ← 核心业务链路模拟
│ 下单 / 支付 / 查询 的模拟链路 │
└──────────────────────────────────┘
三层逐级加深:
- Liveness:轻量,快速返回,不应依赖外部组件(否则会因为 Redis 抖一下就把 Pod 杀了)
- Readiness:验证核心依赖是否可用,不可用时 K8s 停止向该 Pod 分发流量
- Business:模拟核心业务路径,最高成本、最准判断
四、具体实现:自定义 Probe
4.1 Liveness 探针(轻量,不查外部)
@Component
public class LivenessProbe implements HealthIndicator {
@Override
public Health health() {
// 只检查应用本身,不依赖任何外部组件
Runtime runtime = Runtime.getRuntime();
long freeMemory = runtime.freeMemory();
long totalMemory = runtime.totalMemory();
if (freeMemory < totalMemory * 0.05) {
return Health.down()
.withDetail("error", "JVM memory below 5%")
.build();
}
return Health.up()
.withDetail("freeMemory", freeMemory / (1024 * 1024) + "MB")
.withDetail("totalMemory", totalMemory / (1024 * 1024) + "MB")
.build();
}
}
4.2 Readiness 探针(核心,查依赖组件)
这里是重头戏——要比对每一个依赖组件的真实可用性。
@Component
public class ReadinessProbe implements HealthIndicator {
@Autowired private DataSource dataSource;
@Autowired private RedisTemplate<String, String> redisTemplate;
@Autowired private RabbitTemplate rabbitTemplate;
@Autowired private RestHighLevelClient esClient;
@Override
public Health health() {
Health.Builder builder = Health.up();
boolean degraded = false;
// 1. 数据库:执行轻量查询,不只是连上
try (Connection conn = dataSource.getConnection()) {
conn.createStatement().executeQuery("SELECT 1");
builder.withDetail("database", "UP");
} catch (Exception e) {
builder.withDetail("database", "DOWN: " + e.getMessage());
degraded = true;
}
// 2. Redis:写一个标记位,验证读写能力
try {
String testKey = "health_check:" + System.currentTimeMillis();
redisTemplate.opsForValue().set(testKey, "1", 5, TimeUnit.SECONDS);
String value = redisTemplate.opsForValue().get(testKey);
if (!"1".equals(value)) {
throw new RuntimeException("Redis write succeeded but read failed");
}
builder.withDetail("redis", "UP (read+write verified)");
} catch (Exception e) {
builder.withDetail("redis", "DOWN: " + e.getMessage());
degraded = true;
}
// 3. RabbitMQ:检查连接状态
try {
rabbitTemplate.execute(channel -> {
// channel.isOpen() 验证真实连接
return channel.isOpen();
});
builder.withDetail("rabbitmq", "UP");
} catch (Exception e) {
builder.withDetail("rabbitmq", "DOWN: " + e.getMessage());
degraded = true;
}
// 4. Elasticsearch:发一个 _cluster/health
try {
ClusterHealthResponse response = esClient.cluster()
.health(RequestOptions.DEFAULT);
if ("red".equals(response.getStatus().name().toLowerCase())) {
throw new RuntimeException("ES cluster status is RED");
}
builder.withDetail("elasticsearch", "UP (" + response.getStatus() + ")");
} catch (Exception e) {
builder.withDetail("elasticsearch", "DOWN: " + e.getMessage());
degraded = true;
}
if (degraded) {
return builder.status(Status.DOWN)
.withDetail("message", "One or more dependencies unavailable")
.build();
}
return builder.build();
}
}
4.3 关键设计决策
上面这段代码里,有几个容易忽略的细节:
数据库验证不只是连上。 dataSource.getConnection() 只会告诉你连接池有空闲连接,不代表那条连接真的能执行查询。必须 executeQuery("SELECT 1") 跑一下才知道。
Redis 必须写+读双重验证。 如果 Redis 分片变成了只读模式——就像那次凌晨事故——能读不能写,只发一个 GET 是发现不了的。先 SET 再 GET,两步都成功才算 UP。
每个依赖的健康检查都是独立的。 任何一个组件挂了都标记 degraded,但别一上来就抛异常退出——先把所有依赖都查完,汇总状态后再返回。这样做的好处是监控大盘上能看到"Redis 挂了但其他都正常",而不是一个无信息的 DOWN。
4.4 K8s 探针配置
光写了 Java 代码不够,得让 K8s 知道该调哪个端点。
apiVersion: v1
kind: Pod
spec:
containers:
- name: order-service
image: order-service:latest
ports:
- containerPort: 8080
# Liveness:心跳检查,失败时重启 Pod
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 3 # 连续 3 次失败才重启
# Readiness:依赖检查,失败时摘除流量
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 20
periodSeconds: 10
failureThreshold: 2 # 连续 2 次失败就停止分发流量
关键点:Readiness 的 failureThreshold 比 Liveness 更敏感(2 次 vs 3 次)。原因是依赖故障时应该尽快摘流而不是急着杀 Pod——万一 Redis 抖动 20 秒就恢复了,Pod 还在那,重启反而更慢。
五、更进一步:业务链路模拟
Readiness 解决了"组件通不通"的问题。但还有一个更隐蔽的场景——组件通,逻辑不通。
比如:MySQL SELECT 1 很快返回,但你真正出问题的那张 order 表被锁死了。比如:Redis 读写正常,但你的某个缓存 Key 的序列化方式跟业务代码不匹配。
这时候就需要 Business 层的探针——模拟一条真实的业务链路。
@Component
public class BusinessProbe implements HealthIndicator {
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired private RedisTemplate<String, String> redisTemplate;
@Override
public Health health() {
try {
// 模拟下单链路中的两个关键操作
// 1. 查一条真实商品信息
jdbcTemplate.queryForObject(
"SELECT id, stock FROM product WHERE id = ? AND status = 'ONLINE' LIMIT 1",
new Object[]{PROBE_PRODUCT_ID},
(rs, rowNum) -> rs.getLong("id")
);
// 2. 查该商品的缓存
String cacheKey = "product:cache:" + PROBE_PRODUCT_ID;
String cached = redisTemplate.opsForValue().get(cacheKey);
return Health.up()
.withDetail("business_path", "order_create_link")
.withDetail("cache_hit", cached != null)
.build();
} catch (Exception e) {
return Health.down()
.withDetail("business_path", "order_create_link")
.withDetail("error", e.getMessage())
.build();
}
}
}
这个探针不宜高频调用——10 秒一次足够,没必要跟 Readiness 一样频繁。它最大的价值是在灰度发布和上线后第一时间发现业务链路的问题,而不是等用户投诉。
六、效果对比
那次凌晨事故之后,我们把健康检查从空壳替换成了三层探针。上线一个月:
| 场景 | 旧健康检查 | 新健康检查 |
|---|---|---|
| Redis 分片只读 | 200 OK,无感知 | Readiness DOWN,自动摘流 |
| 数据库连接池打满 | 200 OK(连接还在池里 | Readiness DOWN,摘流告警 |
| MySQL 表锁 | 200 OK(SELECT 1 正常) | Business DOWN,灰度时拦截 |
| ES 集群 RED | 200 OK | Readiness DOWN,提前发现 |
| 真实影响 | 3 次凌晨事故 | 0 次 |
最关键的变化:故障发现从"用户投诉"变成了"探针告警"。 以前是用户说付不了款我们才知道,现在是探针探测到 Redis 异常后 10 秒内摘流+发通知,用户全程无感。
七、实践中踩的坑
坑一:Liveness 别查外部依赖。 如果把 Redis 检查放在 Liveness 里,Redis 一抖 Pod 就被杀了——可 Redis 的问题通常不是杀你的 Pod 能解决的。Liveness 只管应用自己,依赖留给 Readiness。
坑二:健康检查本身也会产生开销。 Readiness 里加 Redis 读写验证时记得设 TTL,不然 redisTemplate.opsForValue().set() 每次写一条新数据永远不过期,时间长了也是个隐患。
坑三:多个依赖并行检查。 如果依次检查 DB → Redis → MQ → ES,串行下来可能要两三秒。用 CompletableFuture 并行检查:
CompletableFuture<Health> dbHealth = CompletableFuture
.supplyAsync(this::checkDatabase);
CompletableFuture<Health> redisHealth = CompletableFuture
.supplyAsync(this::checkRedis);
// ... 并行检查所有依赖
CompletableFuture.allOf(dbHealth, redisHealth, ...).join();
坑四:Readiness 失败后注意 K8s 的启动依赖。 如果你的服务启动时要连数据库,但数据库还没就绪,Readiness 一直 DOWN,Pod 永远进不了 Running。解决方案:initialDelaySeconds 设得足够长,或者读 Readiness 失败的 Pod 日志确认不是启动问题。
一个返回 200 的健康检查,比没有健康检查更危险——因为它让你以为自己很安全。
这次写的是健康检查的正确打开方式。你的项目里 /actuator/health 现在查了什么?评论区说说,看看谁的最敷衍。
标题:服务一直返回200,K8s却说它活着,直到业务全挂了
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/21/1782007540757.html
公众号:服务端技术精选
评论