Golang Go语言中防止缓存击穿之进程内共享调用

发布于 1周前 作者 htzhanglong 来自 Go语言

Golang Go语言中防止缓存击穿之进程内共享调用

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 两种方法的抽象

    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

微信交流群


更多关于Golang Go语言中防止缓存击穿之进程内共享调用的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html

2 回复

更多关于Golang Go语言中防止缓存击穿之进程内共享调用的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


在Golang中防止缓存击穿(Cache Penetration)是一个重要的性能优化问题,特别是当多个请求同时尝试访问一个已经失效的缓存项时,可能会直接穿透到后端数据库,导致数据库压力剧增。进程内共享调用是一种有效的缓解策略,以下是具体的实现思路:

  1. 使用互斥锁(Mutex): 当缓存失效时,使用互斥锁来确保只有一个goroutine能够去重新加载数据到缓存中,其他goroutine则等待缓存更新完成。这样可以避免并发请求同时访问数据库。

  2. 双重检查锁定(Double-Checked Locking): 在获取互斥锁之前,先检查一次缓存是否已更新,以避免不必要的锁开销。如果缓存已更新,则直接返回缓存数据;否则,进入临界区进行缓存的更新操作。

  3. 本地缓存与分布式锁结合: 对于分布式系统,可以使用分布式锁(如Redis的分布式锁)来确保整个集群中只有一个节点进行缓存更新。同时,每个节点可以维护一个本地缓存,以减少分布式锁的争用。

  4. 缓存预热: 在应用启动时,预先加载一些热点数据到缓存中,减少缓存失效的概率,从而减轻缓存击穿的风险。

  5. 设置合理的缓存过期时间和更新策略: 根据数据变化频率,设置合理的缓存过期时间,并采用定时或异步的方式更新缓存,以减少缓存失效期间的访问压力。

通过上述策略,可以有效防止Golang应用中的缓存击穿问题,提升系统的稳定性和性能。

回到顶部