一个热点 key 过期瞬间打挂数据库:一次 Redis 缓存击穿与雪崩穿透的复盘

一个爆款商品详情页一天几百万访问,商品信息做了缓存第一次查库后塞进 Redis 设一小时过期,上线后数据库负载稳得很,可某天起监控出现诡异的规律性抖动每隔约一小时数据库 CPU 毫无征兆瞬间飙到 100% 持续两三秒接口大面积超时然后自己恢复,周期恰好等于缓存过期时间。排查梳理:数据库被打挂第一刀先看它前面的缓存挡板还在不在,很多时候不是 DB 自己不行是挡板突然失效让本该被挡的流量全涌向 DB,判断信号 DB 飙升瞬间在跑大量重复的同一条查询是典型的缓存没接住、缓存命中率断崖式下跌、事故周期约等于缓存 TTL;核心根因缓存击穿,标准读缓存 Cache-Aside 查 Redis 命中直接返回未命中才查 DB 并写回,平时热点 key 把海量 QPS 全挡在 Redis DB 这一项负载是 0,致命瞬间在热点 key 过期那一刻 key 一消失那几十毫秒内涌入的成百上千并发请求全部缓存 miss 一起冲向 DB 查同一条数据这叫惊群,平时被挡的 8000 QPS 毫无缓冲全额砸到只能扛几千 QPS 的 DB 上是从 0 到爆表的瞬间冲击,击穿特指单个热点 key 冷门 key 过期无所谓;辨清三兄弟击穿是单个热点 key 过期数据存在锁重建、雪崩是一大批 key 在同一时刻集体失效常因给一批 key 设完全相同 TTL 数据也存在 TTL 打散、穿透是查根本不存在的数据查不到就没东西写回缓存导致永远穿透直击 DB 常被黑客拿不存在 id 攻击空值缓存加布隆过滤器;治本击穿是别让所有人一起冲,互斥锁重建缓存 miss 时用分布式锁 SET NX 保证同一时刻只有一个请求去查库重建没抢到锁的短暂 sleep 后重查缓存,逻辑过期缓存不设物理 TTL value 里自存逻辑过期时间过期后异步重建自己先返回旧数据,超级热点 key 干脆不过期改为数据更新时主动刷新;治雪崩给 TTL 加随机抖动把扎堆同一秒过期的 key 打散、Redis 上集群主从避免单点加本地缓存兜底;治穿透把空结果也缓存写特殊空值标记加较短 TTL、布隆过滤器把所有真实 id 预灌进去答不存在就一定不存在直接拒掉。正确做法是数据库被打挂先确认是不是缓存层失效,缓存击穿用互斥锁重建只放一个请求查库,雪崩 TTL 打散穿透空值加布隆,以及一套 Redis 缓存排查纪律。

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 穿透空值+布隆

避坑清单

  1. 数据库突然被打挂先看它前面的缓存挡板还在不在,很多时候不是 DB 自己不行是挡板失效了
  2. DB 飙升瞬间在跑大量重复的同一条查询,是典型的缓存没接住流量直击数据库的信号
  3. 事故有规律的周期且周期约等于缓存 TTL,高度怀疑是缓存过期引发的击穿或雪崩
  4. 缓存击穿是单个热点 key 过期的瞬间海量并发一起 miss,全部冲向数据库查同一条数据
  5. 缓存雪崩是大批 key 在几乎同一时刻集体失效,常因给一批 key 设了完全相同的 TTL
  6. 缓存穿透是查根本不存在的数据,查不到就没东西写回缓存,这类请求永远穿透直击 DB
  7. 治击穿用互斥锁重建,缓存 miss 时只放一个请求去查库,其余请求短暂等待后重读缓存
  8. 治雪崩给 TTL 加随机抖动,在基准值上加一个随机量,把扎堆过期的 key 打散到时间窗口里
  9. 治穿透把空结果也缓存起来设较短 TTL,海量攻击场景用布隆过滤器在最外层拦截
  10. 超级热点 key 可以逻辑过期或干脆不过期,改由数据更新时主动刷新,从根上消除过期瞬间

