2024 年某天凌晨,我们的数据库 CPU 突然飙到 100%,大量接口超时,值班同学被告警惊醒。排查发现,数据库被打垮的那一刻,正好是一个被疯狂访问的"首页推荐"缓存 key 过期的瞬间 —— 缓存一失效,成千上万的请求同时穿过缓存涌向数据库,把它瞬间压垮。这是缓存的经典三大问题之一:缓存击穿。顺藤摸瓜,我们还发现了穿透和雪崩的隐患。投了几天做缓存专项治理,本文复盘这次实战。
问题背景
业务:电商首页,Redis 做缓存,MySQL 做存储
事故现象:
- 凌晨 03:00 整,数据库 CPU 从 30% 瞬间冲到 100%
- 大量接口 RT 飙升、超时,持续约 90 秒后自行恢复
- 03:00 是个很整的时间点 —— 可疑
现场排查:
# 1. 看 Redis 里首页推荐 key 的设置代码
String json = redis.get("home:recommend");
if (json == null) { // 缓存没有,回源
json = buildRecommend(); // 这步要跑一堆复杂 SQL,约 800ms
redis.setex("home:recommend", 3600, json); // 缓存 1 小时
}
# 2. 关键发现:这个 key 是 02:00 写入的,TTL 3600s
# -> 正好 03:00 过期
# 3. 过期那一瞬间发生了什么:
# - 首页 QPS 约 8000
# - 03:00:00 缓存失效,8000 个请求同时发现 "缓存没有"
# - 8000 个请求【同时】去执行 buildRecommend() 那堆重 SQL
# - 数据库瞬间被 8000 个 800ms 的重查询打爆
根因:
1. 缓存击穿:一个热点 key 过期的瞬间,所有请求一起回源打 DB
2. 没有任何机制限制"同一时刻只让一个请求去回源"
3. 顺带排查还发现:穿透(查不存在 ID)、雪崩(大量 key 同时过期)
的隐患也都存在
修复 1:缓存击穿 —— 热点 key 过期的瞬间
// === 击穿:某个【热点 key】过期瞬间,大量请求同时回源压垮 DB ===
// === 方案 A:互斥锁 —— 只让一个请求去回源,其余等待 ===
public String getRecommend() {
String json = redis.get("home:recommend");
if (json != null) return json; // 命中,直接返回
// 没命中,用 SETNX 抢一把分布式锁
String lockKey = "lock:home:recommend";
boolean locked = redis.set(lockKey, "1", "NX", "EX", 10);
if (locked) {
try {
// 抢到锁:双重检查,可能别人已经回源好了
json = redis.get("home:recommend");
if (json != null) return json;
// 真的没有,我来回源(全场只有我一个在打 DB)
json = buildRecommend();
redis.setex("home:recommend", 3600, json);
return json;
} finally {
redis.del(lockKey);
}
} else {
// 没抢到锁:稍等一下再读缓存(此时别人多半已回源完成)
Thread.sleep(50);
return getRecommend(); // 重试
}
}
// 效果:8000 个请求里,只有 1 个真正打 DB,其余 7999 个等它的结果。
// === 方案 B:逻辑过期 —— 热点 key 永不真过期,异步重建 ===
// 缓存值里塞一个"逻辑过期时间",key 本身不设 TTL(永不过期)
class CacheData { Object value; long logicalExpireAt; }
public String getRecommendByLogicalExpire() {
CacheData data = readCache("home:recommend");
if (data.logicalExpireAt > System.currentTimeMillis()) {
return (String) data.value; // 逻辑上没过期,直接用
}
// 逻辑过期了:抢到锁的线程【异步】去重建,
// 当前请求先返回旧值(可接受短暂的数据陈旧)
if (tryLock("lock:home:recommend")) {
asyncPool.submit(() -> {
rebuildAndWriteCache("home:recommend");
unlock("lock:home:recommend");
});
}
return (String) data.value; // 返回旧值,绝不阻塞、绝不打满 DB
}
// 互斥锁:强一致,但回源期间其他请求要等;逻辑过期:不等,但有短暂旧数据。
// 核心热点(首页这种)推荐逻辑过期,用户体验最稳。
修复 2:缓存穿透 —— 查一个根本不存在的数据
// === 穿透:请求查询一个【数据库里也不存在】的数据 ===
// 缓存里没有 -> 回源查 DB -> DB 里也没有 -> 不写缓存 -> 下次还回源
// 攻击者用大量随机不存在的 ID 刷接口,每次都直击 DB。
// === 方案 A:把"不存在"也缓存起来(缓存空值)===
public Product getProduct(Long id) {
String key = "product:" + id;
String json = redis.get(key);
if (json != null) {
// 命中了一个特殊标记,说明这个 id 已知不存在
if ("__NULL__".equals(json)) return null;
return parse(json);
}
Product p = productMapper.selectById(id);
if (p == null) {
// DB 里也没有:缓存一个空标记,TTL 短一点(防长期占内存)
redis.setex(key, 60, "__NULL__");
return null;
}
redis.setex(key, 3600, toJson(p));
return p;
}
// 这样同一个不存在的 id,60 秒内不会再打 DB。
// 缺点:如果是海量【不重复】的随机 id,空值缓存会占大量内存。
// === 方案 B:布隆过滤器 —— 在查 DB 之前先挡掉不存在的 ===
// 启动时把所有合法商品 id 灌进布隆过滤器
BloomFilter<Long> bloom = BloomFilter.create(
Funnels.longFunnel(), 10_000_000, 0.01); // 1000 万容量,误判率 1%
productMapper.selectAllIds().forEach(bloom::put);
public Product getProductWithBloom(Long id) {
// 布隆过滤器说"一定不存在",就直接返回,连缓存和 DB 都不碰
if (!bloom.mightContain(id)) {
return null;
}
// 布隆说"可能存在"(有 1% 误判),再走正常缓存逻辑
return getProduct(id);
}
// 布隆过滤器特性:说"不存在"绝对准,说"存在"可能误判。
// 用它做第一道闸,能挡掉绝大多数恶意的不存在请求。
// 海量随机 id 攻击的场景,布隆过滤器比空值缓存更省内存。
修复 3:缓存雪崩 —— 大量 key 在同一时刻集体过期
// === 雪崩:大量缓存 key 在【同一时刻】集体失效,请求齐刷刷打 DB ===
// 典型成因:系统启动时批量预热缓存,都设了同样的 TTL,
// 于是它们也会在同一秒一起过期。
// === 方案 A:过期时间加随机抖动,把失效时间打散 ===
public void cacheWithJitter(String key, String value) {
int baseTtl = 3600; // 基础 1 小时
int jitter = ThreadLocalRandom.current().nextInt(600); // 0~10 分钟随机
redis.setex(key, baseTtl + jitter, value);
// 一批 key 的过期时间被摊到 60~70 分钟这个区间,不再齐步走
}
// === 方案 B:多级缓存,本地缓存兜底 ===
// 加一层进程内本地缓存(Caffeine),Redis 整体抖动时本地还能扛一阵
public String getWithMultiLevel(String key) {
String local = localCache.getIfPresent(key); // 1. 先查本地
if (local != null) return local;
String json = redis.get(key); // 2. 再查 Redis
if (json != null) {
localCache.put(key, json);
return json;
}
json = loadFromDb(key); // 3. 回源
redis.setex(key, 3600, json);
localCache.put(key, json);
return json;
}
// === 方案 C:Redis 整体挂掉时的降级与限流 ===
// 雪崩的极端情况是 Redis 集群整个不可用,此时:
// 1. 回源 DB 这条路必须加限流,只放过 DB 扛得住的流量
// 2. 超出的请求快速失败 / 返回降级数据,绝不能让它们全压向 DB
// 缓存层是 DB 的"保护伞",伞塌了也要靠限流给 DB 撑住最后一道。
修复 4:缓存与数据库的一致性
// === 数据更新时,缓存怎么处理才不会读到脏数据 ===
// === 错误做法 1:先删缓存,再更新 DB ===
// redis.del(key); // 1. 删缓存
// productMapper.update(p); // 2. 更新 DB
// 并发问题:删缓存后、更新 DB 前,另一个读请求进来,
// 发现缓存空,回源读到【旧】数据,又把旧数据写回了缓存 -> 长期脏。
// === 错误做法 2:更新 DB,再更新缓存 ===
// 两个并发写,DB 里 A 后提交、缓存里却 B 后写,缓存和 DB 不一致。
// 而且很多缓存值是算出来的,每次写都重算不划算。
// === 推荐做法:先更新 DB,再删除缓存(Cache Aside)===
public void updateProduct(Product p) {
productMapper.update(p); // 1. 先更新数据库
redis.del("product:" + p.getId()); // 2. 再删除缓存
// 下次读请求发现缓存空,自然回源加载最新数据。
// "删除"而非"更新":让缓存惰性重建,简单且不易错。
}
// === 进阶:延迟双删,应对"删除前的旧读请求把旧值写回" ===
public void updateProductWithDoubleDelete(Product p) {
redis.del("product:" + p.getId()); // 删一次
productMapper.update(p); // 更新 DB
// 延迟一会儿(覆盖旧读请求回源+回写的时间窗)再删一次
delayPool.schedule(() ->
redis.del("product:" + p.getId()), 500, TimeUnit.MILLISECONDS);
}
// === 最终一致兜底:给缓存设合理 TTL ===
// 不管一致性方案多周全,都给缓存留一个 TTL,
// 即使某次删除失败,过期后也会自动重建 —— 这是最后一道保险。
// 强一致场景可订阅 binlog(如 Canal),DB 变更自动驱动缓存失效。
修复 5:热点 key 与大 key 的治理
# === 大 key:单个 value 特别大,操作它会阻塞 Redis ===
# Redis 是单线程,一个大 key 的读写/删除会卡住所有其他请求
$ redis-cli --bigkeys # 扫描出最大的 key
# Biggest string found 'home:recommend' has 8 MB <- 8MB 的 string!
# 大 key 的危害:
# - 读它:一次网络传输 8MB,带宽被吃满,其他命令排队
# - 删它(DEL):单线程删 8MB 会阻塞,要用 UNLINK 异步删
# 治理:拆分大 key(按字段拆成多个小 key),或压缩 value
# === 热点 key:某个 key 的访问量畸高,把单个 Redis 节点打满 ===
$ redis-cli --hotkeys # 需开启 maxmemory-policy 为 lfu
# 治理热点 key:
# 1. 加本地缓存:热点 key 在每个应用实例本地缓存一份,
# 绝大部分请求被本地拦下,根本到不了 Redis
# 2. key 打散:把一个热点 key 复制成 N 份(key#1...key#N),
# 读时随机选一份,把流量摊到多个 Redis slot / 节点上
// 热点 key 本地缓存兜底(和雪崩方案 B 同一思路)
private final Cache<String, String> hotLocal = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.SECONDS) // 本地只缓存 5 秒,容忍秒级延迟
.build();
public String getHotKey(String key) {
// 本地缓存 5 秒,意味着对单个热点 key,
// 每个实例每 5 秒最多打 Redis 一次,Redis 压力骤降
return hotLocal.get(key, k -> redis.get(k));
}
修复 6:缓存监控告警
# 缓存问题的外在信号:命中率掉、DB 压力升
groups:
- name: redis-cache
rules:
# 1. 缓存命中率下降(穿透/击穿/雪崩共同的早期信号)
- alert: CacheHitRateLow
expr: |
rate(cache_hit_total[5m])
/ (rate(cache_hit_total[5m]) + rate(cache_miss_total[5m])) < 0.9
for: 5m
annotations:
summary: "缓存命中率 < 90%,排查穿透/击穿/雪崩"
# 2. 回源 DB 的 QPS 突增(请求绕过缓存直击 DB)
- alert: CacheMissToDbSurge
expr: rate(cache_miss_total[5m]) > 2000
for: 3m
annotations:
summary: "缓存回源 QPS 突增,可能发生击穿或雪崩"
# 3. Redis 内存使用率过高(空值缓存滥用 / 大 key)
- alert: RedisMemoryHigh
expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.85
for: 10m
annotations:
summary: "Redis 内存 > 85%,排查大 key 或空值缓存"
# 4. Redis 慢查询(大 key 操作的典型表现)
- alert: RedisSlowlog
expr: increase(redis_slowlog_length[5m]) > 10
annotations:
summary: "Redis 慢查询增多,排查大 key 操作"
优化效果
指标 治理前 治理后
=============================================================
热点 key 过期 8000 请求齐打 DB 互斥锁,仅 1 个回源
首页缓存 到点真过期 逻辑过期,异步重建
不存在数据查询 每次直击 DB 空值缓存 + 布隆过滤器
批量缓存过期 同一秒集体失效 TTL 加 0-10 分钟随机
缓存与 DB 一致性 先删后更(脏数据) 先更后删 + 延迟双删 + TTL
大 key 8MB string 拆分 + UNLINK 异步删
热点 key 全打单个 Redis 节点 本地缓存 5s 兜底
缓存可观测性 无 命中率/回源QPS/内存监控
压测(首页 10000 QPS,主动让热点 key 过期):
- 治理前:DB CPU 瞬间 100%,大量超时
- 治理后:回源请求始终 = 1,DB 平稳,首页 P99 45ms
治理过程:
- 定位缓存击穿:0.5 天
- 热点 key 互斥锁 / 逻辑过期改造:2 天
- 穿透防护(空值缓存 + 布隆过滤器):1.5 天
- 雪崩防护(TTL 随机 + 多级缓存):1 天
- 一致性方案 + 大 key/热点 key 治理:2 天
- 缓存监控接入:1 天
避坑清单
- 缓存击穿是单个热点 key 过期瞬间大量请求齐回源,用互斥锁或逻辑过期
- 互斥锁保证只有一个请求回源,逻辑过期则返回旧值并异步重建,核心场景选后者
- 缓存穿透是查询不存在的数据,每次直击 DB,用空值缓存或布隆过滤器拦截
- 空值缓存适合少量不存在 id,海量随机 id 攻击用布隆过滤器更省内存
- 缓存雪崩是大量 key 同时过期,过期时间一定要加随机抖动打散
- 加本地缓存做多级缓存,Redis 抖动时还有一层兜底
- 更新数据用先更 DB 再删缓存,不要先删缓存再更 DB(会写回旧值)
- 对一致性要求高可用延迟双删,但无论如何都要给缓存留 TTL 兜底
- 大 key 操作会阻塞单线程的 Redis,要拆分,删除用 UNLINK 而非 DEL
- 热点 key 用本地缓存兜底或 key 打散,避免单个 Redis 节点被打满
总结
这次凌晨数据库被打垮的事故,事后看根因清晰得近乎"经典"——它就是教科书里写明白白的缓存击穿。但真正经历一遍才明白,这些被反复讲述的"经典问题"之所以经典,正是因为它们太容易被忽略:我们的首页推荐缓存代码逻辑上毫无破绽,缓存命中就返回、不命中就回源再写回,平时跑得好好的,直到某个整点,那个被八千 QPS 疯狂访问的热点 key 的 TTL 走到了尽头。就在它失效的那一瞬间,八千个请求几乎在同一刻发现"缓存里没有",然后八千个请求一起冲向数据库去执行那段需要八百毫秒的复杂查询——缓存这把保护伞,在它失效的刹那,反而成了把所有压力精确地、同步地汇聚到数据库上的漏斗。这件事让我对缓存有了一个更本质的认识:缓存的价值不只是"快",更是"挡"——它挡在数据库前面,把绝大部分流量拦下来。而缓存的三大经典问题——击穿、穿透、雪崩,本质上都是"挡"这个功能在不同情况下失效了:击穿是单个热点 key 这块挡板突然消失,穿透是请求专挑挡板上没有的洞钻过去,雪崩则是大片挡板在同一时刻一起消失。想明白这一层,三者的解法方向就都顺理成章了:对击穿,要保证热点 key 失效时只放一个请求去回源(互斥锁),或者干脆让它逻辑上永不过期、后台异步悄悄重建(逻辑过期);对穿透,要让"不存在"这件事本身也能被挡下来,要么把空结果也缓存起来,要么用布隆过滤器在最前面筑一道墙;对雪崩,要给一批 key 的过期时间加上随机抖动,让它们的失效时间分散开,不要齐步走。除了这三大问题,这次治理还顺带让我们正视了缓存与数据库的一致性——记住"先更新数据库再删除缓存"这个顺序,并且无论一致性方案设计得多精巧,都一定要给缓存留一个 TTL 作为最后的兜底,因为它能在任何一次删除操作意外失败时,靠自然过期把系统拉回正轨。最后我想强调的是,缓存层是数据库的护城河,但护城河本身也需要被监控:缓存命中率、回源到数据库的 QPS、Redis 的内存和慢查询,这些指标平时不起眼,可一旦缓存出问题,它们会比数据库的告警更早地发出信号。如果当时我们有一条"缓存命中率骤降"的告警,也许就不必等到数据库 CPU 冲到 100%、把值班同学从睡梦中惊醒了。
—— 别看了 · 2026