Dubbo调用链:代理、目录、重试与副作用各归其位
consumer调用一个接口方法,到provider执行并返回,中间经过代理、Invoker链、集群容错、目录路由与协议层。面试常把这条链压扁成"注册中心+RPC",于是答不出两个关键问题:谁决定重试、超时后业务事实归谁管。本页按调用链逐层拆开,把每个决定权放到具体源码位置。
版本固定 dubbo-3.2.15(官方 tag 2024-08-05 发布)。TCP连接与DNS解析的边界回查网络主题;截止时间预算、熔断限流的通用推导在超时重试、熔断限流降级,本页只讲Dubbo如何落地。注册与配置的控制面在Nacos注册发现与配置一致性。本页实际运行使用 org.apache.dubbo:dubbo:3.2.15,不是 Spring Boot starter 或 Nacos registry 组合。
可复制的直连配置与停止顺序
下面是没有注册中心的最小直连形态,适合先隔离协议、代理、序列化和 provider 生命周期;ReferenceConfig#setUrl 是显式 endpoint,不能被误读成目录发现已经验证。生产配置通常由 DubboBootstrap、注册中心和配置中心装配,需另行验证兼容矩阵。
ApplicationConfig app = new ApplicationConfig("ledger-direct");
ProtocolConfig protocol = new ProtocolConfig("dubbo", 20880);
protocol.setHost("127.0.0.1");
protocol.setRegister(false);
ServiceConfig<LedgerService> service = new ServiceConfig<>();
service.setApplication(app);
service.setProtocol(protocol);
service.setInterface(LedgerService.class);
service.setRef(new LedgerServiceImpl());
service.export();
ReferenceConfig<LedgerService> reference = new ReferenceConfig<>();
reference.setApplication(app);
reference.setInterface(LedgerService.class);
reference.setUrl("dubbo://127.0.0.1:20880/" + LedgerService.class.getName());
reference.setTimeout(800); // 毫秒;每次等待上限,不是端到端预算
reference.setRetries(1); // 总尝试次数为 2,是否安全取决于业务
LedgerService consumer = reference.get();协议端口、bind.ip、序列化实现、线程池与 QoS 都是运行时配置;单位和作用域必须与版本文档核对。timeout=800 不会撤销 provider 已提交的事务;retries=1 也不等于最多一个业务效果。对于写操作,operation_id 去重、查询 UNKNOWN 和重试责任必须在业务协议中显式存在。停机顺序是入口摘流/标记不接新请求 → 让目录和连接池传播 → provider 拒绝新请求并继续处理已接收请求 → 有界排空 → ReferenceConfig.destroy() 与 ServiceConfig.unexport();进程退出不是在途归零的证据。
一次调用从代理进入Invoker链
consumer拿到的是接口代理,不能一概称为 JDK 动态代理:3.2.15 的 ProxyFactory SPI 默认是 javassist,也提供 jdk 等实现。代理方法调用不直接发网络请求:InvokerInvocationHandler.invoke先把方法、参数类型与参数组装成RpcInvocation,再交给InvocationUtil.invoke(invoker, rpcInvocation)进入Invoker链。Invoker链上依次是各Filter、集群策略、目录与路由/负载均衡,最后由协议层真正产生网络调用。Object方法(toString/equals/hashCode)在进入RPC前被拦截,$destroy触发销毁。
原码:代理组装Invocation后交给Invoker链
原码连续摘录(仅统一公共缩进,截取方法局部):InvokerInvocationHandler.java,dubbo-3.2.15,L69–L82。
RpcInvocation rpcInvocation = new RpcInvocation(
serviceModel,
method.getName(),
invoker.getInterface().getName(),
protocolServiceKey,
method.getParameterTypes(),
args);
if (serviceModel instanceof ConsumerModel) {
rpcInvocation.put(Constants.CONSUMER_MODEL, serviceModel);
rpcInvocation.put(Constants.METHOD_MODEL, ((ConsumerModel) serviceModel).getMethodModel(method));
}
return InvocationUtil.invoke(invoker, rpcInvocation);
}注册中心在这条链里只提供地址与元数据:订阅通知到达后RegistryDirectory.notify刷新地址列表并重建Invoker(refreshOverrideAndInvoker),业务调用本身按本地目录选择provider直连,不经过注册中心转发。因此"注册中心挂了业务还能不能调"的答案是:已建立目录的调用可以继续,代价是目录停止更新,边界见Nacos篇。来源:RegistryDirectory。
路由先筛候选,负载均衡再选实例
假设 consumer 已订阅 org.apache.dubbo.demo.DemoService,目录有 A=30.5.120.37:20880、B=30.5.120.38:20880。下列条件路由规则针对该服务的 routeMethod1 只保留 A;其他方法不匹配 when,保持原候选。它是配置中心规则内容,不是放在应用 classpath 就会生效的本地文件:
scope: service
force: true
runtime: true
enabled: true
priority: 1
key: org.apache.dubbo.demo.DemoService
conditions:
- 'method=routeMethod1 => address=30.5.120.37:20880'发布前要确认应用所用配置中心、服务唯一键和权限;3.2.15 服务路由监听的规则键及 group 见 ServiceStateRouter 与 ListenableStateRouter。规则格式参考固定版本上游测试,该测试被禁用,本例和配置中心发布均未实跑。不要把 ZooKeeper 的路径直接套给 Nacos。
当 A 已从该 consumer 的路由输入候选中移除且 B 仍在时:非空 then 的匹配结果为空,force=true 返回空候选并失败;force=false 回退原候选,可能转到 B,因此跨机房、合规隔离等不能只依赖非强制规则。A 进程下线不代表目录已传播删除;若旧候选仍含 A,规则仍可能选 A,后续连接或调用失败。特殊反例:then 为空或 false 是显式禁止,3.2.15 直接返回空列表,force=false 也不回退。条件不匹配时则保留原列表;规则执行异常时也有回退分支,详见 ConditionStateRouter。排障先比对订阅实例、规则键、规则版本、过滤前后候选,再检查实际方法与地址;修正规则后重新调用并核查目标,而不是仅看注册中心“在线”。
过滤后才选实例。3.2.15 默认 random,候选相同时按有效权重随机;例如未命中上述规则的其他方法,最终候选为 A 和 B,权重分别100、300,已完成预热且无粘滞或重试干扰时,长期期望分别约25%、75%,不是每四次固定各命中一次。可在创建 reference 之后、调用 reference.get() 之前显式设置 reference.setLoadbalance("roundrobin"),按权重平滑轮询;provider 可设 service.setWeight(100)。leastactive 观察的是消费者侧 JVM 按 provider URL 与方法维护的活跃调用数,相同键可在该 JVM 内共享,并非服务端全局负载(见 RpcStatus)。默认权重100,预热期会影响有效权重;更改权重后需用分实例请求数及窗口复验。随机算法在所有有效权重为零时仍会均匀选一个,因此零权重不是摘流保证。源码见 负载均衡选择、预热权重、随机分支 与 leastactive。这些是源码推导,本次单地址实验未验证多节点分布。
连接与序列化:先对齐协议,再核实运行结果
下列配置可与前面的原生 ServiceConfig/ReferenceConfig 示例组合,均须在 export/get 之前设置;完整可运行项目与依赖见 实验入口:
protocol.setSerialization("hessian2"); // Dubbo 协议;两端须具备兼容编解码器
reference.setConnections(2); // 每个 provider 上该引用对应的独享客户端数
reference.setLoadbalance("roundrobin");
reference.setTimeout(800); // 每次 timeout,单位毫秒
reference.setRetries(0); // 写方法示例:禁用框架额外重试这是配置示例,不是本票多连接或负载均衡实测。对 Dubbo 协议,connections 未设或为0走按目标地址共享的 client;shareconnections 缺省1。显式2为该引用对应的协议 invoker 创建两个独享 client,多连接调用时轮选;一个 TCP 连接可承载并发 RPC,不等于一个请求一个连接。见 DubboProtocol 和 DubboInvoker。连接增多会增加文件描述符、内存与服务端压力;若一个 consumer 进程只有该服务的一个引用,连20个 provider 且每处2条连接都已建立,规划量为40条;多个独立引用或进程会增加连接,惰性连接或尚未建立则可能更少。诊断应同时看连接状态、活跃请求、排队与 provider 饱和度,不能只盲目加连接。
3.2.15 remoting 的默认序列化选择是 hessian2,系统属性或环境变量 DUBBO_DEFAULT_SERIALIZATION 可覆盖;不能推广为所有协议的默认值。源码 与 Hessian2 实现 分别说明默认选择和依赖。发布前要检查两端 serializer 可用、DTO 的类型和字段兼容,并用旧客户端/新服务端及反向组合试读写;未知序列化 ID 或未获允许的类型可能在解码时失败,见 CodecSupport。不要为消除错误盲目关闭反序列化检查:先确认有效配置、拒绝日志与可信类型范围,再按应用管理允许列表并复验;3.2.15 共享安全管理器可能受多个应用配置影响,不应假设所有序列化器及 Triple 的检查完全相同。
降级只返回有业务语义的结果
假设将查询单独定义为 ReadService 接口,并为它创建独立的 ReferenceConfig<ReadService> readReference;在 readReference.get() 之前设置 readReference.setMock("fail:return null")。设计意图是在符合条件的非业务 RPC 异常后使用 mock,调用者必须把 null 解释为“暂无可用数据”,并记录降级、延迟和恢复指标。不能把 null 当扣款成功,也不应用于把未知写结果伪装成成功。源码的 MockClusterInvoker 区分 force 和 fail:force 进入 mock 选择分支,fail 先调用真实 invoker,仅符合条件的非业务 RPC 异常才进入选择;标为业务异常的 RpcException 会直接抛出,普通业务异常结果不满足该分支,mock 自身也可能失败。return 值必须与方法返回类型匹配。
直连反例:本票同 JVM 的单地址直连实验对独立只读接口设置 force:return null,却实际收到 read:force 且 provider 计数为1;实验未直接记录 force 分支或选中 invoker。按源码推导,3.2.15 单地址引用构建的 StaticDirectory 在无路由链时返回候选;mock 选择 若拿到非空列表,会调用其中首个 invoker,而非必然创建本地 MockInvoker。因此 force 不能保证没有访问 provider;候选为空或使用其他目录时可能不同。本票未运行注册中心下的 fail/force mock 成功路径。上线前必须用目标目录与配置分别测试原调用失败、实际 mock 返回、业务异常及恢复;不要把 mock 成功当下游恢复。
如果网关、Feign 调用层和 Dubbo 分别配置两次总尝试,最坏一次入口可产生 2×2×2=8 次 Dubbo 出站尝试;这是各层确实嵌套并且每次都触发重试时的上界,不是必然发生。应选一个拥有业务语义的重试责任层,其他层禁用额外重试,贯穿 deadline、operation_id 与查询恢复;Cloud/Feign 的实际重试链及其配置见 Feign 与韧性适配。
Failover重试前重新取目录
基线FailoverClusterInvoker。doInvoke计算调用次数(retries+1),循环选择Invoker;重试时重新获取候选目录,业务异常分支直接抛出,部分非业务异常进入后续尝试。教学改写:
times = calculateInvokeTimes(method)
for attempt in times:
非首次:检查集群状态并重新list
选择候选Invoker,记录已调用实例
try invokeWithContext
catch 业务异常:直接抛
catch 可处理调用异常:记录,继续
最终抛包含尝试信息的调用错误这里解释的是该类的策略骨架,不能据此宣称所有异常都可安全重试。异常归类只决定框架分支,不证明业务没执行。
原码:重试前重新取目录并选择Invoker
原码连续摘录(仅统一公共缩进,截取方法局部):FailoverClusterInvoker.java,dubbo-3.2.15,L63–L82。
int len = calculateInvokeTimes(methodName);
// retry loop.
RpcException le = null; // last exception.
List<Invoker<T>> invoked = new ArrayList<>(copyInvokers.size()); // invoked invokers.
Set<String> providers = new HashSet<>(len);
for (int i = 0; i < len; i++) {
// Reselect before retry to avoid a change of candidate `invokers`.
// NOTE: if `invokers` changed, then `invoked` also lose accuracy.
if (i > 0) {
checkWhetherDestroyed();
copyInvokers = list(invocation);
// check again
checkInvokers(copyInvokers, invocation);
}
Invoker<T> invoker = select(loadbalance, invocation, copyInvokers, invoked);
invoked.add(invoker);
RpcContext.getServiceContext().setInvokers((List) invoked);
boolean success = false;
try {
Result result = invokeWithContext(invoker, invocation);len来自calculateInvokeTimes;每次尝试都选择Invoker,第二次起重新list并检查候选列表,避免沿用过期目录。invoked记录已尝试提供者,让负载均衡尽量换节点;但注释明确说目录变化后invoked也失去准确性——换节点解决的是“这台不可达”,不解决“第一次已提交,只是响应丢失”。幂等与未知结果查询仍需业务协议。
每次尝试的超时值在发送前检查
协议层DubboInvoker.doInvoke在真正发送前用RpcUtils.calculateTimeout计算本次请求可用timeout;算出来小于等于0时不再发起调用,直接以TIMEOUT_TERMINATE返回"No time left for making the following call... terminate directly"。
原码:预算不足直接终止,不发起调用
原码连续摘录(仅统一公共缩进,截取方法局部):DubboInvoker.java,dubbo-3.2.15,L106–L114。
int timeout = RpcUtils.calculateTimeout(getUrl(), invocation, methodName, DEFAULT_TIMEOUT);
if (timeout <= 0) {
return AsyncRpcResult.newDefaultAsyncResult(
new RpcException(
RpcException.TIMEOUT_TERMINATE,
"No time left for making the following call: " + invocation.getServiceName() + "."
+ RpcUtils.getMethodName(invocation) + ", terminate directly."),
invocation);
}这是3.2.15发送前的非正 timeout 守门。只有调用上下文存在倒计时,calculateTimeout 才读取剩余时间;否则每次取方法配置的 timeout,不能假定所有重试共享总 deadline。固定版本源码 的 Failover 循环本身也没有退避。此检查不验证剩余时间是否足够完成一次调用,不防在途堆积,也不改变此前调用的 UNKNOWN 结果。总预算如何在调用树中逐层扣除,见调用可靠性的独立预算模型。
超时结束的是本地等待,不是对端事务
时间线:t0发扣款;t80 provider提交;t100 consumer超时;t120第一次响应到达但已无等待者。若t101改到另一provider重新扣款,无业务幂等就重复。把retries设为0可降低框架层重复调用,但消除不了客户端、网关或人工操作的重试,业务最终仍需稳定键与查询恢复。
同步超时、异步结果终结、网络取消和业务事务回滚是四件事,不能画成同一个箭头。Dubbo的本地超时由DefaultFuture完成(源码见调用可靠性),它不会通过时间倒流撤销provider已提交的数据库事务。取消要成为业务协议:带operation_id,服务端在效果提交点原子检查取消/版本;若扣款已成功,取消只能发起退款等后续动作。通用UNKNOWN恢复与Outbox回查业务幂等与跨资源事务。
线程池、隔离与优雅下线
I/O事件循环若执行阻塞SQL或长业务,会影响同连接或同loop上的其他请求。将业务交给线程池需要有界队列、拒绝语义、上下文传递及排队预算;仅增加worker可能把压力转嫁给数据库连接池。隔离按依赖/业务优先级划分资源,A的慢调用不应占满B的全部线程。
优雅下线先停止接收新流量或摘除实例,给目录传播和在途处理留窗口,等待有界期限后关闭资源;consumer缓存可能尚未更新,因此provider也要能够拒绝新请求并返回可判定状态。幂等写在最后关闭前仍需持久化恢复信息。下线“完成”的证据是目录版本更新加在途归零,不是进程退出。
验证:真实直连实验、模型与未运行边界
U41 实验的入口是 python3 labs/dubbo/run.py。它使用本机 Maven 缓存中的 Dubbo 3.2.15,在同一个 JVM 内启动 loopback provider/consumer,不启动 ZooKeeper/Nacos。运行证据见JSON:代理调用返回 echo:hello;charge(op-1,1100ms) 配置 consumer 800ms timeout,provider 收到两次调用但 operationId map 只有一个业务效果;证据未捕获触发重试的异常类型;标记 provider drain 后 echo 和 charge 被拒;unexport 后调用失败,销毁引用和撤销导出正常返回。JDK 21 运行需要显式 --add-opens 以满足 3.2.15 Hessian 对 JDK 内部类型的反射限制,这个启动参数是实验环境约束,不是生产建议。
随后在另一个回环端口重新导出新 provider,以新的显式 URL 成功调用,证明该条件下手动切换地址可恢复;这不是注册中心自动发现或原连接自动恢复。实验只证明同机直连、真实 Dubbo 编码/连接、重试和有限的 provider 生命周期;没有证明注册中心目录推送、Nacos/Dubbo 集群分区、真实丢响应、跨主机网络、TLS、生产容量或业务数据库提交。实验中 calls=2/effects=1 是合成 provider 的去重结果,不是 Dubbo 自动幂等保证。
官方模块逐项去向
Dubbo 3.2.15 tag 的 Git 树含 130 个 pom.xml 路径(包含根 POM、聚合器和示例),按功能处置如下;目录数量不是独立模块数量,模块去向也不代表逐类源码已审读:
| 官方模块组 | 本票去向 |
|---|---|
dubbo-rpc/*、dubbo-cluster、dubbo-common | U41 主路径:代理、Invoker、Failover、Dubbo 协议编码、timeout 与目录选择;injvm、REST、Triple 不由直连实验覆盖。 |
dubbo-config/dubbo-config-api、dubbo-config-spring、dubbo-spring-boot/* | 配置装配与 Spring Boot 适配为范围扩展;直连实验只运行原生 Config API,不宣称 Spring Boot starter 集成。 |
dubbo-registry/dubbo-registry-api、dubbo-registry-nacos、dubbo-registry-zookeeper、dubbo-registry-multicast、dubbo-registry-multiple | 控制面目录/多注册中心差异纳入正文概念;真实 registry 与分区恢复未运行,Nacos/ZK 机制回指 U40/U39。 |
dubbo-remoting/*、dubbo-serialization/*、dubbo-container/* | TCP/Netty/序列化/容器边界按本票调用链说明;HTTP/REST、Triple、Hessian/Fastjson/JDK 全组合未运行,实验只使用发行包默认协议。 |
dubbo-configcenter/*、dubbo-metadata/*、dubbo-metrics/*、dubbo-monitor/* | ConfigCenter/Metadata/指标与监控归配置、观测兄弟票;只解释元数据不等于业务目录,未运行 Nacos/Apollo/Redis/ZK report 或 Prometheus。 |
dubbo-filter/*、dubbo-plugin/dubbo-auth、dubbo-plugin/dubbo-qos*、dubbo-plugin/dubbo-security、dubbo-plugin/dubbo-spring-security、dubbo-plugin/dubbo-reactive | 过滤器、认证、安全、QoS/Reactive 是 E/平台扩展;本票只保留调用顺序和边界,不以默认依赖宣称安全或响应式集成完成。 |
dubbo-serialization/*、dubbo-compiler、dubbo-maven-plugin、dubbo-compatible | 编码实现、代理编译与兼容插件按版本扩展登记;实验只证明当前接口可编码,未逐实现做性能/兼容矩阵。 |
根 pom.xml、dubbo-demo/*、dubbo-test/*、dubbo-build-tools、dubbo-dependencies*、dubbo-distribution/*、dubbo-native*、dubbo-container | 聚合、示例、上游测试、构建 BOM、发行组装和 Native 工具为资产/扩展 X;本票自有实验才是运行证据。 |
官方 tag 的模块完整度已登记;纳入知识点的讲清率和实验运行状态仍分开,未运行项不计通过。
验证:mock推演与预算模型
真实Dubbo集群与网络故障本轮未运行。隔离mock provider的实验设计:provider处理先提交operation_id再sleep超过consumer timeout,预期consumer超时但业务存在;以同operation_id重试应返回原结果,不产生第二次效果。再把provider设为慢且不报错,对比只有QPS限流与并发上限时的在途数。缺少provider环境时记录预期,不伪造压测曲线。
截止时间预算的确定性模型已在本仓库运行:
python3 labs/middleware-senior/reliability_lab.py budget --json
python3 labs/middleware-senior/reliability_lab.py budget --first-attempt-ms 600 --json输入总800ms、固定成本100ms、每次尝试150ms、退避80ms时,逐步扣减后三次尝试剩余550/320/90;第一次耗时改为600ms后,第二次尝试因剩余不足被拒绝(INSUFFICIENT_DEADLINE)。这是独立模型的完整尝试预算检查,强于 DubboInvoker 对非正 timeout 的检查,不能当作框架实测。模型推导与完整断言见调用可靠性与实验入口。
架构师变化条件
改为不可逆外部支付时,比较同步RPC、异步业务单与查询恢复:同步重试在UNKNOWN下可能重复,异步单把重试责任收敛到拥有业务语义的一层。多租户热点下设置租户配额与关键方法隔离,给出失败时如何保住基础流量。重试、连接数和线程数都属于负载放大因素,治理沿完整依赖链设计;扩容解决容量不足,解决不了重复副作用。
闭卷验收
本页 15 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。