一致性哈希算法(附go语言实现)

3,002 阅读5分钟

一致性hash算法常用于分布式分片数据存储系统的key路由的设计上,把数据key均匀的映射到集群中的各位存储节点上。采用一致性hash算法有如下两个优势:

  1. key均匀的映射到集群中的各个存储节点。
  2. 当集群中增加和删除节点Y时,只会影一部分原先映射到Y的相邻节点上的key,不会导致集群中大量的key失效。

什么是一致性hash算法?

普通的hash算法是通过hash(key) % N的方式来做分布式存储的key路由,即先计算数据key的hash值,然后跟集群中的节点数N取模,得到对应的节点。在集群中增加或减少节点时,会导致大量的key无法被找到。

那一致性hash算法是怎么做的呢?

hash圆环

想象有一个2^32个hash slot的的环形hash空间。

将集群节点通过hash算法映射到hash圆环上

对节点的ip或者唯一的主机名做hash计算得到一个0 - 2^32-1之间的散列值,映射到hash圆环上。

把数据的key映射到hash圆环上

在确定数据的读取/写入节点时,对数据的key进行hash运算得到一个在0 - 2^32-1之间的散列值,映射到hash圆环上。

顺时针查找数据对应的节点

确定了数据在hash圆环上的位置之后,按顺时针方向,在hash上遇到的第一个节点,即为写入/读取数据的节点。上图中,DATA1对应的节点是NODE1,DATA2对应的节点是NODE2

虚拟节点

在上图中,节点NODE1和NODE2对hash空间的划分并不均匀,按照在hash圆环上顺时针查找节点的规则,会有更多的读写请求落到NODE1上,导致QPS压力和数据存储空间压力没有在集群中平衡。

解决这个问题的方式是为每个节点在hash圆环上映射多个虚拟节点(virtual node),可以对一个节点的ip/唯一主机名加上一组后缀在计算散列值映射到hash圆环上,只要我们在记录的时候属于同一机器的虚拟节点最终都能指向同一个ip/port即可。由于hash算法具有分散性,在概率上,创建一定数量的虚拟节点之后,可以将hash空间均匀的划分。例如在下图中,一个数据无论被映射到NODE1还是虚拟节点VNODE1上,最终都路由到192.168.1.1这个IP地址上。

Go语言实现

相信很多了解过一致性hash算法原理的同学,会有有这样一个困惑,得到了要操作的key在hash环上映射的hash slot之后,应该用什么方式去找到hash圆环上顺时针方向上最近的一个映射了Node(机器)的hash slot呢,毕竟hash圆环上有2^32方个hash slot,如果通过遍历的方式去搜索,算法的效率肯定是不能接收的。

其实我们可以给hash slot编号,编号的大小按顺时针方向递增。如果我们把所有映射了Node的hash slot编号按顺时针方向保存到一个数组中,就得到了一个元素大小单调递增的数组。通过散列函数计算出一个带操作key的散列值h1之后,搜索对应Node的过程就变成了在数组中查找大于等于h1的最小值,可以通过二分查找来完成,时间复杂度是O(logN),其中N只是虚拟Node的数量,查找的效率非常高。

实现一致性hash算法,我用到了一下几个数据结构:

Map

使用一个map保存所有虚拟Node和hash slot的对应关系,map的key是hash slot的编号,value是虚拟Node的ip/port.

Slice

使用一个slice按从大到小的顺序保存所有虚拟Node映射到的hash slot。当计算出一个key的散列值h1之后,在slice中通过二分查找找到大于等于h1的最小值来确定虚拟Node,再去上面的map中找到虚拟Node对应的ip/port,即为读写这个key的机器的地址。

具体实现:

定义一个结构体

type ConsistenceHash struct {
	nodesMap        map[uint32]string // hash slot和虚拟node的映射关系
	nodesSlots      slots // 虚拟node所有hash slot组成的切片
	NumVirtualNodes int // 为每台机器在hash圆环上创建多少个虚拟Node
}

// 使用sort.Sort函数,传入的参数需要实现的接口
type slots []uint32

func (s slots) Len() int {
	return len(s)
}

func (s slots) Less(i, j int) bool {
	return s[i] < s[j]
}

func (s slots) Swap(i, j int) {
	s[i], s[j] = s[j], s[i]
}

由于hash圆环上有2^32个hash slot,使用uint32类型来标识hash slot。为了能够使用sort.Sort函数排序对虚拟Node的hash slot进行排序,自定义一个[]uint32的类型实现sort.Sort函数约定的参数接口。

添加节点方法

// 通过crc32函数计算散列值
func (h *ConsistenceHash) hash(key string) uint32 {
	return crc32.ChecksumIEEE([]byte(key))
}

// 集群中增加机器
func (h *ConsistenceHash) AddNode(addr string) {
	h.Lock()
	defer h.Unlock()
	// 根据定义的数量生成虚拟Node
	// addr加上不同的后缀计算散列值得到每个虚拟Node的hash slot
	// 同一个机器的所有hash slot最终都指向同一个ip/port
	for i := 0; i < h.NumVirtualNodes; i++ {
		slot := h.hash(fmt.Sprintf("%s%d", addr, i))
		h.nodesMap[slot] = addr
	}
	h.sortNodesSlots()
}

// 所有虚拟Node映射到的hash slot排序后保存到切片
func (h *ConsistenceHash) sortNodesSlots() {
	slots := h.nodesSlots[:]
	for slot := range h.nodesMap {
		slots = append(slots, slot)
	}
	sort.Sort(slots)
	h.nodesSlots = slots
}

选择crc32算法作为散列函数。

删除节点

// 从集群中摘除机器
func (h *ConsistenceHash) DeleteNode(addr string) {
	h.Lock()
	defer h.Unlock()
	// 删除所有的虚拟节点
	for i := 0; i < h.NumVirtualNodes; i++ {
		slot := h.hash(fmt.Sprintf("%s%d", addr, i))
		delete(h.nodesMap, slot)
	}
	h.sortNodesSlots()
}

查找用于读写某个key的Node

// 查找一个key对应的读写Node
func (h *ConsistenceHash) SearchNode(key string) (string, error) {
	slot := h.hash(key)
	// 使用sort包的二分查找函数
	f := func(x int) bool {
		return h.nodesSlots[x] >= slot 
	}
	index := sort.Search(len(h.nodesSlots), f)
	if index >= len(h.nodesSlots) {
		index = 0
	}
	if addr, ok := h.nodesMap[h.nodesSlots[index]]; ok {
		return addr, nil
	} else {
		return addr, errors.New("not found")
	}
}