首先,从使用hash取模数据分片开始说起
无论是哈希槽,还是一致性hash,都属于hash取模数据分片。
先从经典的hash取模数据分片说起
假如 Redis集群的节点数为3个,使用经典的hash取模算法进行数据分片,实际上就是一个节点一个数据分片,分为3片而已。
每次请求使用 hash(key) % 3 的方式计算对应的节点,或者进行 分片的路由。
经典哈希取模分片的问题和对策:
哈希取模分片有一个核心问题:对扩容不友好,扩容的时候数据迁移规模太大。
比如,把节点从3个扩展到4个, 具体如下:
原来的分片路由算法是:hash(key) % 3
现在的分片路由算法是:hash(key) % 4
分片路由算法调整之后,那么,大量的key需要进行节点的迁移。
换句话,即当增加或减少节点时,原来节点中的80%以上的数据,可能会进行迁移操作,对所有数据重新进行分布。
如何应对呢?
规避的措施之一:如果一定要采用哈希取模分片,建议使用多倍扩容的方式,这样只需要适移50%的数据。例如以前用3个节点保存数据,扩容为比以前多一倍的节点即6个节点来保存数据,这样移动50%的数据即可。
规避的措施之一:采用一致性hash分片方法。
哈希取模分片优点:
-
配置简单:对数据进行哈希,然后取余
哈希取模分片缺点:
-
数据节点伸缩时,导致大量数据迁移
-
迁移数量和添加节点数据有关,建议翻倍扩容
一致性hash算法
如果redis使用一致性hash算法进行数据分片,那么核心会涉及到的两个阶段:
-
第一阶段,需要完成key到slot槽位之间的映射。
-
第二阶段,需要完成slot槽位到 redis node节点之间的映射。
首先看第一阶段。
第一阶段,需要完成key到slot槽位之间的映射
第一阶段,使用了哈希取模的方式,不同的是: 对 2^32 这个固定的值进行取模运算。
注意,这里的取模的除数,是 2^32 , 相当于 2^32个槽位, 英文是 slot 。
通过这个槽位的计算,可以确定 key => slot 之间的映射关系。
第二阶段,需要完成slot槽位到 redis node节点之间的映射。
第二阶段,需要完成slot槽位到 redis node节点之间的映射。
如何完成 slot 槽位到node 节点之间的映射呢?
这里,需要 采用一种特殊的结构:Hash槽位环。
Hash槽位环
把一致哈希算法是对 2^32 slot 槽位虚拟成一个圆环,环上的对应 0~2^32 刻度,
如何完成 slot 槽位到node 节点之间的映射呢?
假设有4个redis 节点, 可以把 2^32 slot 槽位环分成4段, 每一个redis 节点负责存储一个slot分段
如何对每一个key进行node 路由呢?
第一步 进行slot槽位计算:每一个key进行hash运算,被哈希后的结果 2^32 取模,获得slot 槽位、
第二步 在hash槽位环上,按顺时针去找最近的redis节点,这个key将会被保存在这个节点上。
一致性哈希原理:
将所有的数据用hash取模, 映射到 2^32个槽位。
把2^32个槽位 当做一个环,把N个redis 节点瞬时间放置在槽位环上,从而把槽位环分成N段,每redis 节点负责一个分段。
当key在槽位环上路由的时候,按顺时针去找最近的redis节点,这个key将会被保存在这个节点上。
来看一致性哈希 三个经典场景:
经典场景1:Key入环
下图我们四个key(Key1/Key2/Key3)经过哈希计算,放入下面环中,第一步是进行hash计算,取模后得到slot槽位。
找到了slot槽位,相当于已经成功映射到哈希环上,
然后将槽位按顺时针方向,找到离自己最近的redis节点上,将value存储到那个节点上。
经典场景2:新增redis节点
现在,需要对redis 节点进行扩容, 在redis1 和 redis2之间,新增加点redis 5。
具体的操作是:在hash槽位环上,把redis 5节点放置进去
添加了新节点之后,对所有的redis 2上的数据,进行重新的检查。
如果redis 2上的数据,顺时针方式最近的新节点不是redis 2而是 redis 5的话,需要进行迁移,迁移到redis 5。
比如,上图的key2,需要从redis 2迁移 redis 5。
而其他节点上的数据,不受影响。比如redis1、redis3、redis4上的数据不受影响。上图中,key1和key3不受影响
经典场景3:删除redis节点
假设,删除hash环上的节点redis 2
那么存储在redis 2节点上的key2,将会重新映射找到离它最近的节点redis3,如下图
另外,key1、key3不受影响。
经典哈希取模与一致性hash的对比:
前面讲到,假设Redis 集群使用经典哈希取模分片, 缺点是在数据节点伸缩时,导致大量数据迁移:
-
最少50%的数据要迁移,这个是在翻倍扩容场景
-
一般有80%以上的数据要迁移。
假设Redis 集群使用一致性哈希取模分片, 通过上面的一致性哈希取模新增节点、一致性哈希取模删除节点的分析之后, 可以得到:
-
一致性hash在伸缩的时候, 需要迁移的数据不到25%(假设4个节点)。
-
和普通hash取模分片相比, 一致性哈希取模分片需要 迁移的数据规模缩小2倍以上。
一致性hash的数据不平衡(数据倾斜)问题
标准的一致性hash,存在一个大的问题:数据不平衡(数据倾斜)问题。
回顾一下,一致性hash算法的两个阶段:
-
第一阶段,需要完成key到slot槽位之间的映射。
-
第二阶段,需要完成slot槽位到 redis node节点之间的映射。
在这个两阶段中,数据不平衡(数据倾斜)问题的来源在第二阶段:
-
第一个阶段,hash算法是均匀的。
-
第二个阶段,如果某个节点宕机,那么就会出现节点的不平衡。
迁移之后,发生了 严重的数据倾斜,或者不平衡。Redis 3上4个key,而redis 1、redis 4上只有1个key。
这样,redis2 上的数量很多,此时会导致节点压力陡增。
旱涝不均。
那如何解决这个旱涝不均问题呢?答案是通过 虚拟节点。
什么是 虚拟节点?
虚拟节点 可以理解为逻辑节点,不是物理节点。 假设在hash环上,引入 32 个虚拟 reids节点。
如何找到物理节点呢? 办法是增加一次映射:虚拟节点到物理节点的映射。
假设加上一层 32 个虚拟 redis节点到 4个 redis 物理节点映射。一种非常简单的map参考映射方案
假设物理节点 redis 3被移除,那么,把redis 3负责的逻辑节点,二次分配到其他三个物理节点就行了
无论如何,通过虚拟节点,就会大大减少了 一致性hash 算法的数据倾斜/数据不平衡。
一致性hash的简易实现
可以使用TreeMap 来实现一致性hash,原因有二:
-
TreeMap的key是有序,
-
使用TreeMap的ceilingEntry(K k) 方法,可以返回大于或等于给定参数K的键, 这就是映射到的节点。
TreeMap是一个小顶堆,默认是根据key的自然排序来组织(比如integer的大小,String的字典排序)。底层是根据红黑树的数据结构构建的。
这里使用TreeMap的ceilingEntry(K key) 方法,该方法用来返回与该键至少大于或等于给定键,如果不存在这样的键的键 - 值映射,则返回null相关联。
一致性hash的简易实现,参考代码如下:
package com.th.treemap;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;
public class ConsistentHash {
/**
* 假设我们一共初始化有8个节点(可以是ip, 就理解为ip吧);
* 把 1024个虚拟节点跟 8个资源节点相对应
*/
public static Map<Integer, String> nodeMap = new HashMap<>();
public static int V_redisS = 1024; // 假设我们的环