总结

这次"一个热点 key 过期,把整个数据库打挂"的事故,纠正了我一个关于"能力"的、藏得极深的错觉。在我过去的脑子里,一个系统"能扛多少量",是一个我可以直接观测、可以信任的数字。我给爆款商品详情页加了缓存,上线后我盯着监控:8000 QPS 的流量涌进来,数据库的负载却稳得像一条直线,几乎贴着地面爬。我心里那个数字就此定下了——我的系统,能轻松扛住 8000 QPS。我甚至有点得意:你看,加了缓存,数据库毫无压力。可这次事故,在那短短两三秒里,用数据库飙到 100% 的 CPU,狠狠地告诉了我:我观测到的那份"从容",根本不是我系统的能力——那是缓存【借】给它的。真正在扛那 8000 QPS 的,从来不是数据库,是 Redis。数据库躲在缓存背后,负载是 0,不是因为它强,是因为它根本【没上场】。缓存像一个站在前面替它挡子弹的人,它在的时候,数据库一颗子弹都吃不到,于是我误以为这个数据库"刀枪不入"。直到那个热点 key 过期、挡子弹的人闪开的那一瞬间,8000 QPS 原封不动地砸到数据库脸上——我才第一次,看见我这个数据库【真实的、不带缓存滤镜的】抗压能力:它只能扛几千,过期那一刻它就趴下了。我一直以为我看到的是"系统的能力",其实我看到的,是"系统 + 缓存、且缓存恰好没失效"时的能力——我把一个有前提的数字,当成了无条件的事实。复盘到最深,我意识到:缓存给你的,是一种"借来的从容"。借来的东西,有两个特点:一是它让你过得很舒服,舒服到你忘了它是借来的;二是,它总有要还的那一天。缓存 key 的过期、被内存淘汰、Redis 整个宕机——每一个,都是"债主上门"的时刻。而我过去做的所有容量评估、所有"我们能扛多少"的判断,全都建立在"缓存永远在、永远命中"这个我从未言明、也从未质疑的前提上。这个前提一旦被抽掉哪怕两三秒,我精心构筑的从容,就碎给我看。这个教训,我后来到处都看见它的影子:一个接口"响应很快",快是因为它依赖的下游有缓存,下游缓存一冷它就慢成一团;一个服务"很稳",稳是因为限流器在前面挡着,限流阈值一配错洪水立刻漫进来;一个系统"内存很充足",充足是因为有一层对象池在悄悄复用,池子一耗尽 GC 就开始疯转。它们的从容,全是借来的——借自缓存、借自限流、借自池化、借自那个还没被触发过的降级开关。而我们太容易,把这份借来的从容,当成自己与生俱来的体魄。这次最大的收获,是我给自己立了一条新规矩:每当我要回答"我的系统能扛多少"的时候,我都会逼自己再追问一句——我说的这个数字,是"系统裸奔"的数字,还是"系统 + 某个随时可能失效的优化"的数字?如果是后者,那我就必须接着算一笔账:当那个优化失效的瞬间(缓存全部过期、Redis 整个挂掉),洪水原封不动地砸下来,我的【地基】——那个躲在缓存背后、平时负载是 0 的数据库——它,接得住吗?缓存击穿那一刀教给我的,不是一个加锁重建的技巧,而是一句更冷峻的话:缓存是优化,不是地基。优化能让你飞得很高,但它不负责在你跌落时接住你。你真正的安全高度,从来不是你加了缓存之后飞到的那个高度,而是——缓存全部消失的那一刻,你那个数据库地基,还能稳稳托得住的那个高度。别在借来的从容里,忘了自己真实的海拔。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

用大模型抽 JSON 测试全过上线偶崩:一次 LLM 结构化输出不可靠的复盘

2026-5-21 11:27:56

技术教程

AI 流式回答总是憋半天一次性蹦出来:一次 SSE 流式输出被 Nginx 缓冲的复盘

2026-5-21 11:56:30

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索