我给系统加了 Redis 缓存本以为高枕无忧,大促零点却被缓存集体失效瞬间打垮了数据库,我对着缓存雪崩、击穿、穿透排查了大半天的复盘
那是我第一次扛大促。为了应对流量,我提前给系统的核心查询都加上了 Redis 缓存,自测时缓存命中率很高、数据库压力很小,我信心满满地以为"有缓存挡着,数据库稳如泰山"。可大促零点一过,监控就开始疯狂告警:数据库 CPU 瞬间打满、连接池耗尽、大量慢查询,接口大面积超时。我盯着那条几乎垂直拉起的数据库 QPS 曲线,百思不得其解:缓存呢?我加的缓存去哪了?排查了大半天,才把三个名字相近、却各有各坑的"缓存失效"问题——雪崩、击穿、穿透——彻底搞明白。这篇就把这场"缓存没挡住、数据库被打垮"的事故,从头复盘一遍。
故障现场:缓存命中率断崖,数据库 QPS 飙升
先看现场。零点前后,缓存和数据库的曲线形成了刺眼的反差:
# 1. 监控曲线(零点前后):
缓存命中率: 零点前 98% ──→ 零点后 骤降到 30% 以下
数据库 QPS: 零点前 200 ──→ 零点后 飙到 18000+ (几乎垂直拉起)
数据库 CPU: 零点前 15% ──→ 零点后 100% 打满
接口 RT: 零点前 20ms ──→ 零点后 大量超时(>5s)
# 2. 排查发现的三个"巧合", 正好对应三种缓存失效问题:
# 巧合A(雪崩): 大量缓存 key 在同一时刻过期
# 我图省事, 给所有缓存都设了相同的过期时间(比如统一 30 分钟)。
# → 这批 key 是在系统启动/预热时一起写入的, 于是 30 分钟后"集体过期"。
# → 零点刚好撞上这个时间点, 海量请求同时穿透到数据库。
# 3. 巧合B(击穿): 某个超热门商品的缓存 key 恰好失效
# 一个爆款商品(QPS 上万)的缓存 key 过期的一瞬间,
# → 上万个请求同时发现"缓存没了", 一起冲向数据库去查同一条数据,
# → 数据库被这一个 key 的并发重建请求瞬间压垮。
# 4. 巧合C(穿透): 有人在刷"不存在的商品ID"
# 日志里大量查询 product_id = -1 / 999999999 这种不存在的ID,
# → 数据库查不到 → 不会写缓存 → 下次同样的请求又落到数据库,
# → 缓存完全失去作用, 每次都打数据库(可能是恶意攻击或爬虫)。
# 现象拼图:
# - 我以为"加了缓存 = 数据库安全", 但缓存"失效的瞬间"恰恰是最危险的。
# - 雪崩: 大量key同时失效; 击穿: 单个热key失效; 穿透: 查根本不存在的数据。
# - 三者都导致请求"绕过缓存、直击数据库", 高并发下瞬间打垮 DB。
看清这三个"巧合"后,我后背一凉:原来我对缓存的理解太天真了。我以为"加了缓存 = 数据库安全",却忽略了一个致命的事实:缓存"失效的那一瞬间",恰恰是整个系统最脆弱的时候。而"失效"有三种截然不同的形态:雪崩(大量 key 同时失效)、击穿(单个热点 key 失效)、穿透(查询根本不存在的数据,缓存永远不命中)。这三者,都让请求绕过了本该挡在前面的缓存、直接冲击数据库,在大促的高并发下,瞬间就把数据库打垮了。要根治,得先把这三个"长得像、根却不同"的问题一个个拆开。
第一件事:搞懂雪崩、击穿、穿透到底有什么区别
要解决,首先得精确区分这三个常被混为一谈的概念。它们的成因和解法,其实完全不同。
缓存三大失效问题, 精确辨析
# ════ 缓存雪崩(Avalanche)════
# 是什么: "大量" key 在"同一时间"集体失效(或 Redis 整体宕机),
# 导致海量请求同时穿透到数据库。
# 关键词: 大面积、同时。
# 成因: 过期时间设成一样的 / Redis 挂了。
# 比喻: 一整面挡水的墙(缓存)同时塌了, 洪水(请求)全涌向后方(DB)。
# ════ 缓存击穿(Breakdown / Hotspot)════
# 是什么: "某一个热点" key 在失效的瞬间, 大量并发请求同时去重建它,
# 一起冲向数据库查同一条数据。
# 关键词: 单个、热点、瞬间。
# 成因: 一个超高并发访问的 key 过期了。
# 比喻: 墙上本来好好的, 突然破了"一个洞", 但这个洞正对着消防栓(热点),
# 于是水流全从这一个洞猛灌进去。
# ════ 缓存穿透(Penetration)════
# 是什么: 查询"根本不存在"的数据, 缓存里永远没有(因为查不到也没法缓存),
# 于是每次请求都落到数据库。
# 关键词: 数据不存在、缓存永不命中。
# 成因: 查不存在的ID(恶意攻击/爬虫/参数错误)。
# 比喻: 有人专门来要一件"店里根本没有的商品", 你每次都得跑去仓库
# (DB)确认"真没有", 缓存(货架)上永远不会有它。
# ── 一句话区分 ──
# 雪崩: 缓存"大面积"失效 → 量大。
# 击穿: 缓存"单个热点"失效 → 点热。
# 穿透: 数据"压根不存在" → 缓存帮不上忙。
# 核心: 三者都让请求绕过缓存打到DB, 但成因不同 ——
# 雪崩=大量key同时过期/Redis宕、击穿=单热点key失效、穿透=查不存在的数据。
把这三个概念逐一辨析清楚后,迷雾散开了。缓存雪崩,关键词是"大面积、同时"——大量 key 在同一时间集体失效(或 Redis 整体宕机),像一整面挡水墙同时塌了,洪水全涌向数据库。缓存击穿,关键词是"单个、热点、瞬间"——某一个超高并发的热点 key 失效的瞬间,大量请求同时去重建它,像墙上破了一个正对消防栓的洞。缓存穿透,关键词是"数据不存在、缓存永不命中"——查询根本不存在的数据,缓存里永远没有,每次都落到数据库(常是恶意攻击或爬虫)。一句话区分:雪崩是缓存"大面积"失效(量大)、击穿是缓存"单个热点"失效(点热)、穿透是数据"压根不存在"(缓存帮不上忙)。它们的共同点是都让请求绕过缓存打到 DB,但成因截然不同——而成因不同,解法自然也完全不同。这正是我之前一锅粥、找不到方向的根源:把三个不同的病,当成一个病在治。
第二件事:正解——三个问题,三套对症的解法
搞懂了区别,正解就清晰了:雪崩靠过期时间打散+高可用+多级缓存;击穿靠互斥锁重建+逻辑过期;穿透靠空值缓存+布隆过滤器。
import random, threading
# ════ 解法一: 治雪崩 —— 过期时间打散 + 高可用 + 兜底 ════
def set_cache_avalanche(key, value, base_ttl=1800):
# ✓ 给过期时间加随机抖动, 避免大量key在同一刻过期
ttl = base_ttl + random.randint(0, 300) # 30分钟 + 0~5分钟随机
redis.setex(key, ttl, value)
# 其他: Redis 集群/主从 保证高可用(别让整个Redis挂掉);
# 加多级缓存(本地caffeine + Redis); 数据库侧限流/熔断兜底。
# ════ 解法二: 治击穿 —— 互斥锁重建 + 逻辑过期 ════
def get_with_mutex(key, build_func, ttl=1800):
value = redis.get(key)
if value is not None:
return value
# 缓存失效: 用分布式锁, 只让"一个"请求去重建, 其他等待
lock_key = f"lock:{key}"
if redis.set(lock_key, "1", nx=True, ex=10): # 抢到锁
try:
value = build_func() # 只有我查数据库重建
redis.setex(key, ttl, value)
finally:
redis.delete(lock_key)
return value
else: # 没抢到锁
time.sleep(0.05) # 等一下
return get_with_mutex(key, build_func, ttl) # 重试(此时缓存可能已重建)
# → 热key失效时, 只有1个请求打DB重建, 其余等待复用结果, DB不被压垮。
# 进阶: "逻辑过期"(value里存逻辑过期时间, 不设真实TTL, 异步重建)避免等待。
# ════ 解法三: 治穿透 —— 空值缓存 + 布隆过滤器 ════
def get_with_penetration_guard(key, build_func, ttl=1800):
value = redis.get(key)
if value is not None:
return None if value == "__NULL__" else value # 命中空值标记
# 布隆过滤器: 先判断"这个ID是否可能存在", 不存在直接拒绝(防恶意刷)
if not bloom_filter.might_contain(key):
return None # 一定不存在, 不查DB
value = build_func() # 查数据库
if value is None:
redis.setex(key, 60, "__NULL__") # ✓ 不存在也缓存(短TTL空值)
return None # 下次同样查询命中空值, 不打DB
redis.setex(key, ttl, value)
return value
# → 空值缓存: 查不到的也缓存个标记, 挡住"重复查不存在数据"。
# → 布隆过滤器: 海量不存在的ID, 在缓存前就被快速拦截。
# 核心: 雪崩→过期打散+高可用+多级缓存+限流; 击穿→互斥锁/逻辑过期只让一个重建;
# 穿透→空值缓存+布隆过滤器拦截不存在的查询。三病三药, 对症下药。
修复的关键,是"针对三个不同的问题,用三套对症的解法"。治雪崩:核心是给过期时间加随机抖动(避免大量 key 同一刻过期),再配合 Redis 高可用(集群/主从,别让整个 Redis 挂)、多级缓存(本地 + Redis)、数据库侧限流熔断兜底。治击穿:用互斥锁(分布式锁)重建——热点 key 失效时,只让一个请求去查 DB 重建,其余请求等待复用结果,DB 就不会被一个 key 的并发重建压垮;进阶用"逻辑过期"(异步重建,避免等待)。治穿透:用空值缓存(查不到的数据也缓存一个空标记、设短 TTL,挡住重复查询不存在数据)+ 布隆过滤器(海量不存在的 ID 在查缓存前就被快速拦截,防恶意刷)。归根结底:雪崩→过期打散+高可用+多级缓存+限流;击穿→互斥锁/逻辑过期只让一个重建;穿透→空值缓存+布隆过滤器。三病三药,对症下药。
第三件事:缓存与数据库的一致性,别又踩新坑
修缓存失效问题时,我差点又踩进另一个经典坑:缓存与数据库的数据一致性。顺手也梳理了一遍。
缓存更新策略与一致性
# 数据更新时, 缓存怎么处理? 几种常见策略:
# 1. 先更新数据库, 再删除缓存 (Cache-Aside, 最常用, 推荐)
# 更新DB → 删缓存(让下次读时重建)。
# 为什么"删"不"更新"缓存? 删除更简单、避免并发更新时的脏数据。
# 为什么"先DB后删缓存"? 减少不一致窗口(虽仍有极小概率)。
# 2. 先删缓存, 再更新数据库 (有坑!)
# 删缓存 → (此时其他请求读, 发现没缓存, 用"旧DB值"重建了缓存!) → 更新DB
# → 缓存里是旧值, 不一致。需配合"延迟双删"(更新DB后再删一次)。
# 3. 延迟双删
# 删缓存 → 更新DB → 延迟一会儿(如500ms) → 再删一次缓存。
# → 删掉"更新期间被旧值重建的缓存"。兜底方案。
# ── 一致性的本质 ──
# - 缓存 + DB 是两个系统, 严格的强一致几乎不可能(且代价高)。
# - 大多数场景追求"最终一致" + 用短TTL兜底(就算没删成, TTL到了也会刷新)。
# - 关键: 接受"短暂不一致", 用合理策略把不一致窗口缩到最小。
# 核心: 推荐 Cache-Aside(先更DB再删缓存)+ 短TTL兜底;
# 缓存与DB难强一致, 追求最终一致, 把不一致窗口缩到最小。
原来缓存的世界里,"失效问题"和"一致性问题"是两座都得翻的山。关于更新策略:最常用且推荐的是 Cache-Aside(先更新数据库、再删除缓存)——"删"而不"更新"是因为删除更简单、还能避免并发更新的脏数据;"先 DB 后删缓存"是为了减少不一致窗口。而"先删缓存再更新 DB"有坑(删完、更新前,其他请求可能用旧值重建缓存),需配合延迟双删(更新 DB 后延迟再删一次)兜底。更深一层的本质是:缓存和 DB 是两个独立系统,严格强一致几乎不可能(且代价极高);大多数场景应追求"最终一致" + 用短 TTL 兜底(就算删缓存失败,TTL 到了也会自动刷新)。归根结底:推荐 Cache-Aside + 短 TTL 兜底;接受短暂不一致,用合理策略把不一致窗口缩到最小。下面这张图,是这次缓存失效打垮数据库的三种成因与解法:
第四件事:雪崩、击穿、穿透对比速查
这次踩坑后,我把三者的区别和解法整理成一张速查表,贴在工位上,设计缓存时对照着想。
| 问题 | 触发 | 关键词 | 核心解法 |
|---|---|---|---|
| 雪崩 Avalanche | 大量key同时失效/Redis宕 | 大面积、同时 | 过期时间打散+高可用+多级缓存+限流 |
| 击穿 Breakdown | 单个热点key失效瞬间 | 单个、热点、瞬间 | 互斥锁重建+逻辑过期 |
| 穿透 Penetration | 查询不存在的数据 | 数据不存在、永不命中 | 空值缓存+布隆过滤器 |
这张表,把三个"长得像"的问题彻底钉死在各自的格子里。记忆的诀窍,就在那几个关键词:雪崩看"量"(大面积同时)、击穿看"点"(单个热点)、穿透看"有没有"(数据压根不存在)。它给我的最大启发是:很多容易混淆的技术概念,辨析它们的关键,不是死记定义,而是抓住每个概念"最本质的那个特征维度"——雪崩的本质维度是"失效的范围(大)"、击穿是"失效对象的热度(高)"、穿透是"数据的存在性(无)"。一旦抓住了这个"区分性维度",三个概念就再也不会搞混,对应的解法也自然推得出来(范围大→打散、热度高→加锁只让一个进、数据不存在→缓存空值/提前拦截)。这是一种"理解性记忆"——比起背诵,理解每个概念"独一无二的那一面",才是真正把知识变成自己的。
第五件事:缓存设计的整体检查清单
这次事故后,我整理了一份"给系统加缓存"的检查清单,设计时逐项过一遍,避免再裸奔上线。
| 检查项 | 裸奔做法(危险) | 正确做法 |
|---|---|---|
| 过期时间 | 所有key统一TTL | 基础TTL+随机抖动 |
| 热点key | 失效就让全部请求重建 | 互斥锁/逻辑过期 |
| 不存在的数据 | 查不到不处理 | 空值缓存+布隆过滤器 |
| Redis可用性 | 单点Redis | 集群/主从高可用 |
| DB兜底 | 无保护,缓存挂就裸奔 | 限流+熔断+降级 |
| 一致性 | 随意更新缓存 | Cache-Aside+短TTL兜底 |
| 多级缓存 | 只有Redis一层 | 本地缓存+Redis |
这张清单,是我用一次被打垮的数据库换来的"缓存上线前必检项"。它把"加缓存"这件看似简单的事,背后真正需要考虑的维度都列了出来:过期打散、热点保护、空值/布隆防穿透、Redis 高可用、DB 限流熔断兜底、一致性策略、多级缓存。它给我的最大启发,远超缓存本身:"加缓存"绝不是"加一行 redis.get/set"那么简单——它本质上是引入了一个新的、独立的、会失效的中间层,而这个中间层一旦失效,系统反而可能比"没有缓存"时更脆弱(因为下游 DB 的容量,是按"有缓存挡着"来规划的)。这让我领悟到一个架构上的深刻道理:每引入一个组件(缓存、消息队列、网关……),在获得它带来的好处的同时,也引入了它自身的失效模式和复杂度;真正成熟的架构设计,不只是"用对组件实现功能",更是"想清楚这个组件失效时会发生什么、并为之准备好预案"。架构的健壮性,往往就体现在这些"为失效做的准备"里。
第六件事:给系统设计缓存时,我现在的决策路径
现在再给系统加缓存,我不再 redis.get/set 一把梭,而是按这张图把三种失效和兜底都考虑进去:
这张图的精髓,是"加缓存时,就把它的各种失效场景一并设计进去"。从过期时间打散(防雪崩)开始,逐一判断:有热点 key 吗?→ 加互斥锁(防击穿);会被刷不存在的数据吗?→ 空值缓存+布隆(防穿透);Redis 要高可用(集群/主从);DB 侧要有限流熔断降级兜底;更新用 Cache-Aside+短 TTL。而最后一步,是我以前完全没有的意识:压测时主动模拟"缓存失效"的场景,看 DB 到底扛不扛得住——别只测"缓存正常命中"的美好情况,要测"缓存崩了"的最坏情况。这套决策,让我设计缓存时,从"加上就以为安全了"变成了"假设它会失效,并准备好预案"——核心始终是:缓存是会失效的中间层,设计时必须为"它失效的瞬间"做好准备。
我立下的几条规矩
这场"缓存没挡住、DB 被打垮"的事故,换来了我做缓存/架构设计时,刻进骨子里的几条铁律:
- 过期时间一定加随机抖动。别让大量 key 在同一刻集体过期(防雪崩)。
- 热点 key 失效用互斥锁重建。只让一个请求打 DB,其余等待复用(防击穿)。
- 对不存在的数据做防护。空值缓存 + 布隆过滤器,挡住穿透和恶意刷。
- Redis 要高可用,DB 要有兜底。缓存层会挂,下游必须有限流、熔断、降级。
- 缓存一致性用 Cache-Aside + 短 TTL。接受最终一致,把不一致窗口缩到最小。
- 压测要模拟缓存失效。别只测命中的美好情况,要测缓存崩了 DB 扛不扛得住。
- 引入任何组件,都要想清它失效时会怎样。架构健壮性藏在"为失效做的准备"里。
附:一个集三种防护于一体的缓存读取封装
口说无凭。下面把防雪崩、防击穿、防穿透三套解法,合到一个生产可用的缓存读取函数里:
import random, time, json
def cached_get(key, build_func, base_ttl=1800, lock_ttl=10):
"""集三防于一体的缓存读取:
防穿透(空值缓存) + 防击穿(互斥锁) + 防雪崩(TTL抖动)。"""
# ---- 1. 先读缓存 ----
raw = redis.get(key)
if raw is not None:
if raw == "__NULL__": # 命中"空值标记"(防穿透)
return None
return json.loads(raw)
# ---- 2. 缓存未命中: 布隆过滤器先拦截不存在的key(防穿透/恶意刷)----
if not bloom_filter.might_contain(key):
return None # 一定不存在, 直接返回, 不打DB
# ---- 3. 互斥锁: 只让一个请求去重建(防击穿)----
lock_key = f"lock:{key}"
if redis.set(lock_key, "1", nx=True, ex=lock_ttl):
try:
# double-check: 抢到锁后再看一眼(可能别人刚建好)
raw = redis.get(key)
if raw is not None:
return None if raw == "__NULL__" else json.loads(raw)
value = build_func() # 只有我查DB
if value is None:
# 防穿透: 不存在也缓存空值(短TTL, 别污染太久)
redis.setex(key, 60, "__NULL__")
return None
# 防雪崩: 基础TTL + 随机抖动, 避免集体过期
ttl = base_ttl + random.randint(0, 300)
redis.setex(key, ttl, json.dumps(value))
return value
finally:
redis.delete(lock_key) # 释放锁
else:
# 没抢到锁: 短暂等待后重试(此时缓存大概率已被别人重建)
time.sleep(0.05)
return cached_get(key, build_func, base_ttl, lock_ttl)
# 用法:
# product = cached_get(f"product:{pid}",
# build_func=lambda: db.query_product(pid))
#
# 三防一体:
# 防穿透 = 布隆过滤器(提前拦) + 空值缓存(查不到也缓存标记)
# 防击穿 = 互斥锁(热key失效只让一个重建) + double-check
# 防雪崩 = TTL 随机抖动(避免集体过期)
# 再配合: Redis高可用 + DB限流熔断, 就是一套完整的缓存防护。
# 核心: 把雪崩/击穿/穿透三套解法封装进一个读取函数, 一次写对, 处处复用;
# 配合Redis高可用与DB兜底, 缓存层才真正"扛得住失效"。
这个 cached_get,把这篇文章的三套解法,落成了一个可以直接复用的函数。它把三道防线编织在一次读取流程里:布隆过滤器 + 空值缓存(防穿透,在打 DB 前就拦掉不存在的 key)、互斥锁 + double-check(防击穿,热点 key 失效时只让一个请求重建、其余等待复用)、TTL 随机抖动(防雪崩,避免重建出来的一批 key 又在同一刻集体过期)。这,正是我想用这个函数,留给每个做缓存的人的最后一课:把"踩过的所有坑、想清楚的所有防护",沉淀成一个写对一次、处处复用的封装,是工程师对抗复杂度最有力的武器。缓存的这三个坑,每一个单独看都不难,难的是"每次写缓存都记得把三个都防住";而一个好的封装,正是把"依赖每个人每次都记得"的脆弱,变成了"一次正确、长久可靠"的稳固。从"每次手写 redis.get/set"到"调用一个三防一体的 cached_get",这一步的距离,正是"能跑的代码"和"扛得住生产的代码"之间,那道由无数次踩坑铺成的鸿沟。把经验固化成代码,让正确变得廉价、让犯错变得困难——这,或许就是工程的真谛。
写在最后
回头看,这场由"缓存集体失效"引发的、把数据库打垮的大促事故,真正教给我的,远不止雪崩、击穿、穿透这三个名词。它彻底改变了我对"引入一个组件"这件事的认知。过去的我,看待缓存的眼光是"它能带来什么好处"(挡住流量、加速查询);而这次事故,逼我学会了用另一只眼睛去看:"它失效的时候,会带来什么灾难"。我天真地以为"加了缓存,数据库就安全了",却没意识到一个反直觉的真相:引入缓存后,数据库的"抗压能力规划",其实是建立在"缓存能正常工作"这个假设之上的——一旦缓存失效,下游 DB 要直面它从未被设计去承受的全部流量,反而比"压根没有缓存、DB 一直在硬扛"时更危险(因为后者至少 DB 的容量是按全量流量规划的)。这让我领悟到一个深刻的架构哲学:每一个为了"优化"而引入的组件(缓存、队列、读写分离……),在提升系统某方面能力的同时,都悄悄地引入了一个新的"它自己会失效"的故障点,以及一个"系统开始依赖它正常工作"的隐含假设;而真正的架构功力,不在于"会用多少高级组件",而在于"能否清醒地认识到每个组件的失效模式,并为最坏情况设计好兜底"。系统的健壮,从来不是靠"组件永不失效"的乐观假设,而是靠"假设一切都会失效"的悲观设计。这,是我用一次被打垮的数据库,换来的、关于缓存、也关于"防御性架构"的、最朴素也最深刻的领悟。如果这篇复盘,能让你在下次给系统加缓存时,多问一句"它失效的那一刻,我的 DB 扛得住吗",那我对着那条垂直拉起的 QPS 曲线熬的这大半天,就值了。
—— 别看了 · 2026