发布时最容易被忽略的一段代码是停机逻辑。滚动更新把 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. 自测
- 为什么 Provider 不该"先注销再等待在途请求"?
dubbo.service.shutdown.wait调到 60 秒,K8s 预算还是 30 秒,会发生什么?preStop sleep解决的是哪一段延迟?server.shutdown=graceful为什么管不到 Dubbo 的端口?ContextClosedEvent与 Web 容器优雅停机的先后顺序是什么?- 用 QoS 的
offline能验证什么,不能验证什么?