181|Dubbo 核心架构:服务暴露/引用、集群容错、路由、负载均衡和 SPI

微服务与服务治理约 17 分钟阅读9,480 字符
知识库文档 183 / 251

阅读时间:约 45 分钟 适用人群:Java 高级工程师、技术专家、架构师 前置知识:RPC 基础概念、Java SPI 机制、Spring 容器基础


一、为什么 Dubbo 是面试必问的微服务框架

在 Java 生态中,Dubbo 不是你"用过"的框架,而是你需要"理解"的框架。它和 Spring Cloud 一起构成了国内微服务架构的两大支柱,但 Dubbo 的设计哲学更贴近高性能 RPC 的本质——它不绑定 HTTP、不依赖 Servlet 容器、以协议和序列化可插拔为设计目标。

面试官问 Dubbo,不是在问"你会不会配置 XML",而是在问:

  • 你理解 RPC 框架从代理到网络传输的完整链路吗?
  • 你知道一个服务是如何从本地调用变成远程调用的吗?
  • 集群容错、路由、负载均衡这些概念在代码层面是怎么串联的?
  • 当一个框架设计了 300+ 个扩展点,它是如何在"稳定性"和"灵活性"之间取得平衡的?

这篇文章会从 Dubbo 的核心架构出发,逐层拆解服务暴露、服务引用、集群容错、路由和负载均衡的实现原理,并且以 SPI 扩展机制为线索串联整个体系。


二、Dubbo 分层架构总览

Dubbo 的整体架构可以概括为 三层 + 两大机制:

  • 三层:业务层(Business)、RPC 层(Remote Procedure Call)、Remoting 层(远程通信)
  • 两大机制:SPI 扩展机制(横切所有层)、服务治理机制(注册中心、配置中心、元数据中心)

文章配图
文章配图

2.1 三层架构详解

业务层(Business):面向用户的 API 层。开发者通过 @DubboService 暴露服务,通过 @DubboReference 引用服务。这一层负责将业务接口转换为 Dubbo 内部的 ServiceConfig 和 ReferenceConfig。

RPC 层(核心):这是 Dubbo 最复杂的部分,负责:

  • 代理(Proxy):生成服务的客户端代理和服务端 Invoker 包装
  • 注册中心(Registry):服务地址的注册与发现
  • 集群(Cluster):将多个服务提供者抽象为一个 Cluster Invoker,内部包含路由、负载均衡和容错
  • 协议(Protocol):服务暴露和引用的入口,管理 Exporter 和 Invoker 的生命周期
  • 监控(Monitor):调用次数、耗时等统计

Remoting 层:负责网络通信,包括:

  • 信息交换(Exchange):将请求/响应模型封装为 Request/Response 对象
  • 传输层(Transport):抽象了 Netty、Mina、Grizzly 等网络框架
  • 序列化(Serialize):Hessian2、Protobuf、Kryo、Fastjson2 等

2.2 核心领域模型

理解 Dubbo 源码,首先要理解这几个核心对象的关系:

对象 职责 生命周期
Invoker 可执行对象,封装了服务的地址和调用方法 调用期间
Invocation 一次调用的上下文,包含方法名、参数、返回值类型等 单次调用
Exporter 服务暴露后的持有对象,维护了 Invoker 与 URL 的映射 服务运行期间
URL Dubbo 的"配置总线",所有配置都以 URL 参数的形式传递 全局
Result RPC 调用的返回结果,包含返回值或异常 单次调用

其中 URL 是 Dubbo 最核心的设计——它不是 HTTP 的 URL,而是 Dubbo 内部的一个配置传递模型,格式为:protocol://host:port/path?key1=value1&key2=value2。Dubbo 的整个扩展点体系都依赖 URL 进行参数传递和配置决策。

2.3 Dubbo 调用链全景

一次完整的 Dubbo RPC 调用链路如下:

code
Consumer 端:
  Proxy(JDK/Javassist) → ClusterInvoker → Directory(Registry) → Router → LoadBalance
  → Filter Chain → Protocol(Invoker) → ExchangeClient → Transport(Netty) → Serialize

