缓存击穿及其解决(代码实现)

缓存击穿似乎是面试的重点,刚好学习了黑马的 Redis 课程,而且项目中实践过,就记录下来把。

PS:黑马的 40 个小时多的 Redis 课程真的很赞,学到很多实战的知识!

缓存击穿

在高并发系统中,会出现大量的请求同时查询同一个 key的情况,假如此时这个hot key 刚好失效了,就会导致大量的请求都打到数据库上面去,数据库在瞬间扛不住大量的请求时就会崩掉,这种现象就是缓存击穿

解决方案

下面是八股文式的解答,在代码实现会讲清楚细节。在这里我们只需要有个大概印象就可以了。

  • 互斥锁:为了避免出现缓存击穿的情况,我们可以在第一个请求去查询数据库的时候对他加一个互斥锁(注意:相同 key 才会同一个锁),其余的查询请求都会被阻塞住,直到锁被释放,后面的线程进来发现已经有缓存了,就直接走缓存,从而保护数据库。

  • singleflight,singleflight 的设计思路是将一组相同的请求合并成一个请求,最终只会有一个请求到达MySQL。

  • 直接让热点数据永远不过期,但设置保存逻辑过期时间,获取数据时检查逻辑过期时间,如果已经过期,tryLock 如获得锁则开启一个异步线程去更新数据,其他立刻返回脏数据。

    /images/缓存击穿及其解决(代码实现)/逻辑过期.png
    逻辑过期

优缺点:

  • 互斥锁和 singleflight 的目的是一样的,都是让一个请求到达 MySQL,而其他请求等待。这样使得缓存击穿时的缓存查询变成串行化,可以保护数据库,但也大大降低并发度。

  • 而设置逻辑过期则在过期时直接返回脏数据,不会影响并发度,但存在数据不一致问题

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 数组,采用分段锁的方式访问这个数组。

那么对于进行数据库请求的流程就变成了:

  1. 计算 key 的 hash 值;
  2. 采用 % 运算得到指定的 singleflight;
  3. 调用该 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 巧妙的利用 sync.WaitGroup,当第一个请求返回时立刻通知唤醒所有等待的请求协程。此刻过后,所有的线程都不会阻塞,可以并发的继续执行。

  • 而互斥锁在第一个请求返回后会释放锁,其他阻塞的请求协程需要逐个获取互斥锁,然后进行 double check 发现缓存已经存在,然后释放锁。也就是说所有请求的线程由于互斥锁的缘故,都是串行的

所有理论上 singleflight 的性能会比互斥锁好

当然,互斥锁也有其他实现方式,黑马 Redis 课程的实现如下:

/images/缓存击穿及其解决(代码实现)/互斥锁.png
互斥锁
0%