服务一直返回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 默认的健康检查,只验证了三件事:

  1. 应用上下文加载成功
  2. JVM 没 OOM
  3. 端口监听正常

它不在乎 Redis 能不能读写,不在乎数据库连接池有没有打满,不在乎消息队列消费有没有卡住。只要应用进程还活着,它就返回 UP。

就像一个心电监护仪只看你有没有心跳,不管你脑子里在想什么。


二、一次真实故障的排查路径

回到凌晨那次事故,我们从头复盘了健康检查的盲区:

依赖组件实际状态健康检查感知影响
MySQL正常,连接池 80/100UP暂无
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 是发现不了的。先 SETGET,两步都成功才算 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 集群 RED200 OKReadiness 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
公众号:服务端技术精选
    评论
    0 评论
avatar

取消