Dubbo 优雅下线:dubbo-go 和 dubbo-java 的设计对比

#dubbo#dubbo-go#graceful-shutdown
2026年 3月 28日

用比较直白的方式聊清楚:服务下线时,怎样尽量不影响请求?

这篇文章想聊清楚的,不是“进程怎么退出”,而是:一个服务实例准备下线时,流量怎样才能平滑撤走

很多人第一次听到“优雅下线”,会觉得就是:收到退出信号以后,等几秒,再把进程关掉。

但在 RPC 框架里,事情远比“sleep 几秒”复杂。因为一个 Provider 下线,不只影响它自己,还会影响所有还在调用它的 Consumer。


0. 先把问题说透:优雅下线到底在解决什么?

服务下线时,最常见的问题通常有这几类:

  • Provider 已经准备退出了,但 Consumer 还在继续调它;
  • 请求已经打到了 Provider,但业务还没处理完,进程却停了;
  • 注册中心已经把实例摘掉了,但 Consumer 本地缓存还没更新;
  • 第一次请求失败后,Consumer 仍然反复打到同一个正在关闭的实例;
  • 本地刚摘掉的实例,又被一条晚到的旧注册数据“加回来”。

所以,优雅下线的本质不是“优雅地关进程”,而是“优雅地撤流量”

换句话说,它至少要同时做到两件事:

  1. 新请求尽快别再打进来
  2. 已经打进来的旧请求尽量处理完

下面这张图,可以把一条理想的下线过程先整体看清楚。

图1:一次理想的服务优雅下线路径

从这条主线可以看出:

  • 下线不是“立刻终止”,而是分阶段推进
  • 真正关键的阶段,是通知外部别再调我 → 等待客户端感知 → 继续处理在途请求
  • 只有当新流量基本停住、旧流量也接近排空后,才应该进入拒绝请求、释放资源、退出进程阶段。

1. 只靠注册中心,为什么往往不够?

最基础的思路当然是:

  • Provider 从注册中心注销;
  • 注册中心把变更通知给 Consumer;
  • Consumer 更新本地服务列表;
  • 后续路由不再选这个 Provider。

这个思路本身没问题,但真正的问题是:注册中心通知是异步的

也就是说,Provider 注销完成,不等于所有 Consumer 已经同步完成了本地更新。中间总会有一个时间差。而这个时间差,就是故障最容易发生的地方。

图2:为什么只靠注册中心不够

在这段时间差里,如果 Provider 注销后立刻退出,就可能出现:

  • 请求失败;
  • 调用超时;
  • 连接中断;
  • Consumer 第一次失败后继续重试到这个实例。

所以,“从注册中心删掉自己”只是优雅下线的第一步,不是全部


2. dubbo-java 的思路:按顺序把服务关干净

在经典的 dubbo-java 方案里,主线是比较清晰的:

  1. 收到停机信号;
  2. 从注册中心注销 Provider;
  3. 等待 Consumer 感知变化;
  4. 关闭协议层;
  5. 等待在途请求处理完成;
  6. 释放资源并退出。

图3:dubbo-java 的经典优雅停机流程

这种方案的优点很明显:

  • 过程容易理解;
  • 关闭顺序比较规整;
  • 工程实现相对简单;
  • 在很多稳定环境里已经足够好用。

但它的一个天然限制也很明显:

它更依赖“注册中心通知足够快、Consumer 感知足够快”。

如果注册中心链路慢、缓存刷新慢、网络抖动大,等待时间即使设置了,也不一定完全稳。

所以你可以把 dubbo-java 的思路理解成:

我先从注册中心摘掉自己,再等一会儿,希望大家都知道我要下线了。


3. dubbo-go 的思路:不只是下线自己,而是尽快让 Consumer 停止继续调你

dubbo-go 的思路会再往前走一步。

它承认注册中心很重要,但同时也承认:

真实环境里,不能只靠注册中心。

所以它在“注册中心反注册”之外,又叠加了多条 Consumer 感知路径:

  • 注册中心反注册;
  • 长连接主动通知;
  • 正常响应里携带 closing=true
  • 调用失败时识别“连接正在关闭”的错误。

图4:dubbo-go 的多层感知式优雅下线

这张图的关键点是:

  • Provider 进入 closing 状态后,不是只做一件事,而是多路同时发信号
  • Consumer 只要从其中任意一条路径感知到“这个实例要下线了”,就可以开始本地避开它;
  • 优雅下线不再只是 Provider 的事情,而是 Provider 和 Consumer 一起完成撤流量

这和 dubbo-java 的区别,可以先用一句话记住:

dubbo-java 更像“按顺序把服务关掉”;

dubbo-go 更像“想办法让调用方尽快别再调我”。


4. `closing` 状态到底是什么意思?

closing 是 dubbo-go 这套设计里的一个关键概念。

它不是“已经停机”,也不是“这个实例不可达了”,而更像是一种下线预告

我还活着,还能处理请求,但我已经准备走了,不应该再被继续当成正常实例去路由。

图5:closing 状态到底是什么意思

进入 closing 之后,一个 Provider 通常会做两类动作:

此时还会继续做的事

  • 继续处理已经进入的请求;
  • 在响应里带上 closing 标记;
  • 主动通知 Consumer;
  • 等待 Consumer 把自己从本地路由里摘掉。

接下来会逐步推进的事

  • 逐步停止接收新流量;
  • 等待在途请求排空;
  • 拒绝新的请求;
  • 最后关闭协议和资源。

所以一定要记住一句话:

