跳到正文

优雅上下线与启动检查 ​

一句话:优雅上下线把「进程存活」和「可以接流量」拆成两个状态,用启动检查、就绪探针和停机排干换发布期间的低错误率;代价是发布变慢,并且排干窗口内仍可能有少量失败。

适用:Spring Boot 2.3+(server.shutdown=graceful)、Kubernetes 1.20+(startupProbe 稳定)、Nacos 2.x。文中端点和类名为通用示例命名。

为什么需要 ​

滚动发布时,新实例刚启动、依赖还没连上就被注册,调用方拿到地址直接 500。反过来,停机时进程先退出,注册中心还没把地址摘掉,调用方继续把请求打过来,连接被重置。

这两种错误都不是代码 bug,而是时序问题:注册、探针、负载均衡缓存、进程退出各自按自己的节奏走。优雅上下线就是把这些节奏对齐,并给每个阶段留出可验证的等待窗口。

核心机制 ​

启动:什么时候可以被接流量 ​

启动检查按依赖方向排序,前一步不成立,后一步的检查没有意义:

  1. 配置加载:必需的 key 是否存在,配置中心是否已拉取。
  2. 数据库:连接池能取到连接,SELECT 1 能返回。
  3. Redis/缓存:PING 通,序列化配置正确。
  4. MQ/RPC:生产者/消费者连接建立,RPC 端口可达。
  5. 缓存预热:关键字典、路由表已加载。
  6. 注册中心:以上都通过后才登记。

失败策略要分开:配置和数据库缺失时服务无法正确工作,fail-fast 退出;Redis 只做加速、MQ 可以稍后重连的,降级并打点;预热只影响性能的,异步做,不阻塞就绪。

检查本身不能拖垮启动:每个检查单独超时(经验值 3–5s),总预算小于 startupProbe 的 failureThreshold × periodSeconds;检查放在独立线程池执行,探针处理器只读状态,不在请求线程里 sleep 等标志位。

图里两条虚线是策略分叉:依赖失败不一定都要退出,只有「缺了就无法正确服务」的才 fail-fast。注册放在最后,因为登记即代表可以被调用,前面任何一步没完成,提前注册都会把错误暴露给调用方。另外,Spring Cloud 默认在 WebServerInitializedEvent 就自动注册,早于 ApplicationReadyEvent;要严格做到「检查通过后再注册」,需关闭自动注册并手动登记,否则至少让 readiness 在检查通过前保持失败。

停止:什么时候可以不再接流量 ​

停机顺序和启动相反,目标是先让调用方「忘记」本实例,再把手上活干完:

  1. 从注册中心摘除实例,或把健康状态置为不接收流量。
  2. 等待调用方本地缓存过期:注册中心推送延迟 + 负载均衡缓存 TTL。Spring Cloud LoadBalancer 实例缓存默认 35s,Ribbon 时代列表刷新默认 30s,取较大值再加缓冲。
  3. 停止接收新请求,关闭监听端口或让 readiness 失败。
  4. 等待在途请求完成,设上限,超时后记录并强制结束。
  5. 关闭 MQ 消费者、线程池、连接池。

图的关键在第三步:摘除不是瞬时生效的。注册中心推送有延迟,调用方还有本地缓存,两个窗口叠加才是「不再有新请求」的真实时间。只等 5s 就杀进程,缓存里的地址仍会被打到。注册中心侧的行为见 02-服务注册与发现。

探针分工 ​

  • liveness:只回答「进程是否已经不可恢复」。不要放数据库、Redis 检查;下游抖动会让一批实例同时被判死并重启,故障被放大。
  • readiness:回答「现在能不能接流量」。放启动完成、关机中、预热完成这类本地状态;是否放关键依赖检查,取决于「依赖挂了是否所有实例都该下线」。
  • startupProbe:慢启动保护。成功前不执行 liveness/readiness,避免用巨大的 initialDelaySeconds 掩盖真实启动时间。
  • readinessGates:在 Pod 就绪判断里加入自定义 condition,由外部控制器写入,适合「注册中心已登记/已摘除」这类应用外状态。

