redis 分布式一致性hash

164 阅读7分钟

1. 简介

当我们使用分布式集群时,一般都要处理负载均衡问题,每台节点服务器的请求两要尽量均匀,且保证一定的扩缩容问题,常规方案有轮询或者hash映射

轮询一般只在无状态的服务中使用,像数据库或者缓存服务中一般不大使用

hash映射,最简单的就是hash对节点取模,这样固定key的数据就会去同一台缓存服务器存取,但是当节点服务器存在扩缩容的问题时,如果依旧使用hash取模的方式,映射便全部失效了

故业界常使用一致性hash的方式来解决分布式集群负载均衡问题,即通过构建环状的Hash空间代替线性Hash空间

3b4231ff4b4baec92c9303c6dab08821.jpg

整个Hash空间被构建成一个首尾相接的环,使用一致性Hash时需要进行两次映射。第一次,给每个节点(集群)计算Hash,然后记录它们的Hash值,这就是它们在环上的位置。第二次,给每个Key计算Hash,然后沿着顺时针的方向找到环上的第一个节点,就是该Key储存对应的集群。

当节点进行扩缩容的时候,负载均衡受到的影响如下图所示:

cf5ebf2d5a5df97b9e47ffdbbb4f5bb9.jpg

2. 一致性hash存在的问题

2.1 数据倾斜

对节点进行hash时,当节点数量比较少,大部分情况下节点在环上的位置会很不均匀,挤在某个很小的区域.集群的每个实例上储存的缓存数据量不一致,会发生严重的数据倾斜。

2.2 缓存雪崩

如果每个节点再hash环上只有一个节点如node1,node2,node3 当node0节点奔溃时,node0上的所有请求会分配到node1上,如果此时node1承受不住压力奔溃,则更大的请求压力打到node3上,造成雪崩式奔溃

3.解决方案

3.1虚拟节点

一个实际节点将会映射多个虚拟节点,这样Hash环上的空间分割就会变得均匀。同时,引入虚拟节点还会使得节点在Hash环上的顺序随机化,这意味着当一个真实节点失效退出后,它原来所承载的压力将会均匀地分散到其他节点上去。如下图:

42fa1f589aebf9c5b284b057eb8f6477.jpg

4.代码测试

4.1 hash函数选择

  1. md5
  2. murmurhash
  3. crc

4.2 hash环的数据结构

  1. 数组或链表,查找的时候需要排序,节点数量多时,效率慢
  2. 二叉平衡树, 写少读多,效率高,具体实现使用java的treeMap(红黑树)

4.3 无虚拟节点代码

public class ConsistentHashingWithoutVirtualNode {

    /**
     * 集群地址列表
     */
    private static String[] groups = {
        "192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
        "192.168.0.3:111", "192.168.0.4:111"
    };

    /**
     * 用于保存Hash环上的节点
     */
    private static SortedMap<Integer, String> sortedMap = new TreeMap<>();

    /**
     * 初始化,将所有的服务器加入Hash环中
     */
    static {
        // 使用红黑树实现,插入效率比较差,但是查找效率极高
        for (String group : groups) {
            int hash = HashUtil.getHash(group);
            System.out.println("[" + group + "] launched @ " + hash);
            sortedMap.put(hash, group);
        }
    }

    /**
     * 计算对应的widget加载在哪个group上
     *
     * @param widgetKey
     * @return
     */
    private static String getServer(String widgetKey) {
        int hash = HashUtil.getHash(widgetKey);
        // 只取出所有大于该hash值的部分而不必遍历整个Tree
        SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
        if (subMap == null || subMap.isEmpty()) {
            // hash值在最尾部,应该映射到第一个group上
            return sortedMap.get(sortedMap.firstKey());
        }
        return subMap.get(subMap.firstKey());
    }

    public static void main(String[] args) {
        // 生成随机数进行测试
        Map<String, Integer> resMap = new HashMap<>();

        for (int i = 0; i < 100000; i++) {
            Integer widgetId = (int)(Math.random() * 10000);
            String server = getServer(widgetId.toString());
            if (resMap.containsKey(server)) {
                resMap.put(server, resMap.get(server) + 1);
            } else {
                resMap.put(server, 1);
            }
        }

        resMap.forEach(
            (k, v) -> {
                System.out.println("group " + k + ": " + v + "(" + v/1000.0D +"%)");
            }
        );

	//group 192.168.0.2:111: 8572(8.572%)
	//group 192.168.0.1:111: 18693(18.693%)
	//group 192.168.0.4:111: 17764(17.764%)
	//group 192.168.0.3:111: 27870(27.87%)
	//group 192.168.0.0:111: 27101(27.101%)
    }
}

由上可见,10万次实验数据分配不均匀

4.4带虚拟节点代码

public class ConsistentHashingWithVirtualNode {
    /**
     * 集群地址列表
     */
    private static String[] groups = {
        "192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
        "192.168.0.3:111", "192.168.0.4:111"
    };

    /**
     * 真实集群列表
     */
    private static List<String> realGroups = new LinkedList<>();

