本文把缓存三大经典问题——穿透、击穿、雪崩——从成因、区别到落地方案讲清楚,附带基于 Spring Boot + Redis 的可运行代码,以及实际项目中踩过的坑。
一、先分清三个概念
很多人把这三者混为一谈,其实它们的触发条件完全不同:
| 问题 | 请求的数据 | 缓存中 | 数据库中 | 后果 |
|---|---|---|---|---|
| 缓存穿透 | 查询根本不存在的数据 | 没有 | 也没有 | 每次都打到数据库 |
| 缓存击穿 | 查询某个热点 key | 恰好过期 | 有 | 瞬间大量请求压向数据库 |
| 缓存雪崩 | 查询大量 key | 集中过期 | 有 | 大面积请求同时落库 |
一句话记忆:穿透是"查无此物",击穿是"一个热点突然失效",雪崩是"一大批同时失效"。
二、缓存穿透:布隆过滤器 + 空值缓存
2.1 方案一:缓存空值
当数据库查不到数据时,仍然在 Redis 写入一个空标记(设置较短的过期时间),避免恶意请求反复打库。
publicProductgetProduct(Longid){Stringkey="product:"+id;Stringvalue=redisTemplate.opsForValue().get(key);if(value!=null){// 命中空标记if("NULL".equals(value)){returnnull;}returnobjectMapper.readValue(value,Product.class);}Productproduct=productMapper.selectById(id);if(product==null){// 空值缓存,过期 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 都不查。
@BeanpublicBloomFilter<Long>productBloomFilter(){// 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){Stringkey="hot:product:"+id;Stringvalue=redisTemplate.opsForValue().get(key);if(value!=null){returnobjectMapper.readValue(value,Product.class);}StringlockKey="lock:product:"+id;try{// SETNX 抢锁,带过期防止死锁Booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",Duration.ofSeconds(10));if(Boolean.FALSE.equals(locked)){// 没抢到锁,稍等后重试读缓存Thread.sleep(50);returngetHotProduct(id);}// 双重检查:可能在等锁期间已被其他线程重建value=redisTemplate.opsForValue().get(key);if(value!=null){returnobjectMapper.readValue(value,Product.class);}Productproduct=productMapper.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 中存一个逻辑过期时间。发现逻辑过期后,异步线程去更新,当前请求先返回旧数据。牺牲短暂一致性,换取不阻塞。
classCacheData<T>{privateTdata;privateLocalDateTimeexpireTime;}publicProductgetWithLogicalExpire(Longid){Stringkey="hot:product:"+id;Stringjson=redisTemplate.opsForValue().get(key);CacheData<Product>cache=objectMapper.readValue(json,newTypeReference<CacheData<Product>>(){});if(cache.getExpireTime().isAfter(LocalDateTime.now())){returncache.getData();// 未逻辑过期}// 已过期,尝试异步刷新StringlockKey="lock: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 分钟随机,打散过期时间intrandom=ThreadLocalRandom.current().nextInt(0,600);redisTemplate.opsForValue().set(key,value,Duration.ofSeconds(1800+random));}配套措施:
- 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 集群和限流兜底,基本可以平稳扛住日常和大促流量。