【遇见青山】项目难点:缓存击穿问题解决方案
【遇见青山】项目难点:缓存击穿问题解决方案
- 1.缓存击穿
- 互斥锁🔒方案
- 逻辑过期方案
- 2.基于互斥锁方案的具体实现
- 3.基于逻辑过期方案的具体实现
1.缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
互斥锁🔒方案
给线程添加互斥锁🔒
优点:
- 没有额外的内存消耗
- 保证一致性
- 实现简单
缺点:
- 线程需要等待,性能受影响
- 可能有死锁风险
逻辑过期方案
缓存中维护一个expire
字段,代表逻辑过期时间(并非TTL值)
例如:
优点:
- 线程无需等待,性能较好
缺点:
- 不保证一致性
- 有额外内存消耗
- 实现复杂
2.基于互斥锁方案的具体实现
需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题
架构流程图:
首先自定义获取锁和释放锁的方法:
/*** 尝试获取锁,解决缓存击穿问题方案** @param key key*/
private boolean tryLock(String key) {Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);return BooleanUtil.isTrue(aBoolean);
}/*** 删除锁,解决缓存击穿问题方案** @param key key*/
private void unlock(String key) {stringRedisTemplate.delete(key);
}
核心代码:
/*** 缓存击穿解决方案** @param id 商户id* @return 商户对象*/
public Shop queryWithMutex(Long id) {// 从redis查询商户缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);// 判断商户缓存是否存在if (StringUtils.isNotBlank(shopJson)) {// 此商户缓存存在,直接返回结果return JSONUtil.toBean(shopJson, Shop.class);}// 判断命中的是否为空值 "",防止缓存穿透if ("".equals(shopJson)) {return null;}Shop shop = null;try {// 实现缓存重建// 获取互斥锁boolean isLock = tryLock(LOCK_SHOP_KEY + id);// 判断是否取锁成功if (!isLock) {// 失败,则进入休眠并重试Thread.sleep(50);return queryWithMutex(id);}// 缓存中商户信息不存在,查询数据库shop = getById(id);// 模拟重建的延时Thread.sleep(200);// 查询数据库不存在,返回错误if (shop == null) {// 将null值写入Redis,防止缓存穿透问题stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}// 查询数据库存在,写入数据到Redis中stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {// 释放互斥锁unlock(LOCK_SHOP_KEY + id);}// 返回数据给前端return shop;
}
使用JMeter做压力测试:
这里开1000个线程延时5秒:
结果:
全部请求成功!
平均QPS 200:
3.基于逻辑过期方案的具体实现
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
设计架构图:
这里,我们如何将过期时间字段加入Redis中呢,为了不对原有的代码进行修改,最好的解决办法是封装一个带有过期时间的实体类:
/*** 逻辑过期时间的实体支持*/
@Data
public class RedisData {private LocalDateTime expireTime;private Object data;
}
创建一个将商户预热的方法:
/*** 将热点商户加入到缓存中,进行预热** @param id 商户id* @param expireSeconds 逻辑过期时间*/
public void saveShopToRedis(Long id, Long expireSeconds) {// 查询商户数据Shop shop = getById(id);// 封装逻辑过期时间对象RedisData redisData = new RedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));// 写入RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
进行测试:
@SpringBootTest
@RunWith(SpringRunner.class)
public class QingShanApplicationTests {@Resourceprivate ShopServiceImpl shopService;/*** 添加热点商户到Redis缓存的测试类*/@Testpublic void testSaveShop() {shopService.saveShopToRedis(1L, 10L);}
}
添加热点缓存成功!
核心实现:
// 生成线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);/*** 逻辑过期解决缓存击穿问题* @param id 商户id* @return 商户对象*/
public Shop queryWithLogicalExpire(Long id){// 组装keyString key = RedisConstants.CACHE_SHOP_KEY + id;// 去redis中读取数据String value = stringRedisTemplate.opsForValue().get(key);// 如果缓存未命中,说明访问的不是热点数据,直接返回空if(StrUtil.isBlank(value)){return null;}// 缓存命中后,还需要先判断数据有没有过期RedisData redisData = JSONUtil.toBean(value, RedisData.class);// 获取数据信息,由于数据存进去时是object类型,这里需要做一下处理Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);// 获取数据的逻辑过期时间LocalDateTime expireTime = redisData.getExpireTime();// 如果数据没有过期,就直接返回if(expireTime.isAfter(LocalDateTime.now())){return shop;}// 如果数据已过期,则尝试获取互斥锁String lockKey = RedisConstants.LOCK_SHOP_KEY+id;boolean tryLock = tryLock(lockKey);if(tryLock){// 获取锁成功则开辟一条独立线程执行缓存重建CACHE_REBUILD_EXECUTOR.submit(()->{try {// 再次检查缓存有没有过期,防止在高并发环境下缓存多次重建LocalDateTime time = JSONUtil.toBean(stringRedisTemplate.opsForValue().get(key), RedisData.class).getExpireTime();if(time.isAfter(LocalDateTime.now())){// 数据没过期则直接结束return;}// 调用重建缓存的方法saveShopToRedis(id,10l);} catch (Exception e) {throw new RuntimeException(e);} finally {//释放锁操作unlock(lockKey);}});}// 返回过期数据return shop;
}