    /**
     * 虚拟节点映射关系
     */
    private static SortedMap<Integer, String> virtualNodes = new TreeMap<>();

    private static final int VIRTUAL_NODE_NUM = 1000;

    static {
        // 先添加真实节点列表
        realGroups.addAll(Arrays.asList(groups));

        // 将虚拟节点映射到Hash环上
        for (String realGroup: realGroups) {
            for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
		// 真实节点和虚拟节点的映射采用了字符串拼接的方式,这种方式虽然简单但很有效
                String virtualNodeName = getVirtualNodeName(realGroup, i);
                int hash = HashUtil.getHash(virtualNodeName);
                System.out.println("[" + virtualNodeName + "] launched @ " + hash);
                virtualNodes.put(hash, virtualNodeName);
            }
        }
    }

    private static String getVirtualNodeName(String realName, int num) {
        return realName + "&&VN" + String.valueOf(num);
    }

    private static String getRealNodeName(String virtualName) {
        return virtualName.split("&&")[0];
    }

    private static String getServer(String widgetKey) {
        int hash = HashUtil.getHash(widgetKey);
        // 只取出所有大于该hash值的部分而不必遍历整个Tree
        SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash);
        String virtualNodeName;
        if (subMap == null || subMap.isEmpty()) {
            // hash值在最尾部,应该映射到第一个group上
            virtualNodeName = virtualNodes.get(virtualNodes.firstKey());
        }else {
            virtualNodeName = subMap.get(subMap.firstKey());
        }
        return getRealNodeName(virtualNodeName);
    }

    public static void main(String[] args) {
        // 生成随机数进行测试
        Map<String, Integer> resMap = new HashMap<>();

        for (int i = 0; i < 100000; i++) {
            Integer widgetId = i;
            String group = getServer(widgetId.toString());
            if (resMap.containsKey(group)) {
                resMap.put(group, resMap.get(group) + 1);
            } else {
                resMap.put(group, 1);
            }
        }

        resMap.forEach(
            (k, v) -> {
                System.out.println("group " + k + ": " + v + "(" + v/100000.0D +"%)");
            }
        );
	//group 192.168.0.2:111: 18354(18.354%)
	//group 192.168.0.1:111: 20062(20.062%)
	//group 192.168.0.4:111: 20749(20.749%)
	//group 192.168.0.3:111: 20116(20.116%)
	//group 192.168.0.0:111: 20719(20.719%)
    }
}

当产生节点数量扩缩容时:

private static void refreshHashCircle() {
    // 当集群变动时,刷新hash环,其余的集群在hash环上的位置不会发生变动
  virtualNodes.clear();
    for (String realGroup: realGroups) {
      for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
           String virtualNodeName = getVirtualNodeName(realGroup, i);
            int hash = HashUtil.getHash(virtualNodeName);
            System.out.println("[" + virtualNodeName + "] launched @ " + hash);
            virtualNodes.put(hash, virtualNodeName);
        }
    }
}
private static void addGroup(String identifier) {
  realGroups.add(identifier);
    refreshHashCircle();
}

private static void removeGroup(String identifier) {
    int i = 0;
    for (String group:realGroups) {
      if (group.equals(identifier)) {
          realGroups.remove(i);
        }
        i++;
    }
    refreshHashCircle();
}

//running the normal test.
//group 192.168.0.2:111: 19144(19.144%)
//group 192.168.0.1:111: 20244(20.244%)
//group 192.168.0.4:111: 20923(20.923%)
//group 192.168.0.3:111: 19811(19.811%)
//group 192.168.0.0:111: 19878(19.878%)
//removed a group, run test again.
//group 192.168.0.2:111: 23409(23.409%)
//group 192.168.0.1:111: 25628(25.628%)
//group 192.168.0.4:111: 25583(25.583%)
//group 192.168.0.0:111: 25380(25.38%)

综上可以得出结论,在引入足够多的虚拟节点后,一致性hash还是能够比较完美地满足负载均衡需要的。

5. 优雅扩缩容

5.1 问题

缓存服务器对于性能有着较高的要求,因此我们希望在扩容时新的集群能够较快的填充好数据并工作。但是从一个集群启动,到真正加入并可以提供服务之间还存在着不小的时间延迟

缩容后,剩余各个节点上的访问压力都会有所增加,此时如果某个节点因为压力过大而宕机,就可能会引发连锁反应。

如果你使用一个集群来作为负载均衡,并使用一个配置服务器比如ConfigServer来推送集群状态以构建Hash环,那么在某个集群退出时这个状态并不一定会被立刻同步到所有的LB上,这就可能会导致一个暂时的调度不一致,如果某台LB错误地将请求打到了已经退出的集群上,就会导致缓存击穿,如下图:

ed032371306a031abed2f92115346eb4.png

5.2 扩容解决方案

5.2.1 高频key预热

负载均衡器作为路由层,是可以收集并统计每个缓存Key的访问频率的,如果能够维护一份高频访问Key的列表,新的集群在启动时根据这个列表提前拉取对应Key的缓存值进行预热,便可以大大减少因为新增集群而导致的Key失效。具体的设计可以通过缓存来实现,如下:

