缓存击穿问题(热点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);
}
第一步: 在RedisConstants
中声明Redis缓存相关的常量key的和有效时间
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;
// 店铺信息的key
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final Long CACHE_SHOP_TTL = 30L;
// 缓存null的时间
public static final Long CACHE_NULL_TTL = 2L;
// 店铺类型信息的key,一般不会频繁变化
public static final String CACHE_SHOP_TYPE_KEY = "cache:shop:type";
// 互斥锁的key
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
}
第二步: 在ShopServiceImpl
中单独实现负责解决缓存击穿
问题的方法queryWithMutex
最终需要返回Reids中缓存的店铺信息
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@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);
}
// todo: 再次检测Redis中缓存的信息是否存在,如果存在则无需重建缓存
// ........................
//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;
}
}
第三步: 在queryById
方法中对查询的结果做统一判断并返回结果类
@Override
public Result queryById(Long id) {
// 使用互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
// 如果shop等于null,表示数据库中对应店铺不存在或者缓存的店铺信息是空字符串
if (shop == null) {
return Result.fail("店铺不存在!!");
}
// shop不等于null,把查询到的商户信息返回给前端
return Result.ok(shop);
}
测试互斥锁
使用Jmete开100个线程访问查询店铺信息的接口, 然后将Redis中缓存的对应热点店铺的信息删除模拟TTL到期
查看后台日志只输出了一条SQL语句则说明我们的互斥锁是生效的,没有造成大量用户都去数据库执行SQL语句查询店铺的信息
逻辑过期(缓存预热)
缓存击穿问题主要原因是由于我们对key设置了过期时间,假设我们不设置过期时间其实就不会有缓存击穿的问题,但是不设置过期时间,缓存数据又会一直占用内存
- 优点: 通过异步线程构建缓存,避免其他线程出现等待,提高了性能
- 缺点: 构建异步线程
业务复杂
; 需要维护一个expire字段增加额外内存消耗
;数据不一致
因为在异步线程构建完缓存之前其他线程返回的都是过期数据(脏数据)
逻辑过期实现
实现根据店铺Id查询商铺
的业务,需要提前添加热点key进行缓存预热, 然后基于逻辑过期方式来解决缓存击穿问题
第一步: 因为现在Redis中缓存店铺信息需要带上过期时间
属性,可以通过聚合的方式新建一个实体类包含店铺信息和过期时间
字段, 这样可以不侵入原来代码
@Data
public class RedisData {
// 过期时间
private LocalDateTime expireTime
// 店铺信息(万能的Object)
private Object data;
}
第二步: 在ShopServiceImpl
编写queryWithLogicalExpire
方法返回Redis中缓存的店铺信息(可能为过期数据)
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
//声明一个线程池,因为使用逻辑过期解决缓存击穿的方式需要新建一个线程来完成重构缓存
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) {
//1.先从Redis中查询对应的热点店铺缓存信息(包含过期时间),这里的常量值是固定的店铺前缀+查询店铺的Id
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.如果未命中即json等于null或命中了但json等于空字符串,符合条件直接返回null,说明我们没有导入对应的key
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){
// todo: 再次检测Redis中缓存的信息是否过期,如果没有过期则无需重建缓存
// ........................
// 如果Redis中缓存的店铺信息还是过期,开启独立线程实现缓存重建,
CACHE_REBUILD_EXECUTOR.submit( ()->{// 开启独立线程
try{
// 查询数据库中的店铺信息并设置逻辑过期时间封装为RedisData对象存入Redis
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(LOCK_SHOP_KEY + id);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
}
// 重新
第三步: 编写方法根据店铺Id查询数据库中的店铺信息并设置逻辑过期时间
封装为RedisData
对象存入Redis,实际中缓存的逻辑过期时间设置为30分钟
public void saveShop2Redis(Long id, Long expirSeconds) {
// 1.根据店铺Id去数据库中查询店铺数据
Shop shop = getById(id);
// 由于本地查询数据库较快,模拟缓存重建延时200mms
Thread.sleep(200);
// 2.封装RedisData
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));
}
第四步: 在queryById
方法中对查询的结果做统一判断并返回结果类
@Override
public Result queryById(Long id) {
// 测试使用逻辑过期的方式解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
// 如果shop等于null,表示数据库中对应店铺不存在或者缓存的店铺信息是空字符串
if (shop == null) {
return Result.fail("店铺不存在!!");
}
// shop不等于null,把查询到的商户信息返回给前端
return Result.ok(shop);
}
测试逻辑过期
第一步: 在test
包下编写测试方法调用saveShop2Redis
方法向Redis中添加一个热点店铺信息的缓存同时设置缓存逻辑过期时间为2秒
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private ShopServiceImpl shopService;
@Test
public void test(){
shopService.saveShop2Redis(1L,2L);
}
}
第二步: 查看在Redis中存入的缓存对象RedisData
及其包含的data和expireTime
属性的值
{
"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": "102茶餐厅",
"x": 120.149192,
"y": 30.316078,
"typeId": 1,
"id": 1
},
"expireTime": 1666519036559
}
第三步: 在MySQL数据库中手动修改Reids中缓存的对应热点店铺信息,2秒后缓存的店铺信息将会逻辑过期,此时数据库中保存的和Redis中缓存的店铺信息出现不一致
第四步: 使用Jmeter在1秒钟内执行100个线程访问查询店铺信息的接口
第五步: 在控制台中最终最会执行一条查询店铺信息的SQL; 在缓存数据重构完之前只能获得脏数据(修改前的数据),重构完后才能获得新数据(修改后的数据)