2022 年我做一个电商的商品详情接口。这个接口QPS 很高,首页、搜索、推荐,到处都在调它。第一版我直接查数据库,压测一上量,数据库 CPU 就飙红。我马上想到加缓存:查商品时,先查 Redis,Redis 有就直接返回,没有再查数据库、查完写回 Redis。这套"旁路缓存"加上去,效果立竿见影——数据库 CPU 应声降下来,接口快得飞起。我当时心里很踏实:"有缓存挡着,数据库稳了。"可上线之后,数据库还是三番五次地被打挂,每次现象还不一样。第一次:监控显示有人在疯狂查一批根本不存在的商品 ID,这些请求全部绕过了缓存、直怼数据库。第二次:某个大促爆款商品,它的缓存恰好过期的那一秒,成千上万个请求在同一瞬间涌向数据库。第三次最壮观:某天凌晨,一大批缓存几乎同时过期,数据库瞬间被压垮。我盯着这三次各不相同的事故想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"加了缓存,数据库就被保护了"。可这句话只对了一半。缓存,只在它被"命中"的时候保护数据库;而它一旦没命中——无论是因为数据压根不存在、还是 key恰好过期、还是一大批 key 集体过期——流量就会穿过这层缓存,原封不动地砸到数据库上。缓存不是一堵密不透风的墙,它是一张有可能漏的网。这三次事故,正对应着缓存最经典的三个"漏点":缓存穿透、缓存击穿、缓存雪崩。我以为加缓存就是"查之前先看一眼 Redis",结果真做下来,坑一个接一个。这篇文章就把它梳理一遍:为什么加了缓存数据库还会被打挂、缓存穿透是什么怎么防、布隆过滤器怎么用、缓存击穿怎么用互斥锁和逻辑过期挡住、缓存雪崩怎么靠随机过期化解,以及缓存一致性、缓存预热这些把缓存真正做对要避开的坑。
问题背景
先把那三次事故和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个高 QPS 的商品详情接口,加了 Redis 旁路缓存后数据库压力骤降。但上线后数据库仍被三次打挂:一次是有人狂查不存在的商品 ID,请求全部绕过缓存;一次是热点商品缓存过期的瞬间,海量请求同时压向数据库;一次是大批 key 几乎同时过期,数据库瞬间崩溃。
我当时的错误认知:"加了缓存,所有读请求就都被缓存挡住了,数据库自然就安全了。"
真相:缓存只在命中时保护数据库。一旦没命中,流量会直接穿到数据库。没命中有三种典型成因,对应三种事故:数据本就不存在(缓存穿透)、单个热点 key 过期(缓存击穿)、大量 key 同时过期(缓存雪崩)。把缓存做对,不是"加上 Redis"就完事,而是要逐一堵上这三个漏点。
要把缓存防护做对,需要几块认知:
- 为什么"加了缓存"数据库还会被打挂——缓存只在命中时有效;
- 缓存穿透:查不存在的数据,怎么用空值缓存和布隆过滤器防;
- 缓存击穿:热点 key 过期瞬间,怎么用互斥锁、逻辑过期挡住;
- 缓存雪崩:大量 key 同时失效,怎么用随机过期时间打散;
- 缓存一致性、缓存预热、降级这些工程坑怎么处理。
一、为什么"加了缓存"数据库还会被打挂
先把这件最根本的事钉死:缓存只在"命中"那条路径上保护数据库;只要请求"没命中"缓存,它就会原样落到数据库上。缓存挡住的是"重复读到的热数据",挡不住"没命中"本身。
下面这段代码,就是我那个"加了还会被打挂"的第一版——它是教科书式的旁路缓存(Cache-Aside),本身没写错:
import json
import redis
r = redis.Redis(host="localhost", port=6379)
def get_product_naive(pid: int) -> dict | None:
# 旁路缓存:先查 Redis,没有再查库,查完写回 Redis。
key = f"product:{pid}"
cached = r.get(key)
if cached is not None:
return json.loads(cached) # 缓存命中,直接返回
# 缓存没命中 —— 落到数据库
row = db_query_product(pid)
if row is not None:
r.set(key, json.dumps(row), ex=3600) # 写回缓存,存 1 小时
return row
# 这段代码没写错,但它只在"命中"时保护数据库。
# 一旦大量请求集中在"没命中"上,它们会一起砸向 db_query_product。
这段代码逻辑完全正确,在正常流量下也工作得很好——同一个热门商品被反复查,第一次落库,之后全走缓存。它的问题不在代码本身,而在一个错误的安全感:它默认"没命中"是偶尔、零星发生的小概率事件。可现实里,"没命中"会成片地、集中地爆发。我们把"没命中"拆开看,会发现它有三种截然不同的成因:
- 缓存穿透:查的数据本来就不存在。数据库里没有,缓存里永远也不会有,每次查都必然落库。
- 缓存击穿:某个热点 key 到点过期了。在它失效的那一瞬间,原本全走缓存的海量请求,同时没命中、同时落库。
- 缓存雪崩:大量 key 在几乎同一时刻过期。一大片热数据的保护同时消失,数据库被瞬间压垮。
这三种,是三个不同的洞,得分别来堵。先看第一个,也是最容易被恶意利用的——缓存穿透。
二、缓存穿透:查一个永远不存在的东西
缓存穿透,指的是请求一个数据库里根本不存在的数据。它的致命之处在于:对于一个不存在的 ID,db_query_product 返回 None,于是上面那段代码不会写回缓存(因为 row is None)。结果——下一次查同一个不存在的 ID,还是没命中,还是落库。这个 ID永远命中不了缓存。如果有人恶意拿着一堆随机的、不存在的 ID 疯狂请求,等于缓存形同虚设,每一发都直接命中数据库。
解法的第一招,朴素而有效:既然查不到,那就把"查不到"这件事本身也缓存起来。给不存在的 ID,在缓存里存一个特殊的空值标记。
NULL_FLAG = "__NULL__" # 特殊标记:代表"这个 ID 查过了,不存在"
def get_with_null_cache(pid: int) -> dict | None:
"""防穿透:把"查无此数据"也缓存起来,挡住对不存在 ID 的重复查询。"""
key = f"product:{pid}"
cached = r.get(key)
if cached is not None:
if cached == NULL_FLAG.encode():
return None # 命中空值标记:直接返回,不落库
return json.loads(cached)
row = db_query_product(pid)
if row is None:
# 关键:数据不存在,也往缓存里写一个空值标记,
# 但 TTL 设短一些(比如 60 秒)—— 万一这个 ID 之后真有了数据。
r.set(key, NULL_FLAG, ex=60)
return None
r.set(key, json.dumps(row), ex=3600)
return row
空值缓存把"不存在"也变成了一次命中,挡住了对同一个不存在 ID 的重复查询。但它有个软肋:如果攻击者每次用的都是全新的、不重样的随机 ID,那每个新 ID 第一次仍会落库一次,而且会在 Redis 里堆出海量的空值 key。要从根上堵住"大量不重样的不存在 ID",需要一件更专门的武器。
三、布隆过滤器:在查库之前就拦下不存在的 ID
对付"海量不重样的不存在 ID",需要一个能在查库之前就快速回答"这个 ID 有没有可能存在"的东西。这就是布隆过滤器(Bloom Filter)。
它的特点要记准:布隆过滤器说一个 ID "不存在",那就一定不存在;它说"可能存在",那就可能存在(有极小概率误判)。换句话说,它没有漏报、只有极少误报。这个特性正好够用:我们把所有真实存在的商品 ID 预先灌进布隆过滤器;请求来时先问它——它说"不存在",就百分百能放心地直接拒绝,连缓存和数据库都不用碰。
import hashlib
class BloomFilter:
"""极简布隆过滤器:用一个大 bit 数组 + 多个哈希函数。"""
def __init__(self, size: int = 1 << 20, hash_count: int = 4):
self.size = size
self.hash_count = hash_count
self.bits = bytearray(size // 8 + 1)
def _hashes(self, value: str):
for i in range(self.hash_count):
h = hashlib.md5(f"{value}:{i}".encode()).hexdigest()
yield int(h, 16) % self.size # 每个哈希函数定位一个 bit
def add(self, value: str):
for pos in self._hashes(value):
self.bits[pos // 8] |= (1 << (pos % 8)) # 把对应 bit 置 1
def might_contain(self, value: str) -> bool:
# 只要有一个 bit 是 0,就【一定】不存在;全是 1 才【可能】存在
return all(self.bits[pos // 8] & (1 << (pos % 8))
for pos in self._hashes(value))
把布隆过滤器架在最前面,商品查询的第一道关就成了"问布隆":
# 服务启动时,把数据库里所有真实存在的商品 ID 灌进布隆过滤器
product_bloom = BloomFilter()
for pid in db_all_product_ids():
product_bloom.add(str(pid))
def get_with_bloom(pid: int) -> dict | None:
"""防穿透终极版:查缓存查库之前,先问布隆过滤器。"""
if not product_bloom.might_contain(str(pid)):
# 布隆说"不存在" —— 那就一定不存在,直接拒绝,根本不碰 db
return None
# 布隆说"可能存在",才走正常的缓存 + 数据库流程
return get_with_null_cache(pid)
布隆过滤器把"不存在的 ID"挡在了数据库门外。要记得:商品新增时,要同步把新 ID add 进布隆过滤器,否则新商品会被误判成不存在。穿透这个洞堵上了,接下来看第二个——比穿透更突然的击穿。
四、缓存击穿:热点 key 过期的那一瞬间
缓存击穿,和穿透完全是两回事。击穿的数据是真实存在的,而且通常还是个超级热点(比如大促爆款)——平时它稳稳地待在缓存里,海量请求全靠它命中。问题出在它过期的那一刹那:在缓存被删掉、新值还没写回的那个极短的时间窗里,本来全走缓存的成千上万个并发请求,会同时没命中、同时冲进数据库,去查同一行数据。数据库被这同一瞬间的尖峰瞬间打爆。
解法的核心思路是:既然这成千上万个请求要查的是同一行数据,那就没必要让它们都去查。只放一个请求去查库重建缓存,其余的稍等一下、等那一个把缓存建好,直接用就行。实现这个"只放一个"的,是一把互斥锁:
import time
def get_with_lock(pid: int) -> dict | None:
"""防击穿:热点 key 失效时,只放一个请求去重建缓存。"""
key = f"product:{pid}"
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# 没命中 —— 抢锁,只有抢到锁的那一个请求能去查库
lock_key = f"lock:{key}"
got_lock = r.set(lock_key, "1", nx=True, ex=10) # SET NX 抢锁
if not got_lock:
# 没抢到锁:说明已有别人在重建缓存,稍等一下再读缓存
time.sleep(0.05)
return get_with_lock(pid) # 重试:这次大概率能命中了
try:
row = db_query_product(pid) # 抢到锁的,才查库
if row is not None:
r.set(key, json.dumps(row), ex=3600)
return row
finally:
r.delete(lock_key) # 重建完,务必释放锁
互斥锁把"万箭齐发"收敛成了"一个请求查库、其余等结果"。它的代价是:没抢到锁的请求会多等那么几十毫秒。如果连这点延迟都不能忍,还有另一种思路——逻辑过期:缓存永不真正过期,只在 value 里存一个"逻辑过期时间";请求读到一个逻辑上已过期的值,照样先把这个旧值返回(不阻塞),同时另起一个后台任务去悄悄重建。
def get_with_logical_expire(pid: int) -> dict | None:
"""防击穿之逻辑过期:key 不设真实 TTL,过期了也先返回旧值,
后台异步重建 —— 谁都不用等。"""
key = f"product:{pid}"
cached = r.get(key)
if cached is None:
return None # 约定:热点数据已提前预热进缓存
data = json.loads(cached)
if data["expire_at"] > time.time():
return data["value"] # 逻辑上没过期,直接用
# 逻辑上过期了:先把旧值返回(不阻塞用户),再触发后台重建
if r.set(f"lock:{key}", "1", nx=True, ex=10):
submit_async(rebuild_cache, pid) # 异步重建,不阻塞当前请求
return data["value"] # 当前请求拿的是"略旧"的数据
互斥锁和逻辑过期,是两种取舍:互斥锁保证数据新,代价是少数请求要等;逻辑过期保证不阻塞,代价是短时间内会读到旧值。按你的业务对"新"和"快"哪个更敏感来选。击穿是单个热点 key 的事;如果一大批 key 同时出事,那就是更大的灾难——雪崩。
五、缓存雪崩:大量 key 在同一时刻集体失效
缓存雪崩,是击穿的"群体版"。它的成因往往很不起眼:你在某次批量预热缓存时,把一大批商品在同一个循环里写进了 Redis,而且统一设了 ex=3600。于是这批 key,会在一小时后的同一秒,集体过期。那一秒,数据库要同时承受这一大批数据的重建请求,瞬间被压垮——这就是雪崩。
雪崩的根因,是 key 的过期时间太"整齐"了。解法也就顺理成章:给过期时间加一个随机扰动,把"集体过期"打散成"分散过期"。
import random
def set_with_jitter(key: str, value, base_ttl: int = 3600):
"""防雪崩:给 TTL 加一个随机扰动,避免大量 key 同时过期。"""
# 在 base_ttl 基础上,随机上浮 0 ~ 600 秒。
# 这样原本会"同一秒集体过期"的 key,被打散到 10 分钟的区间里。
ttl = base_ttl + random.randint(0, 600)
r.set(key, json.dumps(value), ex=ttl)
def warm_up_products(pids: list[int]):
"""缓存预热:服务上线前,把热点商品提前灌进缓存。
注意每个 key 的 TTL 都带独立随机扰动,不会同时过期。"""
for pid in pids:
row = db_query_product(pid)
if row is not None:
set_with_jitter(f"product:{pid}", row) # 每个 TTL 都不同
一行 random.randint,就把"一根尖峰"摊平成了"一段平缓的小坡"。除了随机 TTL,雪崩的防护通常还叠加两道保险:一是给数据库访问加限流和熔断——万一真有大批请求漏下来,也只放一部分进数据库,其余快速失败,绝不让数据库被彻底压垮;二是多级缓存,在 Redis 之外再加一层本地缓存,Redis 整个挂掉时还有本地缓存兜一下。三个洞——穿透、击穿、雪崩——到这里都堵上了。但缓存还有一类更安静的坑,不出事故,却会让你的数据悄悄出错。
六、工程坑:缓存一致性、预热与降级
三个"被打挂"的洞堵上了,但缓存还有几个不出事故、却会让数据出错的工程坑。坑 1:数据更新时,缓存和数据库的一致性。当一个商品被修改,数据库和缓存两份数据就要同步。一个常见的错误是"先删缓存,再更新数据库":在你删了缓存、还没更新完数据库的空档里,另一个读请求进来,没命中缓存,从数据库读到了旧值,又把这个旧值写回了缓存——于是缓存里长期是错的。更稳妥的顺序是先更新数据库,再删除缓存:
def update_product(pid: int, new_data: dict):
"""更新商品:先改数据库,再删缓存(不是改缓存,是删)。"""
db_update_product(pid, new_data) # 1. 先把数据库改掉
# 2. 再删缓存 —— 注意是【删除】不是【更新】。
# 删掉它,下一次读自然会从新的数据库重建,简单又不易错。
r.delete(f"product:{pid}")
# 为什么是"删"不是"改":若两个更新并发,各自改缓存可能
# 把缓存改成"后改的数据库 + 先改的缓存"这种错乱组合。
# 而"删除"没有这个问题 —— 下一次读以数据库为准重建。
这里有两个要点。一是删缓存,而不是更新缓存:并发更新下,两个请求各自去改缓存,可能改出错乱的组合;而删是幂等的,删掉后下一次读以数据库为准重建,简单可靠。二是顺序先库后缓存。即便如此,理论上仍有极小的不一致窗口,要求强一致的话还需更复杂的方案(如订阅数据库 binlog 来删缓存),但对绝大多数业务,"先更新数据库、再删缓存"已经足够。
坑 2:缓存预热不能漏。服务刚上线或 Redis刚重启时,缓存是空的。如果这时直接放全量流量进来,所有请求第一次全部没命中、全部落库——这本身就是一场人造的雪崩。所以上线前要用第五节的 warm_up_products 把热点数据预先灌好。坑 3:Redis 挂了要能降级。缓存是性能优化,不该是系统命脉。如果你的代码在 Redis 连不上时直接抛异常,那 Redis 一挂、整个服务就跟着挂——缓存反而成了新的单点故障。正确的做法是降级:Redis 不可用时,捕获异常、跳过缓存、直接查数据库(同时配合限流,别把数据库压垮),让服务带病也能转。坑 4:缓存的 key 要有规范。product:123 这样带业务前缀的命名,能避免不同业务的 key 互相撞车,排查问题时也一眼能认。下面这张图,把一次带完整防护的缓存查询串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 缓存只在命中时有效 | 没命中的请求会原样落到数据库,缓存挡不住没命中本身 |
| 缓存穿透 | 查数据库里本就不存在的数据,缓存永远不命中,每次都落库 |
| 空值缓存 | 把查无此数据也缓存成空标记,TTL 设短,挡住对同一不存在 ID 的重复查 |
| 布隆过滤器 | 说不存在就一定不存在,在查缓存查库前拦下海量不重样的不存在 ID |
| 缓存击穿 | 单个热点 key 过期瞬间,海量请求同时没命中、同时落库查同一行 |
| 互斥锁重建 | 热点 key 失效时只放一个请求查库重建,其余请求稍等后读缓存 |
| 逻辑过期 | key 不设真实 TTL,过期也先返回旧值,后台异步重建,谁都不用等 |
| 缓存雪崩 | 大量 key 在同一时刻集体过期,数据库瞬间承受大批重建被压垮 |
| 随机过期时间 | 给 TTL 加随机扰动,把集体过期打散成分散过期,削平尖峰 |
| 先更库再删缓存 | 更新时先改数据库再删缓存,删而不是改,下次读以数据库为准重建 |
避坑清单
- 别以为加了缓存数据库就安全,缓存只在命中时有效,没命中的请求会原样砸到数据库。
- 缓存穿透是查不存在的数据,这种 ID 永远不会被写回缓存,每次都落库,容易被恶意刷。
- 防穿透先用空值缓存,把查无此数据也缓存成空标记,但 TTL 要设短防止数据后来真有了。
- 对付海量不重样的不存在 ID 要用布隆过滤器,它说不存在就一定不存在,可在查库前拦下。
- 商品新增时要同步把新 ID 加进布隆过滤器,否则新商品会被误判成不存在而查不到。
- 缓存击穿是单个热点 key 过期瞬间海量请求同时落库,要用互斥锁只放一个请求去重建。
- 不能忍受互斥锁那点等待时,可用逻辑过期,过期也先返回旧值后台异步重建,代价是数据略旧。
- 缓存雪崩是大量 key 同时过期,根因是 TTL 太整齐,要给过期时间加随机扰动打散。
- 更新数据时要先更新数据库再删缓存,且是删不是改,并发改缓存可能改出错乱组合。
- 服务上线前要预热热点缓存,Redis 挂了要能降级直查数据库,别让缓存变成新的单点故障。
总结
回头看那三次"加了缓存、数据库还是被打挂"的事故,以及我后来在缓存防护上接连踩的坑,最该记住的不是某一段加锁代码,而是我动手前那个想当然的判断——"加了缓存,数据库就被保护了"。这句话错在它把缓存,想象成了一堵密不透风的墙:似乎只要墙立起来,所有流量就都被挡在墙外了。可缓存根本不是一堵墙,它是一张网——一张大部分时候很管用、但天然就带着洞的网。它能牢牢兜住那些被反复读到的热数据,但它兜不住三种东西:它兜不住本来就不存在的数据(穿透),因为不存在的东西从来就没进过这张网;它兜不住热点 key 过期的那一刻(击穿),因为网上那一个最关键的结恰好断开了;它兜不住大批 key 同时过期(雪崩),因为半张网在同一瞬间整片消失了。缓存防护这件事想清楚的,正是这个——你不能只满足于"把网架起来",你得盯着这张网会从哪儿漏,然后一个洞一个洞地去补。
所以做缓存,真正的工程量不在"查之前先看一眼 Redis"那个一目了然的主干上。那个旁路缓存的主干,十几行代码就写完了,而且怎么测都对。真正的工程量,在于你有没有把那三个洞都认出来、并分别堵上:面对"查不存在的数据",你有空值缓存和布隆过滤器吗?面对"热点 key 失效的瞬间",你有互斥锁或逻辑过期吗?面对"大批 key 集体过期",你的 TTL 带随机扰动吗?——而且这三个洞,形态完全不同:穿透是"数据不存在"的问题,击穿是"时间点集中"的问题,雪崩是"数量规模"的问题。它们名字相近、却不能用同一招对付。这篇文章的几节,其实就是顺着这三个洞展开的:先想清楚缓存为什么会漏,再逐一看穿透、击穿、雪崩各自怎么堵,最后是一致性、预热、降级这几个把缓存真正做对的工程细节。
你会发现,缓存防护的思路,和现实里给一个热门窗口分流人潮完全相通。一个景区的热门售票窗口,平时排队的人就是被"缓存"住的常规客流。可它也会遇到三种麻烦:有人拿着假票、根本不该来这个窗口反复纠缠(这是穿透,得在入口先验票拦下);窗口的售票员临时离岗那几分钟,憋了一长队人同时涌上来(这是击穿,得留一个人先顶上、别让队伍炸开);更糟的是所有窗口恰好同时换班(这是雪崩,所以换班时间得错开)。应对的智慧是相通的:不该进来的,在最外层就拦掉;关键节点失效时,别让所有人一拥而上;别让大批保护在同一刻一起消失。缓存的穿透、击穿、雪崩三道防线,本质上就是把这套朴素的分流智慧,翻译成了代码。
最后想说,缓存做没做扎实,差距永远不会在开发期暴露——开发时你点几下页面,缓存命中率高得很,有没有防穿透、TTL 有没有加随机,功能跑起来一模一样。它只在真实的、有恶意流量、有大促热点、有海量并发的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我一样,在某个深夜被告警叫醒——数据库 CPU 100%,接口大面积超时,你登上去一查,要么是有人拿着不存在的 ID 在疯狂刷你,要么是一个爆款的缓存过期了把库击穿,要么是一整批缓存同时到期掀起雪崩。而做对了,它会安安静静地、不被任何人察觉地,把恶意的不存在请求挡在布隆过滤器外,把热点过期的瞬间收敛成一个请求重建,把本会集体过期的 key 均匀打散到一段时间里——数据库那头的负载稳稳的、纹丝不动,大促流量再汹涌,它也波澜不惊。所以别等数据库被打挂的告警找上门,在你写下第一行"先查一下缓存"的代码时就该想清楚:有人查一个不存在的东西,我的缓存挡得住吗?一个热点缓存过期的那一瞬,会发生什么?我的一批缓存,会在同一秒一起过期吗?这几个问题都有了答案,你的缓存才不只是开发库里那个让接口变快的小优化,而是一道无论流量怎么冲、怎么刷,都能稳稳替数据库扛住的可靠防线。
—— 别看了 · 2026