Provider 端:
  Transport(Netty) → Deserialize → ExchangeServer → Protocol(Exporter)
  → Filter Chain → Invoker(ServiceBean) → 实际业务实现

三、Dubbo SPI —— 框架的"灵魂插件"

Dubbo 的 SPI(Service Provider Interface)不是 JDK 原生的 SPI,而是 Dubbo 自研的增强版 SPI。它是理解 Dubbo 所有扩展机制的前提。

3.1 JDK SPI 的局限

JDK 原生 SPI 有三个核心问题 Dubbo 不能接受:

  1. 全量加载:ServiceLoader.load() 会实例化所有实现类,Dubbo 有 300+ 扩展点,全部加载不可接受
  2. 无扩展点命名:无法按名称获取特定实现,只能用 Iterator 遍历
  3. 无 AOP/IOC 能力:扩展点之间无法依赖注入,也无法做包装增强

3.2 Dubbo SPI 的核心设计

Dubbo SPI 通过 ExtensionLoader 实现,核心特性:

按需加载 + 缓存:

java
// 只加载指定名称的实现
Protocol protocol = ExtensionLoader
    .getExtensionLoader(Protocol.class)
    .getExtension("dubbo");

扩展点配置文件:META-INF/dubbo/internal/ 下的文件,格式为 name=full.class.name:

code
dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol
rest=org.apache.dubbo.rpc.protocol.rest.RestProtocol

自动包装(AOP):如果扩展实现类有一个构造函数接受同类型的参数,Dubbo 会自动将其包装:

java
public class ProtocolFilterWrapper implements Protocol {
    private final Protocol protocol;
    // Dubbo 自动将真正的 Protocol 实现注入
    public ProtocolFilterWrapper(Protocol protocol) {
        this.protocol = protocol;
    }
}

自动注入(IOC):通过 setter 方法自动注入其他扩展点:

java
public class DubboProtocol extends AbstractProtocol {
    // Dubbo 自动注入 ExchangeClient 的 Adaptive 扩展
    private ExchangeClient exchangeClient;
    public void setExchangeClient(ExchangeClient client) {
        this.exchangeClient = client;
    }
}

Adaptive 自适应扩展:@Adaptive 注解标记在类上或方法上,根据 URL 参数动态选择实现:

java
@SPI("dubbo")
public interface Protocol {
    @Adaptive
    <T> Exporter<T> export(Invoker<T> invoker) throws RpcException;
}

当调用 Protocol 的 export 方法时,Dubbo 会自动生成一个 Protocol$Adaptive 类,根据 invoker.getUrl().getProtocol() 的值动态选择对应的 Protocol 实现。

文章配图
文章配图

3.3 SPI 的加载流程

code
ExtensionLoader.getExtensionLoader(Protocol.class)
  → 扫描 META-INF/dubbo/internal/ 下的配置文件
  → 按 name 映射到对应的 Class
  → 创建实例(包装类自动注入)
  → 注入 setter 依赖(IOC)
  → 返回包装后的实例

3.4 SPI 是贯穿全篇的钥匙

理解 SPI 之后,再看 Dubbo 的任何模块都会发现同一个模式:接口定义 + @SPI 注解 + 多个实现 + Adaptive 动态选择。下面讲的服务暴露、集群容错、路由、负载均衡,全部基于 SPI 机制。


四、服务暴露:从本地 Bean 到网络端口

服务暴露是将本地 Spring Bean 注册到注册中心并绑定到网络端口的过程。Dubbo 的服务暴露分为两个阶段:本地暴露和远程暴露。

4.1 服务暴露的触发时机

在 Spring 容器中,ServiceBean 实现了 ApplicationListener<ContextRefreshedEvent>,当 Spring 容器刷新完成时触发 export()。如果你是 Dubbo 3.x 用户,@DubboService 注解的解析由 ServiceClassPostProcessor 在 Bean 注册阶段完成,但实际的暴露仍在容器刷新后。

