【Redis应用】基于Redis实现缓存

本节目标:

  • 如何解决缓存一致性问题(缓存的更新策略)
    • 删除缓存还是更新缓存
    • 如何保存缓存与数据库的操作同时成功或失败
    • 先操作缓存还是先操作数据库
  • 高并发条件下引发的缓存问题:
    • 缓存穿透
    • 缓存雪崩
    • 缓存击穿

缓存作用模型

引入店铺实战案例

针对上面的模型,我们对根据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,对过期的数据自动删除 编写业务逻辑,修改数据库提示,更新缓存
一致性 一般
维护成本

业务场景:

  • 低一致性 需求:使用内存淘汰机制。例如店铺类型的查询
  • 高一致性 需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存

自动更新策略三种方案

  1. Cache Aside Pattern:由缓存的调用者,在更新数据库的同时更新缓存
  2. Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性,调用者调用该服务,无需关心缓存一致性问题。
  3. Write Behind Caching Pattern:调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保证最终一致性。

Cache Aside Pattern (⭐⭐)

思考:

  1. 删除缓存还是更新缓存
  2. 如何保存缓存与数据库的操作同时成功或失败
  3. 先操作缓存还是先操作数据库

先删缓存再操作数据库

先操作数据库再删除缓存

显然可以看到无论是选择哪种方式都会出现缓存一致性的问题。那我们到底是选择先删除缓存先,还是选择先操作数据库先呢?这个就关系到缓存于数据库之间的区别了。

  • 对缓存的操作的微秒级的(快)
  • 对数据库的操作速率是比较慢的

大概知道这两点之后,我们先来分析一下第一种情况先删缓存再操作数据库 的异常情况,对数据库的读写是比较久的,所以在更新完成数据库之前,会有其他的继承进来读数据库中的数据,而对缓存的写入也是比较快的,所以先删缓存再操作数据库 的异常在高并发的条件下出现的可能性是非常大的。而对于第二种情况先操作数据库再删缓存 的异常,redis的操作是微秒级的,要想在这期间对数据库进行操作发生的概率显然低的多。所以对于缓存一致性的问题,我们采用先操作数据库再删缓存。

Read/Write Through Pattern

Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略。

Write Behind Caching Pattern

Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。

利用主动更新策略解决缓存一致性实战

案例

给查询店铺的缓存添加超时剔除和主动更新的策略:

修改ShopController中的业务逻辑,满足下面需求:

  1. 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
  2. 根据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();
}

缓存引发的三大异常

缓存穿透

缓存穿透 是指客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会失效,这些请求都会打到数据库.针对这种异常我们有两种解决方案:

  1. 缓存空值
  2. 布隆过滤

缓存空值

布隆过滤

实战优化

缓存雪崩

缓存雪崩 是指在同一时间段大量缓存key过期或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力

解决方案:

  1. 设置不用的缓存TTL
  2. 利用redis集群提高服务的可用性 (针对宕机)
  3. 给缓存业务添加降级限流策略
  4. 给业务添加多级缓存

缓存击穿

缓存击穿 是指大量的请求同时访问某个key时,key正好过期,导致大量请求到达数据库,带来巨大压力

缓存击穿问题也叫做热点key问题,就是一个被高并发访问且缓存重建业务比较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击

解决方案:

  1. 互斥锁
  2. 逻辑过期

注意:缓存击穿与缓存穿透之间的区别在于,缓存击穿中请求的数据是数据库存在的,而缓存穿透请求的数据,是数据库不存在的。

互斥锁

优点:

  • 没有额外的内存消耗
  • 保证一致性
  • 实现简单

缺点:

  • 线程需要等待,性能受影响
  • 可能有死锁的风险

逻辑过期

优点:

  • 线程无需等待,性能好

缺点:

  • 不保证一致性
  • 有额外内存消耗
  • 实现复杂

实战优化

基于互斥锁方式解决缓存击穿问题(还是之前根据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));
}

测试

相关推荐
技术卷1 小时前
Redis数据库与GO(一):安装,string,hash
数据库·redis·golang
CoderJia程序员甲8 小时前
重学SpringBoot3-集成Redis(四)之Redisson
java·spring boot·redis·缓存
深山夕照深秋雨mo8 小时前
在Java中操作Redis
java·开发语言·redis
阳光阿盖尔8 小时前
redis——哨兵机制
数据库·redis·缓存·主从复制·哨兵
小小娥子8 小时前
【Redis】Hash类型的常用命令
数据库·spring boot·redis
盒马盒马8 小时前
Redis:cpp.redis++通用接口
数据库·c++·redis
qq_51583806 彩雷王11 小时前
1004-05,使用workflow对象创建http任务,redis任务
redis·网络协议·http
Wang's Blog11 小时前
Redis: Sentinel节点管理,故障迁移一致性以及TILT模式
redis·sentinel
九圣残炎12 小时前
【springboot】简易模块化开发项目整合Redis
spring boot·redis·后端
小登ai学习13 小时前
简单认识 redis -3 -其他命令
数据库·redis·缓存