Dubbo 3.x 服务发现迁移:从接口级到应用级的渐进式切换

Dobbo3.x在原有的接口级模型之外引入了应用级服务发现。

1.思维导图

2.两种发现模型

2.1 Dubbo 2.x:接口级服务发现

每个服务接口独立注册到注册中心(以 ZK 为例):

ini 复制代码
ZooKeeper
├── /dubbo/org.apache.dubbo.demo.DemoService/providers
│   ├── dubbo://192.168.1.1:20880/DemoService?group=&version=1.0.0
│   └── dubbo://192.168.1.2:20880/DemoService?group=&version=1.0.0
│
├── /dubbo/org.apache.dubbo.demo.GreetingService/providers
│   ├── dubbo://192.168.1.1:20880/GreetingService?group=&version=1.0.0
│   └── dubbo://192.168.1.3:20880/GreetingService?group=&version=1.0.0
│
└── ...(每个接口一个目录)

一个暴露了 50 个接口的应用程序在每个实例上会生成 50 个注册条目,随着接口和实例的增加,注册中心的数据将呈平方级增长。

2.2 Dubbo 3.x:应用级服务发现

每个应用只注册一次 到注册中心,并在该单一条目中携带其所有暴露接口的元数据。消费者通过 ServiceNameMapping 将接口名映射到应用名来发现实例,然后订阅实例级别的变更事件。:

bash 复制代码
ZooKeeper(或 Nacos)
├── /services/UserService
│   └── {"name":"UserService", "id":"192.168.1.1:20880", "metadata":{"dubbo.endpoints":[...]}}
│
├── /services/OrderService
│   └── {"name":"OrderService", "id":"192.168.1.2:20880", "metadata":{...}}
│
└── ...(每个应用一个节点)

接口和应用的映射关系存在元数据中心(如 Nacos、ZK 的另一个路径):

css 复制代码
元数据中心
├── DemoService → [UserService, OrderService]
└── GreetingService → [UserService]

3.三步迁移

迁移被建模为由 MigrationStep 枚举定义的严格三步阶梯,代表对应用级发现的逐步推进:

步骤 行为 风险等级 使用场景
FORCE_INTERFACE 仅接口级 Invoker 处于活跃状态;服务发现 Invoker 被销毁 最低 回滚或仅存留旧版环境
APPLICATION_FIRST 同时维护两个 Invoker;按 proportionthreshold 分配流量 中等 通过双通道验证进行渐进式迁移
FORCE_APPLICATION 仅服务发现 Invoker 处于活跃状态;接口级 Invoker 被销毁 最高 完成迁移的服务

3.1 安全门控:threshold(阈值)

DefaultMigrationAddressComparator.shouldMigrate() 决定"应用级地址够不够":

arduino 复制代码
return ((float) newAddressSize / (float) oldAddressSize) >= threshold;

计算公式:应用级地址数 / 接口级地址数 >= threshold

threshold 含义
0.0(默认) 只要有 1 个应用级地址就迁移
0.5 应用级地址数至少是接口级的 50%
1.0 应用级地址数必须 >= 接口级(完全覆盖才迁移)

3.2 流量比例:proportion

APPLICATION_FIRST 模式下,proportion=60 表示 60% 的流量走应用级,40% 走接口级。通过 ThreadLocalRandom 实现随机分配。

3.3 强制标志:force

force=true 时跳过阈值检查,强制切换。用于紧急回滚场景。

4. 完整对象关系图

yaml 复制代码
MigrationInvoker
│
├── invoker (接口级 ClusterInvoker)
│   │
│   │  创建:RegistryFactory SPI → ZookeeperRegistryFactory → ZookeeperRegistry
│   │  订阅:ZK /dubbo/DemoService/providers
│   │  回调:Provider 列表变了 → directory.notify() → 更新 Invoker 列表
│   │
│   └── FailoverClusterInvoker
│         └── RegistryDirectory
│               ├── registry: ZookeeperRegistry(直接操作 ZK)
│               └── urlInvokerMap: {url → DubboInvoker}
│
├── serviceDiscoveryInvoker (应用级 ClusterInvoker)
│   │
│   │  创建:RegistryFactory SPI → ServiceDiscoveryRegistryFactory
│   │        → ServiceDiscoveryRegistry(内部用 ServiceDiscovery)
│   │  订阅:Nacos /services/UserService
│   │  回调:实例列表变了 → directory.notify() → 更新 Invoker 列表
│   │
│   └── FailoverClusterInvoker
│         └── ServiceDiscoveryRegistryDirectory
│               ├── registry: ServiceDiscoveryRegistry
│               │     └── serviceDiscovery: NacosServiceDiscovery
│               └── urlInvokerMap: {url → DubboInvoker}
│
└── currentAvailableInvoker → 指向上面其中一个,根据 MigrationRule 切换