4.2 暴露全流程

code
ServiceBean.export()
  → doExport() // 组装 URL:从 @DubboService 注解、XML 配置、系统属性等合并参数
  → doExportUrls() // 为每个注册中心和协议生成 URL
  → loadRegistries() // 获取注册中心地址列表
  → doExportUrlsFor1Protocol(protocolConfig)
    → 构建 URL,例如:dubbo://192.168.1.100:20880/com.example.HelloService?version=1.0.0&...
    → 本地暴露:protocol.export(invoker) // 默认 injvm 协议,同 JVM 走本地调用
    → 远程暴露:registryProtocol.export(invoker)
      → 注册中心注册:zkClient.create("/dubbo/com.example.HelloService/providers/...")
      → 协议暴露:DubboProtocol.export(invoker) // 开启 Netty Server 监听 20880

文章配图
文章配图

4.3 关键源码分析

DubboProtocol.export():

java
public <T> Exporter<T> export(Invoker<T> invoker) throws RpcException {
    URL url = invoker.getUrl();
    String key = serviceKey(url); // 例如:com.example.HelloService:20880
    // 创建 DubboExporter,持有 invoker 引用
    DubboExporter<T> exporter = new DubboExporter<>(invoker, key, exporterMap);
    exporterMap.put(key, exporter);
    // 开启 Netty Server(如果该端口还没有)
    openServer(url);
    return exporter;
}

openServer() 的逻辑:先检查 serverMap 中是否已有同 host:port 的 Server 实例,有则复用,无则创建。这就是为什么同端口可以暴露多个服务。

4.4 URL 的组装规则

URL 是 Dubbo 服务暴露和引用的配置核心。一个典型的 Dubbo URL 长这样:

code
dubbo://192.168.1.100:20880/com.example.HelloService
?anyhost=true
&application=demo-provider
&deprecated=false
&dubbo=2.0.2
&dynamic=true
&generic=false
&interface=com.example.HelloService
&methods=sayHello
&pid=12345
&timeout=3000
&version=1.0.0

URL 参数的来源优先级(从高到低):

  1. JVM 系统属性(-D 参数)
  2. 配置文件(dubbo.properties)
  3. @DubboService / @DubboReference 注解属性
  4. XML 配置(<dubbo:service>)
  5. @SPI 接口的默认值

五、服务引用:从接口代理到远程调用

服务引用是将远程服务包装成本地代理对象的过程。消费者调用 helloService.sayHello() 时,实际执行的是代理对象的 invoke 方法,最终通过网络发送 RPC 请求。

5.1 服务引用的触发时机

ReferenceBean 同样实现了 InitializingBean 和 FactoryBean。当 Spring 容器在注入 @DubboReference 属性时,会调用 ReferenceBean.getObject(),触发 init() → createProxy()。

5.2 引用全流程

code
ReferenceBean.getObject()
  → init()
    → createProxy(map)
      → 判断是本地引用(injvm)还是远程引用
      → 远程引用:protocol.refer(interfaceClass, url)
        → RegistryProtocol.refer()
          → 订阅注册中心:zkClient.subscribe("/dubbo/com.example.HelloService/providers")
          → 获取提供者地址列表 → 构建 Directory
          → Cluster.join(directory) // 将多个 Invoker 聚合成一个 ClusterInvoker
          → 返回代理对象:ProxyFactory.getProxy(clusterInvoker)

文章配图
文章配图

5.3 关键源码分析

RegistryProtocol.refer():

java
public <T> Invoker<T> refer(Class<T> type, URL url) throws RpcException {
    // 1. 获取注册中心实例
    Registry registry = getRegistry(url);
    // 2. 创建 RegistryDirectory,负责服务发现和地址列表维护
    RegistryDirectory<T> directory = new RegistryDirectory<>(type, url);
    directory.setRegistry(registry);
    directory.setProtocol(protocol);
    // 3. 订阅注册中心
    directory.subscribe(subscribeUrl.addParameter(CATEGORY_KEY,
        PROVIDERS_CATEGORY + "," + CONFIGURATORS_CATEGORY + "," + ROUTERS_CATEGORY));
    // 4. 将 Directory 包装为 ClusterInvoker
    return cluster.join(directory);
}

