前阵子刚把公司核心链路从单机房迁到同城双机房本以为就是改改注册中心地址、扩一批机器的事结果上线当晚监控就开始飘红java.net.SocketTimeoutException: Read timed out和No provider available from registry交替出现。更尴尬的是打开Nacos控制台服务明明在线每个机房都有节点可消费者就是时而报超时、时而报没有提供者。这问题看着像网络抖动实际根源卡在 Dubbo 集群对多机房拓扑毫无感知。默认的随机负载均衡、默认的重试策略、默认的路由规则全是在“单机房无脑可用”的前提下设计的一旦跨机房RPC 耗时、可用性、容错方式全都会变化。这篇文章就是针对 Dubbo 多机房部署场景把超时异常和无提供者异常的成因拆开再给出通过集群扩展自定义 LoadBalance、Router、Cluster让 Dubbo 适配多机房的完整方案。内容适合正在做多机房改造、被跨机房调用问题折磨过的 Java 服务端同学。读完你至少能明白两类异常到底是谁触发的timeout 该怎么算以及怎么用最少的代码让 Dubbo 具备“同机房优先、跨机房兜底”的能力。1. 多机房部署下两类异常的真实成因1.1 一次跨机房RPC调用比想象中多花多少时间很多人对超时的第一反应是“接口慢了”但在多机房场景里慢的不一定是接口本身。一次 Dubbo 调用在网络上要经历这些阶段consumer 通过 Netty 发请求走 TCP 连接经过交换机、防火墙、光缆传输到达 provider 所在机房的机器处理完再原路返回。单这一趟同机房可能只要 0.2ms跨同城机房通常 2~10ms跨地域或跨海那就在 30ms 以上了。我按不同部署模式整理过一份耗时参考耗时来源同机房同城跨机房异地跨机房网络 RTT0.1~0.5ms1~10ms30~200ms序列化/反序列化与数据量成正比同上但受带宽影响明显放大业务处理耗时不变不变不变排队等待线程池饱和时低中高也就是说跨机房调用天然要比同机房多出几毫秒到几十毫秒的固定成本。如果业务接口本身 P99 是 500ms你在消费端把 timeout 写死成 300ms那跨机房时就必然超时。这类超时并不是服务挂了而是你的超时预算压根没覆盖掉网络开销。1.2 No provider异常为什么“凭空出现”无提供者异常比超时更让人头皮发麻因为它在 Nacos 上明明能看到服务节点。No provider available from registry这个报错从字面理解是“注册中心里没有可用的提供者”。但实际触发它的情况可以五花八门consumer 连接的 namespace 和 provider 不在一个 namespace。consumer 订阅的 group 和 provider 注册的 group 不一致。provider 进程起了但注册动作还没完成consumer 抢先订阅。Nacos 的健康检查还没来得及把不健康节点摘掉consumer 本地缓存里已经全是死节点。路由规则把 provider 全过滤了剩下的 invoker 列表为空。这才是多机房场景最坑的地方网络抖动会让 Nacos 的临时实例被标记不健康如果 consumer 收到的推送不及时本地还拿着旧列表去调用报的就是 No provider如果路由规则里写死了某个机房的 IP 段而那个机房刚好缩容也会报 No provider。所以排查这两类异常不能只盯着业务代码要把网络链路、注册中心数据、路由结果、集群容错策略全部串起来看。2. 超时设置不是拍脑袋参数计算与配置落地2.1 搞懂timeout的优先级和覆盖关系Dubbo 的 timeout 配置层级很容易让人迷糊我直接说结论。消费端配置优先级高于提供端。也就是说consumer 那边配了 timeout就以 consumer 的为准没配才回落 provider。方法级配置高于接口级接口级高于全局默认。全局默认值在最新版本里是 1000ms。用 XML 举例优先级从高到低是这样!-- 方法级最精确 -- dubbo:reference iduserService interfacecom.example.UserService dubbo:method namegetUserById timeout2000 retries0/ /dubbo:reference !-- 接口级 -- dubbo:reference iduserService interfacecom.example.UserService timeout3000/ !-- 全局兜底 -- dubbo:consumer timeout5000/在多机房场景里我强烈建议不要在 provider 端裸奔设 timeout。因为 provider 根本不知道调用方在哪个机房只有当所有 consumer 都没有配置时provider 的配置才会生效。正确的做法是超时配置放消费端并且结合下方说的耗时口径来算。2.2 用P99的测量结果倒推合理timeout真实项目中timeout 是不能靠“感觉”设的。我的习惯是先拿到接口的耗时分布再按公式推。公式很简单timeout P99 业务耗时 网络RTT预估 序列化传输耗时 重试预算举个例子。你调用getUserById单机压测下 P99 是 450msconsumer 和 provider 分布在同城两个机房网络 RTT 按 5ms 算传输一个 2KB 的对象基本可忽略。那么 timeout 至少是 450 5 余量我一般会再乘 1.5~2 倍也就是 700~1000ms。如果这个接口还会被做成失败重试那情况就完全不同了。Dubbo 默认retries2意思是第一次失败后会再重试 2 次总共最多执行 3 次。此时对于不幂等的写接口超时后重试可能造成重复下单、重复扣款。所以我一般在消费端把写接口的 retries 直接设成 0读接口看情况保留一次重试但 timeout 相应放大。还有一个小技巧多机房链路刚上线时不要直接用线上流量赌。做一轮小流量压测从 consumer 所在机房发起请求记录 Tomcat / Dubbo 线程池的耗时分布再把监控里的 TP99 拉出来用这个值算 timeout基本就能避免无脑超时。2.3 retries是双刃剑超时后的雪崩效应默认的集群容错failover在超时后会自动换节点重试。单机房场景下一个节点超时换一台机器可能就好了但多机房场景下如果 consumer 第一次调用打到跨机房节点超时重试又随机选到另一个跨机房节点等于把一个慢请求变成 3 个慢请求。更恶心的是consumer 侧的超时并不会中断 provider 侧的继续执行。场景是这样的consumer 调用 provider A设置 timeout1000ms。provider A 实际执行需要 1500ms。consumer 在 1000ms 时抛超时然后触发 failover 重试到 provider B。provider A 还在继续执行稍后也返回了结果但这个结果已经没人接收。最终 provider A 和 B 都被执行了一遍。流量翻倍数据库压力翻倍超时又会引发更多超时。所以我在多机房实战中有一个原则凡是改写了数据的接口一律 retries0读接口重试要控制次数重试是否跨机房应由路由策略决定而不是随机选。这也是后面为什么要做集群扩展的核心理由——不是 Dubbo 默认不能用而是默认策略在多机房下太“无脑”。3. 无提供者异常从注册中心到路由逐层排查3.1 Nacos侧最容易踩的四个坑先别急着写代码Nacos 侧的问题占无提供者异常的一大半。以下四个坑我在排查现场反复见到。第一个是 namespace 隔离。每个环境一个 namespace 本来是好习惯但 Dubbo 消费端和提供端如果配置的 namespace 不一致两边数据完全不通。Nacos 控制台看起来一切正常因为你是用某个 namespace 登录的consumer 在另一个 namespace 下当然订阅不到任何 provider。第二个是 group 不一致。Dubbo 服务默认组是DUBBO_DEFAULT_GROUP如果你在 provider 里配了dubbo:service grouppay/consumer 侧没配或者配成了其他组No provider 马上出现。第三个是网络分区导致的健康检查滞后。多机房场景里如果 Nacos 和某个机房的网络抖动provider 心跳发不过来Nacos 端会把这个实例标记为不健康。临时实例默认 15 秒没心跳就是不健康30 秒后会被移除。这期间如果 consumer 被推送了一个空列表就会报无提供者。反过来如果推送失败consumer 本地还保留着已经死掉的节点调用就会超时或拒绝连接。第四个是 Dubbo 3.x 的应用级服务发现。3.x 默认走应用级发现需要 Nacos 2.x 配合同时依赖 metadata 信息。如果 provider 的dubbo.application.name配置不一致或者 metadata center 不通也会出现“Nacos 里有服务、consumer 却拿不到可用实例”的诡异现象。排查无提供者异常我第一步永远是打开 Nacos 控制台切到消费端配置的 namespace搜索对应的服务名看一眼服务列表里有几个 IP。如果 IP 都在再去查 consumer 日志里打印的注册中心地址和 group如果 IP 不在就要去 provider 机器上看启动日志确认服务注册成功没有。3.2 用消费者日志和Dubbo运维命令组合定位当 Nacos 控制台显示服务在线consumer 还报 No provider 时就进入了最烧脑的阶段。这时候要善用 Dubbo 自带的运维能力。consumer 启动日志里会打印关键信息[RegistryDirectory] Register: consumer://... to registry nacos://... [RegistryDirectory] Subscribe: provider://... No provider available for the service com.example.UserService from registry nacos://...注意看from registry后面的地址确认 consumer 连接的是哪个 Nacos 集群。然后再通过 Dubbo QOS 端口进入运维命令默认端口是 22222telnet 127.0.0.1 22222 dubbo ls dubbo ps dubbo invoke com.example.UserService.getUserById(1001)ls可以看当前进程发布了哪些服务ps可以看注册中心的信息invoke可以手动发起一次调用。如果手动 invoke 能成功说明 consumer 到 provider 的网络链路没问题问题大概率出在路由过滤或动态配置上如果 invoke 也报 No provider那就必须去看 Directory 里的 invoker 列表到底是不是空的。这里有另一个重要检查点所有 provider 是否被路由规则过滤了。Dubbo 的 Router 执行顺序是在 Directory.list() 返回结果之后、负载均衡之前。假如你在配置中心配了条件路由例如host 10.20.*而 provider 实际 IP 是10.30.*那么即便注册列表有 10 个节点经过路由后也可能变成 0 个最终报错就是 No provider available。这类“假无提供者”在界面上完全看不出来必须靠日志或者临时移除路由规则来验证。3.3 路由过滤导致的“假无提供者”我在生产环境就踩过一次自定义 Router 的坑。当时为了做机房优先写了一个 RouterFactory只保留同机房的 provider。逻辑本身没问题但新机房刚上线时 provider 数量不足某个服务的同机房节点全部缩容下线自定义 Router 把跨机房节点全过滤了consumer 直接报 No provider。这个教训最终变成一条铁律自定义 Router 过滤后要加空保护。也就是说如果过滤后 invoker 列表为空应当返回原始列表作为兜底让 Dubbo 至少能调到跨机房节点而不是连调用的机会都没有。ListInvokerT matched doFilter(invokers); if (matched null || matched.isEmpty()) { return invokers; } return matched;后面第 4 节的实战代码里我会保留这个保护逻辑这一点比任何花哨的路由策略都重要。4. 集群扩展实战机房优先的路由与负载均衡4.1 为什么默认的随机负载均衡不友好Dubbo 默认负载均衡是random每个 provider 按权重随机选中。单机房部署没问题多机房就不行了如果机房 A 有 20 个节点机房 B 有 5 个节点随机策略下机房 A 的 consumer 会有约 20% 的流量被分到机房 B跨机房调用的比例几乎是不可控的。跨机房调用本身不是问题问题在于它放大了超时风险和容错压力。所以要做的第一件事是让 Dubbo 的负载均衡具备“同机房优先”的感知能力。实现方式有两种自定义 LoadBalance在候选集合中选择时给同机房节点更高权重。自定义 Router在候选集合生成阶段就过滤掉跨机房节点。两者职责不同。Router 决定“哪些节点能调”LoadBalance 决定“能调的节点里优先调谁”。实际项目里我倾向于组合使用Router 做严格的同机房首选LoadBalance 做同机房内部的权重分配。4.2 先给provider打上机房标签无论用哪种扩展前提都是让 Dubbo 知道每个 provider 属于哪个机房。最简单的方案是直接在 provider 注册的 URL 参数里加自定义参数。XML 方式dubbo:provider idzoneAProvider parameterszonezoneA/ dubbo:service interfacecom.example.UserService providerzoneAProvider/Java 注解方式Service(parameters {zonezoneA}) public class UserServiceImpl implements UserService {}这样 provider 注册到 Nacos 时URL 上会带有zonezoneA参数。consumer 从注册中心拿到的 invoker 列表里每个 invoker 的 URL 也带这个参数。判断机房就变得很简单取本地 IP 对应的机房再比较 provider 地址上的 zone 参数。而且注意机房判断最好用统一的配置映射。比如把“IP 段 - 机房名”的映射放到一个配置类里public final class ZoneUtil { private static final MapString, String ZONE_MAP new HashMap(); static { ZONE_MAP.put(10.10., zoneA); ZONE_MAP.put(10.20., zoneB); } public static String getZoneByHost(String host) { for (Map.EntryString, String entry : ZONE_MAP.entrySet()) { if (host.startsWith(entry.getKey())) { return entry.getValue(); } } return unknown; } }4.3 自定义LoadBalance同机房优先的权重选择自定义 LoadBalance 需要实现 Dubbo 的 SPI 扩展点。核心代码我写了一个简化版本package com.example.dubbo; import org.apache.dubbo.common.URL; import org.apache.dubbo.rpc.Invocation; import org.apache.dubbo.rpc.Invoker; import org.apache.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance; import java.util.List; import java.util.stream.Collectors; public class ZoneAwareLoadBalance extends AbstractLoadBalance { Override protected T InvokerT doSelect(ListInvokerT invokers, URL url, Invocation invocation) { String localZone ZoneUtil.getZoneByHost(url.getHost()); ListInvokerT sameZoneInvokers invokers.stream() .filter(invoker - localZone.equals( invoker.getUrl().getParameter(zone, unknown))) .collect(Collectors.toList()); // 没有同机房节点时降级到全量节点绝不能返回空 if (sameZoneInvokers.isEmpty()) { return invokers.get(0); } return sameZoneInvokers.get(0); } }这里为了可读性做了简化实际上行业内会用加权随机算法在sameZoneInvokers里选一个而不是直接取第一个。你可以把AbstractLoadBalance的getWeight方法和权重随机逻辑套进来实现思路是同机房节点权重设为 100跨机房节点权重设为 1那么绝大多数流量会落在同机房少量流量做跨机房探测和兜底。SPI 注册文件放在META-INF/dubbo/org.apache.dubbo.rpc.cluster.LoadBalancezoneAwarecom.example.dubbo.ZoneAwareLoadBalance消费端使用dubbo:reference iduserService interfacecom.example.UserService loadbalancezoneAware/这个方案的优点是改动小、见效快。缺点是它只影响“选谁”不会把跨机房节点完全排除。如果业务要求必须同机房调用跨机房一律不允许那就要配合 Router 使用。4.4 用Router做更严格的机房隔离自定义 Router 同样走 SPI。先定义 RouterFactorypackage com.example.dubbo; import org.apache.dubbo.common.URL; import org.apache.dubbo.rpc.cluster.Router; import org.apache.dubbo.rpc.cluster.RouterFactory; public class ZoneRouterFactory implements RouterFactory { Override public T Router getRouter(URL url) { return new ZoneRouter(); } }然后实现 Router 的 route 方法package com.example.dubbo; import org.apache.dubbo.common.URL; import org.apache.dubbo.rpc.Invocation; import org.apache.dubbo.rpc.Invoker; import org.apache.dubbo.rpc.RpcException; import org.apache.dubbo.rpc.cluster.Router; import java.util.List; import java.util.stream.Collectors; public class ZoneRouter implements Router { Override public T ListInvokerT route(ListInvokerT invokers, URL url, Invocation invocation) throws RpcException { if (invokers null || invokers.isEmpty()) { return invokers; } // 从调用上下文里取目标机房没指定就用本地机房 String targetZone invocation.getAttachment(zone); if (targetZone null) { targetZone ZoneUtil.getZoneByHost(url.getHost()); } ListInvokerT matched invokers.stream() .filter(invoker - targetZone.equals( invoker.getUrl().getParameter(zone, unknown))) .collect(Collectors.toList()); // 关键过滤为空时回退全量防止 No provider if (matched.isEmpty()) { return invokers; } return matched; } }SPI 注册文件META-INF/dubbo/org.apache.dubbo.rpc.cluster.RouterFactoryzonecom.example.dubbo.ZoneRouterFactory这段代码里有一个重要的取舍当目标机房没有 provider 时返回全量列表而不是空列表。这样做的代价是跨机房调用仍会发生但换来的是服务可用性优先。如果业务上能接受“目标机房挂了就失败”也可以把空列表直接返回让上层容错策略兜底但生产环境我强烈建议保留回退逻辑先保命再谈隔离。5. 集群容错策略扩展同机房失败后怎么办5.1 Dubbo内置Cluster的适用边界Dubbo 内置了多种集群容错策略多机房场景下要重新审视它们的适用性策略行为多机房适用性failover失败自动切换默认重试 2 次适合读接口但重试可能跨机房放大流量failfast失败立即报错适合写接口避免重复执行但多机房下可用性最低failsafe失败吞掉异常适合日志等非关键调用多机房下容易掩盖问题failback失败记录后定时重发适合异步化场景但你要自己处理幂等forking同时调用多个节点取最早成功严重放大流量多机房慎用默认 failover 在多机房下最令人担忧的一点是它重试时不会考虑机房属性还是会通过负载均衡重新选节点。也就是说第一次调跨机房超时第二次很可能又随机到跨机房第三次也是。整个超时窗口被拉长用户体验极差。要解决这个问题除了前面说的路由和负载均衡之外还有一个更彻底的方案自定义 Cluster让整个容错过程就限定在“同机房优先”的大前提下。5.2 自定义Cluster先同机房、后跨机房Dubbo 的 Cluster 扩展入口是Cluster接口实际工作是在ClusterInvoker的doInvoke方法里完成的。我写了一个基于 FailoverClusterInvoker 的扩展package com.example.dubbo; import org.apache.dubbo.common.URL; import org.apache.dubbo.rpc.Invocation; import org.apache.dubbo.rpc.Invoker; import org.apache.dubbo.rpc.Result; import org.apache.dubbo.rpc.RpcException; import org.apache.dubbo.rpc.cluster.Directory; import org.apache.dubbo.rpc.cluster.LoadBalance; import org.apache.dubbo.rpc.cluster.support.FailoverClusterInvoker; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; public class ZoneAwareClusterInvokerT extends FailoverClusterInvokerT { public ZoneAwareClusterInvoker(DirectoryT directory) { super(directory); } Override protected Result doInvoke(Invocation invocation, ListInvokerT invokers, LoadBalance loadbalance) throws RpcException { String localZone ZoneUtil.getZoneByHost(getUrl().getHost()); ListInvokerT localInvokers invokers.stream() .filter(invoker - localZone.equals( invoker.getUrl().getParameter(zone, unknown))) .collect(Collectors.toList()); // 同机房有节点就在同机房范围内做 failover if (!localInvokers.isEmpty()) { return super.doInvoke(invocation, localInvokers, loadbalance); } // 同机房没节点降级为全量 failover return super.doInvoke(invocation, invokers, loadbalance); } }然后是 Cluster 接口的实现package com.example.dubbo; import org.apache.dubbo.common.URL; import org.apache.dubbo.rpc.Invoker; import org.apache.dubbo.rpc.RpcException; import org.apache.dubbo.rpc.cluster.Cluster; import org.apache.dubbo.rpc.cluster.Directory; public class ZoneAwareCluster implements Cluster { Override public T InvokerT join(DirectoryT directory, boolean buildFilterChain) throws RpcException { return new ZoneAwareClusterInvoker(directory); } }SPI 注册文件META-INF/dubbo/org.apache.dubbo.rpc.cluster.ClusterzoneAwarecom.example.dubbo.ZoneAwareCluster这个方案的精妙之处在于它把机房选择的逻辑和容错逻辑绑定在一起。同机房有可用节点时failover 只会在同机房间切换不会出现“同机房超时后重试到异地机房”的问题。同机房没有节点时再降级到全量节点保证起码能用。用 XML 开启dubbo:reference iduserService interfacecom.example.UserService clusterzoneAware/5.3 集群扩展组合使用的完整配置示例实际项目中我不会只用一个扩展。推荐组合是Router 负责严格场景的机房隔离。LoadBalance 负责平时流量的同机房优先。Cluster 负责兜底容错策略。消费端完整配置如下dubbo:reference iduserService interfacecom.example.UserService timeout2000 retries1 loadbalancezoneAware clusterzoneAware /dubbo:reference这样配置后一次调用的路径是服务目录拿到全部 provider 列表。自定义 Router 先按目标机房过滤得到同机房节点列表。自定义 LoadBalance 在同机房节点中按权重选择。如果调用失败自定义 Cluster 重试时仍然优先选择同机房节点。如果同机房没有可用节点降级到全量节点避免 No provider。这就把“同机房优先、跨机房兜底”的架构原则完整落到了 Dubbo 集群层业务代码完全不用动。6. 多机房Dubbo常见问题速查表最后把这两年多机房排障过程中的高频问题整理成速查表方便大家直接比对。现象可能原因排查步骤解决方案No provider但Nacos控制台有节点namespace 不一致登录 Nacos 查看服务所在 namespace统一 consumer/provider namespaceNo providerNacos节点数正常group 不一致对比两边 group 参数统一 group 配置No providerprovider 刚发布注册未完成就订阅看 provider 注册日志等待注册完成再做流量切换No provider自定义 Router 后出现路由过滤为空临时移除 Router 验证Router 返回全量兜底频繁 SocketTimeoutExceptiontimeout 预算不足拉监控 TP99 对比 RTT按 P99 RTT 重算 timeout超时后流量突然翻倍failover 跨机房重试看 Dubbo 线程池指标retries 降为 0 或自定义 Cluster同机房节点健康跨机房超时严重网络链路质量差ping 抓包确认减少跨机房流量Router 强隔离写接口超时后重复入库retries 未关闭看调用日志重复请求数写接口 retries0再补充三个避坑经验provider 端的线程池耗尽也会导致 consumer 无脑超时。遇到大面积超时先看 provider 的 Dubbo 线程池活跃线程数而不是一上来就调大 timeout。Nacos 集群本身最好按机房就近部署。consumer 连接本机房的 Nacos 节点能减少注册中心跨机房同步导致的心跳和推送延迟。自定义扩展上线前务必在测试环境验证“某机房全挂”的场景。我当时就是因为没测这个场景上线后把跨机房兜底逻辑写成死循环反而把故障范围扩大了。最后聊点个人体会做多机房改造最容易犯的错误是“把单机房思路原封不动搬过来”。Dubbo 本身提供了非常灵活的 SPI 扩展机制Router、LoadBalance、Cluster 都可以按需替换但很多人直到线上出故障才想起来用。我的建议是多机房改造的第一周就把自定义 LoadBalance 和 Cluster 写好哪怕先不做严格隔离也要让 Dubbo 对“同机房优先”这件事有感知。等业务稳定了再逐步加上 Router 的强隔离规则。顺序反了你会被 No provider 和超时异常追着跑。另外一个细节是所有自定义扩展都要做好降级保护。Dubbo 的集群能力再强它也只是 RPC 框架真正决定可用性的是你对“失败之后怎么办”的设计。宁可降级跨机房调用也不要因为一个过滤逻辑把所有流量都打没。