面试官:一致性hash算法了解吗?实现原理是什么?能解决什么问题?
你:呃。。。要不俺现场撸一个golang的简单版吧。
Talk is cheap, show me your code!
不得不说,如今这个以微服务高并发为主导的coder时代,掌握一致性hash的基本原理还是很有必要的。
一致性哈希的原理其实并不复杂,简单来说,就是:
- 为
N
台服务器生成互不相同的keyword
(比如机器的hostname
); - 将关键词用
hash
算法映射为数字,并使得该数字始终维持在一个范围,如1到2^32次方
; - 将hash后的数字正序排序,如此以来,
N
台服务器就会根据自身的keyword
生成的数字,分布到一个数字形成的环上,如1到2^32次方
首尾相连的环; - 将需要存储的
变量key
同样使用hash算法计算,得到一个1到2^32次方
范围内的数字; - 对比
变量key
的数字,与N
台服务器的数字值,找到第一个>=变量key的hash数值
的服务器,即为需要存储到的机器。
此处仅是简单描述一致性哈希的实现步骤,方便后续的代码编写,如果对其原理尚有疑惑的小伙伴,可移步any
搜索工具自行查找。
结构预定义:
// 定义一致性hash的方法类型,便于扩展其他hash算法, type HashFn func(data []byte) uint32 type ConsistentHashMap struct { hashFn HashFn // 一致性哈希函数,默认crc32 copyNum int // 机器节点的拷贝份数 vhosts []int // 哈希环上的虚拟节点列表 trueMap map[int]string // 虚拟节点与真实节点的映射map }
初始化
func NewChash(copyNum int, fn HashFn) *ConsistentHashMap { m := &ConsistentHashMap{ copyNum: copyNum, hashFn: fn, trueMap: make(map[int]string), } if m.hashFn == nil { m.hashFn = crc32.ChecksumIEEE } return m }
添加机器节点
func (c *ConsistentHashMap) AddChash(keys ...string) { for _, realHostKey := range keys { // 根据自定义的拷贝份数,将真实节点复制对应倍数 for i := 0; i < c.copyNum; i++ { // 真实key添加后缀以生成虚拟key节点 vhostKey := []string{strconv.Itoa(i), realHostKey} // 计算虚拟key的crc32值 vhostNum := int(c.hashFn([]byte(strings.Join(vhostKey, "")))) c.vhosts = append(c.vhosts, vhostNum) // 追加到虚拟节点数组 c.trueMap[vhostNum] = realHostKey // 保存虚拟节点到真实节点的映射关系 } } sort.Ints(c.vhosts) // 每次添加节点都要对虚拟节点执行排序 }
获取 变量key 被映射到的真实机器节点
func (c *ConsistentHashMap) GetChash(sk string) string { if len(c.vhosts) == 0 { panic("none-c-host") } kNum := int(c.hashFn([]byte(sk))) // 二分查找第一个 >=kNum 的虚拟节点下标 // 即,kNum 在vhosts中的插入位置 L, R := 0, len(c.vhosts)-1 for L <= R { m := (L + R) / 2 if c.vhosts[m] >= kNum { R = m - 1 } else { L = m + 1 // if c.vhosts[m] < kNum } } // 插入到虚拟节点的下标:R+1 insertId := (R + 1) % len(c.vhosts) return c.trueMap[c.vhosts[insertId]] }
这个简单版的一致性hash,大约60行代码左右,其中涉及几个小 trick
,此处简略提一下:
- 将
hash函数
做了自定义扩展;其实默认使用crc32
算法基本够用了,之所以自定义,一则是方便后续扩展,二则是为了测试时浅显易懂; - 节点数做了虚拟化倍数扩展(代码中的
copyNum
);之所以如此,是因为生产环境中的变量key
经过hash
计算得到的数字,不大可能如我们所愿,均匀分布到1到2^32次方
范围内,既然变量key
不可控,那就扩展节点数量,节点数量足够多的时候,变量key
的分布就会被动变均匀;就和工作一样,如果改变不了他人的看法,那就努力提升自己的实力; - 节点的虚拟化扩展方式,就是简单
将索引下标添加到服务器keyword之前
(代码中的AddChash
方法),这个方式可以按实际情况或自己的心情随意定,上面的Demo
如此定义是为了便于观察算法结果(参考文末的测试代码); - 获取经过一致性哈希的
变量key
对应的机器时(GetChash
),使用了二分查找
,其实如果只是测试,直接遍历虚拟节点列表vhosts
,找到第一个大于变量key
的机器即可(vhosts
已排序),但实际应用时,是有性能问题的,假设虚拟化了100
个节点,QPS是1万,直接遍历的代价是100万次循环,二分却可使其降到7万次,差距还是很明显的; - 二分逻辑旨在获取
变量key
的hash数值在虚拟节点列表vhosts
中的插入位置,但该位置存在溢出情况,即数值超过了vhosts
中的所有值,此时的插入位置在列表末尾,下标值变为vhosts
长度,超出了下标界限,因此采用了一个取余的小trick
,以保证溢出时,下标insertId
会取余变为0
,保障一致性hash的正常运行。
测试代码如下:
func main() {
// trick-1:自定义 hash 方法,专为测试和验证 使用;
// 直接将下标id添加到机器key之前,并将其转换为数字,便于验证我们的算法结果
hash := NewChash(3, func(key []byte) uint32 {
i, _ := strconv.Atoi(string(key))
return uint32(i)
})
// 我们添加 2、4、6 这三台机器key,此时根据我们上面的自定义hash方法可推导出
// 环节点经拼接下标id后共产生:2, 4, 6, 12, 14, 16, 22, 24, 26 等虚拟节点
hash.AddChash("6", "4", "2")
// 验证结论
testCases := map[string]string{
"2": "2", // 变量key如果为2,则实际映射到机器2
"11": "2", // 变量key如果为11,同样映射到机器2
"23": "4", // 23,映射到机器4
"27": "2", // 27,映射到机器2
}
for k, v := range testCases {
if hash.GetChash(k) != v {
// 如果不符合我们的预期,则说明,一致性hash算法存在问题
fmt.Printf("Asking for %s, should have yielded %s\n", k, v)
}
}
hash.AddChash("8") // 添加机器8,虚拟节点产生:8, 18, 28
testCases["27"] = "8" // 此时,变量key的27 就应当被映射到机器 8
for k, v := range testCases {
if hash.GetChash(k) != v {
fmt.Printf("Asking for %s, should have yielded %s\n", k, v)
}
}
}
注:本文代码参考了go公共库groupcache
的部分源码
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。