缓存击穿

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。

缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

我在 go-redis/cache 中发现了库使用了 singleflight , 经过查阅资料,了解了 这个库的主要作用就是将一组相同的请求合并成一个请求,实际上只会去请求一次,然后对所有的请求返回相同的结果。 这样会大大降低数据库的压力。

singleflight 使用

函数签名

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Group struct {
    mu sync.Mutex       // protects m
    m  map[string]*call // lazily initialized
}
// Do 执行函数, 对同一个 key 多次调用的时候,在第一次调用没有执行完的时候
// 只会执行一次 fn 其他的调用会阻塞住等待这次调用返回
// v, err 是传入的 fn 的返回值
// shared 表示是否真正执行了 fn 返回的结果,还是返回的共享的结果
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)
// DoChan 和 Do 类似,只是 DoChan 返回一个 channel,也就是同步与异步的区别
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result
// Forget 用于通知 Group 删除某个 key 这样后面继续这个 key 的调用的时候就不会在阻塞等待了
func (g *Group) Forget(key string) 

示例

接下来我们来讲解一个简单的例子,我们来看看 singleflight 的使用方式,先来看一个简单的例子:

先使用一个普通的例子,这时一个获取blog文章详情的函数,我们在函数里面使用一个 count 模拟不同并发下的耗时的不同,并发越多请求耗时越多。

1
2
3
4
5
6
7
func getBlogDetail(id int) (string, err error) {
    atomic.AddInt32(&count, 1)

    time.Sleep(time.Duration(count) * time.Millisecond)

    return fmt.Sprintf("blog: %d", id), nil
}

我们使用 singleflight 的时候就只需要 new(singleflight.Group) 然后调用一下相对应的 Do 方法就可了,是不是很简单

1
2
3
4
5
6
7
func singleflightGetArticle(sg *singleflight.Group, id int) (string, error) {
	v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
		return getBlogDetail(id)
	})

	return v.(string), err
}

我们接下来来对比下2个函数的执行时间,先来看一个简单的例子:

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main


import (
"fmt"
"golang.org/x/sync/singleflight"
"sync"
"sync/atomic"
"time"
)

var count int32

func singleflightGetBlogArticle(sg *singleflight.Group, id int) (string, error) {
	v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
		return getBlogDetail(id)
	})

	return v.(string), err
}

func getBlogDetail(id int) (string, error) {
	atomic.AddInt32(&count, 1)

	time.Sleep(time.Duration(count) * time.Millisecond)

	return fmt.Sprintf("blog: %d", id), nil
}

func main() {
	time.AfterFunc(1*time.Second, func() {
		atomic.AddInt32(&count, -count)
	})

	var (
		wg  sync.WaitGroup
		now = time.Now()
		n   = 1000
		sg  = &singleflight.Group{}
	)

	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			res, _ := singleflightGetBlogArticle(sg, 1)
			//res, _ := getBlogDetail(1)
			if res != "blog: 1" {
				panic("err")
			}
			wg.Done()
		}()
	}

	wg.Wait()
	fmt.Printf("同时发起 %d 次请求,耗时: %s", n, time.Since(now))
}

这是没有使用 singleflight 的情况下的耗时:

1
2
同时发起 1000 次请求,耗时: 1.0221228s
Process finished with the exit code 0

使用 singleflight 的情况下的耗时是:

1
2
同时发起 1000 次请求,耗时: 21.9379ms
Process finished with the exit code 0

可以看到使用了 singleflight 的耗时比未使用效果更好。

其他

使用 singleflight 我们比较常见的是直接使用 Do 方法,但是这个极端情况下会导致整个程序 hang 住,如果我们的代码出点问题,有一个调用 hang 住了,那么会导致所有的请求都 hang 住

参考