跳到正文

Dubbo 核心原理 ​

一句话:Dubbo 用「接口代理 + 二进制协议 + 长连接 + 注册中心」把远程调用做得像本地调用,换来低延迟和治理能力,代价是协议绑定、连接模型和重试策略都要理解清楚。

适用版本:Dubbo 3.2.x(dubbo-spring-boot-starter),注册中心 Nacos 2.x。Dubbo 2.7 与 3.x 的配置前缀和默认协议有差异,以官方文档为准。

为什么需要 ​

HTTP/JSON 的服务间调用够通用,但每次调用都要建连接、序列化文本、解析 JSON;内部高频调用时,这些开销占比很高。Dubbo 的选择是:

  • 二进制协议 + Hessian2/Protobuf 序列化,减少报文体积和解析成本。
  • 单一长连接 + 请求 ID 复用,减少连接建立。
  • 注册中心 + 路由 + 负载均衡 + 容错,把治理能力放进框架。

它不是「更快的 HTTP」,而是一套面向 Java 内部服务的 RPC 与治理体系。

核心机制 ​

一次调用的链路 ​

调用方侧的四层各司其职:Cluster 决定「调谁」,Protocol 决定「怎么表示」,Exchange 决定「响应配哪个请求」,Transport 决定「怎么走网络」;Provider 侧原路返回。

每一层都可以通过 SPI 替换,这是 Dubbo 灵活性的来源,也是排障复杂度的来源。

为什么是「URL 驱动」的 SPI ​

Dubbo 的所有配置最终都会汇聚成一个 URL(协议、地址、参数),扩展点通过 @Adaptive 从 URL 参数里选择实现。这样做的因果是:

  • 同一套代码可以按服务/方法配置不同协议、序列化、负载均衡。
  • 新增实现只需加 SPI 文件,不改框架代码。
  • 代价是调用链是「配置驱动」的,出问题时要看 URL 参数,而不是看一行行代码。

JDK SPI 会一次性实例化所有实现;Dubbo SPI 按 key 懒加载,并支持依赖注入和 Wrapper 包装。

为什么默认是单一长连接 ​

Dubbo 协议默认每个 Consumer 到每个 Provider 建一条共享长连接(connections=0),请求通过 ID 复用连接。好处是连接数少、握手成本低;代价是:

  • 大报文会占用连接,影响同连接上的其他请求(队头阻塞)。
  • 默认 payload 上限 8MB;超过会报错。
  • 高并发下单连接可能成为瓶颈,可以调大 connections 或换协议。

所以「Dubbo 适合小数据量高并发」不是空泛的宣传语,是连接模型的直接结果。

重试:retries=2 是「首次之外再试 2 次」 ​

Dubbo 默认 retries=2,含义是首次调用失败后最多再重试 2 次,总尝试次数 3 次。很多人把它误读成「总共 2 次」,导致超时预算算错。

  • 只对幂等操作开重试;非幂等写用 retries=0 或 Failfast。
  • 重试会放大下游流量:下游越慢,重试越多,越慢。
  • 超时预算要按「总尝试次数」算:timeout × (retries + 1)。

集群容错与 Merger 不是一回事 ​

策略行为适用
Failover(默认)失败换实例重试读、幂等写
Failfast失败立即抛非幂等写
Failsafe失败忽略日志、审计
Failback后台定时重试通知类最终一致
Forking并行调用取第一个成功低延迟读,浪费资源
Broadcast广播全部,全部成功才算成功缓存刷新

Merger 是「合并分组调用的返回结果」的 SPI,不是熔断器。Dubbo 本身没有内置熔断;熔断要用 Sentinel 适配或 Resilience4j。把 Merger 说成熔断是概念错误。

实战场景 ​

一、retries=2 被当成“总共 2 次”:一次 GC 变成三次扣减 ​

现象:账务服务一次 Full GC 停顿 1.2 秒,消费方报 TimeoutException,随后对账发现 9 笔重复扣减。代码里没有显式配置重试,用的是 Dubbo 默认值。

排查:默认重试是隐性行为,先确认“一次业务调用发出了几次请求”。

定位层取证据判定
实际尝试次数provider 侧访问日志按 traceId 统计同一方法的调用次数1 次业务调用对应 3 次服务端执行 → 首次 + retries=2
容错策略cluster 配置(默认 failover)Failover 失败换实例重试,默认开启
超时预算timeout 与总耗时关系:timeout × (retries + 1)timeout=1000 时最坏占 3 秒,上游早已超时
重试触发点是连接失败还是读超时读超时重试最危险:请求可能已经执行成功
服务端幂等是否有幂等键与去重表无唯一键即重复生效,与客户端配置无关

语义要背准:retries=2 是首次之外再试 2 次,总尝试 3 次。把它当总次数,超时预算和下游容量都会算错。

对症做法:

