深入理解 Go 语言缓解压力利器 SingleFlight

时间:2024-10-22 11:24:53

        缓存系统是我们提高程序性能最常用的手段之一,它把经常要读取的程序放在内存中,避免对后台数据库等进行频繁的访问。常用的缓存系统有 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,它提供了三个方法:

  1. type Group
  2. func (g *Group) Do(key string,fn func()(interface{},error))(v interface{},err error,shared bool)
  3. func(g *Group) DoChan(key string,fn func()(interface{},error)) <-chan Result
  4. func(g *Group) Forget(key string)
  5. 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 的请求或者已经执行完的请求。

  1. // 定义call,代表一个正在执行的请求,或者已经执行完的请求
  2. type call struct {
  3. wg
  4. // val这个字段代表处理完的值,在 WaitGroup 完成之前只会写一次。
  5. // 在 WaitGroup 完成之后读取这个值
  6. val interface {}
  7. err error
  8. // forgotten 指示当 call 在处理时是否要忘记这个 key
  9. forgotten bool
  10. dups int
  11. chans []chan<- Result
  12. }
  13. // Group 代表一个 SingleFlight 对象
  14. type Group struct {
  15. mu // 保护 m
  16. m map[string]*call //惰性初始化
  17. }

        我们来看 Do 方法的处理,DoChan 方法的处理与之类似:

  1. func (g *Group) Do(key string,fn func()(interface{},error))(v interface{},err error,shared bool){
  2. ()
  3. if == nil {
  4. = make(map[string]*call)
  5. }
  6. //检查此 key 是否有执行中的任务
  7. if c,ok := [key];ok{
  8. ++ //重复任务数加1
  9. ()
  10. ()//等待正在执行的函数 fn 完成任务
  11. if e,ok := .(*panicError);ok {
  12. panic(e)
  13. } else if == errGoexit {
  14. ()
  15. }
  16. return ,,true
  17. }
  18. c := new(call) //没有执行中的任务,它就是第一个
  19. (1)
  20. [key] = c
  21. ()
  22. (c,key,fn) //调用方法,执行任务
  23. return ,, >0
  24. }

        doCall 方法会调用函数 fn,它的实现原本没有这么复杂,但是为了处理调用时可能发生的 panic 或者用户的 调用,它使用了两个 defer 来区分这两种情况。

  1. func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
  2. normalReturn := false
  3. recovered := false
  4. //使用两个defer,从 事件中识别出 panic 事件
  5. defer func() {
  6. //在给定的函数 fn 中调用了
  7. if !normalReturn && !recovered {
  8. = errGoexit
  9. }
  10. ()
  11. defer ()
  12. ()
  13. if [key] == c { // 执行完毕,删除此key
  14. delete(, key)
  15. }
  16. if e, ok := .(*panicError); ok {
  17. if len() > 0 {
  18. go panic(e)
  19. select {}
  20. } else {
  21. panic(e)
  22. }
  23. } else if == errGoexit {
  24. } else {
  25. //正常返回,告诉那些 waiter 调用结果来了
  26. for _, ch := range {
  27. ch <- Result{, , > 0}
  28. }
  29. }
  30. }()
  31. func() {
  32. defer func() {
  33. if !normalReturn {
  34. if r := recover(); r != nil {
  35. = newPanicError(r)
  36. }
  37. }
  38. }()
  39. , = fn()
  40. normalReturn = true
  41. }()
  42. if !normalReturn {
  43. recovered = true
  44. }
  45. }

        在 Go 标准库的代码中就有一个SingleFlight 的实现,而扩展库中的 SingleFlight 是在标准库的代码的基础上修改得来的,逻辑几乎一模一样。但是,扩展库中的 doCall 做了异常处理,而标准库内部使用的 SingleFlight 还依然保留着其纯朴的样子。

  1. func (g *Group) doCall(c *call,key string,fn func()(any,error)){
  2. , = fn()
  3. ()
  4. ()
  5. if [key] == c {
  6. delete(,key)
  7. }
  8. for _,ch := range {
  9. ch <- Result{,, >0}
  10. }
  11. ()
  12. }

        Forget 方法很简单,它只是把 key 从正在处理的 map 中删除,后续使用相同的 key 的调用者调用 Go 方法时,又会再次执行函数 fn。

  1. func (g *Group) Forget(key string){
  2. ()
  3. delete(,key)
  4. ()
  5. }

 

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 的加载并发请求。

  1. type Group struct {
  2. .................
  3. // loadGroup 保证不管当前并发量有多大,每个 key 值都只被获取一次
  4. loadGroup flightGroup
  5. .................
  6. }
  7. func (g *Group) load(ctx ,key string,dest Sink)(value ByteView,destPopulated bool,err error){
  8. viewi,err := (key,func()(interface{},error){
  9. return value,nil
  10. })
  11. if err == nil {
  12. value = viewi.(ByteView)
  13. }
  14. return
  15. }

        其他知名项目如 CockroachDB、CoreDNS (DNS服务器)等都有对 SingleFlight 的应用。

        总体来说,使用 SingleFlight 时,可以通过合并请求的方式降低对下游服务的并发压力,从而提高系统的性能。