主题
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
# 只在“允许降级启动”的旁路消费者上设 falsebash
# 3. 上线自检:消费方启动时主动探测 provider 是否存在,失败即 readiness 不通过
telnet 127.0.0.1 22222 # QoS 控制台
ls # 看 consumer 侧的 provider 地址数是否为 0QoS 默认端口 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。