假设现在有 5 台 redis 实例,每台实例负责保存一部分数据。客户端可以通过如下算法定位具体实例:
hash(key) % 5
随着业务发展,数据越来越多,5 台实例已经无法满足业务。现在要新增两台,新增两台后客户端算法如下:
hash(key) % 7
这就导致老算法计算出的值和新算法不一致:之前 hash(a) % 5 = 1,key 为 a 的键值对保存在 1 号实例,而 hash(a) % 7 的值可能不为 1。也就是说:因为扩容(缩容),大多数旧 key 都失效了。为了解决该问题,一致性 Hash 算法诞生了
一致性 Hash 算法不再根据实例数取模,而是以 2 ^ 32 - 1(int 最大整数值)取模,取模后值域为 0 ~ 2 ^ 32 - 1,之后根据不同 redis 实例的 IP 或 其它唯一属性进行 Hash 算法:
假设 redis 实例 x、y、z 经过 hash 算法后得出的值分别为 a,b,c(a < b < c 并且a、b、c 都小于 2 ^ 32 - 1),那么之后 x、y,z 负责的范围分别是:
- x 实例:c ~ a,也就是 c ~ 2 ^ 32 -1 和 0 ~ a 的并集
- y 实例:a ~ b
- z 实例:b ~ c
对于随机 key,根据一致性 hash 算法计算出的值和上述范围一起判断应该放到哪个实例
此时如果 x,y,z 实例中的 x 挂掉了,hash 结果处于 c ~ a 范围内的 key 会打到实例 y,其它区间无影响。如果要在 x ~ z 之间新增实例 k,也只会影响 hash 值处于 c ~ d 区间的 key,原来它们会打到实例 x,现在会打到 实例 k,其它区间无影响
也就是说,使用一致性 Hash 算法能大大减少扩容(缩容)所带来的影响,只需要重定向一小部分数据,有更好的容错性和可扩展性
假设现在只存在两个实例 x,y,他们一致性 hash 算法得出的值分别为 a,b。如果 a 和 b 的值特别接近,那么打到实例 y 上的数据会远远小于实例 x 上的数据,为了解决该问题,我们可以对每个实例计算多个虚拟节点,之后根据虚拟节点来完成划分,只需要额外增加一层从虚拟节点到实例的映射关系即可
最后简单看一个一致性 Hash 算法实现:
// 一致性 hash 算法接口
public interface ConsistencyHashService {
long hash(String key);
}
// 一致性 hash 算法接口实现
// MurMurHash算法,性能更高,结果更离散
// 摘自简书,原文 [点击这里](https://www.jianshu.com/p/528ce5cd7e8f)
public class ConsistencyHashServiceImpl implements ConsistencyHashService {
@Override
public long hash(String key) {
ByteBuffer buf = ByteBuffer.wrap(key.getBytes());
int seed = 0x1234ABCD;
ByteOrder byteOrder = buf.order();
buf.order(ByteOrder.LITTLE_ENDIAN);
long m = 0xc6a4a7935bd1e995L;
int r = 47;
long h = seed ^ (buf.remaining() * m);
long k;
while (buf.remaining() >= 8) {
k = buf.getLong();
k *= m;
k ^= k >>> r;
k *= m;
h ^= k;
h *= m;
}
if (buf.remaining() > 0) {
ByteBuffer finish = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
finish.put(buf).rewind();
h ^= finish.getLong();
h *= m;
}
h ^= h >>> r;
h *= m;
h ^= h >>> r;
buf.order(byteOrder);
return h;
}
}
// 模拟 redis 节点
public class RedisNode {
public String ip;
public String name;
public RedisNode(String ip, String name) {
this.ip = ip;
this.name = name;
}
public String getIp() {
return ip;
}
public void setIp(String ip) {
this.ip = ip;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
// 使用 ip 计算 redis 虚拟节点 hash 值
public String toString() {
return ip;
}
}
// 一致性 hash 操作
public class ConsistencyHash<T> {
/**
* 使用的一致性 hash 算法
*/
private final ConsistencyHashService service;
/**
* 每个 redis 节点虚拟节点的数量
*/
private final int num;
/**
* key 为 hash 值,value 为具体 redis 节点
* hash 值实际就对应不同虚拟节点
*/
private final SortedMap<Long, T> map = new TreeMap();
public ConsistencyHash(ConsistencyHashService service, int num, Collection<T> list) {
this.service = service;
this.num = num;
for (T t : list) {
add(t);
}
}
/**
* 增加虚拟节点
*
* @param t
*/
public void add(T t) {
for (int i = 0; i < num; ++i) {
map.put(service.hash(t.toString() + i), t);
}
}
/**
* 删除虚拟节点
*
* @param t
*/
public void delete(T t) {
for (int i = 0; i < num; ++i) {
map.remove(service.hash(t.toString() + i));
}
}
/**
* 获取当前 key 应该放到哪个 redis 实例
*
* @param key
* @return
*/
public T get(String key) {
if (map.isEmpty()) {
return null;
}
long hash = service.hash(key);
// 包含说明当前 key 正好处于某个虚拟节点,否则根据范围判断
if (!map.containsKey(hash)) {
// 返回 key 大于 hash 的 所有 key value 组成的 map
SortedMap<Long, T> temp = map.tailMap(hash);
// map 为空说明没有大于 hash 的虚拟节点,存到从 0 开始第一个虚拟节点
// map 不为空说明 hash 后面存在虚拟节点,存到对应虚拟节点
hash = temp.isEmpty() ? map.firstKey() : temp.firstKey();
}
// 根据虚拟节点定位到具体 redis 实例
return map.get(hash);
}
}
具体测试类源码如下:
public class ConsistencyTest {
private static final String PRE_IP = "192.168.0.";
public static void main(String[] args) {
// 用来记录每个实例保存多少节点,方便验证
Map<String, Integer> map = new HashMap<>();
List<RedisNode> list = new ArrayList<RedisNode>();
// 模拟 10 个 redis 实例
for (int i = 1; i <= 10; ++i) {
String realIP = PRE_IP + i;
map.put(realIP, 0);
list.add(new RedisNode(realIP, "主机" + i));
}
ConsistencyHashService service = new ConsistencyHashServiceImpl();
// 模拟每个 redis 实例包含 500 个虚拟节点
ConsistencyHash cHash = new ConsistencyHash(service, 500, list);
// 模拟保存 50000 个节点
for (int i = 0; i < 50000; ++i) {
String key = UUID.randomUUID().toString() + i;
RedisNode node = (RedisNode) cHash.get(key);
map.put(node.getIp(), map.get(node.getIp()) + 1);
}
// 打印每台实例保存了多少元素
for (int i = 1; i <= 10; ++i) {
RedisNode node = list.get(i - 1);
System.out.println(node.getIp() + "保存节点数:" + map.get(node.getIp()));
}
// 尝试删除一个实例
cHash.delete(list.get(0));
// 清空记录
for (int i = 1; i <= 10; ++i) {
RedisNode node = list.get(i - 1);
map.put(node.getIp(), 0);
}
// 模拟保存 50000 个节点
for (int i = 0; i < 50000; ++i) {
String key = UUID.randomUUID().toString() + i;
RedisNode node = (RedisNode) cHash.get(key);
map.put(node.getIp(), map.get(node.getIp()) + 1);
}
for (int i = 1; i <= 10; ++i) {
RedisNode node = list.get(i - 1);
System.out.println(node.getIp() + "保存节点数:" + map.get(node.getIp()));
}
}
}
测试结果如下:
192.168.0.1保存节点数:5181
192.168.0.2保存节点数:5160
192.168.0.3保存节点数:4805
192.168.0.4保存节点数:4764
192.168.0.5保存节点数:5170
192.168.0.6保存节点数:4271
192.168.0.7保存节点数:5239
192.168.0.8保存节点数:5177
192.168.0.9保存节点数:5157
192.168.0.10保存节点数:5076
192.168.0.1保存节点数:0
192.168.0.2保存节点数:5718
192.168.0.3保存节点数:5169
192.168.0.4保存节点数:5553
192.168.0.5保存节点数:5455
192.168.0.6保存节点数:4943
192.168.0.7保存节点数:5570
192.168.0.8保存节点数:5733
192.168.0.9保存节点数:6039
192.168.0.10保存节点数:5820
从结果可以看出,所有节点相对较均匀的保存到不同实例
最后简单提一下:虚拟节点只能解决数据倾斜问题,如果 redis 实例本身数量不够多,就仍有可能由于数据量过大而宕机,此时只能扩容。一般情况下,一致性 hash 算法越高效、结果越离散(均匀),Redis 实例数越多,整体效率越高
Redis Cluster 实际并没有使用上面我提到的分流方法,关于这点以后在 Redis Cluster 集群篇再介绍