Hello World

吞风吻雨葬落日 欺山赶海踏雪径

0%

同接口多引用 × Spring Cloud 注册中心变更推送丢失

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
2
3
4
5
6
7
// 显式指定超时 10 秒
@DubboReference(timeout = 10000)
private AccountNacosClient accountClient;

// 无任何属性,全部走默认值
@DubboReference
private AccountNacosClient accountNacosClient;

provider 在 K8s 重发布后:

  • Pod 重建,IP 从老地址变成新地址;
  • 老 IP 随即被集群里另一个应用的新 Pod 占用;
  • 只有一个引用刷新到新 IP,另一个继续打老 IP;
  • 老 IP 对端也监听 Dubbo 端口,三次握手成功,但 ServiceRepository 里没有这个服务,返回 service not found。症状隐蔽,难归因。

两个待回答的问题:

  1. 同一接口两处 @DubboReference(配置不一致)会不会产生多个 Bean?
  2. 注册中心推送变更时,是不是只更新了其中一个 Bean?为什么?

2. 当前环境

2.1 版本事实

1
2
3
4
5
+- com.alibaba.cloud:spring-cloud-starter-dubbo:jar:2.2.7.RELEASE:compile
| +- org.apache.dubbo:dubbo:jar:2.7.18:compile
| +- org.apache.dubbo:dubbo-spring-boot-starter:jar:2.7.13:compile
+- com.alibaba.cloud:spring-cloud-starter-alibaba-nacos-discovery:jar:2.2.9.RELEASE:compile
| +- com.alibaba.nacos:nacos-client:jar:1.4.2: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
2
3
4
5
6
7
8
9
// SpringCloudRegistryFactory#createRegistry(SCA 2.2.7)
switch (dubboCloudProperties.getRegistryType()) {
case SPRING_CLOUD_REGISTRY_PROPERTY_VALUE: // "spring-cloud"
registry = new SpringCloudRegistry(...); // 旧版,AbstractSpringCloudRegistry
break;
default: // "dubbo-cloud"(默认值)
registry = new DubboCloudRegistry(...);
break;
}

DubboCloudProperties.registryType 默认是 "dubbo-cloud"。当前环境显式配成 "spring-cloud",命中已经 @Deprecated 的 SpringCloudRegistry。这是本次 bug 的载体。

3. 多引用如何形成两条独立订阅

3.1 @DubboReference 注入入口与 bean name

ReferenceAnnotationBeanPostProcessor#doGetInjectedBean 处理注解字段:

1
2
3
4
5
6
7
8
9
10
11
12
// ReferenceAnnotationBeanPostProcessor#doGetInjectedBean(Dubbo 2.7.18)
protected Object doGetInjectedBean(AnnotationAttributes attributes, Object bean, String beanName,
Class<?> injectedType, InjectionMetadata.InjectedElement injectedElement) {
String referencedBeanName = buildReferencedBeanName(attributes, injectedType);
String referenceBeanName = getReferenceBeanName(attributes, injectedType);

referencedBeanNameIdx.computeIfAbsent(referencedBeanName, k -> new TreeSet<>()).add(referenceBeanName);

ReferenceBean referenceBean = buildReferenceBeanIfAbsent(referenceBeanName, attributes, injectedType);
// 直接拿 ReferenceBean 内部生成的代理,不走 Spring 按类型查找
return getBeanFactory().applyBeanPostProcessorsAfterInitialization(referenceBean.get(), referenceBeanName);
}

bean name 由 generateReferenceBeanName 生成,注解的全部非默认属性都参与命名:

1
2
3
4
5
6
7
8
9
10
11
private String generateReferenceBeanName(AnnotationAttributes attributes, Class<?> interfaceClass) {
StringBuilder beanNameBuilder = new StringBuilder("@Reference");
if (!attributes.isEmpty()) {
beanNameBuilder.append('(');
for (Map.Entry<String, Object> entry : attributes.entrySet()) {
beanNameBuilder.append(entry.getKey()).append('=').append(convertAttribute(entry.getValue())).append(',');
}
}
beanNameBuilder.append(" ").append(interfaceClass.getName());
return beanNameBuilder.toString();
}
注解写法 生成的 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
2
3
4
5
6
7
8
9
10
11
12
13
// RegistryProtocol#doCreateInvoker(Dubbo 2.7.18)
protected <T> ClusterInvoker<T> doCreateInvoker(DynamicDirectory<T> directory, Cluster cluster, Registry registry, Class<T> type) {
directory.setRegistry(registry);
directory.setProtocol(protocol);
Map<String, String> parameters = new HashMap<String, String>(directory.getConsumerUrl().getParameters());
URL urlToRegistry = new URL(CONSUMER_PROTOCOL, parameters.remove(REGISTER_IP_KEY), 0, type.getName(), parameters);
directory.subscribe(toSubscribeUrl(urlToRegistry));
return (ClusterInvoker<T>) cluster.join(directory);
}

