2487 字
约 8 分钟
19
S17-X06 · Dubbo 优雅停机:下线时那几秒发生了什么

发布时最容易被忽略的一段代码是停机逻辑。滚动更新把 8 个 Pod 一个个换掉,如果每个 Pod 都在收到 SIGTERM 的瞬间立刻断开连接,调用方就会看到一批 channel closed,而这些失败恰好落在没有重试的接口上。优雅停机要解决的就是这段时间。

1. 为什么必须等:注册中心的"撤销"是异步的

下线不是原子事件。Provider 停止、注册中心撤销、调用方刷新列表、网关摘流量,四件事各有自己的时钟:

时刻 Provider 注册中心 调用方 结果
t0 收到 SIGTERM 仍有该实例 本地缓存里有它 还在往这台机器打
t0+1s 进程退出 会话超时未到 缓存未刷新 请求打到死实例
t0+5s 已退出 实例被摘除 收到通知,刷新 恢复
t0+30s — 列表稳定 全部走新实例 正常

也就是说,Provider 从"活着"到"注册中心摘掉它"之间有一个窗口,窗口里所有打到它的请求都会失败。调用方的本地缓存(Directory)还会再多保留一段时间,Dubbo 默认对不可用地址做 60 秒的 blacklist,期间不会重试到同一地址。

优雅停机做的事,就是在窗口里保持"还能服务",而不是"立刻死"。

2. shutdown hook 的五步顺序

// DubboShutdownHook 注册到 JVM
Runtime.getRuntime().addShutdownHook(DubboShutdownHook.getDubboShutdownHook());

真正执行时按语义分五步:

步骤 动作 对应日志/现象
1 停止接收新请求(标记 destroying,拒绝新调用) Run shutdown hook now.
2 等待在途请求完成,最长 shutdown.wait 线程栈里还能看到业务方法
3 从注册中心注销(unregister) 注册中心列表减少一个实例
4 关闭协议与 Server,断开 channel Close ... channel
5 关闭线程池、销毁 FrameworkModel Destroy ... executor

顺序不能颠倒。先注销再等,等于让注册中心提前通知调用方"这台没了",但同时这台还在处理旧请求,两边都会不干净;先等再注销,则新请求会一直进来,永远等不完。所以是"先拒绝新增、再等存量、最后注销"。

第 2 步的等待是有条件的:只等"已进入业务逻辑"的请求,不等那些还在网络缓冲区里没被读出来的。这也是为什么单纯调大等待时间不能解决问题——得配合上游先摘流量。

3. 等待时长与调大之后的副作用

# Dubbo 2.7 起的写法,单位毫秒,默认 10000
dubbo.service.shutdown.wait=10000

# 3.x 里部分版本也接受同名语义的短名,落地前按自己的版本核对一次
# dubbo.shutdown.timeout=10000
配置 默认 作用 调大的副作用
dubbo.service.shutdown.wait 10000ms 最长等在途请求时间 滚动发布变慢,Pod 终止被 SIGKILL 截断
terminationGracePeriodSeconds 30s(K8s) SIGTERM 到 SIGKILL 的总预算 要大于"等待 + 关闭耗时"
dubbo.registry.timeout 5000ms 注销请求超时 注销慢会拖长整个停机

最典型的事故是:把 shutdown.wait 调到 60 秒,K8s 的 terminationGracePeriodSeconds 还是 30 秒。结果进程刚等到第 30 秒就被 SIGKILL,前面的等待全部白费,日志停在"等待在途请求"中间。

另一个副作用是注册中心注销被推迟。如果等待 60 秒后才注销,调用方在这 60 秒里持续往这台机器发新请求(因为它还在列表里),等于把窗口拉长了。正确做法是让上游先摘流量(K8s 的 readiness),Provider 侧只负责把存量处理完。

4. K8s 场景的双重保障

单靠 Dubbo 的 hook 不够,因为 SIGTERM 与 Endpoint 摘除是两条并行的路径,摘除还依赖 kube-proxy 的规则刷新。标准做法是先让探针失败、再留出传播时间:

spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: order-service
      lifecycle:
        preStop:
          exec:
            command: ["sh","-c","sleep 10"]
      readinessProbe:
        httpGet:
          path: /actuator/health/readiness
          port: 8080
        periodSeconds: 5
        failureThreshold: 1
      env:
        - name: JAVA_TOOL_OPTIONS
          value: "-Ddubbo.service.shutdown.wait=15000"
机制 解决什么 生效时机
readinessProbe 失败 让 Endpoint 把它从 Service 摘掉 Pod 进入 Terminating 时自动失败
preStop sleep 10 等 kube-proxy / Ingress 规则刷新 SIGTERM 之前执行
Dubbo shutdown.wait 等在途 RPC 完成 preStop 之后、SIGTERM 时
terminationGracePeriodSeconds 给上面三步留总预算 全程

预算要算清楚:preStop 10s + shutdown.wait 15s + 关闭开销 3s ≈ 28s,terminationGracePeriodSeconds 必须大于它,留 30% 余量最稳。反过来,如果 shutdown.wait 比预算是还长,SIGKILL 会替你做决定。

5. Spring Boot 的 server.shutdown=graceful 管不到 Dubbo

server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

这两个只作用于内嵌 Web 容器(Tomcat 停止接收新连接、等在途 HTTP 请求),对 Dubbo 的 TCP 端口没有任何作用。更麻烦的是执行顺序:

