🍖 缓存穿透
🥩 原理以及解决方案
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,这样请求就会先访问缓存再打到数据库。这原本是个正常现象并没有什么事情,但是如果有人借助这个漏洞一直请求缓存和数据库中都不存在的数据,那么就极有可能导致数据库崩溃。
常见的解决方案有两种:第一种就是使用缓存空对象,也就是当请求发送过来缓存和数据库中都没有数据的时候,将该请求的结果为null并设置过期时间存入缓存中,然后返回401状态码,等到下一次请求时直接返回null即可。这样做的优点就是实现简单方便维护,但是造成额外内存损耗的缺点也很明显,过期时间就可以降低该缺点的影响
第二种方案就是在缓存查询之前使用布隆过滤器,布隆过滤器就是由byte数组和一系列哈希函数两部分组成的数据结构,将数据使用hash函数计算出hash值,然后将这个hash值转成二进制位保存至布隆过滤器中,请求发送过来的话就计算出它的hash值,对应位置为1就说明存在0就是不存在。布隆过滤器的优点是不用频繁添加缓存内存占用小,但是缺点是实现相对复杂,而且会出现误判,布隆过滤器判断不存在的值一定不存在,它判断存在的值不一定就存在
🥩 缓存空对象代码实现
缓存空对象和之前的查询相比无非就是两步,一是缓存和数据库中都查不到的话就往缓存中添加然后返回错误信息,二是缓存中查到数据进行非空判断,如果是""串的话就刷新TTL然后返回错误信息,下面的代码中的15~19行 22~26行中分别有体现
/** * 根据id查询商铺信息,涉及到redis的缓存 * @param id 商铺id * @return 前端返回信息 */ @Override public Result queryById(Long id) { // 从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id); // 判断该商铺缓存中是否存在 if (StrUtil.isNotBlank(shopJson)) { // 存在直接返回 Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } else if ("".equals(shopJson)) { // 缓存中存在但是结果为""空字符串 也就是说之前使用 缓存空对象 方案时存入的,这样的话就刷新它的缓存时间返回异常 stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY + id, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES); return Result.fail("店铺不存在"); } // 不存在查询数据库 Shop shop = getById(id); if (shop == null) { // 数据库中不存在 将null存入缓存设置过期时间2min并返回错误信息 stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES); return Result.fail("店铺不存在"); } // 数据库中存在写入redis stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); // 返回 return Result.ok(shop); }
🍖 缓存雪崩
🥩 原理以及解决方案
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,因此会给数据库带来巨大压力
为了解决大量缓存数据同时失效,可以将数据的TTL设置为随机值,而不是使用一个固定值。为了解决Redis服务宕机,可以使用主从架构的集群提高服务的可用性,万一出现宕机可以使用从节点顶上。为了进一步防止缓存雪崩,我们还可以给缓存业务添加降级限流策略,也就是说当redis发生故障的时候可以直接拒绝服务而不是继续访问数据库;或者给业务添加多级缓存,在浏览器、nginx、redis、jvm、数据库等一层层的添加缓存
🍖 缓存击穿
🥩 原理以及解决方案
缓存击穿也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,此时很多的请求会在瞬间给数据库带来巨大冲击。
为了解决缓存击穿可以使用互斥锁,如果发生缓存击穿后,第一个请求查询数据库中该数据的时候,使用一个锁锁住,后续的所有请求在锁未放开之前访问这个数据就让它休眠一会重新查询缓存。这个方案优点就不说了,缺点就是在第一个线程写缓存期间,其他访问该数据的线程拿不到锁就只能处于等待状态,所以说这就很损耗性能
还有一种方案就是逻辑过期,顾名思义逻辑过期就是不作真正的删除,而是使用一个字段存储过期时间代替TTL的过期删除,所有的线程在获取到数据的时候都去通过过期时间字段判断是否过期,过期的话就新建一个线程先更新数据库再删除缓存,自己就返回已过期的数据,在此期间所有的访问都会返回过期数据,等到新建线程的任务完成之后再次访问的线程就负责添加新的缓存数据并返回新的数据
🥩 互斥锁代码实现
互斥锁方案解决缓存击穿相比较于缓存空对象解决缓存穿透的方案而言,最大的不同就是,在从缓存中查询到数据的情况下,需要先判断一下是否可以获得该数据对应的锁,可以就查询数据库并写入缓存,否则线程休眠重新调用该方法,上述代码需要放在try catch中使用finally释放锁,以上思路在21~40代码中实现
/** * 查询商铺信息 互斥锁解决缓存击穿 * @param id 商铺编号 * @return 查询到的商铺信息 */ public Shop queryWithMutex(Long id) { // 从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id); // 判断该商铺缓存中是否存在 if (StrUtil.isNotBlank(shopJson)) { // 存在直接返回 Shop shop = JSONUtil.toBean(shopJson, Shop.class); return shop; } else if ("".equals(shopJson)) { // 缓存中存在但是结果为""空字符串 也就是说之前使用 缓存空对象 方案时存入的,这样的话就刷新它的缓存时间返回异常 stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY + id, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } // 不存在先查看是否可以获取到锁,如果可以就休眠重试,否则就查询数据库写缓存 Shop shop = null; try { if (!tryLock(RedisConstants.LOCK_SHOP_KEY + id)) { // 获取锁失败 Thread.sleep(50); queryWithMutex(id); } shop = getById(id); if (shop == null) { // 数据库中不存在 将null存入缓存设置过期时间2min并返回错误信息 stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } // 数据库中存在写入redis stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { // 释放互斥锁 unLock(RedisConstants.LOCK_SHOP_KEY + id); } // 返回 return shop; }
🥩 逻辑过期代码实现
逻辑过期的前提条件是先向缓存中存入一个带有过期时间字段的商铺信息,也就是模拟现将需要做活动的商品信息存入到redis缓存中,然后再对缓存进行查询,如果缓存中不存在就返回null,存在的话就进行逻辑过期的操作
// 创建线程池 用于更新缓存时间过期的时候 更新缓存使用 private static final ExecutorService CACH_REBUILD_EXECUTOR = Executors.newFixedThreadPool(5); /** * 查询商铺信息 逻辑过期解决缓存击穿 * @param id 商铺编号 * @return 查询到的商铺信息 */ public Shop queryWithLogicalExpire(Long id) { // 从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id); // 判断该商铺缓存中是否存在 if (StrUtil.isBlank(shopJson)) { // 不存在直接返回null return null; } // 存在 反序列化获取 店铺信息 和 expire字段 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); // 判断是否过期 过期时间在当前时间之后即为未过期 if (expireTime.isAfter(LocalDateTime.now())) { // 未过期直接返回店铺信息 return shop; } // 已过期 尝试获取锁 if (tryLock(RedisConstants.LOCK_SHOP_KEY + id)) { // 获取锁成功 开启独立线程去做缓存重建 CACH_REBUILD_EXECUTOR.submit(() -> { // 创建缓存 try { saveShopWithExpireTimeToRedis(id, 20L); } catch (Exception e) { throw new RuntimeException(e); } finally { // 释放锁 unLock(RedisConstants.LOCK_SHOP_KEY + id); } }); } // 返回 return shop; }