2.3 ConsistentHashLoadBalance

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

2.3 ConsistentHashLoadBalance - 图1

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

2.3 ConsistentHashLoadBalance - 图2

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

2.3 ConsistentHashLoadBalance - 图3

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

到这里背景知识就普及完了,接下来开始分析源码。我们先从 ConsistentHashLoadBalance 的 doSelect 方法开始看起,如下:

  1. public class ConsistentHashLoadBalance extends AbstractLoadBalance {
  2. private final ConcurrentMap<String, ConsistentHashSelector<?>> selectors =
  3. new ConcurrentHashMap<String, ConsistentHashSelector<?>>();
  4. @Override
  5. protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
  6. String methodName = RpcUtils.getMethodName(invocation);
  7. String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;
  8. // 获取 invokers 原始的 hashcode
  9. int identityHashCode = System.identityHashCode(invokers);
  10. ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);
  11. // 如果 invokers 是一个新的 List 对象,意味着服务提供者数量发生了变化,可能新增也可能减少了。
  12. // 此时 selector.identityHashCode != identityHashCode 条件成立
  13. if (selector == null || selector.identityHashCode != identityHashCode) {
  14. // 创建新的 ConsistentHashSelector
  15. selectors.put(key, new ConsistentHashSelector<T>(invokers, methodName, identityHashCode));
  16. selector = (ConsistentHashSelector<T>) selectors.get(key);
  17. }
  18. // 调用 ConsistentHashSelector 的 select 方法选择 Invoker
  19. return selector.select(invocation);
  20. }
  21. private static final class ConsistentHashSelector<T> {...}
  22. }

如上,doSelect 方法主要做了一些前置工作,比如检测 invokers 列表是不是变动过,以及创建 ConsistentHashSelector。这些工作做完后,接下来开始调用 ConsistentHashSelector 的 select 方法执行负载均衡逻辑。在分析 select 方法之前,我们先来看一下一致性 hash 选择器 ConsistentHashSelector 的初始化过程,如下:

  1. private static final class ConsistentHashSelector<T> {
  2. // 使用 TreeMap 存储 Invoker 虚拟节点
  3. private final TreeMap<Long, Invoker<T>> virtualInvokers;
  4. private final int replicaNumber;
  5. private final int identityHashCode;
  6. private final int[] argumentIndex;
  7. ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {
  8. this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
  9. this.identityHashCode = identityHashCode;
  10. URL url = invokers.get(0).getUrl();
  11. // 获取虚拟节点数,默认为160
  12. this.replicaNumber = url.getMethodParameter(methodName, "hash.nodes", 160);
  13. // 获取参与 hash 计算的参数下标值,默认对第一个参数进行 hash 运算
  14. String[] index = Constants.COMMA_SPLIT_PATTERN.split(url.getMethodParameter(methodName, "hash.arguments", "0"));
  15. argumentIndex = new int[index.length];
  16. for (int i = 0; i < index.length; i++) {
  17. argumentIndex[i] = Integer.parseInt(index[i]);
  18. }
  19. for (Invoker<T> invoker : invokers) {
  20. String address = invoker.getUrl().getAddress();
  21. for (int i = 0; i < replicaNumber / 4; i++) {
  22. // 对 address + i 进行 md5 运算,得到一个长度为16的字节数组
  23. byte[] digest = md5(address + i);
  24. // 对 digest 部分字节进行4次 hash 运算,得到四个不同的 long 型正整数
  25. for (int h = 0; h < 4; h++) {
  26. // h = 0 时,取 digest 中下标为 0 ~ 3 的4个字节进行位运算
  27. // h = 1 时,取 digest 中下标为 4 ~ 7 的4个字节进行位运算
  28. // h = 2, h = 3 时过程同上
  29. long m = hash(digest, h);
  30. // 将 hash 到 invoker 的映射关系存储到 virtualInvokers 中,
  31. // virtualInvokers 需要提供高效的查询操作,因此选用 TreeMap 作为存储结构
  32. virtualInvokers.put(m, invoker);
  33. }
  34. }
  35. }
  36. }
  37. }

ConsistentHashSelector 的构造方法执行了一系列的初始化逻辑,比如从配置中获取虚拟节点数以及参与 hash 计算的参数下标,默认情况下只使用第一个参数进行 hash。需要特别说明的是,ConsistentHashLoadBalance 的负载均衡逻辑只受参数值影响,具有相同参数值的请求将会被分配给同一个服务提供者。ConsistentHashLoadBalance 不 关系权重,因此使用时需要注意一下。

在获取虚拟节点数和参数下标配置后,接下来要做的事情是计算虚拟节点 hash 值,并将虚拟节点存储到 TreeMap 中。到此,ConsistentHashSelector 初始化工作就完成了。接下来,我们来看看 select 方法的逻辑。

  1. public Invoker<T> select(Invocation invocation) {
  2. // 将参数转为 key
  3. String key = toKey(invocation.getArguments());
  4. // 对参数 key 进行 md5 运算
  5. byte[] digest = md5(key);
  6. // 取 digest 数组的前四个字节进行 hash 运算,再将 hash 值传给 selectForKey 方法,
  7. // 寻找合适的 Invoker
  8. return selectForKey(hash(digest, 0));
  9. }
  10. private Invoker<T> selectForKey(long hash) {
  11. // 到 TreeMap 中查找第一个节点值大于或等于当前 hash 的 Invoker
  12. Map.Entry<Long, Invoker<T>> entry = virtualInvokers.tailMap(hash, true).firstEntry();
  13. // 如果 hash 大于 Invoker 在圆环上最大的位置,此时 entry = null,
  14. // 需要将 TreeMap 的头节点赋值给 entry
  15. if (entry == null) {
  16. entry = virtualInvokers.firstEntry();
  17. }
  18. // 返回 Invoker
  19. return entry.getValue();
  20. }

如上,选择的过程相对比较简单了。首先是对参数进行 md5 以及 hash 运算,得到一个 hash 值。然后再拿这个值到 TreeMap 中查找目标 Invoker 即可。

到此关于 ConsistentHashLoadBalance 就分析完了。在阅读 ConsistentHashLoadBalance 源码之前,大家一定要先补充背景知识,不然很难看懂代码逻辑。