Dubbo源码解析:服务发现与负载均衡
场景:一个订单服务如何找到它的库存服务?
假设有20个库存服务实例,订单服务调用inventoryService.deduct()时,Dubbo客户端怎么知道该调哪个IP?这背后是服务发现和负载均衡两步动作。
先看核心接口。
一、服务发现:RegistryDirectory 的订阅机制
Dubbo的服务发现不是客户端每次调用都去注册中心拉取全量列表。它是订阅-推送模式。
public class RegistryDirectory<T> implements Directory<T> {
private final Map<String, List<Invoker<T>>> methodInvokerMap;
private final ConcurrentMap<URL, Map<String, List<Invoker<T>>>> urlInvokerMap;
public void subscribe(URL url) {
// 向注册中心注册监听器
registry.subscribe(url, new NotifyListener() {
public void notify(List<URL> urls) {
// 收到变更推送,重建invoker列表
refreshInvoker(urls);
}
});
}
}
关键点在refreshInvoker()。Zookeeper推送的是一批URL,每个URL代表一个服务实例。Dubbo把这些URL包装成Invoker(调用器),按方法名分组成methodInvokerMap。每次推送都全量替换,不是增量更新——这简化了状态管理,代价是频繁推送时重建开销大。
踩坑警告:refreshInvoker里有个destroy旧Invoker的逻辑。如果消费者同时有正在进行的调用,销毁Invoker会导致连接中断。所以这里的销毁是延迟的——先标记destroyed=true,等调用结束才真正关闭连接。
二、负载均衡:AbstractLoadBalance 的策略骨架
服务列表有了,选哪个?负载均衡接口定义了选择逻辑:
public interface LoadBalance {
<T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) {
if (invokers.size() == 1) return invokers.get(0);
return doSelect(invokers, url, invocation);
}
}
模板方法模式。单实例直接返回,多实例才走策略。Dubbo内置四种策略:Random(加权随机)、RoundRobin(加权轮询)、LeastActive(最少活跃调用)、ConsistentHash(一致性哈希)。
三、加权随机:Double 精度陷阱
最常用的是RandomLoadBalance。但它的加权不是简单按权重占比取整,而是用分段积分数:
public class RandomLoadBalance extends AbstractLoadBalance {
protected Invoker doSelect(List<Invoker> invokers, URL url, Invocation invocation) {
int length = invokers.size();
int totalWeight = 0;
boolean sameWeight = true;
for (int i = 0; i < length; i++) {
int weight = getWeight(invokers.get(i), invocation);
totalWeight += weight;
if (sameWeight && i > 0
&& weight != getWeight(invokers.get(i - 1), invocation)) {
sameWeight = false;
}
}
if (totalWeight > 0 && !sameWeight) {
int offset = ThreadLocalRandom.current().nextInt(totalWeight);
for (int i = 0; i < length; i++) {
offset -= getWeight(invokers.get(i), invocation);
if (offset < 0) return invokers.get(i);
}
}
return invokers.get(ThreadLocalRandom.current().nextInt(length));
}
}
这段代码解决的是权重动态变化的问题。getWeight()会读取URL上的weight参数,该参数可能被监控中心动态调整。如果每次循环都重新计算权重,总权重可能不一致,所以先扫描一遍确认权重是否相同。
关键点在offset -= weight的递减逻辑。这避免了浮点数的精度问题——用整数做减法,命中区间即返回。所有权重相同时,退化为纯随机,不产生额外开销。
容易踩坑:权重为0的实例仍可能被选中。getWeight()返回0时,offset不会减少,但循环会继续走,最终落到最后一个权重>0的实例上。这在预热场景下是对的——新启动的实例权重从0开始,但一旦所有实例都是0,退化为随机选。
四、最少活跃:从 Nginx 抄来的思路
LeastActiveLoadBalance的思路来自Nginx的least_conn。维护一个活跃调用数计数器,每次调用前比较:
protected Invoker doSelect(List<Invoker> invokers, URL url, Invocation invocation) {
int length = invokers.size();
int leastActive = -1;
int leastCount = 0;
int[] leastIndexes = new int[length];
int totalWeight = 0;
int firstWeight = 0;
boolean sameWeight = true;
for (int i = 0; i < length; i++) {
Invoker invoker = invokers.get(i);
int active = RpcStatus.getStatus(invoker.getUrl(),
invocation.getMethodName()).getActive();
int weight = invoker.getUrl().getMethodParameter(
invocation.getMethodName(), Constants.WEIGHT_KEY, Constants.DEFAULT_WEIGHT);
if (leastActive == -1 || active < leastActive) {
leastActive = active;
leastCount = 1;
leastIndexes[0] = i;
totalWeight = weight;
firstWeight = weight;
sameWeight = true;
} else if (active == leastActive) {
leastIndexes[leastCount++] = i;
totalWeight += weight;
if (sameWeight && weight != firstWeight) {
sameWeight = false;
}
}
}
// 活跃数最少的只有一个,直接返回
if (leastCount == 1) return invokers.get(leastIndexes[0]);
// 多个最少活跃的,按权重随机挑
if (!sameWeight && totalWeight > 0) {
int offsetWeight = ThreadLocalRandom.current().nextInt(totalWeight);
for (int i = 0; i < leastCount; i++) {
int leastIndex = leastIndexes[i];
offsetWeight -= getWeight(invokers.get(leastIndex), invocation);
if (offsetWeight < 0) return invokers.get(leastIndex);
}
}
return invokers.get(leastIndexes[ThreadLocalRandom.current().nextInt(leastCount)]);
}
这代码看着长,逻辑就三步:找最小活跃数、如果多个就按权重挑、权重一样就随机。
RpcStatus.getActive()是Dubbo的调用计数器,在Filter链里维护。每进入一个调用active++,调用结束active--。这里有个并发问题:getActive()不是原子的,可能读到脏值。但没关系,负载均衡本来就是概率选择,差一两个计数不影响整体分布。
五、一致性哈希:解决缓存失效问题
ConsistentHashLoadBalance解决的是同一用户的请求打到不同实例的问题。比如用户会话存本地内存,每次路由到不同机器就丢了。
public class ConsistentHashLoadBalance extends AbstractLoadBalance {
private final ConcurrentMap<String, ConsistentHashSelector> selectors =
new ConcurrentHashMap<String, ConsistentHashSelector>();
protected Invoker doSelect(List<Invoker> invokers, URL url, Invocation invocation) {
String key = url.toServiceString() + "." + invocation.getMethodName();
ConsistentHashSelector selector = selectors.get(key);
if (selector == null || selector.identityHashCode != invokers.hashCode()) {
selectors.put(key, new ConsistentHashSelector(invokers,
url.getMethodParameter(invocation.getMethodName(), "hash.nodes", 160)));
selector = selectors.get(key);
}
return selector.select(invocation);
}
}
identityHashCode是判断服务列表是否变化的依据。列表一变,重建哈希环。每个物理节点生成160个虚拟节点,分散在哈希环上,避免数据倾斜。
为什么是160? 这是默认值,hash.nodes参数可调。160意味着每个物理节点有160个虚拟节点,环上总共3200个点。节点数量少时,虚拟节点越多分布越均匀。但虚拟节点太多会增加内存占用,160是实践折中的值。
六、设计模式串联
这套设计用了三个经典模式:
| 模式 | 位置 | 解决什么问题 |
|---|---|---|
| 观察者模式 | RegistryDirectory 订阅注册中心 | 服务列表变化时自动通知,不用轮询 |
| 模板方法模式 | AbstractLoadBalance | 固定"单实例直接返回"骨架,策略只实现doSelect |
| 策略模式 | LoadBalance 接口 + 四种实现 | 运行时切换负载均衡算法,不需要改调用方代码 |
观察者模式是整个服务发现的基石。注册中心是观察目标,RegistryDirectory是观察者。它订阅了/dubbo/com.example.InventoryService/providers路径,节点变化时Zookeeper推送事件,触发refreshInvoker。这个设计让服务发现从"客户端轮询"变成"服务端推送",延迟从秒级降到毫秒级。
七、踩坑总结
- 服务列表频繁变更时,
refreshInvoker重建开销大。老版本Dubbo会阻塞在锁上,新版本用CopyOnWrite思想优化,但仍有性能损耗。建议控制实例上下线频率。
- 权重预热陷阱:默认权重100,但新实例注册时可能带
warmup参数。getWeight()里有预热逻辑——启动后前10分钟权重从0渐增到配置值。这是防止刚启动的实例被大量请求打垮。
- 一致性哈希的坑:
identityHashCode是invokers.hashCode(),但ArrayList的hashCode是内容哈希。列表顺序变了(比如Zookeeper推送顺序调整),哈希值就变,导致哈希环重建。频繁重建会失去一致性哈希的意义——缓存全部失效。
下一步行动:建议在测试环境用dubbo-admin手动调整某个服务的权重,观察负载均衡行为。再压测时开LeastActiveLoadBalance,对比与随机策略的吞吐差异。实际生产环境,优先用LeastActive,它比Random更能自动适应性能不均的实例。
本文关键词:Dubbo、服务发现、负载均衡、一致性哈希、LeastActive