Skip to main content

如何解决缓存击穿?

作者:程序员马丁

在线博客:https://open8gu.com

note

大话面试,技术同学面试必备的八股文小册,以精彩回答应对深度问题,助力你在面试中拿个offer。

面试话术

缓存击穿指在高并发的系统中,一个热点数据缓存过期或者在缓存中不存在,导致大量并发请求直接访问数据库,从而给数据库造成巨大压力,甚至可能引起宕机。

具体来说,当某个热点数据在缓存中过期时,如果此时有大量并发请求同时访问这个数据,由于缓存中不存在,所有请求都会直接访问数据库,导致数据库负载急剧增加。

image.png

一般来说,解决缓存击穿的主要方法分为三种:热点数据永不过期、热点数据预加载以及加分布式锁

1. 热点数据预加载

热点数据预加载,指的是在活动或者大促开始前,针对已知的热点数据从数据库加载到缓存中,这样可以避免海量请求第一次访问热点数据需要从数据库读取的流程。

可以极大减少请求响应时间,有效避免缓存击穿。

2. 热点数据永不过期

热点数据永不过期,指的就是可以预知的热点数据,在活动开始前,设置过期时间为 -1。这样的话,就不会有缓存击穿的风险。

这个可以搭配热点数据预加载一起完成。等对应热点缓存的活动结束后,这些数据访问量就比较低了,可以通过后台任务的方案对指定缓存设置过期时间,这样可以有效降低 Redis 存储压力。

3. 分布式锁

分布式锁的解决方案就是保证只有一个请求可以访问数据库,其它请求等待结果。这样可以避免大量的请求同时访问数据库。

image.png

但是这种的话有一个弊端,那就是获取分布式锁的请求,都会执行一遍查询数据库,并更新到缓存。理论上只有第一个加载数据库记录请求是有效的

针对这个问题,可以通过双重判定锁的形式,在获取到分布式锁之后,再次查询一次缓存是否存在。如果缓存中存在数据,就直接返回;如果不存在,才继续执行查询数据库的操作。这样就可以避免大量请求访问数据库。

双重判定锁有效提升了锁性能以及数据库访问。

image.png

问题详解

我们可以从分布式锁的角度上,分析如何通过互斥锁完成缓存击穿场景解决方案?

1. 查询缓存不存在请求数据库

第一版,查询数据缓存是否存在,不存在的话请求数据库,数据库存在则把当前数据回写到缓存。

问题比较明显,如果缓存过期或被删除,大量请求就会全部请求到数据库,导致数据库压力暴涨。

伪代码如下:

public String selectTrain(String id) {
String cacheData = cache.get(id);
// 查询缓存不存在,去数据库查询并放入到缓存
if (StrUtil.isBlank(cacheData)) {
// 获取数据库中存在的数据
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
// 将查询到的数据放入缓存,下次查询就有数据了
cahce.set(id, dbData);
cacheData = dbData;
}
}
return cacheData;
}

2. 分布式互斥锁优化数据库压力

在获取数据时,使用分布式锁(如 Redis 的分布式锁)来控制同时只有一个请求可以去后端获取数据,其他请求需要等待锁释放。这样可以防止多个请求同时穿透到后端存储。

image.png

在原有基础上继续改进,伪代码如下:

public String selectTrain(String id) {
String cacheData = cache.get(id);
// 查询缓存不存在,去数据库查询并放入到缓存
if (StrUtil.isBlank(cacheData)) {
// 为避免大量请求同时访问数据库,通过分布式锁减少数据库访问量
Lock lock = getLock(id);
lock.lock();
try {
// 获取数据库中存在的数据
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
// 将查询到的数据放入缓存,下次查询就有数据了
cahce.set(id, dbData);
cacheData = dbData;
}
} finally {
lock.unlock();
}
}
return cacheData;
}

这种方案有效地避免了缓存击穿问题,因为只有一个线程能够在同一时间内查询数据库,其他线程需要等待,不会同时穿透到后端存储系统。

完美解决问题,但是很多老板比较疑惑,为什么我在之前很多文档里都说了双重判定锁这个东西?这玩意是个啥,跟着马哥一起往下看,技术揭秘。

3. 双重判定锁

上边还有一个问题就是,假如 100w 的请求读取一个缓存,100w 的请求全部卡在 lock.lock 获取分布式锁处,只有一个线程会执行逻辑请求数据库并放入缓存。

问题来了,剩下正在获取分布锁的请求,就是 100w 个请求减去一个获取到锁的请求,还是会继续请求数据库获取数据。大家读一下上面的伪代码就明白了。

这会造成两个实际的问题:

  1. 全部用户获取锁后查询数据库,会对数据库造成无用的性能浪费,因为这 100w 的请求,只有第一次是有效的。
  2. 查询数据库会造成用户响应时间变长,接口吞吐量下降

