数据库与缓存排查 列表全部文章
数据库与缓存排查

月初报税高峰,热点 key 同时“下班”:缓存击穿把 MySQL 打成了 PPT

月初征期一到,一批热点 key 在同一秒过期,请求全部回源,MySQL 连接数瞬间打满。从命中率与 QPS 曲线定位击穿,辨析穿透/击穿/雪崩,并给出互斥锁、逻辑过期、随机 TTL、布隆过滤器与多级缓存的落地写法。

更多

故事发生在某个月的 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 单独看是击穿(大量并发查同一条数据),整体看是小规模雪崩。

N 个并发请求 Redis MySQL ① GET hot:tax:123 命中,返回(此前一直如此) 09:00:00 TTL 到期,key 消失 ② 再 GET:nil(miss)×N ③ 每个请求都去 SELECT ...(同一条 SQL 并发 N 次) 连接池借满,慢查询堆积 ④ 重建缓存很慢,期间新请求继续 miss,形成放大

图里最关键的是第 ④ 步:重建缓存要查库、要拼装、要序列化,这段时间越长,窗口内 miss 的请求越多,数据库越慢,重建越慢——一个正反馈的死循环。所以击穿的杀伤力不是“过期”本身,而是“重建慢 × 并发高”。

三、排查路线图

整个思路我画成一张决策树,值班手册里就贴这个:

DB 告警:QPS / 连接数飙升先看 Redis 命中率曲线 Redis 自己也告警/超时→ 宕机/网络,走高可用预案 命中率长期偏低→ 穿透:查 DB 不存在的 key 命中率某点断崖后恢复→ 击穿/雪崩:大量 key 到期 对账:expired_keys 增速+ TTL 分布 + DB 慢日志同一条 SQL 先止血(限流/预热/手动重建),再根治(见第五节)

四、动手:找到“同一秒过期”的凶手

第 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) 27

TTL 返回 -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 26

300 个 key 里 287 个 TTL 相同——分布几乎是一根柱子。理想的分布应该是一片“散开”的曲线。到这里,根因基本确认。

第 3 步:回溯代码——谁写的固定 TTL

翻代码,问题出在启动预热和更新任务:每天凌晨零点半,定时任务把全国几百个地区的政策配置一次性刷进 Redis,统一 expire 24h。今天凌晨的任务没有问题,但月初有一个“征期切换”的逻辑:8:59:00 把上个月配置整体 refresh 一次,TTL 统一 60 秒试探性缓存(为了让新政策尽快生效),于是 60 秒后(9:00 整)全部同时过期。

五、根因:三个因素叠加

  1. 同一批 key,同一个 TTL,同一时刻写入——过期时间高度集中;
  2. 热点到爆:征期第一分钟,全国会计同时进来,每个 key 的并发都是平时的几十倍;
  3. 重建代价大且无保护:回源没有任何互斥,每个 miss 的请求各自去查库、各自 SET,200 个线程做同样的事。

顺带说明一下为什么 Redis 的过期并不是“精确到点”:Redis 用惰性删除 + 定期删除两种方式回收过期 key。惰性删除是访问时才检查;定期删除是每秒多次随机抽样一批带 TTL 的 key,发现过期比例高就继续抽。但对读请求而言,只要 key 过了期限,就一定读到 nil,不管它有没有被真正物理删除。所以“同一秒过期”对业务的效果就是“同一秒全部 miss”。

六、止血与根治

止血(15 分钟内)

  1. 限流 + 降级:网关层把这几个查询接口的限流阈值降到平时的 1.5 倍,多余请求返回“稍后重试”,保护数据库不再被继续压;
  2. 手动预热:跑一个脚本,串行(不是并发!)把热点配置查一遍并写入缓存,TTL 用随机值。缓存一旦恢复,压力立刻回落;
  3. 如果 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 加随机、重建加互斥(或逻辑过期)、不存在的走布隆、可预期的高峰提前预热。

后来我给这套东西起了个名字叫“别让缓存同时下班”。缓存和人一样,一起下班就会堵在地铁口——而数据库,就是那个被挤在中间的可怜地铁站。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人