聊聊Dubbo:从"它是什么"到"集群容错到底怎么跑起来的"
前段时间带新人的时候,被问到一个问题:"我们系统里到处都是@DubboReference、@DubboService,这玩意儿到底解决了什么问题?"我愣了一下,发现自己虽然用了好几年,但从来没有系统地捋过这条线------从最基础的"Dubbo是什么",到线上真正踩过坑的负载均衡和容错策略,再到源码层面集群容错是怎么跑起来的。这篇文章就把这条线从头到尾理一遍,尽量少放官方定义、多讲实际会遇到的问题。
一、什么是Dubbo,它到底解决了什么问题
先说结论:Dubbo是一个RPC框架,核心作用是让服务之间的调用像调用本地方法一样简单,同时把服务发现、负载均衡、容错这些分布式系统必须要有的能力封装掉。
这句话听着还是有点空,举个例子。假设你在做一个电商系统,订单服务下单成功后要调库存服务扣库存。如果没有RPC框架,最朴素的做法是订单服务发一个HTTP请求给库存服务,地址写死在配置文件里,比如http://localhost:8080/deduct。这样做有几个问题会很快暴露出来:
- 库存服务如果部署了3台机器做负载,你总不能在订单服务里写死3个IP轮询吧;某台机器要下线维护,还得手动改配置、重启订单服务。
- 库存服务的某台机器挂了,订单服务的请求打过去直接超时,得等到探活机制发现问题才能摘掉这台机器,这段时间内的请求全部失败。
- 调用方式是HTTP+JSON,参数校验、序列化反序列化都得自己写,接口一多,重复代码到处都是。
Dubbo(以及类似的RPC框架,比如早期的gRPC、Motan)本质上是把这些"分布式系统的公共问题"抽出来做成了基础设施:
- 注册中心(Nacos、ZooKeeper这些)负责服务发现,库存服务启动时把自己的地址注册上去,订单服务从注册中心拿到一份可用地址列表,地址变化是自动感知的,不需要人工改配置。
- 负载均衡决定这份地址列表里到底调用哪一台。
- 容错机制决定某次调用失败了该怎么办------是换一台重试,还是直接报错,还是记个日志算了。
- 调用方式从"手写HTTP+JSON"变成了面向接口编程,消费方直接注入一个接口,Dubbo在底层帮你完成序列化、网络传输、反序列化,写法上跟调本地方法几乎没区别。
我自己的理解是,Dubbo这类框架本质上不是发明了什么新东西,而是把大家在分布式系统里必然会遇到、必然要写的"胶水代码"标准化、组件化了。这也是为什么理解Dubbo,绕不开注册中心、负载均衡、容错这三个词------它们才是Dubbo真正的价值所在,RPC调用本身只是外壳。
二、负载均衡策略:地址列表拿到了,到底调哪一台
订单服务从注册中心拿到库存服务的3个地址(假设是A、B、C),每次调用选哪一台,这就是负载均衡要解决的问题。Dubbo内置了几种策略,通过loadbalance属性配置,比如:
<dubbo:reference interface="com.example.StockService" loadbalance="roundrobin" />
或者注解方式:
@DubboReference(loadbalance = "roundrobin")
private StockService stockService;
主要有这几种,我按实际用到的频率来讲:
1. Random(随机,默认策略)
每次调用从可用列表里随机选一台,但不是简单的等概率随机,而是按权重随机。如果你给某台机器配置了权重200,其他机器是100,那这台机器被选中的概率就是别人的2倍。这个策略的好处是简单、长期调用下来分布均匀,缺点是短时间内可能出现"连续几次都打到同一台"的情况,对于QPS不高的接口,偶尔会看到某台机器瞬时压力大一点。
我们之前遇到过一个真实场景:一台新扩容的机器权重没调(默认跟老机器一样),但这台机器是刚启动的,JVM还没热身,结果流量一进来响应时间明显比其他机器长。后来的做法是新机器上线先把权重调低(比如设成20),观察响应时间稳定后再逐步调回100,这个操作在Dubbo里就是改注册中心里这台机器的元数据,不需要重启。
2. RoundRobin(轮询)
严格按顺序A、B、C、A、B、C这样轮着调,同样支持权重(按权重比例分配轮询次数)。这个策略比Random更"均匀",但有个容易被忽视的坑:如果某台机器处理慢,轮询依然会按固定节奏把请求打过去,不会因为它慢就少分配一点。也就是说轮询不感知机器的实际负载情况,纯粹按配置的权重分配。
3. LeastActive(最少活跃调用数)
这个策略会记录每台机器当前"正在处理中、还没返回"的请求数(活跃数),优先把新请求分给活跃数最少的那台。直觉上这是最"聪明"的策略------谁不忙就找谁。我们后来在一个查询接口(响应时间波动比较大,有的请求要查缓存有的要查慢SQL)上把负载均衡从Random切成了LeastActive,观察到的效果是长尾请求(P99)有明显改善,因为不会出现"一台机器已经堆了一堆慢请求,还继续有新请求往上打"的情况。
代价是这个策略需要维护每台机器的活跃计数,有一点点额外开销,但实际用下来完全可以忽略。
4. ConsistentHash(一致性哈希)
这个用得相对少,但有个场景绕不开:同一个用户的请求,希望每次都落到同一台机器上,比如后端做了本地缓存、或者依赖了会话状态。一致性哈希会根据请求参数(比如用户ID)计算哈希值,保证相同参数的请求大概率落到同一台机器。它的好处是"缓存命中率高",坏处是一旦某台机器下线,哈希环重新分布,会有一部分之前"专属"某台机器的请求被分散到别的机器,缓存会有一次性的失效冲击。
选型上我自己的经验是:没有特殊需求就用默认的Random,处理时间波动大的接口用LeastActive,需要本地状态亲和性的场景才上ConsistentHash,RoundRobin反而是用得最少的,因为它对"慢节点"完全不敏感,容易在流量不均匀的场景下拖垮某台机器。
三、容错机制:调用失败了,Dubbo该怎么办
负载均衡选中了一台机器,调用失败了(网络超时、对方抛异常、连接不上),接下来怎么处理,这就是容错机制(Cluster容错)要管的事。配置方式:
<dubbo:reference interface="com.example.StockService" cluster="failover" retries="2" />
Dubbo提供了几种策略,理解它们最好的方式是想清楚"这个接口失败了到底能不能重试":
1. Failover(失败自动切换,默认策略)
调用失败后自动重试其他机器,retries参数控制重试次数(默认是2,也就是最多调3次)。这个策略最大的风险是用在非幂等操作上。举个真实踩过的坑:早期有个内部系统,"扣库存"接口用了默认的Failover容错,一次线上抖动,A机器处理了扣库存操作、正准备返回结果时网络抖了一下,Dubbo判定为超时,自动重试到了B机器,B机器又把库存扣了一次------库存被多扣了一次。
这个问题的根源不在Dubbo,而在于扣库存这个操作本身不是幂等的。修复方式是给这类写操作接口做幂等设计(比如带上请求流水号,服务端先查这个流水号是否处理过),或者干脆把这类接口的容错策略改成Failfast,不重试,交给上层业务自己决定怎么处理失败(比如让用户重新点一次下单)。
这里有个经验性的判断标准:查询类接口(读操作)默认用Failover基本没问题;涉及资金、库存这类写操作,要么保证接口幂等再用Failover,要么干脆用Failfast。
2. Failfast(快速失败)
调用失败立即抛异常,不重试。适合上面说的非幂等写操作,或者对实时性要求高、重试意义不大的场景(比如一个用户手动点击的操作,重试的等待时间用户已经等不及了,不如直接报错让用户重试)。
3. Failsafe(失败安全)
调用出现异常,直接忽略,只打个日志,不往上抛异常。典型场景是日志上报、埋点统计这类"失败了也不影响主流程"的调用。我们系统里有个用户行为埋点服务,调用方式就是Failsafe------埋点服务挂了,不能因为这个把用户的正常下单流程也搞挂了。
4. Failback(失败自动恢复)
调用失败后,不是立即重试,而是记录到失败队列,用一个定时任务在后台重试,当前这次调用直接返回空结果或者不阻塞。也是用在不影响主流程但又希望"最终能补上"的场景,比如异步通知类的调用。
5. Forking(并行调用多台,只要一个成功就返回)
同时调用多台机器,只要有一台先返回成功结果就算成功,用forks参数控制并行调用几台。这个策略是用资源(多打几次请求)换时间(谁快用谁的),适合对响应时间极度敏感、但调用成本不高的场景,实际项目里用得比较少,因为大部分接口经不起"同时打3份请求"这种资源消耗。
6. Broadcast(广播调用)
调用所有机器,只要有一台报错整个调用就算失败。典型场景是批量通知所有服务节点更新本地缓存------比如某个配置变更了,需要通知集群里每一台机器都刷新自己的本地缓存,这时候就得用Broadcast,保证每台机器都收到了。
四、服务消费者怎么配置服务引用
前面讲的负载均衡和容错,其实都是配在"服务引用"这一层的属性。这里专门说一下消费端引用服务的时候,常用又容易被忽略的几个配置项。
1. 基础引用方式
XML方式:
<dubbo:reference id="stockService" interface="com.example.StockService" version="1.0.0" />
注解方式(现在更常用):
@DubboReference(version = "1.0.0", timeout = 3000, retries = 0)
private StockService stockService;
2. version和group:多版本、多分组共存
这两个属性是实际项目里绕不开的。version 用来做接口的多版本管理,比如你要给StockService加一个不兼容老逻辑的新特性,可以让提供方同时发布version=1.0.0和version=2.0.0两个版本,消费方按自己的需要指定引用哪个版本,实现灰度升级------先让一部分消费方切到2.0.0验证没问题,再逐步把所有消费方切过去,出问题也能快速回退到1.0.0。
group 则是用来隔离同一个接口的不同实现,比如同一个PayService接口,微信支付渠道和支付宝支付渠道各自实现一套,通过group=wechat和group=alipay区分,消费方按需引用不同group的实现。这两个属性一个是"时间维度隔离"(新老版本),一个是"空间维度隔离"(不同实现),日常项目中用group做多渠道适配的场景其实比version更常见一点。
3. timeout和retries:这两个参数要谨慎配
timeout控制单次调用的超时时间,默认是1秒,这个默认值在实际项目里经常不够用------如果被调用的接口本身涉及一些聚合查询或者慢SQL,1秒超时会导致大量误报的"调用失败",需要按接口实际情况单独调大。
retries前面提过,是Failover策略下的重试次数,这里要强调一点:timeout和retries是叠加生效的 ,如果timeout=3000、retries=2,意味着一次调用最坏情况下要等待接近3次×3秒=9秒才会最终失败。曾经见过一个真实故障:某个下游接口性能下降,响应时间从200ms涨到了4秒,因为配置了3秒超时+2次重试,导致这个慢接口被反复重试,直接把下游服务的连接池打满,形成了雪崩------本来只是响应变慢,因为重试策略反而演变成了服务不可用。这也是为什么慢查询、聚合类接口,我们后来倾向于把retries设成0,宁可失败一次,也不要让重试放大压力。
4. check=false:避免启动依赖阻塞
@DubboReference(check = false)
private StockService stockService;
Dubbo默认在消费方启动时会检查所依赖的服务提供方是否可用,如果不可用会直接抛异常导致消费方启动失败。这个设计的出发点是好的(尽早暴露问题),但在开发联调环境 或者服务之间存在循环依赖需要先后启动 的场景下会很麻烦------A服务启动依赖B,B服务启动又依赖A,两边都起不来。这时候可以把非核心依赖的check设为false,允许消费方"先启动起来,调用的时候再检查提供方是否可用"。生产环境对核心依赖建议保持默认的check=true,能在部署阶段就发现配置错误,比等到线上调用时才报错要好。
5. url属性:点对点直连调试
@DubboReference(url = "dubbo://127.0.0.1:20880")
private StockService stockService;
跳过注册中心,直接指定提供方地址,这个在联调阶段特别好用------你本地起了一个服务提供方实例,想让消费方直接调你本地这个实例做调试,而不是走注册中心随机分配到测试环境的其他机器上,配上url属性就能实现点对点直连。
五、深一层:集群容错到底是怎么跑起来的
前面讲的负载均衡和容错是分开介绍的,但实际调用的时候,它们其实是一条链路上的两个环节,理解这条链路才能说是真正理解了"集群容错"这四个字。
一次消费方调用的大致流程是这样的(以Failover为例):
- 消费方拿到的其实不是一个"具体的服务提供方",而是一个ClusterInvoker(对应你选的容错策略,比如FailoverClusterInvoker)。这一层是伪装成"一个服务"的外壳,实际内部管理着一整个服务集群。
- 调用发生时,ClusterInvoker先去问Directory要一份当前可用的Invoker列表------Directory是对注册中心里这个服务所有可用地址的一层封装,会自动感知机器上下线(通过订阅注册中心的变化)。
- 拿到列表后,会先过一遍Router(路由规则),比如你配置了"只调用同机房的机器"这种规则,Router在这一步就把不符合条件的机器过滤掉。
- 过滤后的列表交给LoadBalance,也就是前面讲的Random、RoundRobin这些策略,从中选出一个具体的Invoker。
- 真正发起调用,如果这次调用抛出了异常(网络问题或者服务端异常),FailoverClusterInvoker会捕获这个异常,回到第2步重新拿列表(这里会把刚才失败的这台机器排除掉),再走一遍负载均衡选一台新的机器,直到达到
retries设置的重试次数上限。
理解这条链路之后,前面说的几个容易踩坑的地方就有了更清楚的解释:
- 为什么Failover容错下"重试"不会重试到刚才失败的那台机器------因为第5步明确会把失败的Invoker排除掉再选。
- 为什么
timeout和retries会叠加------因为每一次重试,本质上是重新走了一遍完整的"拿列表→路由→负载均衡→调用"流程,每一轮都会重新计时。 - 为什么Router可以做机房隔离、灰度发布------因为它是在负载均衡之前生效的一道过滤,负载均衡只会在Router筛选剩下的机器里做选择。
从这个链路也能看出来,Dubbo的设计思路其实是责任链模式的一次典型应用:Directory管"有哪些机器",Router管"这些机器里哪些能用",LoadBalance管"能用的里面挑哪个",ClusterInvoker管"挑出来的这个调用失败了怎么办"。每一层职责单一、可以独立替换(都支持SPI扩展自定义实现),这也是为什么Dubbo虽然功能复杂,但源码读起来层次是很清楚的。
写在最后
回到开头那个问题------Dubbo解决的核心问题,从来不是"怎么发起一次远程调用",这件事本身HTTP就能干。它真正的价值在于把"这次调用打给谁""打失败了怎么办""怎么保证这个过程对业务代码透明"这几个分布式系统里躲不掉的问题,做成了可插拔、可配置的标准组件。
实际项目里我的建议是:负载均衡策略别一股脑用默认值,想清楚接口的调用特性(响应时间是否稳定、是否需要状态亲和);容错策略先问自己一句"这个接口幂等吗",再决定能不能用Failover;timeout和retries一定要按接口实际表现去调,不要用默认值应付所有场景------很多线上故障的根源,往往不是Dubbo这个框架的问题,而是这几个配置项没有匹配接口的真实特性。