目录
1、缓存的作用与成本
作用: 暂存数据处理结果,并提供下次访问使用。在很多场合,数据的处理或者数据获取可能非常费时,当对这个数据的请求量很大时,频繁的数据处理会消耗大量资源。缓存的作用就是将这些来之不易的数据存储起来,当再次请求此数据时,直接从缓存中获取而省略数据处理流程,从而降低资源的消耗提高响应速度
成本:数据不一致问题,缓存层和数据层有时窗口不一致,和更新策略有关;代码维护成本,原本只需读写MySQL就能实现功能,但加入了缓存之后就需要去维护缓存的数据,增加了代码复杂度
2、关于缓存双写一致性
相关图解:
相关代码实现:
从 redis 中进行查询,若缓存命中,则直接返回对应数据
//1.从 redis 中查询缓存
String shopJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//1.1 若缓存中有店铺的相关信息则进行返回,即缓存命中
if(StrUtil.isNotBlank(shopJSON)){
//1.2 这里需要将 JSON 类型的数据,转换为 java 类型的数据进行返回
Shop shop = JSONUtil.toBean(shopJSON, Shop.class);
return Result.ok(shop);
}
若 redis 中命中缓存失败,则需要从 mysql 中进行查询数据返回,并将查询到的数据写回 redis 中作为缓存
//2.缓存未命中,则从 mysql 中查询相关信息
Shop shop = shopService.getById(id);
//2.1 mysql 中存在相关信息,写入 redis 缓存,并则进行返回
if(!Objects.isNull(shop)){
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop));
}else {
//2.2 若不存在,则给出提示信息
Result.fail("店铺ID 为"+id+"的信息不存在!");
}
return Result.ok(shop);
这时需要考虑到一个问题,在数据库中的数据进行更新时,这时 redis 缓存那边保存的还是旧数据,需要将数据库中的数据更新到 redis 中,以保证双写一致性
2.1 四种更新策略
- 先更新数据库,再更新缓存
其中,可能出现的问题:
- 先更新mysql的某商品的库存,当前商品的库存是100,更新为99个。
- 先更新mysql修改为99成功,然后更新redis。
- 此时假设异常出现,更新redis失败了,这导致mysql里面的库存是99而redis里面的还是100 。
- 上述发生,会让数据库里面和缓存redis里面数据不一致,读到redis脏数据
- 先更新缓存,再更新数据库(不太推荐,业务上一般把mysql作为底单数据库,以保证最后解释)
其中,可能出现的问题:
【异常逻辑】多线程环境下,A、B两个线程有快有慢有并行
A update redis 100
B update redis 80
B update mysql 80
A update mysql 100
最终结果:mysql100,redis80
- 先删除缓存,再更新数据库
其中,可能出现的问题:
- 请求A进行写操作,删除redis缓存后,工作正在进行中,更新mysql......A还么有彻底更新完mysql,还没commit
- 请求B开工查询,查询redis发现缓存不存在(被A从redis中删除了)
- 请求B继续,去数据库查询得到了mysql中的旧值(A还没有更新完)
- 请求B将旧值写回redis缓存
- 请求A将新值写入mysql数据库
解决方法:使用延时双删策略
- 先更新数据库,再删除缓存(一般推荐使用这种方法)
其中,可能出现的问题:
假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。
解决方法:使用中间件,如MQ消息队列
- 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
- 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
- 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
- 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
2.2 最佳实践方案
- 低一致性需求:使用 redis 自带的内存淘汰机制
- 高一致性需求:主动更新,并以超时剔除作为兜底方案
读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并且写入缓存,加上超时时间
写操作:
- 先写数据库,再删除缓存
- 确保数据库与 redis 缓存的原子性
代码案例:
@Transactional //这里进行添加事务
public Result updateShop(Shop shop) {
if(!Objects.isNull(shop.getId())) {
//1.先更新数据库
shopService.updateById(shop);
//2.再删除 redis 缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
}else {
Result.fail("店铺的 ID 不能为空!");
}
return Result.ok();
}
3、关于缓存穿透
定义:当去查询一条记录,先查 redis 中,查询不到,然后再查询 mysql 中,也查询不到;即使这样,请求最终每次都会打到 mysql 数据库中,从而导致后台数据库的压力暴增,redis 形同虚设 ,接下来,介绍对应的解决方案🤔
3.1 使用 缓存空对象 方案
可以增强回写机制。mysql也查不到的话也让redis存入刚刚查不到的key并保护mysql。第一次来查询uid:abcdxxx,redis和mysql都没有,返回null给调用者,但是增强回写后第二次来查uid:abcdxxx,此时redis就有值了。可以直接从Redis中读取default缺省值(null)返回给业务应用程序,避免了把大量请求发送给mysql处理,打爆mysql。
但是,这样存在缺点,只能解决 key 值相同的问题,同时会带来额外的内存消耗与短期的不一致性(由于TTL过期时间)
代码案例: