开源推荐 防止缓存击穿之进程内共享调用

kevwan · 2020年09月15日 · 最后由 pastor 回复于 2020年11月26日 · 2857 次阅读
本帖已被设为精华帖!

go-zero 微服务框架中提供了许多开箱即用的工具,好的工具不仅能提升服务的性能而且还能提升代码的鲁棒性避免出错,实现代码风格的统一方便他人阅读等等。

本文主要讲述进程内共享调用神器SharedCalls

使用场景

并发场景下,可能会有多个线程(协程)同时请求同一份资源,如果每个请求都要走一遍资源的请求过程,除了比较低效之外,还会对资源服务造成并发的压力。举一个具体例子,比如缓存失效,多个请求同时到达某服务请求某资源,该资源在缓存中已经失效,此时这些请求会继续访问 DB 做查询,会引起数据库压力瞬间增大。而使用 SharedCalls 可以使得同时多个请求只需要发起一次拿结果的调用,其他请求"坐享其成",这种设计有效减少了资源服务的并发压力,可以有效防止缓存击穿。

高并发场景下,当某个热点 key 缓存失效后,多个请求会同时从数据库加载该资源,并保存到缓存,如果不做防范,可能会导致数据库被直接打死。针对这种场景,go-zero 框架中已经提供了实现,具体可参看sqlcmongoc等实现代码。

为了简化演示代码,我们通过多个线程同时去获取一个 id 来模拟缓存的场景。如下:

func main() {
  const round = 5
  var wg sync.WaitGroup
  barrier := syncx.NewSharedCalls()

  wg.Add(round)
  for i := 0; i < round; i++ {
    // 多个线程同时执行
    go func() {
      defer wg.Done()
      // 可以看到,多个线程在同一个key上去请求资源,获取资源的实际函数只会被调用一次
      val, err := barrier.Do("once", func() (interface{}, error) {
        // sleep 1秒,为了让多个线程同时取once这个key上的数据
        time.Sleep(time.Second)
        // 生成了一个随机的id
        return stringx.RandId(), nil
      })
      if err != nil {
        fmt.Println(err)
      } else {
        fmt.Println(val)
      }
    }()
  }

  wg.Wait()
}

运行,打印结果为:

837c577b1008a0db
837c577b1008a0db
837c577b1008a0db
837c577b1008a0db
837c577b1008a0db

可以看出,只要是同一个 key 上的同时发起的请求,都会共享同一个结果,对获取 DB 数据进缓存等场景特别有用,可以有效防止缓存击穿。

关键源码分析

  • SharedCalls interface 提供了 Do 和 DoEx 两种方法的抽象
// SharedCalls接口提供了Do和DoEx两种方法
type SharedCalls interface {
  Do(key string, fn func() (interface{}, error)) (interface{}, error)
  DoEx(key string, fn func() (interface{}, error)) (interface{}, bool, error)
}
  • SharedCalls interface 的具体实现 sharedGroup
// call代表对指定资源的一次请求
type call struct {
  wg  sync.WaitGroup  // 用于协调各个请求goroutine之间的资源共享
  val interface{}     // 用于保存请求的返回值
  err error           // 用于保存请求过程中发生的错误
}

type sharedGroup struct {
  calls map[string]*call
  lock  sync.Mutex
}
  • sharedGroup 的 Do 方法

    • key 参数:可以理解为资源的唯一标识。
    • fn 参数:真正获取资源的方法。
    • 处理过程分析:
// 当多个请求同时使用Do方法请求资源时
func (g *sharedGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
  // 先申请加锁
  g.lock.Lock()

  // 根据key,获取对应的call结果,并用变量c保存
  if c, ok := g.calls[key]; ok {
    // 拿到call以后,释放锁,此处call可能还没有实际数据,只是一个空的内存占位
    g.lock.Unlock()
    // 调用wg.Wait,判断是否有其他goroutine正在申请资源,如果阻塞,说明有其他goroutine正在获取资源
    c.wg.Wait()
    // 当wg.Wait不再阻塞,表示资源获取已经结束,可以直接返回结果
    return c.val, c.err
  }

  // 没有拿到结果,则调用makeCall方法去获取资源,注意此处仍然是锁住的,可以保证只有一个goroutine可以调用makecall
  c := g.makeCall(key, fn)
  // 返回调用结果
  return c.val, c.err
}
  • sharedGroup 的 DoEx 方法

    • 和 Do 方法类似,只是返回值中增加了布尔值表示值是调用 makeCall 方法直接获取的,还是取的共享成果
func (g *sharedGroup) DoEx(key string, fn func() (interface{}, error)) (val interface{}, fresh bool, err error) {
  g.lock.Lock()
  if c, ok := g.calls[key]; ok {
    g.lock.Unlock()
    c.wg.Wait()
    return c.val, false, c.err
  }

  c := g.makeCall(key, fn)
  return c.val, true, c.err
}
  • sharedGroup 的 makeCall 方法

    • 该方法由 Do 和 DoEx 方法调用,是真正发起资源请求的方法。
