开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 6 天,点击查看活动详情
1.什么是缓存击穿
缓存击穿问题是redis中某个key过期了,而这时有大量的请求同时来访问这个key并且这个key的缓存重建业务比较复杂(意味着将新缓存存进redis中需要较长时间),无数请求最后只能先去访问数据库,给数据库带来巨大的冲击。
2.缓存击穿解决方案
解决缓存击穿的常见方案有两种:
- 互斥锁
- 逻辑过期
2.1 互斥锁
互斥锁的逻辑可以简化为上图所示。线程1先发来请求,并且获取到了互斥锁。这时线程1就可以去查询数据库,接着将查询到的数据缓存到redis中,最后记得释放锁,不然以后别的线程无法访问数据库。如果在线程1获取互斥锁成功并且还未重建缓存、释放互斥锁的时候,线程2的请求到达,那么线程2无法在redis中获取到缓存,无法获取到互斥锁,那么我们就让线程休眠一会,比方休眠50毫秒,再让线程2去查询缓存,如果还是查询不到,那么再重新休眠,再重新查询。
2.2 逻辑过期
逻辑过期,意味着永不过期。缓存击穿问题产生的原因是某个热点key过期了,请求都打到数据库了,造成数据库压力过大。因此我们可以提前准备一个不过期的热点key (比如参加活动的商品),不设置它的过期时间,将这个key保存到redis中,这样理论上总能命中redis。那是怎么判断这个key逻辑上过期了?答案是这个key的value存储一个过期时间,我们判断这个key是否过期的依据,就是这个key的value保存的过期时间。
2.3 互斥锁和逻辑过期两种方案对比
| 解决方案 | 优点 | 缺点 |
|---|---|---|
| 互斥锁 | 1.没有额外的内存消耗 2.保证一致性 3.实现简单 | 1.线程需要等待 |
| 逻辑过期 | 线程无需等待,性能较好 | 1.不保证一致性 2.有额外内存消耗 3.实现复杂 |
两种方案没有孰优孰劣之分,试开发者的需求来决定采用何种方式。如果你看重数据的一致性,那么显然是采用互斥锁;如果你对数据的一致性要求不高,看重性能,显然逻辑过期方案会更好。
3.实践
下面的例子是黑马点评,查询店铺的代码。
3.1 利用互斥锁解决缓存击穿问题
- 我们将利用互斥锁的主要业务逻辑写在一个叫queryWithMutex(Long id)的方法里。这里对它做一点介绍。首先我们去redis里查询是否有可以返回的缓存,如果有就直接返回;如果没有可以返回的缓存,判断是否是redis保存的空值,如果是空值的话,那就说明数据库并没有对应的数据可以缓存到redis中,返回null即可。接着就是获取互斥锁,如果获取到互斥锁,那么就可以去查询数据库了,这里又有两种情况,分别是:1.在数据库能够查询到数据,那么将数据缓存到redis,并将查询到的数据返回(记得释放互斥锁);2.在数据库当中查询不到数据,那么往redis中写入空字符串(空值),以防缓存穿透问题。
- 这部分的重点在于互斥锁是怎么做的。这个互斥锁其实是利用了redis本身的特性。大家回忆下redis的String数据结构,里面有一个setnx,它的作用是在设置key的时候,必须保证这个key在redis中是不存在的,如果这个key在redis中早已存在,那么是无法正常使用setnx来设置key和value的。我们的互斥锁就是利用了这个特性,如果某个进程先用setnx设置了key (所有线程都应该使用setnx来设置同一个key,才能成功设置互斥锁),那么其它线程是无法用setnx的,也就无法拿到互斥锁。我们在这里写了两个方法tryLock(String key)和unlock(String key),前者是用来设置互斥锁,后置是用来释放互斥锁。stringRedisTemplate是没有setnx方法的,而是setIfAbsent方法。
- 设置互斥锁的时候,记得给这个key一个过期时间,以防某些原因这个key没有被删除掉。
- 获取到互斥锁的时候,记得再次检测redis中缓存是否存在。因为有可能有线程重建好了缓存后,释放了锁,而当前进程恰好拿到了锁。
- 通过这个例子的学习,我们应该摒弃以前只考虑缓存穿透的问题,我们发现在写了避免缓存穿透的代码之后,还可以更少避免缓存击穿的代码。缓存穿透和缓存击穿的原因都是请求直接访问数据库,导致数据库压力过大,所以这两个问题的解决办法就一点,不要让过多的请求去访问数据库。
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
//Shop shop = queryWithLogicalExpire(id);
if (shop == null){
return Result.fail("店铺不存在!");
}
//7.返回
return Result.ok(shop);
}
public Shop queryWithMutex(Long id){
//String key = "cache:shop:" + id;
String key = RedisConstants.CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.redis存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//判断命中的是否是redis缓存的空值
if (shopJson != null){
//返回错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String locKey = "lock:shop" + id;
Shop shop = null;
try {
boolean isLock = tryLock(locKey);
// 4.2.判断是否获取成功
if (!isLock){
// 4.3.失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
/**
* 获取锁成功应该再次检测redis缓存是否存在,做DoubleCheck,
* 如果存在则无需重建缓存。为什么要doublecheck?原因是以防刚刚
* 有线程才写好缓存,释放锁。另一个线程又获取到了锁,有要再去重建缓存
*/
String shopJsonCheck = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJsonCheck)){
return JSONUtil.toBean(shopJsonCheck, Shop.class);
}
// 4.4.成功,根据id查询数据库
shop = getById(id);
// 模拟重建的延时
//Thread.sleep(200);
//5.数据库不存在,返回错误
if (shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6.数据库存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(locKey);
}
//8.返回
return shop;
}
//生成互斥锁
private boolean tryLock(String key){
//这里是执行redis的setnx value其实设置成什么都无所谓,这里设置1 为了避免这个值无法被删除,所以设置10秒有效期。毕竟通常业务1秒即可完成
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}
3.2 利用逻辑过期解决缓存击穿问题
- 采用逻辑过期解决缓存击穿的情况下,一般会有一个管理系统,在那里输入预热的key,所以理论上这个可以是会被命中的。如果在redis中没有查到缓存,直接返回null即可,因为这个key并不是我们要预热的。我们这里没有管理系统,是写了一个测试类,往redis里面写入预热的key
- 因为是采用逻辑过期的方式,所以过期时间是写在实体类里面的。为了不改变原有实体类的结构,我们创建了一个新类RdisData来封装原有的实体类Shop。当然最简单的是用这个新类去继承实体类,例子中我们不是采用继承而是封装。后面将JSON序列化为对象采用的是hutool提供的工具类。
- 获取锁后,重建完缓存,要释放锁。
- 获取锁后,还需要重新判断是否有缓存已经建立,如果已经建立缓存,直接返回即可。这么做是因为有可能前面有线程已经重建好缓存了,刚释放锁,就被当前进程获取到了锁。
- 采用互斥锁解决缓存穿透问题时,当我们发现缓存和数据库都没有对应的数据时,我们会往redis写入空值,避免以后的请求都去访问数据库,给数据库造成太大的压力。采用逻辑过期解决缓存穿透问题,我们不用写空值到redis中。因为采用这种方案的时候,我们是知道哪些数据会被高频访问,所以我们提前用管理系统在redis中存入了对应的key(预热),所以redis中的数据会被命中。如果没命中的话,直接返回null,因为显然这个请求不重要。
- 我们这里在添加key的逻辑时间时,代码是这么写的LocalDateTime.now().plusSeconds(expireSeconds),就是当前时间的基础上,增加expireSeconds秒。以后比对时间是否过期就用当前时间和我们现在添加到key的时间比对,如expireTime.isAfter(LocalDateTime.now()),如果值是true,说明key的时间还在当前时间之后,key没过期,其中expireTime就是我们存到key里的时间。添加时间的方法是saveShop2Redis(Long id,Long expireSeconds),LocalDataTime这个类的有些方法比较少用,大家可以自行搜索。
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
//Shop shop = queryWithMutex(id);
Shop shop = queryWithLogicalExpire(id);
if (shop == null){
return Result.fail("店铺不存在!");
}
//7.返回
return Result.ok(shop);
}
//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id){
//String key = "cache:shop:" + id;
String key = RedisConstants.CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
//3.redis缓存不存在,直接返回null
return null;
}
//4.命中,需要先把JSON反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject)redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//5.1 未过期,直接返回店铺信息
return shop;
}
//5.2 已过期,需要缓存重建
//6.缓存重建
//6.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//6.2 判断是否获取锁成功
if (isLock){
/**
* 获取锁成功后应该再次检测redis缓存是否过期,做DoubleCheck。
* 如果缓存存在,则无需重建缓存。为什么需要doublecheck?因为
* 有肯能已经有线程重建好了缓存,释放了锁,另一个线程刚好拿到了锁
*/
String shopJsonCheck = stringRedisTemplate.opsForValue().get(key);
RedisData redisData2 = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data2 = (JSONObject)redisData.getData();
Shop shop2 = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime2 = redisData.getExpireTime();
//5.判断是否过期
if (expireTime2.isAfter(LocalDateTime.now())){
//5.1 未过期,直接返回店铺信息
return shop2;
}
// 6.3 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
}
//6.4 返回过期的商铺信息
return shop;
}
@Data
public class RdisData {
private LocalDateTime expireTime;
private Object data;
}
//测试类
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private ShopServiceImpl shopService;
@Test
void testSaveShop() {
shopService.saveShop2Redis(1L,10L);
}
}
public void saveShop2Redis(Long id,Long expireSeconds){
//1.查询店铺数据
Shop shop = getById(id);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(redisData));
}