Golang singleflight(1) 缓存击穿

827 阅读5分钟

今天我们来聊聊缓存和 singleflight。

缓存的问题

缓存是业务开发中我们经常使用的方法,根据回源策略的不同,可以针对各种场景解决问题。思路上看,缓存就是一份更可靠,低延时的数据冗余。

  • 针对一个不稳定的源站,我们可以采取优先回源的策略,当访问源站失败时,使用缓存中的数据作为兜底;
  • 优先读缓存,没有命中才回源,这样能够一方面减轻对于源站的压力,另一方面缩短数据访问的延时。

缓存中常涉及到三个概念:缓存击穿,缓存雪崩,缓存穿透。我们先来区分一下:

  1. 缓存穿透 指的是数据库本就没有这个数据,请求直奔数据库,缓存系统形同虚设,对数据库产生很大压力从而影响正常服务。
  2. 缓存击穿 (失效)指的是数据库有数据,缓存本应该也有数据,但是缓存过期了,这层流量防护屏障被击穿了,大量高并发请求直奔数据库。
  3. 缓存雪崩 指的是大量的热点数据无法在缓存中处理(大面积热点数据缓存失效、缓存服务宕机),流量全部打到数据库,导致数据库极大压力。

缓存击穿和缓存雪崩有点像,又有点不一样,缓存雪崩是指大面积的缓存失效,打崩了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 项目下,对外提供了以下几个方法:

image.png

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 实现的原理和设计思想。感谢大家的阅读!

Golang singleflight (2)源码剖析

本文正在参加技术专题18期-聊聊Go语言框架