2023 年,一次"一个热点商品的缓存过期,就在过期的那一瞬间,整个数据库被打到 CPU 100%、网站崩了几秒"的事故,把我对"缓存"这件事的理解,从头到尾翻新了一遍。我们有个爆款商品,详情页一天几百万次访问。商品信息我当然做了缓存:第一次有人访问,从数据库查出来,塞进 Redis,设个一小时的过期时间;后面一小时内的所有访问,全走 Redis,不碰数据库。这套缓存,我自认为做得标准、做得漂亮,上线后数据库负载稳得很。可某天起,监控开始出现一种诡异的规律性抖动:每隔大约一小时,数据库的 CPU 会【毫无征兆地】瞬间飙到 100%,持续两三秒,接口大面积超时,然后又自己恢复如初。像有人每小时,准时朝数据库捅一刀。我懵了。我们的流量是平滑的,没有什么整点活动;那一小时里数据库明明轻松得很——它怎么会自己,每隔一小时,准时抽搐一次?我盯着那个"一小时"的周期看了很久,突然一身冷汗:一小时……那不正是我给缓存设的【过期时间】吗?如果缓存是用来"保护"数据库的,那它【过期的那一刻】,到底发生了什么,能让被保护得好好的数据库,瞬间被打趴下?一个 key 的过期,怎么会有这么大的破坏力?这件事逼着我把缓存击穿、缓存雪崩、缓存穿透这三个概念、它们的区别、还有缓存重建该怎么做,彻底理清了。本文复盘这次实战。
问题背景
环境:一个电商,热点商品详情页,Redis 缓存 + MySQL
事故现象:
- 平时数据库负载很低,缓存命中率 99%+
- ★ 每隔约 1 小时,数据库 CPU 瞬间飙到 100%,持续 2~3 秒
- ★ 这 2~3 秒接口大面积超时,然后自动恢复
- 周期非常规律 —— 恰好等于缓存的过期时间
现场排查:
# 1. ★ 抖动周期 = 1 小时,正好是缓存 TTL,这不是巧合
$ 商品缓存设的 TTL:3600 秒
$ 数据库 CPU 飙升的间隔:≈ 3600 秒 # ★★ 对上了
# 2. ★ 抓 CPU 飙升那一刻,数据库在跑什么
$ 看 DB 慢查询 / processlist(飙升瞬间)
# 同一条 SQL,同时出现了几千次:
# SELECT * FROM products WHERE id = 88001 ← 热点商品
# ★★ 几千个一模一样的查询,在同一瞬间涌进了数据库
# 3. ★ 看这个商品 key 在 Redis 里的情况
$ redis-cli TTL product:88001
# (在飙升那一刻) -2 # ★ -2 = key 不存在(刚过期)
# (平时) 2841 # 正常,还有 2841 秒
# 4. ★ 算一笔账:这个热点 key 平时扛多少 QPS
# 这个爆款详情页:峰值约 8000 QPS
# 平时这 8000 QPS,全被 Redis 接住了,DB 一下没有
# ★★ 而它一旦过期 —— 这 8000 QPS,瞬间全压到 DB 上
根因(后来想清楚的):
1. ★ 缓存的工作方式:同一个 key,第一个人来,缓存
没有(miss),就去查 DB、把结果写回缓存;后面
的人来,缓存有(hit),直接拿走,不碰 DB。
2. ★★ 致命的时刻在【过期的那一瞬间】:这个热点 key
一过期,在那一刻【涌进来的成百上千个并发请求】,
会【同时】发现缓存 miss。
3. ★★ 于是这成百上千个请求,【在同一瞬间,全部】
越过缓存,一起冲向数据库,去查【同一条】数据。
4. ★ 平时被 Redis 稳稳接住的 8000 QPS,在这一刻
【原封不动地、全部】砸到了只能扛几千 QPS 的
数据库上 —— 数据库瞬间被打爆。
5. ★ 这就是【缓存击穿】:一个【热点 key】过期的
瞬间,海量并发请求穿透缓存、直击数据库。
真相:缓存平时在替数据库"挡子弹"。热点 key 过期
的瞬间,挡板消失,所有子弹同时命中数据库。重建
缓存时,必须只让"一个"请求去查库,别让所有人一起冲。
修复 1:数据库突然被打挂——先确认是不是"缓存层失效"
# === ★ 数据库被打挂,第一刀:它前面的"缓存挡板"还在吗 ===
# === ★ 一个关键视角:数据库前面,有一道"挡板" ===
# ★ 一个有缓存的系统,数据库从来不是直接面对全部
# 流量的。它前面有 Redis 这道挡板:绝大多数读
# 请求,被 Redis 接走了,只有"漏网的"才到 DB。
# ★ ★ 所以"数据库突然被打挂",有一种极常见的原因
# 不是"数据库自己不行了",而是【它前面那道挡板,
# 突然失效了】 —— 本该被挡住的流量,一下子全
# 涌到了数据库面前。
# === ★ 怎么判断是不是"挡板失效" ===
# ★ 信号 1:★★ DB 飙升的瞬间,看它在跑的 SQL ——
# 如果是【大量重复的、一模一样的查询】(几千个
# SELECT ... WHERE id=同一个值),这是典型的
# "缓存没接住,流量直击 DB"。如果是各式各样的
# 慢 SQL,那才更像 DB 自身的问题。
# ★ 信号 2:看缓存命中率。事故瞬间,如果缓存命中率
# 有一个【断崖式下跌】,说明挡板这一刻漏了。
# ★ 信号 3:★ 看事故的【周期性】。本文最大的线索
# 就是"每隔 1 小时一次" —— 周期性的抖动,极可能
# 和缓存的【过期时间】有关。规律的周期不会是偶然。
# === ★ 确认了是"挡板失效",再分是哪一种 ===
# ★ 缓存这道挡板"漏了",经典的有三种漏法,各有各的
# 名字、各有各的解法(下一节细讲):
# - ★ 缓存击穿:一个【热点 key】过期,并发全击 DB;
# - ★ 缓存雪崩:【大量 key】在同一时间一起过期;
# - ★ 缓存穿透:查的是【根本不存在】的数据,缓存
# 永远 miss,每次都落到 DB。
# ★ 本文是第一种 —— 击穿。
# === 认知 ===
# ★ 数据库被打挂,第一刀先看它前面的"缓存挡板"还在
# 不在 —— 很多时候不是 DB 自己不行,是挡板突然失效
# 让本该被挡的流量全涌向了 DB。★★ 判断信号:① DB
# 飙升瞬间在跑【大量重复的同一条查询】是典型的缓存
# 没接住;② 缓存命中率断崖式下跌;③ 事故有规律的
# 周期、且周期≈缓存 TTL。确认是挡板失效后再分三种:
# 击穿(热点 key 过期)、雪崩(大量 key 同时过期)、
# 穿透(查不存在的数据)。
修复 2:核心根因——缓存击穿:热点 key 过期瞬间的"惊群"
# === ★ 把这次事故的总根,挖出来 ===
# === ★ 平时:缓存是怎么"保护"数据库的 ===
# ★ 一个标准的读缓存(Cache-Aside)逻辑:
# ① 来了请求,先查 Redis;
# ② 命中 -> 直接返回(数据库毫无感知);
# ③ 没命中 -> 查数据库 -> 把结果写回 Redis -> 返回。
# ★ ★ 平时,一个热点 key 在 Redis 里好好待着,99.99%
# 的请求都走第②步。数据库被 Redis 严严实实地
# "挡"在后面,它根本不知道外面有 8000 QPS 的洪水。
# === ★★ 致命的瞬间:热点 key 过期的那一刻 ===
# ★ 现在,这个热点 key 的 TTL 到了,Redis 把它删了。
# ★ ★ 想象那个瞬间:这个爆款详情页,每秒有 8000 个
# 请求在涌入。在 key 消失后的那短短几十毫秒里,
# 有几百个请求先后到达,它们【全部】走到了第③步
# —— 因为缓存里那个 key,确实没了。
# ★ ★★ 于是这几百个请求,【在几乎同一瞬间】,一起
# 去查数据库里【同一条】数据,然后一起想把结果
# 写回缓存。这个现象,叫"惊群"(thundering herd)。
# === ★ 为什么这一下就能打挂数据库 ===
# ★ 算笔账就懂了:这个 key 平时替数据库扛了 8000 QPS。
# 它在的时候,数据库这一项的负载是【0】。
# ★ ★ 它一过期,这 8000 QPS【没有任何缓冲地、全额】
# 砸到数据库上。而一个数据库,可能也就扛几千 QPS。
# 这不是"负载高一点",这是【从 0 到爆表】的瞬间冲击。
# ★ ★ 更糟的是:这几百个请求查的是【同一条数据】 ——
# 数据库被迫把【完全一样】的活,重复做了几百遍,
# 纯属浪费。本该只查 1 次的。
# === ★ "击穿"这个词,精确在哪 ===
# ★ ★ 击穿:强调是【单个、热点】的 key。问题不在
# "key 过期"本身(key 当然要过期),而在于这个
# key 太热 —— 它一个人扛着巨大的流量,它的过期,
# 就是一次对数据库的【集中爆破】。
# ★ ★ 对比:冷门 key 过期,无所谓 —— 顶多一两个请求
# miss,查一下 DB,影响微乎其微。所以"击穿"是
# 【热点 key 专属】的问题。
# === 认知 ===
# ★ 标准读缓存(Cache-Aside):查 Redis 命中直接返回、
# 未命中才查 DB 并写回。平时热点 key 把海量 QPS 全
# 挡在 Redis,DB 这一项负载是 0。★★ 致命瞬间在热点
# key 过期那一刻:key 一消失,那几十毫秒内涌入的
# 成百上千并发请求【全部】缓存 miss,一起冲向 DB 查
# 【同一条】数据 —— 这叫"惊群"。平时被挡的 8000 QPS
# 毫无缓冲地全额砸到只能扛几千 QPS 的 DB 上,是从 0
# 到爆表的瞬间冲击。★ "击穿"特指【单个热点 key】,
# 冷门 key 过期无所谓。
修复 3:辨清三兄弟——缓存击穿 vs 雪崩 vs 穿透
# === ★ 这三个词长得像、常被混用,但解法完全不同 ===
# === ★ 缓存击穿(本文):单个热点 key 过期 ===
# ★ 定义:某个【访问极热】的 key 过期的瞬间,大量
# 并发请求同时 miss,一起冲向数据库查【同一条】数据。
# ★ 关键词:★【单个】key、★【热点】、★【过期】。
# ★ 数据是【存在】的,只是缓存里那一份恰好刚没了。
# === ★ 缓存雪崩:大量 key 在同一时间一起过期 ===
# ★ 定义:【大批】缓存 key,在【几乎同一时刻】集体
# 失效 -> 海量请求(查的是各种不同数据)同时
# miss,一起涌向数据库。
# ★ 最常见的成因:★★ 你给一批 key,设了【完全相同】
# 的 TTL。比如某次活动,凌晨批量预热了 10 万个
# 商品缓存,全设 TTL=2 小时 —— 那 2 小时后,这
# 10 万个 key【在同一秒】一起过期。雪崩。
# ★ ★ 击穿 vs 雪崩的区别:击穿是【一个热点 key】,
# 雪崩是【一大批 key】;击穿打的是一条数据,雪崩
# 打的是一大片。
# ★ (广义的雪崩也包括 Redis 整个挂了 -> 全部流量
# 直击 DB。)
# === ★ 缓存穿透:查的是根本不存在的数据 ===
# ★ ★ 这个和前两个【本质不同】。击穿、雪崩,数据
# 都是【存在】的,只是缓存暂时没有。穿透是:查询
# 的数据,在数据库里【压根就不存在】。
# ★ 后果:① 查 Redis,没有(数据本就不存在);
# ② 查数据库,也没有 -> ★ 因为没查到结果,你【没
# 东西可往缓存里写】 -> ③ 下一个一模一样的请求来,
# 缓存【还是】没有,又走一遍 ①②。
# ★ ★ 于是这种请求,【永远】穿透缓存、直击数据库 ——
# 缓存对它【完全失效】。常被黑客利用:拿不存在的
# id(如 id=-1、随机大数)疯狂请求,专打数据库。
# === ★ 一张表记住三者 ===
# ★ 击穿:1 个热点 key 没了 -> 数据【存在】,锁重建
# ★ 雪崩:1 大批 key 同时没了 -> 数据【存在】,TTL 打散
# ★ 穿透:数据【从来不存在】 -> 缓存空值 / 布隆过滤器
# === 认知 ===
# ★ 三兄弟辨析:① 缓存击穿 —— 【单个热点 key】过期
# 瞬间并发全击 DB 查同一条数据,数据是存在的;
# ② 缓存雪崩 —— 【大批 key】在几乎同一时刻集体失效
# (常因给一批 key 设了完全相同的 TTL),海量请求查
# 各种数据一起涌向 DB,数据也是存在的;③ 缓存穿透 ——
# 查的数据在数据库里【根本不存在】,查不到就没东西
# 写回缓存,导致这类请求【永远】穿透缓存直击 DB,常
# 被黑客拿不存在的 id 攻击。★★ 击穿是一个热点 key、
# 雪崩是一大批 key、穿透是数据根本不存在 —— 解法各
# 不同,务必先分清。
修复 4:治本缓存击穿——重建时只放"一个"请求查库
# === ★ 击穿的根因是"惊群",治本就是"别让所有人一起冲" ===
# === ★ 思路:成百上千个请求,只需要一个去查库 ===
# ★ 击穿的浪费很荒唐:几百个请求,查的是【同一条】
# 数据。其实只需要【一个】请求去数据库查一次、
# 把缓存重建好,其余几百个【等它一下】、直接用
# 重建好的缓存就行。
# ★ ★ 所以治本的核心:缓存 miss 时,用一把【互斥锁】,
# 保证【同一时刻,只有一个请求】去查数据库重建缓存。
# === ★★ 方案 1:互斥锁重建(最常用)===
# ★ 流程:
# ① 请求发现缓存 miss;
# ② ★ 去抢一把分布式锁(如 Redis SET NX);
# ③ ★ 抢到锁的那【一个】请求:查数据库 -> 写回
# 缓存 -> 释放锁;
# ④ ★ 没抢到锁的请求:别冲数据库 —— 短暂等一下
# (sleep 几十毫秒)再重新查缓存,这时缓存
# 多半已被③重建好了,直接命中返回。
# ★ ★ 效果:无论瞬间涌进来多少并发,真正落到数据库
# 的,永远【只有 1 个】查询。惊群被彻底掐断。
# === ★ 方案 2:逻辑过期(对延迟极敏感的场景)===
# ★ 互斥锁方案,没抢到锁的请求要"等一下",有一点点
# 延迟。如果业务连这点延迟都不能忍:
# ★ 做法:缓存【永不设物理 TTL】(不让 Redis 自己
# 删它),而是在 value 里【自己存一个"逻辑过期
# 时间"字段】。请求拿到数据,发现"逻辑上已过期",
# 就【异步】起一个后台任务去重建缓存,而自己
# 【先拿旧数据返回】。
# ★ ★ 代价:重建期间,大家拿到的是【稍旧】的数据。
# 用"短暂的数据不新鲜",换"零惊群、零等待"。
# === ★ 方案 3:热点 key 干脆不过期 ===
# ★ 对那种"全站皆知的超级热点"(首页配置、爆款),
# 可以让它的缓存【根本不设过期】,改由数据【更新
# 时】主动去刷新缓存(或后台定时刷)。
# ★ 没有过期,就没有"过期的瞬间",自然没有击穿。
# === 认知 ===
# ★ 击穿根因是"惊群",治本是"别让所有人一起冲":
# ★★ 方案1 互斥锁重建 —— 缓存 miss 时用分布式锁
# (Redis SET NX)保证同一时刻只有一个请求去查库
# 重建,没抢到锁的请求短暂 sleep 后重查缓存(此时
# 多半已重建好),无论多少并发真正落到 DB 的永远
# 只有 1 个查询;方案2 逻辑过期 —— 缓存不设物理
# TTL,value 里自存逻辑过期时间,过期后异步重建、
# 自己先返回旧数据,用数据稍旧换零等待;方案3 超级
# 热点 key 干脆不过期,改为数据更新时主动刷新。
import time, json
def get_product(redis, db, pid, ttl=3600):
key = f"product:{pid}"
val = redis.get(key)
if val is not None:
return json.loads(val) # ★ 缓存命中,直接返回
# ★ 缓存 miss —— 抢互斥锁,只放一个请求去查库
lock_key = f"lock:{key}"
got = redis.set(lock_key, "1", nx=True, ex=10) # ★ SET NX,抢锁
if got:
try:
row = db.query_product(pid) # ★★ 全场只有 1 个请求执行到这
redis.set(key, json.dumps(row), ex=ttl) # 写回缓存
return row
finally:
redis.delete(lock_key) # ★ 释放锁
else:
# ★ 没抢到锁:别冲数据库,等一下再读缓存
time.sleep(0.05)
return get_product(redis, db, pid, ttl) # 重试,这次多半命中
修复 5:治本雪崩与穿透——TTL 打散、空值缓存、布隆过滤器
# === ★ 击穿之外,雪崩和穿透也得各有各的治法 ===
# === ★★ 治雪崩 1:给 TTL 加"随机抖动",别让 key 同时过期 ===
# ★ 雪崩的核心成因,是一大批 key 的过期时间【完全
# 一样】。解法直白:★ 别让它们一样。
# ★ ★ 做法:设 TTL 时,在基准值上【加一个随机量】。
# 比如基准 1 小时,实际设成 "3600 + random(0,600)"
# 秒 —— 这批 key 的过期时间,就被【打散】在了
# 一个 10 分钟的窗口里,不再扎堆在同一秒。
# ★ 一行随机数,就把"雪崩"摊平成了"毛毛雨"。
# === ★ 治雪崩 2:多级缓存 + Redis 高可用 ===
# ★ 广义雪崩里有一种是"Redis 整个挂了"。应对:
# - ★ Redis 上集群 / 主从哨兵,别让它单点;
# - ★ 加一层【本地缓存】(进程内,如 Caffeine):
# Redis 挂了,本地缓存还能挡一阵,争取喘息时间。
# === ★★ 治穿透 1:把"空结果"也缓存起来 ===
# ★ 穿透的根源:查不到数据 -> 没东西写缓存 -> 下次
# 还是 miss。解法:★ 既然查出来是"空",那就把
# 【这个"空"本身,也缓存起来】。
# ★ ★ 做法:数据库查出来确实没有这条数据,就往
# Redis 写一个【特殊的空值标记】(如空字符串、
# "NULL"),给它设一个【较短的 TTL】(如 60 秒)。
# 下次同样的请求来,命中这个"空值缓存",直接返回
# "没有",不再打数据库。
# ★ TTL 设短,是怕这条数据"后来又有了",别缓存太久。
# === ★ 治穿透 2:布隆过滤器,在入口处拦掉 ===
# ★ 空值缓存有个短板:黑客如果用【海量不同的】不
# 存在 id 来打,每个 id 都得缓存一个空值,Redis
# 会被垃圾空值塞满。
# ★ ★ 更狠的一招:布隆过滤器(Bloom Filter)。把
# 【所有真实存在的 id】,预先灌进一个布隆过滤器。
# 请求进来,先问布隆过滤器:"这个 id 可能存在吗?"
# - ★ 它说"不存在" -> 那就【一定】不存在,直接拒掉,
# 连 Redis 都不用查;
# - 它说"可能存在" -> 再走正常的缓存 / DB 查询。
# ★ ★ 布隆过滤器用极小的内存,就把"查不存在数据"的
# 请求,挡在了整个系统的【最外层】。
# ★ (注意:布隆过滤器有极小的"误判为存在"的概率,
# 但【绝不会】把"存在"误判为"不存在" —— 方向是
# 安全的。)
# === 认知 ===
# ★ 雪崩与穿透各有治法。治雪崩:①★★ 给 TTL 加随机
# 抖动(基准值 + random),把扎堆在同一秒过期的 key
# 打散到一个时间窗口里;② Redis 上集群/主从避免单点,
# 加一层本地缓存兜底。治穿透:①★★ 把"空结果"也缓存
# 起来(写特殊空值标记 + 较短 TTL),让查不存在数据
# 的请求也能命中缓存不再打 DB;② 布隆过滤器把所有
# 真实 id 预灌进去,请求先问它,答"不存在"就一定
# 不存在直接拒掉 —— 用极小内存在最外层拦截攻击。
import random, json
# ★ 治雪崩:TTL 加随机抖动,打散过期时间
def set_with_jitter(redis, key, value, base_ttl=3600, jitter=600):
ttl = base_ttl + random.randint(0, jitter) # ★ 3600~4200 秒,散开
redis.set(key, json.dumps(value), ex=ttl)
# ★ 治穿透:查库为空时,也缓存一个"空值",较短 TTL
def get_with_null_cache(redis, db, pid):
key = f"product:{pid}"
val = redis.get(key)
if val == "": # ★ 命中"空值缓存",直接判定不存在
return None
if val is not None:
return json.loads(val)
row = db.query_product(pid)
if row is None:
redis.set(key, "", ex=60) # ★★ 空结果也缓存,TTL 设短(60s)
return None
redis.set(key, json.dumps(row), ex=3600 + random.randint(0, 600))
return row
修复 6:Redis 缓存排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 数据库被打挂,先看它前面的"缓存挡板"还在不在 ===
# === 2. ★★ DB 飙升瞬间在跑【大量重复的同一条查询】= 缓存没接住 ===
# === 3. ★ 事故有规律的周期、且周期≈缓存 TTL —— 高度怀疑缓存过期 ===
# === 4. ★★ 缓存击穿:单个【热点 key】过期,并发全击 DB ===
# === 5. ★ 缓存雪崩:【大批 key】同时过期 / Redis 整个挂了 ===
# === 6. ★ 缓存穿透:查的是【根本不存在】的数据,缓存永远 miss ===
# === 7. ★★ 治击穿:缓存 miss 用互斥锁,只放一个请求查库重建 ===
# === 8. ★ 治雪崩:TTL 加随机抖动,别让一批 key 同一秒过期 ===
# === 9. ★ 治穿透:空结果也缓存(短 TTL)/ 布隆过滤器拦在最外层 ===
# === 10. 排查"缓存把数据库打挂"的步骤链 ===
$ 看 DB 飙升瞬间在跑的 SQL # ① 是不是大量重复的同一条查询
$ redis-cli TTL <热点key> # ② -2 = 刚过期 -> 高度怀疑击穿
$ 看缓存命中率有没有断崖下跌 # ③ 断崖 -> 挡板这一刻漏了
$ 看事故周期是否≈缓存 TTL # ④ 对得上 -> 击穿 / 雪崩
$ 分清三兄弟,对症下药 # ⑤ 击穿锁重建 / 雪崩打散 / 穿透空值+布隆
命令速查
需求 命令 / 做法
=============================================================
看某个 key 还剩多久过期 redis-cli TTL key (-2=不存在 -1=永不过期)
看某个 key 在不在 redis-cli EXISTS key
抢分布式锁(重建用) SET lock:key 1 NX EX 10
看缓存命中率 redis-cli INFO stats 里 keyspace_hits/misses
看 DB 飙升瞬间在跑什么 SHOW PROCESSLIST / 慢查询日志
给 TTL 加随机抖动 base_ttl + random(0, jitter)
缓存空结果防穿透 SET key "" EX 60 (空值 + 短 TTL)
治击穿 互斥锁重建 / 逻辑过期 / 热点 key 不过期
治雪崩 TTL 打散 / Redis 高可用 / 本地缓存兜底
治穿透 空值缓存 / 布隆过滤器
口诀:热点 key 过期瞬间 海量并发穿透缓存直击 DB = 缓存击穿
重建只放一个请求查库 雪崩打散 TTL 穿透空值+布隆
避坑清单
- 数据库突然被打挂先看它前面的缓存挡板还在不在,很多时候不是 DB 自己不行是挡板失效了
- DB 飙升瞬间在跑大量重复的同一条查询,是典型的缓存没接住流量直击数据库的信号
- 事故有规律的周期且周期约等于缓存 TTL,高度怀疑是缓存过期引发的击穿或雪崩
- 缓存击穿是单个热点 key 过期的瞬间海量并发一起 miss,全部冲向数据库查同一条数据
- 缓存雪崩是大批 key 在几乎同一时刻集体失效,常因给一批 key 设了完全相同的 TTL
- 缓存穿透是查根本不存在的数据,查不到就没东西写回缓存,这类请求永远穿透直击 DB
- 治击穿用互斥锁重建,缓存 miss 时只放一个请求去查库,其余请求短暂等待后重读缓存
- 治雪崩给 TTL 加随机抖动,在基准值上加一个随机量,把扎堆过期的 key 打散到时间窗口里
- 治穿透把空结果也缓存起来设较短 TTL,海量攻击场景用布隆过滤器在最外层拦截
- 超级热点 key 可以逻辑过期或干脆不过期,改由数据更新时主动刷新,从根上消除过期瞬间
总结
这次"一个热点 key 过期,把整个数据库打挂"的事故,纠正了我一个关于"能力"的、藏得极深的错觉。在我过去的脑子里,一个系统"能扛多少量",是一个我可以直接观测、可以信任的数字。我给爆款商品详情页加了缓存,上线后我盯着监控:8000 QPS 的流量涌进来,数据库的负载却稳得像一条直线,几乎贴着地面爬。我心里那个数字就此定下了——我的系统,能轻松扛住 8000 QPS。我甚至有点得意:你看,加了缓存,数据库毫无压力。可这次事故,在那短短两三秒里,用数据库飙到 100% 的 CPU,狠狠地告诉了我:我观测到的那份"从容",根本不是我系统的能力——那是缓存【借】给它的。真正在扛那 8000 QPS 的,从来不是数据库,是 Redis。数据库躲在缓存背后,负载是 0,不是因为它强,是因为它根本【没上场】。缓存像一个站在前面替它挡子弹的人,它在的时候,数据库一颗子弹都吃不到,于是我误以为这个数据库"刀枪不入"。直到那个热点 key 过期、挡子弹的人闪开的那一瞬间,8000 QPS 原封不动地砸到数据库脸上——我才第一次,看见我这个数据库【真实的、不带缓存滤镜的】抗压能力:它只能扛几千,过期那一刻它就趴下了。我一直以为我看到的是"系统的能力",其实我看到的,是"系统 + 缓存、且缓存恰好没失效"时的能力——我把一个有前提的数字,当成了无条件的事实。复盘到最深,我意识到:缓存给你的,是一种"借来的从容"。借来的东西,有两个特点:一是它让你过得很舒服,舒服到你忘了它是借来的;二是,它总有要还的那一天。缓存 key 的过期、被内存淘汰、Redis 整个宕机——每一个,都是"债主上门"的时刻。而我过去做的所有容量评估、所有"我们能扛多少"的判断,全都建立在"缓存永远在、永远命中"这个我从未言明、也从未质疑的前提上。这个前提一旦被抽掉哪怕两三秒,我精心构筑的从容,就碎给我看。这个教训,我后来到处都看见它的影子:一个接口"响应很快",快是因为它依赖的下游有缓存,下游缓存一冷它就慢成一团;一个服务"很稳",稳是因为限流器在前面挡着,限流阈值一配错洪水立刻漫进来;一个系统"内存很充足",充足是因为有一层对象池在悄悄复用,池子一耗尽 GC 就开始疯转。它们的从容,全是借来的——借自缓存、借自限流、借自池化、借自那个还没被触发过的降级开关。而我们太容易,把这份借来的从容,当成自己与生俱来的体魄。这次最大的收获,是我给自己立了一条新规矩:每当我要回答"我的系统能扛多少"的时候,我都会逼自己再追问一句——我说的这个数字,是"系统裸奔"的数字,还是"系统 + 某个随时可能失效的优化"的数字?如果是后者,那我就必须接着算一笔账:当那个优化失效的瞬间(缓存全部过期、Redis 整个挂掉),洪水原封不动地砸下来,我的【地基】——那个躲在缓存背后、平时负载是 0 的数据库——它,接得住吗?缓存击穿那一刀教给我的,不是一个加锁重建的技巧,而是一句更冷峻的话:缓存是优化,不是地基。优化能让你飞得很高,但它不负责在你跌落时接住你。你真正的安全高度,从来不是你加了缓存之后飞到的那个高度,而是——缓存全部消失的那一刻,你那个数据库地基,还能稳稳托得住的那个高度。别在借来的从容里,忘了自己真实的海拔。
—— 别看了 · 2026