一. 缓存更新策略
1.1 常见更新策略
- 内存淘汰(利用redis的内部淘汰机制)
- 超时剔除(给缓存数据添加TTL时间)
- 主动更新(编写业务逻辑,在修改数据库的同时,更新缓存)
1.2 主动更新策略
一般会选择第一种方案
1.3 主动更新策略中的原子性问题和数据不一致问题
1.3.1 数据不一致问题(线程安全问题)
先写数据库,再删缓存的线程安全(缓存数据不一致问题)高一点点(比先删缓存,再删数据库)
解释:
- 若先删缓存,在删除完成到数据库更新的期间里,只要其中过来查询,就会将旧的数据重新写到缓存里,造成数据的不一致现象;
- 而如果先修改数据库,则必须要在一系列操作(更新数据库)之前恰好缓存失效,查询到旧数据后,要正好在删除缓存后写入(不然最后还是会被删除),发生概率较低
1.3.2 原子性问题(如何保证删除缓存和更新数据库的操作同时成功或失败)
确保数据库和缓存删除操作的原子性:
- 单体系统:将缓存与数据库操作放在一个事务里
- 分布式系统:利用TCC等分布式事务方案
e.g:
@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不能为空!");
}
//1. 更新数据库
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+id);
return Result.ok();
}
二. 缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会达到数据库。 不断发起这样的请求,会给数据库带来巨大压力
解决方案:
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗 => 设置TTL
- 可能造成短期的不一致
- 布隆过滤 是一种算法
- 优点:内存占用较少,没有多余的key
- 缺点:
- 实现复杂 => redis自带的bitmap可以帮助简化开发
- 存在误判可能
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
//缓存空对象方法实现
public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1. 从redis中查询商铺缓存 (以json形式存储)
String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(cacheShopJson)){
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(cacheShopJson, Shop.class);
return Result.ok(shop);
}
// 判断命中是否是空值
if(cacheShopJson != null){
//说明是缓存空值"" 返回错误
return Result.fail("店铺不存在!");
}
// 4. 不存在,根据id查询数据库
Shop shop = getById(id);
// 5. 不存在,返回错误
if(shop == null){
// 将空值写入redis (避免缓存击穿问题)
stringRedisTemplate.opsForValue().set(key,"", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return Result.fail("店铺不存在!");
}
// 6. 存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. 返回数据
return Result.ok(shop);
}
👆被动方案
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验(如0)
- 加强用户权限校验
- 做好热点参数的限流
三. 缓存雪崩
缓存雪崩是指同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性(哨兵机制 主从)
- 给缓存业务添加降级限流策略
- 给业务增加多级缓存(web,nginx,redis,jvm)
四. 缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的Key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
(缓存雪崩:全部数据TTL过期;缓存击穿:少部分热点数据TTL过期)
解决方案:
-
互斥锁(更看重 一致性)
-
逻辑过期(更看重 可用性)
在活动开始时将数据缓存到redis中(缓存预热),不设置TTL,而是将TTL作为缓存字段,通过程序来进行逻辑上的过期判断,在理论上缓存是一直存在的(旧数据),在活动结束后将热点数据的缓存删除。
解决方案实现:
- 互斥锁
首先,因为要在获得锁后休眠、重试,所以不能使用synchronized
或lock
,因为这两个是会导致等待。所以我们要自定义锁,而redis的setnx
命令是只在key
不存在时才执行,所以可以以此为锁,释放锁的话只需要del
这个锁的key
即可。
// redis实现互斥锁机制
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag); //转成基本类型返回,如果直接返回,拆箱的时候可能会造成空指针
}
private void unLock(String key){
stringRedisTemplate.delete(key);
}
/**
*@Description: 互斥锁解决缓存击穿
*@Param: [id]
*/
public Shop queryWithMutex(Long id){
String key = RedisConstants.CACHE_SHOP_KEY + id;
boolean isWait = true;
String lockKey = "lock:shop:"+id;
Shop shop = null;
try {
while (isWait){
// 1. 从redis中查询商铺缓存(命中)
String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(cacheShopJson)){
shop = JSONUtil.toBean(cacheShopJson, Shop.class);
return shop;
}
//实现缓存重建
//1. 获取互斥锁
boolean isLock = tryLock(lockKey);
//2.判断是否获取成功
if(!isLock){
//2.1 失败,则休眠并重试
Thread.sleep(50);
}else {
isWait = false;
}
}
//2.2 成功
//2.2.1 再次检查缓存,做doublecheck,防止是刚好前一个更新完缓存后放了锁,然后被你拿到了
String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(cacheShopJson)){
shop = JSONUtil.toBean(cacheShopJson, Shop.class);
unLock(lockKey); //放锁
return shop;
}
//2.2.2 根据id查询数据库
shop = getById(id);
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放互斥锁
unLock(lockKey);
}
//返回
return shop;
}
- 逻辑过期
因为实际上不存在缓存不命中的可能,所以我们只需要在命中后判断缓存是否过期
未过期,返回
过期,尝试获取互斥锁,成功了进行重建缓存(开启独立的线程执行,自己返回旧数据),失败了直接返回旧数据。
/**
*@Description: 逻辑过期 实体类
*@Param:
*@return:
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
/**
*@Description: 活动热点数据缓存预热
*@Param: [id, expirSeconds]
*@return: void
*/
public void saveShop2Redis(Long id, Long expirSeconds){
String key = RedisConstants.CACHE_SHOP_KEY+id;
Shop shop = getById(id);
RedisData redisData = new RedisData(LocalDateTime.now().plusSeconds(expirSeconds), shop);
String jsonStr = JSONUtil.toJsonStr(redisData);
stringRedisTemplate.opsForValue().set(key,jsonStr);
}
//线程池
private static final ExecutorService CACHE_REBUILD_EXXECUTOR = Executors.newFixedThreadPool(10);
/**
*@Description: 逻辑过期解决缓存穿透问题
*@Param: [id]
*/
public Shop queryWithLogicExpire(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1. 从redis中查询
String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isBlank(cacheShopJson)) {
//未命中 返回空
return null;
}
// 3. 存在,反序列化为对象
RedisData redisData = JSONUtil.toBean(cacheShopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 4. 判断是否过期
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 判断是否获取锁成功
// 6.3 成功 开启线程执行重建过程
if(isLock){
//可以在次再次检测redis缓存是否过期,doublecheck
CACHE_REBUILD_EXXECUTOR.submit(()->{
// 重建缓存
this.saveShop2Redis(id,20L);
//假设长时间的数据更新
//sleep(200);
//释放锁
unLock(lockKey);
});
}
// 6.4 返回过期商品信息
return shop;
}