closing 的本质是“我准备下线了”,而不是“我已经死了”。


5. Consumer 可以通过哪些路径感知 Provider 正在下线?

在 dubbo-go 里,Consumer 侧并不是只能等注册中心。

它至少可以通过四种路径知道某个 Provider 已经不适合继续选了:

  1. Health watch 收到 NOT_SERVING
  2. 正常 RPC 响应的 attachment 里有 closing=true
  3. 调用失败时识别出 connection closing / unavailable 之类的错误;
  4. 注册中心推送实例下线变更。

图6:Consumer 感知 Provider 下线的四条路径

这一步最重要的认知不是“知道它要下线”,而是:

知道之后,必须真的改变后续路由结果。

如果只是打个日志、记个状态,但负载均衡照样还会选到它,那么感知这件事就没有真正发挥价值。


6. Consumer 侧真正关键的动作:本地把实例摘掉

一旦 Consumer 知道某个 Provider 正在下线,真正关键的动作是:

  • 生成 ClosingEvent
  • 找到对应 serviceKeyDirectory
  • 执行 RemoveClosingInstance(instanceKey)
  • 删除本地 cache 中的 invoker;
  • 刷新可路由实例列表;
  • 让后续请求不再选到这个 Provider。

图7:Consumer 本地摘除实例流程

这一步的意义非常大:

  • 它让 Consumer 不用死等完整的注册中心收敛
  • 只要本地先删掉,负载均衡就能马上避开这个实例;
  • 流量能更快从正在下线的 Provider 身上撤走。

这也是 dubbo-go 方案非常“像云原生”的地方:

不追求所有外部系统先完美同步,再行动;

而是谁先知道,谁先规避


7. `tombstone`:为什么刚摘掉的实例,还可能“复活”?

还有一个分布式系统里很常见、但很容易被忽略的问题:

  • Consumer 刚收到 closing 信号,把实例从本地删掉;
  • 结果一条旧的注册中心 add/update 事件晚到了;
  • Directory 又把这个实例重新加回来了;
  • 请求再次打回了这个本来就快下线的 Provider。

这就是所谓的“实例复活”问题。

dubbo-gotombstone 来解决它。你可以把它理解为:

短期黑名单

图8:tombstone 如何防止实例复活

有了 tombstone 之后:

  • 本地删掉实例时,会同时写入一条 tombstone;
  • 后续如果旧数据晚到,试图重建这个实例;
  • Directory 先检查 tombstone;
  • 命中了就跳过重建;
  • 这样实例不会在短时间内“复活”。

这个机制虽然不是主线流程里最显眼的一步,但它非常重要。因为它解决的不是“理想路径”,而是:

分布式系统里旧数据晚到、事件乱序、状态短暂回滚这些真实问题。


8. 为什么只看 `activeCount == 0` 还不够?

优雅下线通常都要判断一个问题:

现在是不是已经没有在途请求了?是不是可以继续往后走了?

最直觉的做法是看 activeCount == 0

但只看这个值,很容易误判。因为高并发环境里,某一瞬间没请求,不代表下一瞬间不会立刻再来新请求。

图9:为什么只看 activeCount 不够

所以更稳的做法通常是同时满足两个条件:

  1. 当前没有在途请求
  2. 最近一小段时间也没有新请求进入

这个“最近一小段时间”,本质上就是一个滑动窗口。它可以避免这样一种误判:

  • activeCount 瞬间归零;
  • 你以为流量已经排空;
  • 结果马上又来了新请求;
  • 服务在错误的时机进入下一阶段。

因此,dubbo-go 更像是在判断:

流量是否已经安静下来,而不是只判断“这一瞬间是否恰好没人”。


9. 把两套思路放在一起看:dubbo-java 和 dubbo-go 到底差在哪?

说到底,这两套方案并不是“谁先进、谁落后”的关系,而是设计重心不同

图10:dubbo-java 与 dubbo-go 的思路对比

可以把差异总结成下面这几句话:

dubbo-java 更强调

  • 正确的关闭顺序;
  • 先摘注册中心,再等待,再关闭;
  • 用比较经典、主线清晰的方式做优雅停机;
  • 更适合传统、稳定、链路相对可控的场景。

dubbo-go 更强调

  • Consumer 尽快感知并停止调用;
  • 不只靠注册中心,而是多条信号通路一起工作;
  • 本地快速摘除、tombstone 防复活、滑动窗口防误判;
  • 更适合云原生、长连接、滚动发布频繁的场景。

如果用一句更口语化的话概括:

dubbo-java 更像“按顺序把服务关干净”;

dubbo-go 更像“想办法让调用方尽快别再调我”。


10. 最后的总结:优雅下线不是“等几秒再退出”,而是“平滑撤流量”

写到这里,其实整件事已经很清楚了。

优雅下线真正解决的,不是“进程退出得漂不漂亮”,而是下面这件事:

在一个分布式调用链路里,让即将退出的机器尽量少接新请求,同时尽量把已经接住的旧请求处理完。

所以一套更完整的优雅下线,一般都应该包含这些能力:

  • 能告诉外部“我准备下线了”;
  • 能让 Consumer 尽快别再继续选我;
  • 能等待在途请求尽量处理完成;
  • 能防止旧数据把实例重新加回来;
  • 能更稳地判断流量是不是已经真的安静了;
  • 最后再拒绝新请求、关闭协议、释放资源、退出进程。

优雅下线不是 sleep 几秒,也不是简单注销注册中心,而是在一个分布式系统里,尽量平滑地把流量从即将退出的实例上挪走。

编辑