Redis缓存笔记

Redis缓存

缓存就是数据交换的缓冲区(Cache),是存储数据的临时地方,一般读写性能较高。使用缓存可以降低后端负载、提高读写效率、降低响应时间,但是增加了数据一致性成本、代码维护成本、运维成本。

Redis缓存的模型及流程如下图所示:

模拟查询商户的过程,将商户的数据存储在缓存中。

商户缓存1.0

根据以上流程,编写代码

public static final Long CACHE_SHOP_TTL = 30L;
public static final String CACHE_SHOP_KEY = "cache:shop:";
@Autowired
private StringRedisTemplate redisTemplate;


@Override
public Result queryById(Long id) {
    //从redis查询商户信息
    String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    //判断是否存在
    if(StrUtil.isNotBlank(shopJson)){
        //存在对应信息,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }

    //不存在则根据id查询数据库
    Shop shop = getById(id);
    //数据库中也不存在
    if(shop == null){
        return Result.fail("用户不存在");
    }
    //数据库中存在则写入Redis
    redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop));
    return Result.ok(shop);
}
商户缓存2.0

在1.0版本已经实现了将商户数据存储在缓存中,但是缓存数据并不是一成不变的,如果商户信息发生改变,那么缓存数据也要改变,这就涉及到了缓存的更新策略。

内存淘汰超时删除主动更新
说明不需要自己维护,利用Redis的内存淘汰机制,当内存不足自动淘汰部分数据。下次查询时更新缓存。给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存。编写业务逻辑,在修改数据库的同时,更新缓存。
一致性一般
维护成本

更新策略选择根据业务场景进行选择

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

主动更新策略:主动更新策略有多种,常用的是缓存调用者在更新数据库的同时更新缓存。而操作缓存和数据库又存在以下问题:

  1. 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效的写操作较多。(不推荐)
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存。(推荐)
  2. 如何保证缓存与数据库操作同时成功或者失败?

    • 单体系统可以将缓存与数据库操作放在一个事务内,保证同时成功或失败
    • 分布式系统利用TCC等分布式事务方案
  3. 先操作缓存还是先操作数据库?

    • 方案一:先删除缓存,再操作数据库
    • 方案二:先操作数据库,再删除缓存

    首先方案一和方案二在多线程情况下都有可能发生问题,但是方案二发生问题的可能性更低,因为缓存的操作速度是比数据库快的,而方案二发生的前提就是在更新缓存的几毫秒过程中,有另一个线程完成了更新数据库操作+删除缓存操作才会导致存入旧数据,但是这种情况发生的概率非常低。

因此最佳的方案是

  • 读操作:
    • 缓存命中直接返回
    • 缓存未命中查询数据库,并写入缓存设定超时时间
  • 写操作:
    • 先写数据库,再删除缓存
    • 确保数据库与缓存操作的原子性
@Autowired
private StringRedisTemplate redisTemplate;

@Override
public Result queryById(Long id) {
    //从redis查询商户信息
    String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    //判断是否存在
    if(StrUtil.isNotBlank(shopJson)){
        //存在对应信息,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }

    //不存在则根据id查询数据库
    Shop shop = getById(id);
    //数据库中也不存在
    if(shop == null){
        return Result.fail("用户不存在");
    }
    //数据库中存在则写入Redis并设置有效期30分钟
    redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Result.ok(shop);
}
//单体项目使用事务控制原子性
@Override
@Transactional
public Result update(Shop shop) {
    Long id = shop.getId();
    if(id == null){
        return Result.fail("店铺Id不能为空");
    }
    //1.更新数据库
    updateById(shop);
    //2.删除缓存
    redisTemplate.delete(CACHE_SHOP_KEY + id);
    return Result.ok();
}
缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远都不会生效,这些请求都会直接到数据库,当不断地有这种请求就会给数据库带来巨大压力。

在这里插入图片描述

常见的解决方案分为两种:

  • 缓存空对象

    不存在的时候直接缓存null空对象(“”),这样再次请求就不会访问数据库。

    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗、可能造成短期不一致

在这里插入图片描述

public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Autowired
    private StringRedisTemplate redisTemplate;


    @Override
    public Result queryById(Long id) {
        //从redis查询商户信息
        String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //判断Redis是否存在商户信息,如果为设置的空值这里无法判断,在下方再写一个if判断
        if(StrUtil.isNotBlank(shopJson)){
            //存在对应信息,则直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //判断命中的是否为空值,如果为"",那么shopJson就不等于null,""和null是不一样的
        if(shopJson != null){
            return Result.fail("店铺信息不存在");
        }
        //不存在则根据id查询数据库
        Shop shop = getById(id);
        //数据库中也不存在
        if(shop == null){
            //不存在则在Redis存储空值并设置有效期2min
            redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return Result.fail("商户不存在");
        }
        //数据库中存在则写入Redis,并设置有效期30min
        redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }
    //单体项目使用事务控制原子性
    @Override
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if(id == null){
            return Result.fail("店铺Id不能为空");
        }
        //1.更新数据库
        updateById(shop);
        //2.删除缓存
        redisTemplate.delete(CACHE_SHOP_KEY + id);
        return Result.ok();
    }
}
  • 布隆过滤

    在访问缓存之前通过布隆过滤器来判断数据是否存在,如果不存在则直接拒绝访问,避免一直访问缓存和数据库

    • 优点:内存占用小,没有多余key
    • 缺点:实现复杂,存在误判可能

