Redis 缓存穿透击穿和雪崩

一、缓存穿透

1.1 缓存穿透概念

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

1.2 解决方案

        常见的解决方案有两种,分别为缓存空对象和布隆过滤

1.2.1 缓存空对象

        缓存空对象的优点为实现简单,维护方便;缺点为会造成额外的内存消耗、造成短期的数据不一致问题。整体架构如下图:

1.2.2 布隆过滤器

        布隆过滤器的优点为内存占用少,没有多余 key;缺点为实现复杂,存在误判的可能。整体架构如下图:

1.3 案例演示

        修改 ShopController 中的业务逻辑,通过缓存空对象来解决缓存穿透的问题,整体的架构图如下所示:

         代码如下:

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

    @Resource
    StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryShopById(Long id) {

        String key = "cache:shop:"+id;
        // 1、从 redis 查询商铺缓存
        String s = stringRedisTemplate.opsForValue().get(key);
        // 2、判断是否存在
        if(StrUtil.isNotBlank(s)){
            // 3、存在,直接返回
            Shop shop = JSONUtil.toBean(s, Shop.class);
            return Result.ok(shop);
        }
        // 判断是否命中的是空值
        if(s != null){
            // 返回错误信息
            return Result.fail("店铺信息不存在");
        }
        // 4、不存在,根据 id 查询数据库
        Shop shop = getById(id);
        // 5、不存在,直接返回错误信息
        if(shop == null){
            // 将空值写入 redis,并设置有效期
            stringRedisTemplate.opsForValue().set(key,"",2,TimeUnit.MINUTES);
            return Result.fail("店铺不存在");
        }
        // 6、存在,写入 redis
        String s1 = JSONUtil.toJsonStr(shop);
        // 7、返回
        stringRedisTemplate.opsForValue().set(key,s1,30, TimeUnit.MINUTES);
        return Result.ok(shop);
    }

1.4 总结

        问:缓存穿透产生的原因是什么?

        答:用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力。

        问:缓存穿透的解决方案有哪些?

        答:缓存 null 值、布隆过滤、增强id的复杂度,避免被猜测 id 规律、做好数据的基础格式校验、加强用户权限校验、做好热点参数的限流等。

二、缓存雪崩

2.1 缓存雪崩概念

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

2.2 解决方案

        1、给不同的 Key TTL 添加随机值

        2、利用 Redis 集群提高服务的可用性

        3、给缓存业务添加降级限流策略

        4、给业务添加多级缓存

三、缓存击穿

3.1 缓存击穿概念

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

3.2 解决方案

        常见的解决方案有两种,一种是互斥锁,一种是逻辑过期。

3.2.1 互斥锁

        线程 1 发现缓存未命中则先获取锁,只有获取锁成功了才可以重建缓存数据,重建完成后将数据写入缓存,最后释放锁。其他的线程(线程2)在释放锁之前只能不断重试,只有等到线程1释放锁之后才可以命中缓存。

        当有无数的请求都并发的执行下图种的业务,只会有一个线程来完成这个任务,其他的线程都是等待,阻塞和重试。

        这种方式最大的问题就是互相等待,性能可能会差一些。

3.2.2 逻辑过期

        逻辑过期可以认为是永不过期,设置 key 时不设置过期时间,但是在存储数据的时候多添加一个 expire 时间(当前时间 + TTL 时间),用于记录这个 key 理论上的过期时间。

        当线程1查询缓存发现逻辑时间已过期了,为了避免其他线程也来重新缓存,此时也需要获取锁,为了避免获取锁等待的时间过长,会开启一个新的线程(线程2)去做查询数据和缓存重建的功能。

        在线程2写入缓存之前的这一段时间,其他的线程则返回过期数据返回,不再等待。

3.2.3 两种方式对比

解决方案互斥锁逻辑过期
优点

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

线程无需等待,性能较好
缺点线程需要等待,性能受影响;可能有死锁风险不保证一致性;有额外内存消耗;实现复杂

3.3 互斥锁解决缓存击穿

3.3.1 流程图

        接下来我们基于互斥锁的方式解决缓存击穿问题,修改根据 id 查询商铺的业务,基于互斥锁方式来解决缓存击穿问题。整体流程图如下:

3.3.2 代码实现

        下面的代码既解决了缓存穿透,又解决了缓存击穿的问题,代码如下:

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

    @Resource
    StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryShopById(Long id) {

        // 用互斥锁解决缓存击穿
        Shop shop = queryWithMutex(id);
        if (shop == null) {
            return  Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }

    // 用互斥锁解决缓存击穿问题
    public Shop  queryWithMutex(Long id){
        String key = "cache:shop:"+id;
        // 1、从 redis 查询商铺缓存
        String s = stringRedisTemplate.opsForValue().get(key);
        // 2、判断是否存在
        if(StrUtil.isNotBlank(s)){
            // 3、存在,直接返回
            Shop shop = JSONUtil.toBean(s, Shop.class);
            return shop;
        }
        // 4、判断是否命中的是空值
        if(s != null){
            // 返回错误信息
            return null;
        }
        // 5、实现缓存重建
        // 5.1 获取互斥锁
        String lockKey = null;
        Shop shop = null;
        try {
            lockKey = "lock:shop:"+id;
            boolean isLock = tryLock(lockKey);
            // 5.2 判断获取是否成功
            if(!isLock){
                // 5.3 获取失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            // 5.4 获取锁成功后应该再次监测 redis 缓存是否存在,如果存在则无需创建
            String s2 = stringRedisTemplate.opsForValue().get(key);
            if(StrUtil.isNotBlank(s2)) {
                shop = JSONUtil.toBean(s2, Shop.class);
                return shop;
            }
            // 5.5 获取成功之后,根据 id 查询数据库
            shop = getById(id);
            // 模拟重建延迟,可以使用 jmeter 进行压力测试,看看是否存在并发问题,若测试,则打开下面的注释
            // Thread.sleep(200);
            // 6、不存在,直接返回错误信息
            if(shop == null){
                // 6.1 将空值写入 redis,并设置有效期
                stringRedisTemplate.opsForValue().set(key,"",2,TimeUnit.MINUTES);
                return null;
            }
            // 7、存在,写入 redis
            String s1 = JSONUtil.toJsonStr(shop);
            stringRedisTemplate.opsForValue().set(key,s1,30, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 8、释放互斥锁
            unlock(lockKey);
        }
        // 9、返回
        return shop;
    }
	// 自定义互斥锁的加锁方法,其实就是使用 redis 的 setnx 命令
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    // 自定义互斥锁的解锁方法
    private boolean unlock(String key){
        Boolean flag = stringRedisTemplate.delete(key);
        return BooleanUtil.isTrue(flag);
    }
}

3.4 逻辑过期解决缓存击穿

3.4.1 流程图

        接下来我们基于逻辑过期的方式解决缓存击穿问题,修改根据 id 查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题。整体流程图如下:

3.4.2 代码实现

        首先封装一个类用于接收对象和过期时间,代码如下:

@Data
public class RedisData {
    private LocalDateTime expire;

    private Object data;
}

        然后编写基于逻辑过期解决缓存击穿的代码,如下:

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

    @Resource
    StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryShopById(Long id) {
		// 用逻辑过期时间解决缓存穿透问题
        Shop shop = queryWithLogicExpire(id);
        if (shop == null) {
            return  Result.fail("店铺不存在");
        }

        return Result.ok(shop);
    }
    private static final  ExecutorService CACHE_REBUILD_EXECUTOR  = Executors.newFixedThreadPool(10);
    // 用逻辑过期时间解决缓存击穿问题
    public Shop  queryWithLogicExpire(Long id){
        String key = "cache:shop:"+id;
        // 1、从 redis 查询商铺缓存
        String s = stringRedisTemplate.opsForValue().get(key);
        // 2、判断是否存在
        if(StrUtil.isBlank(s)){
            // 3、未命中,直接返回
            return null;
        }
        // 4、命中,先把 json 序列化成对象
        RedisData redisData = JSONUtil.toBean(s, RedisData.class);
        // 由于 RedisData 的属性为 Object 类型,所以被转换成了 JSONObject 类型
        JSONObject data = (JSONObject)redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);

        LocalDateTime expireTime = redisData.getExpire();
        // 5、判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 5.1 未过期,直接返回店铺信息
            return shop;
        }
        // 5.2 已过期,需要缓存重建
        // 6、缓存重建
        // 6.1 获取互斥锁
        String locoKey = "lock:shop:"+id;
        boolean isLock = tryLock(locoKey);
        // 6.2 判断是否获取锁成功
        if(isLock){
            // 6.3 再次判断是否 redis 是否过期,双重检查
            if(expireTime.isAfter(LocalDateTime.now())) {
                // 5.1 未过期,直接返回店铺信息
                return shop;
            }
            // 6.4 若已过期,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() ->{
                try {
                    // 重建缓存,并设置过期时间
                    this.save2Redis(id,30000L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unlock(locoKey);
                }
            });
        }
        // 6.4 失败,返回过期的商铺信息
        return shop;
    }
    // 创建一个存放热点 key 的方法
    public void save2Redis(Long id,Long expireSeconds) throws InterruptedException {
        // 1、查询店铺数据
        Shop shop = getById(id);
        // 2、封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpire(LocalDateTime.now().plusSeconds(expireSeconds));
        // 3、写入 Redis
        stringRedisTemplate.opsForValue().set("cache:shop:"+id,JSONUtil.toJsonStr(redisData));
    }
    // 自定义互斥锁的加锁方法
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    // 自定义互斥锁的解锁方法
    private boolean unlock(String key){
        Boolean flag = stringRedisTemplate.delete(key);
        return BooleanUtil.isTrue(flag);
    }

}

四、缓存工具封装

4.1 功能描述

        基于 StringRedisTemplate 封装一个缓存工具类,满足下列需求:

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

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

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

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

4.2 代码实现

@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

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

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
    // 方法一
    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)));
        // 写入Redis
        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.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否是空值
        if (json != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", 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.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return r;
        }
        // 5.2.已过期,需要缓存重建
        // 6.缓存重建
        // 6.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2.判断是否获取锁成功
        if (isLock){
            // 6.3.成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R newR = dbFallback.apply(id);
                    // 重建缓存
                    this.setWithLogicalExpire(key, newR, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4.返回过期的商铺信息
        return r;
    }
    // 互斥锁解决缓存击穿
    public <R, ID> R queryWithMutex(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, type);
        }
        // 判断命中的是否是空值
        if (shopJson != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            // 4.4.获取锁成功,根据id查询数据库
            r = dbFallback.apply(id);
            // 5.不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6.存在,写入redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 7.释放锁
            unlock(lockKey);
        }
        // 8.返回
        return r;
    }

    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
}

        调用代码如下:

    @Override
    public Result queryById(Long id) {
        // 解决缓存穿透,第一个参数为缓存 key 的前缀
        Shop shop = cacheClient
                .queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

        // 互斥锁解决缓存击穿
        // Shop shop = cacheClient
        //         .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

        // 逻辑过期解决缓存击穿
        // Shop shop = cacheClient
        //         .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
        // 7.返回
        return Result.ok(shop);
    }
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

快乐的小三菊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值