服务发现优雅关闭:别让下线变断连

上线新版本时,大家盯着健康检查、流量切分,可一到下线老实例,就容易出问题——注册中心里还挂着,请求却进不去,或者刚删掉服务节点,客户端缓存还没刷新,瞬间一堆超时。

为什么‘直接杀进程’不靠谱

比如用 Consul 或 Nacos 做服务发现,一个 Spring Boot 服务启动后自动注册,但默认 shutdown 时不会主动注销。你执行 kill -15,应用可能几秒内就退出了,而注册中心的 TTL 还剩 30 秒,这期间调用方还在往它发请求,结果就是 500 或连接拒绝。

真正优雅的三步走

不是等心跳过期,而是主动通知、暂停收新请求、清理注册信息。

1. 先摘除服务(Deregister)

Spring Cloud Alibaba 用户可以在 application.yml 中开启自动注销:

spring:
  cloud:
    nacos:
      discovery:
        register-enabled: true
        # 下线时主动注销
        deregister-on-shutdown: true

Consul 用户则常用 health check 的 pass/ttl 配合 pre-stop 脚本,或在应用关闭前调用 API:

curl -X PUT http://localhost:8500/v1/agent/service/deregister/my-service-id

2. 暂停新请求,处理完存量

以 Tomcat 为例,可在 shutdown hook 中先关闭端口接收,再等活跃连接结束:

@Component
public class GracefulShutdown implements ApplicationRunner {
    @Autowired
    private WebServer webServer;

    @Override
    public void run(ApplicationArguments args) {
        if (webServer instanceof TomcatWebServer tomcat) {
            tomcat.getTomcat().getConnector().setProperty("connectionTimeout", "1000");
        }
    }
}

更稳妥的做法是加一层 /actuator/health 自定义状态,下线前把 readiness 接口返回 {"status":"OUT_OF_SERVICE"},网关或 sidecar 就会自动剔除它。

3. 等待业务线程自然结束

避免强制 interrupt,改用线程池的 shutdown() + awaitTermination()。比如消息消费线程:

private final ExecutorService consumerPool = Executors.newFixedThreadPool(4);

@PreDestroy
public void shutdown() {
    consumerPool.shutdown();
    try {
        if (!consumerPool.awaitTermination(30, TimeUnit.SECONDS)) {
            consumerPool.shutdownNow();
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

真实场景小提醒

某次灰度发布,运维按习惯先重启机器,结果注册中心里两个同名服务并存,新实例还没 ready,旧实例已注销,中间 2 秒所有调用全失败。后来改成:先调 /actuator/health/readiness 切成 down → 等 3 秒 → 再发注销请求 → 最后 kill。故障率降为 0。

服务发现不是注册完就完事,下线动作同样要“有始有终”。每次重启前多写一行 deregister,比半夜被报警叫醒划算得多。