故事发生在某个月的 1 号早上 8 点 59 分。对做过财税类业务的后端来说,这个时间点有种特殊的仪式感:征期开始,全国的会计同时打开系统,点“申报”。我端着豆浆走到工位,还没坐下,钉钉“叮叮叮”三连:MySQL 活跃连接数告警、接口 P99 超时告警、以及运营同事的一句“在吗?页面转圈圈”。
更气人的是,前一天晚上压测报告还是绿的,缓存命中率 99%。我一边喝豆浆一边怀疑人生:缓存明明是好的,数据库怎么就被打穿了?这篇是那天的复盘,也顺手把“穿透、击穿、雪崩”三个总被面试官问、又总被自己搞混的概念一次捋清。
一、现象:命中率“跳水”,QPS 曲线长出一根针
先说原则:先看曲线、保留现场,再动手。缓存类故障最直观的证据不在日志里,在监控曲线上。当时我打开 Grafana(数据来自 redis_exporter 和 MySQL 监控),看到三条曲线在同一时间点同时变形:
- Redis 命中率:从 99% 直接掉到 41%,持续大约 40 秒,然后慢慢爬回去;
- MySQL QPS 与活跃线程数:QPS 从平时的 800 左右蹿到 9000+,
Threads_running飙到 200,连接池(HikariCP)全部借满; - Redis 自身 QPS 和 CPU:反而没怎么涨——说明 Redis 本身没有被打垮,它只是“没有数据可给”。
这三条曲线的组合是击穿/雪崩的典型指纹:命中率断崖 + 数据库 QPS 尖刺 + Redis 自己毫发无伤。如果是 Redis 挂了,你会看到 Redis 的连接超时和 up 指标归零;如果是缓存穿透(查不存在的数据),你会看到命中率长期偏低,而不是“某个时间点突然掉、然后自己恢复”。
用命令从 Redis 侧验证命中情况:
$ redis-cli -h 10.0.0.21 -p 6379 info stats | egrep 'keyspace_hits|keyspace_misses|expired_keys|total_commands_processed'
total_commands_processed:918273645
expired_keys:5821377
keyspace_hits:731882910
keyspace_misses:98211433这四个数是累计值,单看没意义,要隔几秒抓两次做差。我写了个小循环:
$ while true; do
redis-cli -h 10.0.0.21 info stats | egrep 'keyspace_hits|keyspace_misses' | tr -d '\r' | paste -sd' '
sleep 1
done
keyspace_hits:731891002 keyspace_misses:98211470
keyspace_hits:731892210 keyspace_misses:98216983
keyspace_hits:731892871 keyspace_misses:98229544
keyspace_hits:731893105 keyspace_misses:98244019相邻两行做差:第三秒新增 hits 约 660、misses 约 12500——miss 是 hit 的 19 倍。命中率公式是 hits / (hits + misses),此刻的瞬时命中率只有 5% 左右。再配合 expired_keys 的增速,可以看出这一刻有大批 key 到期。
二、辨析:穿透、击穿、雪崩,别再背答案了
这三个词在面试里像三胞胎,出了事故你才会发现它们的处理方式完全不同。我用一张表加一张图说明白:
| 名字 | 触发条件 | 缓存里 | 数据库里 | 典型指纹 |
|---|---|---|---|---|
| 缓存穿透 | 请求根本不存在的数据(恶意或参数错误) | 没有,也存不了 | 没有 | 命中率长期偏低,DB 持续有“查不到”的请求 |
| 缓存击穿 | 一个热点 key 过期的瞬间,大量并发同时到来 | 刚好过期 | 有 | 某个点 DB 对同一条 SQL 出现并发尖刺 |
| 缓存雪崩 | 大量 key 同时过期,或 Redis 整体宕机 | 一批同时失效 | 有 | 命中率断崖,DB QPS 整体上涨;宕机时 Redis 也告警 |
一句话区分:穿透是“数据不存在”,击穿是“一个热点过期”,雪崩是“一片同时过期”。我们这次的现场,是“一批热点 key 在同一秒过期”,夹在击穿与雪崩之间——每个 key 单独看是击穿(大量并发查同一条数据),整体看是小规模雪崩。
图里最关键的是第 ④ 步:重建缓存要查库、要拼装、要序列化,这段时间越长,窗口内 miss 的请求越多,数据库越慢,重建越慢——一个正反馈的死循环。所以击穿的杀伤力不是“过期”本身,而是“重建慢 × 并发高”。
三、排查路线图
整个思路我画成一张决策树,值班手册里就贴这个:
四、动手:找到“同一秒过期”的凶手
第 1 步:看 MySQL 里是谁在排队
mysql> show full processlist;
+-------+------+-------------------+---------+---------+------+--------------+--------------------------------------------------------------+
| Id | User | Host | db | Command | Time | State | Info |
+-------+------+-------------------+---------+---------+------+--------------+--------------------------------------------------------------+
| 88213 | app | 10.0.1.31:51822 | taxdemo | Query | 4 | Sending data | select * from tax_policy_config where region_code = 'R330100' |
| 88217 | app | 10.0.1.32:40118 | taxdemo | Query | 4 | Sending data | select * from tax_policy_config where region_code = 'R330100' |
| 88220 | app | 10.0.1.31:51840 | taxdemo | Query | 3 | Sending data | select * from tax_policy_config where region_code = 'R330100' |
...(同一条 SQL 出现 180+ 次)看到了吧?同一条 SQL、同一个参数,几百个连接同时在跑。正常业务请求不会这么整齐,这就是击穿的味道:一个 key 没了,所有人都去查同一条数据。这张配置表其实不大、单次查询只要 20ms,但配上 200 并发和一个偏重的 JOIN 之后,单次飙到 4 秒。
第 2 步:看这个 key 的 TTL 和类型
$ redis-cli -h 10.0.0.21 ttl policy:R330100
(integer) -2
$ redis-cli -h 10.0.0.21 ttl policy:R330200
(integer) 27
$ redis-cli -h 10.0.0.21 ttl policy:R330300
(integer) 27TTL 返回 -2 表示 key 不存在;-1 表示存在但没设过期;正整数是剩余秒数。注意后两个 key 剩余时间一模一样,都是 27 秒——说明它们是同一时刻写入、同样的 TTL,也就是接下来会在同一秒一起过期。这就是“批量预热 + 固定 TTL”留下的雷。
再扫一下同前缀 key 的 TTL 分布(用 SCAN,别用 KEYS,后面的文章会讲为什么):
$ redis-cli -h 10.0.0.21 --scan --pattern 'policy:*' | head -300 \
| xargs -I{} redis-cli -h 10.0.0.21 ttl {} | sort -n | uniq -c | head
287 27
13 26300 个 key 里 287 个 TTL 相同——分布几乎是一根柱子。理想的分布应该是一片“散开”的曲线。到这里,根因基本确认。
第 3 步:回溯代码——谁写的固定 TTL
翻代码,问题出在启动预热和更新任务:每天凌晨零点半,定时任务把全国几百个地区的政策配置一次性刷进 Redis,统一 expire 24h。今天凌晨的任务没有问题,但月初有一个“征期切换”的逻辑:8:59:00 把上个月配置整体 refresh 一次,TTL 统一 60 秒试探性缓存(为了让新政策尽快生效),于是 60 秒后(9:00 整)全部同时过期。
五、根因:三个因素叠加
- 同一批 key,同一个 TTL,同一时刻写入——过期时间高度集中;
- 热点到爆:征期第一分钟,全国会计同时进来,每个 key 的并发都是平时的几十倍;
- 重建代价大且无保护:回源没有任何互斥,每个 miss 的请求各自去查库、各自
SET,200 个线程做同样的事。
顺带说明一下为什么 Redis 的过期并不是“精确到点”:Redis 用惰性删除 + 定期删除两种方式回收过期 key。惰性删除是访问时才检查;定期删除是每秒多次随机抽样一批带 TTL 的 key,发现过期比例高就继续抽。但对读请求而言,只要 key 过了期限,就一定读到 nil,不管它有没有被真正物理删除。所以“同一秒过期”对业务的效果就是“同一秒全部 miss”。
六、止血与根治
止血(15 分钟内)
- 限流 + 降级:网关层把这几个查询接口的限流阈值降到平时的 1.5 倍,多余请求返回“稍后重试”,保护数据库不再被继续压;
- 手动预热:跑一个脚本,串行(不是并发!)把热点配置查一遍并写入缓存,TTL 用随机值。缓存一旦恢复,压力立刻回落;
- 如果 MySQL 已经被拖到无响应,先
kill掉这批同质 SQL 的连接,给缓存重建腾出资源。
# 找出运行超过 3 秒的同类 SQL 并生成 kill 语句(先看,再手动执行)
mysql> select concat('kill ', id, ';') from information_schema.processlist
-> where command = 'Query' and time > 3 and info like 'select * from tax_policy_config%';根治方案 1:随机化 TTL(最便宜,必做)
// 基础 TTL + 随机抖动,避免一批 key 同一秒过期
long base = 3600;
long ttl = base + ThreadLocalRandom.current().nextLong(0, 600); // 3600 ~ 4200 秒
stringRedisTemplate.opsForValue().set(key, json, ttl, TimeUnit.SECONDS);根治方案 2:互斥锁重建(保证一个 key 同时只有一个人去查库)
思路:miss 之后,先用 SET lockKey token NX EX 10 抢锁,抢到的人去查库并回填缓存,没抢到的休眠几十毫秒后重新读缓存。Redis 官方文档对 SET 的 NX/EX 选项以及“释放锁时校验 token”都有说明,这里照着做:
public String getWithMutex(String key) throws InterruptedException {
for (int i = 0; i < 20; i++) { // 最多重试 20 次 * 50ms = 1s
String v = redis.opsForValue().get(key);
if (v != null) return v;
String lockKey = "lock:" + key;
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue().setIfAbsent(lockKey, token, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(ok)) {
try {
v = redis.opsForValue().get(key); // 双重检查:可能别人刚刚回填完
if (v != null) return v;
v = loadFromDb(key);
long ttl = 3600 + ThreadLocalRandom.current().nextLong(0, 600);
redis.opsForValue().set(key, v == null ? "" : v, ttl, TimeUnit.SECONDS);
return v;
} finally {
// 用 Lua 校验 token 再删,避免误删别人的锁
redis.execute(UNLOCK_SCRIPT, List.of(lockKey), token);
}
}
Thread.sleep(50); // 没抢到,等等再读缓存
}
return loadFromDb(key); // 兜底,避免一直卡住
}互斥锁的优点是数据强一致(重建完读到的一定是新的),缺点是没抢到锁的请求要等;锁 key 的 TTL 也要大于重建耗时。对于 200 并发,效果是 DB 上同一条 SQL 从 200 次降到 1 次。
根治方案 3:逻辑过期(热点永不真正过期,异步重建)
对于“征期配置”这种允许短时间读到旧值的数据,逻辑过期更稳:Redis 里的 key 不设 TTL,value 里带一个 expireAt 字段。读到发现逻辑过期,就先返回旧值,同时抢锁、提交线程池异步重建。
public class Wrapper<T> { public long expireAt; public T data; }
public Config get(String key) {
String json = redis.opsForValue().get(key);
if (json == null) return null; // 只会在从未预热时出现
Wrapper<Config> w = JSON.parseObject(json, new TypeReference<Wrapper<Config>>() {});
if (w.expireAt > System.currentTimeMillis()) return w.data; // 没过期,直接返回
String lockKey = "lock:" + key;
if (Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS))) {
rebuildPool.submit(() -> { // 异步重建
try { save(key, loadFromDb(key), 3600); }
finally { redis.delete(lockKey); }
});
}
return w.data; // 无论抢没抢到,都先返回旧值
}它牺牲了一点一致性(重建完成前读到旧值),换来零等待、零击穿。代价是内存里的 key 不会自己消失,下线数据要主动删。
根治方案 4:穿透用布隆过滤器 / 缓存空值
虽然这次不是穿透,但既然把“辨析”写进来了,就把穿透的对策补齐:
- 缓存空值:查不到的 key 也写一个空值,TTL 设短(如 60 秒),简单有效,缺点是恶意随机 key 会撑满内存;
- 布隆过滤器:启动时把所有合法 ID 灌进去(Redisson 的
RBloomFilter就是现成的),请求先问“这个 id 可能存在吗”,回答“一定不存在”就直接拦截。布隆过滤器有误判率(说“存在”可能是假的),但不会漏判,这正好符合“宁可放过、不可错杀”; - 入口参数校验:id 格式、范围、频控,最便宜也最先做。
根治方案 5:多级缓存与预热
热点配置这类数据,进程内再放一层 Caffeine(expireAfterWrite 几十秒到几分钟),即使 Redis 短暂失效,各实例本地也能扛住大部分读。再配合上线前/征期前的主动预热:提前一个小时、TTL 错开地把热点灌进去,别等用户来“叫醒”缓存。
七、上线后的验证
# 1) TTL 分布是否已经散开
$ redis-cli -h 10.0.0.21 --scan --pattern 'policy:*' | head -300 \
| xargs -I{} redis-cli -h 10.0.0.21 ttl {} | sort -n | uniq -c | sort -rn | head -5
4 3187
3 3402
3 2951
3 3611
3 3760
# 2) MySQL 同一条 SQL 的并发是否消失
mysql> select count(*) from information_schema.processlist where info like 'select * from tax_policy_config%';
+----------+
| count(*) |
+----------+
| 1 |
+----------+TTL 从“一根柱子”变成“一片散点”,同类 SQL 并发从 180+ 降到 1。下个月 1 号,我提前 5 分钟到公司,什么都没发生,甚至有点失落。
八、复盘
| 做法 | 为什么 |
|---|---|
| 缓存 TTL 一律 “基础值 + 随机抖动” | 避免批量写入的 key 同一秒过期,这是最便宜的防雪崩手段 |
| 热点 key 回源必须加互斥或逻辑过期 | 把“N 个请求查 N 次库”压成“1 次”,切断击穿的放大回路 |
| 征期、大促、发布这类可预期的高峰,提前预热 | 可预期的流量不该靠用户来触发缓存重建 |
| 监控命中率的变化率而不只是绝对值 | 99% 掉到 41% 之前,平均值可能还在“健康范围”里 |
| DB 侧盯“同一条 SQL 的并发数” | 击穿的特征就是同质 SQL 并发暴涨,比总 QPS 更灵敏 |
| 非法 id 用布隆过滤器 + 参数校验前置拦截 | 穿透打的是“不存在”,缓存本身救不了,必须在入口挡 |
| 热点配置加 Caffeine 本地缓存作为二级 | Redis 抖动或失效时,多一层缓冲 |
| 数据库连接池设上限、加超时,接口做限流降级 | 缓存失守时,数据库是最后一道墙,墙不能一撞就塌 |
一句话备忘:命中率断崖 + DB 同质 SQL 并发尖刺 = 热点过期。TTL 加随机、重建加互斥(或逻辑过期)、不存在的走布隆、可预期的高峰提前预热。
后来我给这套东西起了个名字叫“别让缓存同时下班”。缓存和人一样,一起下班就会堵在地铁口——而数据库,就是那个被挤在中间的可怜地铁站。
参考资料
- Redis 官方文档:SET 命令——NX / EX 选项,以及“用随机 token + 校验删除”改进简单锁的做法。
- Wikipedia:Cache stampede——总结了加锁、外部重算、概率性提前过期三类缓解手段。
- 小林 coding:缓存雪崩、击穿、穿透——三种问题的定义与对应方案对照。
一起聊聊