java
// 1. 写接口立即关闭重试,容错策略换成 Failfast
@DubboReference(timeout = 800, retries = 0, cluster = "failfast")
private AccountService accountService;

// 2. 读接口保留重试,但要限总预算并确认下游能承接放大流量
@DubboReference(timeout = 300, retries = 1, cluster = "failover")   // 最多 2 次
private StockService stockService;

// 3. 服务端幂等兜底:幂等键唯一索引,重复请求返回首次结果
public DeductResult deduct(DeductReq req) {        // req.requestId 必填
    return idempotency.runOnce(req.requestId(), () -> account.deduct(req));
}
properties
# 4. 上游超时链路要按“总尝试次数”复核
#    Gateway 超时 > timeout × (retries + 1) + 连接开销

非幂等写用 retries=0 或 Failfast;Failsafe(失败忽略)只适合日志、审计这类允许丢的调用,绝不能用在资金链路上。

复盘要点:框架默认值是事故的一部分,必须在 ADR 里显式写出“哪些接口允许重试、幂等键是什么”。判据可自动化:对写接口做一条断言测试——注入首次超时,断言服务端只执行一次;同时监控“同一 traceId 的服务端执行次数”,大于 1 即告警。

二、单长连接上的 10MB 报文:一个慢接口拖住全部调用 ​

现象:报表服务通过 Dubbo 返回 10MB 的明细列表,报 Data length too large: 10485760, max: 8388608。开发把 payload 调到 32MB 后能通了,但同集群的其他接口 P99 从 40ms 涨到 600ms,重启后好转。

排查:Dubbo 默认每个 Consumer 到每个 Provider 一条共享长连接,请求靠 ID 复用;大报文会占住这条连接。

定位层取证据判定
报文大小分布provider 出口字节数按方法统计;错误日志里的 Data length too large单个方法贡献绝大部分字节 → 大报文来源
连接模型connections 参数(默认 0 = 单连接共享)同一对端所有方法共用一条连接 → 队头阻塞
影响面变慢的是不是所有走同一 provider 的接口全变慢 → 连接层排队,不是各方法自己慢
序列化成本CPU profile 里 Hessian2 编解码占比大对象编解码在 provider 线程上执行
线程池dubbo.protocol.threads=200(默认)是否被打满线程被大请求长时间占用 → 拒绝/超时叠加

调大 payload 只是把“报错”换成“拖慢所有人”:8MB 的上限不是保守,而是单连接模型的自我保护。

对症做法:

java
// 1. 拆接口:列表只返回 ID + 摘要,明细分页或按需拉取
List<ReportHeader> queryHeaders(ReportQuery q);     // 小报文
Page<ReportLine> queryLines(long reportId, int page, int size);   // 每页 200 行

// 2. 大结果走对象存储,RPC 只传凭证与元数据
ExportTicket export(ExportReq req);   // provider 写 OSS,返回临时下载地址

// 3. 多连接分散压力(治标,仍不如拆报文)
@DubboReference(connections = 4, timeout = 2000)

// 4. 需要流式或跨语言时改用 Triple(gRPC 兼容),它的流式语义更合适
dubbo.protocol.name = tri

复盘要点:接口契约里要写“单次响应最大字节”并在集成测试里断言(例如超过 1MB 失败,经验值,需校准)。Dubbo 适合小数据量高并发是连接模型的直接结果,不是宣传语;把它当通用数据通道用,就会以全队头阻塞的形式付学费。

三、No provider available:地址找到了但不是你要的那份 ​

现象:新消费方上线时报 No provider available for the service com.example.StockService from registry nacos://...;provider 明确在运行,Nacos 控制台也能看到服务实例。

排查:Dubbo 的服务标识是“接口全限定名 + version + group”三元组,任一不同就是两个服务。

定位层取证据判定
服务标识provider 与 consumer 的 interface、version、group 逐项比对控制台里出现两个相似服务名即命中
注册中心视角QoS ls / 控制台服务列表按完整 key 搜provider 注册的是 1.0.0,consumer 要的是空版本
分组/环境隔离是否配了 group、namespace、dubbo.provider.filter=cluster环境路由把地址过滤成空
可用性标记provider 的 enabled、权重是否为 0权重 0 或下线状态在注册中心里“存在但不可用”
网络与端口consumer 到 provider 的 dubbo.protocol.port 连通性地址有但连不上,报的是超时而不是 No provider
启动顺序consumer 的 check 配置check=true 时 provider 未起会直接启动失败;check=false 把问题推到首次调用

本例命中第 1 行:provider 在 @DubboService(version = "1.0.0"),consumer 端 @DubboReference 没写 version,两边标识不同。

对症做法:

java
// 1. 版本与分组集中定义在共享常量,禁止两边手写字符串
public interface DubboMeta { String STOCK_VERSION = "1.0.0"; String STOCK_GROUP = "trade"; }

