分布式缓存项目RQCache(Go)
在计算机系统中,缓存无处不在,比如我们访问一个网页,网页和引用的 JS/CSS 等静态文件,根据不同的策略,会缓存在浏览器本地或是 CDN 服务器,那在第二次访问的时候,就会觉得网页加载的速度快了不少;比如微博的点赞的数量,不可能每个人每次访问,都从数据库中查找所有点赞的记录再统计,数据库的操作是很耗时的,很难支持那么大的流量,所以一般点赞这类数据是缓存在 Redis 服务集群中的。
缓存会有哪些问题:
- 内存受限:随机删掉好呢?还是按照时间顺序好呢?或者是有没有其他更好的淘汰策略呢?不同数据的访问频率是不一样的,优先删除访问频率低的数据是不是更好呢?数据的访问频率可能随着时间变化,那优先删除最近最少访问的数据可能是一个更好的选择。我们需要实现一个合理的淘汰策略;
- 并发写入冲突:对缓存的访问,一般不可能是串行的。map 是没有并发保护的,应对并发的场景,修改操作(包括新增,更新和删除)需要加锁;
- 单机性能不够:单台计算机的资源是有限的,计算、存储等都是有限的。随着业务量和访问量的增加,单台机器很容易遇到瓶颈。如果利用多台计算机的资源,并行处理提高性能就要缓存应用能够支持分布式,这称为水平扩展(scale horizontally)。与水平扩展相对应的是垂直扩展(scale vertically),即通过增加单个节点的计算、存储、带宽等,来提高系统的性能,硬件的成本和性能并非呈线性关系,大部分情况下,分布式系统是一个更优的选择。
设计一个分布式缓存系统,需要考虑资源控制、淘汰策略、并发、分布式节点通信等各个方面的问题。而且,针对不同的应用场景,还需要在不同的特性之间权衡,例如,是否需要支持缓存更新?还是假定缓存在淘汰之前是不允许改变的。不同的权衡对应着不同的实现。
1. LRU 缓存淘汰策略
最近最少使用(LRU),相对于仅考虑时间因素的 FIFO 和仅考虑访问频率的 LFU,LRU 算法可以认为是相对平衡的一种淘汰算法。LRU 认为,如果数据最近被访问过,那么将来被访问的概率也会更高。LRU 算法的实现非常简单,维护一个队列,如果某条记录被访问了,则移动到队尾,那么队首则是最近最少访问的数据,淘汰该条记录即可。
1.1 核心数据结构
这张图很好地表示了 LRU 算法最核心的 2 个数据结构:
- 绿色的是字典(map),存储键和值的映射关系。这样根据某个键(key)查找对应的值(value)的复杂是
O(1)
,在字典中插入一条记录的复杂度也是O(1)
。 - 红色的是双向链表(double linked list)实现的队列。将所有的值放到双向链表中,这样,当访问到某个值时,将其移动到队尾的复杂度是
O(1)
,在队尾新增一条记录以及删除一条记录的复杂度均为O(1)
。
接下来我们创建一个包含字典和双向链表的结构体类型 Cache,方便实现后续的增删查改操作。
package lru import "container/list" // Cache is a LRU cache. It is not safe for concurrent access. type Cache struct { maxBytes int64 nbytes int64 ll *list.List cache map[string]*list.Element // optional and executed when an entry is purged. OnEvicted func(key string, value Value) } type entry struct { key string value Value } // Value use Len to count how many bytes it takes type Value interface { Len() int }
- 在这里我们直接使用 Go 语言标准库实现的双向链表
list.List
。 - 字典的定义是
map[string]*list.Element
,键是字符串,值是双向链表中对应节点的指针。 maxBytes
是允许使用的最大内存,nbytes
是当前已使用的内存,OnEvicted
是某条记录被移除时的回调函数,可以为 nil。- 键值对
entry
是双向链表节点的数据类型,在链表中仍保存每个值对应的 key 的好处在于,淘汰队首节点时,需要用 key 从字典中删除对应的映射。 - 为了通用性,我们允许值是实现了
Value
接口的任意类型,该接口只包含了一个方法Len() int
,用于返回值所占用的内存大小。
方便实例化 Cache
,实现 New()
函数:
// New is the Constructor of Cache func New(maxBytes int64, onEvicted func(string, Value)) *Cache { return &Cache{ maxBytes: maxBytes, ll: list.New(), cache: make(map[string]*list.Element), OnEvicted: onEvicted, } }
1.2 查找功能
查找主要有 2 个步骤,第一步是从字典中找到对应的双向链表的节点,第二步,将该节点移动到队尾。
// Get look ups a key's value func (c *Cache) Get(key string) (value Value, ok bool) { if ele, ok := c.cache[key]; ok { c.ll.MoveToFront(ele) kv := ele.Value.(*entry) return kv.value, true } return }
- 如果键对应的链表节点存在,则将对应节点移动到队尾,并返回查找到的值。
c.ll.MoveToFront(ele)
,即将链表中的节点ele
移动到队尾(双向链表作为队列,队首队尾是相对的,在这里约定 front 为队尾)
1.3 删除
这里的删除,实际上是缓存淘汰。即移除最近最少访问的节点(队首)。
// RemoveOldest removes the oldest item func (c *Cache) RemoveOldest() { ele := c.ll.Back() if ele != nil { c.ll.Remove(ele) kv := ele.Value.(*entry) delete(c.cache, kv.key) c.nbytes -= int64(len(kv.key)) + int64(kv.value.Len()) if c.OnEvicted != nil { c.OnEvicted(kv.key, kv.value) } } }
c.ll.Back()
取到队首节点,从链表中删除。delete(c.cache, kv.key)
,从字典中c.cache
删除该节点的映射关系。- 更新当前所用的内存
c.nbytes
。 - 如果回调函数
OnEvicted
不为 nil,则调用回调函数。
1.4 新增/修改
// Add adds a value to the cache. func (c *Cache) Add(key string, value Value) { if ele, ok := c.cache[key]; ok { c.ll.MoveToFront(ele) kv := ele.Value.(*entry) c.nbytes += int64(value.Len()) - int64(kv.value.Len()) kv.value = value } else { ele := c.ll.PushFront(&entry{key, value}) c.cache[key] = ele c.nbytes += int64(len(key)) + int64(value.Len()) } for c.maxBytes != 0 && c.maxBytes < c.nbytes { c.RemoveOldest() } }
- 如果键存在,则更新对应节点的值,并将该节点移到队尾。
- 不存在则是新增场景,首先队尾添加新节点
&entry{key, value}
, 并字典中添加 key 和节点的映射关系。 - 更新
c.nbytes
,如果超过了设定的最大值c.maxBytes
,则移除最少访问的节点。
2. 并发读写
接下来我们使用 sync.Mutex
封装 LRU 的几个方法,使之支持并发的读写。在这之前,我们抽象了一个只读数据结构 ByteView
用来表示缓存值,是 GeeCache 主要的数据结构之一。
package geecache // A ByteView holds an immutable view of bytes. type ByteView struct { b []byte } // Len returns the view's length func (v ByteView) Len() int { return len(v.b) } // ByteSlice returns a copy of the data as a byte slice. func (v ByteView) ByteSlice() []byte { return cloneBytes(v.b) } // String returns the data as a string, making a copy if necessary. func (v ByteView) String() string { return string(v.b) } func cloneBytes(b []byte) []byte { c := make([]byte, len(b)) copy(c, b) return c }
- ByteView 只有一个数据成员,
b []byte
,b 将会存储真实的缓存值。选择 byte 类型是为了能够支持任意的数据类型的存储,例如字符串、图片等。 - 实现
Len() int
方法,我们在 lru.Cache 的实现中,要求被缓存对象必须实现 Value 接口,即Len() int
方法,返回其所占的内存大小。 b
是只读的,使用ByteSlice()
方法返回一个拷贝,防止缓存值被外部程序修改。
接下来就可以为 lru.Cache 添加并发特性了。
package geecache import ( "geecache/lru" "sync" ) type cache struct { mu sync.Mutex lru *lru.Cache cacheBytes int64 } func (c *cache) add(key string, value ByteView) { c.mu.Lock() defer c.mu.Unlock() if c.lru == nil { c.lru = lru.New(c.cacheBytes, nil) } c.lru.Add(key, value) } func (c *cache) get(key string) (value ByteView, ok bool) { c.mu.Lock() defer c.mu.Unlock() if c.lru == nil { return } if v, ok := c.lru.Get(key); ok { return v.(ByteView), ok } return }
cache.go
的实现非常简单,实例化 lru,封装 get 和 add 方法,并添加互斥锁 mu。- 在
add
方法中,判断了c.lru
是否为 nil,如果等于 nil 再创建实例。这种方法称之为延迟初始化(Lazy Initialization),一个对象的延迟初始化意味着该对象的创建将会延迟至第一次使用该对象时。主要用于提高性能,并减少程序内存要求。
3. 主体结构 Group
Group 是 GeeCache 最核心的数据结构,负责与用户的交互,并且控制缓存值存储和获取的流程。
是
接收 key --> 检查是否被缓存 -----> 返回缓存值 ⑴
| 否 是
|-----> 是否应当从远程节点获取 -----> 与远程节点交互 --> 返回缓存值 ⑵
| 否
|-----> 调用`回调函数`,获取值并添加到缓存 --> 返回缓存值 ⑶
我们将在 geecache.go
中实现主体结构 Group,那么 GeeCache 的代码结构的雏形已经形成了。
geecache/
|--lru/
|--lru.go // lru 缓存淘汰策略
|--byteview.go // 缓存值的抽象与封装
|--cache.go // 并发控制
|--geecache.go // 负责与外部交互,控制缓存存储和获取的主流程
3.1 回调 Getter
我们思考一下,如果缓存不存在,应从数据源(文件,数据库等)获取数据并添加到缓存中。GeeCache 是否应该支持多种数据源的配置呢?不应该,一是数据源的种类太多,没办法一一实现;二是扩展性不好。如何从源头获取数据,应该是用户决定的事情,我们就把这件事交给用户好了。因此,我们设计了一个回调函数(callback),在缓存不存在时,调用这个函数,得到源数据。
// A Getter loads data for a key. type Getter interface { Get(key string) ([]byte, error) } // A GetterFunc implements Getter with a function. type GetterFunc func(key string) ([]byte, error) // Get implements Getter interface function func (f GetterFunc) Get(key string) ([]byte, error) { return f(key) }
- 定义接口 Getter 和 回调函数
Get(key string)([]byte, error)
,参数是 key,返回值是 []byte。 - 定义函数类型 GetterFunc,并实现 Getter 接口的
Get
方法。 - 函数类型实现某一个接口,称之为接口型函数,方便使用者在调用时既能够传入函数作为参数,也能够传入实现了该接口的结构体作为参数。
3.2 Group 的定义
接下来是最核心数据结构 Group 的定义:
// A Group is a cache namespace and associated data loaded spread over type Group struct { name string getter Getter mainCache cache } var ( mu sync.RWMutex groups = make(map[string]*Group) ) // NewGroup create a new instance of Group func NewGroup(name string, cacheBytes int64, getter Getter) *Group { if getter == nil { panic("nil Getter") } mu.Lock() defer mu.Unlock() g := &Group{ name: name, getter: getter, mainCache: cache{cacheBytes: cacheBytes}, } groups[name] = g return g } // GetGroup returns the named group previously created with NewGroup, or // nil if there's no such group. func GetGroup(name string) *Group { mu.RLock() g := groups[name] mu.RUnlock() return g }
- 一个 Group 可以认为是一个缓存的命名空间,每个 Group 拥有一个唯一的名称
name
。比如可以创建三个 Group,缓存学生的成绩命名为 scores,缓存学生信息的命名为 info,缓存学生课程的命名为 courses。 - 第二个属性是
getter Getter
,即缓存未命中时获取源数据的回调(callback)。 - 第三个属性是
mainCache cache
,即一开始实现的并发缓存。 - 构建函数
NewGroup
用来实例化 Group,并且将 group 存储在全局变量groups
中。 GetGroup
用来特定名称的 Group,这里使用了只读锁RLock()
,因为不涉及任何冲突变量的写操作。
3.3 Group 的 Get 方法
接下来是 GeeCache 最为核心的方法 Get
:
// Get value for a key from cache func (g *Group) Get(key string) (ByteView, error) { if key == "" { return ByteView{}, fmt.Errorf("key is required") } if v, ok := g.mainCache.get(key); ok { log.Println("[GeeCache] hit") return v, nil } return g.load(key) } func (g *Group) load(key string) (value ByteView, err error) { return g.getLocally(key) } func (g *Group) getLocally(key string) (ByteView, error) { bytes, err := g.getter.Get(key) if err != nil { return ByteView{}, err } value := ByteView{b: cloneBytes(bytes)} g.populateCache(key, value) return value, nil } func (g *Group) populateCache(key string, value ByteView) { g.mainCache.add(key, value) }
- Get 方法实现了上述所说的流程 ⑴ 和 ⑶。
- 流程 ⑴ :从 mainCache 中查找缓存,如果存在则返回缓存值。
- 流程 ⑶ :缓存不存在,则调用 load 方法,load 调用 getLocally(分布式场景下会调用 getFromPeer 从其他节点获取),getLocally 调用用户回调函数
g.getter.Get()
获取源数据,并且将源数据添加到缓存 mainCache 中(通过 populateCache 方法)
4. GeeCache HTTP 服务端
分布式缓存需要实现节点间通信,建立基于 HTTP 的通信机制是比较常见和简单的做法。如果一个节点启动了 HTTP 服务,那么这个节点就可以被其他节点访问。今天我们就为单机节点搭建 HTTP Server。
不与其他部分耦合,我们将这部分代码放在新的 http.go
文件中,当前的代码结构如下:
geecache/
|--lru/
|--lru.go // lru 缓存淘汰策略
|--byteview.go // 缓存值的抽象与封装
|--cache.go // 并发控制
|--geecache.go // 负责与外部交互,控制缓存存储和获取的主流程
|--http.go // 提供被其他节点访问的能力(基于http)
首先我们创建一个结构体 HTTPPool
,作为承载节点间 HTTP 通信的核心数据结构(包括服务端和客户端,今天只实现服务端)。
package geecache import ( "fmt" "log" "net/http" "strings" ) const defaultBasePath = "/_geecache/" // HTTPPool implements PeerPicker for a pool of HTTP peers. type HTTPPool struct { // this peer's base URL, e.g. "https://example.net:8000" self string basePath string } // NewHTTPPool initializes an HTTP pool of peers. func NewHTTPPool(self string) *HTTPPool { return &HTTPPool{ self: self, basePath: defaultBasePath, } }
HTTPPool
只有 2 个参数,一个是 self,用来记录自己的地址,包括主机名/IP 和端口。- 另一个是 basePath,作为节点间通讯地址的前缀,默认是
/_geecache/
,那么 http://example.com/_geecache/ 开头的请求,就用于节点间的访问。因为一个主机上还可能承载其他的服务,加一段 Path 是一个好习惯。比如,大部分网站的 API 接口,一般以/api
作为前缀。
接下来,实现最为核心的 ServeHTTP
方法。
// Log info with server name func (p *HTTPPool) Log(format string, v ...interface{}) { log.Printf("[Server %s] %s", p.self, fmt.Sprintf(format, v...)) } // ServeHTTP handle all http requests func (p *HTTPPool) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, p.basePath) { panic("HTTPPool serving unexpected path: " + r.URL.Path) } p.Log("%s %s", r.Method, r.URL.Path) // /<basepath>/<groupname>/<key> required parts := strings.SplitN(r.URL.Path[len(p.basePath):], "/", 2) if len(parts) != 2 { http.Error(w, "bad request", http.StatusBadRequest) return } groupName := parts[0] key := parts[1] group := GetGroup(groupName) if group == nil { http.Error(w, "no such group: "+groupName, http.StatusNotFound) return } view, err := group.Get(key) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/octet-stream") w.Write(view.ByteSlice()) }
- ServeHTTP 的实现逻辑是比较简单的,首先判断访问路径的前缀是否是
basePath
,不是返回错误。 - 我们约定访问路径格式为
/<basepath>/<groupname>/<key>
,通过 groupname 得到 group 实例,再使用group.Get(key)
获取缓存数据。 - 最终使用
w.Write()
将缓存值作为 httpResponse 的 body 返回。
到这里,HTTP 服务端已经完整地实现了。接下来,我们可以在单机上启动 HTTP 服务,使用 curl 进行测试。
5. 一致性哈希
5.1 为什么使用一致性哈希
我该访问谁?对于分布式缓存来说,当一个节点接收到请求,如果该节点并没有存储缓存值,那么它面临的难题是,从谁那获取数据?自己,还是节点1, 2, 3, 4… 。假设包括自己在内一共有 10 个节点,当一个节点接收到请求时,随机选择一个节点,由该节点从数据源获取数据。
假设第一次随机选取了节点 1 ,节点 1 从数据源获取到数据的同时缓存该数据;那第二次,只有 1/10 的可能性再次选择节点 1, 有 9/10 的概率选择了其他节点,如果选择了其他节点,就意味着需要再一次从数据源获取数据,一般来说,这个操作是很耗时的。这样做,一是缓存效率低,二是各个节点上存储着相同的数据,浪费了大量的存储空间。
那有什么办法,对于给定的 key,每一次都选择同一个节点呢?使用 hash 算法也能够做到这一点。那把 key 的每一个字符的 ASCII 码加起来,再除以 10 取余数可以吗?当然可以,这可以认为是自定义的 hash 算法。
从上面的图可以看到,任意一个节点任意时刻请求查找键 Tom
对应的值,都会分配给节点 2,有效地解决了上述的问题。
5.2 节点数量变化了怎么办?
简单求取 Hash 值解决了缓存性能的问题,但是没有考虑节点数量变化的场景。假设,移除了其中一台节点,只剩下 9 个,那么之前 hash(key) % 10
变成了 hash(key) % 9
,也就意味着几乎缓存值对应的节点都发生了改变。即几乎所有的缓存值都失效了。节点在接收到对应的请求时,均需要重新去数据源获取数据,容易引起 缓存雪崩
。
缓存雪崩:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。常因为缓存服务器宕机,或缓存设置了相同的过期时间引起。
那如何解决这个问题呢?一致性哈希算法可以。
5.3 算法原理
步骤
一致性哈希算法将 key 映射到 2^32 的空间中,将这个数字首尾相连,形成一个环。
- 计算节点/机器(通常使用节点的名称、编号和 IP 地址)的哈希值,放置在环上。
- 计算 key 的哈希值,放置在环上,顺时针寻找到的第一个节点,就是应选取的节点/机器。
环上有 peer2,peer4,peer6 三个节点,key11
,key2
,key27
均映射到 peer2,key23
映射到 peer4。此时,如果新增节点/机器 peer8,假设它新增位置如图所示,那么只有 key27
从 peer2 调整到 peer8,其余的映射均没有发生改变。
也就是说,一致性哈希算法,在新增/删除节点时,只需要重新定位该节点附近的一小部分数据,而不需要重新定位所有的节点,这就解决了上述的问题。
数据倾斜问题
如果服务器的节点过少,容易引起 key 的倾斜。例如上面例子中的 peer2,peer4,peer6 分布在环的上半部分,下半部分是空的。那么映射到环下半部分的 key 都会被分配给 peer2,key 过度向 peer2 倾斜,缓存节点间负载不均。
为了解决这个问题,引入了虚拟节点的概念,一个真实节点对应多个虚拟节点。
假设 1 个真实节点对应 3 个虚拟节点,那么 peer1 对应的虚拟节点是 peer1-1、 peer1-2、 peer1-3(通常以添加编号的方式实现),其余节点也以相同的方式操作。
- 第一步,计算虚拟节点的 Hash 值,放置在环上。
- 第二步,计算 key 的 Hash 值,在环上顺时针寻找到应选取的虚拟节点,例如是 peer2-1,那么就对应真实节点 peer2。
虚拟节点扩充了节点的数量,解决了节点较少的情况下数据容易倾斜的问题。而且代价非常小,只需要增加一个字典(map)维护真实节点与虚拟节点的映射关系即可。
5.3 Go语言实现
我们在 geecache 目录下新建 package consistenthash
,用来实现一致性哈希算法。
package consistenthash import ( "hash/crc32" "sort" "strconv" ) // Hash maps bytes to uint32 type Hash func(data []byte) uint32 // Map constains all hashed keys type Map struct { hash Hash replicas int keys []int // Sorted hashMap map[int]string } // New creates a Map instance func New(replicas int, fn Hash) *Map { m := &Map{ replicas: replicas, hash: fn, hashMap: make(map[int]string), } if m.hash == nil { m.hash = crc32.ChecksumIEEE } return m }
- 定义了函数类型
Hash
,采取依赖注入的方式,允许用于替换成自定义的 Hash 函数,也方便测试时替换,默认为crc32.ChecksumIEEE
算法。 Map
是一致性哈希算法的主数据结构,包含 4 个成员变量:Hash 函数hash
;虚拟节点倍数replicas
;哈希环keys
;虚拟节点与真实节点的映射表hashMap
,键是虚拟节点的哈希值,值是真实节点的名称。- 构造函数
New()
允许自定义虚拟节点倍数和 Hash 函数。
接下来,实现添加真实节点/机器的 Add()
方法。
// Add adds some keys to the hash. func (m *Map) Add(keys ...string) { for _, key := range keys { 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 gets the closest item in the hash to the provided key. func (m *Map) Get(key string) string { if len(m.keys) == 0 { return "" } hash := int(m.hash([]byte(key))) // Binary search for appropriate replica. 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
映射得到真实的节点。
至此,整个一致性哈希算法就实现完成了。
6. 分布式节点
geecache 的流程:
是
接收 key --> 检查是否被缓存 -----> 返回缓存值 ⑴
| 否 是
|-----> 是否应当从远程节点获取 -----> 与远程节点交互 --> 返回缓存值 ⑵
| 否
|-----> 调用`回调函数`,获取值并添加到缓存 --> 返回缓存值 ⑶
我们进一步细化流程 ⑵:
使用一致性哈希选择节点 是 是
|-----> 是否是远程节点 -----> HTTP 客户端访问远程节点 --> 成功?-----> 服务端返回返回值
| 否 ↓ 否
|----------------------------> 回退到本地节点处理。
6.1 抽象 PeerPicker
package geecache // PeerPicker is the interface that must be implemented to locate // the peer that owns a specific key. type PeerPicker interface { PickPeer(key string) (peer PeerGetter, ok bool) } // PeerGetter is the interface that must be implemented by a peer. type PeerGetter interface { Get(group string, key string) ([]byte, error) }
- 在这里,抽象出 2 个接口,PeerPicker 的
PickPeer()
方法用于根据传入的 key 选择相应节点 PeerGetter。 - 接口 PeerGetter 的
Get()
方法用于从对应 group 查找缓存值。PeerGetter 就对应于上述流程中的 HTTP 客户端。
6.2 节点选择与 HTTP 客户端
首先创建具体的 HTTP 客户端类 httpGetter
,实现 PeerGetter 接口。
type httpGetter struct { baseURL string } func (h *httpGetter) Get(group string, key string) ([]byte, error) { u := fmt.Sprintf( "%v%v/%v", h.baseURL, url.QueryEscape(group), url.QueryEscape(key), ) res, err := http.Get(u) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned: %v", res.Status) } bytes, err := ioutil.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("reading response body: %v", err) } return bytes, nil } var _ PeerGetter = (*httpGetter)(nil)
- baseURL 表示将要访问的远程节点的地址,例如
http://example.com/_geecache/
。 - 使用
http.Get()
方式获取返回值,并转换为[]bytes
类型。
第二步,为 HTTPPool 添加节点选择的功能。
const ( defaultBasePath = "/_geecache/" defaultReplicas = 50 ) // HTTPPool implements PeerPicker for a pool of HTTP peers. type HTTPPool struct { // this peer's base URL, e.g. "https://example.net:8000" self string basePath string mu sync.Mutex // guards peers and httpGetters peers *consistenthash.Map httpGetters map[string]*httpGetter // keyed by e.g. "http://10.0.0.2:8008" }
- 新增成员变量
peers
,类型是一致性哈希算法的Map
,用来根据具体的 key 选择节点。 - 新增成员变量
httpGetters
,映射远程节点与对应的 httpGetter。每一个远程节点对应一个 httpGetter,因为 httpGetter 与远程节点的地址baseURL
有关。
第三步,实现 PeerPicker 接口。
// Set updates the pool's list of peers. func (p *HTTPPool) Set(peers ...string) { p.mu.Lock() defer p.mu.Unlock() p.peers = consistenthash.New(defaultReplicas, nil) p.peers.Add(peers...) p.httpGetters = make(map[string]*httpGetter, len(peers)) for _, peer := range peers { p.httpGetters[peer] = &httpGetter{baseURL: peer + p.basePath} } } // PickPeer picks a peer according to key func (p *HTTPPool) PickPeer(key string) (PeerGetter, bool) { p.mu.Lock() defer p.mu.Unlock() if peer := p.peers.Get(key); peer != "" && peer != p.self { p.Log("Pick peer %s", peer) return p.httpGetters[peer], true } return nil, false } var _ PeerPicker = (*HTTPPool)(nil)
Set()
方法实例化了一致性哈希算法,并且添加了传入的节点。- 并为每一个节点创建了一个 HTTP 客户端
httpGetter
。 PickerPeer()
包装了一致性哈希算法的Get()
方法,根据具体的 key,选择节点,返回节点对应的 HTTP 客户端。
至此,HTTPPool 既具备了提供 HTTP 服务的能力,也具备了根据具体的 key,创建 HTTP 客户端从远程节点获取缓存值的能力。
6.3 实现主流程
最后,我们需要将上述新增的功能集成在主流程(geecache.go)中。
// A Group is a cache namespace and associated data loaded spread over type Group struct { name string getter Getter mainCache cache peers PeerPicker } // RegisterPeers registers a PeerPicker for choosing remote peer func (g *Group) RegisterPeers(peers PeerPicker) { if g.peers != nil { panic("RegisterPeerPicker called more than once") } g.peers = peers } func (g *Group) load(key string) (value ByteView, err error) { if g.peers != nil { if peer, ok := g.peers.PickPeer(key); ok { if value, err = g.getFromPeer(peer, key); err == nil { return value, nil } log.Println("[GeeCache] Failed to get from peer", err) } } return g.getLocally(key) } func (g *Group) getFromPeer(peer PeerGetter, key string) (ByteView, error) { bytes, err := peer.Get(g.name, key) if err != nil { return ByteView{}, err } return ByteView{b: bytes}, nil }
- 新增
RegisterPeers()
方法,将 实现了 PeerPicker 接口的 HTTPPool 注入到 Group 中。 - 新增
getFromPeer()
方法,使用实现了 PeerGetter 接口的 httpGetter 从访问远程节点,获取缓存值。 - 修改 load 方法,使用
PickPeer()
方法选择节点,若非本机节点,则调用getFromPeer()
从远程获取。若是本机节点或失败,则回退到getLocally()
。
7. 防止缓存击穿
缓存雪崩:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。
缓存击穿:一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到 DB ,造成瞬时DB请求量大、压力骤增。
缓存穿透:查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求 DB,如果瞬间流量过大,穿透到 DB,导致宕机。
假如我们并发了 N 个请求 ?key=Tom
,8003 节点向 8001 同时发起了 N 次请求。假设对数据库的访问没有做任何限制的,很可能向数据库也发起 N 次请求,容易导致缓存击穿和穿透。即使对数据库做了防护,HTTP 请求是非常耗费资源的操作,针对相同的 key,8003 节点向 8001 发起三次请求也是没有必要的。那这种情况下,我们如何做到只向远端节点发起一次请求呢?geecache 实现了一个名为 singleflight 的 package 来解决这个问题。
首先创建 call
和 Group
类型。
package singleflight import "sync" type call struct { wg sync.WaitGroup val interface{} err error } type Group struct { mu sync.Mutex // protects m m map[string]*call }
call
代表正在进行中,或已经结束的请求。使用sync.WaitGroup
锁避免重入。Group
是 singleflight 的主数据结构,管理不同 key 的请求(call)。
实现 Do
方法
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { g.mu.Lock() if g.m == nil { g.m = make(map[string]*call) } if c, ok := g.m[key]; ok { g.mu.Unlock() c.wg.Wait() return c.val, c.err } c := new(call) c.wg.Add(1) g.m[key] = c g.mu.Unlock() c.val, c.err = fn() c.wg.Done() g.mu.Lock() delete(g.m, key) g.mu.Unlock() return c.val, c.err }
- Do 方法,接收 2 个参数,第一个参数是
key
,第二个参数是一个函数fn
。Do 的作用就是,针对相同的 key,无论 Do 被调用多少次,函数fn
都只会被调用一次,等待 fn 调用结束了,返回返回值或错误。
g.mu
是保护 Group 的成员变量 m
不被并发读写而加上的锁。为了便于理解 Do
函数,我们将 g.mu
暂时去掉。并且把 g.m
延迟初始化的部分去掉,延迟初始化的目的很简单,提高内存使用效率。
剩下的逻辑就很清晰了:
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { if c, ok := g.m[key]; ok { c.wg.Wait() // 如果请求正在进行中,则等待 return c.val, c.err // 请求结束,返回结果 } c := new(call) c.wg.Add(1) // 发起请求前加锁 g.m[key] = c // 添加到 g.m,表明 key 已经有对应的请求在处理 c.val, c.err = fn() // 调用 fn,发起请求 c.wg.Done() // 请求结束 delete(g.m, key) // 更新 g.m return c.val, c.err // 返回结果 }
并发协程之间不需要消息传递,非常适合 sync.WaitGroup
。
- wg.Add(1) 锁加1。
- wg.Wait() 阻塞,直到锁被释放。
- wg.Done() 锁减1。
type Group struct { name string getter Getter mainCache cache peers PeerPicker // use singleflight.Group to make sure that // each key is only fetched once loader *singleflight.Group } func NewGroup(name string, cacheBytes int64, getter Getter) *Group { // ... g := &Group{ // ... loader: &singleflight.Group{}, } return g } func (g *Group) load(key string) (value ByteView, err error) { // each key is only fetched once (either locally or remotely) // regardless of the number of concurrent callers. viewi, err := g.loader.Do(key, func() (interface{}, error) { if g.peers != nil { if peer, ok := g.peers.PickPeer(key); ok { if value, err = g.getFromPeer(peer, key); err == nil { return value, nil } log.Println("[GeeCache] Failed to get from peer", err) } } return g.getLocally(key) }) if err == nil { return viewi.(ByteView), nil } return }
- 修改
geecache.go
中的Group
,添加成员变量 loader,并更新构建函数NewGroup
。 - 修改
load
函数,将原来的 load 的逻辑,使用g.loader.Do
包裹起来即可,这样确保了并发场景下针对相同的 key,load
过程只会调用一次。
8. 使用 Protobuf 通信
protobuf 广泛地应用于远程过程调用(RPC) 的二进制传输,使用 protobuf 的目的非常简单,为了获得更高的性能。传输前使用 protobuf 编码,接收方再进行解码,可以显著地降低二进制传输的大小。另外一方面,protobuf 可非常适合传输结构化数据,便于通信字段的扩展。
9. 缓存过期策略
资料:
2. 《Go语言高级编程》
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 25岁的心里话
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现