RegistryDirectory 是消费者端的核心组件,它:

  • 监听注册中心的服务提供者列表变化
  • 将每个提供者 URL 转换为一个 Invoker 对象
  • 维护一个 List<Invoker> 的实时视图
  • 当列表变化时,通知上层 Cluster 刷新

5.4 代理对象的创建

Dubbo 默认使用 Javassist 生成代理类。代理类大致长这样:

java
public class proxy0 implements HelloService {
    public String sayHello(String name) {


        Invocation inv = new RpcInvocation("sayHello", new Class[ ]{String.class}, new Object[ ]{name});


        Result result = handler.invoke(inv);
        return result.recreate();
    }
}

六、集群容错:当服务提供者不可用时

集群(Cluster)将多个服务提供者抽象为一个逻辑整体,对外暴露一个 ClusterInvoker。当消费者发起调用时,Cluster 内部经过路由、负载均衡选出目标 Invoker 执行,如果失败则根据容错策略处理。

6.1 集群组件架构

code
ClusterInvoker
  └── Directory(服务目录)
  └── Router(路由器)
  └── LoadBalance(负载均衡)

文章配图
文章配图

6.2 六大容错策略

Dubbo 内置了 6 种容错策略,通过 @SPI("failover") 标记默认策略:

策略 类名 行为 适用场景
Failover FailoverClusterInvoker 失败自动切换,重试其他提供者 幂等操作(默认)
Failfast FailfastClusterInvoker 快速失败,只发一次调用,失败立即抛异常 非幂等写入
Failsafe FailsafeClusterInvoker 失败安全,吞掉异常只打日志 日志写入、审计
Failback FailbackClusterInvoker 失败自动恢复,后台定时重试 消息通知
Forking ForkingClusterInvoker 并行调用多个提供者,取第一个成功返回 实时性要求高
Broadcast BroadcastClusterInvoker 广播调用所有提供者,逐个调用 通知所有提供者

6.3 FailoverClusterInvoker 源码解读

java
public Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) {
    List<Invoker<T>> copyInvokers = invokers;
    int len = getUrl().getMethodParameter(invocation.getMethodName(), RETRIES_KEY, 2) + 1;
    RpcException le = null;
    List<Invoker<T>> invoked = new ArrayList<>(len);
    for (int i = 0; i < len; i++) {
        // 重试前重新做路由和负载均衡选择
        Invoker<T> invoker = select(loadbalance, invocation, copyInvokers, invoked);
        invoked.add(invoker);
        try {
            Result result = invoker.invoke(invocation);
            return result;
        } catch (RpcException e) {
            le = e;
        }
        // 下一次重试时,从候选列表中移除已失败的
        copyInvokers = invokers;
        if (copyInvokers.size() == invoked.size()) {
            break; // 没有更多可用的提供者了
        }
    }
    throw le; // 全部失败,抛出最后一个异常
}

关键点:每次重试前都会重新执行路由和负载均衡,这保证了重试能选到不同的提供者。

6.4 集群容错的生产实践

重试次数设置:建议 retries=0 用于非幂等操作(如扣款),retries=2 用于幂等操作(如查询)。

实际场景:假设你有一个支付服务,3 个提供者。如果使用 failover + retries=2,第一次调用失败后会重试另一个提供者,但重试可能导致重复扣款。因此在 Dubbo 中,写操作应使用 failfast(retries=0),或者在服务端做幂等保证。


七、路由规则:谁可以调用谁

路由(Router)在负载均衡之前执行,负责从服务提供者列表中筛选出符合条件的子集。简单说:路由回答"哪些提供者可以调用",负载均衡回答"选哪个来调用"。

7.1 路由链

