今天我们来聊聊缓存和 singleflight。
缓存的问题
缓存是业务开发中我们经常使用的方法,根据回源策略的不同,可以针对各种场景解决问题。思路上看,缓存就是一份更可靠,低延时的数据冗余。
- 针对一个不稳定的源站,我们可以采取优先回源的策略,当访问源站失败时,使用缓存中的数据作为兜底;
- 优先读缓存,没有命中才回源,这样能够一方面减轻对于源站的压力,另一方面缩短数据访问的延时。
缓存中常涉及到三个概念:缓存击穿,缓存雪崩,缓存穿透。我们先来区分一下:
- 缓存穿透 指的是数据库本就没有这个数据,请求直奔数据库,缓存系统形同虚设,对数据库产生很大压力从而影响正常服务。
- 缓存击穿 (失效)指的是数据库有数据,缓存本应该也有数据,但是缓存过期了,这层流量防护屏障被击穿了,大量高并发请求直奔数据库。
- 缓存雪崩 指的是大量的热点数据无法在缓存中处理(大面积热点数据缓存失效、缓存服务宕机),流量全部打到数据库,导致数据库极大压力。
缓存击穿和缓存雪崩有点像,又有点不一样,缓存雪崩是指大面积的缓存失效,打崩了DB,而缓存击穿则是指一个热点key,在不停的扛着高并发,高并发集中对着这一个点进行访问,如果这个key在失效的瞬间,持续的并发到来就会穿破缓存,直接请求到数据库,就像一个完好无损的桶上凿开了一个洞,造成某一时刻数据库请求量过大,压力剧增。
针对【穿透】,我们经常考虑的方案是 缓存零值,也就是如果数据库没数据,我们也会缓存一个零值,这样当有请求来的时候,缓存这一层可以清晰的告知对方,不是我这儿没有,而是本来就是零值,你回源也没用,这里是对数据库的保护。
一堆 key 因为种种原因没有被缓存拦住,直接访问DB,这叫【雪崩】,对应的我们可能会考虑是不是打散过期时间,避免同时大量key都需要回源。而一个key失效,大量并发请求同时访问DB 拉这一个key的数据,描述的就是【击穿】。
singleflight 解决的其实就是【缓存击穿】的问题。
首先我们来思考一下,【击穿】本质的问题是什么?
假设此时我有 3 个并发的 goroutine,都需要 key1 的数据,而此时 redis 缓存中没有,我们只能回源。注意,它们三个是并发的,互相之间不存在依赖关系的。所以当然是各走各的路,直接查 DB 去了,最后各自拿到数据。
这里不太对的是什么?
是既然大家是同一个key,那理论上讲访问 DB 拿到的数据也应该是一样的(不考虑此时有并发的写操作)。你们并发去做查询,在 DB 看来就是有 3次请求。而注意,我们是单机!也就意味着,同一个进程的3个goroutine,通过内存,channel 等并发的方案,是可以通信的,并不是分布式的场景。
既然如此,有没有什么办法,我们能将这三个并发的请求合成 1 个。既然大家都需要同一份信息,不如派个代表去拿,这样 DB 压力也小,从其他 goroutine 的角度也少了一次调用。
这就是我们要解决的问题:有没有什么办法,通过协程间的通信,使得原来并发的多个回源请求,最终只需要执行一次查询即可满足诉求。
用大白话说:既然咱想要一个数据,那搞个机制,你去拿到数据,进程内你就给我了,这样我不去拿,我省事,数据库压力还小。
此时,singleflight 就要闪亮出场了。
singleflight 的解法
库如其名,singleflight 起到的作用就是,我用一次 flight,一次跟数据库的交互就够了。
singleflight 在 golang.org/x/sync/singleflight 项目下,对外提供了以下几个方法:
singleflight类的使用方法就新建一个singleflight.Group,使用其方法Do或者DoChan来包装方法,被包装的方法在对于同一个key,只会有一个协程执行,其他协程等待那个协程执行结束后,拿到同样的结果。
// Group represents a class of work and forms a namespace in
// which units of work can be executed with duplicate suppression.
type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}
singleflight.Group 就是上面的这个结构,如官方注释,你可以把它理解为一个【命名空间】,在这里,同一个 key 同时只能被执行一次。
我们来看一个示例,使用Do方法来模拟,解决缓存击穿的问题:
func main() {
var singleSetCache singleflight.Group
getAndSetCache := func (requestID int, cacheKey string) (string, error) {
//do的入参key,可以直接使用缓存的key,这样同一个缓存,只有一个协程会去读DB
value, _, _ := singleSetCache.Do(cacheKey, func() (ret interface{}, err error) {
return "VALUE", nil
})
return value.(string),nil
}
cacheKey := "cacheKey"
for i:=1;i<10;i++{//模拟多个协程同时请求
go func(requestID int) {
value, _:= getAndSetCache(requestID,cacheKey)
}(i)
}
}
如图,声明一个 singleflight.Group 之后,直接使用 Do 方法,把你的回源操作封装进去即可,非常简单。singleflight 库内部会保证只有一个 goroutine 去拿数据,其他 goroutine 阻塞等待,并自动在随后获取结果,无需额外操作。
下一篇文章我们将会一起来看看 singleflight 实现的原理和设计思想。感谢大家的阅读!
本文正在参加技术专题18期-聊聊Go语言框架