5.核心类详解

1. MigrationInvoker --- 核心切换器

职责: 持有接口级和应用级两个 ClusterInvoker,根据规则决定当前用哪个。

属性详解

csharp 复制代码
public class MigrationInvoker<T> implements MigrationClusterInvoker<T> {

注入的依赖(构造函数传入,不可变)

属性 类型 为什么需要
registryProtocol RegistryProtocol 需要调用它的 getInvoker()getServiceDiscoveryInvoker() 来创建接口级/应用级 invoker。它是"invoker 工厂"
cluster Cluster 创建 ClusterInvoker 时需要。比如 FailoverCluster、FailfastCluster 等容错策略
registry Registry 注册中心实例(ZookeeperRegistry / NacosRegistry)。创建 Directory 时需要设置 registry
type Class<T> 服务接口类型,比如 DemoService.class。创建 Directory 和 Invoker 时必须知道接口类型
url URL registry:// 开头的 URL,包含注册中心地址和参数。用于创建 invoker 时传递注册中心信息
consumerUrl URL consumer:// 开头的 URL,包含消费者参数(interface、methods、timeout 等)。是消费者的"身份证"
consumerModel ConsumerModel 消费者模型,包含服务元数据。用于在 destroy 时清理注册表中的引用
reportService FrameworkStatusReportService 状态上报服务。迁移成功/失败时上报状态,用于监控

核心状态(运行时可变,volatile)

属性 类型 为什么需要
invoker ClusterInvoker<T> 接口级 invoker 。由 RegistryDirectory + Cluster 组成。订阅 ZK 的 /dubbo/InterfaceName/providers 节点。当 Consumer 还是 Dubbo 2.x 模式时,用这个
serviceDiscoveryInvoker ClusterInvoker<T> 应用级 invoker 。由 ServiceDiscoveryRegistryDirectory + Cluster 组成。订阅应用级注册中心。当 Consumer 升级到 Dubbo 3.x 模式时,用这个
currentAvailableInvoker ClusterInvoker<T> 当前实际在用的 invokerinvoke() 方法直接调用它的 invoke()。根据 step 的不同,它可能指向 invokerserviceDiscoveryInvoker
step MigrationStep 当前迁移阶段:FORCE_INTERFACE / APPLICATION_FIRST / FORCE_APPLICATION。决定 currentAvailableInvoker 指向谁
rule MigrationRule 当前生效的规则。保存在这里供 invoke() 时使用(比如 APPLICATION_FIRST 模式下需要重新评估)
promotion int 流量比例(0-100)。APPLICATION_FIRST 模式下,promotion=60 表示 60% 的流量走应用级,40% 走接口级

辅助属性

属性 类型 为什么需要
migrationRuleListener MigrationRuleListener 反向引用。destroy 时需要通知 listener 从 handlers map 中移除自己

核心方法

invoke(Invocation) --- 每次 RPC 调用都走这里

scss 复制代码
public Result invoke(Invocation invocation) {
    if (currentAvailableInvoker != null) {
        if (step == APPLICATION_FIRST) {
            // 流量比例控制:promotion=60 → 60% 走应用级,40% 回退接口级
            if (promotion < 100 && ThreadLocalRandom.current().nextDouble(100) > promotion) {
                return invoker.invoke(invocation);  // 回退到接口级
            }
            // 检查应用级是否可用,不可用则回退
            return decideInvoker().invoke(invocation);
        }
        return currentAvailableInvoker.invoke(invocation);
    }
    // 首次调用:根据 step 初始化 currentAvailableInvoker
    ...
}

为什么有 promotion 流量比例?

假设你有 100 个 Provider,其中 60 个升级到了 Dubbo 3.x。设置 promotion=60 意味着 60% 的请求走应用级发现(能访问到 60 个 Provider),40% 走接口级发现(能访问到全部 100 个 Provider)。这样即使应用级有问题,也有 40% 的流量是安全的。

migrateToForceInterfaceInvoker(rule) --- 切到接口级

kotlin 复制代码
public boolean migrateToForceInterfaceInvoker(MigrationRule newRule) {
    // 1. 创建/刷新接口级 invoker
    refreshInterfaceInvoker(latch);

    // 2. 如果应用级 invoker 不存在,直接切
    if (serviceDiscoveryInvoker == null) {
        this.currentAvailableInvoker = invoker;
        return true;
    }

    // 3. 等待地址通知
    waitAddressNotify(newRule, latch);

    // 4. 如果 force=true,跳过阈值检查,强制切换
    if (newRule.getForce(consumerUrl)) {
        this.currentAvailableInvoker = invoker;
        destroyServiceDiscoveryInvoker();  // 销毁应用级 invoker
        return true;
    }

    // 5. 阈值检查:接口级地址 >= 阈值比例 × 应用级地址?
    // 注意:这里是反向检查 --- 确保切回接口级后地址够用
    if (detectors.allMatch(c -> c.shouldMigrate(invoker, serviceDiscoveryInvoker, newRule))) {
        this.currentAvailableInvoker = invoker;
        destroyServiceDiscoveryInvoker();
        return true;
    }

    // 6. 阈值不够,不切换
    return false;
}

为什么要 waitAddressNotify

刚创建 invoker 时,ZK 的 Watcher 还没回调,地址列表可能是空的。等一下让地址通知到达,才能做准确的阈值比较。

为什么有 force 参数?

紧急情况下(比如应用级发现出了 bug),运维想立刻切回接口级,不想等阈值检查。force=true 跳过所有安全检查。

refreshInterfaceInvoker(latch) --- 创建/刷新接口级 invoker

scss 复制代码
protected void refreshInterfaceInvoker(CountDownLatch latch) {
    clearListener(invoker);  // 清除旧的监听器

    if (needRefresh(invoker)) {  // invoker == null || 已销毁 || 没有地址
        if (invoker != null) invoker.destroy();
        // 调用 RegistryProtocol 创建新的接口级 invoker
        invoker = registryProtocol.getInvoker(cluster, registry, type, url);
    }

    // 设置地址变更监听器
    setListener(invoker, () -> {
        latch.countDown();  // 通知等待方"地址到了"
        if (step == APPLICATION_FIRST) {
            calcPreferredInvoker(rule);  // 重新评估用哪个 invoker
        }
    });
}

为什么用 CountDownLatch

migrateToForceApplicationInvoker 需要等待地址通知到达后才能做阈值比较。CountDownLatch 是一个"等 N 个事件发生"的同步工具。这里 N=1(等一次地址通知)。

calcPreferredInvoker(rule) --- APPLICATION_FIRST 模式下重新评估

typescript 复制代码
private synchronized void calcPreferredInvoker(MigrationRule migrationRule) {
    if (serviceDiscoveryInvoker == null || invoker == null) return;

    Set<MigrationAddressComparator> detectors = ...getSupportedExtensionInstances();
    if (detectors.allMatch(c -> c.shouldMigrate(serviceDiscoveryInvoker, invoker, migrationRule))) {
        this.currentAvailableInvoker = serviceDiscoveryInvoker;  // 应用级够了
    } else {
        this.currentAvailableInvoker = invoker;  // 不够,保持接口级
    }
}

为什么是 synchronized

地址通知可能从多个线程同时到达(接口级和应用级的 Watcher 回调)。synchronized 防止并发修改 currentAvailableInvoker 导致不一致。

2. MigrationRuleListener --- 配置监听器

职责: 监听配置中心的规则变更,管理所有 MigrationInvoker 的 MigrationRuleHandler。

属性详解

kotlin 复制代码
@Activate
public class MigrationRuleListener implements RegistryProtocolListener, ConfigurationListener {

核心状态

属性 类型 为什么需要
handlers ConcurrentMap<MigrationInvoker<?>, MigrationRuleHandler<?>> 核心映射表。每个 MigrationInvoker 对应一个 MigrationRuleHandler。规则变更时遍历这个 map,对每个 handler 执行迁移
rawRule String(volatile) 当前规则的原始 YAML 字符串。用于去重 --- 如果新规则和当前规则一样,跳过
rule MigrationRule(volatile) 当前规则的对象形式。由 rawRule 解析而来
ruleKey String 配置中心的 key。格式:应用名.migration,比如 demo-consumer.migration
configuration DynamicConfiguration 配置中心客户端。用于添加/移除监听器、获取配置

并发控制

属性 类型 为什么需要
ruleQueue LinkedBlockingQueue<String> 规则队列。配置中心可能快速推送多次规则,用队列缓冲,逐个处理
executorSubmit AtomicBoolean 确保只有一个线程在消费 ruleQueue。compareAndSet(false, true) 保证只提交一次任务
ruleManageExecutor ExecutorService(单线程) 消费 ruleQueue 的线程。单线程保证规则按顺序处理
ruleMigrationFuture Future<?> 消费线程的 Future。destroy 时需要 cancel
localRuleMigrationFuture ScheduledFuture<?> 本地规则延迟加载的 Future。destroy 时需要 cancel

依赖

属性 类型 为什么需要
moduleModel ModuleModel 模块模型。用于获取应用名、配置环境、动态配置等

核心方法

init() --- 初始化

scss 复制代码
private void init() {
    // 1. 构造配置 key
    this.ruleKey = moduleModel.getApplicationModel().getApplicationName() + ".migration";

    // 2. 获取配置中心客户端
    this.configuration = moduleModel.modelEnvironment().getDynamicConfiguration().orElse(null);

    if (this.configuration != null) {
        // 3. 添加监听器
        configuration.addListener(ruleKey, "DUBBO_SERVICEDISCOVERY_MIGRATION", this);

        // 4. 读取当前规则
        String rawRule = configuration.getConfig(ruleKey, "DUBBO_SERVICEDISCOVERY_MIGRATION");
        if (StringUtils.isEmpty(rawRule)) rawRule = INIT;
        setRawRule(rawRule);
    }

    // 5. 如果有本地规则文件,延迟加载(等配置中心的规则,60秒没来才用本地的)
    String localRawRule = moduleModel.modelEnvironment().getLocalMigrationRule();
    if (!StringUtils.isEmpty(localRawRule)) {
        localRuleMigrationFuture = schedule(() -> {
            if (this.rawRule.equals(INIT)) {
                this.process(new ConfigChangedEvent(null, null, localRawRule));
            }
        }, getDelay(), TimeUnit.MILLISECONDS);
    }
}

为什么有本地规则延迟加载?

场景:Consumer 启动时配置中心还没准备好(网络问题、配置中心重启)。等 60 秒(默认)后如果还没收到配置中心的规则,就用本地的规则文件。这保证了即使配置中心不可用,迁移也能正常进行。

process(ConfigChangedEvent) --- 配置变更回调

scss 复制代码
public synchronized void process(ConfigChangedEvent event) {
    String rawRule = event.getContent();
    if (StringUtils.isEmpty(rawRule)) rawRule = INIT;

    // 1. 放入队列
    ruleQueue.put(rawRule);

    // 2. 确保消费线程在运行
    if (executorSubmit.compareAndSet(false, true)) {
        ruleMigrationFuture = ruleManageExecutor.submit(() -> {
            while (true) {
                String rule = ruleQueue.take();  // 阻塞等待

                // 3. 去重
                if (Objects.equals(this.rawRule, rule)) continue;

                // 4. 更新规则
                setRawRule(rule);

                // 5. 并行执行所有 handler 的迁移
                ExecutorService executor = Executors.newFixedThreadPool(
                    Math.min(handlers.size(), 100));
                List<Future<?>> futures = new ArrayList<>();
                for (MigrationRuleHandler<?> handler : handlers.values()) {
                    futures.add(executor.submit(() -> handler.doMigrate(this.rule)));
                }

                // 6. 等待所有迁移完成
                for (Future<?> future : futures) {
                    future.get();
                }
                executor.shutdown();
            }
        });
    }
}

为什么用队列 + 单线程消费,而不是直接在回调里处理?

配置中心的回调可能从多个线程并发触发(Nacos 的长轮询线程、ZK 的 Watcher 线程)。用队列串行化处理,避免:

