Redis缓存数据 | 黑马点评

时间:2023-01-23 13:50:56

目录

一、什么是缓存

二、添加Redis缓存操作

三、缓存更新策略

缓存的更新策略

​编辑

业务场景

主动更新策略

案例

四、缓存穿透

1、是什么

2、解决方案

(1)缓存空对象

(2)布隆过滤器 

(3)其他策略

3、实践

五、缓存雪崩

1、是什么

2、解决方案

六、缓存击穿

1、是什么

2、解决方案

​编辑

互斥锁

逻辑过期

3、实践-互斥锁

用什么锁

实现代码

4、实践-逻辑删除

七、缓存工具封装


一、什么是缓存

缓存就是数据交换的缓冲区,是临时存储数据的地方,一般读写性能较高。

缓存的作用:降低后端负载,提高读写效率,降低响应时间

缓存的成本:数据一致性成本、代码维护成本、运维成本

二、添加Redis缓存操作

Redis缓存数据 | 黑马点评

Redis缓存数据 | 黑马点评

三、缓存更新策略

缓存的更新策略

Redis缓存数据 | 黑马点评

业务场景

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

主动更新策略

Redis缓存数据 | 黑马点评

操作缓存和数据库时要考虑3个问题:

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

更新缓存:每次更新数据库都更新缓存,无效写操作很多但是一直没查就很亏

删除操作:更新数据库的时删除缓存,下次查询才有(推荐)

2、如何保证缓存与数据库操作同时成功或失败?

单体系统:将缓存和数据库操作放到一个事务里面

分布式系统:利用tcc等分布式事务方案

3、先操作缓存还是先操作数据库?(重点)(面试题)

Redis缓存数据 | 黑马点评

先删除缓存:a在删除完缓存后,正在改数据库,这时候来了线程查询数据库,然后更新到缓存,这时候如果a更新完数据库,缓存和数据库就是不一致了

先改数据库:如果缓存过期失效了,a在去数据库查的过程中,b改了数据库,然后更新缓存,这时候a查到数据再更新缓存。缓存和数据库就不一致了。但是这种情况发送的概率远远小于前者。

因为后面的情况发生概率小,要满足很多条件,所以推荐用后者。推荐先改数据库再改缓存

案例

我们要加上事务注解,保证他们同时成功或同时失败,删之前也要判断一下看id是不是空

Redis缓存数据 | 黑马点评

四、缓存穿透

1、是什么

缓存穿透是指客户端请求的数据在缓存和数据库都没有,这样缓存永远不会生效,这些请求都会打到数据库。

2、解决方案

(1)缓存空对象

第一次他随便乱打个没有的id来查询缓存和数据库,我们就会返回null,然后把null也存到缓存中。

这样再他下一次带这个id来请求的时候,就会从缓存中拿到null。

优点:简单实现,维护方便

缺点:

额外的内存消耗,缓存了很多没用的数据(可以通过设置时间来缓解)

可能造成短期的不一致。因为是随便乱打的id发的请求,我们帮这个id存缓存了,以后要是真的有这个id了,到时候拿这个id来查也是null(也是控制缓存的时间来缓解,也可以当我们新增数据的时候,主动插入缓存替换掉null)

(2)布隆过滤器 

在请求到缓存前加层布隆过滤器,如果这个数据存不存在,直接拒绝,不给继续请求。

布隆过滤器是怎么知道在不在呢?

是通过hash算法算出哈希值,将这些哈希值换成2进制位保存到布隆过滤器,判断数据是否存在的时候就判断对应的位置是0还是1。这种统计不是百分百准确

不存在的时候百分百不存在,说存在不一定存在,可以起到一定过滤作用。还是有一定击穿风险

优点:内存占用少,没有存多余的key

缺点:实现复杂,存在误判的可能。

(3)其他策略

  • 做好数据的基础格式校验
  • 增强id复杂度,避免被猜测id的规律
  • 加强用户权限校验
  • 做好热点参数的限流

3、实践

Redis缓存数据 | 黑马点评

以缓存null值为例,在查询的接口做更改:

当我们查到数据不存在的时候,将这个id作为key,value为空值存储到redis中。

在判断完商品是否存在后加上判断查出来的是否为空值,为空就返回不存在

五、缓存雪崩

1、是什么

缓存雪崩是指同一时段大量的缓存key同时失效或redis服务宕机,导致大量请求打崩数据库

2、解决方案

给不同的key的TTL(过期时间)添加随机值(可以设置成30分钟到40分钟之间的随机数)

利用Redis集群提高服务器的可用性(哨兵机制)

给缓存业务添加降级限流策略(比如当服务器出现问题的时候,拒绝服务,牺牲部分服务来保证安全)

给业务添加多级缓存(nginx、redis、jvm都可以添加缓存,最后才到数据库)

六、缓存击穿

1、是什么

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

高并发访问可以理解为做活动的商品,同一时间有无数的请求来访问这个商品。

缓存重建比较复杂就是缓存可能会过期失效,失效的时候重新添加到缓存的时间很久(可能业务非常复杂,要多表查询运算才能得到的结果)

2、解决方案

Redis缓存数据 | 黑马点评

互斥锁

如果查到缓存中没有,就加锁查数据库冲击,写入缓存之后再释放锁。这个期间内,所有访问的请求都没有拿到锁只能等待重试,直到缓存更新完毕读取缓存。

优点:没有额外的内存消耗、保证缓存和数据库一致性,实现简单

缺点:线程需要等待,性能受影响用户体验不好。可能有死锁的风险

逻辑过期

