一、缓存击穿解决方案
缓存击穿也称热点Key问题,是指缓存中的某个数据过期或者被删除,而此时有大量的并发请求查询这个数据,导致这些请求直接落到后端存储上,增加了存储的负载。为了解决缓存击穿问题,可以考虑以下两个方案:
(1)互斥锁:在缓存失效的瞬间,使用互斥锁(也称为分布式锁)来保证只有一个线程去查询数据库,其他线程等待获取锁。只有当第一个线程查询数据库后,其他线程才能获取缓存的数据。
其优点是确保只有一个线程能够执行数据库查询操作,避免了多个线程同时查询导致的缓存击穿问题。通过互斥锁,可以控制并发访问的数量,避免瞬时的大量请求直接访问数据库。
其缺点是使用互斥锁可能带来一定的性能开销,特别是在高并发场景下。还有不当使用锁可能导致死锁的问题,需要慎重设计锁的使用方式。而且其他线程会一直循环操作,直到解锁。
(2)逻辑过期:设置过期时间,在Redis中,为缓存数据的值(value)中添加了一个过期时间标识。这个过期时间并不会直接作用于Redis,而是后续通过业务逻辑去处理。
假如当线程一发起查询请求时,首先从缓存中获取数据。通过检查value中的过期时间标识,此时假设线程一可以判断出当前的数据已经过期了。由于数据已经过期,线程一尝试获取互斥锁。如果成功获取锁,说明线程一是第一个发现数据过期的线程。
获取锁后,线程一开启一个新的线程(线程二),该线程负责进行以前的重构数据的逻辑。这个重构过程可能包括从数据库中获取最新的数据,并将其写入缓存。
如果此时有其他线程(例如线程三)发起查询请求,由于线程二持有着锁,线程三无法获得锁。因此,线程三也会直接返回当前已过期的数据,直到新开的线程二把重建数据构建完毕。
这种方法的优点在于,通过使用互斥锁和异步构建缓存的方式,系统可以在后台进行数据的重构,而不会阻塞其他线程的查询请求。然而,缺点是在构建完缓存之前,返回的都是过期的数据,可能会导致一段时间内返回的是不准确的结果。
二、缓存击穿案例
本文以互斥锁方法为例解决缓存击穿问题。
(1)首先通过命令熟悉互斥锁的基本使用
如下图所示,在命令行中直接登录Redis客户端,使用setnx lock 1模拟上锁,该命令尝试将键“lock”的值设置为1,但仅当该键不存在时才执行。由于键“lock”之前不存在,该命令执行成功,返回值为 1,表示设置成功。
使用get lock命令用于检索键“lock”的当前值。由于在前一步中成功执行了setnx命令,键 “lock”的值现在为 "1"。
使用setnx lock 2再次尝试使用 setnx 将键“lock”的值设置为 2。由于键“lock”已经存在,该命令执行失败,返回值为 0,表示设置未成功。
使用 del 命令删除键“lock”。返回值为 1,表示成功删除了键 "lock"。
使用setnx lock 2命令,再次尝试使用 setnx 将键“lock”的值设置为 2。由于键“lock”之前已被删除,该命令执行成功,返回值为 1,表示设置成功。
这几行命令简单模拟了互斥锁的使用。
(2)将互斥锁应用到Web项目中
下方代码是一个简单的分布式锁的实现,使用了Redis的setIfAbsent和delete操作来获取锁和释放锁。
//获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
①获取锁tryLock方法:
使用 setIfAbsent 操作,尝试在 Redis 中设置键值对,如果该键不存在(即成功获取锁),则将键的值设置为 "1",并设置过期时间为 10 秒。
返回一个Boolean类型的标志,表示是否成功获取锁。在代码中,使用 BooleanUtil.isTrue(flag) 来转换为布尔值。
②释放锁unlock方法:
使用 delete 操作,将 Redis 中对应的键删除,即释放锁。
编写queryWithMutex方法,实现了缓存击穿和缓存穿透的问题
首次某一个线程使用tryLock方法尝试获取互斥锁,如果获取失败,则进行休眠重试。对应一下部分:
//4.3 失败,则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
在获取互斥锁成功后,根据商店ID查询数据库。为了模拟实际应用中的一些耗时操作,这里使用 Thread.sleep(2000) 模拟延迟。
判断数据库中是否存在该商店信息,不存在则将空值写入 Redis 缓存,然后返回空。如果数据库中存在该商店,将商铺数据写入 Redis 缓存。最后,在 finally 块中释放互斥锁,这时别的线程才能读取到缓存中的信息。互斥锁解决了缓存击穿的问题。
源代码如下:
//互斥锁解决击穿
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2、判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的值是否是空值
if (shopJson != null) {
//返回一个错误信息
return null;
}
// 4.实现缓存重构
//4.1 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断否获取成功
if (!isLock) {
//4.3 失败,则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 成功,根据id查询数据库
shop = getById(id);
//模拟延迟
Thread.sleep(2000);
// 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_NULL_TTL, TimeUnit.MINUTES);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(lockKey);
}
return shop;
}
重启项目,这里为了模拟大量线程对一个失效Key的值的访问,使用Jmeter性能测试工具,模拟1000个线程对URL(localhost/shop/1)进行请求。如图,设置线程组里面有1000个线程,如下图,设置请求的URL。
运行该线程组,如下图,查看结果,所有HTTP请求成功。
如下图,1000个线程请求该URL,最终只执行一次数据库的查询语句。