Redis缓存穿透、击穿、雪崩:生产上是怎么被打垮的
2026/4/30大约 9 分钟
Redis缓存穿透、击穿、雪崩:生产上是怎么被打垮的
适读人群:Java后端开发、架构师、对缓存设计感兴趣的工程师 | 阅读时长:约22分钟
开篇故事
2021年双十一零点,我们的商品详情页缓存系统崩了。
零点整,大促活动开启。10万用户同时涌入,疯狂刷新页面。
第一个倒下的是Redis,然后是MySQL,然后整个商品服务挂掉。
事后复盘,发现是三个问题叠加:
缓存雪崩:前一晚11点,我们预热了10万个商品的缓存,全部设置了1小时过期时间。到零点,恰好是这批缓存的过期时间,大量缓存同时失效。
缓存击穿:10个爆款商品(首页大图展示的)被高度集中的流量打中,缓存失效的瞬间,1万个并发请求同时落到MySQL。
缓存穿透:黑产开始用不存在的商品ID刷接口,每次都穿透到MySQL,MySQL CPU直接打满。
今天把这三个问题的原理、防护方案完整讲一遍。
一、三种问题的本质区别
缓存穿透:查询不存在的数据,缓存和DB都没有
用户/攻击者 → 缓存(miss)→ DB(查不到)→ 重复查询DB
缓存击穿:热点数据缓存过期,大量并发打到DB
大量用户 → 缓存(miss,刚好过期)→ 大量请求 → DB(被打垮)
缓存雪崩:大量缓存同时过期,或Redis宕机
大量用户 → 大量缓存(同时miss)→ 大量请求 → DB(被打垮)二、底层原理与防护方案
2.1 缓存穿透:空值缓存 + 布隆过滤器
布隆过滤器原理:
使用m个bit的位数组 + k个哈希函数
添加元素 "productId=123":
hash1(123) = 17 → bit[17] = 1
hash2(123) = 45 → bit[45] = 1
hash3(123) = 89 → bit[89] = 1
查询 "productId=999":
hash1(999) = 17 → bit[17] = 1 ✓
hash2(999) = 32 → bit[32] = 0 ✗ → 一定不存在!
查询 "productId=456":
hash1(456) = 17 → bit[17] = 1 ✓
hash2(456) = 45 → bit[45] = 1 ✓
hash3(456) = 91 → bit[91] = 1 ✓ → 可能存在(有假阳性)
特性:
- 判断"不存在":100%准确(无假阴性)
- 判断"存在":有一定误判率(假阳性,通常配置为0.1%-1%)2.2 缓存击穿:互斥锁 + 逻辑过期
方案1:互斥锁(Mutex)
查询流程:
① 查缓存 → miss
② 尝试获取互斥锁(SETNX)
③ 获取成功:查DB,写缓存,释放锁
获取失败:等待100ms后重试查缓存
④ 只有一个线程重建缓存,其他线程等待
特点:强一致性,但获取锁失败的线程会等待,有短暂的查询延迟
方案2:逻辑过期(Logic Expire)
缓存结构:{data: ..., expireTime: 时间戳}
查询流程:
① 查缓存 → 命中
② 检查expireTime是否过期
③ 未过期:直接返回data
已过期:
- 立刻返回旧数据(不阻塞请求!)
- 异步启动一个线程:获取互斥锁 → 查DB → 更新缓存
特点:不阻塞,但有短暂的数据不一致(旧数据)2.3 缓存雪崩:随机过期时间 + 降级熔断
防雪崩策略:
1. 随机化过期时间
原本:expire = 3600秒(全部同时过期)
改为:expire = 3600 + random(0, 600) 秒(分散在3600-4200秒之间过期)
2. 永不过期 + 后台刷新
缓存永不过期,后台定时任务主动刷新
适合数据变化不频繁的场景(如商品详情)
3. 多级缓存
本地缓存(JVM)→ Redis → DB
即使Redis雪崩,本地缓存还能扛一段时间
4. 降级熔断
DB被打挂时,直接返回降级数据(兜底页面)
不让请求继续打到DB,避免雪上加霜三、完整解决方案与代码
3.1 布隆过滤器防穿透
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
/**
* 布隆过滤器防缓存穿透
*/
@Component
public class ProductBloomFilter {
// Guava本地布隆过滤器(单机)
// 生产环境推荐用Redis的布隆过滤器(RedisBloom插件或Redisson)
private BloomFilter<String> bloomFilter;
@Autowired
private ProductMapper productMapper;
@PostConstruct
public void init() {
// 初始化:预期插入1000万个元素,误判率0.1%
bloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
10_000_000, // 预期元素数
0.001 // 误判率
);
// 从DB加载所有存在的productId到布隆过滤器
// 注意:数据量大时要分批加载,不要一次性加载到内存
productMapper.selectAllIds().forEach(id ->
bloomFilter.put(String.valueOf(id))
);
}
public boolean mightExist(Long productId) {
return bloomFilter.mightContain(String.valueOf(productId));
}
// 新商品入库时,同步更新布隆过滤器
public void add(Long productId) {
bloomFilter.put(String.valueOf(productId));
}
}
// 使用布隆过滤器的缓存服务
@Service
public class ProductCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
@Autowired
private ProductBloomFilter bloomFilter;
private static final String CACHE_PREFIX = "product:detail:";
private static final String EMPTY_VALUE = "NULL"; // 空值缓存的标记
private static final long CACHE_EXPIRE = 3600;
private static final long EMPTY_EXPIRE = 300; // 空值缓存5分钟(比正常短)
public Product getProduct(Long productId) {
// 第一步:布隆过滤器快速判断
if (!bloomFilter.mightExist(productId)) {
// 布隆过滤器说不存在,100%不存在,直接返回
return null;
}
String cacheKey = CACHE_PREFIX + productId;
String cached = redisTemplate.opsForValue().get(cacheKey);
// 命中空值缓存(防止穿透)
if (EMPTY_VALUE.equals(cached)) {
return null;
}
// 命中正常缓存
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 缓存未命中,查DB
Product product = productMapper.selectById(productId);
if (product == null) {
// 数据库也没有:缓存空值,防止重复穿透
redisTemplate.opsForValue().set(cacheKey, EMPTY_VALUE, EMPTY_EXPIRE, TimeUnit.SECONDS);
} else {
// 随机化过期时间,防止雪崩
long expire = CACHE_EXPIRE + ThreadLocalRandom.current().nextInt(600);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), expire, TimeUnit.SECONDS);
}
return product;
}
}3.2 互斥锁防击穿
/**
* 使用互斥锁防止缓存击穿
* 只有一个线程去查DB重建缓存,其他线程等待
*/
@Service
public class HotProductCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
private static final String LOCK_PREFIX = "lock:product:";
private static final long LOCK_EXPIRE = 10; // 锁10秒
public Product getProductWithMutex(Long productId) {
String cacheKey = "product:" + productId;
String lockKey = LOCK_PREFIX + productId;
// 尝试从缓存获取
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 缓存miss,尝试获取互斥锁
String lockValue = UUID.randomUUID().toString();
boolean locked = Boolean.TRUE.equals(
redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, LOCK_EXPIRE, TimeUnit.SECONDS)
);
if (locked) {
// 获取锁成功,查DB重建缓存
try {
// 双重检查(可能其他线程刚刚重建了缓存)
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
Product product = productMapper.selectById(productId);
if (product != null) {
long expire = 3600 + ThreadLocalRandom.current().nextInt(600);
redisTemplate.opsForValue().set(
cacheKey, JSON.toJSONString(product), expire, TimeUnit.SECONDS);
}
return product;
} finally {
// 释放锁(Lua脚本,原子操作)
releaseLock(lockKey, lockValue);
}
} else {
// 获取锁失败,等待后重试
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithMutex(productId); // 递归重试
}
}
private void releaseLock(String lockKey, String lockValue) {
String script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
}3.3 多级缓存防雪崩
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Service;
/**
* 多级缓存:本地Caffeine缓存 + Redis + DB
* 即使Redis雪崩,本地缓存仍能提供服务
*/
@Service
public class MultiLevelCacheService {
// L1: 本地缓存(JVM内,最快)
private final Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存1万个商品
.expireAfterWrite(5, TimeUnit.MINUTES) // 5分钟过期
.recordStats() // 开启统计
.build();
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProduct(Long productId) {
// L1: 本地缓存查询(纳秒级)
Product product = localCache.getIfPresent(productId);
if (product != null) {
return product;
}
// L2: Redis缓存查询(毫秒级)
String cacheKey = "product:" + productId;
try {
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
product = JSON.parseObject(cached, Product.class);
// 回填本地缓存
localCache.put(productId, product);
return product;
}
} catch (Exception e) {
// Redis异常(宕机/网络),降级到DB查询
log.error("Redis查询失败,降级到DB: {}", e.getMessage());
}
// L3: DB查询(最慢,最可靠)
product = productMapper.selectById(productId);
if (product != null) {
// 回填缓存(Redis可能不可用,不能影响流程)
try {
long expire = 3600 + ThreadLocalRandom.current().nextInt(600);
redisTemplate.opsForValue().set(
cacheKey, JSON.toJSONString(product), expire, TimeUnit.SECONDS);
} catch (Exception e) {
log.warn("回填Redis缓存失败: {}", e.getMessage());
}
localCache.put(productId, product);
}
return product;
}
}四、踩坑实录
坑1:空值缓存过期时间设太长,导致商品上架后用户看不到
场景:商品ID=999999 原本不存在 → 触发穿透 → 缓存空值,过期时间24小时
第二天,运营将新商品ID设为999999 上架了
用户查询 → 命中空值缓存 → 返回null → 用户看不到新商品!
告警:大量用户反馈新商品看不到解决方案:
// 空值缓存设置短过期时间(5分钟)
if (product == null) {
redisTemplate.opsForValue().set(cacheKey, "NULL", 300, TimeUnit.SECONDS);
// 5分钟内穿透保护,5分钟后新上架商品可以正常访问
}
// 或者:商品上架时主动删除可能存在的空值缓存
public void publishProduct(Long productId) {
// 业务操作
productMapper.updateStatus(productId, "PUBLISHED");
// 删除缓存(包括可能的空值缓存)
redisTemplate.delete("product:" + productId);
// 更新布隆过滤器
bloomFilter.add(productId);
}坑2:互斥锁递归深度过深,栈溢出
// 问题代码
public Product getProduct(Long id) {
// ...
if (!locked) {
Thread.sleep(100);
return getProduct(id); // 递归!
}
// ...
}
// 如果锁持续被占用30秒(持锁线程在查大表),
// 每100ms递归一次 → 300次递归 → StackOverflowError报错:
java.lang.StackOverflowError
at com.example.HotProductCacheService.getProduct(HotProductCacheService.java:45)
at com.example.HotProductCacheService.getProduct(HotProductCacheService.java:45)
(重复300次)解决方案:
// 改为循环 + 超时控制
public Product getProduct(Long id) {
long deadline = System.currentTimeMillis() + 3000; // 最多等3秒
while (System.currentTimeMillis() < deadline) {
String cached = redisTemplate.opsForValue().get("product:" + id);
if (cached != null) return JSON.parseObject(cached, Product.class);
boolean locked = tryLock(id);
if (locked) {
try {
return loadAndCache(id);
} finally {
releaseLock(id);
}
}
Thread.sleep(100); // 等待后重试(循环,不递归)
}
// 超时后降级:直接查DB(作为兜底)
log.warn("等待缓存重建超时,直接查DB: id={}", id);
return productMapper.selectById(id);
}坑3:缓存预热时时间设置一致,零点雪崩
这就是开篇故事的根因。
// 错误的预热代码(所有key同时过期)
public void preheatCache() {
List<Product> hotProducts = productMapper.selectTopN(100000);
for (Product p : hotProducts) {
redisTemplate.opsForValue().set(
"product:" + p.getId(),
JSON.toJSONString(p),
3600, // 所有key都是3600秒后过期
TimeUnit.SECONDS
);
}
}
// 正确的预热代码(随机化过期时间)
public void preheatCacheCorrect() {
List<Product> hotProducts = productMapper.selectTopN(100000);
Random random = new Random();
for (Product p : hotProducts) {
long expire = 3600 + random.nextInt(1800); // 3600~5400秒之间随机
redisTemplate.opsForValue().set(
"product:" + p.getId(),
JSON.toJSONString(p),
expire,
TimeUnit.SECONDS
);
}
}
// 更好的方案:永不过期 + 逻辑过期时间
record CacheWrapper<T>(T data, long expireTime) {}
public void preheatWithLogicalExpire() {
List<Product> hotProducts = productMapper.selectTopN(100000);
for (Product p : hotProducts) {
CacheWrapper<Product> wrapper = new CacheWrapper<>(p,
System.currentTimeMillis() + 3600_000L); // 逻辑过期时间
redisTemplate.opsForValue().set(
"product:" + p.getId(),
JSON.toJSONString(wrapper)
// 不设Redis过期时间,永不过期
);
}
}五、总结与延伸
三种缓存问题的防护方案总结:
缓存穿透防护:
├── 布隆过滤器(推荐):O(1)判断key是否存在,准确率99.9%+
└── 空值缓存(兜底):查不到也缓存,5分钟短过期
缓存击穿防护:
├── 互斥锁(强一致):只有一个线程重建,其他等待
└── 逻辑过期(高可用):不阻塞,返回旧数据,异步更新
缓存雪崩防护:
├── 随机化过期时间(最简单)
├── 永不过期+后台刷新(最彻底)
├── 多级缓存(最可靠)
└── 降级熔断(最后防线)生产实践原则:三种方案通常组合使用。布隆过滤器防穿透,随机TTL防雪崩,互斥锁防击穿,多级缓存做整体兜底。没有银弹,只有根据业务特点的合理组合。
