缓存击穿详解及案例实战
流程分析
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
何为缓存重建业务复杂?缓存在redis里存储,到了一定时间会被清楚失效,需要从数据库重新查询并写入redis,需要从多个数据库中进行查询,甚至要做表关联的运算,这样一来这个业务的耗时就会很长。
线程1在重建缓存数据的同时,又有无数的请求访问进来,此时都无法命中,都会再次查询数据库进行缓存重建,这样一来所有的请求都打到了数据库
解决方案
- 互斥锁
- 永不过期+逻辑过期
互斥锁
缓存未命中后,获取锁,直到写入缓存后再次释放锁,其他进程查询缓存未命中后获取锁失败,将会阻塞等待并再次尝试,直到线程1写入缓存并查询成功
如果重建缓存的业务比较复杂,耗时较长,其他的线程将会一直处在阻塞等待的状态。所以互斥锁的方案相对来说性能就差一点,但能保证数据的一致性
永不过期+逻辑过期
将缓存数据设置为永不过期(即不依赖 Redis 的 TTL),这样缓存项本身不会因时间原因自动失效。
每个缓存数据项内部包含一个逻辑过期时间(如时间戳)。当应用程序读取数据时,会检查当前时间与逻辑过期时间的关系:未过期则直接返回缓存数据。已过期则触发新的后台线程(或异步任务)刷新缓存数据,并且立即返回旧的缓存数据,保持应用响应性。存储的数据结构例如下图:
线程1命中后发现时间过期,获取锁并建立一个新的线程来进行缓存的重建,线程1则直接返回旧的数据。新的线程3此时再查询缓存,获取锁失败后将会直接返回旧数据。直至线程2重建缓存成功并释放锁后再次查询缓存才能查询到最新的数据
优缺点对比
这两种方案其实都是在解决缓存重建的这段时间内产生的并发问题,互斥锁的解决方案保证了数据的一致性,牺牲了服务的可用性,性能下降,甚至在阻塞过程中有可能不可用。逻辑过期的方案则保证了可用性,牺牲了一致性。
案例实战
1. 利用互斥锁解决缓存击穿问题
业务流程:
此处的锁需要自定义锁,而不是Synchronized和Lock(拿到锁可以执行,没拿到锁则要一直等待),分布式系统下则采用分布式锁。
自定义锁的实现思路
此处会使用到Redis中的string类型的set nx命令,当且仅当这个key不存在的时候才进行。这样一来只有第一个写入锁的能够成功,而后面写入的都无法成功,思路类似互斥锁:
127.0.0.1:6379> help setnx
SETNX key value
summary: Set the value of a key, only if the key does not exist
since: 1.0.0
group: string
释放锁则只需要将锁删掉即可
此外,我们还需要对锁设置超时时间,例如设置有效期10s,以防止设置锁成功后因某些原因程序出问题了,导致迟迟无法执行释放锁的动作。
案例实战
先抽象出获取锁和释放锁的方法:
获取锁的方法:
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);
}
接下来进行业务逻辑编写:
- 互斥锁
/**
* 封装互斥锁解决缓存击穿的代码
* @param id
* @return 店铺信息
*/
public Shop queryWithMutex(Long id) {
String key = "cache:shop:" + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,转成Java对象,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 也可以用("".equals(shopJson))
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);
}
// 再次从redis查询商铺缓存
String shopJson2 = stringRedisTemplate.opsForValue().get(key);
// 判断是否存在
if (StrUtil.isNotBlank(shopJson2)) {
// 存在,转成Java对象,直接返回
return JSONUtil.toBean(shopJson2, Shop.class);
}
// 4.4成功,根据id查询数据库
shop = getById(id);
// 5.不存在,返回错误
if (shop == null) {
// 写入空值
stringRedisTemplate.opsForValue().set(key,"",2L,TimeUnit.MINUTES);
//返回错误
return null;
}
// 6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放互斥锁
unlock(lockKey);
}
// 8.返回
return shop;
}
- 店铺查询
@Override
public Result queryById(Long id) {
// 用互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null){
return Result.fail("店铺不存在");
}
// 7.返回
return Result.ok(shop);
}
2. 基于逻辑过期方式解决缓存击穿
采用这个方案,理论上来讲key是永不过期的,除非“活动”结束,手动删除这个永不过期的热点key,所以不用考虑是否命中的问题,而如果真的没有命中,那就说明这个活动过期了,手动删除了热点key。所以如果未命中,也无需做一些缓存穿透,缓存击穿的解决方案,直接返回null即可。此处的核心逻辑只考虑命中后的情况
a. 大致流程
ⅰ. 设置过期时间的两种思路
- 方案一:继承
新建一个RedisData类,赋予expireTime属性:
@Data
public class RedisData {
private LocalDateTime expireTime;
}
在需要添加过期时间的对象类上继承该类:
public class Shop extends RedisData implements Serializable{
}
缺点:这种方案仍需要修改源代码,有一定的侵入性
- 方案二:在RedisData类中添加一个Object类的data属性,用来存放需要的属性
修改RedisData类:
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object Data;
}
b. 案例实战
先写一个预设缓存的方法:
private 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:" + id,JSONUtil.toJsonStr(redisData));
}
编写个单元测试来写入数据:
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private ShopServiceImpl service;
@Test
void testSaveShop(){
service.saveShop2Redis(1L,10L);
}
}
成功写入缓存以及过期时间:
接下来修改查询店铺的代码:
// 创建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR =
Executors.newFixedThreadPool(10);
/**
* 查询店铺
* @param id
* @return 店铺信息
*/
@Override
public Result queryById(Long id) {
//逻辑过期解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
if (shop == null){
return Result.fail("店铺不存在");
}
// 7.返回
return Result.ok(shop);
}
/**
* 逻辑过期解决缓存穿透
* @param id
* @return shop
*/
public Shop queryWithLogicalExpire(Long id) {
String key = "cache:shop:" + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
// 3.存在,转成Java对象,直接返回
return null;
}
// 4.命中,需要json反序列化成对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1未过期直接返回
return shop;
}
// 5.2已过期,需要缓存重建
// 6.缓存重建
// 6.1 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2判断是否获取互斥锁
if (isLock) {
// TODO 6.3成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4失败,直接返回过期的信息
return shop;
}
获取锁成功后进行DoubleCheck:
public Shop queryWithLogicalExpire(Long id) {
String key = "cache:shop:" + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
// 3.存在,转成Java对象,直接返回
return null;
}
// 4.命中,需要json反序列化成对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1未过期直接返回
return shop;
}
// 5.2已过期,需要缓存重建
// 6.缓存重建
// 6.1 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 获取锁后再次进行DoubleCheck,再次查询一下数据看是否过期
String shopJson2 = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(shopJson2)) {
return null;
}
RedisData redisData1 = JSONUtil.toBean(shopJson2, RedisData.class);
Shop shop1 = JSONUtil.toBean((JSONObject) redisData1.getData(), Shop.class);
if (expireTime.isAfter(LocalDateTime.now())) {
return shop1;
}
// 6.2判断是否获取互斥锁
if (isLock) {
// TODO 6.3成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4失败,直接返回过期的信息
return shop;
}
c. 测试
为了能够明显的看到缓存重建的过程,在重建缓存的代码中添加休眠时间:
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
//1.查询点店铺数据
Shop shop = getById(id);
// 添加休眠时间
Thread.sleep(200);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入redis
stringRedisTemplate.opsForValue().set("cache:shop:" + id,JSONUtil.toJsonStr(redisData));
}
修改数据库的店铺名称:
打开Jmeter,设置线程100,完成时间1秒:
配置请求:
开始测试,最终在第80次左右的时候成功读取到新的数据:
查看IDEA控制台,发现只走了一次sql查询: