【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));
}

测试

相关推荐
XDHCOM1 小时前
Redis远程连接命令详解,分享高效配置与安全实践技巧
前端·redis·安全
Rsun045519 小时前
Redis中实现访问量计数
数据库·redis·缓存
摇滚侠12 小时前
限流的方法,Redis 计算器限流算法、滑动时间窗口限流算法、漏漏桶限流算法、令牌桶限流算法,Java 开发
java·数据库·redis
fy1216312 小时前
Redis 下载与安装 教程 windows版
数据库·windows·redis
新缸中之脑17 小时前
Google TurboQuant 详解
数据库·redis·缓存
SadSunset20 小时前
第四章:Redis 数据结构与命令
数据结构·数据库·redis
爱敲代码的菜菜21 小时前
【Redis】Redis基本操作
java·数据库·redis·缓存·hash·zset
lclcooky21 小时前
docker下搭建redis集群
redis·docker·容器
雾喔1 天前
redis简单命令
数据库·redis·缓存