zoukankan      html  css  js  c++  java
  • 一致性哈希的理解与实践

    维基百科中这样定义一致性哈希算法:

    一致哈希 是一种特殊的哈希算法。在使用一致哈希算法后,哈希表槽位数(大小)的改变平均只需要对K/n 个关键字重新映射,其中K是关键字的数量,n是槽位数量。然而在传统的哈希表中,添加或删除一个槽位几乎需要对所有关键字进行重新映射。

    好吧,初次接触,看不懂,我们从传统的哈希算法开始讲起。

    1. 传统哈希算法

    随着业务的发展,数据量的提升,我们需要增加服务器节点。对于客户端发来的请求,我们首先通过一个Proxy层,由Proxy层处理来自客户端的读写请求,接收到读写请求后,通过对 Key 做哈希找到对应的节点。如下图所示:

    1

    通过哈希算法,每个 key 都可以寻址到对应的服务器,假设客户端发过来的请求是 key-01,计算公式为 hash(key-01) % 3 ,经过计算寻址到了编号为 1 的服务器节点 A,如下图所示。

    2

    但如果服务器数量发生变化,基于新的服务器数量来执行哈希算法的时候,就会出现路由寻址失败的情况,Proxy 无法找到之前寻址到的那个服务器节点,这是为什么呢?想象一下,假如 3 个节点不能满足业务需要了,这时我们增加了一个节点,节点的数量从 3 变化为 4,那么之前的 hash(key-01) % 3 = 1,就变成了 hash(key-01) % 4 = X,因为取模运算发生了变化,所以这个 X 大概率不是 1,这时你再查询,就会找不到数据了,因为 key-01 对应的数据,存储在节点 A 上,而不是节点 B。同样的道理,如果我们需要下线 1 个服务器节点(也就是缩容),也会存在类似的可能查询不到数据的问题。

    为了解决这一问题,我们就需要迁移数据,基于新的计算公式 hash(key-01) % 4 ,来重新对数据和节点做映射。需要注意的是,数据的迁移成本是非常高的。通过一个具体的场景进行说明:

    假定有1000万条数据,原先存储在3个节点上,如果我们增加1个节点,需要迁移多少数据?

    下面通过代码验证:(完整代码在这里

    // 传统的哈希映射
    func hash(key int, nodes int) int {
    	return key % nodes
    }
    // migrate:需要迁移的数据量
    // keys:数据量(1000万)
    // nodes:旧集群的节点个数
    // newNodes:新集群的节点个数
    // migrateRatio:数据迁移率
    migrate := 0
    for i := 0; i < keys; i++ {
    	if hash(i, nodes) != hash(i, newNodes) {
    		migrate++
    	}
    }
    migrateRatio := float64(migrate) / float64(keys)
    

    通过执行:

    $ go run ./hash.go  -keys 10000000 -nodes 3 -new-nodes 4
    74.999980%
    

    可以看到,我们需要迁移75%的数据。因为普通的哈希映射强依赖于集群的节点个数,当增加或减少节点个数时,映射关系就会发生动荡的变化,这在生产环境中是不可容忍的。

    为了解决这一问题,就引出了一致性哈希算法

    2. 一致哈希算法

    2.1 原理:哈希环

    一致哈希算法也用了取模运算,但与传统的哈希算法对节点的数量进行取模运算不同,一致哈希算法是对 2^32 - 1 进行取模运算(就相当于没取模啊...)。你可以想象下,一致哈希算法,将整个哈希值空间组织成一个虚拟的圆环,也就是哈希环。

    在一致哈希中,通过执行哈希算法(为了演示方便,假设哈希算法函数为“c-hash()”),将节点映射到哈希环上 (通常选择节点的主机名、ip地址等作为参数执行 c-hash()),如下图所示:

    3

    当需要对指定 key 的值进行读写的时候,你可以通过下面 2 步进行寻址:

    1. 首先,将 key 作为参数执行 c-hash() 计算哈希值,并确定此 key 在环上的位置
    2. 然后,从这个位置沿着哈希环顺时针“行走”,遇到的第一节点就是 key 对应的节点

    这一过程如下图所示:

    4

    可以看到,在上图中,key-01 寻址到了节点A,key-02 寻址到了节点B,key-03 寻址到了节点C。如果节点C宕机了,那么根据寻址规则,只需要把key-03重新定位到节点A即可,而key-01,key-02不需要改变。也就是说,一致性哈希算法,在新增/减少节点时,只需要重新定位该节点附近的一小部分数据,而不需要重新定位所有的节点。这样就有效的解决了“增加/减少节点时需要大规模迁移数据”的问题。

    5

    2.2 问题:数据倾斜

    当服务器节点较少时,容易造成节点位置分布不均的情况,从而造成数据倾斜。如下图所示,由于节点分布不均,使得大量的访问请求集中到节点A上,造成缓存节点负载不均衡。这就是数据倾斜问题。

    6

    为了解决数据倾斜问题,引入了虚拟节点的概念。

    2.3 改进:引入虚拟节点

    所谓虚拟节点,就是对每一个服务器节点计算多个哈希值,在每个计算结果位置上,都放置一个虚拟节点,并将虚拟节点映射到实际节点。假设1个真实节点对应2个虚拟节点,那么节点A对应的虚拟节点是node-A-01,node-A-02(通常以添加编号的方式实现),其余节点类似。于是就形成了6个虚拟节点,如下图所示:

    7

    随由于节点数增多了,分布自然会变得均匀了。当寻址时,计算key的哈希值,在环上顺时针寻找应该选取的虚拟节点,比如key-01选择虚拟节点node-B-02,那么就把该请求映射到真实节点B上。

    虚拟节点扩充了节点的数量,解决了节点较少的情况下数据容易倾斜的问题,而且代价非常小,只需要增加一个字典(map)维护真实节点与虚拟节点的映射关系即可。

    2.4 实现

    定义一致性哈希实体。

    // 哈希函数签名
    type Hash func(data []byte) uint32
    // 一致性哈希实体
    type Map struct {
    	hash     Hash           // 哈希函数
    	replicas int            // 每个真实节点对应的虚拟节点个数
    	circle   []int          // 哈希环
    	hashMap  map[int]string // 存储虚拟节点与真实节点的映射
    }
    // 创建实例
    func New(replicas int, hashFunc Hash) *Map {
    	m := &Map{
    		hash:     hashFunc,
    		replicas: replicas,
    		hashMap: make(map[int]string),
    	}
    	if m.hash == nil {
    		m.hash = crc32.ChecksumIEEE
    	}
    	return m
    }
    

    说明:

    • 定义了函数类型Hash,采取依赖注入的方式,允许用户自定义哈希函数,默认为crc32.ChecksumIEEE算法。
    • Map 是一致性哈希算法的主数据结构,包含 4 个成员变量:Hash 函数 hash;虚拟节点倍数 replicas,即每个真实节点对应replicas个虚拟节点;哈希环 circle;虚拟节点与真实节点的映射表 hashMap,键是虚拟节点的哈希值,值是真实节点的名称。
    • 构造函数 New() 允许自定义虚拟节点倍数和 Hash 函数。

    接下来,实现添加真实节点的Add()方法。

    // 添加真实节点
    func (m *Map) Add(peers ...string) {
    	for _, peer := range peers {
    		for i := 0; i < m.replicas; i++ {
    			// 根据节点的名称+编号计算hash值
    			hashVal := int(m.hash([]byte(strconv.Itoa(i) + peer)))
    			//log.Printf("peer:[%s-%d], hash:[%d]
    ", peer, i, hashVal)
    			m.circle = append(m.circle, hashVal)
    			m.hashMap[hashVal] = peer
    		}
    	}
    	// 排序是为了接下来方便查询
    	sort.Ints(m.circle)
    	//log.Printf("circle:
     %v
    ",m.circle)
    }
    

    说明:

    • Add()方法允许传入0个或多个真实节点的名称。
    • 对每个真实节点peer,对应创建replicas个虚拟节点,我们令虚拟节点的名称为strconv.Itoa(i) + peer,即通过添加编号的方式区分不同虚拟节点。
    • 使用 m.hash() 计算虚拟节点的哈希值,使用 append(m.circle, hashVal) 把哈希值添加到环上。
    • hashMap 中增加虚拟节点和真实节点的映射关系。
    • 最后,需要对换上的哈希值进行排序。排序是为了方便后续的查找。

    最后,实现选择节点的Get()方法。

    // 根据请求的key选择对应的真实节点
    // 返回真实节点的名称
    func (m *Map) Get(key string) string {
    	if len(m.circle) == 0 {
    		return ""
    	}
    	// 首先计算key对应的hash值
    	hashVal := int(m.hash([]byte(key)))
    	// 二分查找
    	index := sort.Search(len(m.circle), func(i int) bool {
    		return m.circle[i] >= hashVal
    	})
    	return m.hashMap[m.circle[index%len(m.circle)]]
    }
    

    说明:

    • Get()方法根据请求的key选择对应的真实节点,返回真实节点的名称。
    • 首先,对key计算对应的哈希值。
    • 然后,顺时针找到第一个匹配的虚拟节点的下标 index。所谓匹配,就是在哈希环m.circle中,找到第一个哈希值大于等于key的哈希值的虚拟节点。
    • 最后,通过hashMap映射得到真实的节点。

    至此,一致性哈希算法就全部完成了。

    2.5 测试

    我们测试一致性哈希是否能正常的增加节点和选择节点,另外,我们也测试它在增加节点时数据的迁移率如何,是否针对比传统哈希算法要好。

    func TestConsistentHash(t *testing.T) {
    	// 创建一个一致性哈希实例,并自定义hash函数
    	chash := New(3, func(data []byte) uint32 {
    		i, _ := strconv.Atoi(string(data))
    		return uint32(i)
    	})
    	// 添加真实节点,为了方便说明,这里的节点名称只用数字进行表示
    	chash.Add("4", "6", "2")
    
    	testCases := map[string]string{
    		"15": "6",
    		"11": "2",
    		"23": "4",
    		"27": "2",
    	}
    	for k, v := range testCases {
    		if chash.Get(k) != v {
    			t.Errorf("Asking for %s, should have yielded %s", k, v)
    		}
    	}
    
    	// 新增一个节点"8",对应增加3个虚拟节点,分别为8,18,28
    	chash.Add("8")
    
    	// 此时如果查询的key为27,将会对应到虚拟节点28,也就是映射到真实节点8
    	testCases["27"] = "8"
    
    	for k, v := range testCases {
    		if chash.Get(k) != v {
    			t.Errorf("Asking for %s, should have yielded %s", k, v)
    		}
    	}
    }
    

    为了方便说明,这里简化了节点的名称(仅用数字来表示),并且自定义哈希函数。

    • 一开始,有 2/4/6 三个真实节点,对应的虚拟节点的哈希值是 02/12/22、04/14/24、06/16/26。
    • 那么用例 15/11/23/27 选择的虚拟节点分别是 16/12/24/02,也就是真实节点6/2/4/2。
    • 新增一个节点"8",对应增加3个虚拟节点,分别为8,18,28,此时,用例 27 对应的虚拟节点从2变更为 28,即真实节点 8。

    测试迁移率

    var keysPtr = flag.Int("keys", 10000, "key number")
    var nodesPtr = flag.Int("nodes", 3, "node number of old cluster")
    var newNodesPtr = flag.Int("new-nodes", 4, "node number of new cluster")
    
    // 测试一致性哈希的数据迁移率
    func TestMigrateRatio(t *testing.T) {
    	flag.Parse()
    	var keys = *keysPtr
    	var nodes = *nodesPtr
    	var newNodes = *newNodesPtr
    	fmt.Printf("keys:%d, nodes:%d, newNodes:%d
    ", keys, nodes, newNodes)
    
    	c := New(3, nil)
    	for i := 0; i < nodes; i++ {
    		c.Add(strconv.Itoa(i))
    	}
    
    	newC := New(3, nil)
    	for i := 0; i < newNodes; i++ {
    		newC.Add(strconv.Itoa(i))
    	}
    
    	migrate := 0
    	for i := 0; i < keys; i++ {
    		server := c.Get(strconv.Itoa(i))
    		newServer:= newC.Get(strconv.Itoa(i))
    		if server != newServer {
    			migrate++
    		}
    	}
    	migrateRatio := float64(migrate) / float64(keys)
    	fmt.Printf("%f%%
    ", migrateRatio*100)
    }
    

    在测试迁移率的函数中,为了保证正确性,我们使用默认的hash函数来计算哈希值(前面的测试是为了方便说明所以自定义了hash函数,在这里就不能这样使用了)。通过执行如下命令,可以看到,相比于传统哈希算法,使用我们自己实现的一致哈希算法可以大大的降低数据迁移率。

    $ go test -v -run=TestMigrateRatio -keys 1000000 -nodes 3 -new-nodes 4
    === RUN   TestMigrateRatio
    keys:1000000, nodes:3, newNodes:4
    22.984500%
    --- PASS: TestMigrateRatio (0.50s)
    

    (完)


    参考:

    1. 极客时间专栏:https://time.geekbang.org/column/article/207426
    2. https://geektutu.com/post/geecache-day4.html
    3. https://github.com/stathat/consistent/blob/master/consistent.go
    4. https://github.com/hanj4096/hash/blob/master/consistent-hash.go
  • 相关阅读:
    Longhorn,企业级云原生容器分布式存储
    Longhorn,企业级云原生容器分布式存储
    Longhorn,企业级云原生容器分布式存储
    数通——VLAN
    数通——动态路由
    AtCoder Beginner Contest 216 A~F 题解
    【YBTOJ】序列的第k个数
    【YBTOJ】【UVA10140】Prime Distance
    【YBTOJ】【CodeForces 372C】Watching Fireworks is Fun
    【YBTOJ】涂抹果酱
  • 原文地址:https://www.cnblogs.com/kkbill/p/12728325.html
Copyright © 2011-2022 走看看