7天用Go从零实现分布式缓存--Day4 一致性哈希

51 阅读9分钟

本系列文章全部参考自极客兔兔的《7天用Go从零实现分布式缓存GeeCache》:geektutu.com/post/geecac… 下面的内容均参考自该文章,同时结合了我自己学习过程中的思考,如果存在问题还请大家多多指教。

备注:Day4 的内容原文讲的也很通俗易懂,所以大部分都是原文内容搬过来的,加上了一点我的理解和解释在里面。

Day4 一致性哈希

1 为什么要用一致性哈希

今天我们要实现的是一致性哈希算法,一致性哈希算法是 GeeCache 从单节点走向分布式节点的一个重要的环节。那你可能要问了,一致性哈希算法是啥?为什么要使用一致性哈希算法?这和分布式有什么关系?

1.1 我该访问谁

对于分布式缓存来说,当一个节点接收到请求,如果该节点并没有存储缓存值,那么它面临的难题是,从谁那获取数据?自己,还是节点1, 2, 3, 4… 。假设包括自己在内一共有 10 个节点,当一个节点接收到请求时,随机选择一个节点,由该节点从数据源获取数据。

假设第一次随机选取了节点 1 ,节点 1 从数据源获取到数据的同时缓存该数据;那第二次,只有 1/10 的可能性再次选择节点 1, 有 9/10 的概率选择了其他节点,如果选择了其他节点,就意味着需要再一次从数据源获取数据,一般来说,这个操作是很耗时的。这样做,一是缓存效率低,二是各个节点上存储着相同的数据,浪费了大量的存储空间。

那有什么办法,对于给定的 key,每一次都选择同一个节点呢?使用 hash 算法也能够做到这一点。那把 key 的每一个字符的 ASCII 码加起来,再除以 10 取余数可以吗?当然可以,这可以认为是自定义的 hash 算法。

从上面的图可以看到,任意一个节点任意时刻请求查找键 Tom 对应的值,都会分配给节点 2,有效地解决了上述的问题。

1.2 节点数量变化了怎么办

简单求取 Hash 值解决了缓存性能的问题,但是没有考虑节点数量变化的场景。假设,移除了其中一台节点,只剩下 9 个,那么之前 hash(key) % 10 变成了 hash(key) % 9,也就意味着几乎缓存值对应的节点都发生了改变。即几乎所有的缓存值都失效了。节点在接收到对应的请求时,均需要重新去数据源获取数据,容易引起 缓存雪崩

缓存雪崩:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。常因为缓存服务器宕机,或缓存设置了相同的过期时间引起。

那么如何解决这个问题呢?一致性哈希算法就可以解决这个问题。

2 算法原理

2.1 算法步骤

一致性哈希算法将 key 映射到 2^32 的空间中,将这个数字首尾相连,形成一个环。

  • 计算节点/机器(通常使用节点的名称、编号和 IP 地址)的哈希值,放置在环上。
  • 计算 key 的哈希值,放置在环上,顺时针寻找到的第一个节点,就是应选取的节点/机器。

环上有 peer2,peer4,peer6 三个节点,key11key2key27 均映射到 peer2,key23 映射到 peer4。此时,如果新增节点/机器 peer8,假设它新增位置如图所示,那么只有 key27 从 peer2 调整到 peer8,其余的映射均没有发生改变。

也就是说,一致性哈希算法,在新增/删除节点时,只需要重新定位该节点附近的一小部分数据,而不需要重新定位所有的节点,这就解决了上述的问题。

2.2 数据倾斜问题

如果服务器的节点过少,容易引起 key 的倾斜。例如上面例子中的 peer2,peer4,peer6 分布在环的上半部分,下半部分是空的。那么映射到环下半部分的 key 都会被分配给 peer2,key 过度向 peer2 倾斜,缓存节点间负载不均。

为了解决这个问题,引入了虚拟节点的概念,一个真实节点对应多个虚拟节点。

假设 1 个真实节点对应 3 个虚拟节点,那么 peer1 对应的虚拟节点是 peer1-1、 peer1-2、 peer1-3(通常以添加编号的方式实现),其余节点也以相同的方式操作。

  • 第一步,计算虚拟节点的 Hash 值,放置在环上。
  • 第二步,计算 key 的 Hash 值,在环上顺时针寻找到应选取的虚拟节点,例如是 peer2-1,那么就对应真实节点 peer2。

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

并且虚拟节点还有一个很好的优点,就是可以根据服务器性能的不同分配虚拟节点个数,性能好的就多分配几个,性能差一点的就少分配几个——这就是加权一致性哈希,很多分布式缓存和负载均衡都在用这招。

3 Go语言实现

我们在 geecache 目录下新建 package consistenthash,用来实现一致性哈希算法。

package consistenthash

import (
        "hash/crc32"
        "sort"
        "strconv"
)

type Hash func(data []byte) uint32

