缓存击穿问题(热点key失效)
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且重建缓存业务较复杂的key
突然失效了,此时无数的请求访问会在瞬间打到数据库,带来巨大的冲击
- 一件秒杀中的商品的key突然失效了,由于大家都在疯狂抢购那么这个瞬间就会有无数的请求访问去直接抵达数据库,从而造成缓存击穿
互斥锁
如果缓存中没有缓存对应的店铺信息时,所有的线程过来后需要先获取锁才能查询数据库中的店铺信息,保证只有一个线程访问数据库,避免数据库访问压力过大
- 优点: 实现简单且没有额外内存销毁(加一把锁), 当拿到线程锁的线程把缓存数据重建好后,其他线程再访问时从缓存中查询的数据和数据库中的数据就是一致的
- 缺点: 当拿到线程锁的线程在操作数据库的时候,其他线程只能等待,将查询的性能从并行变成了串行(tryLock方法+double check可以解决),但是还有死锁的风险
setnx实现互斥锁
根据店铺Id查询商铺信息
,增加了获取互斥锁的环节,即缓存未命中时只有获取锁成功的线程才能查询数据库,保证只有一个线程去数据库执行查询语句
,防止缓存击穿
利用redis提供的setnx key(锁Id) value
命令判断是否有线程成功插入key(锁), del key
表示释放锁
返回值 | 描述 |
---|---|
0 | 表示线程插入key失败,即线程获取锁失败 |
1 | 表示线程插入key成功即线程获取锁成功 |
在StringRedisTemplate
中对应setnx指令的方法是setIfAbsent()
,返回true表示插入成功,fasle表示插入失败
// 每一个店铺都有自己的锁,根据锁的Id(锁前缀+店铺ID)尝试获取锁(本质是插入key)
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 我们这里使用了BooleanUtil工具类将Boolean类型的变量转化为boolean,避免在拆箱过程中返回null
return BooleanUtil.isTrue(flag);
}
// 释放锁(本质是删除key)
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
单独实现负责解决缓存击穿
问题的方法queryWithMutex
,在该方法中如果查到店铺信息返回shop查不到则返回null,最后在queryById
中做统一判断返回结果类
- 获取锁成功,应该再次检测redis缓存是否存在,因为此时可能其他线程重建完缓存刚释放完锁后,做双重检查,如果存在则无需重建缓存
@Override
public Result queryById(Long id) {
// 使用互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
// 如果shop等于null,表示数据库中对应店铺不存在或者缓存的店铺信息是空字符串
if (shop == null) {
return Result.fail("店铺不存在!!");
}
// shop不等于null,把查询到的商户信息返回给前端
return Result.ok(shop);
}
@Override
public Shop queryWithMutex(Long id) {
//1.先从Redis中查询对应的店铺缓存信息,这里的常量值是固定的店铺前缀+查询店铺的Id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.如果在Redis中查询到了店铺信息,并且店铺的信息不是空字符串则转为Shop类型直接返回,""和null以及"/t/n(换行)"都会判定为空即返回false
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//3.如果命中的是空字符串即我们缓存的空数据,返回null
if (shopJson != null) {
return null;
}
// 4.没有命中则尝试根据锁的Id(锁前缀+店铺Id)获取互斥锁(本质是插入key),实现缓存重构
// 调用Thread的sleep方法会抛出异常,可以使用try/catch/finally把获取锁和释放锁的过程包裹起来
Shop shop = null;
try {
// 4.1 获取互斥锁
boolean isLock = tryLock(LOCK_SHOP_KEY + id);
// 4.2 判断是否获取锁成功(插入key是否成功)
if(!isLock){
//4.3 获取锁失败(插入key失败),则休眠一段时间重新查询商铺缓存(递归)
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 获取锁成功(插入key成功),则根据店铺的Id查询数据库
shop = getById(id);
// 由于本地查询数据库较快,这里可以模拟重建延时触发并发冲突
Thread.sleep(200);
// 5.在数据库中查不到对应的店铺则将空字符串写入Redis同时设置有效期
if(shop == null){
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6.在数据库中查到了店铺信息即shop不为null,将shop对象转化为json字符串写入redis并设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL,TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}
finally {
//7.不管前面是否会有异常,最终都必须释放锁
unlock(lockKey);
}
// 最终把查询到的商户信息返回给前端
return shop;
}
测试互斥锁解决缓存击穿
使用Jmeter
模拟缓存击穿情景,在某时刻一个热点店铺的缓存的TTL到期了,此时用户不能从Redis中获取热点店铺的缓存数据,然后就都得去数据库里查询店铺信息
-
首先
将Redis中的热点店铺的缓存数据删除模拟TTL到期
,然后使用Jmete开100个线程来访问这个没有缓存的店铺信息 -
如果后台日志只输出了一条SQL语句则说明我们的互斥锁是生效的,没有造成大量用户都去数据库执行SQL语句查询店铺的信息
PLAINTEXT
: ==> Preparing: SELECT id,name,type_id,images,area,address,x,y,avg_price,sold,comments,score,open_hours,create_time,update_time FROM tb_shop WHERE id=?
: ==> Parameters: 2(Long)
: <== Total: 1
逻辑过期(缓存预热)
缓存击穿问题主要原因是由于我们对key设置了过期时间,假设我们不设置过期时间其实就不会有缓存击穿的问题,但是不设置过期时间,缓存数据又会一直占用内存
- 优点: 通过异步线程构建缓存,避免其他线程出现等待,提高了性能
- 缺点: 构建异步线程业务复杂,需要维护一个expire字段需要额外内存消耗, 在异步线程构建完缓存之前,其他线程返回的都是过期的数据(脏数据)导致数据不一致
逻辑过期应用
实现根据店铺Id查询商铺
的业务,基于逻辑过期方式(需要提前添加热点key)来解决缓存击穿问题
第一步: 因为现在redis中存储的数据的value需要带上过期时间属性,可以新建一个实体类包含原有的数据和过期时间字段
(不侵入原来代码)
@Data
public class RedisData {
// 过期时间
private LocalDateTime expireTime
// 原有数据(用万能的Object)
private Object data;
}
第二步: 在ShopServiceImpl中新增一个方法,利用单元测试进行缓存预热即添加热点key
,将热点店铺信息和过期时间字段封装到RedisData对象中并写入Redis缓存中
public void saveShop2Redis(Long id, Long expirSeconds) {
// 1.根据店铺Id去数据库中查询店铺数据
Shop shop = getById(id);
// 由于本地查询数据库较快,模拟重建延时
Thread.sleep(200);
// 2.封装逻辑过期时间(当前时间转换为秒)
RedisData redisData = new RedisData();
// 设置热点店铺信息
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expirSeconds));
// 3.将包含热点的店铺信息和逻辑过期时间字段的RedisData对象转化为JSON字符串缓存到Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
第三步: 在测试类中运行测试方法,然后去Redis图形化页面查看存入的value
(含有data字段即shop对象和expireTime逻辑过期时间字段)
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private ShopServiceImpl shopService;
@Test
public void test(){
shopService.saveShop2Redis(1L,1000L);
}
}
{
"data": {
"area": "大关",
"openHours": "10:00-22:00",
"sold": 4215,
"images": "https://qcloud.dpfile.com/pc/jiclIsCKmOI2arxKN1Uf0Hx3PucIJH8q0QSz-Z8llzcN56-IdNpm8K8sG4.jpg",
"address": "金华路锦昌文华苑29号",
"comments": 3035,
"avgPrice": 80,
"updateTime": 1666502007000,
"score": 37,
"createTime": 1640167839000,
"name": "476茶餐厅",
"x": 120.149192,
"y": 30.316078,
"typeId": 1,
"id": 1
},
"expireTime": 1666519036559
}
第四步: 编写queryWithLogicalExpire
方法,在该方法中如果查到店铺信息返回shop查不到则返回null,最后在queryById
方法中做统一判断并返回结果类
//声明一个线程池,因为使用逻辑过期解决缓存击穿的方式需要新建一个线程来完成重构缓存
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Override
public Result queryById(Long id) {
// 测试使用逻辑过期的方式解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
// 如果shop等于null,表示数据库中对应店铺不存在或者缓存的店铺信息是空字符串
if (shop == null) {
return Result.fail("店铺不存在!!");
}
// shop不等于null,把查询到的商户信息返回给前端
return Result.ok(shop);
}
public Shop queryWithLogicalExpire(Long id) {
//1.先从Redis中查询对应的热点店铺缓存信息(包含过期时间),这里的常量值是固定的店铺前缀+查询店铺的Id
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.如果未命中即json等于null或命中了但json等于空字符串直接返回null(说明我们没有导入对应的key)
//""和null以及"/t/n(换行)"都会判定为空即返回false
if (StrUtil.isBlank(json)) {
return null;
}
//3.如果在Redis中查询到了热点店铺信息并且不是空字符串,则将JSON字符串转化为RedisData对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
//4.redisData.getData()的本质类型是JSONObject类型(还是JSON字符串)并不是Object类型对象,所以不能直接强转为Shop类型,需要使用工具类
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
//5.获取RedisData对象中封装的过期时间,判断是否过期
LocalDateTime expireTime = redisData.getExpireTime();
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return shop;
}
// 6.已过期,需要缓存重建,查询数据库对应的店铺信息然后写入Redis同时设置逻辑过期时间
// 6.1.获取互斥锁
boolean isLock = tryLock(LOCK_SHOP_KEY + id);
// 6.2.判断是否获取锁成功
if (isLock){
// 再次检测Redis缓存是否过期(双重检查),如果存在则无需重建缓存
// 如果Redis中缓存的店铺信息还是过期,开启独立线程,实现缓存重建(测试的时候可以休眠200ms),实际中缓存的逻辑过期时间设置为30分钟
CACHE_REBUILD_EXECUTOR.submit( ()->{// 开启独立线程
try{
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(LOCK_SHOP_KEY + id);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
public void saveShop2Redis(Long id, Long expirSeconds) {
// 1.根据店铺Id去数据库中查询店铺数据
Shop shop = getById(id);
// 由于本地查询数据库较快,模拟重建延时
Thread.sleep(200);
// 2.封装逻辑过期时间(当前时间转换为秒)
RedisData redisData = new RedisData();
// 设置热点店铺信息
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expirSeconds));
// 3.将包含热点的店铺信息和逻辑过期时间字段的RedisData对象转化为JSON字符串缓存到Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
测试逻辑过期解决缓存击穿
使用Jmeter进行测试所有的线程查不到数据时是否都会执行缓存重建还是返回旧数据,重建数据时如果数据不一致会不会更新Redis中的缓存数据
- 在测试类
HmDianPingApplicationTests
中使用saveShop2Redis
方法,向Redis中添加一个热点店铺信息的缓存同时设置逻辑过期时间为2秒 - 在MySQL数据库中手动修改这个热点店铺的信息,2秒后Redis中缓存的热点店铺数据逻辑过期且和MySQL数据库中对应的店铺信息不一致
- 当用户访问到过期的缓存数据的时候就需要来新开一个线程重构缓存数据,在重构之前只能获得脏数据(修改前的数据),重构完后才能获得新数据(修改后的数据)
开100个去访问逻辑过期数据
前面的用户只能看到脏数据,后面的用户看到的才是新数据