我给热点数据加了缓存,本以为能稳稳扛住流量,结果缓存一过期,成千上万的请求瞬间全砸到了数据库、把它直接压垮的深度复盘
这是一个让我对"缓存"刻骨铭心的故事。我有一个热点数据(比如首页的某个榜单、一个被疯狂访问的商品详情),为了扛住巨大的访问量,我给它加了 Redis 缓存:请求来了,先查缓存,命中就直接返回(快、且不打数据库);没命中,才去查数据库、然后把结果写进缓存。加上缓存后,数据库的压力一下就下来了,我很满意——缓存,稳稳地,把绝大多数流量,都挡在了数据库前面。
可有一天,在流量高峰,数据库突然被打挂了——CPU 飙满、连接耗尽、响应超时,一片狼藉。我一脸懵:不是有缓存挡着吗?绝大多数请求,不都该命中缓存、根本到不了数据库吗?怎么数据库会突然被打垮?我盯着监控复盘,才发现了那个致命的瞬间:数据库被打垮,恰好发生在那个热点缓存"过期"的一刹那!原来,我中了缓存的一个经典坑——"缓存击穿(cache breakdown)"。具体来说:那个热点 key,设了一个过期时间;在它过期的那一瞬间,缓存里突然没有它了;而此时,正有成千上万的并发请求,在访问这个热点;它们同时来查缓存,同时都没命中,于是,这成千上万的请求,就在同一瞬间,全部涌向了数据库——它们都想去"查数据库、然后回填缓存";可数据库,哪扛得住这瞬间几万个本该被缓存挡住的请求?它瞬间就被压垮了。而更糟的是,在数据库被压垮、第一个请求还没来得及把缓存回填好之前,后续涌来的请求,依然查不到缓存,继续砸向那个已经奄奄一息的数据库,形成了恶性循环。我这才痛彻地明白:我之前,只享受了缓存"命中时"的美好(它挡住了流量);却完全没有考虑过,缓存"失效时"(过期的那一刻)会发生什么——我想当然地假设了"缓存永远命中";可缓存,是会过期、会失效的;而当一个热点缓存失效时,那原本被它挡住的、汹涌的流量,会在那一瞬间,毫无缓冲地、全部砸向脆弱的数据库。缓存,在这一刻,从"保护伞",变成了"决堤的口子"。
故障现场:热点 key 过期瞬间,流量全砸向数据库
我把这个"缓存击穿"的现场,用代码和过程摊开给你看:
# ✗ 灾难: 朴素的"读缓存→没有则查库回填", 没防"击穿"
def get_hot_data(key):
data = redis.get(key)
if data is not None:
return data # 命中缓存, 快, 不打库
# ✗ 未命中: 直接查库 + 回填(没有任何保护)
data = db.query(key) # 查数据库
redis.set(key, data, ex=300) # 回填, 过期 5 分钟
return data
# 缓存击穿的瞬间(热点 key 过期):
# T0: 热点 key 过期, 缓存里没了。
# T0~T1: 此时正有 1 万个并发请求在访问这个热点
# → 它们同时 redis.get → 同时未命中
# → 1 万个请求"同时"涌向 db.query() → 数据库瞬间被 1 万个查询压垮!
# (而第一个请求还没回填好缓存, 后续请求继续未命中、继续砸库 → 恶性循环)
# → 本该被缓存挡住的流量, 在过期瞬间, 毫无缓冲地全砸向数据库。
# 三个相关但不同的"缓存灾难", 要分清:
# 1. 缓存击穿(breakdown): "某个热点 key"过期瞬间, 大量请求同时穿透到库(本文)。
# 2. 缓存穿透(penetration): 查"根本不存在的数据"(如不存在的 id),
# 缓存永远不命中(因为没数据可缓存), 每次都打库(恶意攻击常用)。
# 3. 缓存雪崩(avalanche): "大量 key 在同一时间"集中过期, 瞬间大量请求打库。
# 根因: 只考虑了"缓存命中"的美好, 没考虑"缓存失效时"的兜底。
# 假设了"缓存永远命中"; 而热点缓存一旦失效, 汹涌流量瞬间砸向脆弱的库。
看着这个过期瞬间的时序,我才算真正理解了这场"数据库被打垮"的根源。问题的核心,是我中了缓存的一个经典坑——"缓存击穿(cache breakdown)"。具体来说:那个热点 key,设了过期时间;在它过期的那一瞬间(T0),缓存里突然没有它了;而此时,正有上万个并发请求,在访问这个热点——它们同时来查缓存、同时都没命中,于是,这上万个请求,就在同一瞬间,全部涌向了数据库(它们都想"查库、然后回填缓存");可数据库,哪扛得住这瞬间上万个、本该被缓存挡住的请求?它瞬间就被压垮了。而更糟的是,在第一个请求还没来得及把缓存回填好之前,后续涌来的请求,依然查不到缓存,继续砸向那个已经奄奄一息的数据库,形成恶性循环。而我也借此,分清了三个相关但不同的"缓存灾难":缓存击穿(breakdown)——"某个热点 key"过期的瞬间,大量请求同时穿透到库(本文);缓存穿透(penetration)——查询"根本不存在的数据"(如一个不存在的 id),缓存里永远没有它(因为没数据可缓存)、所以每次都打库(常被恶意攻击利用);缓存雪崩(avalanche)——"大量 key 在同一时间集中过期",导致瞬间有大量请求一起打库。归根结底:我犯的错,是只考虑了"缓存命中"的美好(它挡住了绝大多数流量),却完全没有考虑过"缓存失效时"该怎么兜底;我想当然地假设了"缓存永远命中"。可缓存,是会过期、会失效的;而当一个热点缓存失效时,那原本被它挡住的、汹涌的流量,会在那一瞬间,毫无缓冲地、全部砸向脆弱的数据库。缓存,在这一刻,从"保护伞",变成了"决堤的口子"——而我,从没为这个"决堤的瞬间",准备过任何防护。
第一件事:搞懂缓存击穿/穿透/雪崩
定位到根源,我必须把"缓存的三大经典问题"彻底搞清楚:
缓存的三大经典问题: 击穿 / 穿透 / 雪崩
# 共同背景: 缓存挡在数据库前面, 大部分请求命中缓存、不打库。
# 但"缓存没命中"的请求, 会穿透到数据库。问题就出在"没命中"上。
# 1. 缓存击穿(breakdown): 单个"热点 key"过期的瞬间
# - 一个被高并发访问的热点 key, 突然过期。
# - 那一刻, 大量并发请求同时未命中 → 同时涌向数据库 → 库被压垮。
# - 特点: 针对"某个热点 key"、发生在"它过期的瞬间"。
# 2. 缓存穿透(penetration): 查"不存在的数据"
# - 查询一个数据库里"根本不存在"的 key(如不存在的 id)。
# - 缓存里永远没有它(没数据可缓存)→ 每次都未命中 → 每次都打库。
# - 常被恶意利用: 故意大量查不存在的 key, 绕过缓存直接打垮库。
# - 特点: 针对"不存在的数据"、缓存形同虚设。
# 3. 缓存雪崩(avalanche): "大量 key 同时"过期 / 缓存整体挂了
# - 大量 key 设了相同的过期时间, 在同一刻集中过期。
# - 或 Redis 整个挂了 → 所有请求瞬间全打库 → 库被压垮。
# - 特点: "大面积"同时失效。
# 它们的共同本质:
# "本该被缓存挡住的流量, 因为缓存没命中, 瞬间/持续地砸向了数据库"。
# 而数据库的承受能力, 远低于缓存 → 被压垮。
# 关键认知: 加缓存时, 不能只想"命中时多美好",
# 必须想"没命中/失效时会怎样"——尤其是热点、大面积失效的情况。
# 核心: 击穿(热点key过期瞬间)、穿透(查不存在的)、雪崩(大量key同时失效)。
# 本质都是"流量在缓存失效时砸向库"。要为"缓存失效"做防护。
原理终于清晰了。这三大问题,有一个共同的背景:缓存挡在数据库前面,大部分请求命中缓存、不打库;但"缓存没命中"的请求,会穿透到数据库——而问题,就出在"没命中"上。具体分三种:缓存击穿(breakdown)——针对"某个热点 key",发生在"它过期的瞬间":一个被高并发访问的热点 key 突然过期,那一刻,大量并发请求同时未命中、同时涌向数据库,库被压垮(本文)。缓存穿透(penetration)——针对"不存在的数据":查询一个数据库里根本不存在的 key,缓存里永远没有它(没数据可缓存),于是每次都未命中、每次都打库,缓存形同虚设(常被恶意利用,故意大量查不存在的 key 来打垮库)。缓存雪崩(avalanche)——"大面积同时失效":大量 key 设了相同的过期时间、在同一刻集中过期,或者 Redis 整个挂了,导致所有请求瞬间全打库。而它们的共同本质,是同一个:"本该被缓存挡住的流量,因为缓存没命中,瞬间或持续地,砸向了数据库";而数据库的承受能力,远低于缓存,于是被压垮。由此,我建立起一个最关键的认知:加缓存时,不能只想"命中时多美好",而必须想"没命中/失效时会怎样"——尤其是热点 key 过期、大面积失效这些情况。归根结底:击穿(热点 key 过期瞬间)、穿透(查不存在的)、雪崩(大量 key 同时失效),它们的本质,都是"流量在缓存失效时砸向了库";所以,加缓存,绝不能止于"享受命中",而必须为"缓存失效"这件必然会发生的事,做好防护——这,是我用一次"缓存击穿、数据库被打垮"的事故,补上的、关于缓存最关键的一课。
第二件事:正解——击穿用互斥/逻辑过期,穿透缓空值,雪崩加随机
搞懂了根因——"缓存失效时流量砸库"——正解就清晰了:针对击穿,用"互斥锁(只让一个请求去查库回填,其余等)"或"逻辑过期(不真过期、后台异步刷新)";针对穿透,"缓存空值"或用"布隆过滤器"挡掉不存在的 key;针对雪崩,给过期时间加随机抖动、做多级缓存、并给数据库加限流兜底。
# 正解1: 防击穿——互斥锁(只让"一个"请求去查库回填, 其余等它)
def get_hot_data(key):
data = redis.get(key)
if data is not None:
return data
# 未命中: 抢一把分布式锁, 只有抢到的那个去查库, 其余等待
lock_key = "lock:" + key
if redis.set(lock_key, "1", nx=True, ex=10): # 抢锁(只有一个能抢到)
try:
data = db.query(key) # 只有这一个请求查库!
redis.set(key, data, ex=300) # 回填
finally:
redis.delete(lock_key)
return data
else:
time.sleep(0.05) # 没抢到锁 → 稍等, 再读缓存(此时已回填)
return get_hot_data(key)
# → 过期瞬间, 1 万个请求里, 只有 1 个去查库, 其余等它回填好 → 库不被压垮。
# (单机用本地锁/singleflight; 分布式用 redis 锁。Go 有 singleflight 库, 一行搞定)
# 正解2: 防击穿(另一思路)——逻辑过期, 不真过期
# - key 不设 TTL(不真过期), 而是在 value 里存一个"逻辑过期时间"。
# - 读时发现"逻辑过期了" → 返回旧值 + 触发"后台异步"刷新缓存。
# → 永远有值可返回(不穿透到库), 刷新在后台悄悄做。适合"可短暂用旧值"的热点。
# 正解3: 防穿透——缓存空值 / 布隆过滤器
# - 缓存空值: 查库为空时, 也把"空"缓存起来(短 TTL), 下次直接返回空, 不打库。
redis.set(key, "NULL", ex=60) # 不存在的也缓存(防止反复查库)
# - 布隆过滤器: 用它快速判断"key 一定不存在" → 不存在直接拒, 不查缓存/库。
# 正解4: 防雪崩——过期时间加随机 + 多级缓存 + 限流兜底
redis.set(key, data, ex=300 + random.randint(0, 60)) # 加随机, 别同时过期
# - 多级缓存(本地缓存 + Redis): Redis 挂了还有本地兜一层。
# - 给数据库加限流/熔断: 万一大量穿透, 也别让库被打死(保命)。
# 核心: 为"缓存失效"做防护——击穿(互斥/逻辑过期)、穿透(空值/布隆)、
# 雪崩(随机过期/多级/限流)。别让缓存失效时, 流量裸奔砸向数据库。
这套正解,核心是针对三种问题,各自为"缓存失效"建立防护。正解1(防击穿:互斥锁):热点 key 未命中时,抢一把锁,只让抢到锁的那一个请求去查库回填,其余请求稍等一下、再读缓存(此时已被回填好);这样,过期的瞬间,上万个请求里,只有 1 个真正去查了库,数据库就不会被压垮了(单机可用本地锁/singleflight,分布式用 Redis 锁;Go 的 singleflight 库一行就能搞定"同一个 key 的并发请求只执行一次")。正解2(防击穿:逻辑过期):另一种思路——key 不设真正的 TTL(不真过期),而是在 value 里存一个"逻辑过期时间";读时发现"逻辑过期了",就返回旧值、并触发后台异步刷新;这样,永远有值可返回(不穿透到库),刷新在后台悄悄做(适合"能短暂容忍旧值"的热点)。正解3(防穿透:缓存空值/布隆过滤器):缓存空值——查库为空时,也把"空"缓存起来(短 TTL),下次直接返回空、不再打库;布隆过滤器——用它快速判断"这个 key 一定不存在",不存在就直接拒掉。正解4(防雪崩:随机过期 + 多级缓存 + 限流):给过期时间加一个随机抖动(别让大量 key 在同一刻过期)、做多级缓存(本地缓存 + Redis,Redis 挂了还有本地兜一层)、并给数据库加限流/熔断(万一大量穿透,也别让库被打死,保命)。归根结底:要为"缓存失效"这件必然会发生的事,做好防护——击穿用互斥锁/逻辑过期、穿透用缓存空值/布隆、雪崩用随机过期/多级/限流;绝不能让缓存失效时,流量"裸奔"着砸向数据库。我那次的错误,正是对缓存失效毫无防护;而正解,就是为每一种失效,都备好兜底。
下面这张图,对比了"无防护"和"有防护"两条路径:
这张图的对比很清楚:左边红色那条,无防护、所有未命中的请求都去查库回填,上万请求同时砸向数据库、瞬间被压垮;右边绿色那条,用互斥锁只让 1 个请求查库回填、其余等它回填好再读缓存,数据库只承受 1 次查询、安然无恙;同时还要防穿透(缓存空值/布隆)和雪崩(随机过期/多级/限流)。两条路的根本分野,在于缓存失效时,有没有给数据库一道防护、而不是让流量裸奔砸过去。
第三件事:三者对比、以及缓存一致性
填平了击穿这个坑,我把击穿/穿透/雪崩的对比、以及更深的"缓存一致性"问题,系统梳理了一遍:
击穿/穿透/雪崩对比 + 缓存一致性
# 三者对比(一眼区分):
# 击穿: 一个"热点 key"过期瞬间 → 互斥锁 / 逻辑过期。
# 穿透: 查"不存在的数据"每次打库 → 缓存空值 / 布隆过滤器。
# 雪崩: "大量 key 同时"失效 / 缓存挂 → 随机过期 / 多级缓存 / 限流。
# 还有一个更基础、更常见的问题: 缓存一致性
# - 数据库更新了, 缓存还是旧的 → 读到脏数据。
# - 常见更新策略:
# a. Cache Aside(旁路缓存, 最常用): 更新库 + 删除缓存(下次读时回填新值)。
# - 为什么是"删除"而非"更新缓存"? 删除更简单、不易出并发不一致。
# b. 先更库再删缓存 vs 先删缓存再更库: 都有并发边界问题,
# 常配合"延迟双删"等手段缓解。
# - 核心: 缓存和库, 是"两份数据", 天然有不一致的窗口; 要选合适的策略
# 把窗口和影响控制到可接受。
# 加缓存前要想清楚的几件事:
# 1. 数据能容忍多久的"旧"(决定 TTL 和一致性策略)?
# 2. 哪些是热点(要防击穿)?
# 3. 会不会被查不存在的(要防穿透)?
# 4. key 过期时间会不会扎堆(要防雪崩)?
# 5. 缓存挂了, 数据库扛得住吗(要兜底/限流)?
# 核心: 缓存不只是"加上去就快了"——要系统考虑失效(击穿/穿透/雪崩)
# 和一致性。加缓存, 是引入了一个需要认真设计的"新系统", 而非一个开关。
这一梳理,让我对缓存有了系统的认识。先把三者一眼区分:击穿是"一个热点 key 过期瞬间"(用互斥锁/逻辑过期)、穿透是"查不存在的数据每次打库"(用缓存空值/布隆)、雪崩是"大量 key 同时失效或缓存挂"(用随机过期/多级缓存/限流)。而除了这三个"失效"问题,还有一个更基础、更常见的问题——缓存一致性:数据库更新了,但缓存还是旧的,就会读到脏数据。最常用的更新策略是 Cache Aside(旁路缓存):更新数据库 + 删除缓存(下次读时再回填新值);为什么是"删除"缓存而不是"更新"缓存?因为删除更简单、更不容易出并发不一致;而"先更库再删缓存" vs "先删缓存再更库",都有各自的并发边界问题,常配合"延迟双删"等手段缓解。核心是:缓存和库,是"两份数据",天然就有不一致的窗口,要选合适的策略,把这个窗口和影响,控制到可接受。由此,我总结出加缓存前必须想清楚的几件事:第一,数据能容忍多久的"旧"(决定 TTL 和一致性策略)?第二,哪些是热点(要防击穿)?第三,会不会被查不存在的(要防穿透)?第四,key 的过期时间会不会扎堆(要防雪崩)?第五,缓存万一挂了,数据库扛得住吗(要兜底/限流)?归根结底:缓存,绝不只是"加上去就快了"这么简单——它需要你系统地考虑失效(击穿/穿透/雪崩)和一致性;加缓存,是引入了一个需要认真设计的"新系统",而不是拨动一个简单的开关。我那次,正是把缓存当成了一个"开关"、而非一个"系统",才在它失效时,被打了个措手不及。
第四件事:缓存设计的完整考量
这次踩坑,逼我把"设计一个缓存,到底要考虑哪些方面"系统地梳理了一遍——它远不止"读缓存、没有就查库"这么简单:
设计一个缓存, 要考虑的完整维度
# 1. 读写策略(怎么读、怎么更新):
# - Cache Aside(旁路, 最常用): 读时未命中查库回填; 写时更库 + 删缓存。
# - Read/Write Through: 缓存层代理读写(应用只跟缓存打交道)。
# - Write Behind: 先写缓存, 异步刷库(快但有丢数据风险)。
# 2. 一致性(缓存 vs 库):
# - 接受多大的不一致窗口? TTL 多长? 用什么更新策略?
# - 强一致几乎不可能(两份数据), 多数场景接受"最终一致 + 短窗口"。
# 3. 失效防护(本文重点):
# - 击穿(热点): 互斥锁 / 逻辑过期。
# - 穿透(不存在): 缓存空值 / 布隆过滤器。
# - 雪崩(扎堆/挂): 随机过期 / 多级缓存 / 限流熔断。
# 4. 容量与淘汰:
# - 缓存内存有限, 满了怎么办? 淘汰策略(LRU/LFU/TTL)。
# - 热点数据要常驻, 冷数据可淘汰。
# 5. 可用性(缓存挂了怎么办):
# - 缓存是"优化", 不该是"依赖"——缓存挂了, 系统应能降级(直连库 + 限流),
# 而不是跟着一起挂。(别让缓存成为单点故障)
# 6. 监控:
# - 命中率(低了就要查为什么)、缓存延迟、内存使用、穿透/击穿告警。
# 一句话: 缓存不是"读写两个操作", 而是"读写策略 + 一致性 + 失效防护
# + 淘汰 + 可用性 + 监控"的一整套设计。
# 核心: 缓存是一个需要系统设计的"组件", 不是一行 get/set。
# 把这些维度都想到, 缓存才能真正"既快、又稳"。
这一梳理,让我意识到,设计一个缓存,远比"读缓存、没有就查库"复杂得多,要考虑一整套维度。第一,读写策略:Cache Aside(旁路,最常用——读时未命中查库回填、写时更库+删缓存)、Read/Write Through(缓存层代理读写)、Write Behind(先写缓存、异步刷库,快但有丢数据风险)。第二,一致性:能接受多大的不一致窗口?TTL 多长?(强一致几乎不可能——毕竟是两份数据,多数场景接受"最终一致 + 短窗口")。第三,失效防护(本文重点):击穿用互斥锁/逻辑过期、穿透用缓存空值/布隆、雪崩用随机过期/多级/限流。第四,容量与淘汰:缓存内存有限,满了用什么淘汰策略(LRU/LFU/TTL)。第五,可用性:缓存是"优化",不该是"依赖"——缓存挂了,系统应能降级(直连库 + 限流),而不是跟着一起挂(别让缓存成为单点故障)。第六,监控:命中率(低了要查为什么)、缓存延迟、内存使用、穿透/击穿告警。归根结底:缓存,不是"读写两个操作",而是"读写策略 + 一致性 + 失效防护 + 淘汰 + 可用性 + 监控"的一整套设计;它是一个需要系统设计的"组件",而不是一行简单的 get/set。把这些维度都想到了,缓存,才能真正做到"既快、又稳"。我那次,正是只做了那行 get/set,而漏掉了其它所有维度。把缓存设计的维度,整理成一张表:
| 维度 | 要考虑什么 | 常见手段 |
|---|---|---|
| 读写策略 | 怎么读、怎么更新 | Cache Aside(更库+删缓存) |
| 一致性 | 能容忍多旧 | 合理 TTL + 最终一致 |
| 失效防护 | 击穿/穿透/雪崩 | 互斥/空值布隆/随机过期 |
| 淘汰 | 满了淘汰谁 | LRU/LFU/TTL |
| 可用性 | 缓存挂了怎么办 | 降级直连库+限流,别成单点 |
第五件事:缓存是"优化"不是"依赖",要为它失效时兜底
这次踩坑,在认知层面给了我最大的纠偏——它让我重新理解了"优化"和"依赖"的区别。我把这层反思,沉淀了下来:
认知纠偏: 缓存是"优化", 不是"依赖"——要为它失效时兜底
# 我的误解(错误的):
# 我加了缓存后, 默默地把系统的"正常运行", 建立在了"缓存一直命中"的
# 假设上——数据库被我当成了"反正有缓存挡着、不用太在意"的东西。
# → 我把"缓存"从一个"优化", 悄悄变成了一个"隐性的依赖"。
# 真相: 缓存是"加速优化", 系统不该"依赖"它才能活
# - 优化: 锦上添花。有它更快, 没它(失效/挂)也能正常工作(慢点而已)。
# - 依赖: 雪中送炭。没它系统就挂。
# - 缓存"应该"是优化, 但如果你的数据库, 离了缓存就扛不住,
# 那缓存就变成了"依赖"——而它一失效, 系统就崩(本文)。
# 关键区别: "缓存命中时" vs "缓存失效时", 系统都得活
# - 别只设计"命中时"的快乐路径。
# - 要问: 缓存失效/挂掉的那一刻, 系统会怎样? 数据库扛得住吗?
# - 如果答案是"会崩", 那就要兜底(限流/降级), 让它"慢"而不是"崩"。
# 更普遍的智慧: 分清"优化"和"依赖", 别让优化悄悄变成依赖
# - 优化(缓存/CDN/预计算)失效时, 系统应能"优雅降级", 而非崩溃。
# - 设计时, 要假设"优化会失效", 并保证"失效时系统仍能(慢点)工作"。
# - 否则, 你享受着优化的快, 却埋下了"优化一失效就全崩"的雷。
# 核心: 缓存是优化, 不是依赖。系统在"缓存失效时"也得活下去。
# 为优化的"失效", 设计好兜底——让它失效时是"变慢", 而不是"崩溃"。
这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:我加了缓存后,默默地,把系统的"正常运行",建立在了"缓存一直命中"这个假设上——数据库,被我当成了"反正有缓存挡着、不用太在意"的东西;我把"缓存",从一个"优化",悄悄地变成了一个"隐性的依赖"。可真相是:缓存,是"加速优化",系统不该"依赖"它才能活。"优化"和"依赖"的区别在于:优化是锦上添花——有它更快,没它(失效/挂)也能正常工作(只是慢点);依赖是雪中送炭——没它,系统就挂。缓存,本应是优化;但如果你的数据库,离了缓存就扛不住,那缓存,就变成了"依赖"——而它一失效,系统就崩(正是本文)。所以,一个关键的区别是:"缓存命中时"和"缓存失效时",系统都得活——别只设计"命中时"那条快乐路径;要主动问自己:缓存失效/挂掉的那一刻,系统会怎样?数据库扛得住吗?如果答案是"会崩",那就必须兜底(限流/降级),让它在缓存失效时,是"变慢",而不是"崩溃"。而这,是一个更普遍的智慧:分清"优化"和"依赖",别让优化悄悄变成依赖——优化(缓存/CDN/预计算)失效时,系统应能"优雅降级",而非崩溃;设计时,要假设"优化会失效",并保证"失效时,系统仍能(慢点)工作";否则,你享受着优化带来的快,却埋下了"优化一失效就全崩"的雷。归根结底:缓存是优化,不是依赖;系统在"缓存失效时",也得活下去。要为优化的"失效",设计好兜底——让它失效时,是"变慢",而不是"崩溃"。我那次的数据库被打垮,正是因为我让"缓存"这个优化,悄悄变成了我那扛不住流量的数据库的"依赖"。把"缓存当依赖"和"缓存当优化"对比成一张表:
| 维度 | 缓存当依赖(踩坑) | 缓存当优化(成熟) |
|---|---|---|
| 假设 | 缓存一直命中 | 缓存会失效/挂 |
| 只设计 | 命中时的快乐路径 | 也设计失效时的兜底 |
| 缓存失效时 | 流量裸奔砸库、崩溃 | 限流/降级,变慢不崩 |
| 数据库 | 离了缓存就扛不住 | 能(慢点)独立扛住 |
| 本质 | 优化变成了隐性依赖 | 优化就是优化 |
一套"加缓存该防什么"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"给数据加缓存时、该考虑哪些防护"的决策图,贴在了团队的架构规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:给数据加缓存,先问几个问题——是高并发热点吗(是就防击穿:互斥锁/逻辑过期)、会被查不存在的 key 吗(是就防穿透:缓存空值/布隆)、大量 key 会扎堆过期吗(是就防雪崩:过期时间加随机);再加上兜底——数据库限流/熔断、缓存挂了能降级;最后,问那个最关键的问题:缓存失效那一刻,数据库扛得住吗?扛不住就加兜底,让它"变慢而非崩溃"。这条"逐一排查击穿/穿透/雪崩、并为失效兜底"的决策链,现在是我们团队加每一个缓存时的准则。
我立下的几条缓存规矩
这次"缓存击穿打垮数据库"的踩坑,让我把缓存的注意事项,认真地立成了几条规矩:
- 热点 key 防击穿。用互斥锁(只让一个请求查库回填)或逻辑过期,别让过期瞬间流量全砸库。
- 防穿透。查不存在的数据要缓存空值、或用布隆过滤器挡掉,别每次都打库。
- 防雪崩。过期时间加随机抖动,别让大量 key 同时过期;做多级缓存。
- 数据库要有兜底。限流/熔断,缓存失效/挂了也别让库被打死。
- 缓存是优化不是依赖。缓存挂了系统要能降级(直连库+限流),别成单点、别跟着崩。
- 处理好一致性。更库 + 删缓存(Cache Aside),合理 TTL,接受短暂的最终一致。
- 加缓存前想清楚失效时怎么办。别只设计命中路径;缓存是要系统设计的组件,不是开关。
写在最后
这次"我加了缓存、却在它过期的瞬间被流量打垮数据库"的经历,是我在架构路上,一次很惊险、也很受用的成长。它教给我的,远不止"防缓存击穿"这一条具体的技术经验,更是一个关于系统设计的根本原则——缓存是"优化"、不是"依赖";要为优化失效的那一刻,设计好兜底。我那场数据库被打垮的灾难,根源就在于,我只享受了缓存"命中时"的美好,却把系统的正常运行,悄悄地建立在了"缓存永远命中"的假设上;于是,当热点缓存过期的那一瞬间,原本被它挡住的汹涌流量,毫无缓冲地、全部砸向了那个早已离不开缓存的脆弱数据库。
所以,当你给系统引入任何一个"优化"时——缓存、CDN、预计算、索引——请别只盯着它带来的"快",而要冷静地问自己一句:"当这个优化失效的那一刻,我的系统,会怎样?它还能(慢点)活下去吗?还是会跟着一起崩?"就像缓存,你只要在加它的时候,就为"它失效的瞬间"想好防护(防击穿、防穿透、防雪崩,加上数据库的限流兜底),就绝不会再经历那种"缓存一过期、数据库就被打垮"的惊魂。从"只享受优化的快"到"为优化的失效兜底",从"把优化当依赖"到"分清优化与依赖",是从一个"会加缓存"的开发,走向一个"能设计稳健系统"的架构师,必经的修炼。愿你引入的每一个优化,都既带来了速度、又守住了系统在它失效时的底线;也愿你我,永远记得为每一份"锦上添花",备好它失效时的那张"网"。共勉。
—— 别看了 · 2026