Dubbo 的路由是一个责任链,多个 Router 按优先级串联执行:

code
Invoker 列表
  → MockServiceRouter(Mock 路由,最高优先级)
  → TagRouter(标签路由,用于灰度发布)
  → AppRouter(应用级路由,基于应用名匹配)
  → ServiceRouter(服务级路由,基于服务名匹配)
  → ConditionRouter(条件路由)
  → MeshRouteRouter(Mesh 路由)
  → 筛选后的 Invoker 列表 → 负载均衡

文章配图
文章配图

7.2 条件路由(ConditionRouter)

条件路由是 Dubbo 最经典的动态路由方式,通过管理后台配置规则,无需重启应用。

规则格式:[服务消费者匹配条件] => [服务提供者匹配条件]

code
// 示例 1:强制灰度
host = 10.20.153.10 => host = 10.20.153.*

// 示例 2:读写分离
method = find*,list*,get*,query* => host = 172.22.3.91,172.22.3.92
method != find*,list*,get*,query* => host = 172.22.3.93

// 示例 3:黑白名单
host != 10.20.153.10 =>

Dubbo 会将条件路由规则解析为 MatchPair 对,消费端匹配条件(whenCondition)和提供端匹配条件(thenCondition)分别存储在 Map<String, MatchPair> 中。

7.3 标签路由(TagRouter)

标签路由是 Dubbo 2.7 引入的灰度发布利器。工作流程:

  1. 消费者通过 RpcContext 设置请求标签:RpcContext.getClientAttachment().setAttachment("dubbo.tag", "gray");
  2. TagRouter 在路由时,优先匹配携带同名标签的提供者
  3. 如果没有匹配到 Tag 提供者,根据 force 参数决定是降级到普通提供者还是直接报错
yaml
# 配置示例
tag-router:
  force: false  # force=false 时降级到普通提供者
  tags:
    - name: gray
      addresses: ["192.168.1.101:20880", "192.168.1.102:20880"]

7.4 路由失效的热更新

路由规则存储在注册中心(如 ZooKeeper 的 /dubbo/config/dubbo/ 路径下),RegistryDirectory 不仅监听 providers 节点,还监听 routers 和 configurators 节点。规则变更后,RouterChain 自动重建,秒级生效。


八、负载均衡:选哪个提供者来调用

经过路由筛选后,剩余的是一个或多个候选项。负载均衡负责从中选出一个 Invoker 执行调用。

8.1 五种负载均衡策略

Dubbo 通过 @SPI(RandomLoadBalance.NAME) 默认使用加权随机策略。

策略 类名 算法 核心思想
Random RandomLoadBalance 加权随机 权重越大,被选中的概率越大
RoundRobin RoundRobinLoadBalance 加权轮询 按权重比例平滑轮询,Nginx 同款算法
LeastActive LeastActiveLoadBalance 最小活跃数 优先选当前正在处理的请求数最少的提供者
ConsistentHash ConsistentHashLoadBalance 一致性哈希 相同参数的请求总是落到同一提供者
ShortestResponse ShortestResponseLoadBalance 最短响应时间 综合历史响应时间和当前活跃数

文章配图
文章配图

8.2 加权随机算法详解

java
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
    int totalWeight = 0;
    boolean sameWeight = true;
    // 1. 计算总权重
    for (int i = 0; i < invokers.size(); i++) {
        int weight = getWeight(invokers.get(i), invocation);
        totalWeight += weight;
        if (sameWeight && i > 0 && weight != getWeight(invokers.get(i-1), invocation)) {
            sameWeight = false;
        }
    }
    // 2. 随机偏移
    if (totalWeight > 0 && !sameWeight) {
        int offset = ThreadLocalRandom.current().nextInt(totalWeight);
        for (Invoker<T> invoker : invokers) {
            offset -= getWeight(invoker, invocation);
            if (offset < 0) {
                return invoker;
            }
        }
    }
    // 3. 权重相同则纯随机
    return invokers.get(ThreadLocalRandom.current().nextInt(invokers.size()));
}

