Go防缓存击穿 singleflight

缓存更新问题

缓存是高并发业务的基石,当访问量突然上升的时候,缓存失效回源时会将请求打到后台数据库,导致服务器响应延迟或者宕机的情况。

通常缓存更新方案:

  • 1.业务代码中,根据key从缓存拿不到数据,访问存储层获取数据后更新缓存
  • 2.由专门的定时脚本在缓存失效前对其进行更新
  • 3.通过分布式锁,实现只有一个请求负责缓存更新,其他请求等待:一种基于哨兵的缓存访问策略

通常获取缓存这样写:

1
2
3
4
data = getCache(key)
if !data {
data = selectDB(key)
}

并发获取

当缓存失效,集中查询DB时,此时需要考虑加锁。

加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
func checkAccess(key) bool {
startTime = time.now()
for true {
if isLock(key) == false {
setLock(key)
return true
}
if (time.now() - startTime) > _maxLockTime {
return false
}
time.sleep(20ms)
}
}

在读取缓存失败,查询 DB 之前,先来一个锁判断,如果锁不存在,那么就加把锁,再去查 DB。如果锁存在,那么就等待,然后回头再去读 redis 或者进入 DB 查询。

singleflight

上面的代码使用起来没什么问题,但是依靠无限循环 + sleep 实现的方法比较低效。而在 go 语言中,借助非常轻量和高效的协程,可以很优雅的实现这种功能,这就是 singleflight

使用方法

使用方法很简单,可以参考其test

1
2
3
4
5
6
7
8
9
10
11
12
func TestDo(t *testing.T) {
var g Group
v, err := g.Do("key", func() (interface{}, error) {
return "bar", nil
})
if got, want := fmt.Sprintf("%v (%T)", v, v), "bar (string)"; got != want {
t.Errorf("Do = %v; want %v", got, want)
}
if err != nil {
t.Errorf("Do error = %v", err)
}
}

可见使用起来非常简单,对外只需要这个Do 函数,传入 Key 和获取缓存的回调函数,如此 singleflight 就能自动帮我们处理同时请求下游服务的问题了。

那么这个Do函数到底做了什么事情?

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type call struct {
wg sync.WaitGroup
val interface{}
err error
}

type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}

func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
c.dups++
g.mu.Unlock()
c.wg.Wait()
return c.val, c.err, true
}
c := new(call)
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()

g.doCall(c, key, fn)
return c.val, c.err, c.dups > 0
}

go 的代码一直都很清晰易懂,可以看到先定义了结构体group和call,group.mu是保护group.m的互斥锁,group.m主要是保存请求的key,而call结构体是用来记录回调函数的结果。

在Do函数中,函数先是判断这个 key 是否是第一次调用,如果是,就会进入doCall调用回调函数获取结果,后续的请求就会阻塞在c.wg.Wait()这里,等待回调函数返回以后,直接拿到结果。

singleflight 的应用

所以依靠 singleflight ,针对并发缓存的更新,我们就可以这样实现:

1
2
3
4
5
6
data = getCache(key)
if !data {
data = g.Do(key, func(){
return selectDB(key)
})
}

Thanks

使用 singleflight 代替传统的并发锁
Golang singleflight 用武之地