背景

说到缓存都离不开 3 个问题,国内总结出了 3 个比较典型的问题 缓存穿透、缓存击穿、缓存雪崩。

缓存穿透

缓存和数据库中都没有的数据,而用户不断发起请求,如发起为 id 为“-1”的数据或 id 为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大

缓存击穿

缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力

缓存雪崩

缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至 Down 机。和缓存击穿不同的是, 缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库

SingleFlight

SingleFlight 用于防止缓存击穿非常有效,其来源于Golang 准官方库 SingleFlight

它主要提供了 3 个 API

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)

举个例子:

func (d *Dao) SFGetTopicByRoom(ctx context.Context, roomId int64) (res topicsModel.Topics, err error) {
  key := getTopicRoomCacheKey(roomId)
  // 定义调用下游的方法
  fn := func(c context.Context, rid int64) func() (interface{}, error) {
      return func() (resp interface{}, err error) {
          return d.GetTopicByRoom(c, rid)
      }
  }
  // 使用singleflight处理请求
  v, err, _ := d.singleFlight.Do(key, fn(ctx, roomId))
  if err != nil {
      return
  }

  res, _ = v.(*topicsModel.Topics)
  return
}

当该接口发生高并发时,其中只有 1 个请求可以通过 fn 方法去调用下游服务获取数据,其余的请求都将等待这一个请求返回,然后白嫖它的结果。 比如当前存在 1000 个并发,只有第一个请求会真正地去调用下游,后来的 999 个请求发现已经有人去下游拉数据了,那么它们就会阻塞等待,直到第一个请求成功返回,把结果直接分发给他们。这样虽然接口层面上发送了 1000 次并发请求,但实际上下游只被调用了 1 次,极大减少了下游的压力。

当该接口发生高并发时,其中只有 1 个请求可以通过 fn 方法去调用下游服务获取数据,其余的请求都将等待这一个请求返回,然后白嫖它的结果。 比如当前存在 1000 个并发,只有第一个请求会真正地去调用下游,后来的 999 个请求发现已经有人去下游拉数据了,那么它们就会阻塞等待,直到第一个请求成功返回,把结果直接分发给他们。这样虽然接口层面上发送了 1000 次并发请求,但实际上下游只被调用了 1 次,极大减少了下游的压力。

虽然很好用,但是还是有几个需要注意的问题:

  • 1、协程暴涨:每一个请求都将分配一个协程,如果并发上万就意味着同时产生上万个协程

  • 2、内存暴涨:原因差不多

  • 3、超时时可能会全军覆没:因为 singleflight 是通过阻塞读实现,第一个请求超时或者失败,意味着后面排队嗷嗷待哺的请求都将失败,产生大量错误

  • 4、后续请求耗时增加:调度等待

这些问题产生的原因可以归结为 singleflight 的两个特征:

阻塞读:缺少超时控制,难以快速失败 单并发:控制了并发量,但牺牲了成功率 一般可以通过一些技巧进行规避:

1、解决阻塞读问题,通过 channel 实现超时控制, singleflight 提供了 DoChan()可用于替换普通的 Do()

func doSomeThings(key string) {
	ch := g.DoChan(key, func() (interface{}, error) {
		ret, err := find(context.Background(), key)
		return ret, err
	})
	// 设定超时时间
	timeout := time.After(500 * time.Millisecond)

	var ret singleflight.Result
	select {
	case <-timeout: // 超时处理
		return errors.New("超时了!!!!")
	case ret = <-ch: // 请求成功处理
		fmt.Printf("请求成功!")
	}
}

2、解决单并发问题,原版的方法过于激进,假设我们有 1000 次并发,那其实我们可以允许其中的 100 次进入下游,以此保证在不对下游造成过大压力的同时 保证一定的请求饱和度,降低全军覆没的概率 具体可以通过 Forget()方法进行分批 singleflight

// 每 10 毫秒删除一次内存缓存
// 相当于按照 100 毫秒为分界,每个 100 毫秒时间片内的并发共享同一个下游请求
// 假如 1s 内有 1000 个并发,平均每 100ms 内有 100 次(这 1s 内的 1000 个并发被切成 10 组),那么相当于一共向下游分别真实请求了 10 次
// 这样的话 即使某一组出了问题,也不至于波及全部
func doSomeThings(key string) {
	v, _, shared := g.Do(key, func() (interface{}, error) {
		go func() {
			time.Sleep(100 * time.Millisecond)
			// 清理内存缓存
			g.Forget(key)
		}()
		ret, err := find(context.Background(), key)
		return ret, err
	})
}

测试结果