权重计算支持预热(Warmup):服务刚启动时权重较低,逐步提升到配置值,避免刚启动的实例被大量流量打垮。

8.3 加权轮询(平滑加权轮询)

Dubbo 的 RoundRobin 实现的是 Nginx 同款平滑加权轮询算法。核心思想:每次选择时,每个提供者的 currentWeight 加上其 weight,选出 currentWeight 最大的,然后将其 currentWeight 减去总权重。

code
假设 A(weight=5), B(weight=1), C(weight=1)
第1轮: A+=5→5, B+=1→1, C+=1→1 → 选 A, A-=7→-2
第2轮: A+=5→3, B+=1→2, C+=1→2 → 选 A, A-=7→-4
第3轮: A+=5→1, B+=1→3, C+=1→3 → 选 B, B-=7→-4
...

这种算法保证了:权重高的节点被选中的次数按比例分配,但不会连续多次选中同一节点,避免负载尖刺。

8.4 一致性哈希的适用场景

一致性哈希适合有状态服务或需要缓存亲和性的场景。相同参数(或通过 hash.arguments 指定的参数)的请求总是路由到同一台机器:

java
// 配置:对方法 getOrder 的前 2 个参数做哈希
@DubboReference(loadbalance = "consistenthash", parameters = {"hash.arguments", "0,1"})
private OrderService orderService;

8.5 负载均衡与集群容错的协作

负载均衡不是独立工作的:

code
ClusterInvoker.invoke()
  → 获取 Directory 中的 Invoker 列表
  → RouterChain.route() 过滤
  → LoadBalance.select() 选中一个
  → 调用 invoke()
  → 如果失败:
    → Failover:重试,重新走 Router + LoadBalance
    → Failfast:直接抛异常
    → Failsafe:吞掉异常

九、一次 RPC 调用的完整链路

结合上面的所有模块,我们来看一次 Dubbo RPC 调用的完整路径:

文章配图
文章配图

消费者端(Consumer)

code
1. helloService.sayHello("world")
   → 代理对象 proxy0.sayHello("world")
   → 构造 RpcInvocation(方法名, 参数类型, 参数值)

2. ClusterInvoker.invoke(invocation)
   → 从 Directory 获取提供者列表
   → Router Chain 路由过滤
   → LoadBalance 选择目标 Invoker

3. 经过 Consumer Filter Chain
   → MonitorFilter 记录调用统计
   → 其他自定义 Filter

4. DubboInvoker.doInvoke()
   → 从连接池获取 ExchangeClient
   → 构造 Request(id, invocation)
   → 发送请求(Netty Channel.writeAndFlush)

5. 等待响应
   → DefaultFuture 阻塞等待(默认超时 1000ms)
   → 收到响应后唤醒

提供者端(Provider)

code
6. NettyServerHandler 收到请求
   → 反序列化 Request
   → 交给 ExchangeHandler 处理

7. 根据 serviceKey 从 exporterMap 中查找 DubboExporter
   → 获取服务端 Invoker

8. 经过 Provider Filter Chain
   → MonitorFilter, ExceptionFilter, 自定义 Filter

9. 反射调用实际业务方法
   → Method.invoke(serviceImpl, args)

10. 构造 Response(result)
    → 序列化响应
    → 通过 Channel 写回消费者

十、生产实践与常见陷阱

10.1 超时配置的"三把锁"

Dubbo 的超时可以配置在多个层级,优先级从高到低:

  1. 方法级:@DubboReference(timeout = 3000, parameters = {"sayHello.timeout", "2000"})
  2. 接口级:@DubboReference(timeout = 3000)
  3. 提供者端:@DubboService(timeout = 5000)(消费者优先,提供者兜底)

最佳实践:消费者端配置超时,提供者端也配置一个稍大的超时作为兜底。不要只依赖默认的 1000ms。

10.2 版本号与灰度发布

