《Java 服务端通信工程》6.3 连接生命周期与 drain

发布时最怕新请求还在进、老请求被一刀切断。本节拆开 ManagedChannel 与 Server 的 shutdown 语义,用 DrainCoordinator 统计在途请求,给出 linkd-v3 从停止入口、健康降级到关闭 channel 的完整顺序和时间预算。

本节目标:能写出一个在 SIGTERM 后「先停止新调用、再等在途完成、最后强制关闭」的停机流程,并知道每一步的语义、顺序和时间预算。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)+ grpc-services 1.76.0。

承接的问题

6.2 的池解决了「用几条 channel」,但没解决「进程退出时这些 channel 怎么办」。发布、缩容、滚动重启都会给进程发 SIGTERM;如果直接 System.exit(),在途请求会被切断,客户端看到 UNAVAILABLE,正在写库的操作可能只完成一半。如果只调 shutdown() 不等待,进程可能在请求还没结束时退出,效果一样。

gRPC 提供了两层关闭语义:客户端的 ManagedChannel 和服务端的 Server。两者都有 shutdown()、shutdownNow()、awaitTermination(),但作用对象不同:前者关的是「我到下游的连接」,后者关的是「上游到我的连接」。一个完整的停机流程要同时处理入口(9094 HTTP、9093 gRPC)和出口(到 libraryd 的 channel),顺序错了就会丢请求。

这一节先讲两类关闭的语义,再讲进程级的 drain 顺序、在途计数和时间预算,最后给出 linkd-v3 的停机代码。健康检查在 5.3,连接池在 6.2,本节把它们串成一个可执行的流程。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,gRPC-Java 1.76.0。本节的时间数字是预算示例,不是实测的停机耗时。

核心机制一:ManagedChannel 的关闭语义

ManagedChannel 的三个方法分工明确:

方法新调用在途调用是否阻塞
shutdown()拒绝,UNAVAILABLE允许完成否
shutdownNow()拒绝取消,CANCELLED否
awaitTermination(timeout)不改变等待结束是,最多 timeout

正确的关闭顺序是 shutdown() → awaitTermination() → 必要时 shutdownNow()。shutdown() 只是「不再接受新调用」,它不会等;awaitTermination() 只等,不会触发关闭;shutdownNow() 是最后手段,会取消还在跑的流。

package com.plume.linkd.v3.core.lifecycle;

import io.grpc.ManagedChannel;
import java.time.Duration;
import java.util.concurrent.TimeUnit;

public final class ChannelDrain {

