Redis缓存穿透、击穿、雪崩的区别与防护方案(含可运行代码)
本文把缓存三大经典问题——穿透、击穿、雪崩——从成因、区别到落地方案讲清楚附带基于 Spring Boot Redis 的可运行代码以及实际项目中踩过的坑。一、先分清三个概念很多人把这三者混为一谈其实它们的触发条件完全不同问题请求的数据缓存中数据库中后果缓存穿透查询根本不存在的数据没有也没有每次都打到数据库缓存击穿查询某个热点 key恰好过期有瞬间大量请求压向数据库缓存雪崩查询大量 key集中过期有大面积请求同时落库一句话记忆穿透是查无此物击穿是一个热点突然失效雪崩是一大批同时失效。二、缓存穿透布隆过滤器 空值缓存2.1 方案一缓存空值当数据库查不到数据时仍然在 Redis 写入一个空标记设置较短的过期时间避免恶意请求反复打库。publicProductgetProduct(Longid){Stringkeyproduct:id;StringvalueredisTemplate.opsForValue().get(key);if(value!null){// 命中空标记if(NULL.equals(value)){returnnull;}returnobjectMapper.readValue(value,Product.class);}ProductproductproductMapper.selectById(id);if(productnull){// 空值缓存过期 2 分钟防止长期占用redisTemplate.opsForValue().set(key,NULL,Duration.ofMinutes(2));returnnull;}redisTemplate.opsForValue().set(key,objectMapper.writeValueAsString(product),Duration.ofMinutes(30));returnproduct;}2.2 方案二布隆过滤器对于 ID 有明确范围的场景比如商品 ID 一定来自已存在的商品表在请求到达缓存前先用布隆过滤器判断 ID 是否可能存在。不存在的直接拦截连 Redis 都不查。BeanpublicBloomFilterLongproductBloomFilter(){// expectedInsertions 预计元素数量fpp 误判率returnBloomFilter.create(Funnels.longFunnel(),10_000_000L,0.01);}publicvoidaddProductToBloom(Longid){productBloomFilter.put(id);}publicProductqueryWithBloom(Longid){if(!productBloomFilter.mightContain(id)){// 一定不存在直接返回returnnull;}returngetProduct(id);}布隆过滤器有误判率它说不存在就一定不存在说存在则可能误判所以误判的少量请求仍会落库需要配合空值缓存兜底。三、缓存击穿互斥锁 逻辑过期热点 key 失效瞬间大量并发同时查库。核心思路是只放一个线程去重建缓存其余等待。3.1 方案一互斥锁简单可靠publicProductgetHotProduct(Longid){Stringkeyhot:product:id;StringvalueredisTemplate.opsForValue().get(key);if(value!null){returnobjectMapper.readValue(value,Product.class);}StringlockKeylock:product:id;try{// SETNX 抢锁带过期防止死锁BooleanlockedredisTemplate.opsForValue().setIfAbsent(lockKey,1,Duration.ofSeconds(10));if(Boolean.FALSE.equals(locked)){// 没抢到锁稍等后重试读缓存Thread.sleep(50);returngetHotProduct(id);}// 双重检查可能在等锁期间已被其他线程重建valueredisTemplate.opsForValue().get(key);if(value!null){returnobjectMapper.readValue(value,Product.class);}ProductproductproductMapper.selectById(id);redisTemplate.opsForValue().set(key,objectMapper.writeValueAsString(product),Duration.ofMinutes(30));returnproduct;}finally{redisTemplate.delete(lockKey);}catch(InterruptedExceptione){Thread.currentThread().interrupt();thrownewRuntimeException(e);}}3.2 方案二逻辑过期不阻塞适合高可用不设置物理 TTL而是在 value 中存一个逻辑过期时间。发现逻辑过期后异步线程去更新当前请求先返回旧数据。牺牲短暂一致性换取不阻塞。classCacheDataT{privateTdata;privateLocalDateTimeexpireTime;}publicProductgetWithLogicalExpire(Longid){Stringkeyhot:product:id;StringjsonredisTemplate.opsForValue().get(key);CacheDataProductcacheobjectMapper.readValue(json,newTypeReferenceCacheDataProduct(){});if(cache.getExpireTime().isAfter(LocalDateTime.now())){returncache.getData();// 未逻辑过期}// 已过期尝试异步刷新StringlockKeylock:product:id;if(Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey,1,Duration.ofSeconds(10)))){// 提交异步任务重建本线程立即返回旧数据executor.submit(()-{try{rebuildHotProduct(id);}finally{redisTemplate.delete(lockKey);}});}returncache.getData();}四、缓存雪崩打散过期时间 多级兜底大量 key 同一时刻过期或 Redis 整体宕机都会造成雪崩。防护从避免集中过期和数据库抗压两侧入手。publicvoidsetWithRandomTtl(Stringkey,Stringvalue){// 基础 30 分钟 0~10 分钟随机打散过期时间intrandomThreadLocalRandom.current().nextInt(0,600);redisTemplate.opsForValue().set(key,value,Duration.ofSeconds(1800random));}配套措施Redis 高可用主从 哨兵或集群避免单点故障多级缓存本地缓存Caffeine作为一级Redis 作为二级Redis 故障时本地缓存仍能挡一部分限流降级数据库侧配置熔断与限流非核心接口返回兜底数据保护数据库不被打垮预热大促前提前加载热点数据错开上线即过期的时间点。五、实战踩过的三个坑坑1空值缓存被当成正常数据反序列化空值用字符串NULL标记但直接readValue会抛异常。务必在反序列化前先判断是否为空标记更稳妥的做法是用统一的包装结构带一个数据是否存在的标志位而不是靠魔法字符串。坑2互斥锁只 SETNX 不设过期服务重启导致死锁如果setIfAbsent不带过期时间拿到锁的线程在重建过程中宕机锁将永远不释放热点 key 直接卡死。锁必须带超时释放锁时最好用 Lua 校验 value存入唯一标识避免误删别人的锁-- 安全释放锁ifredis.call(get,KEYS[1])ARGV[1]thenreturnredis.call(del,KEYS[1])elsereturn0end坑3随机 TTL 只加在部分 key 上雪崩依旧团队给部分热点加了随机过期但批量导入的数据仍统一 TTL结果导入后一小时集中过期引发雪崩。记住凡是批量写入的缓存都要在写入路径统一带随机 TTL不能只在单个查询方法里处理。六、方案选型小结恶意攻击、查不存在数据 →布隆过滤器 空值缓存单个热点 key 高并发 → 强一致用互斥锁高可用用逻辑过期大面积 key 或 Redis 故障 →随机 TTL 高可用 多级缓存 限流降级组合拳。没有银弹根据业务对一致性、可用性的要求组合使用。多数电商类系统的实践是布隆过滤器拦穿透、互斥锁护热点、随机 TTL 防雪崩再配 Redis 集群和限流兜底基本可以平稳扛住日常和大促流量。