使用Go实现一致性哈希
1. 概述
什么是一致性哈希?在维基百科中是怎么定义的:
一致哈希 是一种特殊的哈希算法。在使用一致哈希算法后,哈希表槽位数(大小)的改变平均只需要对K/n
个关键字重新映射,其中K是关键字的数量,n是槽位数量。然而在传统的哈希表中,添加或删除一个槽位的几乎需要对所有关键字进行重新映射。
假设你有N台缓存服务器,一种常用的负载均衡方法是,对资源o的请求使用hash(o) = o mod N 来映射到一台服务器上。但是如果增加或者减少1台服务器,那么N就变成N-1了,这样有可能就会改变所有资源对应的哈希值,也就是所有的缓存都失效了,这会使得缓存服务器大量集中地向原始数据服务器请求更新缓存。
因此,我们需要一种哈希算法来避免这样的问题。新的哈希算法需要满足以下条件:
- 新增或减少一台服务器以后,不会造成大面积的访问错误。
- 同一个资源仍然会映射到之前同样的服务器上。
- 新增一台服务器时,新的服务器需要尽量分担存储其他服务器的缓存资源。
- 减少一台服务器时,其他服务器也可以尽量分担存储它的缓存资源
2. 一致性哈希算法
在动态变化的Cache环境中,衡量一个哈希算法的好坏通常有四个指标
- 单调性
- 平衡性
- 分散性
- 负载
这里着重谈一下单调性和平衡性。
2.1 单调性
单调性是指:如果已经有一些内容通过哈希分派到了相应的Cache中,此时又有新的Cache加入到系统中,那么哈希的结果应能够保证原有已分配的内容可以被映射到原有的或者的新的Cache中去,而不会被映射到集合中的其他Cache中去。
也就是说,缓存节点数量的变更,不会导致整个key space的rehash,而是尽可能的控制在一个小的范围内。
2.1.1 环形空间
通常的哈希算法都是将value映射到一个32位的key值,也即是0 ~ 2^32-1次方的数值空间,如下所示:
image.png
2.1.2 把对象映射到环形空间
现在假设有4个对象a,b,c,d,通过hash函数计算出的hash值key在环上的分布如图:
hash(a) = keya
hash(b) = keyb
hash(c) = keyc
hash(d) = keyd
image.png
2.1.3 把Cache映射到环形空间
一致性哈希的基本思想就是将对象和Cache都映射到同一个Hash空间中,并且使用相同的Hash算法。假设当前有A,B,C三台Cache,那么其映射结果将如下图所示,他们在Hash空间中,以对应的哈希值排列:
hash(A) = keyA
hash(B) = keyB
hash(C) = keyC
image.png
一般情况下,我们使用Cache 服务器的IP地址或机器名作为Hash函数的输入。
2.1.4 把对象映射到Cache
现在Cache和对象都已经通过同一个Hash算法映射到了Hash数值空间中,接下来要考虑的就是如何将对象映射到Cache上。
在这个环形空间中,如果沿着顺时针方向从对象的Key值出发,直到遇见一个Cache,那么就将该对象存储在这个Cache上,因为对象和Cache的哈希值是固定的,因此这个Cache必然是唯一和确定的。
根据上面的方法,对象a和d将被映射到Cache C上,对象b将被映射到Cache A上,对象C将被映射到Cache B上,如图:
image.png
2.1.5 添加或移除Cache
通过Hash求余的方法所带来的最大的问题就在于不能满足单调性,当Cache有所变动时,缓存会失效,进而对后台服务器造成巨大的冲击,接下来分析一下一致性哈希算法如何处理这个问题。
现在假设Cache B被移除了,根据上面的映射方法,这时受影响的仅是那些沿Cache B逆时针遍历到下一个Cache(Cache C)之间的对象,因此这里仅需要移动对象c,将其重新映射到Cache A上即可:
image.png
假设再考虑添加一台Cache D的情况,假设在这个环形空间中,Cache D被映射到了对象a和d之间,这时受影响的将仅是那些沿着Cache D逆时针遍历到下一个Cache(Cache A)之间的对象,将这些对象移动到Cache D上即可:
image.png
通过添加或移除Cache的分析,一致性哈希算法在保持了单调性的同时,还将数据的迁移量达到了最小,这样的算法对于分布式集群来说时非常合适的,避免了大量的数据迁移,减轻了服务器的压力。
2.2 平衡性
平衡性是指哈希的结果能够尽可能分散到所有的Cache中去,这样可以使得所有的缓存空间都能得到充分的利用。
哈希算法并不能保证100%的平衡性,如果Cache较少的话,对象并不能被均匀得映射到Cache上,比如在上面的例子中,仅部署Cache A和Cache B的情况下,在4个对象中,Cache A仅存储了对象b,而Cache B则存储了对象a,d,c,分布很不均匀。为了解决这个问题,一致性哈希算法引入了虚拟节点的概念,定义如下:
虚拟节点是实际节点在Hash空间中的复制品(replica),实际节点对应了若干个虚拟节点,每个实际节点对应的虚拟节点个数称为复制个数,虚拟节点在Hash空间中以Hash值排列
仍以仅部署A和B的情况为例,现在我们引入虚拟节点,并设置复制个数为2,这就意味着一共会存在4个虚拟节点,Cache A1和Cache A2代表了Cache A,Cache B1和Cache B2代表了Cache B,假设一种比较理想的情况,如下图:
image.png
此时映射关系变为:a --> Cache A2;b --> Cache B2;c --> Cache A1;d --> Cache B1,因此对象a和c都被映射到了A上,而b和d都被映射到了B上,平衡性有了很大提高。
引入虚拟节点后,映射关系就从对象 -> 节点变成了对象->虚拟节点,虚拟节点的Hash计算可以采用IP地址加数字后缀的方式。
实现
package consistenthash
import (
"hash/crc32"
"sort"
"strconv"
)
type UInt32Slice []uint32
func (s UInt32Slice) Len() int {
return len(s)
}
func (s UInt32Slice) Less(i, j int) bool {
return s[i] < s[j]
}
func (s UInt32Slice) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
type Hash func(data []byte) uint32
type Map struct {
hash Hash
replicas int // 复制因子
keys UInt32Slice // 已排序的节点哈希切片
hashMap map[uint32]string // 节点哈希和KEY的map,键是哈希值,值是节点Key
}
func New(replicas int, fn Hash) *Map {
m := &Map{
replicas: replicas,
hash: fn,
hashMap: make(map[uint32]string),
}
// 默认使用CRC32算法
if m.hash == nil {
m.hash = crc32.ChecksumIEEE
}
return m
}
func (m *Map) IsEmpty() bool {
return len(m.keys) == 0
}
// Add 方法用来添加缓存节点,参数为节点key,比如使用IP
func (m *Map) Add(keys ...string) {
for _, key := range keys {
// 结合复制因子计算所有虚拟节点的hash值,并存入m.keys中,同时在m.hashMap中保存哈希值和key的映射
for i := 0; i < m.replicas; i++ {
hash := m.hash([]byte(strconv.Itoa(i) + key))
m.keys = append(m.keys, hash)
m.hashMap[hash] = key
}
}
// 对所有虚拟节点的哈希值进行排序,方便之后进行二分查找
sort.Sort(m.keys)
}
// Get 方法根据给定的对象获取最靠近它的那个节点key
func (m *Map) Get(key string) string {
if m.IsEmpty() {
return ""
}
hash := m.hash([]byte(key))
// 通过二分查找获取最优节点,第一个节点hash值大于对象hash值的就是最优节点
idx := sort.Search(len(m.keys), func(i int) bool { return m.keys[i] >= hash })
// 如果查找结果大于节点哈希数组的最大索引,表示此时该对象哈希值位于最后一个节点之后,那么放入第一个节点中
if idx == len(m.keys) {
idx = 0
}
return m.hashMap[m.keys[idx]]
}