如何防止缓存被击穿

在什么情况下缓存会被击穿

高并发应用场景中,当大量请求同时请求同个key,这个key便会失效了,这就使得数据库被超量的请求直接访问。此现象就是缓存击穿,其后果会导致数据库压力陡增。

使用singleflight阻止同时请求

请求1、2、3同时请求相同的key,singleflight机制只会让请求1访问DB,请求1返回的value不仅返回给客户端1,也作为请求2、请求3的结果返回给客户端。这里的多请求理解为多个gotoutine并发执行。

示例代码

package main
​
import (
   "golang.org/x/sync/singleflight"
   "log"
   "sync"
   "time"
)
​
func main() {
   var group singleflight.Group
   var wg sync.WaitGroup
   gonum := 5
   wg.Add(gonum)
   key := "requestKey"
   for i := 0; i < gonum; i++ { //模拟多个协程同时请求
      go func(requestID int) {
         defer wg.Done()
         value, _ := mainproc(&group, requestID, key)
         log.Printf("request %v 获取结果: %v", requestID, value)
      }(i)
   }
   wg.Wait()
}
​
func mainproc(group *singleflight.Group, requestID int, key string) (string, error) {
   log.Printf("request %v 发起请求", requestID)
   value, _, _ := group.Do(key, func() (ret interface{}, err error) { //do的入参key,可以直接使用缓存的key,这样同一个缓存,只有一个协程会去读DB
      log.Printf("request %v 正在运行", requestID)
      time.Sleep(3 * time.Second)
      log.Printf("request %v 完成", requestID)
      return "RESULT", nil
   })
   return value.(string), nil
}

源码解析

1. 数据结构

Group:实现singleflight机制的对象,多个请求共用一个group,其中的mu字段保证并发安全,m字段存请求(key)和对应的call对象(value),多个请求访问同一个key时,m就保证了每个key只有一个call对象。

type Group struct {
   mu sync.Mutex       // 锁,保证m的并发安全
   m  map[string]*call // 存请求(key)和对应的调用信息(value)包括返回结果等。
}
Call:调用信息,包括结果和一些统计字段。多个请求的key相同,只会有一个call
type call struct {
   // 通过wg的机制可以保证阻塞相同key的其他请求。
   wg sync.WaitGroup
​
   // 请求返回结果,保证在wg done之前只写入一次,且在wg done之后才会读
   val interface{}
   err error
​
   // 当前key是否调用了Forget方法
   forgotten bool
​
   // 统计相同key的次数
   dups  int
   // 请求返回结果,但是DoChan方法调用,用channel进行通知。
   chans []chan<- Result
}
Result:请求的返回结果
type Result struct {
   // 返回值
   Val interface{}
   Err error
   // 是否共享(多个相同key的请求等待)
   Shared bool
}

2. 方法

Do方法:singleflight的核心方法,执行给定的函数,并返回结果,一个key返回一次,重复的key会等待第一个返回后,返回相同的结果。
DoCall方法:与Do方法作用一样,区别在于执行函数非阻塞,所有的结果通过chan传给各个请求。

/*
Do 执行给定的函数,并返回结果,一个key返回一次,重复的key会等待第一个返回后,返回相同的结果。
入参:key 请求标识,用于区分是否是相同的请求;fn 要执行的函数
返回值:v 返回结果;err 错误信息;shared 是否是共享的结果,是否将v提供给多个请求
*/
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
   // 相当于给map加锁
   g.mu.Lock()
   // 懒加载,如果g中还没有map,就初始化一个map
   if g.m == nil {
      g.m = make(map[string]*call)
   }
   // key有对应的value,说明有相同的key只在执行,当前的请求需要等待。
   if c, ok := g.m[key]; ok {
      c.dups++      // 相同的请求数+1
      g.mu.Unlock() // 不需要写入,直接释放锁
      c.wg.Wait()   // 等待
​
      // 省略一些错误逻辑处理。。。
      ......
      return c.val, c.err, true
   }
   // 当前的key没有对应value
   c := new(call) // 新建当前key的call实例
   c.wg.Add(1)    // 只有1个请求执行,只需要Add(1)
   g.m[key] = c   // 写入map
   g.mu.Unlock()  // 写入完成释放锁
​
   g.doCall(c, key, fn)            // 执行
   return c.val, c.err, c.dups > 0 // >0 表示当前值需要共享给其他正在等待的请求。
}
​
/*
DoChan 与Do方法作用相同,区别是返回的是chan,可以在有数据时直接填入chan中,避免阻塞。
*/
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
   ch := make(chan Result, 1)
   ......
   if c, ok := g.m[key]; ok {
      c.dups++
      // 等待的请求将自己的ch添加到call实例中的chans列表中,方便有结果时返回
      c.chans = append(c.chans, ch)
      // 因为结果通过ch传递,所以不需要c.wg.Wait()
      ......
      return ch
   }
   c := &call{chans: []chan<- Result{ch}}
   ......
​
   // 因为使用chan传输数据,是非阻塞式的,可以使用其他的goroutine执行处理函数。
   go g.doCall(c, key, fn)
​
   return ch
}
c

作者:陈双寅

posted @ 2023-05-26 14:20  中亿丰数字科技  阅读(15)  评论(0)    收藏  举报