本节目标:
- 如何解决缓存一致性问题(缓存的更新策略)
- 删除缓存还是更新缓存
- 如何保存缓存与数据库的操作同时成功或失败
- 先操作缓存还是先操作数据库
- 高并发条件下引发的缓存问题:
- 缓存穿透
- 缓存雪崩
- 缓存击穿
缓存作用模型
引入店铺实战案例
针对上面的模型,我们对根据id查询店铺方法进行设计其流程
Controller层
java
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryShopById(id);
}
Service层
scss
Result queryShopById(Long id);
impl:
scss
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
// 1.根据店铺id,去redis查询店铺信息
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopInfo = stringRedisTemplate.opsForValue().get(key);
// 2。判断缓存是否命中
if (!StrUtil.isBlank(shopInfo)){
// 3. 缓存命中,直接返回店铺信息
Shop shop = JSONUtil.toBean(shopInfo, Shop.class);
return Result.ok(shop);
}
// 4.缓存未命中,根据店铺id去数据库中查询
Shop shop = getById(id);
// 5 判断店铺是否存在
if (ObjectUtil.isEmpty(shop)){
// 如果不存在直接抛出404
return Result.fail(SystemConstants.SHOP_NOT_EXIST);
}
// 6 如果存在,将数据返回,并将数据写入缓存
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(key,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
缓存引发的一致性问题(解决方案)
缓存虽然可以提高我们的读写速率,但在一定程度上也带来了一些开销,缓存与数据库的共同存在就会引发数据一致性的问题。针对这一问题,缓存做出了不同的更新策略。
缓存更新策略
内存淘汰(redis自带) | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 自动维护,基于redis内存淘汰机制,内存不足时淘汰部分数据。 | 缓存数据添加TTL,对过期的数据自动删除 | 编写业务逻辑,修改数据库提示,更新缓存 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
业务场景:
- 低一致性 需求:使用内存淘汰机制。例如店铺类型的查询
- 高一致性 需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
自动更新策略三种方案
- Cache Aside Pattern:由缓存的调用者,在更新数据库的同时更新缓存
- Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性,调用者调用该服务,无需关心缓存一致性问题。
- Write Behind Caching Pattern:调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保证最终一致性。
Cache Aside Pattern (⭐⭐)
思考:
- 删除缓存还是更新缓存
- 如何保存缓存与数据库的操作同时成功或失败
- 先操作缓存还是先操作数据库
先删缓存再操作数据库
先操作数据库再删除缓存
显然可以看到无论是选择哪种方式都会出现缓存一致性的问题。那我们到底是选择先删除缓存先,还是选择先操作数据库先呢?这个就关系到缓存于数据库之间的区别了。
- 对缓存的操作的微秒级的(快)
- 对数据库的操作速率是比较慢的
大概知道这两点之后,我们先来分析一下第一种情况先删缓存再操作数据库 的异常情况,对数据库的读写是比较久的,所以在更新完成数据库之前,会有其他的继承进来读数据库中的数据,而对缓存的写入也是比较快的,所以先删缓存再操作数据库 的异常在高并发的条件下出现的可能性是非常大的。而对于第二种情况先操作数据库再删缓存 的异常,redis的操作是微秒级的,要想在这期间对数据库进行操作发生的概率显然低的多。所以对于缓存一致性的问题,我们采用先操作数据库再删缓存。
Read/Write Through Pattern
Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略。
Write Behind Caching Pattern
Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。
利用主动更新策略解决缓存一致性实战
案例
给查询店铺的缓存添加超时剔除和主动更新的策略:
修改ShopController中的业务逻辑,满足下面需求:
- 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
- 根据id修改店铺时,先修改数据库,再删除缓存
查询店铺(读操作)
scss
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
// 1.根据店铺id,去redis查询店铺信息
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopInfo = stringRedisTemplate.opsForValue().get(key);
// 2。判断缓存是否命中
if (!StrUtil.isBlank(shopInfo)){
// 3. 缓存命中,直接返回店铺信息
Shop shop = JSONUtil.toBean(shopInfo, Shop.class);
return Result.ok(shop);
}
// 4.缓存未命中,根据店铺id去数据库中查询
Shop shop = getById(id);
// 5 判断店铺是否存在
if (ObjectUtil.isEmpty(shop)){
// 如果不存在直接抛出404
return Result.fail(SystemConstants.SHOP_NOT_EXIST);
}
// 6 如果存在,将数据返回,并将数据写入缓存
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(key,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
修改店铺(写操作)
less
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
return shopService.updateShop(shop);
}
less
@Transactional
@Override
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id == null){
return Result.fail(SystemConstants.SHOP_NOT_EXIST);
}
// 更新数据库
updateShop(shop);
// 删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+id);
return Result.ok();
}
缓存引发的三大异常
缓存穿透
缓存穿透 是指客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会失效,这些请求都会打到数据库.针对这种异常我们有两种解决方案:
- 缓存空值
- 布隆过滤
缓存空值
布隆过滤
实战优化
缓存雪崩
缓存雪崩 是指在同一时间段大量缓存key过期或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
解决方案:
- 设置不用的缓存TTL
- 利用redis集群提高服务的可用性 (针对宕机)
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
缓存击穿 是指大量的请求同时访问某个key时,key正好过期,导致大量请求到达数据库,带来巨大压力
缓存击穿问题也叫做热点key问题,就是一个被高并发访问且缓存重建业务比较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
解决方案:
- 互斥锁
- 逻辑过期
注意:缓存击穿与缓存穿透之间的区别在于,缓存击穿中请求的数据是数据库存在的,而缓存穿透请求的数据,是数据库不存在的。
互斥锁
优点:
- 没有额外的内存消耗
- 保证一致性
- 实现简单
缺点:
- 线程需要等待,性能受影响
- 可能有死锁的风险
逻辑过期
优点:
- 线程无需等待,性能好
缺点:
- 不保证一致性
- 有额外内存消耗
- 实现复杂
实战优化
基于互斥锁方式解决缓存击穿问题(还是之前根据id查询店铺的案例)
typescript
@Override
public Result queryShopById(Long id) {
// 解决缓存穿透的方案
// return queryWithPassThrough(id);
// 使用互斥锁解决缓存击穿
// 1.根据店铺id,到redis中查询店铺信息
return queryWithMutex(id);
}
private Result queryWithMutex(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopInfo = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存是否命中
if (StrUtil.isNotBlank(shopInfo)) {
// 命中则直接返回数据
Shop shop = JSONUtil.toBean(shopInfo, Shop.class);
return Result.ok(shop);
}
if(shopInfo != null){
return null;
}
// 未命中,获取锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
if (tryLock(lockKey)) {
// 获取到锁
shop = getById(id);
if (shop == null){
return Result.fail(SystemConstants.SHOP_NOT_EXIST);
}
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(key,RedisConstants.CACHE_SHOP_TTL,TimeUnit.MINUTES);
}else {
// 未获取到锁 重试
Thread.sleep(SystemConstants.SHOP_SLEEP_LOCK);
return queryWithMutex(id);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
return Result.ok(shop);
}
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
* @return
*/
private void unlock(String key){
stringRedisTemplate.delete(key);
}
测试: 我们使用jMeter工具来模拟多线程环境下高并发场景 只进行了一次数据库的查询
基于逻辑过期方式解决缓存击穿问题
如何添加逻辑过期时间:
添加一个对象RedisData,减少我们对原来数据结构的更改;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
scss
@Override
public Result queryShopById(Long id) {
// 解决缓存穿透的方案
// return queryWithPassThrough(id);
// 使用互斥锁解决缓存击穿
// 1.根据店铺id,到redis中查询店铺信息
Shop shop = queryWithLogicalExpire(id);
if (shop == null){
return Result.fail(SystemConstants.SHOP_NOT_EXIST);
}
return Result.ok(shop);
}
// 创建一个线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
private Shop queryWithLogicalExpire(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopInfo = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存是否命中
if (StrUtil.isBlank(shopInfo)) {
// 命中则直接返回数据
return null;
}
// 命中,
// 判断缓存是否过期
RedisData shopRedisData = JSONUtil.toBean(shopInfo, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) shopRedisData.getData(), Shop.class);
if(shopRedisData.getExpireTime().isAfter(LocalDateTime.now())){
return shop;
}
// 获取锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
if (tryLock(lockKey)) {
// 获取到锁 ,开启另一个线程来执行
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
return shop;
}
private void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException {
// 1.查询店铺数据
Shop shop = getById(id);
Thread.sleep(200);
if(shop == null){
throw new RuntimeException(SystemConstants.SHOP_NOT_EXIST);
}
// 2。封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
测试