  1. 规则乱序(先到的规则后处理)
  2. 并发迁移(两个线程同时对同一个 invoker 执行迁移)

为什么迁移用线程池并行执行?

一个 Consumer 可能订阅了 100 个接口,每个接口有一个 MigrationInvoker。规则变更时需要对这 100 个 invoker 都执行迁移。串行执行太慢,并行执行可以大幅缩短迁移时间。

onRefer() --- Consumer 引用时调用

swift 复制代码
public void onRefer(RegistryProtocol registryProtocol, ClusterInvoker<?> invoker,
                    URL consumerUrl, URL registryURL) {
    // 为这个 MigrationInvoker 创建 Handler
    MigrationRuleHandler<?> handler = ConcurrentHashMapUtils.computeIfAbsent(
        handlers, (MigrationInvoker<?>) invoker,
        _key -> {
            ((MigrationInvoker<?>) invoker).setMigrationRuleListener(this);
            return new MigrationRuleHandler<>((MigrationInvoker<?>) invoker, consumerUrl);
        });

    // 立即用当前规则执行一次迁移
    migrationRuleHandler.doMigrate(rule);
}

为什么在 onRefer 时就执行迁移?

Consumer 启动时,每个接口的 ReferenceConfig 都会调用 RegistryProtocol.refer(),创建 MigrationInvoker。interceptInvoker() 会触发 MigrationRuleListener.onRefer()。这时需要用当前已有的规则(可能从配置中心读到的,也可能是默认规则)立即初始化 invoker 的状态。

3.ServiceDiscoveryRegistry-是关键的连接器

职责: 它实现了经典的 Registry 接口,但在内部委托给 ServiceDiscovery 实例,将接口级的订阅/注册调用转换为应用级的等价操作。

它解决的问题

php 复制代码
 Registry 接口, ServiceDiscovery 接口
ServiceDiscoveryRegistry 桥接了这两者:
  左边:实现 Registry 接口(让 Directory 能调用)
  右边:内部用 ServiceDiscovery(和 Nacos 通信)

谁干什么

scss 复制代码
ServiceDiscoveryRegistry(指挥官)
  │
  │  1. 调用 serviceNameMapping.getMapping(url)
  │     → 查元数据中心:哪个应用提供 DemoService?
  │     → 返回 ["UserService"]
  │
  │  2. 调用 serviceDiscovery.getInstances("UserService")
  │     → 让 ServiceDiscovery 去 Nacos 查实例
  │     → 返回 [192.168.1.1:20880, 192.168.1.2:20880]
  │
  │  3. 调用 serviceDiscovery.addServiceInstancesChangedListener(listener)
  │     → 让 ServiceDiscovery 监听 Nacos 实例变化
  │
  ▼
ServiceDiscovery
  │  负责和 Nacos/ZK 通信
  │  getInstances() → 查实例
  │  addServiceInstancesChangedListener() → 监听变化

ServiceDiscoveryRegistry 负责编排流程,ServiceDiscovery 负责和注册中心通信。

6.触发机制

6.1 启动时触发(onRefer)

Consumer 启动时,MigrationInvoker 刚创建出来是"空的"(currentAvailableInvoker == null),需要立刻初始化:

scss 复制代码
Consumer 启动
  → RegistryProtocol.refer()
    → 创建 MigrationInvoker(空的)
    → interceptInvoker()
      → MigrationRuleListener.onRefer()
        → 创建 MigrationRuleHandler
        → handler.doMigrate(rule)     ← 立刻用当前规则执行一次迁移
          → 创建接口级/应用级 invoker
          → 设置 currentAvailableInvoker
    → MigrationInvoker 现在有状态了,可以正常工作

为什么要立刻执行? 如果等到第一次 RPC 调用才初始化,第一次调用会很慢(要等创建 invoker + 订阅 ZK + 等待地址通知)。

6.2 规则变更时触发

scss 复制代码
运维在 Nacos 控制台修改规则
  → Nacos 推送变更通知
    → MigrationRuleListener.process(rawRule)
      → 放入 ruleQueue
      → 单线程消费:遍历所有 handler
        → MigrationRuleHandler.doMigrate(rule)
          → MigrationInvoker.migrateToXxx(rule)

6.3 地址变更时触发

ini 复制代码
Provider 上下线
  → ZK/Nacos Watcher 触发
    → RegistryDirectory.notify() 或 ServiceDiscoveryRegistryDirectory.notify()
      → 更新 Invoker 列表
      → InvokersChangedListener.onChange()
        → MigrationInvoker 标记 invokersChanged = true
        → APPLICATION_FIRST 模式下重新评估阈值
相关推荐
Ting.~5 小时前
在java中接入百度地图
java·开发语言·dubbo
大囚长1 天前
大模型服务端如何命中缓存
java·人工智能·缓存·dubbo
Jinkxs2 天前
Dubbo- 主流注册中心介绍:Zookeeper/Nacos/Eureka 适配思路
zookeeper·eureka·dubbo
心之伊始2 天前
Dubbo 3 Consumer 调用链路源码分析:从 Proxy 到 Cluster、Directory、Router、LoadBalance
java·微服务·dubbo·源码分析·服务治理
乐兮创想 小林3 天前
企业官网“被搜到“的工程拆解:SEO 设置、SEO 优化与 GEO 三层架构
dubbo·网站建设·北京网站建设公司
程序员皮皮林7 天前
Dubbo 的 SPI 和 JDK 的 SPI 有什么区别?
java·开发语言·dubbo
XWalnut8 天前
dubbo入门
dubbo