设置缓存过期时间的时候,不是真正的设置,而是设置在value里面,如果查询缓存发现逻辑删除时间过期了,就new一个新的线程加锁来查询数据库更新缓存,更新完毕后才释放锁,自己返回旧数据。这个期间内其他线程都没有拿到锁,都返回旧数据,直到更新完毕

优点:线程不用等待,性能好。

缺点:不保证一致性,有额外内存消耗,实现复杂。

3、实践-互斥锁

用什么锁

我们平时用synchronized和lock锁的时候,如果没有拿到锁就要等待,而我们这次是没有拿到锁和拿到锁都有不同的操作要执行。所有用不了这两个

我们可以用redis里面String数据类型的setnx命令来设置锁(当key不存在的时候才可以赋值,存在就不能赋值了)

加锁:setnx lock 1

释放锁:del lock

为了避免死锁,我们通常还会设置上有效期

加锁和释放锁的方法:

Redis缓存数据 | 黑马点评

实现代码

Redis缓存数据 | 黑马点评

Redis缓存数据 | 黑马点评

 最后开启JMeter做测试,模拟1000个线程发请求,发现能扛住200qps

4、实践-逻辑删除

Redis缓存数据 | 黑马点评

要用逻辑删除肯定要多个属性过期时间,我们就可以采用这种方式多增加一个类,这个类有过期时间属性,然后多一个object类,存放要存入redis的数据,这样就不用在原来的类上改动

Redis缓存数据 | 黑马点评

编写添加到redis的方法

这个plusSeconds就是设置过期时间,用户参数传进来

Redis缓存数据 | 黑马点评

重写查询的方法

Redis缓存数据 | 黑马点评

Redis缓存数据 | 黑马点评

七、缓存工具封装

我们发现缓存的操作还是挺复杂的,我们将来使用的时候不可能每次都这样重新写一遍。 我们可以将这些解决方案封装成工具。

我们封装四个方法,1和3可以是平时添加缓存用的,2和4一般是缓存热点数据用防止击穿问题

1、封装存储方法:将任意java对象序列化为json并存储在string类型的key中,并且设置ttl过期时间

2、封装存储方法(逻辑过期):将任意java对象序列化为json并存储在string类型的key中,并且设置逻辑过期时间,用于处理缓存击穿问题

3、查询缓存(空值解决击穿):根据指定key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透

4、查询缓存(逻辑删除解决穿透):根据指定key查询缓存,并反序列化为指定类型,需要用逻辑过期解决缓存击穿

工具类:

@Slf4j
@Component
public class CacheClient {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);


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

    /**
     * 向缓存中添加 key
     * */
    public void set(String key, Object value, Long time, TimeUnit unit){
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 设置逻辑过期时间
     * */
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 缓存穿透
     * */
    public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix + id;
        // 1、根据 Id 查询 Redis
        String json = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        // 2、判断 shopJSON 是否为空
        if (StrUtil.isNotBlank(json)) {
            // 3、存在,直接返回
            R r = JSONUtil.toBean(json, type);
            return r;
        }

        // 增加对空字符串的判断
        if(json != null){
            return null;
        }

        // 4、不存在,查询数据库
        R r = dbFallback.apply(id);

        // 5、不存在,返回错误
        if(r == null){
            // 店铺不存在时,缓存空值
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }

        // 6、存在,写入 Redis
        this.set(key, r, time, unit);
        return r;
    }


    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id,Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix + id;
        // 1、根据 Id 查询 Redis
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2、判断 shopJSON 是否为空
        if (StrUtil.isBlank(json)) {
            // 3、存在,直接返回
            return null;
        }

        // 命中,需要先把 json 反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);

        // 判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())){
            return r;
        }

        // 已过期,需要缓存重建
        // 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;

        boolean isLock = tryLock(lockKey);
        if (isLock) {
            // 开辟独立线程
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 缓存重建
                    R r1 = dbFallback.apply(id);
                    this.setWithLogicalExpire(key, r1, time, unit);
                }catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unLock(lockKey);
                }
            });
        }

        // 6、存在,写入 Redis
        this.set(key, r, time, unit);
        return r;
    }



    // 获取锁
    private boolean tryLock(String key){
        Boolean isTrue = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(isTrue);
    }

    // 释放锁
    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }

}

调用的service

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

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private CacheClient cacheClient;

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    @Override
    public Result queryById(Long id) {
        // 解决缓存穿透
//        Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        // 互斥锁解决缓存击穿
//        Shop shop = queryWithMetux(id);

        // 使用逻辑过期时间解决缓存击穿问题
//        Shop shop = queryWithLogicalExpire(id);
        Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        if(shop == null){
            return Result.fail("店铺不存在!");
        }

        // 7、返回
        return Result.ok(shop);
    }

    public Shop queryWithLogicalExpire(Long id){
        // 1、根据 Id 查询 Redis
        String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        // 2、判断 shopJSON 是否为空
        if (StrUtil.isBlank(shopJson)) {
            // 3、存在,直接返回
            return null;
        }

        // 命中,需要先把 json 反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);

        // 判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())){
            return shop;
        }

        // 已过期,需要缓存重建
        // 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;

        boolean isLock = tryLock(lockKey);
        if (isLock) {
            // 开辟独立线程
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                    try {
                        // 缓存重建
                        this.saveShop2Redis(id, 20L);
                    }catch (Exception e) {
                        throw new RuntimeException(e);
                    } finally {
                        unLock(lockKey);
                    }
            });
        }

        // 6、存在,写入 Redis
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        return shop;
    }

}