异步与消息 ​

停机时先停消费者,再关线程池:顺序反了,正在执行的任务会拿到已关闭的线程池。正在处理的消息要显式 ack;没处理完的 nack/requeue,前提是业务幂等。定时任务必须防重:用分布式锁、调度中心分片或 leader 选举,否则滚动发布时新旧两个 Pod 会同时跑。

实战场景 ​

一、数据库抖动 30 秒,40 个实例被集体重启 ​

现象:主库做主从切换,约 30 秒不可写。期间 order-service 的 40 个 Pod 全部被 kubelet 重启,切换结束后又出现 2 分钟的连接风暴与超时;数据库本身早已恢复。

排查:先确认“谁杀了进程”。

定位层取证据判定
重启原因kubectl describe pod 里 Liveness probe failed: ... 503是探针杀的,不是 OOM 或代码异常
探针端点内容/actuator/health/liveness 是否包含 db、redis 指示器包含依赖检查即为配置错误
时间对齐DB 不可用窗口与探针失败时间点完全重合 → 依赖故障被当成进程故障
失败阈值failureThreshold × periodSeconds 是否小于 DB 抖动时长30 秒抖动被 30 秒阈值判定为死
重启放大重启期间实例的连接建立曲线40 个实例同时重连 → 二次压垮 DB
yaml
# 命中的错误写法示例
livenessProbe:
  httpGet: { path: /actuator/health, port: 8080 }   # 聚合了 db/redis 的全部健康项

根因是把“能不能接流量”和“进程是否该重启”混成一个端点:liveness 失败必须重启,而 DB 挂掉时重启应用毫无意义,只会让所有实例同时失去容量。

对症做法:

yaml
management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        liveness:  { include: ping }                     # 只回答“进程还活着”
        readiness: { include: ping,db,redis,nacos }      # 依赖不可用只摘流量
yaml
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 8080 }
  periodSeconds: 10
  failureThreshold: 6            # 给依赖/GC/瞬时抖动留窗口(经验值,需校准)
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 8080 }
  periodSeconds: 5
  failureThreshold: 2

readiness 里放依赖检查也要判断“依赖挂了是否所有实例都该下线”:共享依赖故障时全量摘流量会让服务彻底不可用,此时更合理的是保留入口 + 降级返回。

复盘要点:判据是“重启原因分布”:任何由 liveness 触发的重启都应有独立告警与人工复盘,正常情况下这个计数应长期为 0。把“liveness 只包含 ping”写进部署模板校验,比在文档里提醒有效。

二、新实例注册得太早:前 20 秒全是 500 ​

现象:营销服务启动需要 25 秒(含字典预热)。发布时新 Pod 一起来就分到流量,前 15~20 秒 502/500 集中出现;调用方日志里的失败 IP 全是刚启动的新实例。

排查:注册时机与就绪时机谁先发生,一比对就清楚。

定位层取证据判定
注册时刻Nacos 实例列表变化时间 / 应用日志 registered to nacos早于 readiness 通过即命中
就绪时刻kubectl get events 里 Ready 变化时间、/actuator/health/readiness两者间隔就是错误窗口
自动注册时机Spring Cloud 默认在 WebServerInitializedEvent 注册,早于 ApplicationReadyEvent端口一开就登记,预热还没跑
预热是否阻塞字典加载、连接池握手、JIT 触发的完成时间未纳入就绪判断即为漏洞
探针路径startupProbe 是否在预热前就放过 readiness放过即“看起来就绪”

对症做法:

yaml
# 方案 1:延迟注册,等检查与预热全部通过后再登记
spring:
  cloud:
    nacos:
      discovery:
        register-enabled: false      # 关闭自动注册
java
// 由启动检查在最后一步显式注册
@EventListener(ApplicationReadyEvent.class)
public void registerWhenReady() throws Exception {
    startupChecker.assertAllPassed();                       // 配置/DB/Redis/MQ/预热
    namingService.registerInstance(serviceId, ip, port);    // 通过后才登记
}

