??经过上一个章节的学习,我们已经知晓了如何搭建了HTTP Server,通过HTTP协议与我们定义的路由,我们可以远程访问这个节点;基于这一点,我们可以部署多台实现了HTTP的缓存服务从而实现分布式的特性。这一章节我们要基于此背景下实现分布式缓存的前置条件:多节点下的调取。
前文链接
手撕分布式缓存之一 | 定义缓存结构体与实现底层功能函数
手撕分布式缓存之二 | 互斥锁的优化
手撕分布式缓存之三 | HTTP Server搭建
??当服务使用多节点运行时,节点之间的负载均衡往往是我们优先需要考虑的问题;但缓存服务不同于一般的多节点服务,它并非仅仅是通过分摊流量就可以避免某个节点不会出问题,因为缓存往往对应着缓存雪崩的问题。举个例子:如果一个热点key在节点A中存储着,但是之后的大量该热点key的请求并没有都发送到节点A上,这样做会导致两个问题:① 每个被请求的节点都需要存储此键值对,造成了不必要的空间损失 ② 通常情况下缓存中可能存储的key-value对应着用户的Cookie,大量的请求失效导致数据库访问激增,可能导致数据库服务的崩坏。 因此我们应该主动的判断要请求的key存储在哪个节点中,尽量减少缓存穿透的情况出现。
??假设这样一个场景:我们根据节点的名称计算出对应的hashKey,并根据此value进行排序,在通过key进行查找value时,我们直接通过取余的方式进行确定应该从哪个节点中HashSearch。这样的方式看起来是行得通的,但是节点是动态的,如果我们加入了一个节点或者删除了一个节点,会导致在key进行取余定位节点时大量的对应关系失效,从而导致缓存雪崩。这种影响是致命的,每一个key值都会受到影响,只有极个别的key值可能重新计算后仍然映射到原本的关系上,但是大部分的key值会在节点变动之后找不到映射关系。
??我们可以发现,上面的影响出现的原因本质上是因为key的映射关系是与节点的数量绑定的,如果我们将key-节点的映射算法抽离节点的数量就可以避免上面场景对应问题的发生了。一致性算法就达到了这样的效果,一致性算法依旧是将key使用固定的hash算法计算,但是将key映射到节点上时并不是采用取模的方式,而是去寻找最大的比hashKey小的节点;这样做的好处就是当有节点A发生变动后,收到影响的key只有那些key-hash是大于节点A对应的hash,且小于另一个节点时,它们去找到的节点会发生变化。
??但是仅这样将真实的节点计算为hash也会有一个问题:哈希倾斜。意思时:如果大部分节点的hash值都很大,而只有一个节点的hash值很小,那么会有很多的key选择存储在这个hash值很小的节点中,因此当这个节点发生变动时,会引起哈希雪崩。针对这一问题,我们可以因为虚拟节点,这些选择虚拟节点的key最终仍然会存储到某一个真实节点中,这样就会使得节点的分布更加的密集,避免将一个大范围的key都映射到一个节点上。
??添加节点时,我们可以使用节点的唯一性标识作为key值传入,然后对节点的唯一性标识计算hash值,然后将此值存储节点的hashKey的列表中(将列表中的值排序后,便于查找hashKey对应的节点),并且将key-value存入字典中(用于在key选择节点之后,根据节点的hashKey查找对应节点的名称);值得注意的是,我们的虚拟节点的key值定义需要根据实际情况变化,最理想的情况是将所有节点(包含虚拟节点和真实节点)均匀的映射到hashMap中,这里的均匀分布不只是物理上节点排列的均匀,更好的情况是实际key请求时,存储在真实节点上的key也是均匀的。本示例中因为没有实际情况可以测试,因此简单的将不同的自然数拼接在真实节点的唯一性标识中,以标识该真实节点对应的不同的虚拟节点的唯一性标识。
??获取节点的场景发生在有key进行请求时,在我们按照相同的算法将key计算为hashKey后,在存储节点hashKey的列表中寻找最大的小于此请求hashKey的节点,找到这个节点的下标后,在列表中找到节点对应的hashValue,然后在字典中找到此hashValue对应的真实节点的名称,并且返回该名称。
定义Hash函数的传参与响应类型,定义Map结构与实现初始化函数
- type Hash func(data []byte) uint32,表示传参类型是byte类型的列表,转换成uint32类型。
- crc32.ChecksumIEEE是包hash/crc32下的一个将string类型计算hash的函数。
package consistenthash
import (
"hash/crc32"
"sort"
"strconv"
)
// Hash 定义了一个将字节数组映射到uint32的函数类型。
type Hash func(data []byte) uint32
// Map 结构体包含所有已经计算过哈希值的key列表,并且使用map存储每个虚拟节点对应的真实key。
type Map struct {
hash Hash // 指向具体的哈希算法的函数指针
replicas int // 每个真实key生成的虚拟节点数量
keys []int // Sorted, 按照从小到大排序
hashMap map[int]string
}
// New 创建一个新的Map实例。如果没有传入Hash参数,则默认使用crc32.ChecksumIEEE作为哈希算法。
func New(replicas int, fn Hash) *Map {
m := &Map{
replicas: replicas,
hash: fn,
hashMap: make(map[int]string),
}
if m.hash == nil { // 如果未提供hash函数,则使用crc32.ChecksumIEEE
m.hash = crc32.ChecksumIEEE
}
return m
}
实现节点的添加
- strconv.Itoa(i)是将任何数字都可以转换成字符串,而强制类型转换是只能将整数转换成字符串。
- sort.Ints(m.keys)对整形数据列表m.keys进行升序排列。
func (m *Map) Add(keys ...string) {
// 循环每个key,对其进行复制并计算哈希值存入hashMap
for _, key := range keys {
// 没有真正的节点概念,所以可视为虚拟节点,因此在生成哈希时使用当前索引和key组合来创建不同的哈希值
for i := 0; i < m.replicas; i++ {
// 将哈希值与key一起保存到hashMap中
hash := int(m.hash([]byte(strconv.Itoa(i) + key))
m.keys = append(m.keys, hash)
m.hashMap[hash] = key
}
}
// 对keys进行排序,以供Get()方法使用二分查找功能
sort.Ints(m.keys)
}
实现通过key请求选择节点
- sort.Search(len(m.keys) 是根据第二个函数参数什么时候返回True来判断是否满足条件,当满足条件时返回当前元素的下标,如果均不满足则返回len(m.keys)。
- idx%len(m.keys)对idx做取余操作,以实现将hash-value环状的放置的效果
// Get函数获取最接近提供的key的项。
func (m *Map) Get(key string) string {
if len(m.keys) == 0 { // 如果没有可用的key,返回空字符串
return ""
}
hash := int(m.hash([]byte(key)) // 计算key的哈希值
// 二分查找合适的复制品。sort.Search()函数是在sort包中定义的一个泛型搜索函数,
//该函数会使用给定的判断条件进行二分查找,并返回满足条件的元素下标或者插入位置下标。
//这里传入的参数len(m.keys)表示要搜索的切片长度,而后面的lambda函数则为判断条件,
//当m.keys[i] >= hash时,就认为已经找到了合适的复制品。
idx := sort.Search(len(m.keys), func(i int) bool {
// 终止条件是m.keys[i] >= hash
return m.keys[i] >= hash
})
return m.hashMap[m.keys[idx%len(m.keys)]]
}