java
// 提供者
@DubboService(version = "2.0.0")
public class HelloServiceImplV2 implements HelloService {}

// 消费者
@DubboReference(version = "2.0.0")  // 只调用 2.0.0 版本
private HelloService helloService;

配合标签路由实现灰度:先让一小部分消费者打上 dubbo.tag=gray,路由到灰度版本的提供者,验证无误后逐步扩大范围。

10.3 多注册中心的隔离策略

code
# 消费者:只订阅但不注册
dubbo.registry.address=zookeeper://127.0.0.1:2181?register=false

# 提供者:只注册但不订阅
dubbo.registry.address=zookeeper://127.0.0.1:2181?subscribe=false

这在多机房部署时非常有用,消费者只订阅本地机房的注册中心,避免跨机房调用。

10.4 线程模型:到底是单连接还是连接池

Dubbo 默认使用单一长连接(同一个消费者-提供者对之间共享一个 TCP 连接),这是 Dubbo 高性能的关键设计之一。但这也意味着:

  • 如果提供者端业务处理慢,会阻塞该连接上的所有请求
  • 可以通过 connections 参数增加连接数:@DubboReference(connections = 3)
  • Dubbo 3.x 引入了 Triple 协议,支持 HTTP/2 多路复用,解决了单连接队头阻塞问题

10.5 异步调用

java
// 方式 1:CompletableFuture
CompletableFuture<String> future = RpcContext.getServiceContext().asyncCall(
    () -> helloService.sayHello("world")
);
future.whenComplete((result, ex) -> {
    // 处理结果
});

// 方式 2:返回值直接定义为 CompletableFuture
@DubboReference
private HelloService helloService; // 接口方法返回 CompletableFuture<String>

10.6 服务端线程池隔离

Dubbo 默认使用固定大小线程池(200 线程),所有服务共享。如果某个服务耗时长,会拖垮整个提供者:

xml
<!-- 为某个服务单独配置线程池 -->
<dubbo:service interface="com.example.SlowService" 
    executors="slowExecutor" />
<dubbo:service interface="com.example.FastService" 
    executors="fastExecutor" />

十一、面试官视角提问

请先自行思考,再参考答案。

Q1:Dubbo 的 SPI 和 JDK SPI 的核心区别是什么?为什么 Dubbo 要自己实现一套 SPI?

Q2:Dubbo 服务暴露的"本地暴露"和"远程暴露"有什么区别?什么场景下本地暴露会生效?

Q3:请描述从消费者调用 helloService.sayHello() 到提供者执行完返回结果的完整链路,包括线程切换和网络 IO。

Q4:Failover 策略下,重试时如何保证不会再次选到同一个失败的提供者?如果重试 2 次后仍然失败,Dubbo 会做什么?

Q5:条件路由和标签路由的设计目标有什么不同?什么场景下应该用条件路由,什么场景下用标签路由?

Q6:Dubbo 的加权随机负载均衡中,预热(Warmup)是如何实现的?如果不做预热,新启动的实例会有什么问题?

Q7:Dubbo 默认使用单长连接,这个设计有什么优缺点?Dubbo 3.x 的 Triple 协议是如何解决单连接队头阻塞的?

Q8:假设你有一个 Dubbo 服务,偶尔出现超时,但监控显示提供者 CPU 和内存都很正常。你会怎么排查?


参考答案

A1:三大区别:(1) JDK SPI 全量加载所有实现类,Dubbo SPI 按需加载 + 多级缓存;(2) JDK SPI 没有名称映射,Dubbo SPI 通过 name=class 格式支持按名称获取;(3) Dubbo SPI 支持 AOP(自动包装)和 IOC(setter 注入),这是 JDK SPI 不具备的。为什么自研:Dubbo 有 300+ 扩展点,全量加载不可接受;同时 Dubbo 需要根据 URL 参数动态选择扩展实现(Adaptive 机制),JDK SPI 做不到。

