缓存系统是我们提高程序性能最常用的手段之一,它把经常要读取的程序放在内存中,避免对后台数据库等进行频繁的访问。常用的缓存系统有 Memcached、Redis等,或者自定义缓存系统。
缓存系统虽然好,但也面临着三大问题。
- 缓存雪崩:某一时刻大规模的缓存同时失效,或者缓存系统重启,导致大量的请求无法从缓存中读取到数据,请求就会直接访问数据库,导致后台数据库等无法承受巨大的压力,可能瞬间就会崩溃。这种情况就称作缓存雪崩。
解决缓存雪崩的方法是将 key 的失效时间加一个随机值,避免大量的 key 同时失效。通过限 流,避免大量的请求同时访问数据库。新缓存节点上线前先进行预热,也可以避免刚上线就 发生雪崩。
- 缓存击穿:如果有大量的请求同时访问某个 key, 一旦这个 key 失效(过期),就会导致这些请求同时访问数据库。这种情况就称作缓存击穿。它和缓存雪崩不同,雪崩是访问大量的 key 导致的,而击穿是访问同一个 key 导致的。解决方法就是使用 SingleFlight 同步原语。
- 缓存穿透:如果请求要访问的 key 不存在,那么它就访问不到缓存系统,它就会去访问数据库。假如有大量这样的请求,这些请求像 “穿透” 了缓存一样直接访问数据库,这种情况就称作缓存穿透。解决方法是在缓存系统中给不存在的 key 设置一个空值或特殊值,或者使用布隆过滤器等快速检查 key 是否存在。
SingleFlight 是 Go 团队提供的一个扩展同步原语。它的作用是在处理多个 goroutine 同时调用同一个函数时,只让一个 goroutine 调用这个函数,当这个 goroutine 返回结果时,再把结果返回给这几个同时调用的 goroutine, 这样就可以减少并发调用的数量。
Go 标准库中的 也可以保证并发的 goroutine 只会执行一次函数 f ,那么 SingleFlight 和 有什么区别呢?
其实, 不仅在并发访问时保证只有一个 goroutine 执行函数 f,而且会保证永远只执行一次这个函数;而 SingFlight 是每次调用时都重新执行函数 f,并且有多个请求同时调用时只有一个请求执行这个函数。它们面对的场景是不同的, 主要被应用在单次初始化的场景中,而 SingleFlight 主要被应用在合并并发请求的场景中,尤其是缓存场景。
当你学会了使用 SingleFlight,在面对秒杀等大并发请求的场景,而且这些请求都是读请求时,就可以把这些请求合并为一个请求,这样就可以将后端服务的压力从 n 降到 1。尤其在面对后端是数据库这样的服务时,采用 SingleFlight 可以极大地提高性能。
1. SingleFlight 的实现
SingleFlight 使用 Mutex 和 Map 来实现,其中 Mutex 提供并发时的 读/写 保护,Map 用来保存正在处理 ( in flight) 的对同一个 key 的请求。
SingleFlight 的数据结构是 Group,它提供了三个方法:
-
type Group
-
func (g *Group) Do(key string,fn func()(interface{},error))(v interface{},err error,shared bool)
-
func(g *Group) DoChan(key string,fn func()(interface{},error)) <-chan Result
-
func(g *Group) Forget(key string)
-
type Result
- Do:这个方法执行一个函数,并返回函数执行的结果。你需要提供一个 key,对于同一个 key,在同一时刻只有一个请求在执行,其他并发的请求会等待。第一个执行的请求返回的结果,就是 Do 方法的返回结果。fn 是一个无参数的函数,它返回一个结果或者 error。Do方法会返回函数 fn 执行的结果或者 error, shared 会指示 v 是否将结果返回给多个请求。
- DoChan: 类似于 Do 方法,只不过它返回一个 chan,当函数 fn 执行完成返回了结果后,就能从这个 chan 中接收这个结果了。
- Forget: 告诉 Group 忘记这个 key。这样一来,之后使用这个 key 调用 Do 方法时,会再次执行函数 fn,而不是等待前一个函数 fn 的执行结果。
下面我们来看具体的实现方法。SingleFlight 先定义了一个辅助对象 call,这个 call 就代表正在执行函数 fn 的请求或者已经执行完的请求。
-
// 定义call,代表一个正在执行的请求,或者已经执行完的请求
-
type call struct {
-
wg
-
// val这个字段代表处理完的值,在 WaitGroup 完成之前只会写一次。
-
// 在 WaitGroup 完成之后读取这个值
-
val interface {}
-
err error
-
-
// forgotten 指示当 call 在处理时是否要忘记这个 key
-
forgotten bool
-
dups int
-
chans []chan<- Result
-
}
-
-
// Group 代表一个 SingleFlight 对象
-
type Group struct {
-
mu // 保护 m
-
m map[string]*call //惰性初始化
-
}
-
我们来看 Do 方法的处理,DoChan 方法的处理与之类似:
-
func (g *Group) Do(key string,fn func()(interface{},error))(v interface{},err error,shared bool){
-
()
-
if == nil {
-
= make(map[string]*call)
-
}
-
//检查此 key 是否有执行中的任务
-
if c,ok := [key];ok{
-
++ //重复任务数加1
-
()
-
()//等待正在执行的函数 fn 完成任务
-
-
if e,ok := .(*panicError);ok {
-
panic(e)
-
} else if == errGoexit {
-
()
-
}
-
return ,,true
-
}
-
c := new(call) //没有执行中的任务,它就是第一个
-
(1)
-
[key] = c
-
()
-
-
(c,key,fn) //调用方法,执行任务
-
return ,, >0
-
}
doCall 方法会调用函数 fn,它的实现原本没有这么复杂,但是为了处理调用时可能发生的 panic 或者用户的 调用,它使用了两个 defer 来区分这两种情况。
-
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
-
normalReturn := false
-
recovered := false
-
-
//使用两个defer,从 事件中识别出 panic 事件
-
defer func() {
-
//在给定的函数 fn 中调用了
-
if !normalReturn && !recovered {
-
= errGoexit
-
}
-
-
()
-
defer ()
-
()
-
if [key] == c { // 执行完毕,删除此key
-
delete(, key)
-
}
-
-
if e, ok := .(*panicError); ok {
-
if len() > 0 {
-
go panic(e)
-
select {}
-
} else {
-
panic(e)
-
}
-
} else if == errGoexit {
-
} else {
-
//正常返回,告诉那些 waiter 调用结果来了
-
for _, ch := range {
-
ch <- Result{, , > 0}
-
}
-
}
-
}()
-
-
func() {
-
defer func() {
-
if !normalReturn {
-
if r := recover(); r != nil {
-
= newPanicError(r)
-
}
-
}
-
}()
-
, = fn()
-
normalReturn = true
-
}()
-
-
if !normalReturn {
-
recovered = true
-
}
-
}
在 Go 标准库的代码中就有一个SingleFlight 的实现,而扩展库中的 SingleFlight 是在标准库的代码的基础上修改得来的,逻辑几乎一模一样。但是,扩展库中的 doCall 做了异常处理,而标准库内部使用的 SingleFlight 还依然保留着其纯朴的样子。
-
func (g *Group) doCall(c *call,key string,fn func()(any,error)){
-
, = fn()
-
-
()
-
()
-
if [key] == c {
-
delete(,key)
-
}
-
for _,ch := range {
-
ch <- Result{,, >0}
-
}
-
()
-
}
Forget 方法很简单,它只是把 key 从正在处理的 map 中删除,后续使用相同的 key 的调用者调用 Go 方法时,又会再次执行函数 fn。
-
func (g *Group) Forget(key string){
-
()
-
delete(,key)
-
()
-
}
2. SingleFlight 的使用场景
在 Go 标准库的代码中有两处用到了 SingleFlight。第一处是在 net/ 中,如果有多个请求同时查询同一个 host, lookupGroup 就会把这些请求合并到一起,只需要一个请求就可以了。
需要注意的是,这里涉及缓存的问题。执行上面的代码,会把结果放在缓存中,这也是常用的一种解决缓存击穿问题的方法。
SingleFlight 更广泛的应用就是在缓存系统中。事实上,在 Go 生态圈知名的缓存框架 groupcache 中,就使用了较早的 Go 标准库中的 SingleFlight 实现。接下来,我们来看 groupcache 是如何使用 SingleFlight 解决缓存击穿问题的。
groupcache 中的 SingleFlight 只有一个方法:
func (g *Group) Do(key string,fn func() (interface{},error)) (interface{},error)
SingleFlight 的作用是,在加载一个缓存项时,合并对同一个 key 的加载并发请求。
-
type Group struct {
-
.................
-
// loadGroup 保证不管当前并发量有多大,每个 key 值都只被获取一次
-
loadGroup flightGroup
-
.................
-
}
-
-
func (g *Group) load(ctx ,key string,dest Sink)(value ByteView,destPopulated bool,err error){
-
viewi,err := (key,func()(interface{},error){
-
return value,nil
-
})
-
if err == nil {
-
value = viewi.(ByteView)
-
}
-
return
-
}
其他知名项目如 CockroachDB、CoreDNS (DNS服务器)等都有对 SingleFlight 的应用。
总体来说,使用 SingleFlight 时,可以通过合并请求的方式降低对下游服务的并发压力,从而提高系统的性能。