假如有一个业务快速增长,流量巨大,服务器压力也随之增加,直接读写数据库的方案已经不合适了,这时候我们就会想到引入分布式缓存机制,从而将许多热点数据放到缓存层,穿透到数据库层的请求就并不多了。此时,缓存的重要性就不言而喻了。但是,由于缓存数据量很大,缓存的快速查询又是基于内存高速存取实现,而服务器的内存资源又是十分稀缺的,所以如何让请求高效命中,分布式缓存集群如何优雅的伸缩就变成了亟待解决的问题。下文将会就这一问题的以下两种解决方案进行探讨:
现在我们先看看 Naive Hash-based Distributed Dictionary 工作方式。假设我们有一个包含 n 个节点的集群,节点编号是 0 , 1 , 2 , 3 … … n − 1 0,1,2,3……n - 1 0,1,2,3……n−1,有一个哈希函数 h a s h ( x ) hash(x) hash(x),节点 k 存储的键值对 ( k , h a s h ( k ) % n ) (k, hash(k) \% n) (k,hash(k)%n)。如果选择的 h a s h ( x ) hash(x) hash(x) 比较理想,那么结果会比较均匀的分布在 0 , 1 , 2 , 3 … … n − 1 0,1,2,3……n - 1 0,1,2,3……n−1。如此一来,便可以将 Distributed Dictionary 均匀地分布在集群的各个节点上。
Naive Hash-based Distributed Dictionary 实现非常简单,但也有严重的缺陷。想象一下,此时向集群添加一个节点,那么 ( k , h a s h ( k ) % n ) (k, hash(k) \% n) (k,hash(k)%n) 将会变成 ( k , h a s h ( k ) % ( n + 1 ) ) (k, hash(k) \% (n + 1)) (k,hash(k)%(n+1)),所以几乎每个键值对结果都会在集群中重新分配。同时 n / ( n + 1 ) n/ (n + 1) n/(n+1) 的数据会被移到新节点,几乎是全部。这种操作是非常慢的,而且开销也很大。如果重分配是在任务运行中发生,这可能会带来不便。同理,当集群中剔除某个节点时,也会有类似的情况发生。
Consistent Hashing 和 Naive Hashing 一样,会把数据均匀地分布在集群的节点上。但与 Naive Hashing 不同的是,Consistent Hashing 只需要移动相对较少的数据,例如向集群中添加一台机器,只需要移动该机器上的数据,其他所有数据都留在原来的位置。Consistent Hashing 有以下特性:
由于 Hash 算法的结果一般是 unsigned int,因此 Consistent Hashing 的哈希值空间是 [ 0 , 2 32 − 1 ] [0, 2^{32} -1] [0,232−1],并且会将整个哈希值空间组织成一个虚拟的环,即哈希环(Hash Ring),如图 1 所示。
得到哈希环之后,就可以对缓存服务器的 IP 或主机名等唯一标识进行哈希运算定位到在哈希环中的位置。在图 2 中的哈希环中有三台服务器。
为了使服务器和数据的哈希值在同一个哈希值空间,就需要对数据使用相同的哈希函数计算出哈希值,从而得到数据在哈希环上的位置。然后,从此位置沿顺时针方向出发,遇到的第一台服务器就是其对应的服务器。根据这种计算逻辑,可以找到在图 3 中数据 Object 和缓存服务器的对应关系:Object1 → Cache B,Object2 → Cache C,Object 3 → Cache A,Object 4 → Cache A。
在分布式环境中,不可避免地会由于某些原因出现增删服务器的情况。当增加服务器的时候,对于一致性哈希,只会影响从新增服务器所在哈希环位置开始,沿逆时针方向遇到第一台服务器的位置之间的数据,而这些数据会映射到新增的服务器。如图 4 哈希环中新增服务器 Cache D,原本映射到 Cache A 的数据 Object 3 会映射到 Cache D,而其他数据包括 Object 4 的映射关系不会改变。
同理,删除服务器(如服务器宕机等)时,根据一致性哈希算法,也只会影响从所删除服务器的位置开始,沿逆时针方向遇到的第一台服务的位置之间的数据,同时这些数据会映射到沿顺时针方向遇到的第一台服务器。在图 5 中,服务器 Cache B 由于某些原因下线了,此时数据 Object 1 会重新映射到服务器 Cache C,而其他数据的映射关系不变。
一致性哈希算法在服务器比较少的时候,会出现分布不均匀的热点(Hot Spotting)现象,从而造成数据倾斜的问题。图 6 中有三台服务器,但是却有 4 份数据映射到了服务器 Cache A,而服务器 Cache B 和 Cache C 都只映射到了一份数据,造成 Cache A 出现了热点问题。
为了解决热点问题,一致性哈希算法引入了虚拟节点(Virtual Nodes)。虚拟节点是哈希环中服务器的副本,每个物理的服务器会对应于环中一个或多个虚拟节点。当添加缓存服务器时,会在哈希环中为它创建了一些虚拟节点;当缓存服务器被移除时,会移除哈希环中其对应所有的虚拟节点。如图 7 中,物理服务器和虚拟节点的对应关系如下: Cache A → Cache A#1 和 Cache A#2,Cache B → Cache B#1 和 Cache B#2、Cache C → Cache C#1 和 Cache C#2。
下面给出一致性哈希算法的 Java 简单实现:
public class ConsistentHashing {
private static final String SEPARATOR = "#";
private final int replicaFactor;
private final HashFunction hashFunction;
private TreeMap<Long, String> hashCircle = new TreeMap<>();
public ConsistentHashing(int replicaFactor, HashFunction hashFunction) {
this.replicaFactor = replicaFactor;
this.hashFunction = hashFunction;
}
public String get(String node) {
if (hashCircle.isEmpty()) {
return null;
}
Long key = hashFunction.hash(node);
System.out.println("key=>" + key);
SortedMap<Long, String> virtualNodes = hashCircle.tailMap(key);
return virtualNodes.isEmpty()
? hashCircle.get(hashCircle.firstKey())
: hashCircle.get(virtualNodes.firstKey());
}
public void add(String node) {
for (int i = 0; i < replicaFactor; i++) {
hashCircle.put(hashFunction.hash(node + SEPARATOR + i), node + SEPARATOR + i);
}
}
public void remove(String node) {
for (int i = 0; i < replicaFactor; i++) {
hashCircle.remove(hashFunction.hash(node + SEPARATOR + i));
}
}
private interface HashFunction {
Long hash(String key);
}
private static class FVNHashFunction implements HashFunction {
@Override
public Long hash(String key) {
final int p = 16777619;
Long hash = 2166136261L;
for (int i = 0, length = key.length(); i < length; i++) {
hash = (hash ^ key.charAt(i)) * p;
}
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
return hash < 0 ? -hash : hash;
}
}
}
使用一致性哈希算法时,当发生增删服务器的时候,并不能彻底杜绝数据迁移,但是却可以有效地避免全量数据迁移。同时,一致性哈希算法使用虚拟节点可以解决热点问题。