在这里插入图片描述

缓存雪崩

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

解决方案:

  • 给不同key的TTL添加随机值,避免大量key同时失效
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存
缓存击穿

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

常见的解决方案有两种

  • 互斥锁

    避免大量的请求都尝试重建数据,使用互斥锁,只有一个线程能够进行重建。也就是当一个请求查询不到缓存,就获取互斥锁,然后写入缓存之后再释放锁。其余线程只能不断地休眠重试,处于等待状态。

    • 优点:相对于逻辑过期没有额外的内存消耗、保证一致性、实现简单、
    • 缺点:线程需要等待,性能受影响、可能有死锁风险

在这里插入图片描述

public Shop queryWithMutex(Long id){
    //从redis查询商户信息
    String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    //判断Redis是否存在商户信息,如果为设置的空值这里无法判断,在下方再写一个if判断
    if(StrUtil.isNotBlank(shopJson)){
        //存在对应信息,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return shop;
    }
    //判断命中的是否为空值,如果为"",那么shopJson就不等于null,""和null是不一样的
    if(shopJson != null){
        return null;
    }
    Shop shop = null;
    //实现缓存重建
    //获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    try {
        boolean isLock = tryLock(lockKey);
        //判断是否获取成功
        //如果获取锁不成功
        if(!isLock){
            //失败则休眠并重试
            Thread.sleep(1000);
            return queryWithMutex(id);
        }
        //如果获取锁成功,则查询数据库进行缓存重建
        shop = getById(id);
        //模拟重建的延迟
        Thread.sleep(200);
        //数据库中也不存在
        if(shop == null){
            //不存在则在Redis存储空值并设置有效期2min
            redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }
        //数据库中存在则写入Redis,并设置有效期30min
        redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);

    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        //释放锁
        unLock(lockKey);
    }
    return shop;
}
//获取锁
private boolean tryLock(String key){
    Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}
//释放锁
public void unLock(String key){
    redisTemplate.delete(key);
}
  • 逻辑过期

    不给热点数据设置一个真正的过期时间,而是逻辑上的过期,在存储数据的时候,增加一个字段存储过期时间(当前时间+期望存活的时间)这样key永远都不会过期。

    • 优点:线程无需等待,性能较好
    • 缺点:不保证一致性、有额外内存消耗、实现复杂
      在这里插入图片描述

在这里插入图片描述

首先为了在Redis中存储商户信息同时,还多出一个过期时间的字段,需要重新建一个类,来存储

public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

创建重建代码

public void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException {
        //查询店铺信息
        Shop shop = getById(id);
        //模拟缓存重建的时间
        Thread.sleep(200);
        //封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //写入redis
        redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
    }

编写单元测试,查看重建方法是否正确的同时,导入热点数据,这样后面进行查看才不会出错,因为之前的商户缓存是没有过期时间字段的

@Test
    void test() throws InterruptedException {
        shopService.saveShop2Redis(1L,20L);
    }

测试成功则编写具体的逻辑过期代码

public Shop queryWithLogicalExpire(Long id){
        //1.从redis查询商户信息
        String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //2.如果为空,为空则返回
        if(StrUtil.isBlank(shopJson)){
            return null;
        }
        //3.如果存在则把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        //4.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())){
            //4.1未过期返回店铺信息
            return shop;
        }

        //4.2已过期,则缓存重建
        //4.2.1 获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean tryLock = tryLock(lockKey);

        //4.2.2 判断是否获取锁成功
        if(tryLock){
            //获取锁成功,需要再次检查是否过期
            // 因为可能线程a发现缓存过期,发起重建请求获取锁的时候,线程b刚刚开始判断缓存是否过期,此时线程b同样认为缓存过期需要重建
            //在b获取锁之前,线程a刚好重建完成释放锁,那么b就顺利拿到锁,再次进行重建,可是此时a已经重建过了,不需要再次重建
            //从redis查询商户信息
            shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
            //如果为空,为空则返回
            if(StrUtil.isBlank(shopJson)){
                return null;
            }
            //如果存在则把json反序列化为对象
            redisData = JSONUtil.toBean(shopJson, RedisData.class);
            expireTime = redisData.getExpireTime();
            //判断是否过期
            if (expireTime.isAfter(LocalDateTime.now())){
                //未过期返回店铺信息
                return shop;
            }
            //4.2.3 过期则开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                //重建
                try {
                    this.saveShop2Redis(id,20L);
                }catch (Exception e){
                    throw new RuntimeException();
                }finally {
                    //释放锁
                    unLock(lockKey);
                }

            });
        }

        //4.2.4 如果拿不到锁就直接返回过期的商户信息
        return shop;
    }
缓存工具封装