// 进入makeCall的一定只有一个goroutine,因为要拿锁锁住的
func (g *sharedGroup) makeCall(key string, fn func() (interface{}, error)) *call {
  // 创建call结构,用于保存本次请求的结果
  c := new(call)
  // wg加1,用于通知其他请求资源的goroutine等待本次资源获取的结束
  c.wg.Add(1)
  // 将用于保存结果的call放入map中,以供其他goroutine获取
  g.calls[key] = c
  // 释放锁,这样其他请求的goroutine才能获取call的内存占位
  g.lock.Unlock()

  defer func() {
    // delete key first, done later. can't reverse the order, because if reverse,
    // another Do call might wg.Wait() without get notified with wg.Done()
    g.lock.Lock()
    delete(g.calls, key)
    g.lock.Unlock()

    // 调用wg.Done,通知其他goroutine可以返回结果,这样本批次所有请求完成结果的共享
    c.wg.Done()
  }()

  // 调用fn方法,将结果填入变量c中
  c.val, c.err = fn()
  return c
}

总结

本文主要介绍了 go-zero 框架中的 SharedCalls 工具,对其应用场景和关键代码做了简单的梳理,希望本篇文章能给大家带来一些收获。

项目地址

https://github.com/tal-tech/go-zero

微信交流群

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
kevin 将本帖设为了精华贴 09月15日 15:21
kevin GoCN 每日新闻 (2020-09-16) 中提及了此贴 09月16日 05:39

这个和 singleFlight 的区别是?

原理差不多吧,这个是好几年前写的

6楼 已删除

而且这个也不是防止缓存击穿的问题吧。缓存击穿通常是指有 TTL 的资源过期时的大量查询落到 db,我没细看,扫了下似乎这个示例的资源都没有超时时间,实际上就是不需要过期的资源在进程内存初始化、缓存,跟缓存击穿不是一码事

8楼 已删除
pastor [该话题已被删除] 中提及了此贴 11月26日 08:55
pastor 回复

缓存击穿是为了防止多个请求同时访问同一个热点数据,那么保障同一个热点数据同一时间只请求一次,然后其余请求共享结果,这个有问题吗?你仔细看一下哈

11楼 已删除
12楼 已删除
13楼 已删除
14楼 已删除
15楼 已删除

之前没细看,以为是读取一次存到 go 程序里不释放,刚才发现 makeCall 里有 defer delete 的操作,那就不是一直不过期了,目测是同一 key 相近时间到达的多个请求,在实际调用过程结束前都统一等待这次调用的结果,这是我之前没细看、理解错了。

昨天最初在网页上看代码,没细看,以为只是个 map 缓存起来、跟缓存击穿场景不一样。 细看不是,可以解决常规的缓存击穿问题。

通用场景的热点数据可能不是少量 key,而是一类热点业务的批量 key,批量 key 的高并发请求到达,其中同 key 同时请求到达如果较少效果不明显

整理下缓存击穿大致的一些点

一、按缓存的 key 命中区分

  1. 正常请求、会命中数据的,走正常的缓存、持久层的加载和查询机制
  2. 非法请求、伪造的 key、不会命中的: 1)布隆过滤器用来减少对缓存和持久层的查询 2)key 合法性规则的校验设计用来尽量减少对缓存和持久层的查询 3)缓存和持久层都没拿到数据可以考虑缓存层存空值返回(如果是 go 服务内存级别的实现,需要考虑 map 容量、大量 key 积累后的 gc 问题)

二、按数据的可变性区分,缓存可以分为

  1. 会变的数据:要有过期时间、更新机制
  2. 不变的数据:程序内存缓存,或者 memcache、redis 等缓存服务、不设置 TTL
17楼 已删除
18楼 已删除

如果只是解决少量特定 key 热点的缓存、数据层访问,我更建议另一些方案:

  1. 进程内按 TTL 做内存缓存,请求来了直接走进程内存,TTL 到了进程自己去数据层更新这个值到进程自己的内存

  2. 或者就以 redis 为例,特定 key 更新时发布订阅(自动触发更新)+ 进程轮询(避免发布时刻刚好 redis 掉线重连导致丢失消息),go 服务或收到发布消息时自动更新、或按一定时间间隔轮询更新并缓存到自己进程的内存。如果是用 etcd 这种,只要配置发现(get+watch)就够用了

上面这两种进程内存缓存的方案,请求来了每次都走进程内存,不需要每次请求来了都走缓存层(缓存失效时要 load 持久层到缓存)。

按上面的 2 种方案算一笔账:假如缓存 TTL 是 5 分钟,那 go 进程 5 分钟更新一次,go 进程 1 小时对这个 key 只需要请求缓存、数据层 12 次。

对比帖子的方案,确实在同 key 同时到达时能复用一些请求,但是内网数据层响应较快,假设 20ms,高峰时段,就算平均 50ms 有一波请求累积,单个节点的 go 进程 1 秒钟还是要走数据层 20 次。

换个说法描述:1 小时内有 100w 次该 key 请求到达,有一些请求几乎同时到达该 go 服务并且通过帖子的方案共享了一部分数据层操作,该节点 go 进程比如节省了 20w 次,甚至节省了 50w 次,但还是请求了几十万次缓存、持久层

但是按我上面说的两种,1 小时内,单节点 go 进程对缓存、持久层的操作只有 12 次(实现方式的少量误差可以忽略)

如果不是针对少量特定 key 的,我前面也说了,高并发多节点多 key(包括非法 key 无法命中缓存的情况)时,主贴的方案,解决不了击穿、雪崩的问题,还是要走数据层限流

20楼 已删除
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册