public static URL toSubscribeUrl(URL url) {
return url.addParameter(CATEGORY_KEY, PROVIDERS_CATEGORY + "," + CONFIGURATORS_CATEGORY + "," + ROUTERS_CATEGORY);
}

两条订阅 URL 示意(参数按 TreeMap 排序,check=false 来自 dubbo.consumer.check):

1
2
3
4
5
6
7
8
9
10
11
① @DubboReference(timeout = 10000)
consumer://<本机IP>/...AccountNacosClient
?application=user-growth&category=providers,configurators,routers&check=false
&dubbo=2.0.2&interface=...AccountNacosClient&side=consumer&sticky=false
&timeout=10000&timestamp=...

② @DubboReference(无属性)
consumer://<本机IP>/...AccountNacosClient
?application=user-growth&category=providers,configurators,routers&check=false
&dubbo=2.0.2&interface=...AccountNacosClient&side=consumer&sticky=false
&timestamp=...

FailbackRegistry.subscribe(url, listener) 最终落到 SpringCloudRegistry.doSubscribe(url, listener),listener 就是该引用自己的 RegistryDirectory。两条订阅、两个 Directory,互不可见——这是后续单边刷新的结构性前提。

4. 首次订阅为什么看起来正常

1
2
3
4
5
6
7
8
9
// AbstractSpringCloudRegistry#doSubscribe(SCA 2.2.7)
else { // for general Dubbo Services
subscribeDubboServiceURLs(url, listener);
}

protected void subscribeDubboServiceURLs(URL url, NotifyListener listener) {
doSubscribeDubboServiceURLs(url, listener); // ① 首次同步全量拉取
registerServiceInstancesChangedEventListener(url, listener); // ② 注册增量变更监听器
}

首次路径 subscribeDubboServiceURL 内部:discoveryClient.getInstances(serviceName) 直查 Nacos → 拉 exportedURLs → 为每个实例拼 provider URL → listener.notify(allSubscribedURLs)。

关键点:首次拉取没有任何按 URL 的去重。两个引用各执行一次、各拿一份正确地址列表。所以启动后一切正常,问题只在后续增量变更时暴露。

5. 变更推送为什么只刷新一个引用

5.1 Nacos 实例变更如何到达 SCA

1
2
3
4
5
6
7
8
9
10
Nacos Server
│ 实例变更(Pod 重建,IP 变化)
▼
nacos-client NamingService.subscribe(serviceName, group, listener)
│ NamingEvent ← SCA NacosConfiguration 按 serviceName 去重,只注册一次
▼
DubboServiceDiscoveryAutoConfiguration#dispatchServiceInstancesChangedEvent
│ 发布 Spring 事件 ServiceInstancesChangedEvent
▼
SimpleApplicationEventMulticaster 回调所有 ApplicationListener

Nacos 侧事件本身是正常的。问题出在消费侧监听器的注册。

5.2 generateId 去重:bug 位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// AbstractSpringCloudRegistry(SCA 2.2.7)
private static final Set<String> REGISTER_LISTENERS = new HashSet<>(); // static,JVM 级共享

private void registerServiceInstancesChangedEventListener(URL url, NotifyListener listener) {
String listenerId = generateId(url);
if (REGISTER_LISTENERS.add(listenerId)) { // 已存在则直接跳过
applicationContext.addApplicationListener(
new ApplicationListener<ServiceInstancesChangedEvent>() {
@Override
public void onApplicationEvent(ServiceInstancesChangedEvent event) {
subscribeDubboServiceURL(url, listener, event.getServiceName(),
s -> event.getServiceInstances());
// 只 notify 捕获的这个 url 对应的 listener
}
});
}
}

private String generateId(URL url) {
return url.toString(VERSION_KEY, GROUP_KEY, PROTOCOL_KEY);
}

REGISTER_LISTENERS 是 static,整个 JVM 一份。第二次订阅如果算出同一个 listenerId,监听器静默丢弃,连日志都没有。

generateId 的取值范围才是根因。Dubbo 2.7.18 的 URL#toString(String... parameters) 走 buildParameters,传入的 parameters 是白名单,只保留这些 key:

