缓存三大坑:穿透 + 雪崩 + 击穿实战复盘——每个都能让数据库瞬间崩掉
引言
线上告警炸了:
[P1] MySQL CPU 100%
[P1] 应用接口 P99 5s
[P1] 数据库连接池打满
打开 Grafana一看:
Redis 命中率从 95% 掉到 30%
MySQL QPS 从 2 千飙到 8 万
应用线程池全满
典型的缓存失效问题。但具体是哪种?
缓存失效有三种典型形态——穿透、雪崩、击穿,每个都能让数据库瞬间崩溃,但原因和解决方案完全不同。
这篇文章把三个真实事故合并在一起复盘,每个坑附事故场景、排查过程、代码方案。看完这篇,下次再遇到你能 5 分钟定位是哪一种。
一、先搞清三个坑的区别
很多人把这三个概念搞混。一张表说清:
| 概念 | 现象 | 根因 | 典型场景 |
|---|---|---|---|
| 穿透 | 查的数据在缓存和 DB 都没有 | 数据不存在 | 恶意攻击、爬虫乱查 ID |
| 雪崩 | 大量缓存同时失效 | 过期时间相同 | 批量预热后统一过期 |
| 击穿 | 单个热点 Key 失效瞬间 | 热点 + 过期 | 秒杀商品、首页推荐 |
记忆口诀:
穿透:查没有的 → DB 被"打穿"
雪崩:集体失效 → DB 被"压塌"
击穿:热点失效 → DB 被"击穿"
二、事故一:缓存穿透——压垮 DB 的隐形杀手
2.1 事故复盘
凌晨 3 点告警:
[告警] MySQL CPU 95%
[告警] 商品查询接口 P99 8s
[告警] 数据库连接池满
排查 Grafana:
Redis QPS: 20 万/s(正常 5 万)
Redis 命中率: 12%(正常 95%)
MySQL QPS: 18 万/s(正常 5 千)
查询日志:
GET product:99999999 → nil
GET product:99999998 → nil
GET product:99999997 → nil
...(全是查不存在的 ID)
2.2 事故场景
某营销活动被黑客盯上,对方写脚本批量请求不存在的商品 ID:
# 攻击脚本
for id in {99999999..99999999-1000000}
do
curl "https://api.example.com/products/$id"
done
这些 ID 在数据库根本不存在,每次请求都打到 DB:
应用 → Redis(miss)→ MySQL(select,无结果)
应用 → Redis(miss)→ MySQL(select,无结果)
... 18 万次/秒
2.3 排查过程
第 1 步:看 Redis 命中率
Grafana → Redis 面板 → 命中率掉到 12%
→ 说明大量 Key 没在缓存
第 2 步:看慢查询
-- MySQL 慢日志
SELECT * FROM products WHERE id = 99999999;
SELECT * FROM products WHERE id = 99999998;
...
这些 SQL 都是"查不到数据",但每次都消耗 DB 资源。
第 3 步:看应用日志
2026-08-08 03:00:15 [http-nio-8080-exec-1] GET /products/99999999
2026-08-08 03:00:15 [http-nio-8080-exec-2] GET /products/99999998
...
同一个 UA、同一秒大量请求不存在的 ID → 攻击特征。
2.4 解决方案
方案 1:空值缓存(最简单)
查不到的数据也缓存起来,下次直接返回 null:
public Product getProduct(Long id) {
String key = "product:" + id;
Object cached = redis.get(key);
// 缓存命中
if (cached != null) {
if (cached instanceof NullObject) {
return null; // 空值缓存命中
}
return (Product) cached;
}
// 缓存未命中,查 DB
Product product = productMapper.selectById(id);
if (product == null) {
// 关键:空值也缓存,但过期时间短(5 分钟)
redis.set(key, NULL_OBJECT, 5, TimeUnit.MINUTES);
return null;
}
// 正常数据缓存 1 小时
redis.set(key, product, 1, TimeUnit.HOURS);
return product;
}
优点:实现简单。
缺点:
- 大量不存在的 ID 会占内存(几百万空值)
- 攻击者改个 ID 就绕过
方案 2:布隆过滤器(更彻底)
预先把"所有存在的 ID"放到布隆过滤器:
启动时:
遍历所有商品 ID → 加入布隆过滤器
查询时:
ID 在布隆过滤器? → 是 → 查 Redis/DB
→ 否 → 直接返回 null(不存在)
布隆过滤器特性:
- 说"存在" → 可能存在(误判率可控)
- 说"不存在" → 一定不存在
- 内存占用极小(1 亿 ID 约 100MB)
@Service
public class ProductService {
@Autowired
private BloomFilter<Long> productIdBloomFilter;
public Product getProduct(Long id) {
// 1. 先过布隆过滤器
if (!productIdBloomFilter.mightContain(id)) {
return null; // 一定不存在
}
// 2. 查 Redis
String key = "product:" + id;
Object cached = redis.get(key);
if (cached != null) {
return (Product) cached;
}
// 3. 查 DB
Product product = productMapper.selectById(id);
if (product != null) {
redis.set(key, product, 1, TimeUnit.HOURS);
}
return product;
}
}
布隆过滤器初始化:
@Configuration
public class BloomFilterConfig {
@Bean
public BloomFilter<Long> productIdBloomFilter(ProductMapper mapper) {
// 预期 1 亿 ID,误判率 0.01%
BloomFilter<Long> filter = BloomFilter.create(
Funnels.longFunnel(), 100_000_000L, 0.0001);
// 启动时加载所有 ID
try (Cursor<Long> cursor = mapper.selectAllIds()) {
cursor.forEachRemaining(filter::put);
}
return filter;
}
}
方案 3:两者结合(生产推荐)
public Product getProduct(Long id) {
// 1. 布隆过滤器:拦截大部分不存在的 ID
if (!productIdBloomFilter.mightContain(id)) {
return null;
}
// 2. Redis
String key = "product:" + id;
Object cached = redis.get(key);
if (cached != null) {
return cached instanceof NullObject ? null : (Product) cached;
}
// 3. DB(布隆过滤器有误判,可能仍不存在)
Product product = productMapper.selectById(id);
if (product == null) {
redis.set(key, NULL_OBJECT, 5, TimeUnit.MINUTES);
return null;
}
redis.set(key, product, 1, TimeUnit.HOURS);
return product;
}
2.5 复盘结论
| 措施 | 效果 |
|---|---|
| 空值缓存 | 解决已知不存在的 ID 反复查 |
| 布隆过滤器 | 拦截 99.99% 的非法 ID |
| WAF 限流 | 治标:限制单 IP 请求频率 |
| 接口鉴权 | 治本:不让未授权的请求进来 |
三、事故二:缓存雪崩——集体失效的群体事件
3.1 事故复盘
某天晚上 20:00,电商大促开始前 1 小时:
[告警] MySQL CPU 100%
[告警] Redis 命中率 0%
[告警] 商品列表接口超时
Grafana 截图:
20:00 整点:
Redis 命中率从 95% → 0%
MySQL QPS 从 2 千 → 30 万
MySQL CPU 飙到 100%
应用大量超时
3.2 事故场景
大促前预热数据,把几百万商品全部加载到 Redis:
// ❌ 预热代码
@Scheduled(cron = "0 0 19 * * *") // 19:00 预热
public void warmupCache() {
List<Product> products = productMapper.selectAll();
for (Product p : products) {
// 全部缓存 1 小时(错误:统一过期时间)
redis.set("product:" + p.getId(), p, 1, TimeUnit.HOURS);
}
}
19:00 预热 → 20:00 整点全部同时过期 → 几百万请求瞬间打到 DB。
3.3 排查过程
第 1 步:看 Redis Key 过期时间分布
# 随机抽样 1000 个 product:* Key,看 TTL
redis-cli --bigkeys
# 发现大量 Key 的 TTL 都是 3598-3600 秒
# 即将同时过期
第 2 步:看 QPS 时间点
19:59 → Redis 命中率 95%,MySQL QPS 2 千
20:00 → Redis 命中率 0%,MySQL QPS 30 万(瞬间 150 倍)
20:01 → 服务雪崩,应用超时
时间点完全吻合"统一过期"特征。
3.4 解决方案
方案 1:过期时间随机化(最简单)
// ❌ 错误:统一过期时间
redis.set(key, value, 1, TimeUnit.HOURS);
// ✅ 正确:过期时间 + 随机扰动
int baseExpire = 3600; // 1 小时
int random = new Random().nextInt(600); // 0-10 分钟随机
redis.set(key, value, baseExpire + random, TimeUnit.SECONDS);
让原本 20:00 同时过期的 Key,分散到 20:00-20:10 之间陆续过期。
生产建议:基础时间 ± 10-30% 的随机扰动。
方案 2:永不过期 + 异步更新(彻底解决)
@Service
public class ProductService {
@Autowired
private ProductCacheRefresher refresher;
public Product getProduct(Long id) {
String key = "product:" + id;
Object cached = redis.get(key);
if (cached != null) {
// 逻辑过期:检查是否需要刷新
ProductCacheEntry entry = (ProductCacheEntry) cached;
if (entry.isExpired()) {
// 异步刷新,当前返回旧数据
refresher.refreshAsync(id);
}
return entry.getProduct();
}
// 缓存真的没有,查 DB
Product product = productMapper.selectById(id);
if (product != null) {
redis.set(key, new ProductCacheEntry(product, LocalDateTime.now()), 7, TimeUnit.DAYS);
}
return product;
}
}
ProductCacheEntry:
@Data
public class ProductCacheEntry implements Serializable {
private Product product;
private LocalDateTime refreshAt; // 逻辑过期时间
public boolean isExpired() {
return LocalDateTime.now().isAfter(refreshAt);
}
}
异步刷新:
@Service
public class ProductCacheRefresher {
@Async("cacheRefresherExecutor")
public void refreshAsync(Long id) {
Product product = productMapper.selectById(id);
if (product != null) {
redis.set("product:" + id,
new ProductCacheEntry(product, LocalDateTime.now().plusHours(1)),
7, TimeUnit.DAYS);
}
}
}
优点:
- 缓存永不过期(物理上 7 天兜底)
- 逻辑过期触发异步刷新
- 请求永远不打到 DB
方案 3:多级缓存(终极方案)
请求 → 本地缓存(Caffeine)→ Redis → DB
100μs 1ms 10ms
@Service
public class ProductService {
// L1:本地缓存
private Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Product getProduct(Long id) {
// L1 本地缓存
Product cached = localCache.getIfPresent(id);
if (cached != null) return cached;
// L2 Redis
String key = "product:" + id;
cached = redis.get(key);
if (cached != null) {
localCache.put(id, cached);
return cached;
}
// L3 DB
Product product = productMapper.selectById(id);
if (product != null) {
redis.set(key, product, 1, TimeUnit.HOURS);
localCache.put(id, product);
}
return product;
}
}
多级缓存让 Redis 失效时,本地缓存兜底,DB 几乎没压力。
3.5 复盘结论
| 措施 | 效果 |
|---|---|
| 过期时间随机化 | 分散失效,简单有效 |
| 永不过期 + 异步刷新 | 彻底杜绝雪崩 |
| 多级缓存 | 本地缓存兜底 |
| 限流降级 | 雪崩时保护 DB |
四、事故三:缓存击穿——单点热点的瞬间爆破
4.1 事故复盘
某天 10:00:00 整点开抢茅台:
[告警] MySQL CPU 100%
[告警] product:12345 (茅台) 查询 QPS 10 万
[告警] 应用连接池满
Grafana:
09:59:59 → Redis 命中率 100%(茅台在缓存)
10:00:00 → 茅台缓存过期
10:00:01 → 10 万 QPS 全部打到 DB
10:00:02 → DB CPU 飙到 100%
10:00:03 → 应用连接池耗尽,开始报错
4.2 事故场景
秒杀商品缓存 1 小时,正好在 10:00 过期,1 秒内涌入 10 万请求:
09:00 → 加载茅台到缓存,过期 1 小时
10:00 → 缓存失效
10:00:00.001 → 10 万请求同时进来
→ Redis miss
→ 10 万请求同时查 DB
→ DB 瞬间炸了
4.3 排查过程
第 1 步:看告警时间点
10:00:01 [告警] MySQL CPU 100%
10:00 整点,秒杀开始时间。吻合。
第 2 步:看 Redis Key 的 TTL
# 09:59 查看
redis.ttl("product:12345")
→ 60(还有 60 秒过期)
# 10:00 查看
redis.ttl("product:12345")
→ -2(已过期)
第 3 步:看 MySQL 慢查询
SELECT * FROM products WHERE id = 12345;
SELECT * FROM products WHERE id = 12345;
SELECT * FROM products WHERE id = 12345;
... 10 万次同样的 SQL
单个 Key 失效 + 高 QPS = 击穿特征。
4.4 解决方案
方案 1:互斥锁(SETNX,最常用)
只有一个请求查 DB,其他等结果:
@Service
public class ProductService {
public Product getProduct(Long id) {
String key = "product:" + id;
Object cached = redis.get(key);
if (cached != null) {
return (Product) cached;
}
// 缓存未命中,加互斥锁
String lockKey = "lock:product:" + id;
try {
// SETNX,超时 10 秒
boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 双重检查(可能其他线程已经写好了缓存)
cached = redis.get(key);
if (cached != null) {
return (Product) cached;
}
// 查 DB + 写缓存
Product product = productMapper.selectById(id);
if (product != null) {
redis.set(key, product, 1, TimeUnit.HOURS);
}
return product;
} else {
// 没拿到锁,等待重试
Thread.sleep(50);
return getProduct(id); // 递归重试
}
} finally {
redis.delete(lockKey);
}
}
}
关键点:
setIfAbsent(SETNX):原子性获取锁- 双重检查:避免重复查 DB
- 短暂等待 + 重试:让其他线程拿到缓存后的结果
- 锁超时:防止持锁线程异常导致死锁
方案 2:逻辑过期(不真正过期)
缓存永远不过期,但记录"逻辑过期时间":
public Product getProduct(Long id) {
String key = "product:" + id;
Object cached = redis.get(key);
if (cached == null) {
// 缓存真的没有,走互斥锁查 DB
return getWithMutex(id);
}
ProductCacheEntry entry = (ProductCacheEntry) cached;
// 逻辑过期 → 返回旧数据 + 异步刷新
if (entry.isExpired()) {
// 尝试拿锁,拿到才刷新
if (redis.setIfAbsent("lock:refresh:" + id, "1", 10, TimeUnit.SECONDS)) {
refresher.refreshAsync(id); // 异步刷新
}
// 立即返回旧数据
return entry.getProduct();
}
return entry.getProduct();
}
关键点:
- 物理上永不过期(或 7 天兜底)
- 逻辑过期触发异步刷新
- 请求永远拿到数据(旧数据 + 后台刷新)
- 没有任何请求打到 DB
方案 3:永不过期(最暴力)
热点数据直接不过期:
// 秒杀商品永不过期
redis.set(key, product); // 不设 TTL
// 通过定时任务更新
@Scheduled(cron = "0 0 * * * *") // 每小时刷新
public void refreshHotProducts() {
List<Long> hotIds = getHotProductIds();
for (Long id : hotIds) {
Product product = productMapper.selectById(id);
redis.set("product:" + id, product);
}
}
适用:超热点数据(首页推荐、秒杀商品)。
4.5 复盘结论
| 措施 | 效果 |
|---|---|
| 互斥锁 | 单请求查 DB,其他等待 |
| 逻辑过期 + 异步刷新 | 不阻塞请求 |
| 永不过期 + 定时刷新 | 彻底解决,但代码复杂 |
| 多级缓存 | 本地缓存兜底 |
五、三大坑对比汇总
5.1 特征对比
| 维度 | 穿透 | 雪崩 | 击穿 |
|---|---|---|---|
| 失效范围 | 不存在的数据 | 大量 Key 同时失效 | 单个热点 Key 失效 |
| 请求特征 | 查不存在的 ID | 各种 Key | 同一热点 Key 高 QPS |
| 触发原因 | 攻击/爬虫 | 过期时间相同 | 热点过期 |
| 影响速度 | 立即 | 瞬间 | 瞬间 |
| 持续时间 | 持续 | 几分钟 | 几秒-几分钟 |
5.2 方案对比
| 坑 | 主要方案 | 兜底方案 |
|---|---|---|
| 穿透 | 布隆过滤器 + 空值缓存 | WAF 限流 |
| 雪崩 | 过期时间随机化 + 异步刷新 | 限流降级 |
| 击穿 | 互斥锁 + 逻辑过期 | 多级缓存 |
5.3 排查决策树
DB 突然压力飙升,是哪个坑?
1. 看 Redis 命中率
└─ 命中率高(95%)→ 单个 Key 问题 → 击穿
2. 命中率低
├─ 看 MySQL 查询是否都是"查不到"
│ └─ 是 → 穿透
└─ 查到的是各种数据
└─ 看时间点
├─ 集中失效(同一时刻)→ 雪崩
└─ 持续低命中 → 容量不足/预热不够
六、统一防御方案
实际项目中,三个坑要一起防:
@Service
public class CachedProductService {
@Autowired
private ProductMapper productMapper;
@Autowired
private RedisTemplate<String, Object> redis;
@Autowired
private BloomFilter<Long> productBloomFilter; // 防穿透
@Autowired
private CacheRefresher refresher; // 防雪崩(异步刷新)
// 本地缓存:防击穿兜底
private Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Product getProduct(Long id) {
// ===== 1. 防穿透:布隆过滤器 =====
if (!productBloomFilter.mightContain(id)) {
return null;
}
// ===== 2. L1 本地缓存(防击穿)=====
Product local = localCache.getIfPresent(id);
if (local != null) return local;
// ===== 3. L2 Redis =====
String key = "product:" + id;
Object cached = redis.opsForValue().get(key);
if (cached != null) {
if (cached instanceof NullObject) return null;
ProductCacheEntry entry = (ProductCacheEntry) cached;
// ===== 4. 防雪崩:逻辑过期 + 异步刷新 =====
if (entry.isExpired()) {
if (redis.opsForValue().setIfAbsent(
"lock:refresh:" + id, "1", Duration.ofSeconds(10))) {
refresher.refreshAsync(id);
}
}
localCache.put(id, entry.getProduct());
return entry.getProduct();
}
// ===== 5. 防击穿:互斥锁 =====
return getWithMutex(id);
}
private Product getWithMutex(Long id) {
String lockKey = "lock:product:" + id;
String key = "product:" + id;
try {
if (Boolean.TRUE.equals(
redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10)))) {
// 拿到锁,双重检查
Object cached = redis.opsForValue().get(key);
if (cached instanceof ProductCacheEntry) {
return ((ProductCacheEntry) cached).getProduct();
}
// 查 DB
Product product = productMapper.selectById(id);
if (product == null) {
// 防穿透:空值短缓存
redis.opsForValue().set(key, NULL_OBJECT, Duration.ofMinutes(5));
return null;
}
// 防雪崩:过期时间随机化
int ttl = 3600 + new Random().nextInt(600);
redis.opsForValue().set(key,
new ProductCacheEntry(product, LocalDateTime.now().plusHours(1)),
Duration.ofSeconds(ttl));
return product;
} else {
Thread.sleep(50);
return getProduct(id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
redis.delete(lockKey);
}
}
}
七、监控指标
再好的方案也要监控。三个坑都要监控这些指标:
7.1 关键指标
| 指标 | 含义 | 告警阈值 |
|---|---|---|
| Redis 命中率 | 命中数 / 总查询 | < 80% 告警 |
| DB QPS | 数据库查询数 | > 5 万/秒 告警 |
| 慢查询数 | 超过 1s 的 SQL | > 10/分钟 告警 |
| 接口 P99 | 响应时间 | > 1s 告警 |
| 空值缓存数 | Redis 中 NULL_OBJECT 数 | 监控异常增长 |
7.2 Grafana 面板
# Redis 命中率
redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)
# 缓存写入速率(判断预热是否正确)
rate(redis_commands_total{cmd="set"}[1m])
# DB QPS(核心)
rate(mysql_queries_total[1m])
7.3 告警规则
groups:
- name: cache-alerts
rules:
# 命中率掉到 80% 以下
- alert: LowCacheHitRate
expr: |
1 - (rate(redis_keyspace_hits_total[5m])
/ (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m])))
> 0.2
for: 2m
labels:
severity: warning
annotations:
summary: "Redis 命中率低于 80%"
# DB QPS 异常飙升
- alert: HighDBQPS
expr: rate(mysql_queries_total[1m]) > 50000
for: 1m
labels:
severity: critical
annotations:
summary: "MySQL QPS 超过 5 万"
八、总结
三大坑对照
| 坑 | 核心问题 | 主方案 | 兜底方案 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 布隆过滤器 + 空值缓存 | WAF 限流 |
| 雪崩 | 大量 Key 同时失效 | 过期时间随机化 + 异步刷新 | 限流降级 |
| 击穿 | 热点 Key 失效瞬间 | 互斥锁 + 逻辑过期 | 多级缓存 |
排查口诀
命中率看穿与崩,慢查询看击穿。
同一时刻大量 miss → 雪崩
单 ID 反复查不到 → 穿透
单 ID 高 QPS miss → 击穿
防御口诀
穿透用布隆 + 空值
雪崩用随机 + 异步
击穿用锁 + 逻辑过期
全兜底用多级缓存
监控必装
- Redis 命中率(< 80% 必告警)
- DB QPS(异常飙升必告警)
- 慢查询数(持续高位必告警)
- 接口 P99(> 1s 必告警)
一句话
穿透是查没有的、雪崩是集体失效、击穿是热点失效。布隆过滤 + 随机过期 + 互斥锁 + 多级缓存,四个方案组合起来,99% 的缓存事故都能预防。
互动话题:你遇到过最严重的缓存事故是哪种?最后怎么解决的?欢迎留言讨论!
参考资料
标题:缓存三大坑:穿透 + 雪崩 + 击穿实战复盘——每个都能让数据库瞬间崩掉
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/11/1786163205745.html
公众号:服务端技术精选
- 引言
- 一、先搞清三个坑的区别
- 二、事故一:缓存穿透——压垮 DB 的隐形杀手
- 2.1 事故复盘
- 2.2 事故场景
- 2.3 排查过程
- 2.4 解决方案
- 方案 1:空值缓存(最简单)
- 方案 2:布隆过滤器(更彻底)
- 方案 3:两者结合(生产推荐)
- 2.5 复盘结论
- 三、事故二:缓存雪崩——集体失效的群体事件
- 3.1 事故复盘
- 3.2 事故场景
- 3.3 排查过程
- 3.4 解决方案
- 方案 1:过期时间随机化(最简单)
- 方案 2:永不过期 + 异步更新(彻底解决)
- 方案 3:多级缓存(终极方案)
- 3.5 复盘结论
- 四、事故三:缓存击穿——单点热点的瞬间爆破
- 4.1 事故复盘
- 4.2 事故场景
- 4.3 排查过程
- 4.4 解决方案
- 方案 1:互斥锁(SETNX,最常用)
- 方案 2:逻辑过期(不真正过期)
- 方案 3:永不过期(最暴力)
- 4.5 复盘结论
- 五、三大坑对比汇总
- 5.1 特征对比
- 5.2 方案对比
- 5.3 排查决策树
- 六、统一防御方案
- 七、监控指标
- 7.1 关键指标
- 7.2 Grafana 面板
- 7.3 告警规则
- 八、总结
- 三大坑对照
- 排查口诀
- 防御口诀
- 监控必装
- 一句话
- 参考资料
评论