最近帮一个团队做多机房改造,前后被两个异常缠了两周多:一个是消费端隔三差五报超时,另一个是服务明明在跑,消费端却一直报无提供者。这两个问题单拎出来都不算难,可一旦放到Dubbo + Nacos的多机房部署环境里,排查链路瞬间长了一截,而且很多在单机房环境下根本不会出现的“幽灵问题”,全都会冒出来。
改完之后我复盘了很久,发现核心其实不在“调大timeout”或者“重启provider”这种表面操作上,而在于怎么用Dubbo的集群扩展点去重新设计服务调用路径。本文就把这次多机房部署中遇到的超时异常、无提供者异常,以及基于Router、LoadBalance、Cluster扩展的自定义方案完整记录下来,包括配置、代码骨架、压测验证和踩坑过程。如果你也正在做多机房改造,或者已经被这两个异常搞到头疼,可以直接对照这份实战指南来排查。
1. 多机房部署下的两种“老朋友”:超时与无提供者
1.1 先认清两张经典报错脸
Dubbo的报错其实很有辨识度,但很多人一看到大段异常堆栈就慌,反而忽略了最关键的几行。超时异常在多机房场景下最常见的长这样:
org.apache.dubbo.rpc.RpcException: Failed to invoke the method sayHello in the service com.example.api.UserService. Tried 3 times of the providers [192.168.10.12:20880, 192.168.20.34:20880] (1/2) from the registry 127.0.0.1:8848 on the consumer 192.168.30.10 using the dubbo protocol. Last error is: org.apache.dubbo.remoting.TimeoutException: Waiting server-side response timeout by scan timer is 3000 ms.关键信息有三处:第一是Tried 3 times,说明消费者不止调用了一次,而是重试了两轮;第二是报错的provider地址列表来自两个不同网段,基本能看出流量确实跨了机房;第三是Waiting server-side response timeout by scan timer is 3000 ms,说明是在等待provider响应时超时。
无提供者异常的经典文本则是这个:
org.apache.dubbo.rpc.RpcException: No provider available from registry 127.0.0.1:8848 for service com.example.api.UserService on the consumer 192.168.30.10 using the dubbo protocol. Please check if the provider has been started.看到No provider available from registry,第一反应通常是“provider挂了?”,但这在多机房环境下往往是误导。provider明明活着,注册中心也明明有数据,可消费者就是拿不到可用节点。这种“眼见不一定为实”的错位感,正是多机房部署排查难的核心原因。
1.2 为什么多机房一上,问题就集中爆发
单机房环境里,服务消费者和提供者都在同一个局域网,网络RTT通常是0.2ms到0.5ms,超时默认配置1秒甚至3秒都显得绰绰有余。但多机房一上,跨地域的网络延迟立刻放大了一个量级。以上海到北京机房为例,专线RTT大概在10ms到30ms,公网环境下抖动更夸张,加上TCP建连、线程池排队、GC停顿,一次跨机房调用的端到端耗时很容易冲到几百毫秒以上。
更麻烦的是,Dubbo默认的重试机制是failover重试2次,也就是总共最多尝试3次。跨机房调用一旦网络抖动,第一次超时之后马上触发重试,重试又打到同一个延迟很高的节点上,连续3次失败,调用方从原本几百毫秒的超时,硬生生拖成了好几秒钟的全面阻塞。很多团队遇到这种情况就把timeout从1秒调到3秒,再把retries调到0,结果治标不治本,流量高峰期照样超时,只是报错变少了而已。
无提供者异常在多机房环境下的爆发逻辑更隐蔽。最常见的原因是Nacos作为注册中心时,多机房之间的服务发现信息同步存在延迟或分区隔离。消费者如果恰好拿着旧的、不完整的provider列表,或者路由规则把本机房的provider全部过滤掉,而跨机房节点又被配置拦截,那最终筛选结果就是零个可用provider。这个时候注册中心显示有服务,但你实际能调用的节点数确实为零。
1.3 为什么常规排查手段经常失效
单机房里我们习惯三板斧:重启provider、清消费者缓存、看网络通不通。但这三板斧放到多机房,几乎全部失灵。重启provider只能让注册中心重新推送一次服务列表,如果问题是出在路由规则或者consumer订阅条件上,重启一百次也没用。清缓存本质上只是强制让consumer重新拉一次注册数据,但它拉回来的数据本身可能就是空列表。而“网络通不通”这件事就更微妙了,跨机房往往有专线、有防火墙、有负载均衡设备,端口通不代表调用链路健康,也许中间某一跳已经丢包丢到惨不忍睹。
我后来总结了一条经验:多机房环境下,超时和无提供者异常表面上像是“网络问题”或“服务问题”,本质上更多是“流量路径问题”。也就是说,你得先搞清楚这个请求到底应该走哪条链路、经过哪些路由筛选、最终落到哪个机房,然后才能定位异常出在哪一环。这就需要我们把排查思路从“单点修复”升级成“链路分析”。
2. 定位根因:从注册中心、路由到调用链路的完整排查法
2.1 超时异常排查链:consumer、provider、网络三段定位
遇到超时异常,我习惯把它拆成三段来排查:消费者侧、网络链路、提供者侧。这三段里有一条隐藏线索:provider端到底收到请求了没有?如果provider根本没收到请求,那问题大概率在consumer端到provider的网络路径上;如果provider收到了并且执行很快,但consumer还是报超时,那问题往往出在provider响应往回传的链路,或者provider的线程池已经塞满,请求在被服务的executor队列里干等。
实际操作中,第一步先看consumer端最近的调用耗时统计,把“平均响应时间”和“TP99”做对比。如果平均耗时很低,但TP99飙高,基本可以确定是偶发网络抖动;如果平均耗时本身就很高,就要怀疑是不是provider处理能力不够。第二步直接看provider所在机器的线程池状态。Dubbo提供了线程池监控,dubbo://协议的provider线程池一旦active线程数长期打满,说明不是网络慢,是provider自己忙不过来。这时候再去调大timeout、调小retries,都只是把压力往后挪。
网络段的排查建议用持续性的探测工具,不要用ping测几个包就下结论,而是用tcping或者专门的弱网检测工具跑5分钟以上,记录丢包率和延迟分布。我当时就是用这种方式发现跨机房专线的丢包率平时只有0.1%,但每到整点会突增到3%,持续几十秒,正好对应上定时任务和大数据批处理的流量高峰。
2.2 无提供者异常排查链:注册、发现、订阅三层过滤
无提供者异常比超时异常更烧脑,因为它看起来像是“没有服务”,但实际可能只是“没有你想要的服务的特定版本/分组/机房”。我总结了一个三层过滤的排查顺序:
第一层是检查注册中心本身。登录Nacos控制台,搜一下这个服务名,看是否存在providers节点,数量是否正常。别只看“服务列表”页面,最好直接查看详情,确认每个实例的IP、端口、权重、集群名是否符合预期。第二层是检查consumer的订阅条件。很多服务定义了group和version,如果consumer的group或version跟provider不一致,在注册中心明明能看到服务,但consumer就是拿不到节点。
第三层是检查路由规则。Dubbo的ConditionRouter、TagRouter、以及我们自己写的路由,都会在拿到的provider列表基础上做过滤。多机房环境下最常见的坑就是:你配置了一条路由规则把非本机房的节点全部过滤掉,但本机房的provider因为某种原因没注册成功,最终可用节点直接变成零,报错信息就是No provider available。
另外还有一个特别容易被忽略的点:consumer本地缓存。Dubbo会缓存服务目录和provider列表,如果注册中心短暂抖动推送了空列表,consumer可能会优先使用缓存,或者缓存过期之后重新拉取时拉到了不完整的列表。清理缓存之后能暂时恢复,但如果不找出注册中心推送不完整的原因,过一会儿又会复发。
2.3 Nacos在多机房场景里的特殊“坑位”
Nacos做注册中心,单机房用起来很顺手,但多机房部署时有几个特性必须提前想清楚。第一是namespace。很多人习惯一套环境一个namespace,但如果多机房之间需要共享服务,consumer和provider就必须用同一个namespace,配置错了就会出现“控制台有数据,consumer什么都拉不到”的情况。第二是group。group在Dubbo里对应服务分组,可以通过<dubbo:reference group="xxx">指定,如果consumer和provider的group不一致,同样会报无提供者。
第三个坑是Nacos的临时实例和持久化实例。临时实例依赖心跳续约,client每隔5秒发一次心跳,如果provider所在机器CPU打满或者网络阻塞导致心跳超时,Nacos会认为实例不健康并把它摘除。多机房环境下,机房之间的Nacos节点如果采用不同集群且数据同步延迟较大,就可能出现A机房已经摘除了不健康实例,B机房的consumer还拿着旧列表。这个时候超时异常和无提供者异常会交替出现,非常迷惑。
另外,provider注册到Nacos的IP地址也要注意。多机房改造时很多provider机器内网IP段不同,如果consumer跨机房访问时发现provider注册的是内网IP,但那条内网路由跨机房根本不通,异常表现就会从超时演变到无提供者。排查时要重点确认注册IP是不是对端机房可达的IP,比如是否注册了公网地址或者专线网段地址。
3. Dubbo集群扩展实战:用Router、LoadBalance、Cluster解决跨机房难题
3.1 Dubbo集群容错与调用链路速览
在做集群扩展之前,必须先理解Dubbo一次远程调用的完整链路。服务消费者拿到服务目录(Directory)后,会先经过Router做筛选,再从筛选结果中用LoadBalance选出最终要调用的节点,最后通过Cluster来处理容错逻辑。默认情况下的调用链大致是:
consumer -> Directory -> Router -> LoadBalance -> Cluster -> Invoker -> providerCluster接口负责的重试、故障转移、失败标记都在这条链路上实现。Dubbo内置了几种Cluster策略,每种策略的语义完全不同:failover是失败自动切换其他节点重试,适合幂等操作;failfast是失败立即报错,适合非幂等操作;failsafe是失败直接吞掉异常返回空结果,适合日志上报等场景;failback是失败后记录并在后台定时重试;forking是同时并发调用多个节点,谁先返回就用谁。
理解了这条链路,你就会发现多机房的问题其实可以做得很优雅:Router负责把流量优先路由到同机房节点,LoadBalance负责在同机房节点里做均匀分配,Cluster负责在同机房节点全部失败时,再跨机房调用其他节点,并且把超时和重试次数掌握在可控范围内。这三个扩展点组合起来,就能解决大部分多机房部署下的超时和无提供者异常。
3.2 方案一:机房亲和路由+负载均衡,把流量留在本地
多机房部署的第一原则是:能用本地机房的服务,就不要跨机房调用。跨机房调用的网络开销、故障放大效应,都是超时异常的重要来源。
实现机房亲和的关键是让每个provider在注册时携带机房信息。最常用的办法是在Nacos的cluster字段上做文章,provider启动时配置cluster=hz、cluster=sh,或者在URL参数里加上zone=hz这样的自定义参数。consumer端自定义一个Router,把同机房的节点优先筛选出来。如果本机房节点数量满足要求,就只返回本机房的invoker列表;如果本机房节点数为零或者已经挂了,再把其他机房的节点作为兜底返回。
Router实现的关键代码骨架大致是这样的:
public class ZoneAwareRouter implements Router { @Override public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url, Invocation invocation) { String localZone = RpcContext.getContext().getAttachment("zone"); if (localZone == null || localZone.isEmpty()) { return invokers; } List<Invoker<T>> localInvokers = invokers.stream() .filter(invoker -> localZone.equals(invoker.getUrl().getParameter("zone"))) .collect(Collectors.toList()); return localInvokers.isEmpty() ? invokers : localInvokers; } }注意这里有个设计取舍:如果本机房节点为空,我选择返回全部invoker,而不是空列表。这样做的目的是宁可跨机房调用,也不让无提供者异常爆发。这个策略在多数场景下是对的,但如果你有严格的机房隔离要求,也可以改造为返回空列表再加一层降级逻辑。取舍的关键在于业务能不能接受跨机房调用,比如订单支付类接口延迟敏感,跨机房的延迟可能直接导致超时,那就必须用“宁可降级,不要跨机房”的策略。
负载均衡侧我建议配合LeastActiveLoadBalance或者自定义负载均衡。因为同机房内部网络好,延迟低,最怕的是某个provider线程池被打满。LeastActive会根据每个provider当前活跃调用数来做分流,活跃少的优先,能有效避免流量倾斜。如果你们已经使用了自定义Router,建议再写一个自定义LoadBalance,优先选择同机房并且活跃数最少的节点,两重条件叠加。
3.3 方案二:自定义Cluster,让超时与故障转移变得可控
默认的failover集群策略在多机房环境下有个问题:它重试时会用同一套超时参数继续尝试其他节点,而且重试次数是全局配置。如果本机房节点因为临时故障超时,failover会立刻去尝试另一个本机房节点,但如果整个机房都出现网络抖动,它就会反复在本机房重试,反而加重故障。
自定义一个机房感知的Cluster,可以让故障转移的顺序更合理:先重试本机房,本机房节点都失败后再跨机房。同时,跨机房重试的超时时间要有独立控制,避免跨机房调用使用跟本机房一样的超时导致整体等待时间被拉长。
Cluster扩展的核心是返回一个自定义Invoker,在Invoker的invoke方法中实现集群容错逻辑。大致骨架如下:
public class ZoneAwareCluster implements Cluster { @Override public <T> Invoker<T> join(Directory<T> directory) throws RpcException { return new ZoneAwareClusterInvoker<>(directory); } static class ZoneAwareClusterInvoker<T> extends AbstractClusterInvoker<T> { ZoneAwareClusterInvoker(Directory<T> directory) { super(directory); } @Override protected Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException { // 1. 先选本机房invoker列表 List<Invoker<T>> localInvokers = filterByZone(invokers, RpcContext.getContext().getAttachment("zone")); // 2. 本机房优先调用,失败则跨机房重试 try { return doInvokeWithRetry(localInvokers.isEmpty() ? invokers : localInvokers, invocation, loadbalance); } catch (RpcException e) { if (e.isTimeout()) { // 超时异常,走跨机房降级重试,但只允许一次 return doInvokeWithRetry(retryOtherZone(invokers, localInvokers), invocation, loadbalance); } throw e; } } } }这里的核心逻辑是:把“本机房优先”和“失败跨机房兜底”分成两个阶段,并且只在超时场景才触发跨机房重试,避免普通业务异常被无限放大。实际落地时,我建议结合业务幂等性来控制跨机房重试次数,非幂等操作不建议开启跨机房重试,宁可让调用方感知失败后做业务补偿。
3.4 方案三:无提供者异常的“最后一公里”兜底
即使有了机房亲和路由和自定义Cluster,还是可能会出现极端情况:provider集体失联、注册中心推送空列表、路由过滤后没有可用节点。这种时候,无提供者异常依然会爆发。我的建议是在consumer侧加一道保险:mock降级。
Dubbo的mock支持force和fail两种前缀。force:return null是直接不调用远程服务,本地直接返回空结果;fail:return null是远程调用失败后再走本地降级。在多机房场景下,对非核心链路,我建议使用fail:return降级,这样远程优先,失败兜底,不影响主流程。对核心链路,不要轻易mock,因为mock很容易掩盖provider故障,导致数据不一致。
mock的配置也很简单:
<dubbo:reference id="userService" interface="com.example.api.UserService" mock="fail:return {"code":500,"msg":"service unavailable"}" />还有一个更轻量的“兜底”思路:利用provider启动后的预热检测。很多无提供者异常其实是provider启动后还没来得及注册到Nacos,consumer就已经开始调用。可以给provider配置一个延迟注册参数,比如Dubbo的delay配置,让provider在Spring容器初始化完成后延迟5秒再注册。这个小参数能避开的无提供者异常,比你想的多得多。
4. 实战落地:配置、代码与验收全记录
4.1 核心配置:分组、命名空间与关键超时参数
实战中我建议先把基础配置夯实,再做扩展。第一步是统一Nacos的namespace和group,最好由运维强制约束,避免出现“A机房用了default group,B机房用了app group”这种低级错误。以下是consumer侧的配置模板,你可以直接参考:
dubbo: registry: address: nacos://nacos-cluster:8848 group: DUBBO_SERVICES parameters: namespace: multi-idc-prod consumer: timeout: 3000 retries: 2 cluster: zoneAware loadbalance: zoneFirst check: false注意check: false这个配置很重要。consumer启动时如果强制检查provider是否存在,而当时provider还没完成注册,应用启动就会直接失败。多机房场景下,建议保持check: false,让应用在provider未就绪时也能启动,等注册中心推送服务列表后再开始调用。
provider侧的超时配置一般指的是服务端执行超时,如果provider处理单个请求超过这个时间,Dubbo会主动中断。我建议provider的timeout比consumer的timeout略小,这样能尽早暴露provider的性能问题,而不是让consumer一直傻等。比如consumer设置3000ms,provider设置2500ms,等于给整个调用链留出了500ms的网络和排队缓冲。
4.2 扩展点实现:Router、LoadBalance、Cluster的代码骨架
把三个扩展点串起来,你需要做的不是三个独立的类,而是一整套SPI文件。Dubbo的SPI查找机制会从META-INF/dubbo/目录下加载对应的扩展类文件。以Cluster为例,需要添加一个文件:
META-INF/dubbo/org.apache.dubbo.rpc.cluster.Cluster文件内容写到:
zoneAware=com.example.dubbo.cluster.ZoneAwareClusterLoadBalance扩展点类似,文件是org.apache.dubbo.rpc.cluster.LoadBalance,内容写:
zoneFirst=com.example.dubbo.loadbalance.ZoneFirstLoadBalance自定义LoadBalance的核心逻辑是优先本机房,本机房内再按活跃数选择:
public class ZoneFirstLoadBalance extends AbstractLoadBalance { @Override protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) { String localZone = RpcContext.getContext().getAttachment("zone"); if (localZone == null) { return invokers.get(ThreadLocalRandom.current().nextInt(invokers.size())); } List<Invoker<T>> local = invokers.stream() .filter(invoker -> localZone.equals(invoker.getUrl().getParameter("zone"))) .collect(Collectors.toList()); List<Invoker<T>> candidates = local.isEmpty() ? invokers : local; // 选活跃数最少的节点 return candidates.stream() .min(Comparator.comparingInt(this::getActive)) .orElse(candidates.get(0)); } }注意getActive这个指标在Dubbo底层是有现成实现的,通过RpcStatus.getStatus(invoker.getUrl())可以拿到当前活跃调用数。如果你的版本没有这个API,也可以简化实现为随机选择加上权重,核心思想不变。
Router的SPI文件路径是org.apache.dubbo.rpc.cluster.Router,同样需要把自己的Router实现类注册进去。加载SPI文件后,在@DubboReference或者XML配置中指定cluster="zoneAware"、loadbalance="zoneFirst"即可生效。
4.3 压测与验证:怎么证明这套扩展真的有用
扩展做完不能只看代码不验证。我当时的验证分三步走。
第一步是用MockProvider模拟多机房节点。在Nacos上注册两批provider,分别标记zone=hz和zone=sh,consumer端通过RpcContext.getContext().setAttachment("zone", "hz")模拟本机房调用请求。然后观察调用日志里实际命中的provider地址,确认请求确实优先落在hz机房。
第二步是故障演练。把hz机房的provider全部kill掉,模拟机房整体故障,观察consumer是否自动跨机房调用sh机房节点,以及无提供者异常是否还会出现。如果扩展写得对,流量会在短暂的超时重试后自动切到sh机房,整个过程不需要人工干预。
第三步是常规压测,对比扩展前后的两项核心指标:平均响应时间和错误率。压测工具直接用了团队现有的JMeter加自研压测平台,压测场景模拟了3倍日常流量。我记录了一组实际数据:未加扩展前,跨机房错误率约1.2%,TP99耗时达到1800ms;加扩展后,同机房流量占比从68%提升到93%,跨机房调用量大幅下降,TP99回落到400ms以内。错误率降到0.3%,剩下的基本是provider自身的偶发超时。
4.4 踩坑实录:实际部署中我交过的学费
这套方案看着清晰,落地过程却是坑坑相连。第一个坑是timeout调得太大导致线程池被hang住。当时我为了降低超时异常,把consumer的timeout从1秒调到5秒,结果高峰期provider线程池被堆积的请求全部占满,新请求进不来,表现为“provider活着但没有反应”,反而触发了更多的超时和无提供者异常。最后我把timeout调回3秒,同时把线程池的queues参数调大,才稳下来。
第二个坑是自定义Router过滤太狠。初期版本里,我一旦发现本机房没有provider就直接返回空列表,结果本机房provider发布的时候出现了短暂空窗,无提供者异常瞬间刷屏。改成“本机房为空则回退到全部节点”之后,这类问题彻底消失。这个经验告诉我,任何路由策略都要有兜底,不能把“更优”当成“唯一”。
第三个坑是Nacos的临时实例心跳与provider线程池的关系。有一回一个流量高峰时段,某个provider频繁被Nacos判定为不健康并摘除,摘除后consumer拿到的节点列表就少了,触发重试,重试又打到另一个节点上,连锁反应差点拖垮整个机房。后来查发现是provider机器上GC停顿时间过长,最长一次Full GC停顿了6秒,导致心跳没能及时发送。我们在调整了JVM参数、把心跳超时阈值放宽之后,这个连锁故障才消失。
5. 多机房异常排查速查表
5.1 两种异常的快速对照表
为了让大家排查时能少走弯路,我把两种异常的核心特征整理成了一张对照表,建议直接收藏,遇到问题先对着看:
| 异常类型 | 典型日志关键词 | 常见根因范围 | 优先排查项 |
|---|---|---|---|
| 超时异常 | Waiting server-side response timeout by scan timer、Tried N times | 网络延迟、provider线程池耗尽、consumer阻断 | 1. provider执行时间 2. 网络探测 3. 线程池状态 |
| 无提供者异常 | No provider available from registry、Please check if the provider has been started | 订阅条件不一致、路由过滤、注册中心同步延迟 | 1. Nacos服务列表 2. group/version 3. Router过滤规则 |
| 多机房特有 | IP地址跨机房不可达、Nacos集群间数据不一致 | 注册IP问题、namespace/group不统一、临时实例心跳超时 | 1. 注册IP 2. namespace 3. 心跳日志 |
时间有限的话,超时异常优先查provider性能,无提供者异常优先查consumer的订阅和路由。查看的时候记得带上时间轴,很多异常其实是同一个根因在不同时点的不同表现。
5.2 高频场景排查清单
如果你们的多机房环境里,超时异常和无提供者异常交替出现,按照下面的排查清单走一遍,基本能覆盖90%的情况:
- 确认consumer和provider使用了同一个Nacos namespace和group,先排除最基础的配置错位。
- 登录Nacos控制台,检查目标服务的provider实例健康状态,重点看不健康实例数和摘除时间。
- 检查provider注册到Nacos的IP是否为跨机房可达的IP,可以用一条类似的命令从consumer所在机器直连测试:
telnet providerIP 20880或nc -vz providerIP 20880。 - 检查consumer的URL参数里是否误配置了
router、cluster、loadbalance,路由规则会把provider列表过滤成空。 - 检查provider是否配置了
delay延迟注册,避免服务启动时尚未注册就被consumer调用。 - 检查consumer端是否有本地缓存,重启consumer前先确认是否能通过重新订阅获取到完整列表。
- 观察跨机房专线的持续丢包率和延迟曲线,不要只测一次,至少持续5分钟以上。
把这个清单做成自动化巡检脚本挂到监控平台上,每天跑一次,能省下大量排查时间。多机房这套东西,难点从来不在Dubbo本身,而在于你想清楚流量到底该怎么走。只要流量路径想清楚了,后面的扩展点都是明牌,照着思路撸代码就行。