44a25757538fe0799346019db266200b.jpg

不过这个方案在实际使用时有一个很大的限制,那就是高频Key本身的缓存失效时间可能很短,预热时储存的Value在实际被访问到时可能已经被更新或者失效,处理不当会导致出现脏数据,因此实现难度还是有一些大的。

5.2.2 历史Hash环保留

新增节点后,它所对应的Key在原来的节点保留一段时间。因此在扩容的延迟时间段,如果对应的Key缓存在新节点上还没有被加载,可以去原有的节点上尝试读取

46d5c70d0f0fc698392315f566cbbd35.jpg

5.3 缩容解决方案

5.3.1 熔断机制

作为兜底方案,应当给每个集群设立对应熔断机制来保护服务的稳定性。

5.3.2 缓慢缩容

等到Hash环完全同步后再操作。可以通过监听退出集群的访问QPS来实现这一点,等到该集群几乎没有QPS时再将其撤下。

5.3.3 手动删除

如果Hash环上对应的节点找不到了,就手动将其从Hash环上删除,然后重新进行调度,这个方式有一定的风险,对于网络抖动等异常情况兼容的不是很好。

5.3.4 主动拉取和重试

当Hash环上节点失效时,主动从ZK上重新拉取集群状态来构建新Hash环,在一定次数内可以进行多次重试。

6 redis的HashSlot

参考文档 : 一致性 Hash 是什么?在负载均衡中的应用 (qq.com)

6.1 一致性hash存在的问题

  • 整个分布式缓存需要一个路由服务来做负载均衡,存在单点问题(如果路由服务挂了,整个缓存也就凉了)
  • Hash环上的节点非常多或者更新频繁时,查找性能会比较低下

6.2 原理

image.png

记录和物理机之间引入了虚拟桶层,记录通过hash函数映射到虚拟桶,记录和虚拟桶是多对一的关系;

第二层是虚拟桶和物理机之间的映射,同样也是多对一的关系,即一个物理机对应多个虚拟桶,这个层关系是通过内存表实现的。对照抽象模型章节,key-partition是通过hash函数实现的,partition-machine是通过内存表来实现的。

redis cluster 默认分配了 16384(2^14)个slot,当我们set一个key 时,会用CRC16算法来取模得到所属的slot,然后将这个key 分到哈希槽区间的节点上,具体算法就是:CRC16(key) % 16384

  • 每个节点都保存有完整的HashSlot - 节点映射表,也就是说,每个节点都知道自己拥有哪些Slot,以及某个确定的Slot究竟对应着哪个节点。
  • 无论向哪个节点发出寻找Key的请求,该节点都会通过CRC(Key) % 16384计算该Key究竟存在于哪个Slot,并将请求转发至该Slot所在的节点。

943b0f9a90381ad92c1ce7854c2b0ef7.png

假设现在有3个节点已经组成了集群,分别是:A, B, C 三个节点,它们可以是一台机器上的三个端口,也可以是三台不同的服务器。那么,采用哈希槽 (hash slot)的方式来分配16384个slot 的话,它们三个节点分别承担的slot 区间是:

  • 节点A覆盖0-5460;
  • 节点B覆盖5461-10922;
  • 节点C覆盖10923-16383.

这种将哈希槽分布到不同节点的做法使得用户可以很容易地向集群中添加或者删除节点。 比如说:

  • 如果用户将新节点 D 添加到集群中, 那么集群只需要将节点 A 、B 、 C 中的某些槽移动到节点 D 就可以了。比如我想新增一个节点D,redis cluster的这种做法是从各个节点的前面各拿取一部分slot到D上。大致就会变成这样:

    • 节点A覆盖1365-5460
    • 节点B覆盖6827-10922
    • 节点C覆盖12288-16383
    • 节点D覆盖0-1364,5461-6826,10923-12287
  • 与此类似, 如果用户要从集群中移除节点 A , 那么集群只需要将节点 A 中的所有哈希槽移动到节点 B 和节点 C , 然后再移除空白(不包含任何哈希槽)的节点 A 就可以了。

6.3 hash slots数量为什么时16384

16384=2^14=16K

CRC16算法可以产生2^16-1=65535个值

  1. redis的一个节点的心跳信息中需要携带该节点的所有配置信息,而16K大小的槽数量所需要耗费的内存为2K,但如果使用65K个槽,这部分空间将达到8K,心跳信息就会很庞大。因为心跳信息使用bitmap来存储节点是否在此节点上,即一共16384个位, 16384/8=2KByte
  2. Redis集群中主节点的数量基本不可能超过1000个。
  3. Redis主节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中,会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话,bitmap的压缩率就很低,所以N表示节点数,如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。而16K个槽当主节点为1000的时候,是刚好比较合理的,既保证了每个节点有足够的哈希槽,又可以很好的利用bitmap。
  4. 选取了16384是因为crc16会输出16bit的结果,可以看作是一个分布在0-2^16-1之间的数,redis的作者测试发现这个数对2^14求模的会将key在0-2^14-1之间分布得很均匀,因此选了这个值。