Dubbo 优雅下线:dubbo-go 和 dubbo-java 的设计对比
用比较直白的方式聊清楚:服务下线时,怎样尽量不影响请求?
这篇文章想聊清楚的,不是“进程怎么退出”,而是:一个服务实例准备下线时,流量怎样才能平滑撤走。
很多人第一次听到“优雅下线”,会觉得就是:收到退出信号以后,等几秒,再把进程关掉。
但在 RPC 框架里,事情远比“sleep 几秒”复杂。因为一个 Provider 下线,不只影响它自己,还会影响所有还在调用它的 Consumer。
0. 先把问题说透:优雅下线到底在解决什么?
服务下线时,最常见的问题通常有这几类:
- Provider 已经准备退出了,但 Consumer 还在继续调它;
- 请求已经打到了 Provider,但业务还没处理完,进程却停了;
- 注册中心已经把实例摘掉了,但 Consumer 本地缓存还没更新;
- 第一次请求失败后,Consumer 仍然反复打到同一个正在关闭的实例;
- 本地刚摘掉的实例,又被一条晚到的旧注册数据“加回来”。
所以,优雅下线的本质不是“优雅地关进程”,而是“优雅地撤流量”。
换句话说,它至少要同时做到两件事:
- 新请求尽快别再打进来;
- 已经打进来的旧请求尽量处理完。
下面这张图,可以把一条理想的下线过程先整体看清楚。

从这条主线可以看出:
- 下线不是“立刻终止”,而是分阶段推进;
- 真正关键的阶段,是通知外部别再调我 → 等待客户端感知 → 继续处理在途请求;
- 只有当新流量基本停住、旧流量也接近排空后,才应该进入拒绝请求、释放资源、退出进程阶段。
1. 只靠注册中心,为什么往往不够?
最基础的思路当然是:
- Provider 从注册中心注销;
- 注册中心把变更通知给 Consumer;
- Consumer 更新本地服务列表;
- 后续路由不再选这个 Provider。
这个思路本身没问题,但真正的问题是:注册中心通知是异步的。
也就是说,Provider 注销完成,不等于所有 Consumer 已经同步完成了本地更新。中间总会有一个时间差。而这个时间差,就是故障最容易发生的地方。

在这段时间差里,如果 Provider 注销后立刻退出,就可能出现:
- 请求失败;
- 调用超时;
- 连接中断;
- Consumer 第一次失败后继续重试到这个实例。
所以,“从注册中心删掉自己”只是优雅下线的第一步,不是全部。
2. dubbo-java 的思路:按顺序把服务关干净
在经典的 dubbo-java 方案里,主线是比较清晰的:
- 收到停机信号;
- 从注册中心注销 Provider;
- 等待 Consumer 感知变化;
- 关闭协议层;
- 等待在途请求处理完成;
- 释放资源并退出。

这种方案的优点很明显:
- 过程容易理解;
- 关闭顺序比较规整;
- 工程实现相对简单;
- 在很多稳定环境里已经足够好用。
但它的一个天然限制也很明显:
它更依赖“注册中心通知足够快、Consumer 感知足够快”。
如果注册中心链路慢、缓存刷新慢、网络抖动大,等待时间即使设置了,也不一定完全稳。
所以你可以把 dubbo-java 的思路理解成:
我先从注册中心摘掉自己,再等一会儿,希望大家都知道我要下线了。
3. dubbo-go 的思路:不只是下线自己,而是尽快让 Consumer 停止继续调你
dubbo-go 的思路会再往前走一步。
它承认注册中心很重要,但同时也承认:
真实环境里,不能只靠注册中心。
所以它在“注册中心反注册”之外,又叠加了多条 Consumer 感知路径:
- 注册中心反注册;
- 长连接主动通知;
- 正常响应里携带
closing=true; - 调用失败时识别“连接正在关闭”的错误。

这张图的关键点是:
- Provider 进入 closing 状态后,不是只做一件事,而是多路同时发信号;
- Consumer 只要从其中任意一条路径感知到“这个实例要下线了”,就可以开始本地避开它;
- 优雅下线不再只是 Provider 的事情,而是 Provider 和 Consumer 一起完成撤流量。
这和 dubbo-java 的区别,可以先用一句话记住:
dubbo-java 更像“按顺序把服务关掉”;
dubbo-go 更像“想办法让调用方尽快别再调我”。
4. `closing` 状态到底是什么意思?
closing 是 dubbo-go 这套设计里的一个关键概念。
它不是“已经停机”,也不是“这个实例不可达了”,而更像是一种下线预告:
我还活着,还能处理请求,但我已经准备走了,不应该再被继续当成正常实例去路由。

进入 closing 之后,一个 Provider 通常会做两类动作:
此时还会继续做的事
- 继续处理已经进入的请求;
- 在响应里带上 closing 标记;
- 主动通知 Consumer;
- 等待 Consumer 把自己从本地路由里摘掉。
接下来会逐步推进的事
- 逐步停止接收新流量;
- 等待在途请求排空;
- 拒绝新的请求;
- 最后关闭协议和资源。
所以一定要记住一句话:
closing 的本质是“我准备下线了”,而不是“我已经死了”。
5. Consumer 可以通过哪些路径感知 Provider 正在下线?
在 dubbo-go 里,Consumer 侧并不是只能等注册中心。
它至少可以通过四种路径知道某个 Provider 已经不适合继续选了:
Health watch收到NOT_SERVING;- 正常 RPC 响应的 attachment 里有
closing=true; - 调用失败时识别出
connection closing / unavailable之类的错误; - 注册中心推送实例下线变更。