方法一:将任意Java对象序列化为json并存储在String类型的key中,并且可以设置TTL过期时间

方法二:将任意Java对象序列化为json并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题

方法三:根据指定key查询缓存,并反序列化为指定类型,利用缓存空值方式,解决缓存穿透问题

方法四:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

@Component
@Slf4j
public class CacheClient {
    private final StringRedisTemplate redisTemplate;

    public CacheClient(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 将任意Java对象序列化为json并存储在String类型的key中,并且可以设置TTL过期时间
     * @param key key值
     * @param value value值
     * @param time 过期时间
     * @param timeUnit 过期时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit timeUnit){
        redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,timeUnit);
    }

    /**
     * 将任意Java对象序列化为json并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
     * @param key   key值
     * @param value     value值
     * @param time      过期时间
     * @param timeUnit  过期时间单位
     */
    public void setWithLogicalExpire(String key, Object value,Long time,TimeUnit timeUnit){
        //设置逻辑过期
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
        //写入redis
        redisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
    }

    /**
     * 根据指定key查询缓存,并反序列化为指定类型,利用缓存空值方式,解决缓存穿透问题
     * @param keyPrefix    String类型key值的前缀
     * @param id        唯一编码id(不限类型)
     * @param type  返回值类型
     * @param dbFallback    获取数据库内容函数
     * @param time      过期时间
     * @param timeUnit      过期时间单位
     * @return
     * @param <R>   返回值泛型
     * @param <ID>  返回值泛型
     */
    public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback
            ,Long time,TimeUnit timeUnit){
        String key = keyPrefix + id;
        //从redis查询商户信息
        String json = redisTemplate.opsForValue().get(key);
        //判断Redis是否存在商户信息,如果为设置的空值这里无法判断,在下方再写一个if判断
        if(StrUtil.isNotBlank(json)){
            //存在对应信息,则直接返回
            return JSONUtil.toBean(json, type);

        }
        //判断命中的是否为空值,如果为"",那么shopJson就不等于null,""和null是不一样的
        if(json != null){
            return null;
        }
        //不存在则根据id查询数据库
        R r = dbFallback.apply(id);
        //数据库中也不存在
        if(r == null){
            //不存在则在Redis存储空值并设置有效期2min
            redisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }
        //数据库中存在则写入Redis,并设置有效期
        this.set(key,r,time,timeUnit);
        return r;
    }


    //线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);


    //获取锁
    private boolean tryLock(String key){
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //释放锁
    public void unLock(String key){
        redisTemplate.delete(key);
    }


    /**
     * 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
     * @param keyPrefix     Stiring类型key的前缀
     * @param id        唯一标识可以为任意类型
     * @param type      返回值类型
     * @param dbFallback    获取数据库内容函数
     * @param time      过期时间
     * @param timeUnit  过期时间单位
     * @return
     * @param <R>   返回值类型
     * @param <ID>  返回值类型
     */
    public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallback
            ,Long time,TimeUnit timeUnit){
        String key = keyPrefix +id;
        //1.从redis查询商户信息
        String json = redisTemplate.opsForValue().get(key);
        //2.如果为空,为空则返回
        if(StrUtil.isBlank(json)){
            return null;
        }
        //3.如果存在则把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        //4.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())){
            //4.1未过期返回店铺信息
            return r;
        }

        //4.2已过期,则缓存重建
        //4.2.1 获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean tryLock = tryLock(lockKey);

        //4.2.2 判断是否获取锁成功
        if(tryLock){
            //获取锁成功,需要再次检查是否过期
            // 因为可能线程a发现缓存过期,发起重建请求获取锁的时候,线程b刚刚开始判断缓存是否过期,此时线程b同样认为缓存过期需要重建
            //在b获取锁之前,线程a刚好重建完成释放锁,那么b就顺利拿到锁,再次进行重建,可是此时a已经重建过了,不需要再次重建
            //从redis查询商户信息
            json = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
            //如果为空,为空则返回
            if(StrUtil.isBlank(json)){
                return null;
            }
            //如果存在则把json反序列化为对象
            redisData = JSONUtil.toBean(json, RedisData.class);
            expireTime = redisData.getExpireTime();
            //判断是否过期
            if (expireTime.isAfter(LocalDateTime.now())){
                //未过期返回店铺信息
                return r;
            }
            //4.2.3 过期则开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                //重建
                try {
                    //查询数据库
                    R r1 = dbFallback.apply(id);

                    this.setWithLogicalExpire(key,r1,time,timeUnit);

                }catch (Exception e){
                    throw new RuntimeException();
                }finally {
                    //释放锁
                    unLock(lockKey);
                }

            });
        }

        //4.2.4 如果拿不到锁就直接返回过期的商户信息
        return r;
    }

}

方法的调用方式:

//使用工具类解决缓存穿透
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, dataId -> getById(dataId), CACHE_SHOP_TTL, TimeUnit.MINUTES);

//使用工具类逻辑过期解决缓存击穿
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, dataId -> getById(dataId), CACHE_SHOP_TTL, TimeUnit.MINUTES);
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值