type Map struct {
        hash     Hash           // Hash 函数
        replicas int            // 每个真实节点要生成多少个虚拟节点
        keys     []int          // 所有虚拟节点的哈希值,排好序后形成一个“环”
        hashMap  map[int]string // 虚拟节点与真实节点的映射表(key为虚拟节点的hash值,value为真实节点的名称)
}

// New 用于创建 Map 实例
func New(replicas int, fn Hash) *Map {
        m := &Map{
                replicas: replicas,
                hash:     fn,
                hashMap:  make(map[int]string),
        }
        // 如果用户未指定 Hash 函数,默认使用 crc32.ChecksumIEEE
        if m.hash == nil {
                m.hash = crc32.ChecksumIEEE
        }
        return m
}
  • 定义了函数类型 Hash,采取依赖注入的方式,允许用于替换成自定义的 Hash 函数,也方便测试时替换,默认为 crc32.ChecksumIEEE 算法。

  • Map 是一致性哈希算法的主数据结构,包含 4 个成员变量:

    • hash:Hash 函数
    • replicas:表示每个真实节点生成多少个虚拟节点
    • keys:所有虚拟节点的哈希值,是一个有序列表,用于模拟“环”
    • hashMap:虚拟节点与真实节点的映射表(key为虚拟节点的hash值,value为真实节点的名称)
  • 构造函数 New() 允许自定义虚拟节点个数和 Hash 函数

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

// Add 用于添加 真实节点/机器
func (m *Map) Add(keys ...string) {
        for _, key := range keys {
                // 创建 replicas 个虚拟节点
                for i := 0; i < m.replicas; i++ {
                        hash := int(m.hash([]byte(strconv.Itoa(i) + key)))
                        m.keys = append(m.keys, hash)
                        m.hashMap[hash] = key
                }
        }
        sort.Ints(m.keys)
}
  • Add 函数允许传入 0 或 多个真实节点的名称。

  • 对每一个真实节点 key,对应创建 m.replicas 个虚拟节点,虚拟节点的名称是:strconv.Itoa(i) + key,即通过添加编号的方式区分不同虚拟节点。

  • 使用 m.hash() 计算虚拟节点的哈希值,使用 append(m.keys, hash) 添加到环上。

  • hashMap 中增加虚拟节点和真实节点的映射关系。

  • 最后一步,环上的哈希值排序。

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

// Get 用于选择节点
func (m *Map) Get(key string) string {
        // 如果环上没有节点可以用来存储缓存,直接返回
        if len(m.keys) == 0 {
                return ""
        }
        hash := int(m.hash([]byte(key))) // 计算 key 的 hash值
        // 二分查找,找到第一个 >= hash 的虚拟节点位置,并把下标返回给 idx
        idx := sort.Search(len(m.keys), func(i int) bool {
                return m.keys[i] >= hash
        })

        // 返回匹配到的节点的真实名字
        return m.hashMap[m.keys[idx%len(m.keys)]]
}
  • 选择节点就非常简单了,第一步,计算 key 的哈希值。
  • 第二步,顺时针找到第一个匹配的虚拟节点的下标 idx,从 m.keys 中获取到对应的哈希值。如果 idx == len(m.keys),说明应选择 m.keys[0],因为 m.keys 是一个环状结构,所以用取余数的方式来处理这种情况。
  • 第三步,通过 hashMap 映射得到真实的节点。

至此,整个一致性哈希算法就实现完成了。

4 测试

// geecacge/consistenthash/consistenthash_test.go
package consistenthash

import (
        "strconv"
        "testing"
)

func TestHashing(t *testing.T) {
        // 这里传入了一个“假hash”,方便测试
        hash := New(3, func(key []byte) uint32 {
                i, _ := strconv.Atoi(string(key))
                return uint32(i)
        })

        // 加入3个真实节点
        // 生成虚拟节点后得到的哈希环:2, 4, 6, 12, 14, 16, 22, 24, 26
        hash.Add("6", "4", "2")

        testCases := map[string]string{
                "2":  "2",
                "11": "2",
                "23": "4",
                "27": "2",
        }

        for k, v := range testCases {
                if hash.Get(k) != v {
                        t.Errorf("Asking for %s, should have yielded %s", k, v)
                }
        }

        // 在环上添加虚拟节点 8, 18, 28
        hash.Add("8")

        // 27 应该映射到 8
        testCases["27"] = "8"

        for k, v := range testCases {
                if hash.Get(k) != v {
                        t.Errorf("Asking for %s, should have yielded %s", k, v)
                }
        }

}

如果要进行测试,那么我们需要明确地知道每一个传入的 key 的哈希值,那使用默认的 crc32.ChecksumIEEE 算法显然达不到目的。所以在这里使用了自定义的 Hash 算法。自定义的 Hash 算法只处理数字,传入字符串表示的数字,返回对应的数字即可。

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