1
2
3
4
5
6
7
8
9
10
11
12
// URL#buildParameters(Dubbo 2.7.18)
private void buildParameters(StringBuilder buf, boolean concat, String[] parameters) {
if (CollectionUtils.isNotEmptyMap(getParameters())) {
List<String> includes = (ArrayUtils.isEmpty(parameters) ? null : Arrays.asList(parameters));
for (Map.Entry<String, String> entry : new TreeMap<>(getParameters()).entrySet()) {
if (StringUtils.isNotEmpty(entry.getKey())
&& (includes == null || includes.contains(entry.getKey()))) {
// 仅白名单内的 key 参与拼接
}
}
}
}

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
2
3
4
5
6
7
8
// RegistryDirectory#subscribe(Dubbo 2.7.18)
@Override
public void subscribe(URL url) {
setConsumerUrl(url);
CONSUMER_CONFIGURATION_LISTENER.addNotifyListener(this);
referenceConfigurationListener = new ReferenceConfigurationListener(this, url);
registry.subscribe(url, this); // this = NotifyListener
}

地址列表活在两个实例字段上:

1
2
3
// Map<url, Invoker> cache service url to invoker mapping.
protected volatile Map<URL, Invoker<T>> urlInvokerMap;
protected volatile Set<URL> cachedInvokerUrls;

每个 RegistryDirectory 一份,互不共享。集群调用走 FailoverClusterInvoker#doInvoke → list(invocation) → Directory.doList,读的就是这份 invokers。

6.1 notify 是刷新的唯一入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public synchronized void notify(List<URL> urls) {
Map<String, List<URL>> categoryUrls = urls.stream()
.filter(Objects::nonNull)
.filter(this::isValidCategory)
.filter(this::isNotCompatibleFor26x)
.collect(Collectors.groupingBy(this::judgeCategory));

List<URL> configuratorURLs = categoryUrls.getOrDefault(CONFIGURATORS_CATEGORY, Collections.emptyList());
this.configurators = Configurator.toConfigurators(configuratorURLs).orElse(this.configurators);

List<URL> routerURLs = categoryUrls.getOrDefault(ROUTERS_CATEGORY, Collections.emptyList());
toRouters(routerURLs).ifPresent(this::addRouters);

List<URL> providerURLs = categoryUrls.getOrDefault(PROVIDERS_CATEGORY, Collections.emptyList());
refreshOverrideAndInvoker(providerURLs);
}

notify 按 category 拆成 configurators / routers / providers,真正换地址在 refreshOverrideAndInvoker → refreshInvoker。

6.2 refreshInvoker:换新列表,顺手销毁老 invoker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private void refreshInvoker(List<URL> invokerUrls) {
if (invokerUrls.size() == 1
&& EMPTY_PROTOCOL.equals(invokerUrls.get(0).getProtocol())) {
this.forbidden = true;
this.invokers = Collections.emptyList();
destroyAllInvokers(); // 空协议:禁止访问,关掉全部 invoker
return;
}

Map<URL, Invoker<T>> oldUrlInvokerMap = this.urlInvokerMap;
if (invokerUrls.isEmpty() && this.cachedInvokerUrls != null) {
invokerUrls.addAll(this.cachedInvokerUrls); // 本次只推了 override/router,沿用缓存地址
} else {
this.cachedInvokerUrls = new HashSet<>(invokerUrls);
}
if (invokerUrls.isEmpty()) {
return;
}

Map<URL, Invoker<T>> newUrlInvokerMap = toInvokers(invokerUrls);
List<Invoker<T>> newInvokers = Collections.unmodifiableList(new ArrayList<>(newUrlInvokerMap.values()));
routerChain.setInvokers(newInvokers);
this.invokers = newInvokers;
this.urlInvokerMap = newUrlInvokerMap;

destroyUnusedInvokers(oldUrlInvokerMap, newUrlInvokerMap); // 老 IP 从这里被干掉
}

toInvokers 的缓存 key 是 merge 后的 provider URL。URL 没变就复用旧 invoker,URL 变了(IP 变了)就 protocol.refer 新建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private Map<URL, Invoker<T>> toInvokers(List<URL> urls) {
Map<URL, Invoker<T>> newUrlInvokerMap = new ConcurrentHashMap<>();
Map<URL, Invoker<T>> localUrlInvokerMap = this.urlInvokerMap;
for (URL providerUrl : urls) {
URL url = mergeUrl(providerUrl);
Invoker<T> invoker = localUrlInvokerMap == null ? null : localUrlInvokerMap.get(url);
if (invoker == null) { // 缓存没有:重新 refer
invoker = new InvokerDelegate<>(protocol.refer(serviceType, url), url, providerUrl);
newUrlInvokerMap.put(url, invoker);
} else {
newUrlInvokerMap.put(url, invoker); // URL 没变:复用
}
}
return newUrlInvokerMap;
}

