K8s 里 provider 重发布后 IP 变了,消费端同一个 Dubbo 接口却出现「一半调用新 IP、一半还在打老 IP」。更糟的是老 IP 马上被别的应用占用,TCP 能连上,对端却返回 service not found,排查很容易误判成 provider 自己的问题。
根因其实很窄:同接口多引用把订阅拆成两条,旧版 spring-cloud 注册中心又把这两条监听器按同一把 key 去重了。 下面按源码把链路拆开。
分析基线:org.apache.dubbo:dubbo:2.7.18 + com.alibaba.cloud:spring-cloud-starter-dubbo:2.2.7.RELEASE + spring-cloud-starter-alibaba-nacos-discovery:2.2.9.RELEASE(nacos-client 1.4.2)。注册中心走的是 Spring Cloud DiscoveryClient,不是 Dubbo 原生 nacos://。
1. 问题现象
消费端对同一个接口写了两处引用,注解属性不一致:
1 | // 显式指定超时 10 秒 |
provider 在 K8s 重发布后:
- Pod 重建,IP 从老地址变成新地址;
- 老 IP 随即被集群里另一个应用的新 Pod 占用;
- 只有一个引用刷新到新 IP,另一个继续打老 IP;
- 老 IP 对端也监听 Dubbo 端口,三次握手成功,但
ServiceRepository里没有这个服务,返回service not found。症状隐蔽,难归因。
两个待回答的问题:
- 同一接口两处
@DubboReference(配置不一致)会不会产生多个 Bean? - 注册中心推送变更时,是不是只更新了其中一个 Bean?为什么?
2. 当前环境
2.1 版本事实
1 | +- com.alibaba.cloud:spring-cloud-starter-dubbo:jar:2.2.7.RELEASE:compile |
消费端另有 dubbo.consumer.check=false。依赖树里没有 dubbo-registry-nacos,服务发现走 Spring Cloud DiscoveryClient,即 spring-cloud:// 协议。Nacos 配置中心显式设置了 spring.cloud.dubbo.registry-type: spring-cloud。
2.2 注册中心选型
SCA 2.2.7 里 spring-cloud:// 有两套实现,由 SpringCloudRegistryFactory 按配置创建:
1 | // SpringCloudRegistryFactory#createRegistry(SCA 2.2.7) |
DubboCloudProperties.registryType 默认是 "dubbo-cloud"。当前环境显式配成 "spring-cloud",命中已经 @Deprecated 的 SpringCloudRegistry。这是本次 bug 的载体。
3. 多引用如何形成两条独立订阅
3.1 @DubboReference 注入入口与 bean name
ReferenceAnnotationBeanPostProcessor#doGetInjectedBean 处理注解字段:
1 | // ReferenceAnnotationBeanPostProcessor#doGetInjectedBean(Dubbo 2.7.18) |
bean name 由 generateReferenceBeanName 生成,注解的全部非默认属性都参与命名:
1 | private String generateReferenceBeanName(AnnotationAttributes attributes, Class<?> interfaceClass) { |
| 注解写法 | 生成的 bean name |
|---|---|
@DubboReference(全默认) |
@Reference com.xxx.AccountNacosClient |
@DubboReference(timeout = 10000) |
@Reference(timeout=10000) com.xxx.AccountNacosClient |
属性不同 → bean name 不同 → buildReferenceBeanIfAbsent 缓存 miss → 各建一个 ReferenceBean。属性完全一致才会复用同一个实例。
每个 ReferenceBean 再以单例注册进 Spring 容器。ReferenceBean 继承 ReferenceConfig,getObject() 走 get() → init() → createProxy()。ReferenceConfig 之间没有静态代理缓存(ReferenceConfigCache 只服务编程式 API),ref / invoker 都是实例字段。
于是两个引用 = 2 个代理 + 2 个 RegistryDirectory + 2 条订阅 URL,超时分别生效(10s / 默认 1000ms)。
3.2 订阅 URL 从参数层面分叉
1 | // RegistryProtocol#doCreateInvoker(Dubbo 2.7.18) |
两条订阅 URL 示意(参数按 TreeMap 排序,check=false 来自 dubbo.consumer.check):
1 | ① @DubboReference(timeout = 10000) |
FailbackRegistry.subscribe(url, listener) 最终落到 SpringCloudRegistry.doSubscribe(url, listener),listener 就是该引用自己的 RegistryDirectory。两条订阅、两个 Directory,互不可见——这是后续单边刷新的结构性前提。
4. 首次订阅为什么看起来正常
1 | // AbstractSpringCloudRegistry#doSubscribe(SCA 2.2.7) |
首次路径 subscribeDubboServiceURL 内部:discoveryClient.getInstances(serviceName) 直查 Nacos → 拉 exportedURLs → 为每个实例拼 provider URL → listener.notify(allSubscribedURLs)。
关键点:首次拉取没有任何按 URL 的去重。两个引用各执行一次、各拿一份正确地址列表。所以启动后一切正常,问题只在后续增量变更时暴露。
5. 变更推送为什么只刷新一个引用
5.1 Nacos 实例变更如何到达 SCA
1 | Nacos Server |
Nacos 侧事件本身是正常的。问题出在消费侧监听器的注册。
5.2 generateId 去重:bug 位置
1 | // AbstractSpringCloudRegistry(SCA 2.2.7) |
REGISTER_LISTENERS 是 static,整个 JVM 一份。第二次订阅如果算出同一个 listenerId,监听器静默丢弃,连日志都没有。
generateId 的取值范围才是根因。Dubbo 2.7.18 的 URL#toString(String... parameters) 走 buildParameters,传入的 parameters 是白名单,只保留这些 key:
1 | // URL#buildParameters(Dubbo 2.7.18) |
generateId 只保留 version / group / protocol。消费端订阅 URL 上这三个参数通常都不存在(接口无 version/group,consumer URL 上也没有 protocol 参数)。于是两个引用的去重 key 变成同一个裸 URL:
1 | listenerId① = listenerId② = "consumer://<本机IP>/...AccountNacosClient" |
timeout=10000 让订阅 URL 不同(两个 Directory),但 generateId 把差异抹平(去重命中):
| 引用 | 实际订阅 URL | generateId |
|---|---|---|
引用① timeout=10000 |
consumer://ip/...AccountNacosClient?...&timeout=10000&... |
consumer://ip/...AccountNacosClient |
| 引用② 全默认 | consumer://ip/...AccountNacosClient?...(无 timeout) |
完全相同 |
谁中招由 Spring Bean 初始化顺序决定。@DubboReference 字段注入发生在宿主 Bean 创建时,先初始化的引用拿到变更监听器,后初始化的引用被 REGISTER_LISTENERS.add 吞掉。
6. RegistryDirectory:地址列表的唯一更新入口
这一节把消费端「为什么老 IP 不会自己消失」补完整。RegistryDirectory 本身就是 NotifyListener,订阅时把自己交给注册中心:
1 | // RegistryDirectory#subscribe(Dubbo 2.7.18) |
地址列表活在两个实例字段上:
1 | // Map<url, Invoker> cache service url to invoker mapping. |
每个 RegistryDirectory 一份,互不共享。集群调用走 FailoverClusterInvoker#doInvoke → list(invocation) → Directory.doList,读的就是这份 invokers。
6.1 notify 是刷新的唯一入口
1 |
|
notify 按 category 拆成 configurators / routers / providers,真正换地址在 refreshOverrideAndInvoker → refreshInvoker。
6.2 refreshInvoker:换新列表,顺手销毁老 invoker
1 | private void refreshInvoker(List<URL> invokerUrls) { |
toInvokers 的缓存 key 是 merge 后的 provider URL。URL 没变就复用旧 invoker,URL 变了(IP 变了)就 protocol.refer 新建:
1 | private Map<URL, Invoker<T>> toInvokers(List<URL> urls) { |
destroyUnusedInvokers 则按 URL 做差集:旧 map 里有、新 map 里没有的,直接 destroyAll():
1 | private void destroyUnusedInvokers(Map<URL, Invoker<T>> oldUrlInvokerMap, Map<URL, Invoker<T>> newUrlInvokerMap) { |
所以:只有收到 notify,Directory 才会换地址、才会销毁老 IP invoker。 连接断开、调用失败、failover 重试,都不会回头刷新地址列表。FailoverClusterInvoker 每次 list() 拿到的仍是启动时那份 invokers。
6.3 两个引用在 notify 之后的分叉
被刷新侧(先订阅、监听器注册成功):
ServiceInstancesChangedEvent回调匿名监听器;subscribeDubboServiceURL重新拉元数据,拼出新 IP 的 provider URL;RegistryDirectory.notify→refreshInvoker→toInvokers创建新 IP invoker;destroyUnusedInvokers把老 IP invokerdestroyAll();- 后续调用切到新 IP。
停滞侧(后订阅、监听器被去重吞掉):
RegistryDirectory.notify从未被调用;urlInvokerMap/invokers停留在启动时刻(老 IP);FailoverClusterInvoker每次仍从老列表选 invoker;- 无自愈:重连、超时、
retries=2都只是对着老 IP 再打几次。
这就是「一半刷新、一半打老 IP」的消费端机制。注册中心 bug 在 SCA,消费端 Directory 则忠实执行「没 notify 就不动」。
7. 根因链条
1 | 同接口两处 @DubboReference 属性不同(timeout=10000 vs 全默认) |
回答开头两个问题:
- 会。 同接口配置不一致会产生多个 Bean:注解非默认属性参与
ReferenceBean命名,属性不同就各建一个;属性完全一致则复用。 - 会,而且是确定性的。 旧版
SpringCloudRegistry的变更监听器按generateId去重,这个 ID 抹掉了两个引用的全部差异,后订阅引用的监听器从未注册,推送永远只刷新先订阅的那个。同接口多引用 +registry-type: spring-cloud,100% 复现。
时序:
sequenceDiagram
participant K8s as K8s 重发布
participant N as Nacos Server
participant NC as nacos-client
participant AC as SCA AutoConfiguration
participant MC as Spring 事件多播器
participant L1 as 匿名监听器(引用①)
participant D1 as RegistryDirectory①
participant D2 as RegistryDirectory②
Note over D1,D2: 启动:doSubscribe 全量拉取,两引用列表均正确
generateId 相同 → 仅①注册了变更监听器
K8s->>N: Pod 重建(IP:老 → 新)
N->>NC: NamingEvent
NC->>AC: Nacos EventListener 回调
AC->>MC: publishEvent(ServiceInstancesChangedEvent)
MC->>L1: onApplicationEvent(唯一注册的监听器)
L1->>D1: listener①.notify(新 IP 列表)
D1->>D1: refreshInvoker + destroyUnusedInvokers(老 IP)
Note over D2: 无监听器 → 无回调 → urlInvokerMap 仍是老 IP
触发条件:
| 场景 | ReferenceBean 数量 | 变更监听器 | 结果 |
|---|---|---|---|
| 同接口、两处属性完全一致 | 1(复用) | 1 条(也只需要 1 条) | 无问题 |
| 同接口、属性不一致(本次) | 2 | 只注册 1 条,另一条被吞 | 单边刷新 |
| 不同接口 | 各 1 | generateId 含接口名,天然不同 | 无互相影响 |
registry-type: dubbo-cloud(默认) |
2 | 单一监听器遍历所有 handler | 两个引用都会刷新 |
两个条件缺一不可:没有多 Bean,去重无害;多 Bean 但走默认 dubbo-cloud,DubboCloudRegistry#onApplicationEvent 会遍历 urlSubscribeHandlerMap 刷新所有订阅。
对照一下默认实现:
1 | // DubboCloudRegistry#refreshGeneralServiceInfo(SCA 2.2.7) |
dubbo-cloud 正常路径下两个引用都会刷新。它自己另有一条概率性坑:新 revision 首次出现时要 RPC 新实例的 DubboMetadataService 拉元数据,新 Pod 未就绪会失败(只打 warn),该 revision 不被登记、本轮不刷新,且后续事件不再重试。当前环境没走这条实现,仅作对照。
7.1 老 IP 被占用 vs 空闲
| 老 IP 状态 | 症状 | 排查难度 |
|---|---|---|
| 空闲,无 Pod | TCP 连不上,直接超时 / RpcException,retries=2 放大 |
容易归因到「连的是老 IP」 |
| 被其他应用占用(本次) | 握手成功,对端返回 service not found |
很容易误判成 provider 端问题 |
7.2 附带影响
| 维度 | 影响 |
|---|---|
| 注入正确性 | 无影响。@DubboReference 直接返回 referenceBean.get(),不走按类型查找 |
| 超时行为 | 分别生效:一侧 10s,另一侧默认 1000ms,批量查询 + 默认 2 次重试是隐患 |
| 资源开销 | 多一份订阅、一个 Directory、一条 Invoker 链;TCP 连接在协议层按地址池化,开销可控 |
| 按类型注入 | 两个 ReferenceBean 都以接口类型暴露,若再用 @Autowired AccountNacosClient 会 NoUniqueBeanDefinitionException |
| 启动可观测 | 启动完成 WARN:xxx has 2 reference instances, there are: @Reference(timeout=10000) ..., @Reference ... |
8. 验证方法
- 启动日志(多 Bean):搜
reference instances,同一接口对应 2 个 reference bean 名称。 - 事件日志(单边刷新):实例变更时
The Dubbo Service URL[ID : consumer://<ip>/...AccountNacosClient...] is being subscribed for service[name: xxx]只出现一个 ID。同一接口本应每个引用刷新一次。 - 运行时定位(Arthas):
vmtool --action getInstances --className org.apache.dubbo.registry.integration.RegistryDirectory --express 'instances.size()',同一接口应看到 2 个 Directory。- 对两个实例分别 `ognl ‘#invokers={@org.apache.dubbo.common.utils.ReflectUtils@getFieldValue(instance,”invokers”)}, #invokers.