阶段 发生什么 谁受影响
1 发布 ContextClosedEvent Dubbo 监听它并开始销毁
2 lifecycleProcessor.onClose() Web 容器优雅停机
3 destroyBeans() 其余 Bean

按 Spring Boot 3.x 的 AbstractApplicationContext#doClose 顺序核对:Dubbo 的销毁早于 Tomcat 的优雅停机。这意味着 Tomcat 还在收 HTTP 请求时,Dubbo 的 channel 已经关了,这些请求会以"下游不可用"的形式失败。

要修这个顺序,别指望配置项,直接用 SmartLifecycle 把 Dubbo 的销毁挪到最后:

@Component
public class DubboShutdownLifecycle implements SmartLifecycle {
    private volatile boolean running;

    @Override public void start() { running = true; }
    @Override public void stop() {
        DubboBootstrap.getInstance().destroy();   // phase 越大越晚停
        running = false;
    }
    @Override public boolean isRunning() { return running; }
    @Override public int getPhase() { return Integer.MAX_VALUE; }   // 最后停
}

同时要把默认的 DubboShutdownHook 关掉(dubbo.shutdown.hook=false,按版本核对键名),否则两个销毁路径会撞在一起。

6. kill -9 之后调用方看到什么

没有 hook,没有注销,连接是 RST 断的。调用方典型输出:

org.apache.dubbo.rpc.RpcException: Invoke remote method timeout.
  ...
Caused by: org.apache.dubbo.remoting.RemotingException:
  All channel closed on address /10.2.3.4:20880

或者:

java.nio.channels.ClosedChannelException
  at org.apache.dubbo.remoting.transport.netty4.NettyClientHandler.channelClosed(...)

此时调用方还会做两件事:把该地址加入失败列表(Directory 层,默认 60 秒),以及触发集群容错重试(Failover 默认重试 2 次)。所以一次 kill -9 的实际影响是最多 3 倍请求量打在剩余实例上——如果剩余实例本来就接近容量,会引发连锁超时。

注册中心侧,实例要等心跳或会话超时才被摘除:ZooKeeper 会话超时通常 30–60 秒,Nacos 临时实例 15 秒不健康、30 秒移除。这段时间里新请求还会被路由到这台已死的机器。

7. 怎么验证优雅停机真的生效

三条独立证据,缺一条都不算验证过:

# 1) Provider 侧日志:hook 触发的时序
grep -E "Run shutdown hook now|Waiting for|unregister|Close .*channel" app.log

# 2) 在途请求是否跑完:停机窗口内的业务日志
awk '/Run shutdown hook now/{f=1} f' app.log | grep -c "order processed"

# 3) 注册中心列表变化(Nacos OpenAPI)
curl -s "http://127.0.0.1:8848/nacos/v1/ns/instance/list?serviceName=order-service" \
  | jq '.hosts | length'

更主动的做法是用 QoS 先手工摘流量,观察调用方是否还有失败:

telnet 127.0.0.1 22222
# 提示符下执行
offline            # 只注销,不停止服务
ls                 # 查看当前 provider/consumer 列表
online             # 恢复

offline 只影响注册,进程还在跑,是验证"注销后调用方多久停止访问"的最佳手段。如果 offline 之后调用方还在打,说明缓存刷新链路有问题,跟停机逻辑无关。

8. 排错:六种"停机还是失败了"

现象 原因 处理
停机时大量 channel closed 等待为 0 或未配置 设 dubbo.service.shutdown.wait,并让上游先摘流量
日志停在"等待在途请求"后中断 被 SIGKILL 截断 调大 terminationGracePeriodSeconds
停机很慢但请求没失败 等待时间里还在接新请求 先 readiness 失败,再 SIGTERM
注销后调用方仍访问数分钟 消费端缓存刷新慢 检查注册中心推送/长轮询是否正常
双实例同时销毁 两个销毁路径(hook + Spring)都开了 只留一条,关掉 dubbo.shutdown.hook
Tomcat 还在收请求但 RPC 已断 ContextClosedEvent 早于 Web 优雅停机 用 SmartLifecycle 把 Dubbo 销毁挪到最后

9. 边界:这些情况优雅停机也救不了

场景 更该做的事 理由
长轮询 / WebSocket 连接 应用层发关闭帧 + 客户端重连 TCP 层面等待没有意义
消费端 MQ 消费者 先停拉取、再提交 offset 停机等待不覆盖消息语义
批量任务正在跑 任务级 checkpoint + 续跑 等 15 秒不可能等完一小时批处理
有状态分片 分片迁移协议 停机顺序要配合 rebalance
数据库连接池 先关池再退出 连接泄漏比请求失败更难查

10. 自测

  1. 为什么 Provider 不该"先注销再等待在途请求"?
  2. dubbo.service.shutdown.wait 调到 60 秒,K8s 预算还是 30 秒,会发生什么?
  3. preStop sleep 解决的是哪一段延迟?
  4. server.shutdown=graceful 为什么管不到 Dubbo 的端口?
  5. ContextClosedEvent 与 Web 容器优雅停机的先后顺序是什么?
  6. 用 QoS 的 offline 能验证什么,不能验证什么?
S17-X06 · Dubbo 优雅停机:下线时那几秒发生了什么
https://www.clxhxhhr.top/posts/5669/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。