0%

【踩坑】Dubbo服务Provider上下线导致Consumer端阻塞

Dubbo 之类的RPC调用需要采用负载均衡算法,负载均衡目的是是将网络请求,或者其他形式的负载“均摊”到不同的机器上。在 Dubbo 中有四种实现方法,在业务中比较常用的就是一致性哈希算法,它能够保证同一个 key 的请求能放到同一台机器上,主要针对一些具有缓存的业务。
在 Dubbo 服务 Provider 上下线时,因为需要重新计算一致性哈希的原因,会导致消费端线程堵塞。

问题发生

问题分析

在服务端正常重启 Dubbo 服务时,接收到了 Consumer 端的机器报警,Cunsumer 端虚拟机的 Load 在重启阶段快速升高,在重启完后又很快下降。因为是稳定复现的线上问题,于是需要进行排查。

利用在线服务诊断平台 [bistoury] (https://github.com/qunarcorp/bistoury)的记录, JStack 打印了当时机器升高时的异常栈,发现有大量线程在进行 Dubbo 调用,点开其线程栈后发现几乎所有的线程都在 com.alibaba.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance$ConsistentHashSelector.(ConsistentHashSelector.java:117) 这里卡住了,这里是是重新计算一致性哈希节点时的代码。

经过对一致性哈希源码的分析,发现是在线上高并发场景下,所有的调用都在重新计算哈希节点,计算耗费 CPU 时间,导致所有的任务都卡住了。

1
2
3
4
5
6
7
//经过简化的业务代码
executorService.submit(param0);
for (String params : lists) {
executorService.submit(new RTSearch(params.get[0]));
executorService.submit(new RTSearch(params.get[1]));
executorService.submit(new RTSearch(params.get[2]));
}

这是进行 Dubbo调用的地方,可以看出在一瞬间消费端会并发发出大量的请求(通过分析日志是 30ms 内有 32 个请求),而通过单测在线上配置虚拟节点为 640 的情况下,一致性哈希的计算时间为 28ms,所以同时计算占用了大量 CPU 时间,导致系统 Load 升高。

解决方案

通过对这边代码的分析,得出解决方案为:

  1. 降低虚拟节点数量,这个可以有效降低一致性哈希的计算时间,降低 CPU 占用。但是会导致一定程度上哈希虚拟节点分布不够均匀,产生热点 key 和 热点机器。
  2. 对 Dubbo 提供的一致性哈希算法进行重写,对重新计算哈希节点的代码进行加锁,只要有一个正在计算让其他线程都进入 wait 状态。这样可以保证不用每个线程都重新计算哈希节点。但是需要考虑锁释放后其他线程的惊群效应,让系统状态恶化。

源码分析

一致性哈希算法

通过参考 Dubbo 官方文档中有关 负载均衡 的内容,分析其一致性哈希算法的实现。

一致性 hash 算法由麻省理工学院的 Karger 及其合作者于1997年提出的,算法提出之初是用于大规模缓存系统的负载均衡。它的工作过程是这样的,首先根据 ip 或者其他的信息为缓存节点生成一个 hash,并将这个 hash 投射到 [0, 232 - 1] 的圆环上。当有查询或写入请求时,则为缓存项的 key 生成一个 hash 值。然后查找第一个大于或等于该 hash 值的缓存节点,并到这个节点中查询或写入缓存项。如果当前节点挂了,则在下一次查询或写入缓存时,为缓存项查找另一个大于其 hash 值的缓存节点即可。大致效果如下图所示,每个缓存节点在圆环上占据一个位置。如果缓存项的 key 的 hash 值小于缓存节点 hash 值,则到该缓存节点中存储或读取缓存项。比如下面绿色点对应的缓存项将会被存储到 cache-2 节点中。由于 cache-3 挂了,原本应该存到该节点中的缓存项最终会存储到 cache-4 节点中。

下面来看看一致性 hash 在 Dubbo 中的应用。我们把上图的缓存节点替换成 Dubbo 的服务提供者,于是得到了下图:

这里相同颜色的节点均属于同一个服务提供者,比如 Invoker1-1,Invoker1-2,……, Invoker1-160。这样做的目的是通过引入虚拟节点,让 Invoker 在圆环上分散开来,避免数据倾斜问题。所谓数据倾斜是指,由于节点不够分散,导致大量请求落到了同一个节点上,而其他节点只会接收到了少量请求的情况。比如:

如上,由于 Invoker-1 和 Invoker-2 在圆环上分布不均,导致系统中75%的请求都会落到 Invoker-1 上,只有 25% 的请求会落到 Invoker-2 上。解决这个问题办法是引入虚拟节点,通过虚拟节点均衡各个节点的请求量。

一致性哈希算法的入口在com.alibaba.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance#doSelect中。主要对 invokers 进行了判断,如果 invokers 是一个新的 List 对象,意味着 Provider 数量发生了变化。

1
2
3
4
if (selector == null || selector.identityHashCode != identityHashCode) {
selectors.put(key, new ConsistentHashSelector<T>(this, invokers, invocation.getMethodName(), identityHashCode));
selector = (ConsistentHashSelector<T>) selectors.get(key);
}

以上是判断了哈希环是否需要初始化,以下就是初始化的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
private static final class ConsistentHashSelector<T> {

// 使用 TreeMap 存储 Invoker 虚拟节点
private final TreeMap<Long, Invoker<T>> virtualInvokers;

private final int replicaNumber;

private final int identityHashCode;

private final int[] argumentIndex;

ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {
this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
this.identityHashCode = identityHashCode;
URL url = invokers.get(0).getUrl();
// 获取参与 hash 计算的参数下标值,默认对第一个参数进行 hash 运算
String[] index = Constants.COMMA_SPLIT_PATTERN.split(loadBalance.getHashArguments(url, methodName));
argumentIndex = new int[index.length];
for (int i = 0; i < index.length; i++) {
argumentIndex[i] = Integer.parseInt(index[i]);
}

//虚拟节点数

int replicaNumber = loadBalance.getHashNodes(url, methodName);
for (Invoker<T> invoker : invokers) {
String address = invoker.getUrl().getAddress();
for (int i = 0; i < replicaNumber / 4; i++) {
// 对 address + i 进行 md5 运算,得到一个长度为16的字节数组
byte[] digest = md5(address + i);
// 对 digest 部分字节进行4次 hash 运算,得到四个不同的 long 型正整数
for (int h = 0; h < 4; h++) {
// h = 0 时,取 digest 中下标为 0 ~ 3 的4个字节进行位运算
// h = 1 时,取 digest 中下标为 4 ~ 7 的4个字节进行位运算
// h = 2, h = 3 时过程同上
long m = hash(digest, h);
// 将 hash 到 invoker 的映射关系存储到 virtualInvokers 中,
// virtualInvokers 需要提供高效的查询操作,因此选用 TreeMap 作为存储结构
virtualInvokers.put(m, invoker);
}
}
}
}
}

在初始化完成或不需要初始化的情况下,调用 select 方法选择 Invoker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public Invoker<T> select(Invocation invocation) {
// 将参数转为 key
String key = toKey(invocation.getArguments());
// 对参数 key 进行 md5 运算
byte[] digest = md5(key);
// 取 digest 数组的前四个字节进行 hash 运算,再将 hash 值传给 selectForKey 方法,
// 寻找合适的 Invoker
return selectForKey(hash(digest, 0));
}

// 将参数拼接为 Key
private String toKey(Object[] args) {
StringBuilder buf = new StringBuilder();
for (int i : argumentIndex) {
if (i >= 0 && i < args.length) {
buf.append(args[i]);
}
}
return buf.toString();
}

private Invoker<T> selectForKey(long hash) {
Invoker<T> invoker;
Long key = hash;

// 到 TreeMap 中查找第一个节点值大于或等于当前 hash 的 Invoker
//使用 TailMap 获取比该节点大的子 Map,获取第一个。若子Map为空,则获取全局第一个节点
if (!virtualInvokers.containsKey(key)) {
SortedMap<Long, Invoker<T>> tailMap = virtualInvokers.tailMap(key);
if (tailMap.isEmpty()) {
key = virtualInvokers.firstKey();
} else {
key = tailMap.firstKey();
}
}
invoker = virtualInvokers.get(key);
// 返回 Invoker
return invoker;
}

以上就是一致性哈希计算的主要过程,就是将参数进行 md5 和 hash 计算,得到一个 hash 值。然后再拿这个值到 TreeMap 中查找目标 Invoker 即可。