A2:本地暴露使用 injvm 协议,消费者和提供者在同一个 JVM 时生效。Dubbo 默认先本地暴露,如果消费者在同一个 JVM 内,调用会走 injvm 协议,跳过网络传输,直接调用本地方法。远程暴露绑定到网络端口(如 Netty Server 监听 20880)。判断本地引用:如果消费者引用的 URL 中有 scope=local 或在 RegistryDirectory 中发现提供者同 JVM,则走本地调用。

A3:消费者端:代理对象 → ClusterInvoker(路由 + 负载均衡选 Invoker)→ Consumer Filter Chain → DubboInvoker → ExchangeClient 从连接池获取连接 → Netty 通道发送请求 → DefaultFuture 阻塞等待。网络传输:Netty I/O 线程处理编解码。提供者端:Netty I/O 线程收到请求 → 反序列化 → 交业务线程池 → 从 exporterMap 查找 Exporter → Provider Filter Chain → 反射调用实际方法 → 构造 Response → 序列化 → 写回通道。关键线程切换:消费端 Netty I/O 线程 → 业务线程回调;提供端 Netty I/O 线程 → Dubbo 业务线程池。

A4:每次重试前会重新执行 select(loadbalance, invocation, copyInvokers, invoked),其中 invoked 参数会传入已尝试过的 Invoker 列表,select 方法内部会排除已调用的 Invoker。同时,copyInvokers 也会排除已调用项。如果全部提供者都失败,抛出最后一个 RpcException。注意:如果 invoked.size() == copyInvokers.size() 说明没有更多可用提供者,提前退出循环。

A5:条件路由是按属性匹配(如 IP、方法名、参数值),适合静态的黑白名单、读写分离。标签路由是按动态标签匹配,通过 RpcContext 传标签,适合灰度发布、A/B 测试。条件路由的规则在注册中心存储,修改后全局生效;标签路由由消费者主动设置标签,更灵活但需要业务代码配合。

A6:预热通过 getWeight() 方法实现。服务刚启动时,根据启动时间与当前时间的差值,计算一个预热权重:weight = (uptime / warmup) * configuredWeight。随着时间推移,权重从 0 线性增长到配置值。不做预热的问题:新实例刚启动,JIT 还未优化、连接池未建立,性能远低于稳定运行的实例,如果立即接收全量流量会导致超时和雪崩。

A7:优点:减少连接开销(TCP 握手、内存占用),适合服务提供者少的场景;缺点:单连接队头阻塞,一个慢请求阻塞后续所有请求。Dubbo 3.x Triple 协议基于 HTTP/2,支持 Stream 多路复用——在单个 TCP 连接上建立多个逻辑 Stream,各 Stream 独立传输,互不阻塞。同时 Triple 还支持 Server Push 和双向流。

A8:排查思路:(1) 确认是偶发还是持续——看监控的 P99/P999 分布;(2) 检查 GC 日志——是否有 STW 导致的暂停;(3) 检查 Dubbo 线程池——dubbo- 开头的线程是否全部繁忙;(4) 检查网络——ping 延迟、丢包率;(5) 检查下游依赖——数据库、缓存、其他 RPC 的耗时;(6) 检查 Dubbo 序列化——大对象序列化耗时;(7) 检查是否触发了 JIT 去优化。最可能的原因:Full GC 暂停、线程池耗尽、下游依赖慢。


总结

Dubbo 的核心架构是一座精心设计的"积木城堡"——SPI 是积木的接口标准,服务暴露/引用是城堡的城门,集群容错是守卫,路由是门禁,负载均衡是调度中心。每个模块既独立又通过 URL 和 SPI 紧密协作。

掌握 Dubbo 架构的关键不是记住每个类的名字,而是理解数据流(URL 如何传递配置)和调用链(一次 RPC 的完整路径)。当你能在脑海中画出从 @DubboReference 注解到 Netty 字节流的完整链路时,Dubbo 对你来说就不再是"黑盒框架"了。


本文基于 Dubbo 2.7.x / 3.x 源码分析,部分细节可能因版本不同有差异,请以实际使用的版本为准。