【遇见青山】项目难点:缓存击穿问题解决方案
- 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));
// 写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
进行测试:
@SpringBootTest
@RunWith(SpringRunner.class)
public class QingShanApplicationTests {
@Resource
private ShopServiceImpl shopService;
/**
* 添加热点商户到Redis缓存的测试类
*/
@Test
public 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){
// 组装key
String 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;
}