目录
1 一致性哈希算法用途
一致性哈希是解决上线、下线后相同的请求尽可能的命中原来服务器的问题。
假如我们自己设计了一个高可用缓存系统,可以集群部署,那么我们每个节点上应该怎样分配数据呢?
假如说我们存放一个k-v数据,这个数据需要怎么确定存放节点?常用的方式是用key的哈希值对服务器节点取模,这样实现比较简单,但是带来的问题就是缓存系统上线、下线节点后原来节点缓存的数据命中率打打大大降低。
三个节点的路由表
哈希值 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
路由 0 1 2 0 1 2 0 1 2 0 1 2 0 1 2
四个节点的路由表
哈希值 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
路由 0 1 2 3 0 1 2 3 0 1 2 3 0 1 2
可以看到只有6个哈希值在可以路要到原来的服务器,其余的缓存都不能命中。这样带来的直接后果是
1 可能可能会带来类似缓存雪崩的影响。缓存雪崩是指缓存数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至宕机。
2 节点中大量无法命中的数据过期之前还在占用内存。
2 一致性哈希算法介绍
为了更好地提升缓存系统的伸缩性需要设计一个可靠的一致性哈希算法。
一致性哈希算法是构建一个哈希环来实现key到节点的映射。我们定义一个哈希环如下结构(0,1,2三个节点哈希值分别是0,5,10),每个节点是每个缓存系统的哈希值
0->5->10->0
路由规则是每个key落到刚好比它哈希值大的节点上。如果这个key大于环上所有的哈希值那么就落在最小的哈希节点
三个节点的路由表
哈希值 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
路由 1 1 1 1 1 2 2 2 2 2 0 0 0 0 0
这个时候我们增加了一个节点,哈希值是12,那么新的哈希环如下
0->5->10->12->0
四个节点的路由表
哈希值 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
路由 1 1 1 1 1 2 2 2 2 2 3 3 0 0 0
这个时候我们看到只有两个哈希值没有路由到原来的节点,服务扩容后只会影响新增服务下一个节点的缓存路由,很好的解决了取模路由暴露的两个两个问题。
3 一致性哈希算法实现
3.1 排序算法+二分查找
最优的排序算法时间复杂度是O(NlogN),二分查找算法时间复杂度是O(logN),所以这种方式的时间复杂度取决于耗时较长的排序算法,即O(NlogN)
3.2 直接遍历
这种方式比较简单,时间复杂度是O(N)
3.3 二叉查找树
二叉查找树的优点是查找效率高,时间复杂度是Olog(N),缺点是建树过程比较耗性能。不过考虑到服务上下线场景比较有限,这个缺点可以忽略,下面我们就以二叉查找树为例来实现一致性哈希算法
4 TreeMap实现一致性哈希
4.1 红黑树介绍
满足以下特征的树就是红黑树
1. 节点是红色或者黑色
2. 根节点是黑色
3. 每个叶子的节点都是黑色的空节点(NULL)
4. 每个红色节点的两个子节点都是黑色的。
5. 从任意节点到其每个叶子的所有路径都包含相同的黑色节点。
这些约束强制了红黑树的关键性质: 从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。
TreeMap就是红黑树的实现。
4.2 哈希再计算
我们看一下调用String默认的哈希算法计算节点ip的哈希值
127.0.0.1:8080->127.0.0.1:8084
@Test
public void testStringHash() {
// 5个server服务器
for (int i = 0; i < 5; i ++) {
String server = "127.0.0.1:808" + i;
System.out.println(server + "->" + server.hashCode());
}
}
输出:
127.0.0.1:8080->-35736627
127.0.0.1:8081->-35736626
127.0.0.1:8082->-35736625
127.0.0.1:8083->-35736624
127.0.0.1:8084->-35736623
可以看到这种方式计算的结果,哈希值根本散不开
重新计算Hash值的算法有很多,可以参考https://blog.csdn.net/whut_gyx/article/details/39002191了解下
这里我们采用散列效果和性能都不错的FNV1_32_HASH算法
private int fnv32Hash(String str) {
final int p = 16777619;
int hash = -2128831035;
for (int i = 0; i < str.length(); i++) {
hash = (hash ^ str.charAt(i)) * p;
}
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
// 如果算出来的值为负数则取其绝对值
if (hash < 0) {
hash = Math.abs(hash);
}
return hash;
}
测试代码
@Test
public void testFnv32Hash() {
// 5个server服务器
for (int i = 0; i < 5; i ++) {
String server = "127.0.0.1:808" + i;
System.out.println(server + "->" + fnv32Hash(server));
}
}
输出:
127.0.0.1:8080->1617490351
127.0.0.1:8081->674407738
127.0.0.1:8082->1511613106
127.0.0.1:8083->1255419186
127.0.0.1:8084->265259256
可以看到散列效果还是不错的
4.3 一致性哈希算法代码实现
// key list
private List<String> keyList;
// 缓存服务器ip集合
private List<String> serverList;
// 缓存服务器构建的二叉树
private SortedMap<Integer, String> sortedMap = new TreeMap<>();
@Before
public void init() {
// 100个key
keyList = new ArrayList<>();
for (int i = 0; i < 100; i ++) {
keyList.add("key" + i);
}
// 5个server服务器
serverList = new ArrayList<>();
for (int i = 0; i < 5; i ++) {
serverList.add("127.0.0.1:808" + i);
}
}
// 获取key路由到的服务器
private String getServer(String key) {
// 得到带路由的结点的Hash值
int hash = fnv32Hash(key);
// 得到大于该Hash值的所有Map
SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
if (subMap.size() <= 0) {
subMap = sortedMap;
}
// 第一个Key就是顺时针过去离node最近的那个结点
Integer i = subMap.firstKey();
// 返回对应的服务器名称
return subMap.get(i);
}
private int fnv32Hash(String str) {
final int p = 16777619;
int hash = -2128831035;
for (int i = 0; i < str.length(); i++) {
hash = (hash ^ str.charAt(i)) * p;
}
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
// 如果算出来的值为负数则取其绝对值
if (hash < 0) {
hash = Math.abs(hash);
}
return hash;
}
测试代码:
@Test
public void testConsistentHash() {
for (String server : serverList) {
int hash = fnv32Hash(server);
System.out.println("[" + server + "]加入集合中, 其Hash值为" + hash);
sortedMap.put(hash, server);
}
Map<String, List<String>> map = new HashMap<>();
for (String key : keyList) {
String server = getServer(key);
List<String> list = map.get(server);
if (Objects.isNull(list)) {
list = new ArrayList<>();
map.put(server, list);
}
list.add(key);
}
map.forEach((k, v) -> System.out.println(k + "->" + v.size() + ":" + v));
}
输出:
[127.0.0.1:8080]加入集合中, 其Hash值为1617490351
[127.0.0.1:8081]加入集合中, 其Hash值为674407738
[127.0.0.1:8082]加入集合中, 其Hash值为1511613106
[127.0.0.1:8083]加入集合中, 其Hash值为1255419186
[127.0.0.1:8084]加入集合中, 其Hash值为265259256
127.0.0.1:8081->12:[key13, key19, key30, key49, key61, key63, key64, key70, key72, key92, key95, key98]
127.0.0.1:8082->11:[key0, key10, key21, key34, key39, key45, key51, key60, key81, key86, key96]
127.0.0.1:8080->8:[key4, key14, key43, key44, key56, key84, key87, key89]
127.0.0.1:8083->27:[key6, key7, key9, key11, key20, key25, key26, key27, key28, key33, key40, key41, key42, key46, key47, key52, key55, key65, key67, key71, key74, key77, key78, key90, key91, key93, key97]
127.0.0.1:8084->42:[key1, key2, key3, key5, key8, key12, key15, key16, key17, key18, key22, key23, key24, key29, key31, key32, key35, key36, key37, key38, key48, key50, key53, key54, key57, key58, key59, key62, key66, key68, key69, key73, key75, key76, key79, key80, key82, key83, key85, key88, key94, key99]
4.4 一致性哈希算法优化(虚拟节点)
上述代码一致性哈希算法我们可以看到每个key可以被正确路由到对应的节点,但是有另一个问题,分配不均。我们看到分配最少缓存的节点上只有8个缓存,最多缓存节点上有42个缓存
解决这个问题的办法是引入虚拟节点,其工作原理是:将一个物理节点拆分为多个虚拟节点,并且同一个物理节点的虚拟节点尽量均匀分布在Hash环上。采取这样的方式,就可以有效地解决增加或减少节点时候的负载不均衡的问题。
带有虚拟节点的一致性哈希算法实现
@Test
public void testConsistentVirtualHash() {
List<String> virtualServerList = new ArrayList<>();
// 每个server虚拟5个地址
int virtualServerCount = 5;
for (String server: serverList) {
for (int i = 0; i < virtualServerCount; i ++) {
String virtualServer = server + "@VN" + i;
virtualServerList.add(virtualServer);
}
}
for (String virtualServer : virtualServerList) {
int hash = fnv32Hash(virtualServer);
System.out.println("[" + virtualServer + "]加入集合中, 其Hash值为" + hash);
sortedMap.put(hash, virtualServer);
}
Map<String, List<String>> map = new HashMap<>();
for (String key : keyList) {
String virtualServer = getServer(key);
// 解析出真正的ip
String realServer = virtualServer.split("@")[0];
List<String> list = map.get(realServer);
if (Objects.isNull(list)) {
list = new ArrayList<>();
map.put(realServer, list);
}
list.add(key);
}
map.forEach((k, v) -> System.out.println(k + "->" + v.size() + ":" + v));
}
输出
[127.0.0.1:8080@VN0]加入集合中, 其Hash值为1653752734
[127.0.0.1:8080@VN1]加入集合中, 其Hash值为2045770422
[127.0.0.1:8080@VN2]加入集合中, 其Hash值为642460142
[127.0.0.1:8080@VN3]加入集合中, 其Hash值为2064903931
[127.0.0.1:8080@VN4]加入集合中, 其Hash值为134338595
[127.0.0.1:8081@VN0]加入集合中, 其Hash值为1207025989
[127.0.0.1:8081@VN1]加入集合中, 其Hash值为1416458113
[127.0.0.1:8081@VN2]加入集合中, 其Hash值为2109124764
[127.0.0.1:8081@VN3]加入集合中, 其Hash值为487588720
[127.0.0.1:8081@VN4]加入集合中, 其Hash值为887324084
[127.0.0.1:8082@VN0]加入集合中, 其Hash值为27662755
[127.0.0.1:8082@VN1]加入集合中, 其Hash值为1353238534
[127.0.0.1:8082@VN2]加入集合中, 其Hash值为1234344991
[127.0.0.1:8082@VN3]加入集合中, 其Hash值为1502278984
[127.0.0.1:8082@VN4]加入集合中, 其Hash值为1362517544
[127.0.0.1:8083@VN0]加入集合中, 其Hash值为1128722563
[127.0.0.1:8083@VN1]加入集合中, 其Hash值为1998095489
[127.0.0.1:8083@VN2]加入集合中, 其Hash值为2077514034
[127.0.0.1:8083@VN3]加入集合中, 其Hash值为1266869294
[127.0.0.1:8083@VN4]加入集合中, 其Hash值为842010729
[127.0.0.1:8084@VN0]加入集合中, 其Hash值为263233063
[127.0.0.1:8084@VN1]加入集合中, 其Hash值为1695356940
[127.0.0.1:8084@VN2]加入集合中, 其Hash值为956902
[127.0.0.1:8084@VN3]加入集合中, 其Hash值为2050272550
[127.0.0.1:8084@VN4]加入集合中, 其Hash值为1840275863
127.0.0.1:8081->11:[key7, key9, key42, key47, key59, key61, key64, key67, key70, key71, key99]
127.0.0.1:8082->8:[key0, key21, key39, key45, key60, key79, key81, key96]
127.0.0.1:8080->31:[key3, key4, key10, key12, key13, key14, key19, key22, key23, key24, key30, key31, key34, key43, key44, key49, key50, key51, key54, key56, key57, key63, key66, key72, key73, key82, key84, key86, key87, key89, key98]
127.0.0.1:8083->30:[key5, key6, key8, key11, key17, key18, key20, key25, key26, key27, key28, key33, key38, key40, key41, key46, key52, key55, key65, key74, key77, key78, key85, key90, key91, key92, key93, key94, key95, key97]
127.0.0.1:8084->20:[key1, key2, key15, key16, key29, key32, key35, key36, key37, key48, key53, key58, key62, key68, key69, key75, key76, key80, key83, key88]
可以看到我们虚拟5个节点后分配最少缓存的节点上只有8个缓存,最多缓存节点上有31个缓存,提高了缓存分配均衡性