// readiness 表达“预热完成”,避免依赖探针时序巧合
class WarmupHealthIndicator implements HealthIndicator {
    public Health health() {
        return warmupDone.get() ? Health.up().build()
                                : Health.down().withDetail("phase", "warming").build();
    }
}
yaml
# 方案 2:保留自动注册,但用 startupProbe + readinessGates 把接流量卡在读上
readinessGates:
  - conditionType: "example.com/registered"      # 由外部控制器写入“已登记且已热”

启动检查的总预算要小于 startupProbe.failureThreshold × periodSeconds,每项检查单独超时 3~5 秒;检查放在独立线程池,探针处理器只读状态。配置与数据库缺失 fail-fast,Redis/MQ 这类可降级的打点放行。

复盘要点:把“注册时刻 → 首个请求 → 满流量时刻”三个时间点做成发布指标,异常时一眼看出是哪一段太早。判据是“新实例在 ready 前的请求数必须为 0”,用调用方日志与注册时间戳比对即可自动校验。

三、停机顺序写反:消息重复入库与线程池拒绝 ​

现象:一次常规发布产生 300 多条重复入库记录,应用日志里同时出现 RejectedExecutionException: Task ... rejected from ThreadPoolExecutor;MQ 侧该队列的重投次数突增。

排查:顺序问题只能用时间戳序列判断。

定位层取证据判定
关闭顺序停机日志里 shutdown consumer 与 shutdown executor 的时间戳线程池先关 → 在途任务拿到已关闭的池
任务失败形态RejectedExecutionException 出现在哪些消费者线程命中正在执行的消息处理逻辑
消息是否 ack失败消息是显式 ack、nack 还是超时重投未 ack 必然重投,重复由此而来
在途排干活跃消费数 gauge 在 SIGTERM 后是否归零未归零就被杀 → 半条消息被重复处理
幂等是否存在入库唯一键与去重逻辑无幂等键则重投直接变成重复数据
定时任务新旧 Pod 是否同时执行同一任务无分布式锁则双跑

对症做法:

java
// 1. 顺序固定:先停消费者 → 等在途任务排空 → 再关线程池 → 最后关连接池
@PreDestroy
void shutdownInOrder() throws Exception {
    consumer.pause();                                   // 停止拉取新消息
    consumer.shutdown(20, SECONDS);                     // 等在途消息处理完
    executor.shutdown();
    if (!executor.awaitTermination(15, SECONDS)) {
        metrics.interrupted(executor.shutdownNow().size());   // 强制中断要计数
    }
    dataSource.close();
}
java
// 2. 每条消息显式定结果:处理成功才 ack,失败 nack 或进重试/死信队列
try { handler.handle(msg); session.ack(msg); }
catch (RetryableException e) { session.nack(msg, DELAY); }        // 依赖可恢复,延迟重投
catch (Exception e) { deadLetter.send(msg, e); session.ack(msg); } // 不可恢复,别堵队列

// 3. 消费幂等:业务唯一键 + 去重表,重投不产生新数据
insertIgnore(msg.bizKey, ...);   // 唯一索引冲突即视为已处理

// 4. 定时任务防重:调度中心分片或分布式锁,避免新旧 Pod 双跑
yaml
# 5. 容器侧留出足够排干窗口,且不要贴着上限
server.shutdown: graceful
spring.lifecycle.timeout-per-shutdown-phase: 30s
terminationGracePeriodSeconds: 90      # 大于 preStop 40s + 排干 30s

RejectedExecutionException 的直接修法是拒绝策略:对必须完成的任务用 CallerRunsPolicy 或提交前判断 isShutdown(),但顺序修正才是根因解决。

复盘要点:判据是“发布窗口的重复入库数必须为 0”,用业务唯一键在日级对账里核对;同时把“停机时被强制中断的任务数”做成常驻指标,非零即说明排干窗口不够。SIGKILL 会重置连接、回滚未提交事务并触发重投,所以幂等不是可选项——见 流量切换平台 里的拉出编排同样依赖这条纪律。

最小上手 ​

K8s 侧的关键片段:

yaml
# Pod 模板与 Deployment 策略的关键字段,省略层级
spec:
  terminationGracePeriodSeconds: 90      # 大于 preStop 40s + 应用排干 30s
  readinessGates:                        # 可选:由外部控制器写入
    - conditionType: "example.com/registered"
  containers:
    - name: order-service
      lifecycle:
        preStop:                         # 示例端点:先摘除,再睡过缓存 TTL
          exec:
            command: ["sh", "-c", "curl -s localhost:8080/actuator/offline && sleep 40"]
      startupProbe:                      # 成功前不执行 liveness/readiness
        httpGet: { path: /actuator/health/liveness, port: 8080 }
        periodSeconds: 5
        failureThreshold: 24             # 最多等 120s
      livenessProbe:
        httpGet: { path: /actuator/health/liveness, port: 8080 }
        periodSeconds: 10
        failureThreshold: 3
      readinessProbe:
        httpGet: { path: /actuator/health/readiness, port: 8080 }
        periodSeconds: 5
        failureThreshold: 2
  strategy:
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1

应用侧打开 Spring Boot 的优雅停机:

yaml
server.shutdown: graceful                # 默认 immediate
spring.lifecycle.timeout-per-shutdown-phase: 30s   # 默认 30s

验证:发布时用 kubectl get endpoints <svc> -w 观察实例先摘除再从列表消失;同时压测接口,把 5xx 比例与自定义在途请求 gauge(或 tomcat.threads.busy)对上,确认排干窗口足够。

生产动作 ​

  • 在途请求计数:暴露活跃请求数、优雅停机耗时、被强制中断的请求数;没有计数就只能靠猜。
  • 排干时间:terminationGracePeriodSeconds=90 是示例起点不是结论,要大于 preStop 睡眠 + 最长请求耗时(本例 40 + 30),且小于发布平台允许的总等待。
  • 强制杀进程的代价:SIGKILL 会重置在途 HTTP 连接、回滚未提交事务、让 MQ 消息重投;重投依赖幂等,没幂等就是重复数据。
  • 滚动参数:慢启动服务用 maxUnavailable: 0 + maxSurge: 1,先起新再停旧;启动快的服务可以放宽,换取发布速度。
  • 发布期观察:对比发布前后 5 分钟的错误率、P99、连接重置数;错误率只在发布窗口上升,优先查排干时间而不是代码。
  • 验证摘除延迟:在调用方日志里找旧实例 IP,确认最后一次请求与摘除时间的间隔,按实测值调整等待。

排障路径:发布后持续 5xx → 看错误是否集中在某个旧 IP → 查该实例摘除时间与 preStop 日志 → 排干不足就加等待;新实例 5xx → 看 readiness 是否在依赖就绪前就通过 → 把依赖检查移入启动检查。

边界 ​

  • 不能保证零错误:摘除与缓存过期之间总有窗口,长尾请求可能超过排干上限。
  • 长连接要单独处理:WebSocket、SSE、gRPC streaming 不会因为停止接新连接而结束,需要主动发关闭帧或等客户端重连。
  • K8s 强杀会截断 preStop:超过 terminationGracePeriodSeconds 后 preStop 和进程都会被终止,等待时间不能贴着上限设。
  • liveness 塞依赖检查是常见误区:下游故障会触发整批重启,重启又加重下游负担。
  • 启动检查不是越全越好:每加一个检查就多一个启动失败源,核心链路之外的检查应降级或异步。

讲清楚 ​

优雅上下线对齐发布期间的时序:新实例未就绪就注册,旧实例已退出但调用方缓存仍有地址。启动侧按配置、数据库、缓存、MQ/RPC、预热、注册的顺序检查,缺了就无法服务的 fail-fast,能降级的降级,总预算不超过 startupProbe。停机侧先摘除,等注册推送和缓存过期,再停新请求、排干在途,最后关消费者和线程池。探针分工:liveness 只看进程是否该重启,readiness 看能否接流量,startupProbe 保护慢启动。边界是只能降低错误率,不能归零;长连接和 K8s 强杀要单独设计。

把一门技术讲透到既懂也会用