简单区分下:
缓存雪崩:
当某一个时刻出现大规模的缓存key失效的情况,那么就会导致大量的请求直接打在数据库上面,如果在高并发的情况下,可能瞬间就会导致数据库宕机。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。
缓存击穿:
是指一个存在的key非常热点,在不停的扛着大并发。用户集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库。
其和缓存雪崩区别,雪崩是大量key失效,而击穿是在某个存在的key失效瞬间的。
缓存穿透:
查询一个不存在的key,因为不存在则不会写到缓存中,所以每次都会去请求 DB(DB中也可能不存在的),如果瞬间流量过大,穿透到 DB,导致宕机。
其和缓存击穿区别:缓存击穿查找的key是存在的,而穿透是该key是不存在的。
在上一章节中,我们并发了 N 个请求 ?key=Tom
,8003 节点向 8001 同时发起了 N 次请求。
假设在某个瞬间这个key失效了,假设对数据库的访问也没有做任何限制的,很可能向数据库也发起 N 次请求,容易导致缓存击穿。
即使对数据库做了防护,HTTP 请求是非常耗费资源的操作,针对相同的 key,8003 节点向 8001 发起三次请求也是没有必要的。那这种情况下,我们如何做到只向远端节点发起一次请求呢?
那容易想到一种做法,在多个请求并发访问同一个 key,过滤掉重复请求。
做法:
请求A,请求B,请求C都是访问key-Tom。假设请求A先到,那B,C就要阻塞等待,等到A完成后才进行下一步操作。每一个请求都是一个协程,而协程又需要等待其他协程完成 之后才有下一步操作,那想到可以使用sync.WaitGroup。
需要记录请求,那需要定义好请求对象,创建请求结构体call,其内部有val返回结果,err错误信息,还有sync.WaitGroup类型变量wg,控制其他同样的请求的线程是否需要等待。
type call struct {
wg sync.WaitGroup // 控制线程是否等待
val interface{} // 请求返回结果
err any // 返回的错误信息
}
有了请求对象后,那需要记录,那存储到哪呢?还有这些都是同样的key的请求,那怎样通过key快速找到该请求呢?可以想到是存储到map中嘛。
用map[string]*call
来存储, key 为查询缓存的 key, value 是本次请求的对象。
用了 map, 就需要考虑线程安全问题, 所以就必须考虑上锁。
type Group struct {
mutex sync.Mutex
callMap map[string]*call
}
Group
是 singleflight 的主数据结构,管理不同 key 的请求(call)。
有了这些结构后,那肯定是需要修改下访问缓存的方式的了。
其接收 2 个参数,第一个参数是 key
,第二个参数是一个函数 fn
。Do 的作用是,针对相同的 key,无论 Do 被调用多少次,函数 fn
都只会被调用一次,等待 fn 调用结束了,返回返回值或错误。
代码分析:
不是第一个访问该key的请求就进入第4代码,一直等待Wait()。
而第一个访问该key的请求就直接到第8行,进行加锁等等操作,执行回调函数fn,请求结束就Done()。
这时第4行的Wait()就不用等待了,那其他线程也就拿到了返回的结果。
这里的回调函数就是访问该key的操作函数,集成到geecache的Group中就清晰其如何使用了。
//为了简单易懂点,这里去掉了map的上锁那些操作。(实际实现需要对map加锁)
func (g *Group) Do(key string, fn func() (any, error)) (any, error) {
if c, ok := g.callMap[key]; ok {
c.wg.Wait() //如果有请求正在进行中,那就等待
return c.val, c.err //请求结束,返回结果
}
c := new(call)
c.wg.Add(1) //发起请求前加锁
g.callMap[key] = c //添加到map中,表明该key已有对应的请求在处理
c.val, c.err = fn() //调用fn,即是访问key的函数
c.wg.Done() //请求结束
delete(g.callMap, key) //删除该key,表示该key当前没有请求在处理
return c.val, c.err
}
下面是map加锁后的做法
g.
callMap延迟初始化,其目的很简单,提高内存使用效率
func (g *Group) Do(key string, fn func() (any, error)) (any, error) {
g.mutex.Lock()
if g.callMap == nil {
g.callMap = make(map[string]*call)
}
if c, ok := g.callMap[key]; ok {
g.mutex.Unlock()
c.wg.Wait() //如果有请求正在进行中,那就等待
return c.val, c.err //请求结束,返回结果
}
c := new(call)
c.wg.Add(1) //发起请求前加锁
g.callMap[key] = c //添加到map中,表明该key已有对应的请求在处理
g.mutex.Unlock()
c.val, c.err = fn() //调用fn,即是访问key的函数
c.wg.Done() //请求结束
g.mutex.Lock()
delete(g.callMap, key) //删除该key,表示该key当前没有请求在处理
g.mutex.Unlock()
return c.val, c.err
}
其实
Go语言中已经实现了singleflight功能的,在
internal/singleflight库中。原教程那时可能还没有这个库,但现在官方已有这个库了,我们还是再次实现该功能也不错的。
又是回到geecache.go文件中的load函数中,这里获取远程节点,访问远程节点。
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
}
}
}
return g.getLocally(key)
}
要想可以使用,那Group结构体中需要添加singleflight.Group,并更新构建函数 NewGroup。
type Group struct {
name string
mainCache cache
getter Getter
peers *HTTPPool //添加了节点集合
loader *singlefilght.Group // each key is only fetched once
}
func NewGroup(name string, cacheBytes int64, getter Getter) *Group {
// ...
g := &Group{
// ...
loader: &singleflight.Group{},
}
return g
}
回到重要的load方法
load
函数,将原来的 load 的逻辑,使用 g.loader.Do
包裹起来即可,这样确保了并发场景下针对相同的 key,load
过程只会调用一次。func (g *Group) load(key string) (value ByteView, err error) {
viewi, err := g.loader.Do(key, func() (any, 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
}
使用上一章节的代码即可。
完整代码:https://github.com/liwook/Go-projects/tree/main/go-cache/6-single-flight