目录
一. 缓存穿透
1.1 产生原因
1.2 常见解决方案举例(缓存空值,布隆过滤器)
二. 缓存雪崩
2.1 产生原因
2.2 常见解决方案举例(随机时间,设置集群,构建多级缓存)
三. 缓存击穿
3.1 产生原因
3.2 常见解决方案举例(互斥锁,逻辑过期)
一. 缓存穿透
1.1 产生原因
用户请求的数据在 Redis 和数据库都不存在,并且大量线程重复不断的发送请求,导致所有查询请求全部打到数据库,造成巨大压力,可能是恶意攻击(比如用户请求商品号为0的商品,大概率不存在) 或 业务漏洞。
1.2 常见解决方案举例(缓存空值,布隆过滤器)
方案一:缓存空值,示例代码如下
优点:实现逻辑简单,方便维护;
缺点:(1)缓存控制导致额外内存消耗,少量 null 值缓存尚可,若缓存大量 null 数据,不合理;
(2)此外,还可能导致短期数据不一致,假设现在马上查询商品A,数据库和缓存都没有数据,缓存了 null 值,有效期3分钟,1分钟后数据库插入了有效数据商品A,但未能及时删除或更新缓存,那么接下来的两分钟,数据库数据与缓存 null 数据就会出现数据不一致的情况(也可以在INSERT,UPDATE代码逻辑添加缓存同步解决此问题)。
@Autowired private RedissonClient redissonClient; @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private ShopMapper shopMapper; public static final String SHOP_CACHE_PREFIX = "cache:shop:"; public static final String NULL_VALUE = ""; public static final String LOCK_PREFIX = "lock:shop:"; /** * 查询商户缓存击穿代码 * */ public Shop queryShopById(Long id) { // 1. 先组装 key 查询缓存 String key = SHOP_CACHE_PREFIX + id; String shopJson = stringRedisTemplate.opsForValue().get(key); // 2. 判断缓存是否为空值,不为空则解析返回 if (StrUtil.isNotBlank(shopJson)){ return JSONUtil.toBean(shopJson, Shop.class); } // 3. 判断缓存是否为null,不为null则说明查询到的是缓存的空字符串,则返回 null if (shopJson != null){ return null; } // 4. 缓存为null,则查询数据库,加互斥锁防止击穿,所以先获取锁 String lockKey = LOCK_PREFIX + id; RLock lock = redissonClient.getLock(lockKey); try { // 5. 先尝试加锁,防止其他线程已经上锁, 这里设置 2s 尝试加锁,10s 释放锁,防止死锁 if(lock.tryLock(2,10, TimeUnit.SECONDS)){ // 6. 加锁成功,先双重检查,防止在此期间,其他线程已经读取数据并写入缓存,避免无效查询 shopJson = stringRedisTemplate.opsForValue().get(key); // 7. 再次查询缓存判断缓存值,与上面同样的逻辑,先判断是否空值 if (StrUtil.isNotBlank(shopJson)){ return JSONUtil.toBean(shopJson, Shop.class); } if (shopJson != null){ return null; } // 8. 缓存仍为空,查询数据库 Shop shop = shopMapper.selectById(id); // 9. 判断数据库返回值,不为空写入缓存,为空则缓存空值(3分钟) if (shop != null){ stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 3, TimeUnit.MINUTES); } else { stringRedisTemplate.opsForValue().set(key, NULL_VALUE, 3, TimeUnit.MINUTES); } return shop; } else { // 获取锁失败,略作等待后再次尝试从缓存获取 log.info("未能获取锁 id={},等待后重试读缓存", id); Thread.sleep(100); // 递归重试,演示方便直接简单调用,实际业务逻辑需添加重试次数 return queryShopById(id); } } catch (InterruptedException e) { log.info("获取锁失败 id={},请稍后重试", id); return null; } finally { // 释放锁,但需要判断锁是否是当前线程持有 if (lock.isHeldByCurrentThread()){ lock.unlock(); } } }方案二:使用布隆过滤器
布隆过滤器,简单理解为在业务服务层做前置判断,客户端请求 Redis 之前,需要先由布隆过滤器判断当前数据 key 是否可能属于合法数据,在缓存中是否存在;
布隆过滤器底层是由 BitMap + 多个 hash 函数实现。
bitmap 是一段二进制位数组,每一位只有0或1。当一个 key 加入布隆过滤器时,会被多个 hash 函数计算出多个下标,然后把 bitmap 中这些位置都置为1。查询时,再用同样的 hash 函数计算下标:如果有任意一位是0,说明这个 key 一定不存在;如果所有位都是1,只能说明它可能存在。
向布隆过滤器加入数据,会被多个哈希函数进行计算,得到的指定位置的值会置为1,当请求过来时,经过哈希函数计算,若指定位置的值为1,则大概率说明该数据存在于布隆过滤器,也存在于Redis;所以说布隆过滤器的判断并非100%准确,因为两个不同的值,经过哈希函数之后,可能会得到相同的结果,比如数据A,数据B经过哈希函数后,结果都为3,将id = 3的值置为1,若此时Redis中只缓存了数据A,未缓存数据B,但由于哈希计算之后,得到的结果为3,到id = 3发现值为1,初步判断数据B可能存在于缓存;但是,若某一处值始终为0,则说明没有任何数据哈希计算后落于此处,说明缓存中不可能存在此数据;
所以,我们不难发现布隆过滤器的一个判断特点,即:若布隆过滤器认为此数据在Redis存在,则此数据只是可能存在,并非一定存在;若布隆过滤器判断此数据在Redis一定不存在,则一定不存在,另外,普通布隆过滤器不擅长删除数据,因为多个元素可能共享同一个 bit 位;如果业务需要删除,可以考虑 Counting Bloom Filter 或重建过滤器。
此外,布隆过滤器的使用,也可以搭配缓存空值方案一起使用,可以进一步提高系统健壮性,防止缓存穿透情况发生。
布隆过滤器与 bitmap 的内在关联:bitmap 是布隆过滤器的底层存储结构,布隆过滤器是在 bitmap 之上叠加多 hash 映射和概率判断规则。普通 bitmap 更像“一个位置对应一个状态”,而布隆过滤器是“一个元素映射到多个 bit 位”。
补充方案
除了上述做法之外,还有以下部分补充方案供参考
- 增加 id 复杂度,防止被猜到id规律进行恶意攻击,比如使用雪花算法;
- 做好参数的基础格式校验,对于明显不是id参数的请求入参,直接拦截;
- 加强用户权限校验,部分接口及网站信息需要拥有相关权限才可访问;
- 做好热点参数的限流,使用相关微服务限流组件如 Sentinel,Hystrix;
二. 缓存雪崩
2.1 产生原因
大量缓存在同一时段同时过期,或者缓存服务器宕机,导致所有请求都打到数据库,造成数据库压力过大甚至崩溃。
2.2 常见解决方案举例(随机时间,设置集群,构建多级缓存)
大量缓存同一时间过期,常用且易于实现的方法就是在设置缓存的时候,不设置一个固定的默认值,而是设置一个TTL随机值,防止集体过期;
方案一:随机TTL过期时间,防止大量缓存同时过期
在上面的防止缓存穿透的代码基础上,只需要将过期时间出的逻辑稍微做一下修改,给不同的缓存添加随机TTL过期时间,如下所示,就完成了防止缓存穿透 + 缓存雪崩两大功能
public static final String SHOP_CACHE_PREFIX = "cache:shop:"; public static final String NULL_VALUE = ""; public static final String LOCK_PREFIX = "lock:shop:"; /** * 查询商户 - 解决缓存穿透和缓存雪崩 * 缓存穿透:通过互斥锁 + 空值缓存 * 缓存雪崩:通过 TTL 随机化(30-90秒),避免大量缓存同时失效 */ public Shop queryShopById(Long id) { // 1. 先组装 key 查询缓存 String key = SHOP_CACHE_PREFIX + id; String shopJson = stringRedisTemplate.opsForValue().get(key); // 2. 判断缓存是否为空值,不为空则解析返回 if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 3. 判断缓存是否为 null,不为 null 则说明查询到的是缓存的空字符串,则返回 null if (shopJson != null) { return null; } // 4. 缓存为 null,则查询数据库,加互斥锁防止击穿,所以先获取锁 String lockKey = LOCK_PREFIX + id; RLock lock = redissonClient.getLock(lockKey); try { // 5. 先尝试加锁,防止其他线程已经上锁, 这里设置 2s 尝试加锁,10s 释放锁,防止死锁 if (lock.tryLock(2, 10, TimeUnit.SECONDS)) { // 6. 加锁成功,先双重检查,防止在此期间,其他线程已经读取数据并写入缓存,避免无效查询 shopJson = stringRedisTemplate.opsForValue().get(key); // 7. 再次查询缓存判断缓存值,与上面同样的逻辑,先判断是否空值 if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } if (shopJson != null) { return null; } // 8. 缓存仍为空,查询数据库 Shop shop = shopMapper.selectById(id); // 9. 判断数据库返回值,使用随机 TTL 写入缓存 if (shop != null) { // 缓存雪崩解决:设置随机过期时间 60-180 秒(1-3分钟),也可以更长,视具体业务场景而定,这里只举例说明 long randomTtl = generateRandomTtl(60, 180); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), randomTtl, TimeUnit.SECONDS); log.debug("写入缓存成功,key={}, TTL={}秒", key, randomTtl); } else { // 空值缓存也使用随机 TTL,避免同时失效(30-90秒) long randomTtl = generateRandomTtl(30, 90); stringRedisTemplate.opsForValue().set(key, NULL_VALUE, randomTtl, TimeUnit.SECONDS); log.debug("写入空值缓存,key={}, TTL={}秒", key, randomTtl); } return shop; } else { // 获取锁失败,略作等待后再次尝试从缓存获取 log.info("未能获取锁 id={},等待后重试读缓存", id); Thread.sleep(100); // 递归重试,演示方便直接简单调用,实际业务逻辑需添加重试次数 return queryShopById(id); } } catch (InterruptedException e) { log.error("获取锁失败 id={},请稍后重试", id, e); Thread.currentThread().interrupt(); // 恢复中断状态 return null; } finally { // 释放锁,但需要判断锁是否是当前线程持有 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } /** * 生成随机 TTL(Time To Live) * @param minSeconds 最小秒数 * @param maxSeconds 最大秒数 * @return 随机秒数 */ private long generateRandomTtl(int minSeconds, int maxSeconds) { // 使用 ThreadLocalRandom 提高性能,避免并发冲突 return java.util.concurrent.ThreadLocalRandom.current().nextInt(minSeconds, maxSeconds + 1); }方案二:设置 Redis 集群防止 Redis 服务器宕机
相比起大量缓存同时失效,Redis 服务器宕机带来的问题则更加严重,等于所有缓存全部失效,为了防止此问题,则可以设置 Redis 集群,依赖于哨兵检测功能,即使某一台Redis 服务器挂了,其他 Redis 服务器依然可以正常对外提供服务,但使用集群也会带来更多需要解决的其他问题如分布式锁,Redis集群数据一致性等问题需要解决;
方案三:构建多级缓存,如 nginx 缓存,不同层级的缓存相互配合,可靠性更强
此外,还可以给缓存添加降级限流策略,防止过高的并发量,也能在线程出现问题时及时阻断防止影响整体业务;
三. 缓存击穿
3.1 产生原因
缓存中的热点数据 key 在某一时刻过期,而恰好这时有大量并发请求过来,由于缓存失效,所有请求都去查数据库,造成数据库压力瞬时增大,其实和缓存穿透有些类似,只是这个在数据库中确实存在数据,只是缓存失效了,而且,在重新构建缓存的过程中,可能由于数据量大或数据需要多表连接查询导致比较慢。会出现缓存真空期,一旦出现高并发,大量线程访问数据库可能导致崩溃;
3.2 常见解决方案举例(互斥锁,逻辑过期)
了解了缓存击穿的产生原因,其实不难看出,他完全也可以使用互斥锁的方式来解决,只是相比于缓存穿透代码,不考虑缓存空值,不需要额外判断空值,其余代码逻辑与上面的举例代码完全一样,所以总的来说,下面这段代码,已经基本解决了缓存穿透、缓存雪崩、缓存击穿三个问题;
方案一:互斥锁解决缓存击穿
public static final String SHOP_CACHE_PREFIX = "cache:shop:"; public static final String NULL_VALUE = ""; public static final String LOCK_PREFIX = "lock:shop:"; /** * 查询商户 - 解决缓存穿透和缓存雪崩 * 缓存穿透:通过互斥锁 + 空值缓存 * 缓存雪崩:通过 TTL 随机化(30-90秒),避免大量缓存同时失效 */ public Shop queryShopById(Long id) { // 1. 先组装 key 查询缓存 String key = SHOP_CACHE_PREFIX + id; String shopJson = stringRedisTemplate.opsForValue().get(key); // 2. 判断缓存是否为空值,不为空则解析返回 if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 3. 判断缓存是否为 null,不为 null 则说明查询到的是缓存的空字符串,则返回 null if (shopJson != null) { return null; } // 4. 缓存为 null,则查询数据库,加互斥锁防止击穿,所以先获取锁 String lockKey = LOCK_PREFIX + id; RLock lock = redissonClient.getLock(lockKey); try { // 5. 先尝试加锁,防止其他线程已经上锁, 这里设置 2s 尝试加锁,10s 释放锁,防止死锁 if (lock.tryLock(2, 10, TimeUnit.SECONDS)) { // 6. 加锁成功,先双重检查,防止上锁期间,其他线程已经读取数据写入缓存并释放了锁,避免无效查询 shopJson = stringRedisTemplate.opsForValue().get(key); // 7. 再次查询缓存判断缓存值,与上面同样的逻辑,先判断是否空值 if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } if (shopJson != null) { return null; } // 8. 缓存仍为空,查询数据库 Shop shop = shopMapper.selectById(id); // 9. 判断数据库返回值,使用随机 TTL 写入缓存 if (shop != null) { // 缓存雪崩解决:设置随机过期时间 60-180 秒(1-3分钟),也可以更长,视具体业务场景而定,这里只举例说明 long randomTtl = generateRandomTtl(60, 180); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), randomTtl, TimeUnit.SECONDS); log.debug("写入缓存成功,key={}, TTL={}秒", key, randomTtl); } else { // 空值缓存也使用随机 TTL,避免同时失效(30-90秒) long randomTtl = generateRandomTtl(30, 90); stringRedisTemplate.opsForValue().set(key, NULL_VALUE, randomTtl, TimeUnit.SECONDS); log.debug("写入空值缓存,key={}, TTL={}秒", key, randomTtl); } return shop; } else { // 获取锁失败,略作等待后再次尝试从缓存获取 log.info("未能获取锁 id={},等待后重试读缓存", id); Thread.sleep(100); // 递归重试,演示方便直接简单调用,实际业务逻辑需添加重试次数 return queryShopById(id); } } catch (InterruptedException e) { log.error("获取锁失败 id={},请稍后重试", id, e); Thread.currentThread().interrupt(); // 恢复中断状态 return null; } finally { // 释放锁,但需要判断锁是否是当前线程持有 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } /** * 生成随机 TTL(Time To Live) * @param minSeconds 最小秒数 * @param maxSeconds 最大秒数 * @return 随机秒数 */ private long generateRandomTtl(int minSeconds, int maxSeconds) { // 使用 ThreadLocalRandom 提高性能,避免并发冲突 return java.util.concurrent.ThreadLocalRandom.current().nextInt(minSeconds, maxSeconds + 1); }方案二:设置逻辑过期时间+异步线程更新缓存解决缓存击穿
此方案也很好理解,缓存的数据可以设置为永不过期或数天之后过期(一个较长的过期时间),但可以再额外设置一个逻辑过期时间。
举个简单的例子,假设今天中午12:00线程A读取商品A到缓存中,设置为永不过期,但同时在缓存的数据中设置一个逻辑过期时间,比如当前时间再加30分钟,那么逻辑过期时间就是12:30分;假设过了过了30分钟之后,12:31分,线程B获取商品A数据,因为缓存永不过期,所以可以读取到商品A数据,同时也获取逻辑过期时间发现是12:30分,早于当前时间12:31分,说明此时获取的数据虽然没有缓存失效,但从逻辑上来讲已经失效了,已经是比较旧的数据了,可能不是最新的,所以再开辟一个新线程,更新商品A缓存,同时再设置一个30分钟的逻辑过期时间,当前时间是12:31分,再添加30分钟逻辑过期时间,那么下次的逻辑过期时间就是13:01分,若13:01分之后又有新线程来访问商品A,则重复此操作,重新查询数据库刷新商品A的缓存。循环往复......
示例代码逻辑如下:
a.首先需要对商品类重构,可以创建一个公共的缓存数据类,通过泛型控制具体数据类型,再添加逻辑过期时间字段
@Data @AllArgsConstructor @NoArgsConstructor //我们给所有要放入缓存的数据对象,都统一添加一个逻辑过期时间字段,实际数据通过泛型进行控制即可 public class RedisData<T> { /** * 逻辑过期时间 */ private LocalDateTime expireTime; /** * 实际数据 */ private T data; }b.添加新的常量和线程池
// 逻辑过期时间(30分钟) public static final long LOGIC_EXPIRE_MINUTES = 30; // 缓存重建线程池 private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);c.编写新的逻辑代码
// 商品缓存层级前缀字符串 public static final String SHOP_CACHE_PREFIX = "cache:shop:"; // 空字符串,空缓存值 public static final String NULL_VALUE = ""; // 互斥锁前缀 public static final String LOCK_PREFIX = "lock:shop:"; // 逻辑过期时间(30分钟) public static final long LOGIC_EXPIRE_MINUTES = 30; // 缓存重建线程池 private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); /** * 逻辑过期解决缓存击穿 */ public Shop queryShopById2(Long id) { // 1. 先组装 key 查询缓存 String key = SHOP_CACHE_PREFIX + id; String shopJson = stringRedisTemplate.opsForValue().get(key); // 2. 判断缓存是否为空值,为空直接返回 null if (StrUtil.isBlank(shopJson)) { return null; } // 3. 缓存不为空,则解析数据,序列化为对象 RedisData<Shop> shopRedisData = JSONUtil.toBean(shopJson, RedisData.class); // 4. 序列化后,获取逻辑过期时间,判断当前数据是否已经逻辑过期 // (4.1)若未过期,直接返回解析后的数据即可 LocalDateTime expireTime = shopRedisData.getExpireTime(); if (expireTime.isAfter(LocalDateTime.now())){ return shopRedisData.getData(); } // (4.2)若已逻辑过期,则开辟新线程重建缓存,但当前缓存仍然返回旧数据,开辟新线程,使用互斥锁,同一时刻,只允许一个线程重建缓存 String lockKey = LOCK_PREFIX + id; RLock lock = redissonClient.getLock(lockKey); boolean isLock = lock.tryLock(); if (isLock) { // 如果加锁成功,则开启新线程重建缓存,使用线程池线程 CACHE_REBUILD_EXECUTOR.submit(() -> { // 为确保锁最终能被释放,要对当前重建缓存线程添加 try...catch...finally... try { // 查询商品数据 Shop shop = shopMapper.selectById(id); // 封装逻辑过期时间30分钟 RedisData<Shop> redisData = new RedisData<>(LocalDateTime.now().plusMinutes(LOGIC_EXPIRE_MINUTES), shop); // 重建商品缓存 stringRedisTemplate.opsForValue().set(SHOP_CACHE_PREFIX + id, JSONUtil.toJsonStr(redisData)); } catch (Exception e){ log.error("重建缓存失败:{}", e.getMessage()); throw new RuntimeException(e); } finally { // 释放锁 lock.unlock(); if (isLock && lock.isHeldByCurrentThread()) { lock.unlock(); } } }); } // 如果加锁失败,说明已经存在线程重建缓存,则直接返回旧数据 return shopRedisData.getData(); }OK,通过上述两个不同的方案,我们可以得出以下结论
| 解决方案 | 优点 | 缺点 |
| 互斥锁 | (1)没有额外内存消耗 (2)互斥锁保证数据一致性 (3)实现相对简单 | (1)由于是互斥锁,线程需要等待,性能受到一定影响; (2)在庞大复杂的业务中,可能会存在其它业务锁,可能存在死锁风险; |
| 逻辑过期 | 线程无需等待,性能较好 | (1)不保证数据一致性,高并发情况下,若未获取到锁,线程会返回旧数据; (2)有额外内存消耗; (3)使用了线程池及逻辑过期,实现相比互斥锁更复杂,在实际业务中,需要考虑的则会更多更复杂; |