双重判断:获取锁后,在查询数据库之前,再次检查一下缓存中是否存在数据。这是一个双重判断,如果缓存中存在数据,就直接返回;如果不存在,才继续执行查询数据库的操作。

image.png

伪代码如下:

public String selectTrain(String id) {
// 查询缓存不存在,去数据库查询并放入到缓存
String cacheData = cache.get(id);
if (StrUtil.isBlank(cacheData)) {
// 为避免大量请求同时访问数据库,通过分布式锁减少数据库访问量
Lock lock = getLock(id);
lock.lock();
try {
// 获取锁后双重判定
cacheData = cache.get(id);
// 理论上只有第一个请求加载数据库是有效的,因为它加载后会把数据放到缓存
// 后面的请求再请求数据库加载缓存就没有必要了
if (StrUtil.isBlank(cacheData)) {
// 获取数据库中存在的数据
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
// 将查询到的数据放入缓存,下次查询就有数据了
cahce.set(id, dbData);
cacheData = dbData;
}
}
} finally {
lock.unlock();
}
}
return cacheData;
}

下面是这种场景下解决方案的一般步骤:

  1. 获取锁:在查询数据库前,首先尝试获取一个分布式锁。只有一个线程能够成功获取锁,其他线程需要等待。
  2. 查询数据库:如果双重判断确认数据确实不存在于缓存中,那么就执行查询数据库的操作,获取数据。
  3. 写入缓存:获取到数据后,将数据写入缓存,并设置一个合适的过期时间,以防止缓存永远不会被更新。
  4. 释放锁:最后,释放获取的锁,以便其他线程可以继续使用这个锁。

4. 高并发极端情况

很多同学认为到这里就结束了,但这恰恰只是开始,真正难得是接下来要讲的。

我举个场景,有一万个请求同一时间访问触发了缓存击穿,如果用双重判定锁,逻辑是这样的:

  1. 第一个请求加锁、查询缓存是否存在、查询数据库、放入缓存、解锁,假设我们用了 50 毫秒;
  2. 第二个请求拿到锁查询缓存、解锁用了 1 毫秒;
  3. 那最后一个请求需要等待 10049 毫秒后才能返回,用户等待时间过长,极端情况下可能会触发应用的内存溢出。

4.1. 尝试获取锁 tryLock

像上面这种场景,类似于秒杀的架构,我们要做的就是不让用户请求在服务端阻塞过长时间。那就可以使用尝试获取锁 tryLock API,它的语义是如果拿锁失败直接返回,而不是阻塞等待直到获取锁。

public String selectTrain(String id) {
// 查询缓存不存在,去数据库查询并放入到缓存
String cacheData = cache.get(id);
if (StrUtil.isBlank(cacheData)) {
// 为避免大量请求同时访问数据库,通过分布式锁减少数据库访问量
Lock lock = getLock(id);
// 尝试获取锁,获取失败直接返回用户请求,并提醒用户稍后再试
if (!lock.tryLock()) {
throw new RuntimeException("当前访问人数过多,请稍候再试...");
}
try {
// 获取数据库中存在的数据
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
// 将查询到的数据放入缓存,下次查询就有数据了
cahce.set(id, dbData);
cacheData = dbData;
}
} finally {
lock.unlock();
}
}
return cacheData;
}

通过这种方式我们可以快速失败,告诉用户网络异常请稍后再试,等用户再尝试刷新的时候,其实获取锁的线程已经把数据放到了缓存。

因为这种方案对用户操作体验不友好,所以也只是适用于部分场景。在实际开发中,需要灵活变更。

4.2. 分布式锁分片

还有一种比较优雅的解决方案是通过分布式锁分片的形式,让并行的线程更多一些。因为同一时间有多个线程能同时操作,所以理论上,设置分片量的多少,也就是性能提升了近多少倍。

public String selectTrain(String id, String userId) {
// 查询缓存不存在,去数据库查询并放入到缓存
String cacheData = cache.get(id);
if (StrUtil.isBlank(cacheData)) {
// 假设设置10把分布式锁,那么就通过唯一标识(这里取用户ID)进行取模获取分片下标
int idx = Math.abs(userId.hashCode()) % 10;
// 为避免大量请求同时访问数据库,通过分布式锁减少数据库访问量
Lock lock = getLock(id + idx);
lock.lock();
try {
// 获取锁后双重判定
cacheData = cache.get(id);
// 理论上只有第一个请求加载数据库是有效的,因为它加载后会把数据放到缓存
// 后面的请求再请求数据库加载缓存就没有必要了
if (StrUtil.isBlank(cacheData)) {
// 获取数据库中存在的数据
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
// 将查询到的数据放入缓存,下次查询就有数据了
cahce.set(id, dbData);
cacheData = dbData;
}
}
} finally {
lock.unlock();
}
}
return cacheData;
}