前言:如果还在用端口判断服务“活着”,迟早要背锅先讲一个我亲眼见过的线上事故。某天深夜,一个核心服务集群的CPU被打满,进程还挂着,端口也正常,但所有请求都卡在连接池里超时。监控面板上一切绿油油,直到凌晨三点用户密集投诉,值班的人手动重启才恢复。事后排查发现,探针一直用TCP端口连通性做存活判断——端口通,进程在,但服务已经没有处理能力了。这就是典型的“活着但没活着”。SpringBoot健康检查要解决的就是这个问题:对外提供一个标准化的接口,用真实、语义化、多层次的状态去表达服务是否具备处理请求的能力。它不是一个“端口通不通”的开关,而是一个体系:从进程本身,到依赖组件,再到自定义业务场景,逐层上报。配合监控告警、负载均衡摘除、容器编排探针,才能把线上事故的发现时间从“用户投诉”提前到“系统自检”。这篇文章适合谁?正在用SpringBoot做微服务或单体应用,希望在线上环境中配置出一套靠谱的健康检查,或者在踩了“假健康检查”的坑之后想彻底搞明白原理的人。我会从设计思路讲起,补全配置细节、实操步骤和排障经验,把我这些年踩过的坑一次性讲透。1. 整体设计与思路拆解1.1 健康检查的本质:服务状态的分层表达在讨论SpringBoot怎么做之前,先想清楚一个底层问题:一个服务在什么情况下算“健康”?绝大多数团队的第一反应是“进程没挂”。但进程没挂,顶多算“活着”,距离“可以正常完成业务请求”还有很大距离。我习惯把服务状态分成三个层次:进程层:操作系统层面进程是否存在。端口与容器层:端口能否建立TCP连接,容器是否进入Ready状态。依赖与业务层:数据库连接是否可用、消息队列是否能消费、外部接口是否响应、核心业务线程池是否有余量。健康检查真正有价值的地方,在于它能把这三层信息统一收敛成一个“探针返回体”。上游调用方、网关、负载均衡器、K8s调度器只需要看这个返回体的状态码,就能决定流量继续进来还是立刻摘除。你可以把健康检查理解成服务对外递交的一份“体检报告”,而不是简单的“呼吸检测仪”。1.2 为什么不该继续用端口探测做存活判断如果只是TCP端口通不通,很多真正的故障根本暴露不出来。举几个最常见的场景:线程池耗尽。Tomcat默认线程池满了之后,新建TCP连接依然能握手成功,但请求会一直排队,最终超时。数据库连接池抽干。服务进程正常,但所有线程卡在获取连接上。端口肯定通,服务早已不可用。磁盘写满。磁盘100%时,应用日志写不进去,某些场景下进程不会立即崩,但业务已经停滞。JVM发生频繁Full GC,应用线程长时间停顿。端口还在监听,但实际吞吐量趋近于零。在这些场景下,“端口通”不仅没有带来安全感,反而带来了虚假的稳定信号。而SpringBoot健康检查把这些真实状态都用Indicator包装起来,返回一个HTTP状态码:200代表UP,503代表DOWN。这个状态码可以被K8s、负载均衡器、监控系统直接消费。1.3 SpringBoot健康检查在整个架构里的位置我不是说一个健康检查接口能解决所有稳定性问题,它是整个稳定性体系里最基础的那个环节。架构上它一般存在于三条链路:容器编排链路:K8s的readinessProbe和livenessProbe直接请求健康检查接口,决定Pod是否被纳入Service流量负载、是否触发重启。网关与负载均衡链路:网关通过主动探测或服务注册中心的健康标记,决定是否将请求路由给某个实例。监控告警链路:监控系统定时拉取健康检查结果,状态为DOWN时触发告警通知。这三条链路共用同一个协议,却有不同的诉求。readinessProbe要求结果真实反映“能否接受流量”,livenessProbe要求结果反映“进程是否还能自救”,监控告警则希望结果包含足够的细节,比如具体挂了哪个组件。所以后面聊配置时,你可能会看到同一个健康检查端点,在K8s探针和监控告警里的使用方式是完全不同的。2. 核心细节解析与实操要点2.1 Actuator健康检查端点的准备工作SpringBoot的健康检查能力由spring-boot-starter-actuator提供。引入依赖后,健康检查端点默认就是开启状态,并且HTTP路径是/actuator/health。但默认开启不意味着配置到位,生产环境里必须把下面几项搞明白。先看一下基础依赖配置:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>如果是Gradle工程,对应的是:implementation 'org.springframework.boot:spring-boot-starter-actuator'引入之后,SpringBoot会自动把所有已注册的HealthIndicator聚合到一起,最终返回一个树形结构的JSON,根节点是整体状态,子节点是各组件状态。这里有个关键认知:健康检查的整体状态不是由某一个组件单独决定的,而是所有Indicator状态的汇总归纳。比如数据库挂了,但Redis正常,最终整体状态大概率是DOWN,因为这已经说明服务无法正确处理请求。不过,是否真的会被判定为DOWN,取决于状态聚合规则和排除规则。2.2 状态码与字段语义解读健康检查返回的状态分为四级:UP:组件正常,服务健康。DOWN:组件故障或不可用。OUT_OF_SERVICE:组件被主动下线,一般维护场景使用。UNKNOWN:状态无法确认,例如刚刚启动还在初始化。真实返回的JSON长这样:{ "status": "DOWN", "components": { "db": { "status": "DOWN", "details": { "error": "Cannot get connection: Connection refused" } }, "diskSpace": { "status": "UP", "details": { "total": 268435456000, "free": 8847360000, "threshold": 10485760 } }, "redis": { "status": "UP" } } }注意两个关键点:默认情况下,details细节不会对外开放,避免泄露内部连接信息。默认show-details策略是never。HTTP状态码和status字段的映射关系可以通过配置调整。默认情况下,整体状态为UP时返回200,DOWN或OUT_OF_SERVICE时返回503,UNKNOWN返回200。这个映射关系在业务网关里很重要。很多网关是根据HTTP状态码来做摘除和熔断判断的,如果状态码没有正确返回,健康检查做得再精细也白搭。2.3 show-details、show-components与安全隔离线上环境里,必须显式配置健康检查细节的暴露策略。我的建议分场景设置:给K8s探针用:保持默认的never,只要状态码正确就行,越简单越好。给监控告警平台用:可以配置为when-authorized,让内部监控系统通过认证后看到完整细节。给人工排查用:临时debug时改成always,用完立刻改回来。相关配置如下:management: endpoint: health: show-details: when-authorized show-components: always status: http-mapping: DOWN: 503 OUT_OF_SERVICE: 503这里要重点解释status.http-mapping的作用。比如你有一个业务组件被判定为OUT_OF_SERVICE,在维护窗口期内希望负载均衡器把流量摘掉,那返回503是合理的。如果觉得默认行为不合适,可以自定义http-mapping,把OUT_OF_SERVICE映射到200,让它在网关层面“假死”但没有告警,适合某些特殊的下线流程。不过这属于偏门玩法,绝大多数团队用默认映射就够了。再补一个安全相关的提醒:健康检查端点不要直接用开放权限暴露到公网。如果应用用了Spring Security,绕开安全过滤器的常规做法是把/actuator/health加入白名单,但白名单同时也会把健康检查路径裸露出去,如果show-details被配置为always,恶意用户就能看到数据库连接地址和错误信息。稳妥的方案是:健康检查端口独立绑定在内网管理端口。show-details保持never或when-authorized。网络层面做安全组限制,而不是全依赖代码。2.4 常用内置HealthIndicator说明SpringBoot按依赖自动装配了很多Indicator,我挑几个最常用的讲,它们分别覆盖了不同的依赖类型。DataSourceHealthIndicator如果数据源存在就会自动注册。默认会执行一条SELECT 1(不同数据库方言有差异,Oracle之类会调用duel查询),能成功返回就判定UP。有一个隐藏参数validation-query,绝大多数情况下不需要自己设置,因为SpringBoot会根据数据库连接自动推断。需要注意的场景是:数据库连接池满了,这个Indicator拿不到连接,会直接抛异常,状态变为DOWN。DiskSpaceHealthIndicator磁盘空间检查,默认只会检查一个路径,而且默认阈值是10MB。换句话说,如果磁盘剩余空间低于10MB,就会被判定为DOWN。这个阈值往往不够生产环境用,我一般会显式把它调高到1GB以上,因为磁盘剩余空间低于1GB时,很多应用其实已经处于崩溃边缘了。同时只查一个路径远远不够,如果应用日志和业务存储在不同分区,还得额外配置多个路径检查。RedisHealthIndicator有Spring Data Redis依赖就会自动注册。它会向Redis发一个PING命令,正常返回PONG就是UP。这里有个容易忽略的问题:Redis实例如果在网络抖动期间短暂不可达,健康检查立刻变DOWN,然后K8s开始重启Pod。明明只是一个瞬断,结果被扩大成了一次线上事故。解决办法是给健康检查加上缓冲期,后面我会细讲。RabbitHealthIndicator、KafkaHealthIndicator等消息中间件的检查逻辑类似,会探活单个连接。问题也一样:瞬时抖动会被直接放大。这类组件在架构里的重要性通常很高,我建议为它们单独做更精细的探活,而不是只依赖SpringBoot自带的这层简单检测。2.5 健康检查的TimeToLive与缓冲期SpringBoot健康检查端点有个默认缓存策略:health.time-to-live。这个值很关键。如果不设置,每次调用都会实时执行所有Indicator,在高频探针轮询下会给数据库、Redis、磁盘带来额外压力。如果设置太长,故障发生后,探针拿到的还是缓存里的UP状态,告警又变成“迟到”了。我的经验是:监控系统轮询间隔如果是10秒,那么time-to-live缓存时间设为5秒左右,取一个折中。K8s探针默认会设置periodSeconds、timeoutSeconds,所以缓存时间建议小于periodSeconds。READY探针和LIVENESS探针建议不要共用一个缓存前缀,否则频率一高一低会互相拖累。配置示例:management: endpoint: health: time-to-live: 5s这个5秒的意思是说:虽然每次调用接口都会即时返回,但内部指标数据会缓存5秒。如果在这5秒内Redis挂了,接口最快也要到缓存过期后才能感知。这个时间窗口,对于大部分监控场景完全够用。2.6 分组与聚合:同一探针,多个视角有时候同一个健康检查端点需要承担多个角色。比如:网关链路只关心“Service是否Ready”,不需要知道数据库的细节。运维告警链路需要看“具体哪个组件掉线了”。K8s的readinessProbe希望只关心“能否接受流量”,如果只是磁盘空间低但它还够用,不希望直接摘流量。SpringBoot的health group就是为这种需求准备的。我们可以定义一个group,只聚合某几个Indicator,甚至给group单独配置show-details和状态映射。配置示例:management: endpoint: health: group: readiness: include: db,redis,diskSpace liveness: include: ping这样就有了两个独立探针路径:/actuator/health/readiness和/actuator/health/liveness。前者用于K8s的readinessProbe,后者用于livenessProbe。readiness关注依赖是否就绪,liveness只关心Spring容器本身是否还活着。这里有一个很重要的设计原则:readiness探针和liveness探针绝不能使用同一个状态集合。原因很直接:如果数据库闪断,readiness应该立刻摘流量,但liveness不能跟着去重启Pod,否则数据库恢复时需要重建所有实例,属于典型的“故障扩大化”。3. 实操过程与核心环节实现3.1 一个完整的项目改造示例下面我会用一个虚构的“订单查询服务”举例,演示一套可落地的配置和代码。这个服务依赖MySQL、Redis和一个外部风控接口,部署在K8s集群中,前面有网关做流量分发。第一步,依赖准备。除了starter-actuator之外,还要按需引入spring-boot-starter-data-redis、spring-boot-starter-jdbc等。第二步,配置application.yml。我给出一个偏保守但可靠的配置方案:management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: when-authorized show-components: always time-to-live: 5s group: readiness: include: db,redis,diskSpace,externalRiskApi liveness: include: ping server: port: 9090这里management独立端口设置成9090,避免业务端口和监控端口混淆。生产环境建议给监控端口单独开管理通道。第三步,自定义外部依赖的健康检查。默认的Indicator里没有“外部HTTP接口是否可用”这种概念,需要自己实现。代码如下:@Component public class ExternalRiskApiHealthIndicator extends AbstractHealthIndicator { private final RestTemplate restTemplate; public ExternalRiskApiHealthIndicator(RestTemplate restTemplate) { super(10000); this.restTemplate = restTemplate; } @Override protected void doHealthCheck(Health.Builder builder) throws Exception { HttpHeaders headers = new HttpHeaders(); headers.setBasicAuth("healthcheck", "secret"); HttpEntity<Void> entity = new HttpEntity<>(headers); try { ResponseEntity<String> response = restTemplate.exchange( "https://external-risk-api.example.com/health", HttpMethod.GET, entity, String.class ); if (response.getStatusCode().is2xxSuccessful()) { builder.up() .withDetail("httpStatus", response.getStatusCode().value()) .withDetail("message", "external risk api is reachable"); } else { builder.down() .withDetail("httpStatus", response.getStatusCode().value()) .withDetail("message", "external risk api returned non-2xx"); } } catch (Exception ex) { builder.down() .withDetail("error", ex.getClass().getSimpleName()) .withDetail("message", ex.getMessage()); } } }注意构造方法里传入的10000,这是AbstractHealthIndicator的超时时间,单位是毫秒。它保证整个健康检查执行过程在10秒内必须返回,避免某个外部接口长时间无响应拖垮整个健康检查调用链路。外部接口健康检查一般包括连接超时和读取超时,对于RestTemplate我会单独设置连接超时2秒、读取超时5秒,总的健康检查时间控制在10秒以内。3.2 配置K8s探针时的典型示例K8s探针直接消费健康检查端点,需要定义readiness和liveness两套探测参数。下面是一份比较合理的配置:apiVersion: apps/v1 kind: Deployment metadata: name: order-query-service spec: template: spec: containers: - name: order-query-service image: registry.example.com/order-query-service:1.2.0 ports: - containerPort: 8080 readinessProbe: httpGet: path: /actuator/health/readiness port: 9090 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 9090 initialDelaySeconds: 60 periodSeconds: 15 timeoutSeconds: 3 failureThreshold: 3几个参数我是按照经验定的:initialDelaySeconds设为30,给SpringBoot启动、数据源初始化留出时间。periodSeconds设为10,与缓存时间5秒配套,避免频繁触发真实探测。timeoutSeconds设为3,一是避免探针调用过久,二是让一次失败的检测不会拖垮整个周期。failureThreshold设为3,连续三次失败才会真正把Pod标记为异常。这样能过滤掉瞬时抖动。这里最核心的一个坑是:不要顺手把readinessProbe和livenessProbe指向同一个path。如果你用一个包含db和redis的复杂健康检查做liveness,数据库短暂超时一秒,Pod就会被杀掉,这是很多团队踩过的“探针误杀”大坑。3.3 在网关层配置摘除逻辑网关层消费健康检查时,我建议设计一个简单的轮询任务。以Spring Cloud Gateway为例,可以自己拉取健康检查结果,然后把DOWN状态的实例从路由列表中剔除。伪代码如下:public boolean isHealthy(InstanceInfo instance) { String url = "http://" + instance.getHost() + ":9090/actuator/health/readiness"; try { HttpResponse response = httpClient.execute(url, 2, TimeUnit.SECONDS); return response.getStatusLine().getStatusCode() == 200; } catch (Exception e) { return false; } }注意这里我只用了HTTP状态码,没有解析JSON。原因在于:网关关心的是“这个实例能不能流流量”,而不是“磁盘还剩下多少”。把JSON解析放在网关层只会增加耦合度,状态码已经足够表达“是否可用”。3.4 验证探针效果的具体操作配置完成后,可以在本地模拟一次故障来验证整体体验。比如把数据库停掉:docker stop mysql curl http://localhost:9090/actuator/health/readiness正常情况会返回类似下面的JSON:{ "status": "DOWN", "components": { "db": { "status": "DOWN", "details": { "error": "Connection refused" } } } }再恢复数据库,等缓存过期后再请求一次,状态会重新回到UP。整个过程验证了三个关键点:Indicator能正确感知依赖状态、缓存时间不会掩盖故障、HTTP状态码能正确返回503。4. 常见问题与排查技巧实录4.1 Spring Security把健康检查拦在门外现象:K8s探针请求/actuator/health/readiness返回401。排查过程:先确认management.endpoint.health的路径是否真的存在,再用curl手动带上认证信息请求,如果带认证就通、不带就不通,那就是被Spring Security拦截了。解决方式分两种:如果只是想让探针访问,不暴露细节,那对/actuator/health**路径放行是安全的,前提是show-details设为never。如果希望监控系统拿到组件细节,就把management.server.port独立出来,然后在Security里只对管理端口放行。http .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health").permitAll() .requestMatchers("/actuator/health/**").permitAll() .anyRequest().authenticated() );但要注意,这段代码放行的是所有环境下的健康检查端点,生产环境一定要配合网段限制,不能裸奔。4.2 服务假死但健康检查依然全绿这是刚开始提到的那个事故的高发版本。进程在、端口通、数据库和Redis都正常,但请求处理已经卡死。这种问题最复杂,也是最容易漏掉的场景。根本原因在于:默认Indicator只检查外部依赖状态,不检查应用自身的处理能力。怎么解决?自定义一个Indicator,专门探测核心业务的线程池或请求队列。举个例子,如果你的核心处理器使用的是固定大小的线程池,可以这样检测:@Component public class CoreThreadPoolHealthIndicator extends AbstractHealthIndicator { private final ThreadPoolTaskExecutor coreExecutor; public CoreThreadPoolHealthIndicator(ThreadPoolTaskExecutor coreExecutor) { this.coreExecutor = coreExecutor; } @Override protected void doHealthCheck(Health.Builder builder) { int activeCount = coreExecutor.getActiveCount(); int poolSize = coreExecutor.getPoolSize(); int queueSize = coreExecutor.getThreadPoolExecutor().getQueue().size(); int utilization = (int) Math.floor(activeCount * 100.0 / poolSize); if (queueSize > 5000 || utilization > 95) { builder.down() .withDetail("activeCount", activeCount) .withDetail("queueSize", queueSize) .withDetail("utilization", utilization + "%"); return; } builder.up() .withDetail("activeCount", activeCount) .withDetail("queueSize", queueSize) .withDetail("utilization", utilization + "%"); } }我会把这个线程池的指标检查加入readiness探针组,这样“业务容量耗尽”才能真正反映到健康状态里。经验值是:线程池活跃度超过90%且持续一段时间,就已经具备事故特征了,健康状态必须降级为DOWN。4.3 健康检查状态导致负载均衡错误摘除现象:某个实例的健康检查偶发510状态码,结果网关立刻把所有流量切走,其他实例扛不住压力,最终集群整体雪崩。这类问题多半是健康检查里包含了一些“非核心依赖”,这些依赖短暂抖动后恢复了,但负载均衡器已经做出了摘除决策。解决办法:把健康检查分组。核心依赖(数据库、核心消息队列)放在readiness探针组,非核心依赖(某个外部报表接口)放在单独分组。给非核心依赖单独配置一个statusMapping,把它映射为200,或者说映射成UP。但要谨慎,这样会掩盖真实故障。在负载均衡器端增加“连续失败N次才摘除”的策略,而不是一次失败立即摘除。4.4 健康检查接口本身拖垮了服务现象:健康检查被监控系统以1秒一次的频率轮询,每次都要查询数据库、Redis、外部接口。高峰期时,健康检查调用本身消耗了大量连接和线程资源,反而引发了故障。解决方案:设置缓存时间,至少2秒,推荐5秒。独立管理端口,避免公共流量和探针流量互相争抢。将健康检查逻辑中的外部调用超时控制在几百毫秒到2秒之间,不要让它慢吞吞地等满10秒。监控系统轮询周期统一调整为10秒,而不是秒级。4.5 数据库连接池耗尽导致健康检查假DOWNDataSourceHealthIndicator需要拿一个连接执行校验SQL。如果连接池已经耗尽,它拿不到连接,健康检查就返回DOWN。但这未必意味着数据库有问题,可能是某个大查询占满了连接池。这种case下,真正的修复方向是排查连接泄漏和慢SQL,而不是让应用不停重启。所以我建议把数据源健康检查的超时时间调小,并加入连接池使用率信息:management: health: db: timeout: 3s同时,自定义数据源Indicator,把连接池当前空闲数、活跃数、等待队列长度都放到details里,方便监控系统给出精确告警。4.6 缓存与时间窗口导致的状态滞后健康检查缓存时间设得越长,状态滞后越明显。极端情况下,K8s探针刚拿到UP,几秒后服务已不可用,但探针下一次检查还在缓存期内,延迟摘除流量。所以缓存时间的设定要综合考虑监控系统的轮询周期和探针的periodSeconds。我一般会遵循一个简单公式:缓存时间 <= 探针周期 / 2比如探针周期10秒,缓存时间最多5秒。这样即使状态滞后,也不会超过半个探针周期。4.7 附:一份问题排查速查表现象可能原因优先排查项健康检查返回401Spring Security拦截放行路径,或独立管理端口服务假死但健康检查UP未检查业务线程池/队列自定义核心线程池Indicator数据库正常但健康DOWN连接池耗尽,查询超时检查活跃连接数与慢SQL频繁触发Pod重启liveness和readiness混用拆分探针组,liveness只含ping网关流量异常抖动偶发状态码导致立即摘除增加连续失败阈值,调整group探针接口慢、资源占用高无缓存,外部调用超时过长设置time-to-live,调小外部超时磁盘未满但状态DOWN默认阈值10MB只会看一个路径配置多个路径并调高阈值结尾从我的实践经验来看,健康检查这个功能,真正难的不是引入Actuator、写几行配置,而是把“健康定义”想清楚,不同场景用不同探针,不同依赖配置不同超时。踩过几次坑之后,我现在每个新项目的健康检查配置都会先过一遍“三问”:有没有把端口监控当成服务存活判断?有没有把瞬时抖动误判成永久故障?有没有把readiness和liveness的职责混在一起?这三问过完,线上事故的发现时间基本就能从“用户投诉”提前到“系统自动摘除”。最后再分享一个小技巧:每次上线前,我会特意人为停掉一两个下游依赖,看看健康检查的返回体是否还是干净的UP。如果看到的是乱七八糟的状态码,说明配置还是有漏洞,趁早修好再上线。这套方法,建议每个团队都在测试环境里跑一遍。
前言:如果还在用端口判断服务“活着”,迟早要背锅先讲一个我亲眼见过的线上事故。某天深夜,一个核心服务集群的CPU被打满,进程还挂着,端口也正常,但所有请求都卡在连接池里超时。监控面板上一切绿油油,直到凌晨三点用户密集投诉,值班的人手动重启才恢复。事后排查发现,探针一直用TCP端口连通性做存活判断——端口通,进程在,但服务已经没有处理能力了。这就是典型的“活着但没活着”。SpringBoot健康检查要解决的就是这个问题:对外提供一个标准化的接口,用真实、语义化、多层次的状态去表达服务是否具备处理请求的能力。它不是一个“端口通不通”的开关,而是一个体系:从进程本身,到依赖组件,再到自定义业务场景,逐层上报。配合监控告警、负载均衡摘除、容器编排探针,才能把线上事故的发现时间从“用户投诉”提前到“系统自检”。这篇文章适合谁?正在用SpringBoot做微服务或单体应用,希望在线上环境中配置出一套靠谱的健康检查,或者在踩了“假健康检查”的坑之后想彻底搞明白原理的人。我会从设计思路讲起,补全配置细节、实操步骤和排障经验,把我这些年踩过的坑一次性讲透。1. 整体设计与思路拆解1.1 健康检查的本质:服务状态的分层表达在讨论SpringBoot怎么做之前,先想清楚一个底层问题:一个服务在什么情况下算“健康”?绝大多数团队的第一反应是“进程没挂”。但进程没挂,顶多算“活着”,距离“可以正常完成业务请求”还有很大距离。我习惯把服务状态分成三个层次:进程层:操作系统层面进程是否存在。端口与容器层:端口能否建立TCP连接,容器是否进入Ready状态。依赖与业务层:数据库连接是否可用、消息队列是否能消费、外部接口是否响应、核心业务线程池是否有余量。健康检查真正有价值的地方,在于它能把这三层信息统一收敛成一个“探针返回体”。上游调用方、网关、负载均衡器、K8s调度器只需要看这个返回体的状态码,就能决定流量继续进来还是立刻摘除。你可以把健康检查理解成服务对外递交的一份“体检报告”,而不是简单的“呼吸检测仪”。1.2 为什么不该继续用端口探测做存活判断如果只是TCP端口通不通,很多真正的故障根本暴露不出来。举几个最常见的场景:线程池耗尽。Tomcat默认线程池满了之后,新建TCP连接依然能握手成功,但请求会一直排队,最终超时。数据库连接池抽干。服务进程正常,但所有线程卡在获取连接上。端口肯定通,服务早已不可用。磁盘写满。磁盘100%时,应用日志写不进去,某些场景下进程不会立即崩,但业务已经停滞。JVM发生频繁Full GC,应用线程长时间停顿。端口还在监听,但实际吞吐量趋近于零。在这些场景下,“端口通”不仅没有带来安全感,反而带来了虚假的稳定信号。而SpringBoot健康检查把这些真实状态都用Indicator包装起来,返回一个HTTP状态码:200代表UP,503代表DOWN。这个状态码可以被K8s、负载均衡器、监控系统直接消费。1.3 SpringBoot健康检查在整个架构里的位置我不是说一个健康检查接口能解决所有稳定性问题,它是整个稳定性体系里最基础的那个环节。架构上它一般存在于三条链路:容器编排链路:K8s的readinessProbe和livenessProbe直接请求健康检查接口,决定Pod是否被纳入Service流量负载、是否触发重启。网关与负载均衡链路:网关通过主动探测或服务注册中心的健康标记,决定是否将请求路由给某个实例。监控告警链路:监控系统定时拉取健康检查结果,状态为DOWN时触发告警通知。这三条链路共用同一个协议,却有不同的诉求。readinessProbe要求结果真实反映“能否接受流量”,livenessProbe要求结果反映“进程是否还能自救”,监控告警则希望结果包含足够的细节,比如具体挂了哪个组件。所以后面聊配置时,你可能会看到同一个健康检查端点,在K8s探针和监控告警里的使用方式是完全不同的。2. 核心细节解析与实操要点2.1 Actuator健康检查端点的准备工作SpringBoot的健康检查能力由spring-boot-starter-actuator提供。引入依赖后,健康检查端点默认就是开启状态,并且HTTP路径是/actuator/health。但默认开启不意味着配置到位,生产环境里必须把下面几项搞明白。先看一下基础依赖配置:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>如果是Gradle工程,对应的是:implementation 'org.springframework.boot:spring-boot-starter-actuator'引入之后,SpringBoot会自动把所有已注册的HealthIndicator聚合到一起,最终返回一个树形结构的JSON,根节点是整体状态,子节点是各组件状态。这里有个关键认知:健康检查的整体状态不是由某一个组件单独决定的,而是所有Indicator状态的汇总归纳。比如数据库挂了,但Redis正常,最终整体状态大概率是DOWN,因为这已经说明服务无法正确处理请求。不过,是否真的会被判定为DOWN,取决于状态聚合规则和排除规则。2.2 状态码与字段语义解读健康检查返回的状态分为四级:UP:组件正常,服务健康。DOWN:组件故障或不可用。OUT_OF_SERVICE:组件被主动下线,一般维护场景使用。UNKNOWN:状态无法确认,例如刚刚启动还在初始化。真实返回的JSON长这样:{ "status": "DOWN", "components": { "db": { "status": "DOWN", "details": { "error": "Cannot get connection: Connection refused" } }, "diskSpace": { "status": "UP", "details": { "total": 268435456000, "free": 8847360000, "threshold": 10485760 } }, "redis": { "status": "UP" } } }注意两个关键点:默认情况下,details细节不会对外开放,避免泄露内部连接信息。默认show-details策略是never。HTTP状态码和status字段的映射关系可以通过配置调整。默认情况下,整体状态为UP时返回200,DOWN或OUT_OF_SERVICE时返回503,UNKNOWN返回200。这个映射关系在业务网关里很重要。很多网关是根据HTTP状态码来做摘除和熔断判断的,如果状态码没有正确返回,健康检查做得再精细也白搭。2.3 show-details、show-components与安全隔离线上环境里,必须显式配置健康检查细节的暴露策略。我的建议分场景设置:给K8s探针用:保持默认的never,只要状态码正确就行,越简单越好。给监控告警平台用:可以配置为when-authorized,让内部监控系统通过认证后看到完整细节。给人工排查用:临时debug时改成always,用完立刻改回来。相关配置如下:management: endpoint: health: show-details: when-authorized show-components: always status: http-mapping: DOWN: 503 OUT_OF_SERVICE: 503这里要重点解释status.http-mapping的作用。比如你有一个业务组件被判定为OUT_OF_SERVICE,在维护窗口期内希望负载均衡器把流量摘掉,那返回503是合理的。如果觉得默认行为不合适,可以自定义http-mapping,把OUT_OF_SERVICE映射到200,让它在网关层面“假死”但没有告警,适合某些特殊的下线流程。不过这属于偏门玩法,绝大多数团队用默认映射就够了。再补一个安全相关的提醒:健康检查端点不要直接用开放权限暴露到公网。如果应用用了Spring Security,绕开安全过滤器的常规做法是把/actuator/health加入白名单,但白名单同时也会把健康检查路径裸露出去,如果show-details被配置为always,恶意用户就能看到数据库连接地址和错误信息。稳妥的方案是:健康检查端口独立绑定在内网管理端口。show-details保持never或when-authorized。网络层面做安全组限制,而不是全依赖代码。2.4 常用内置HealthIndicator说明SpringBoot按依赖自动装配了很多Indicator,我挑几个最常用的讲,它们分别覆盖了不同的依赖类型。DataSourceHealthIndicator如果数据源存在就会自动注册。默认会执行一条SELECT 1(不同数据库方言有差异,Oracle之类会调用duel查询),能成功返回就判定UP。有一个隐藏参数validation-query,绝大多数情况下不需要自己设置,因为SpringBoot会根据数据库连接自动推断。需要注意的场景是:数据库连接池满了,这个Indicator拿不到连接,会直接抛异常,状态变为DOWN。DiskSpaceHealthIndicator磁盘空间检查,默认只会检查一个路径,而且默认阈值是10MB。换句话说,如果磁盘剩余空间低于10MB,就会被判定为DOWN。这个阈值往往不够生产环境用,我一般会显式把它调高到1GB以上,因为磁盘剩余空间低于1GB时,很多应用其实已经处于崩溃边缘了。同时只查一个路径远远不够,如果应用日志和业务存储在不同分区,还得额外配置多个路径检查。RedisHealthIndicator有Spring Data Redis依赖就会自动注册。它会向Redis发一个PING命令,正常返回PONG就是UP。这里有个容易忽略的问题:Redis实例如果在网络抖动期间短暂不可达,健康检查立刻变DOWN,然后K8s开始重启Pod。明明只是一个瞬断,结果被扩大成了一次线上事故。解决办法是给健康检查加上缓冲期,后面我会细讲。RabbitHealthIndicator、KafkaHealthIndicator等消息中间件的检查逻辑类似,会探活单个连接。问题也一样:瞬时抖动会被直接放大。这类组件在架构里的重要性通常很高,我建议为它们单独做更精细的探活,而不是只依赖SpringBoot自带的这层简单检测。2.5 健康检查的TimeToLive与缓冲期SpringBoot健康检查端点有个默认缓存策略:health.time-to-live。这个值很关键。如果不设置,每次调用都会实时执行所有Indicator,在高频探针轮询下会给数据库、Redis、磁盘带来额外压力。如果设置太长,故障发生后,探针拿到的还是缓存里的UP状态,告警又变成“迟到”了。我的经验是:监控系统轮询间隔如果是10秒,那么time-to-live缓存时间设为5秒左右,取一个折中。K8s探针默认会设置periodSeconds、timeoutSeconds,所以缓存时间建议小于periodSeconds。READY探针和LIVENESS探针建议不要共用一个缓存前缀,否则频率一高一低会互相拖累。配置示例:management: endpoint: health: time-to-live: 5s这个5秒的意思是说:虽然每次调用接口都会即时返回,但内部指标数据会缓存5秒。如果在这5秒内Redis挂了,接口最快也要到缓存过期后才能感知。这个时间窗口,对于大部分监控场景完全够用。2.6 分组与聚合:同一探针,多个视角有时候同一个健康检查端点需要承担多个角色。比如:网关链路只关心“Service是否Ready”,不需要知道数据库的细节。运维告警链路需要看“具体哪个组件掉线了”。K8s的readinessProbe希望只关心“能否接受流量”,如果只是磁盘空间低但它还够用,不希望直接摘流量。SpringBoot的health group就是为这种需求准备的。我们可以定义一个group,只聚合某几个Indicator,甚至给group单独配置show-details和状态映射。配置示例:management: endpoint: health: group: readiness: include: db,redis,diskSpace liveness: include: ping这样就有了两个独立探针路径:/actuator/health/readiness和/actuator/health/liveness。前者用于K8s的readinessProbe,后者用于livenessProbe。readiness关注依赖是否就绪,liveness只关心Spring容器本身是否还活着。这里有一个很重要的设计原则:readiness探针和liveness探针绝不能使用同一个状态集合。原因很直接:如果数据库闪断,readiness应该立刻摘流量,但liveness不能跟着去重启Pod,否则数据库恢复时需要重建所有实例,属于典型的“故障扩大化”。3. 实操过程与核心环节实现3.1 一个完整的项目改造示例下面我会用一个虚构的“订单查询服务”举例,演示一套可落地的配置和代码。这个服务依赖MySQL、Redis和一个外部风控接口,部署在K8s集群中,前面有网关做流量分发。第一步,依赖准备。除了starter-actuator之外,还要按需引入spring-boot-starter-data-redis、spring-boot-starter-jdbc等。第二步,配置application.yml。我给出一个偏保守但可靠的配置方案:management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: when-authorized show-components: always time-to-live: 5s group: readiness: include: db,redis,diskSpace,externalRiskApi liveness: include: ping server: port: 9090这里management独立端口设置成9090,避免业务端口和监控端口混淆。生产环境建议给监控端口单独开管理通道。第三步,自定义外部依赖的健康检查。默认的Indicator里没有“外部HTTP接口是否可用”这种概念,需要自己实现。代码如下:@Component public class ExternalRiskApiHealthIndicator extends AbstractHealthIndicator { private final RestTemplate restTemplate; public ExternalRiskApiHealthIndicator(RestTemplate restTemplate) { super(10000); this.restTemplate = restTemplate; } @Override protected void doHealthCheck(Health.Builder builder) throws Exception { HttpHeaders headers = new HttpHeaders(); headers.setBasicAuth("healthcheck", "secret"); HttpEntity<Void> entity = new HttpEntity<>(headers); try { ResponseEntity<String> response = restTemplate.exchange( "https://external-risk-api.example.com/health", HttpMethod.GET, entity, String.class ); if (response.getStatusCode().is2xxSuccessful()) { builder.up() .withDetail("httpStatus", response.getStatusCode().value()) .withDetail("message", "external risk api is reachable"); } else { builder.down() .withDetail("httpStatus", response.getStatusCode().value()) .withDetail("message", "external risk api returned non-2xx"); } } catch (Exception ex) { builder.down() .withDetail("error", ex.getClass().getSimpleName()) .withDetail("message", ex.getMessage()); } } }注意构造方法里传入的10000,这是AbstractHealthIndicator的超时时间,单位是毫秒。它保证整个健康检查执行过程在10秒内必须返回,避免某个外部接口长时间无响应拖垮整个健康检查调用链路。外部接口健康检查一般包括连接超时和读取超时,对于RestTemplate我会单独设置连接超时2秒、读取超时5秒,总的健康检查时间控制在10秒以内。3.2 配置K8s探针时的典型示例K8s探针直接消费健康检查端点,需要定义readiness和liveness两套探测参数。下面是一份比较合理的配置:apiVersion: apps/v1 kind: Deployment metadata: name: order-query-service spec: template: spec: containers: - name: order-query-service image: registry.example.com/order-query-service:1.2.0 ports: - containerPort: 8080 readinessProbe: httpGet: path: /actuator/health/readiness port: 9090 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 9090 initialDelaySeconds: 60 periodSeconds: 15 timeoutSeconds: 3 failureThreshold: 3几个参数我是按照经验定的:initialDelaySeconds设为30,给SpringBoot启动、数据源初始化留出时间。periodSeconds设为10,与缓存时间5秒配套,避免频繁触发真实探测。timeoutSeconds设为3,一是避免探针调用过久,二是让一次失败的检测不会拖垮整个周期。failureThreshold设为3,连续三次失败才会真正把Pod标记为异常。这样能过滤掉瞬时抖动。这里最核心的一个坑是:不要顺手把readinessProbe和livenessProbe指向同一个path。如果你用一个包含db和redis的复杂健康检查做liveness,数据库短暂超时一秒,Pod就会被杀掉,这是很多团队踩过的“探针误杀”大坑。3.3 在网关层配置摘除逻辑网关层消费健康检查时,我建议设计一个简单的轮询任务。以Spring Cloud Gateway为例,可以自己拉取健康检查结果,然后把DOWN状态的实例从路由列表中剔除。伪代码如下:public boolean isHealthy(InstanceInfo instance) { String url = "http://" + instance.getHost() + ":9090/actuator/health/readiness"; try { HttpResponse response = httpClient.execute(url, 2, TimeUnit.SECONDS); return response.getStatusLine().getStatusCode() == 200; } catch (Exception e) { return false; } }注意这里我只用了HTTP状态码,没有解析JSON。原因在于:网关关心的是“这个实例能不能流流量”,而不是“磁盘还剩下多少”。把JSON解析放在网关层只会增加耦合度,状态码已经足够表达“是否可用”。3.4 验证探针效果的具体操作配置完成后,可以在本地模拟一次故障来验证整体体验。比如把数据库停掉:docker stop mysql curl http://localhost:9090/actuator/health/readiness正常情况会返回类似下面的JSON:{ "status": "DOWN", "components": { "db": { "status": "DOWN", "details": { "error": "Connection refused" } } } }再恢复数据库,等缓存过期后再请求一次,状态会重新回到UP。整个过程验证了三个关键点:Indicator能正确感知依赖状态、缓存时间不会掩盖故障、HTTP状态码能正确返回503。4. 常见问题与排查技巧实录4.1 Spring Security把健康检查拦在门外现象:K8s探针请求/actuator/health/readiness返回401。排查过程:先确认management.endpoint.health的路径是否真的存在,再用curl手动带上认证信息请求,如果带认证就通、不带就不通,那就是被Spring Security拦截了。解决方式分两种:如果只是想让探针访问,不暴露细节,那对/actuator/health**路径放行是安全的,前提是show-details设为never。如果希望监控系统拿到组件细节,就把management.server.port独立出来,然后在Security里只对管理端口放行。http .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health").permitAll() .requestMatchers("/actuator/health/**").permitAll() .anyRequest().authenticated() );但要注意,这段代码放行的是所有环境下的健康检查端点,生产环境一定要配合网段限制,不能裸奔。4.2 服务假死但健康检查依然全绿这是刚开始提到的那个事故的高发版本。进程在、端口通、数据库和Redis都正常,但请求处理已经卡死。这种问题最复杂,也是最容易漏掉的场景。根本原因在于:默认Indicator只检查外部依赖状态,不检查应用自身的处理能力。怎么解决?自定义一个Indicator,专门探测核心业务的线程池或请求队列。举个例子,如果你的核心处理器使用的是固定大小的线程池,可以这样检测:@Component public class CoreThreadPoolHealthIndicator extends AbstractHealthIndicator { private final ThreadPoolTaskExecutor coreExecutor; public CoreThreadPoolHealthIndicator(ThreadPoolTaskExecutor coreExecutor) { this.coreExecutor = coreExecutor; } @Override protected void doHealthCheck(Health.Builder builder) { int activeCount = coreExecutor.getActiveCount(); int poolSize = coreExecutor.getPoolSize(); int queueSize = coreExecutor.getThreadPoolExecutor().getQueue().size(); int utilization = (int) Math.floor(activeCount * 100.0 / poolSize); if (queueSize > 5000 || utilization > 95) { builder.down() .withDetail("activeCount", activeCount) .withDetail("queueSize", queueSize) .withDetail("utilization", utilization + "%"); return; } builder.up() .withDetail("activeCount", activeCount) .withDetail("queueSize", queueSize) .withDetail("utilization", utilization + "%"); } }我会把这个线程池的指标检查加入readiness探针组,这样“业务容量耗尽”才能真正反映到健康状态里。经验值是:线程池活跃度超过90%且持续一段时间,就已经具备事故特征了,健康状态必须降级为DOWN。4.3 健康检查状态导致负载均衡错误摘除现象:某个实例的健康检查偶发510状态码,结果网关立刻把所有流量切走,其他实例扛不住压力,最终集群整体雪崩。这类问题多半是健康检查里包含了一些“非核心依赖”,这些依赖短暂抖动后恢复了,但负载均衡器已经做出了摘除决策。解决办法:把健康检查分组。核心依赖(数据库、核心消息队列)放在readiness探针组,非核心依赖(某个外部报表接口)放在单独分组。给非核心依赖单独配置一个statusMapping,把它映射为200,或者说映射成UP。但要谨慎,这样会掩盖真实故障。在负载均衡器端增加“连续失败N次才摘除”的策略,而不是一次失败立即摘除。4.4 健康检查接口本身拖垮了服务现象:健康检查被监控系统以1秒一次的频率轮询,每次都要查询数据库、Redis、外部接口。高峰期时,健康检查调用本身消耗了大量连接和线程资源,反而引发了故障。解决方案:设置缓存时间,至少2秒,推荐5秒。独立管理端口,避免公共流量和探针流量互相争抢。将健康检查逻辑中的外部调用超时控制在几百毫秒到2秒之间,不要让它慢吞吞地等满10秒。监控系统轮询周期统一调整为10秒,而不是秒级。4.5 数据库连接池耗尽导致健康检查假DOWNDataSourceHealthIndicator需要拿一个连接执行校验SQL。如果连接池已经耗尽,它拿不到连接,健康检查就返回DOWN。但这未必意味着数据库有问题,可能是某个大查询占满了连接池。这种case下,真正的修复方向是排查连接泄漏和慢SQL,而不是让应用不停重启。所以我建议把数据源健康检查的超时时间调小,并加入连接池使用率信息:management: health: db: timeout: 3s同时,自定义数据源Indicator,把连接池当前空闲数、活跃数、等待队列长度都放到details里,方便监控系统给出精确告警。4.6 缓存与时间窗口导致的状态滞后健康检查缓存时间设得越长,状态滞后越明显。极端情况下,K8s探针刚拿到UP,几秒后服务已不可用,但探针下一次检查还在缓存期内,延迟摘除流量。所以缓存时间的设定要综合考虑监控系统的轮询周期和探针的periodSeconds。我一般会遵循一个简单公式:缓存时间 <= 探针周期 / 2比如探针周期10秒,缓存时间最多5秒。这样即使状态滞后,也不会超过半个探针周期。4.7 附:一份问题排查速查表现象可能原因优先排查项健康检查返回401Spring Security拦截放行路径,或独立管理端口服务假死但健康检查UP未检查业务线程池/队列自定义核心线程池Indicator数据库正常但健康DOWN连接池耗尽,查询超时检查活跃连接数与慢SQL频繁触发Pod重启liveness和readiness混用拆分探针组,liveness只含ping网关流量异常抖动偶发状态码导致立即摘除增加连续失败阈值,调整group探针接口慢、资源占用高无缓存,外部调用超时过长设置time-to-live,调小外部超时磁盘未满但状态DOWN默认阈值10MB只会看一个路径配置多个路径并调高阈值结尾从我的实践经验来看,健康检查这个功能,真正难的不是引入Actuator、写几行配置,而是把“健康定义”想清楚,不同场景用不同探针,不同依赖配置不同超时。踩过几次坑之后,我现在每个新项目的健康检查配置都会先过一遍“三问”:有没有把端口监控当成服务存活判断?有没有把瞬时抖动误判成永久故障?有没有把readiness和liveness的职责混在一起?这三问过完,线上事故的发现时间基本就能从“用户投诉”提前到“系统自动摘除”。最后再分享一个小技巧:每次上线前,我会特意人为停掉一两个下游依赖,看看健康检查的返回体是否还是干净的UP。如果看到的是乱七八糟的状态码,说明配置还是有漏洞,趁早修好再上线。这套方法,建议每个团队都在测试环境里跑一遍。