在分布式系统里,Redis常作为Spring Boot应用的第一道防线和数据库之间的缓冲层。一旦缓存层被异常流量突破,后端MySQL或PostgreSQL就会承受超出预期的查询压力,轻则接口超时,重则实例宕机。缓存穿透、击穿、雪崩是三类机理不同但表现相似的问题,很多团队在故障复盘时才发现混淆了概念。本节先给出可运行的模拟环境搭建方式,后续再逐一拆解每种问题的复现与修复。
一、搭建可模拟问题的Spring Boot基础工程
要真实模拟三类缓存异常,需要先准备一个最简Spring Boot工程,引入Web、Data JPA、Spring Data Redis以及JUnit测试依赖。我们设计一个商品服务,通过主键查询商品信息,缓存使用Redis的String结构,过期时间统一设为五分钟。这样的结构足以在本地用多线程测试代码触发各种边界情况。
在配置文件中,除了配置Redis连接地址,还要打开JPA的SQL日志,方便观察是否发生了数据库直查。测试时我们用H2内存库代替真实MySQL,避免环境污染。下面的配置片段展示了核心参数,实际项目中可把超时与连接池调大一些以应对压测。
spring.redis.host=127.0.0.1 spring.redis.port=6379 spring.jpa.show-sql=true spring.jpa.hibernate.ddl-auto=create-drop spring.datasource.url=jdbc:h2:mem:testdb
业务类采用典型的Cache Aside模式:先查Redis,未命中再查库并回写。这个看似稳妥的写法在极端场景下就是漏洞源头。后面三节会基于同一段查询逻辑,分别注入不存在ID、热点ID过期、批量ID同时过期三种条件,让你看到问题是如何发生的。
二、缓存穿透的模拟与布隆过滤器方案
缓存穿透的本质是查询一个根本不存在的数据,比如用负数ID或伪造的UUID访问商品接口。因为Redis和数据库都没有这条记录,每次请求都会穿透到库。攻击者只需构造海量非法ID,就能用极低成本打垮数据库。我们用JUnit起二十个线程并发查ID为-1的商品,即可在日志中看到二十条相同的SELECT语句。
解决思路有两种主流做法。其一是缓存空值,将查不到的结果以空对象写入Redis并设较短过期时间,这样后续请求在缓存层就被拦截。其二是引入布隆过滤器,在请求进入业务逻辑前先判断ID是否可能存在于集合,不存在直接拒绝。下面的代码演示了空值缓存改造,注意空值过期时间要明显短于正常数据,防止无效键堆积。
public Product getProduct(Long id) {
String cache = redisTemplate.opsForValue().get("product:" + id);
if (cache != null) {
if ("NULL".equals(cache)) {
return null;
}
return JSON.parseObject(cache, Product.class);
}
Product p = productRepository.findById(id).orElse(null);
if (p == null) {
redisTemplate.opsForValue().set("product:" + id, "NULL", 2, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set("product:" + id, JSON.toJSONString(p), 5, TimeUnit.MINUTES);
}
return p;
}
布隆过滤器方案更适合ID空间巨大且非法请求占比高的场景。可以用Redisson的RBloomFilter在应用启动时把已有商品ID全部放入,查询前先调用contains方法。它的代价是存在极微小误判率,且删除困难,但在防穿透上比空值缓存更省内存。两种方案也可叠加:过滤器挡掉明显非法的,空值缓存兜住偶发漏网之鱼。
三、缓存击穿的互斥锁与逻辑过期模拟
缓存击穿发生在某个极度热点键过期的瞬间,比如首页爆款商品缓存刚好失效,此时涌进来的数千并发全部发现缓存未命中,一齐去查库并回写,数据库瞬间被压垮。我们用单测让一百个线程同时查同一个存在的热点ID,并在查询前手动删除该ID的缓存,就能复现这一状况。
最常见的解法是互斥锁:第一个线程拿到锁去查库并回写,其余线程等待或短暂休眠后重试。Spring Boot里可以用Redis的setIfAbsent配合过期时间实现分布式锁。下面的示例展示了加锁逻辑,注意锁必须设超时防止死锁,且释放时要校验持有者避免误删。
public Product getHotProduct(Long id) {
String key = "product:" + id;
String cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
return JSON.parseObject(cache, Product.class);
}
String lockKey = "lock:" + key;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
Product p = productRepository.findById(id).orElse(null);
redisTemplate.opsForValue().set(key, JSON.toJSONString(p), 5, TimeUnit.MINUTES);
return p;
} finally {
redisTemplate.delete(lockKey);
}
} else {
try { Thread.sleep(50); } catch (InterruptedException e) {}
return getHotProduct(id);
}
}
另一种思路叫逻辑过期,即Redis键不设置物理TTL,而在值里存入过期时间戳,业务线程发现逻辑过期后异步刷新。这种做法读请求永不阻塞,但实现复杂度高,且在刷新完成前可能返回旧数据。对于一致性要求不极端的场景,互斥锁已经足够清晰可控,也是多数团队的首选。
四、缓存雪崩的随机过期与多级缓存防护
缓存雪崩是指大量键在同一时刻集体失效,或者Redis实例宕机,导致请求像雪崩一样全部砸向数据库。我们在测试中可以初始化一千个商品并统一设五分钟过期,然后用定时任务模拟五分钟后批量失效,紧接着发起混合查询,即可看到数据库连接池被瞬间占满的告警。
预防雪崩的核心是打散过期时间。给每个键的基础TTL加上随机偏移量,比如五分钟加减三十秒,就能避免集中失效。同时,对关键业务引入本地缓存(如Caffeine)作为二级缓存,即使Redis全挂,本地还能扛住部分流量。下面代码展示了随机过期时间的写法,注意随机范围要根据数据总量调整。
int base = 5 * 60; int random = new Random().nextInt(60) - 30; redisTemplate.opsForValue().set(key, value, base + random, TimeUnit.SECONDS);
除了过期打散,高可用架构也不可忽视。生产环境应使用Redis Cluster或哨兵模式,保证单节点故障能自动切换。配合熔断组件(如Resilience4j)在数据库压力过大时快速失败,能进一步缩小故障半径。雪崩不是单点技术问题,而是缓存层、应用层、架构层共同协防的结果,只有组合多种手段才能做到真正稳妥。
Spring_Boot缓存穿透缓存雪崩修改时间:2026-08-17 15:56:37