    public static boolean drain(ManagedChannel channel, Duration timeout) {
        channel.shutdown();
        boolean terminated;
        try {
            terminated = channel.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            channel.shutdownNow();
            return false;
        }
        if (!terminated) {
            channel.shutdownNow();
            try {
                channel.awaitTermination(1, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        return terminated;
    }
}

三个细节:

  • shutdown() 是幂等的。 重复调用不会抛异常;但不要在每次请求结束时调,它会让 channel 进入终态,后续调用全部失败。
  • awaitTermination 可以被中断。 捕获 InterruptedException 后要恢复中断标记;如果停机线程被中断,说明进程可能正在被强杀,直接 shutdownNow() 是合理的。
  • shutdownNow() 之后还要再等一次。 取消是异步的,流收到 CANCELLED 需要一点时间;再等 1 秒能让资源释放干净,避免进程退出时日志里出现「channel 还在关闭」。

isShutdown() 表示「已经调用过 shutdown/shutdownNow」,isTerminated() 表示「所有资源已释放」。awaitTermination 返回 true 等价于 isTerminated()。不要用 isShutdown() 判断能否继续调用;它只说明关闭动作已经开始。

核心机制二:Server 的 drain 与 GOAWAY

服务端的 Server 接口有同样的三个方法,但语义是「停止接受新调用」:

  • shutdown():停止接受新调用,向客户端发 GOAWAY,已在途的调用继续完成。
  • awaitTermination():等待所有调用和连接结束。
  • shutdownNow():强制关闭,取消在途调用。

GOAWAY 是 HTTP/2 的帧,告诉客户端「这条连接不再接受新流,但已经开始的流可以继续」。gRPC 的客户端收到 GOAWAY 后,会把新调用转到其他连接或重新解析;在途流不受影响。服务端不需要手工发 GOAWAY,Server.shutdown() 会处理。

服务端停机前要先把健康状态改成 NOT_SERVING,让负载均衡器停止把新流量送过来。顺序是「健康降级 → shutdown() → 等待 → 强制关闭」:

package com.plume.linkd.v3.core.lifecycle;

import io.grpc.Server;
import io.grpc.health.v1.HealthCheckResponse;
import io.grpc.protobuf.services.HealthStatusManager;
import java.time.Duration;
import java.util.concurrent.TimeUnit;

public final class GrpcServerDrain {

    public static final String LIBRARY_SERVICE = "plume.linkd.v3.Library";

    public static void drain(Server server, HealthStatusManager health, Duration timeout) {
        health.setStatus("", HealthCheckResponse.ServingStatus.NOT_SERVING);
        health.setStatus(LIBRARY_SERVICE, HealthCheckResponse.ServingStatus.NOT_SERVING);
        health.enterTerminalState();

        server.shutdown();
        try {
            if (!server.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
                server.shutdownNow();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            server.shutdownNow();
        }
    }
}

enterTerminalState() 把所有服务永久标记为 NOT_SERVING,之后不能再改回;它适合停机,不适合临时摘流。临时摘流只调用 setStatus(NOT_SERVING),恢复时再设回 SERVING。

shutdown() 之后,9093 的监听 socket 关闭,新的 TCP 连接被拒绝;已有的 HTTP/2 连接收到 GOAWAY,新流被拒绝,老流继续。客户端如果还有未发送的调用,会收到 UNAVAILABLE,应该由上游重试或降级。

核心机制三:进程级 drain 的顺序

一个 linkd-v3 进程同时有四个入口/出口:

组件端口停机动作
HTTP 网关9094停止接受新请求,返回 503
管理/指标9095健康检查返回 NOT_SERVING
gRPC 服务9093Server.shutdown(),等在途流
下游 channel/池到 9093shutdown(),等在途调用

正确顺序:

  1. 停止入口。 9094 不再接受新请求;9095 的健康检查改为 NOT_SERVING,让 LB 把实例摘掉。
  2. 等待在途请求。 用计数器等所有已进入的请求结束,最多等到 drain 超时。
  3. 关闭 gRPC 服务。 Server.shutdown(),等在途流;超时 shutdownNow()。
  4. 关闭下游 channel/池。 ChannelDrain.drain() 或 LibraryChannelPool.close()。
  5. 退出进程。 释放线程池、关闭日志。

第 1 步和第 2 步之间有一个传播窗口:LB 收到 NOT_SERVING 后,可能还要几秒才停止转发。Kubernetes 的 preStop 睡眠或 readiness 探针的失败阈值就是用来覆盖这个窗口的。不要在第 1 步之后立刻 shutdown() 9093,否则 LB 还没摘掉,新请求已经被拒绝。

在途计数要覆盖「从请求进入到响应写完」的整个过程,不能只包住 gRPC 调用。下面是一个基于监视器锁的计数器,enter() 和 leave() 成对,beginDrain() 之后拒绝新请求:

package com.plume.linkd.v3.core.lifecycle;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

public final class DrainCoordinator {

    private final Object lock = new Object();
    private boolean draining;
    private int inFlight;

    public boolean enter() {
        synchronized (lock) {
            if (draining) {
                return false;
            }
            inFlight++;
            return true;
        }
    }

    public void leave() {
        synchronized (lock) {
            if (inFlight > 0) {
                inFlight--;
            }
            if (inFlight == 0) {
                lock.notifyAll();
            }
        }
    }

    public void beginDrain() {
        synchronized (lock) {
            draining = true;
            lock.notifyAll();
        }
    }

    public int inFlight() {
        synchronized (lock) {
            return inFlight;
        }
    }

    public boolean awaitQuiescence(Duration timeout) throws InterruptedException {
        long deadline = System.nanoTime() + timeout.toNanos();
        synchronized (lock) {
            while (inFlight > 0) {
                long remaining = deadline - System.nanoTime();
                if (remaining <= 0L) {
                    return false;
                }
                long millis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remaining));
                lock.wait(millis);
            }
            return true;
        }
    }
}

beginDrain() 和 enter() 在同一把锁下,避免「检查时没在 drain、进入后已经开始 drain」的竞态:enter() 返回 true 的请求一定会计入 inFlight,awaitQuiescence 会等它。leave() 在 inFlight 已经是 0 时不做减法,防止计数器被误用成负数。

awaitQuiescence 返回 false 表示超时,还有请求没结束。这时不要无限等;记录剩余请求数和它们的请求 ID,进入第 3 步强制关闭,并在日志里标记这些请求可能未完成。

核心机制四:时间预算与竞态

停机时间不是越长越好。Kubernetes 的 terminationGracePeriodSeconds 是硬上限,超过就 SIGKILL;所有步骤的预算必须小于它,并留出余量。一个可用的预算:

阶段预算说明
停止入口 + 健康降级立即设置标志、返回 503
LB 传播等待2 spreStop 或 readiness 失败窗口
等待在途请求3 s覆盖 800 ms 总 deadline 加余量
9093 shutdown + 等待5 s覆盖 streaming 流的收尾
下游 channel 关闭2 sshutdown + 等待
强制兜底1 sshutdownNow 后再等
合计约 13 sterminationGracePeriodSeconds: 20

预算要和客户端 deadline 对齐。客户端总 deadline 是 800 ms,单跳 300 ms;服务端 drain 等 3 秒,足够覆盖大多数请求。如果某个 streaming 流(WatchStock)持续几分钟,drain 不可能等它自然结束,超时后必须 shutdownNow(),客户端会收到 CANCELLED 或 UNAVAILABLE,由客户端重新订阅。

几个竞态:

  • 请求在 beginDrain() 之后、shutdown() 之前到达 9093。 入口 9094 已经拒绝,但直连 9093 的客户端可能还能进来;enter() 返回 false,网关返回 503,不会计入在途。
  • 请求已经进入、但还没登记到计数器。 计数要放在请求处理的最外层,在解析参数之前 enter();不要放在 gRPC 调用之前,否则参数解析和鉴权的时间不在计数内。
  • 异步任务在响应写完之后还在跑。 如果请求派生了一个后台任务,计数器要覆盖到后台任务结束,或者在 drain 时取消它;否则进程退出会切断后台任务。
  • 健康检查自己也被 drain。 9095 的健康端点在 drain 期间要返回 NOT_SERVING,但不能返回 500;LB 把 500 当成故障,可能触发不必要的告警。

在 linkd-v3 里落地:SIGTERM 停机流程

linkd-v3 的启动类持有 Server、HealthStatusManager、DrainCoordinator 和下游 channel。SIGTERM 的 hook 按下面的顺序执行:

package com.plume.linkd.v3.server;

import com.plume.linkd.v3.core.lifecycle.ChannelDrain;
import com.plume.linkd.v3.core.lifecycle.DrainCoordinator;
import com.plume.linkd.v3.core.lifecycle.GrpcServerDrain;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.protobuf.services.HealthStatusManager;
import java.time.Duration;

public final class LinkdShutdown implements Runnable {

    private final DrainCoordinator coordinator;
    private final Server grpcServer;
    private final HealthStatusManager health;
    private final ManagedChannel libraryChannel;
    private final HttpGateway gateway;

    public LinkdShutdown(DrainCoordinator coordinator, Server grpcServer,
                         HealthStatusManager health, ManagedChannel libraryChannel,
                         HttpGateway gateway) {
        this.coordinator = coordinator;
        this.grpcServer = grpcServer;
        this.health = health;
        this.libraryChannel = libraryChannel;
        this.gateway = gateway;
    }

    @Override
    public void run() {
        gateway.stopAccepting();
        coordinator.beginDrain();
        try {
            if (!coordinator.awaitQuiescence(Duration.ofSeconds(3))) {
                System.err.println("drain timeout, in-flight=" + coordinator.inFlight());
            }
            GrpcServerDrain.drain(grpcServer, health, Duration.ofSeconds(5));
            ChannelDrain.drain(libraryChannel, Duration.ofSeconds(2));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            grpcServer.shutdownNow();
            libraryChannel.shutdownNow();
        }
    }
}

注册 hook:

DrainCoordinator coordinator = new DrainCoordinator();
Runtime.getRuntime().addShutdownHook(
        new Thread(new LinkdShutdown(coordinator, server, health, channel, gateway),
                "linkd-shutdown"));

gateway.stopAccepting() 让 9094 对新请求返回 503 并带 Retry-After;coordinator.beginDrain() 让已经进入的请求继续计数,新请求被拒绝。GrpcServerDrain 先把健康状态改为 NOT_SERVING,再 shutdown() 9093;最后关下游 channel。

如果使用 6.2 的池,把 ChannelDrain.drain(libraryChannel, ...) 换成 pool.close();池内部会对每条 channel 做同样的 shutdown、等待、shutdownNow。不要在 hook 里直接 System.exit(),那会跳过其余 hook,也会让日志缓冲区来不及刷。

常见错误与边界

错误一:先 shutdownNow() 再 shutdown()。 顺序反了,在途请求会先被取消,shutdown() 已经没有意义。正常停机先 shutdown(),shutdownNow() 只做超时兜底。

错误二:不等待就退出。 调了 shutdown() 但没 awaitTermination,JVM 退出时线程被强杀,效果和 shutdownNow() 一样,只是没有明确的 CANCELLED。

错误三:在 EventLoop 或 gRPC 回调线程里 awaitTermination。 会阻塞该线程负责的其他调用,甚至造成死锁。停机逻辑放在独立的 shutdown hook 线程里。

错误四:drain 预算大于 terminationGracePeriodSeconds。 Kubernetes 会在预算用完之前 SIGKILL,日志里看不到「drain timeout」,只会看到进程被杀。所有预算之和要小于 grace period,并留 20% 余量。

错误五:健康状态还显示 SERVING 就关闭 9093。 LB 还没摘流,新请求会打到正在关闭的实例,客户端看到 UNAVAILABLE。先改健康状态,等传播窗口,再 shutdown()。

错误六:只关 9093,不关下游 channel。 进程退出时下游 channel 的 EventLoop 线程还在,JVM 可能延迟退出;连接也没有发送正常的 GOAWAY。下游 channel 也要 drain。

边界:drain 不能保证所有请求成功。 超过预算的请求会被强制取消;streaming 流可能永远不结束,必须靠 shutdownNow()。drain 的目标是「减少突然切断」,不是「保证零失败」。

边界:客户端也可能 drain。 网关自己也是上游的客户端;它的 9094 入口要停止接受新请求,同时它的下游 channel 要等在途调用。两个方向都要处理,不能只关一边。

小结

  • ManagedChannel.shutdown() 停止新调用、保留在途;shutdownNow() 取消在途;awaitTermination() 只等待。
  • Server.shutdown() 发 GOAWAY、停止新流、保留在途;健康状态要先改为 NOT_SERVING,再关闭。
  • 进程级顺序是「停入口 → 健康降级 → 等在途 → 关 9093 → 关下游 channel → 退出」。
  • DrainCoordinator 用同一把锁保护 draining 和 inFlight,避免新请求在检查后被漏计。
  • 时间预算要和客户端 deadline、Kubernetes grace period 对齐;超时后 shutdownNow() 是必要的兜底。
  • drain 的目标是减少突然切断,不是保证零失败;streaming 流和超预算请求会被取消。
  • 本节的时间数字是预算示例,不是本机实测的停机耗时。

下一节进入第 7 章:重试预算、退避和抖动。linkd-v3 的 800 ms 总预算里,重试最多 2 次、基础退避 20 ms,怎么分配才不会把 deadline 吃光,是 7.1 的内容。

阅读导航:上一节 6.2 客户端连接池设计 | 下一节 7.1 重试预算与退避

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收