destroyUnusedInvokers 则按 URL 做差集:旧 map 里有、新 map 里没有的,直接 destroyAll():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void destroyUnusedInvokers(Map<URL, Invoker<T>> oldUrlInvokerMap, Map<URL, Invoker<T>> newUrlInvokerMap) {
if (newUrlInvokerMap == null || newUrlInvokerMap.size() == 0) {
destroyAllInvokers();
return;
}
if (oldUrlInvokerMap != null) {
for (URL key : oldUrlInvokerMap.keySet()) {
if (key != null && !newUrlInvokerMap.containsKey(key)) {
Invoker<T> invoker = oldUrlInvokerMap.get(key);
if (invoker != null) {
invoker.destroyAll(); // 老 IP 连接在这里关掉
}
}
}
}
}

所以:只有收到 notify,Directory 才会换地址、才会销毁老 IP invoker。 连接断开、调用失败、failover 重试,都不会回头刷新地址列表。FailoverClusterInvoker 每次 list() 拿到的仍是启动时那份 invokers。

6.3 两个引用在 notify 之后的分叉

被刷新侧(先订阅、监听器注册成功):

  1. ServiceInstancesChangedEvent 回调匿名监听器;
  2. subscribeDubboServiceURL 重新拉元数据,拼出新 IP 的 provider URL;
  3. RegistryDirectory.notify → refreshInvoker → toInvokers 创建新 IP invoker;
  4. destroyUnusedInvokers 把老 IP invoker destroyAll();
  5. 后续调用切到新 IP。

停滞侧(后订阅、监听器被去重吞掉):

  1. RegistryDirectory.notify 从未被调用;
  2. urlInvokerMap / invokers 停留在启动时刻(老 IP);
  3. FailoverClusterInvoker 每次仍从老列表选 invoker;
  4. 无自愈:重连、超时、retries=2 都只是对着老 IP 再打几次。

这就是「一半刷新、一半打老 IP」的消费端机制。注册中心 bug 在 SCA,消费端 Directory 则忠实执行「没 notify 就不动」。

7. 根因链条

1
2
3
4
5
6
7
8
9
10
同接口两处 @DubboReference 属性不同(timeout=10000 vs 全默认)
→【多 Bean】两个 ReferenceBean / 两个代理 / 两条订阅 URL / 两个 RegistryDirectory
→【注册中心侧】SpringCloudRegistry(registry-type: spring-cloud)
REGISTER_LISTENERS 静态集合按 generateId(url) 去重
generateId = URL#toString(version, group, protocol) → 白名单过滤掉 timeout
→ 两个引用的 listenerId 相同
→ 后订阅引用的 ServiceInstancesChangedEvent 监听器【从未注册】(静默失败,无日志)
→ provider 重发布时事件只回调先订阅引用的监听器
→ 只 notify 先订阅引用的 RegistryDirectory
→ 后订阅引用的地址列表停留在启动时刻 → 持续调用老 IP

回答开头两个问题:

  1. 会。 同接口配置不一致会产生多个 Bean:注解非默认属性参与 ReferenceBean 命名,属性不同就各建一个;属性完全一致则复用。
  2. 会,而且是确定性的。 旧版 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
2
3
4
5
6
7
8
9
// DubboCloudRegistry#refreshGeneralServiceInfo(SCA 2.2.7)
urlSubscribeHandlerMap.forEach((url, handler) -> { // key = 完整订阅 URL,两个引用两个 entry
if (handler.relatedWith(appName, revision)) {
urls2refresh.add(url);
}
});
for (URL url : urls2refresh) {
handler.refresh(); // 各自 listener.notify
}

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. 验证方法

  1. 启动日志(多 Bean):搜 reference instances,同一接口对应 2 个 reference bean 名称。
  2. 事件日志(单边刷新):实例变更时 The Dubbo Service URL[ID : consumer://<ip>/...AccountNacosClient...] is being subscribed for service[name: xxx] 只出现一个 ID。同一接口本应每个引用刷新一次。
  3. 运行时定位(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.