缓存击穿似乎是面试的重点,刚好学习了黑马的 Redis 课程,而且项目中实践过,就记录下来把。
PS:黑马的 40 个小时多的 Redis 课程真的很赞,学到很多实战的知识!
缓存击穿
在高并发系统中,会出现大量的请求同时查询同一个 key的情况,假如此时这个hot key 刚好失效了,就会导致大量的请求都打到数据库上面去,数据库在瞬间扛不住大量的请求时就会崩掉,这种现象就是缓存击穿。
解决方案
下面是八股文式的解答,在代码实现会讲清楚细节。在这里我们只需要有个大概印象就可以了。
-
互斥锁:为了避免出现缓存击穿的情况,我们可以在第一个请求去查询数据库的时候对他加一个互斥锁(注意:相同 key 才会同一个锁),其余的查询请求都会被阻塞住,直到锁被释放,后面的线程进来发现已经有缓存了,就直接走缓存,从而保护数据库。
-
singleflight,singleflight 的设计思路是将一组相同的请求合并成一个请求,最终只会有一个请求到达MySQL。
-
直接让热点数据永远不过期,但设置保存逻辑过期时间,获取数据时检查逻辑过期时间,如果已经过期,tryLock 如获得锁则开启一个异步线程去更新数据,其他立刻返回脏数据。
优缺点:
singleflight 代码实现和测试
代码实现
singleflight 本质就是当缓存失效时,把对相同 key 的多个相同的请求整和成一次请求。
对于业务逻辑:我们只需要在缓存失效,对数据库进行访问时,直接调用 singleflight.Do 包装方法执行数据库访问,就可以实现对相同 key 的多个相同的请求整和成一次请求。
所以我们主要看下 singleflight 的实现,下面是我参考 “golang.org/x/sync/singleflight” 的简单实现:
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
|
// 一个 call 表示对于相同数据的多个请求整合成一次数据请求
type call struct {
wg sync.WaitGroup
val interface{}
err error
}
type singleflight struct {
// 对 map 的并发安全访问
sync.Mutex
m map[string]*call
}
func NewSingleflight() *singleflight {
return &singleflight{
m: make(map[string]*call),
}
}
// 使得对于相同 key 的请求只发一次
func (g *singleflight) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
g.Lock()
if c, ok := g.m[key]; ok {
g.Unlock()
// 已经存在,说明已经有第一个请求发出去了,现在只需要等待该请求返回即可
c.wg.Wait()
return c.val, c.err
}
// 下面的逻辑是第一个请求,需要访问 MySQL 获取数据
c := &call{}
g.m[key] = c
// 让等待
c.wg.Add(1)
g.Unlock()
// fn 是获取数据,也就是从 MySQL 等数据源获取数据
c.val, c.err = fn()
// 已经获取数据,唤醒等待的其他请求
c.wg.Done()
g.Lock()
delete(g.m, key)
g.Unlock()
return c.val, c.err
}
|
测试
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
|
var redis, mysql = map[string]string{}, map[string]string{}
func init() {
mysql["ayang"] = "coder"
}
func main() {
w := &sync.WaitGroup{}
w.Add(1000)
// Do 获取成功的次数
count := atomic.Int32{}
// 从 redis 获取的次数
getCount := atomic.Int32{}
g := NewSingleflight()
for i := 0; i < 1000; i++ {
go func() {
// 模拟缓存失效时, 1000 个请求对缓存的访问,由于缓存未命中,所以需要去 MySQL 获取,调用 singleflight 模块
if str, err := g.Do("ayang", func() (interface{}, error) {
// 注意:double check,因为外面的逻辑:(检查缓存发现没有,执行 Do)不是原子的
if str, ok := redis["ayang"]; ok {
return str, nil
}
// 表示从远程获取
time.Sleep(time.Second)
getCount.Add(1)
if str, ok := mysql["ayang"]; ok {
return str, nil
} else {
return nil, nil
}
}); err == nil {
if str == "coder" {
count.Add(1)
}
}
w.Done()
}()
}
w.Wait()
if count.Load() != 1000 {
println("获取成功次数没达到 1000")
}
if getCount.Load() != 1 {
println("执行 MySQL 获取次数超过 1 次")
}
}
|
再度优化
可以看到,如果发生缓存失效,所有的线程都会访问 singleflight 里面的 map,而这个 map 采用互斥锁实现并发安全。
在高并发条件下,访问这个 map 就会造成性能的瓶颈。
解决方法:
很简单,再加一层。即对 singleflight 进行封装,形成 singleflight 数组,采用分段锁的方式访问这个数组。
那么对于进行数据库请求的流程就变成了:
- 计算 key 的 hash 值;
- 采用 % 运算得到指定的 singleflight;
- 调用该 singlefligh.Do 发送数据库请求或等待其他请求完成。
互斥锁
Java 实现
业务逻辑如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// 伪代码
public string getFromCache(string key) {
if redis[key] {
// 缓存存在,直接返回
return val
} else {
// 否则使用互斥锁,发起 MySQL 请求
synchronized (key.intern()) {
// 同样是 double check
if redis[key] {
return val
}
// 数据库请求
val = mysql[key]
// 赋值给缓存 redis
redis[key] = val
}
}
}
|
Go 实现
再看上面,Java 巧妙的利用了 string.intern()
,也就是 string 类型维护的对象池,然后对对象池里的对象上互斥锁。
Go 相比 Java 的互斥锁实现要麻烦一点,因为 Go 既没有对象锁也没有内置的对象池。
没有,那我们就自己实现一个:
1
|
var mutexMap map[unsafe.Pointer]*sync.Mutex // 对象锁,每一次从这里获得锁(第一次需要存入锁,对对象取地址即可)
|
业务代码也就跟 Java 类似,就不重复写了。
优化
可以看到,对于 mutexMap 也是全局都需要访问,所以也被上了互斥锁。那么我们同样可以采用分段锁的方式提高其并行性。
互斥锁和 singleflight 的对比
上面这种互斥锁的实现方式是比 singleflight 的阻塞粒度大很多的。
此话怎讲?
相同点:在缓存失效重建之前,对缓存的所有请求都会被阻塞。
不同点:
所有理论上 singleflight 的性能会比互斥锁好。
当然,互斥锁也有其他实现方式,黑马 Redis 课程的实现如下: