type
Post
status
Published
date
Jun 1, 2026
slug
dubbogo2
summary
tags
技术探索
category
icon
password
如果一个 Dubbo-go consumer 调用失败,我们一般先做的一件事,是打开 Nacos 控制台。
假设控制台里清清楚楚地显示着三个 Provider:
很多排障会在这里得出一个看似合理的结论:
注册中心没问题,三个实例都在,下一步应该去查负载均衡。
我在排查 Issue #3302 和 #3303 时就被这个直觉误导过。
因为“注册中心里有三个实例”只能证明 Registry 这一层观察到了三个 ServiceInstance。它不能证明 consumer 最终构造出了三个可调用的 Invoker,也不能证明 Router 在这一次调用里仍然保留三个候选,更不能证明 LoadBalancer 最后一定能拿到非空集合。
对 Dubbo-go 来说,服务发现更接近一条持续运行的“物化链路”:
这条链里,任何一步都可能让候选集合变少,也可能让决定路由所需的信息丢失。
注册中心返回的是“外部观察到的实例”,RPC 需要的是“当前调用语义下仍然合法的 Invoker”。
1. 先建立心智模型:ServiceInstance 为什么不是 Invoker?
注册中心里的 ServiceInstance 描述的是一个服务实例。它通常包含地址、端口、application、metadata、revision、environment 等信息。
但 Dubbo-go 真正发起 RPC 时使用的是 Invoker。
两者之间并不是简单的:
中间至少要回答这些问题:
- 这个实例暴露哪些 Dubbo service?
- 使用什么协议?
- 对应哪个 interface / service key?
- 当前 metadata 是否完整?
- consumer 如何从 ServiceInfo 构造 URL?
- URL 能否进一步 Refer 成 Invoker?
- 对这一次 invocation,Router 是否仍允许它参与?
这也是
Directory 这个抽象存在的意义。当前
Directory 的核心方法是:注意这里有两个关键词。
第一个是返回值:
[]base.Invoker。Directory 对上层暴露的已经不是注册中心实例,而是 RPC runtime 可以使用的候选对象。第二个是参数:
Invocation。这说明“有哪些 Provider 存在”和“这一次调用允许使用哪些 Provider”并不天然是同一个问题。因此,我现在会把 Directory 理解成 consumer 侧非常重要的一道边界:它把 discovery world 里的 ServiceInstance/metadata,物化成 RPC runtime world 里的 Invoker。
这比“Registry 的下一层缓存”更准确。
2. Issue #3302:Nacos 明明有多个 Provider,为什么 Directory 最后只剩一个?
Issue #3302 打破了“注册成功 = 服务发现成功”的直觉。
当时的问题并不是 Provider 没有注册。相反,多个 Provider 都已经出现在 Nacos 中。
真正出错的是 consumer 侧:service-discovery event 在被转换成 ServiceInfo、URL 和 Directory state 的过程中丢失了信息。
我提交的 PR #3309 对根因的描述可以拆成两部分。
2.1 多个 Provider 的 metadata revision 被错误聚合
不同 Provider 可能携带不同的 metadata revision。
假设三个实例分别对应:
如果 listener 内部的聚合粒度过粗,只保存:
那么多个 revision 就可能互相覆盖或复用。
最终出现一种非常反直觉的现象:
从控制台看,服务发现“完全正常”;从 consumer runtime 看,却已经只剩一个可调用对象。
我的 PR #3309 的修复方向,是把这层关系调整为类似:
也就是不再把不同 revision 粗暴压进同一个槽位。
这里需要关注的是 cardinality contract:
Registry 里观察到的实例数,在经过 metadata 与 URL 物化之后,是否仍以正确的语义进入 runtime?
所以排查这类问题时,我现在会连续数四次:
哪个阶段数量突然下降,哪个阶段就比最终的
No provider 更接近根因。2.2 environment 丢失:为什么 metadata 不是“展示字段”
#3302 的第二个问题是
environment。Provider 可以通过配置声明自己属于 Dev、Pre、Prod 等环境。如果这个字段只是展示在控制台里,丢失当然不至于影响 RPC。
但在服务治理里,metadata 很可能直接参与 Router predicate。
例如一条规则要求:
那么“Provider 实际属于 Pre”是不够的。
必须保证这段信息沿着下面的链路一直存在:
如果
environment=pre 在任意一步被丢掉,注册、订阅、网络连接都可以是正常的,但路由仍然会失败。这也是为什么我更愿意把 #3302 定义成 materialization 过程破坏了信息保真度,而不只是“Nacos adapter 的一个 bug”。
PR #3309 最终同时处理了多 revision 聚合和 environment metadata 传播,并补充 provider remove/restart 等测试。
服务发现不仅要保持实例数量,还要保持决定后续治理语义所需的 metadata。数量和信息,两者缺一不可。
3. Directory 之后为什么还需要 Router?
假设 Directory 当前拥有三个可调用对象:
这只能说明:从服务拓扑和 RPC runtime 的角度看,它们都存在。
但这一次请求可能携带:
而路由规则要求:
于是一次调用里的集合变化应该是:
这里能看出 Directory、Router、LoadBalancer 的职责边界:
- Directory:当前服务有哪些可以参与调用的 runtime candidates;
- Router:对这一次 invocation,哪些 candidate 仍然合法;
- LoadBalancer:在 Router 留下的合法候选里,最终选谁。
Router 不是 LoadBalancer,也不应该把 invocation-specific 的过滤结果永久写回 Directory。
否则这次
uid=user-2 过滤掉 Pre/Prod 以后,下一次 uid=user-1 就可能再也看不到 Pre。Directory 负责“谁存在”,Router 负责“谁适合这次调用”,LoadBalancer 负责“这次到底选谁”。
4. Issue #3303:Router 把候选过滤为空以后,系统应该发生什么?
另一个更棘手的问题来自 Polaris 路由样例,也就是 Issue #3303。
当时有三组 Provider:Dev、Pre、Prod,只配置了依赖 invocation label
uid 的路由规则,例如:但是没有 discovery fallback。
在 Issue 记录使用的
v3.3.2-20260419 基线上,consumer 在目录初始化或规则未命中阶段可能出现 RouteRuleNotMatch,随后候选 invoker 被打空,并最终在后续调用链里出现 nil pointer panic。加入 discovery fallback 后,同样的 Provider 与 uid 组合可以稳定工作。
这里需要强调状态边界:截至本文研究时间,#3303 仍然是 Open Issue,而且没有关联修复 PR。因此我不会把 Issue 中较早基线上的现象直接写成“当前 main 一定仍然 panic”。
更准确的说法是:
#3303 记录了一个真实 failure mode:路由未命中可能导致候选集合被清空,而 downstream 对空集合的处理不够安全。
4.1 重新读当前 Polaris Router:错误和“成功但为空”不是一回事
重新阅读当前
main@08a4387... 的 Polaris Router,可以看到它已经对多种异常做了防御性处理。例如:routing disabled、输入 invokers 为空、instance map 为空、构造 route request 失败、
ProcessRouters 返回 error 等路径,都会直接返回或回退原始 invokers。但有一个边界非常值得注意:
如果
ProcessRouters(...) 调用成功,只是 SDK 返回了 0 个实例,当前路径会构造并返回一个空结果集。这说明至少存在三种不同语义:
- Router 自身报错;
- Router 正常执行并找到候选;
- Router 正常执行,但合法候选为 0。
第三种不是“异常返回”,它可能是一个完全合法的路由结果。
真正的问题因此不是“为什么 Router 会返回空”,而是:
框架有没有定义,空候选集究竟意味着什么?
当前源码里“SDK error”和“success + zero instances”确实属于两条不同路径;这与 #3303 中记录的 zero-invoker failure mode 在概念上高度相关,但在没有对当前 main 重新跑完整 reproducer 前,我不会把两者写成同一条已经确认的 bug 路径。
这也是写源码文章时非常重要的边界:
5. Zero Invokers 应该是什么语义?
假设 Router 输入:
输出:
框架至少有四种设计选择。
方案一:Fail Fast
直接返回明确错误,例如:
或:
优点是语义最清晰,也不会绕过治理规则;缺点是一条错误的规则可能立即扩大故障半径。
对于“强隔离”规则来说,这通常比偷偷 fallback 更符合预期。
方案二:自动回退到 Router 之前的候选
把:
恢复成:
可用性会更高,但风险也最大。
如果原规则表达的是:
那么无条件 fallback 就等于框架主动绕过治理。
因此,这类行为不应该是隐式默认值。
方案三:继续使用 previous known-good snapshot
当新 discovery/routing 结果暂时为空时,短时间继续使用旧候选。
这对“注册中心抖动”“瞬时空快照”可能有效,但对“治理规则明确要求清空”就很危险:旧快照可能包含已经下线的 Provider,也可能直接违背新规则。
所以“发现为空”和“路由为空”未必应该共享一套 fallback 语义。
方案四:把 empty policy 显式配置出来
例如概念上定义:
或:
甚至给 discovery 单独定义:
这是语义最清晰的方向,但代价是配置面、测试矩阵和状态机都会变复杂。
我的建议并不是替社区替换默认策略,而是最低限度保证一条原则:
zero invokers 必须变成显式、可观测、可测试的 framework outcome。
是 fail-fast 还是 fallback 可以继续讨论;但 nil pointer panic 永远不应该是一条正常治理规则的用户可见语义。
本节结论:空候选集本身不是 bug;没有定义空候选集意味着什么,才是危险之处。
6. #3303 更深的一层:Discovery-stage 和 Invocation-stage 拥有的信息并不一样
#3303 还有一个我认为比 nil panic 更值得讨论的问题:
uid 来自 invocation。但 discovery / directory 初始化往往发生在根本还没有具体请求的时候。
也就是说,两个阶段能看到的信息不同。
如果一条规则依赖
uid,却在完全没有 uid 的阶段被要求“必须匹配”,就容易出现:Issue #3303 的期望也正是围绕这个问题:如果 discovery 阶段根本不具备 invocation label,就应该安全 fallback,或者显式区分不同 routing stage。
这里我只把它写成 Issue 暴露出的设计问题,不会进一步声称 Dubbo-go 已经正式定义了“discovery-stage routing / invocation-stage routing”两级公共契约。
但有一条原则是确定的:
任何 Router 都必须知道自己的决策依赖哪些信息,并确认执行时这些信息已经存在。
7. Nacos #3302 和 Polaris #3303,其实在破坏同一条链
表面看,两个问题完全不同。
#3302 是:
#3303 是:
但放回完整链路就会发现,它们只是破坏了不同阶段:
用户最终看到的却可能高度相似:
甚至:
所以不能根据最终错误字符串倒推模块。
更有效的方法,是逐层观察“集合在哪里变小”。
如果:
优先查 materialization。
如果:
优先查 routing。
如果:
再去查 load balance。
同一个
No provider 可以由不同阶段制造;诊断的关键不是猜模块,而是逐层记录候选集合的变化。8. 我现在会怎么排查一类服务发现 / 路由 Bug?
如果以后再遇到类似问题,我会固定按下面顺序抓数据:
日志和 trace 里,我希望至少能看到这些字段:
而不是只有一句:
真正有诊断价值的是类似:
看到
3 → 3 → 3 → 0 的那一刻,问题已经基本被压缩到 Router。这比凌晨两点先打开 Nacos,看见三个绿色实例,然后开始怀疑网络,要高效得多。
9. 测试不能只断言 “RPC 调通了”
#3302 如果只写:
测试可能刚好打到那个没有被覆盖掉的 Provider,于是功能仍然 PASS。
#3303 如果只测试带 fallback 的 happy path,也永远看不到 zero-candidate 分支。
所以这类测试不能只断言最终 RPC 结果,而要同时验证每个转换阶段的不变量。
我会至少覆盖这些维度:
维度 | Case |
Provider 数量 | 1 / 3 |
metadata revision | 相同 / 不同 |
environment | 完整 / 缺失 |
Router | match / miss |
fallback | on / off |
生命周期 | startup / remove / restart |
invocation label | empty / user-1 / user-2 |
每个 case 至少断言:
表驱动测试可以把期望直接写成:
这样测的不是“最后有没有返回 200”,而是:
每一次从外部拓扑到 RPC runtime 的状态转换,是否保持了应该保持的数量、metadata 与失败语义。
10. Provider restart 为什么必须测?
PR #3309 专门把 provider remove/restart 放进验证范围,不是为了增加测试数量。
启动时的一次性转换:
通常是最容易正确的。
真正困难的是持续变化:
这要求系统同时正确处理:
任意一步残留 stale state,都可能出现最难排查的那种故障:
值得注意的是,#3309 后来又被一个新的 Provider restart 相关 Issue 引用。这说明一个更重要的事实:
服务发现是一套持续运行的状态同步系统,而不是应用启动时执行一次的地址加载函数。
11. 最后,我现在怎么定义 Registry、Directory、Router、LoadBalancer?
读完这两个问题之后,我自己的定义变得很简单。
Registry
回答:
外部服务发现系统告诉我,现在有哪些 ServiceInstance?
它提供 observed topology。
Directory
回答:
这些 discovery result 经 metadata / URL materialization 后,当前服务有哪些可调用 Invoker?
它把 discovery world 转换成 RPC runtime world。
Router
回答:
对这一次 invocation,哪些 Invoker 仍然允许参与?
它负责治理选择。
LoadBalancer
回答:
在 Router 留下的合法候选中,这一次到底选谁?
它也不应该承担“修复上游错误状态”的职责。
如果 Router 已经给它一个空集合,LoadBalancer 不应该凭空创造 Provider。
发现、物化、过滤、选择是四个不同的问题;把它们拆开,生产 Bug 才能真正被定位。
12. 这两个 Issue 最后教会我的,是监控“集合如何变小”
传统服务发现监控经常只看:
现在我更希望同时看到:
甚至在异常时直接记录整个变化链:
因为这比最终一句:RPC failed 信息量大得多。
#3302 让我看到:Registry 里的 cardinality 和 metadata 可以在 materialization 时被破坏。
#3303 让我看到:Directory 里的合法 Invoker 也可能在 Router 阶段被过滤为 0,而 0 必须拥有明确的 failure semantics。
把两者放到一起之后,我对“服务发现”的定义也发生了变化:
服务发现不是找到 IP,而是在一系列状态转换中持续证明:这个 Provider 到了真正发起 RPC 的那一刻,仍然是一个合法候选。
这也是为什么,看到 Nacos 里三个绿色 Provider,只能算排障的起点,远远不能算结论。
分享