这一步最重要的认知不是“知道它要下线”,而是:
知道之后,必须真的改变后续路由结果。
如果只是打个日志、记个状态,但负载均衡照样还会选到它,那么感知这件事就没有真正发挥价值。
6. Consumer 侧真正关键的动作:本地把实例摘掉
一旦 Consumer 知道某个 Provider 正在下线,真正关键的动作是:
- 生成
ClosingEvent; - 找到对应
serviceKey的Directory; - 执行
RemoveClosingInstance(instanceKey); - 删除本地 cache 中的 invoker;
- 刷新可路由实例列表;
- 让后续请求不再选到这个 Provider。

这一步的意义非常大:
- 它让 Consumer 不用死等完整的注册中心收敛;
- 只要本地先删掉,负载均衡就能马上避开这个实例;
- 流量能更快从正在下线的 Provider 身上撤走。
这也是 dubbo-go 方案非常“像云原生”的地方:
不追求所有外部系统先完美同步,再行动;
而是谁先知道,谁先规避。
7. `tombstone`:为什么刚摘掉的实例,还可能“复活”?
还有一个分布式系统里很常见、但很容易被忽略的问题:
- Consumer 刚收到 closing 信号,把实例从本地删掉;
- 结果一条旧的注册中心
add/update事件晚到了; - Directory 又把这个实例重新加回来了;
- 请求再次打回了这个本来就快下线的 Provider。
这就是所谓的“实例复活”问题。
dubbo-go 用 tombstone 来解决它。你可以把它理解为:
短期黑名单。

有了 tombstone 之后:
- 本地删掉实例时,会同时写入一条 tombstone;
- 后续如果旧数据晚到,试图重建这个实例;
- Directory 先检查 tombstone;
- 命中了就跳过重建;
- 这样实例不会在短时间内“复活”。
这个机制虽然不是主线流程里最显眼的一步,但它非常重要。因为它解决的不是“理想路径”,而是:
分布式系统里旧数据晚到、事件乱序、状态短暂回滚这些真实问题。
8. 为什么只看 `activeCount == 0` 还不够?
优雅下线通常都要判断一个问题:
现在是不是已经没有在途请求了?是不是可以继续往后走了?
最直觉的做法是看 activeCount == 0。
但只看这个值,很容易误判。因为高并发环境里,某一瞬间没请求,不代表下一瞬间不会立刻再来新请求。

所以更稳的做法通常是同时满足两个条件:
- 当前没有在途请求;
- 最近一小段时间也没有新请求进入。
这个“最近一小段时间”,本质上就是一个滑动窗口。它可以避免这样一种误判:
activeCount瞬间归零;- 你以为流量已经排空;
- 结果马上又来了新请求;
- 服务在错误的时机进入下一阶段。
因此,dubbo-go 更像是在判断:
流量是否已经安静下来,而不是只判断“这一瞬间是否恰好没人”。
9. 把两套思路放在一起看:dubbo-java 和 dubbo-go 到底差在哪?
说到底,这两套方案并不是“谁先进、谁落后”的关系,而是设计重心不同。

可以把差异总结成下面这几句话:
dubbo-java 更强调
- 正确的关闭顺序;
- 先摘注册中心,再等待,再关闭;
- 用比较经典、主线清晰的方式做优雅停机;
- 更适合传统、稳定、链路相对可控的场景。
dubbo-go 更强调
- Consumer 尽快感知并停止调用;
- 不只靠注册中心,而是多条信号通路一起工作;
- 本地快速摘除、tombstone 防复活、滑动窗口防误判;
- 更适合云原生、长连接、滚动发布频繁的场景。
如果用一句更口语化的话概括:
dubbo-java 更像“按顺序把服务关干净”;
dubbo-go 更像“想办法让调用方尽快别再调我”。
10. 最后的总结:优雅下线不是“等几秒再退出”,而是“平滑撤流量”
写到这里,其实整件事已经很清楚了。
优雅下线真正解决的,不是“进程退出得漂不漂亮”,而是下面这件事:
在一个分布式调用链路里,让即将退出的机器尽量少接新请求,同时尽量把已经接住的旧请求处理完。
所以一套更完整的优雅下线,一般都应该包含这些能力:
- 能告诉外部“我准备下线了”;
- 能让 Consumer 尽快别再继续选我;
- 能等待在途请求尽量处理完成;
- 能防止旧数据把实例重新加回来;
- 能更稳地判断流量是不是已经真的安静了;
- 最后再拒绝新请求、关闭协议、释放资源、退出进程。
优雅下线不是 sleep 几秒,也不是简单注销注册中心,而是在一个分布式系统里,尽量平滑地把流量从即将退出的实例上挪走。