@DubboService(interfaceClass = StockService.class, version = DubboMeta.STOCK_VERSION, group = DubboMeta.STOCK_GROUP)
@DubboReference(version = DubboMeta.STOCK_VERSION, group = DubboMeta.STOCK_GROUP, timeout = 300, retries = 0)
properties
# 2. 核心链路的启动检查保留:宁可在启动阶段失败,不要把问题留到第一次调用
dubbo.consumer.check = true
# 只在“允许降级启动”的旁路消费者上设 false
bash
# 3. 上线自检:消费方启动时主动探测 provider 是否存在,失败即 readiness 不通过
telnet 127.0.0.1 22222   # QoS 控制台
ls       # 看 consumer 侧的 provider 地址数是否为 0

QoS 默认端口 22222 暴露的是服务与地址信息,生产要限制访问来源或关闭。

复盘要点:三元组不一致属于结构性问题,靠约定挡不住,要靠共享的接口契约包(常量 + 接口定义同源发布)。把“consumer 侧 provider 地址数为 0”做成启动失败条件,比在业务异常里发现它早几个小时;序列化白名单、优雅停机(dubbo.application.shutdown.wait 先摘地址)也属于同组前置检查,见 优雅上下线与启动检查。

最小上手 ​

依赖(Dubbo 3.2.x):

xml
<dependency>
  <groupId>org.apache.dubbo</groupId>
  <artifactId>dubbo-spring-boot-starter</artifactId>
  <version>3.2.10</version>
</dependency>
<dependency>
  <groupId>com.alibaba.nacos</groupId>
  <artifactId>nacos-client</artifactId>
  <version>2.3.2</version>
</dependency>
yaml
dubbo:
  application:
    name: stock-service
  registry:
    address: nacos://127.0.0.1:8848
  protocol:
    name: dubbo
    port: -1          # -1 表示自动选端口
java
@DubboService
public class StockServiceImpl implements StockService {
    public int get(String sku) { return 10; }
}

@DubboReference(timeout = 1000, retries = 0)
private StockService stockService;

验证:启动后在 QoS 控制台查看已暴露的服务:

bash
telnet 127.0.0.1 22222
# 进入后执行
ls

能看到 StockService 的 provider/consumer 信息即成功。QoS 默认端口 22222,生产要限制访问来源或关闭。

生产动作 ​

  • 超时:timeout=1000(默认 1s);按下游 P99 调整,重试时总预算乘 retries+1。
  • 重试:非幂等写 retries=0;读操作可保留默认 2,但要确认下游能承受放大流量。
  • 线程池:dubbo.protocol.threads=200(默认);线程池满表现为拒绝/超时,先看 provider 的线程池指标。
  • payload:默认 8MB;大报文改走对象存储或拆接口,不要盲目调大。
  • 启动检查:check=false 可避免启动时强依赖注册中心,但会把故障推迟到第一次调用;核心服务建议保留 check=true。
  • 优雅停机:开启 dubbo.application.shutdown.wait,先摘除地址再停进程,避免调用方打到正在关闭的实例。
  • 序列化安全:限制反序列化白名单,禁用不安全的 Hessian 类;跨语言优先 Triple/gRPC。
  • 排障:调用超时先看 provider 线程池与下游 RT;找不到 provider 看注册中心地址与 enabled;序列化错误看接口/DTO 版本是否一致。

边界 ​

  • 大报文/流式:单长连接 + 8MB payload 不适合;用 Triple/gRPC 流式或对象存储。
  • 跨语言:Dubbo 协议和 Hessian2 是 Java 生态强绑定;跨语言用 Triple(gRPC 兼容)。
  • 不是消息队列:Dubbo 是同步 RPC,不解决削峰、广播持久化、最终一致;这些用 MQ。
  • 注册中心不是唯一依赖:Dubbo 强依赖注册中心做地址发现;注册中心故障时,已缓存的地址仍可用,但新实例不可见。
  • 不要把所有治理都塞进框架:熔断、限流、灰度可以放 Sentinel/网关,避免 Dubbo 配置膨胀。

讲清楚 ​

Dubbo 的核心是「代理 + 集群 + 协议 + 传输」:调用接口时动态代理生成 Invocation,Cluster 从注册中心拿地址、按路由过滤、按负载均衡选一个,Protocol 用 Hessian2 序列化,Exchange 用请求 ID 匹配响应,Transport 走 Netty 长连接。默认单连接、小报文、高并发;大报文会队头阻塞,所以有 8MB payload 上限。retries=2 是首次之外再试 2 次,总尝试 3 次,非幂等写必须关掉。Merger 是合并分组结果,不是熔断;Dubbo 没有内置熔断,要用 Sentinel 或 Resilience4j。跨语言、流式场景改用 Triple/gRPC。

把一门技术讲透到既懂也会用