本文代码地址:https://gitee.com/lymgoforIT/gee-cache/tree/master/day6-single-flight
本文是7天用Go从零实现分布式缓存GeeCache
的第六篇。
- 缓存雪崩、缓存击穿与缓存穿透的概念简介。
- 使用
singleflight
防止缓存击穿,实现与测试。代码约70
行
1 缓存雪崩、缓存击穿与缓存穿透
GeeCache
第五天 提到了缓存雪崩和缓存击穿,在这里做下总结:
-
缓存雪崩:缓存在同一时刻全部失效,造成瞬时
DB
请求量大、压力骤增,引起雪崩。缓存雪崩通常因为缓存服务器宕机、缓存的key
设置了相同的过期时间等引起。 -
缓存击穿:一个存在的
key
,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到DB
,造成瞬时DB
请求量大、压力骤增。 -
缓存穿透:查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求
DB
,如果瞬间流量过大,穿透到DB
,导致宕机。
2 singleflight 的实现
还记得 GeeCache
第五天 最后的测试结果吗?
2024/07/29 21:17:45 [Server http://localhost:8003] Pick peer http://localhost:8001
2024/07/29 21:17:45 [Server http://localhost:8003] Pick peer http://localhost:8001
2024/07/29 21:17:45 [Server http://localhost:8003] Pick peer http://localhost:8001
我们并发了 N
个请求 ?key=Tom
,8003
节点向 8001
同时发起了 N
次请求。假设对数据库的访问没有做任何限制的,很可能向数据库也发起 N
次请求,容易导致缓存击穿和穿透。即使对数据库做了防护,HTTP
请求是非常耗费资源的操作,针对相同的 key
,8003
节点向 8001
发起三次请求也是没有必要的。那这种情况下,我们如何做到只向远端节点发起一次请求呢?
geecache
实现了一个名为 singleflight
的 package
来解决这个问题。
day6-single-flight/geecache/singleflight/singleflight.go
首先创建 call
和 Group
类型。
package singleflight
import "sync"
type call struct {
wg sync.WaitGroup // 避免锁重入
val interface{} // 存当次请求的返回值
err error // 存当次请求是否出现错误
}
type Group struct {
mu sync.Mutex // 保护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)
}
// 对于当前key已经有其他在进行中或者已结束的请求,那么当前请求就应该等待,而不是也发起真实请求
if c, ok := g.m[key]; ok {
g.mu.Unlock()
c.wg.Wait()
return c.val, c.err
}
// 对于当前key没有在进行中或者已结束的请求,那么新建一个,并锁+1
c := new(call)
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock() // 当前对m的操作已经结束,可以先解锁了
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
,如果需要消息传递,则用channel
。
wg.Add(1)
锁加1
。wg.Wait()
阻塞,直到锁被释放。wg.Done()
锁减1
。
3 singleflight 的使用
day6-single-flight/geecache/geecache.go
type Group struct {
name string
getter Getter
mainCache cache
peerPicker PeerPicker // 用于选取节点,并从选取的节点获取缓存值
// 使用singleflight保证同一个key同一时间只请求远程一次
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.peerPicker != nil {
if peerGetter, ok := g.peerPicker.PickPeer(key); ok {
value, err := g.getFromPeer(peerGetter, key)
if err != nil {
log.Println("[GeeCache] Failed to get from peerGetter")
// 从远程获取失败后,尝试从本地回源获取
return g.getLocally(key)
}
return value, nil
}
}
return g.getLocally(key)
})
if err != nil {
return ByteView{}, err
}
return viewi.(ByteView), nil
}
- 修改
geecache.go
中的Group
,添加成员变量loader
,并更新构建函数NewGroup
。 - 修改
load
函数,将原来的load
的逻辑,使用g.loader.Do
包裹起来即可,这样确保了并发场景下针对相同的key
,load
过程只会调用一次。
4 测试
执行 run.sh
就可以看到效果了。
$ ./run.sh
2024/07/30 22:36:00 [Server http://localhost:8003] Pick peer http://localhost:8001
2024/07/30 22:36:00 [Server http://localhost:8001] GET /_geecache/scores/Tom
2024/07/30 22:36:00 [SlowDB] search key Tom
630630630
可以看到,向 API
发起了三次并发请求,但8003
只向 8001
发起了一次请求,就搞定了。
如果并发度不够高,可能仍会看到向 8001
请求三次的场景。这种情况下三次请求是串行执行的,并没有触发 singleflight
的锁机制工作,可以加大并发数量再测试。即,将 run.sh
中的 curl
命令复制 N
次。
原文地址:https://geektutu.com/post/geecache-day6.html