2020 年我维护一个商品详情服务。最初每个请求都直接查数据库,流量一大,数据库就开始吃力。我给它加了一层 Redis 缓存:查询时先看缓存,命中就直接返回,没命中再查数据库、把结果写回缓存。上线后效果立竿见影——缓存命中率稳定在 95% 以上,数据库负载降到原来的零头。我当时的想法很简单:加了缓存,数据库就高枕无忧了。可后来接连出过三次事故,每一次数据库都被打挂,而且打挂的方式一次比一次让我意外。第一次,有人写脚本,拿一堆根本不存在的商品 ID 狂刷接口。这些 ID 数据库里查不到,缓存里自然也永远不会有——每个这样的请求,都"穿过"缓存直达数据库。缓存命中率看着还是 95%,可那 5% 没命中的流量全是这种请求,数据库被它们活活压垮。第二次,一个爆款商品,它的缓存 key 到点过期了。就在过期的那一瞬间,几千个并发请求同时发现缓存没了,于是几千个请求"一拥而上"全去查数据库、全去重建这同一个 key——数据库被这一瞬的并发尖峰打挂。第三次,我图省事,给一大批缓存 key 设了完全相同的过期时间。结果某次集中预热的那批缓存,在几乎同一秒集体失效——海量请求在同一刻全部穿透到数据库,数据库瞬间被冲垮。三次事故,我才彻底想明白:加了缓存,数据库并不会自动安全。缓存只保护"命中"的那部分流量;没命中的流量,会原原本本地砸到数据库上。更要命的是,缓存自己的失效行为——key 不存在、key 过期、key 集体过期——如果设计不当,反而会制造出比没有缓存时更猛烈、更集中的瞬时冲击。这三种情况,各有专门的名字:缓存穿透、缓存击穿、缓存雪崩。我以为缓存不过是"查之前先看一眼 Redis",结果真做下来坑一个接一个。那三次之后,我才认真把缓存这三大问题从头搞明白。这篇文章就把它梳理一遍:为什么加了缓存数据库还会被打挂、穿透/击穿/雪崩各是怎么回事、缓存空值和布隆过滤器怎么挡穿透、互斥锁怎么防击穿、TTL 随机扰动怎么防雪崩,以及缓存一致性、预热、命中率监控这些把缓存真正做稳要避开的坑。
问题背景
先把那三次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个商品详情服务,前面加了 Redis 缓存,命中率 95% 以上,平时数据库负载很低。但接连三次数据库被打挂:其一,大量查询不存在的商品 ID,这些请求全部穿透缓存直达数据库;其二,一个热点 key 过期的瞬间,海量并发同时去查库、重建同一个 key;其三,一大批 key 设了相同的过期时间,在同一刻集体失效,海量请求同时穿透。
我当时的错误认知:"加了缓存,命中率又高,数据库就安全了,不用再操心。"
真相:缓存只保护"命中"的那部分流量;没命中的流量,会原样砸到数据库上。而"什么样的请求会不命中",并不是固定的——查询不存在的数据、热点 key 恰好过期、大批 key 同时过期,都会让"不命中"的流量异常地多、异常地集中。更关键的是,缓存的失效行为本身,设计不当会制造出集中的瞬时冲击。正确的做法是:针对穿透、击穿、雪崩这三种失效场景,分别加上对应的防护。
要把缓存做稳,需要几块认知:
- 为什么加了缓存,数据库还会被打挂,缓存到底保护了什么;
- 缓存穿透是什么,缓存空值和布隆过滤器怎么挡住它;
- 缓存击穿是什么,为什么要用互斥锁让热点 key 只被重建一次;
- 缓存雪崩是什么,TTL 随机扰动和降级怎么应对;
- 缓存与数据库一致性、缓存预热、命中率监控这些工程坑怎么处理。
一、为什么加了缓存,数据库还会被打挂
先把这件最根本的事钉死:缓存只保护"命中"的流量,挡不住"不命中"的流量。
我们用的是最标准的缓存模式——缓存旁路(cache-aside):读数据时,先查缓存,命中就返回;没命中,再查数据库,并把结果写回缓存,下次就能命中了。
import redis
r = redis.Redis()
def get_product(product_id: int):
"""缓存旁路(cache-aside):先查缓存,没有再查库并回写。"""
key = f"product:{product_id}"
cached = r.get(key)
if cached is not None:
return cached # 缓存命中,直接返回
# 没命中,查数据库
product = db_query_product(product_id)
if product is not None:
r.setex(key, 3600, product) # 回写缓存,设 1 小时过期
return product
这段代码本身没有错,它就是最标准的 cache-aside。问题在于它隐含了一个假设:"不命中是少数"。命中率 95% 时,只有 5% 的流量会落到 db_query_product 上——数据库轻松扛得住。可这个"5%"不是一个固定的常量,它取决于"什么样的请求会不命中"。如果不命中的,都是正常用户对冷门商品的零星查询,那没事;可一旦不命中的流量变成"大量、集中"的,数据库瞬间就会被这部分流量压垮。
缓存的三大问题——穿透、击穿、雪崩,本质上是同一件事的三种形态:它们都让"不命中"的流量,异常地多、异常地集中。下面三节,就分别看这三种情况,以及怎么挡住它们。
二、缓存穿透:查询永远不存在的数据
缓存穿透,指的是查询一个数据库里压根不存在的数据。
回头看上一节的代码:查一个不存在的 ID,db_query_product 返回 None,于是 if product is not None 为假,不回写缓存。结果下一次同样的查询,缓存里还是没有,还是要查库。这个 key 的每一次查询,都会穿透缓存。正常用户不会反复查不存在的 ID,但攻击者会——拿一堆随机 ID 狂刷接口,每个都穿透,缓存形同虚设。
第一种解法,叫缓存空值:数据库查不到时,也往缓存里写一条记录,只不过写的是一个特殊的"空"标记,并且给它设一个较短的过期时间。这样,下次再查同一个不存在的 ID,就会命中这个空标记,直接返回,不再查库。
NULL_SENTINEL = "__NULL__"
def get_product_pen(product_id: int):
"""缓存穿透防护:数据库查不到的 key,也缓存一个空标记。"""
key = f"product:{product_id}"
cached = r.get(key)
if cached == NULL_SENTINEL:
return None # 命中"空标记":确定这个 id 不存在
if cached is not None:
return cached # 命中真实数据
product = db_query_product(product_id)
if product is None:
# 关键:查不到也写缓存,用空标记 + 较短过期时间
r.setex(key, 60, NULL_SENTINEL)
return None
r.setex(key, 3600, product)
return product
空标记的过期时间要设短(这里用 60 秒),原因是:万一这个 ID 后来真的有了数据,你不希望那个过期的空标记一直挡着它。缓存空值能挡住"反复查同一批不存在 ID"的情况。但它有个明显的弱点——下一节就来补上。
三、布隆过滤器:把不存在的 key 挡在缓存之外
缓存空值的弱点是:如果攻击者每次都用不同的随机 ID,那么每一个不存在的 ID,都会在缓存里占掉一个空标记。攻击者刷上几百万个不同的随机 ID,你的缓存就被几百万条毫无用处的空标记塞满了。
更彻底的办法是布隆过滤器(Bloom Filter)。它是一个极省内存的数据结构,能回答一个问题:"这个元素可能存在吗?"它有一个关键特性:说"不存在"时一定准;说"可能存在"时,有小概率误判(其实不存在)。把所有真实存在的商品 ID 预先灌进布隆过滤器,查询时先问它一句——它说"不存在",就直接返回,缓存和数据库都不用碰。
class BloomFilter:
"""布隆过滤器:用位数组 + 多个哈希,极省内存地判断元素是否可能存在。"""
def __init__(self, size: int = 1 << 20, hash_count: int = 5):
self._size = size
self._hash_count = hash_count
self._bits = bytearray(size // 8 + 1)
def _positions(self, item: str):
# 用多个哈希,把一个元素映射到位数组的多个位置
for i in range(self._hash_count):
yield hash(f"{item}:{i}") % self._size
def add(self, item: str):
for pos in self._positions(item):
self._bits[pos // 8] |= (1 << (pos % 8))
def might_exist(self, item: str) -> bool:
# 任意一位是 0,就【一定】不存在;全是 1,才【可能】存在
return all(self._bits[pos // 8] & (1 << (pos % 8))
for pos in self._positions(item))
用法是:服务启动时,把数据库里所有真实存在的商品 ID 一次性灌进布隆过滤器;之后每个查询请求,先过一道布隆过滤器这关。
# 服务启动时,把所有真实存在的商品 id 灌进布隆过滤器
_bloom = BloomFilter()
for pid in db_all_product_ids():
_bloom.add(str(pid))
def get_product_bloom(product_id: int):
"""先用布隆过滤器挡掉一定不存在的 id,再走缓存逻辑。"""
if not _bloom.might_exist(str(product_id)):
return None # 布隆说"一定不存在" —— 直接返回,不碰缓存和库
# 布隆说"可能存在",再走缓存空值那套逻辑
return get_product_pen(product_id)
布隆过滤器为什么能省内存:它不存元素本身,只在一个位数组上标记几个位。一百万个 ID,可能只占一两兆内存。它"说不存在一定准"的特性,恰好就是用来挡穿透的——那些随机 ID 攻击,绝大多数会被布隆过滤器在第一道关就拦下,根本到不了缓存和数据库。代价是它有小概率误判(把不存在的说成"可能存在"),但这没关系——误判的那一小撮,后面还有缓存空值兜底。
四、缓存击穿:热点 key 失效的瞬间
缓存击穿,和穿透是两回事。穿透的 key 是不存在的;击穿的 key 是真实存在的,只是它刚好过期了。
设想一个爆款商品,每秒有几千个请求查它。它的缓存 key 平时一直命中,岁月静好。可这个 key 总有过期的一刻。就在它过期、到被某个请求重新写回缓存之间,有一个极短的时间窗——在这个窗口里,所有打到这个 key 的请求全部不命中,于是几千个请求同时涌去查数据库、同时去重建同一个 key。数据库被这一瞬间凭空冒出来的并发尖峰打垮。
问题的核心是:重建这个 key,本来只需要一个请求去做一次,结果几千个请求重复地做了几千次。解法就是让"重建缓存"这个动作变成互斥的:同一时刻,只允许一个请求去查库重建,其他请求等一下。用 Redis 的 SET ... NX 可以实现一把简单的分布式锁。
import time
def get_product_lock(product_id: int):
"""缓存击穿防护:热点 key 过期后,只让一个请求去重建。"""
key = f"product:{product_id}"
cached = r.get(key)
if cached is not None:
return cached
lock_key = f"lock:{key}"
# SET NX:只有第一个请求能成功设置这把锁,拿到重建权
got_lock = r.set(lock_key, "1", nx=True, ex=10)
if got_lock:
try:
product = db_query_product(product_id) # 只有它查库
if product is not None:
r.setex(key, 3600, product)
return product
finally:
r.delete(lock_key) # 重建完成,释放锁
else:
# 没抢到锁:别去挤数据库,稍等一下,等持锁的请求把缓存重建好
time.sleep(0.05)
return get_product_lock(product_id)
这段代码的关键,在 r.set(..., nx=True):NX 的意思是"只有这个 key 不存在时才设置成功"。所以并发的几千个请求里,只有第一个能 set 成功、拿到锁,它去查库重建;其余请求 set 失败,它们不会一起冲进数据库,而是 sleep 一小会儿再重试——等持锁的那个请求把缓存重建好,它们重试时就直接命中了。给锁设一个过期时间(ex=10)也很重要:万一持锁的请求崩了,锁能自动释放,不会把所有人永远卡死。
五、缓存雪崩:大量 key 在同一刻集体失效
缓存雪崩,是击穿的"放大版"。击穿是一个热点 key 过期;雪崩是大量 key 在几乎同一时刻一起过期。
它最常见的成因有两个。其一,就是我犯的错——给一大批 key 设了完全相同的过期时间。比如服务启动时集中预热了十万个 key,TTL 都设成 1 小时,那么 1 小时后,这十万个 key 会在同一秒集体失效,海量请求在同一刻全部穿透到数据库。其二,是 Redis 实例整个宕机,所有 key 一瞬间全没了。
针对第一种成因,解法简单却关键:别给 key 设固定的过期时间,在基准 TTL 上加一个随机扰动,把集体失效的时刻打散到一个时间区间里。
import random
def set_with_jitter(key: str, value, base_ttl: int = 3600):
"""给过期时间加随机扰动,避免大批 key 在同一刻集体失效。"""
# 在基准 TTL 上下浮动,把过期时刻打散到一个区间里
jitter = random.randint(-base_ttl // 5, base_ttl // 5)
r.setex(key, base_ttl + jitter, value)
# 这样十万个 key 的过期时刻,会均匀散布在 48 到 72 分钟之间,
# 而不是【全部挤在第 60 分钟那一秒】。
针对第二种成因(Redis 整个挂掉),加随机扰动就没用了——key 不是"过期",是整层缓存消失。这时唯一能救数据库的是降级:服务要能识别出"缓存层不可用"这个状态,然后主动给数据库限流,绝不能让本该被缓存挡住的全部流量,原样砸向数据库。
def get_product_safe(product_id: int):
"""缓存层整体故障时的兜底:降级保护数据库,不让它被全量流量打挂。"""
try:
cached = r.get(f"product:{product_id}")
if cached is not None:
return cached
except redis.ConnectionError:
# Redis 挂了:此刻所有请求都会涌向数据库 —— 必须限流兜底
if not _db_limiter.allow():
raise ServiceBusy("系统繁忙,请稍后再试")
return db_query_product(product_id)
# Redis 正常、只是没命中,正常查库回写
product = db_query_product(product_id)
if product is not None:
set_with_jitter(f"product:{product_id}", product)
return product
这里 _db_limiter 是一个给数据库兜底的限流器。它的意义是:Redis 挂掉的那一刻,缓存能挡住的 95% 流量会瞬间全部转向数据库。如果不管,数据库必然被冲垮——然后是一场连数据库一起赔进去的真雪崩。有了这道限流,数据库最多只承受它扛得住的那部分流量,超出的请求快速失败。牺牲一部分,保住整体。
六、工程坑:一致性、预热与命中率监控
三大问题的防护都有了,但要把缓存真正做稳,还有几个绕不开的工程坑。
坑 1:缓存和数据库的数据一致性。数据被更新了,缓存里还是旧值,怎么办?常见且稳妥的做法是"先更新数据库,再删除缓存"——注意是删除,不是更新缓存。删除之后,下一次读这个 key 自然不命中,会从数据库回填最新值。
def update_product(product_id: int, new_data):
"""更新数据:先写库,再删缓存(而不是改缓存)。"""
db_update_product(product_id, new_data) # 1. 先更新数据库
r.delete(f"product:{product_id}") # 2. 再删除缓存
# 下次有人读这个 id,缓存没命中,会自然从库里回填最新值。
# 用"删除"而不是"更新缓存":一是省去重新拼缓存值的逻辑,
# 二是避免并发更新时,两个请求把缓存写成互相覆盖的旧值。
坑 2:缓存预热。服务刚启动、或缓存刚被清空时,缓存是空的。如果这时直接把全量流量放进来,所有请求都不命中、全部穿透到数据库——这本身就是一场自己制造的小雪崩。所以上线、扩容前要预热:用脚本提前把热点数据(销量最高的那批商品)加载进缓存,等缓存"热"了,再放流量进来。
坑 3:监控缓存命中率。命中率是缓存健康度最核心的指标。命中率突然下跌,几乎一定意味着穿透或雪崩正在发生——它是这些事故最早的信号。一定要把命中率接入监控,设好告警,别等数据库都冒烟了才从日志里慢慢找原因。下面这张图,把一个查询请求穿过三道防护的完整路径串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 缓存旁路 cache-aside | 读时先查缓存,没命中再查库并回写;缓存只保护命中的流量 |
| 缓存穿透 | 查询数据库里根本不存在的数据,每次都穿过缓存直达数据库 |
| 缓存空值 | 查不到的 key 也缓存一个空标记并设短过期,挡住反复查同一批不存在 id |
| 布隆过滤器 | 极省内存判断元素是否可能存在,说"不存在"一定准,把随机 id 攻击挡在最外层 |
| 缓存击穿 | 真实存在的热点 key 过期瞬间,海量并发同时去查库重建同一个 key |
| 互斥锁重建 | 用 SET NX 让热点 key 同一时刻只被一个请求重建,其余请求等待 |
| 缓存雪崩 | 大量 key 在同一刻集体失效,或 Redis 整体宕机,海量流量同时压向数据库 |
| TTL 随机扰动 | 给过期时间加随机偏移,把集体失效时刻打散到一个区间,防雪崩 |
| 缓存降级 | 缓存层整体故障时识别状态并给数据库限流,牺牲部分请求保住整体 |
| 先更库后删缓存 | 更新数据时先写库再删缓存,下次读自然回填最新值,避免写回旧值 |
避坑清单
- 加了缓存数据库并不会自动安全:缓存只保护命中的流量,不命中的流量会原样砸到数据库上。
- 穿透、击穿、雪崩本质是同一件事的三种形态——都让"不命中"的流量异常地多、异常地集中。
- 缓存穿透是查不存在的数据,查不到不回写缓存导致每次都穿透;用缓存空值挡住反复查同一批 id。
- 空标记要设较短过期时间,否则这个 id 后来真有了数据,过期的空标记会一直把它挡在外面。
- 攻击者用海量不同随机 id 时缓存空值会被垃圾塞满,要用布隆过滤器在最外层挡掉一定不存在的 id。
- 缓存击穿是真实存在的热点 key 过期瞬间海量并发重建同一个 key;用 SET NX 互斥锁只让一个去重建。
- 互斥锁必须设过期时间,否则持锁请求崩溃后锁不释放,会把所有请求永久卡死。
- 缓存雪崩常因大批 key 设了相同 TTL 集体失效;给过期时间加随机扰动把失效时刻打散开。
- Redis 整体宕机时随机扰动无用,要靠降级:识别缓存层故障并给数据库限流,牺牲部分保住整体。
- 更新数据先写库再删缓存;上线扩容前预热热点数据;把缓存命中率接入监控,下跌即告警。
总结
回头看那三次"加了缓存数据库反而被打挂"的事故,最该记住的不是某一段防护代码,而是我加缓存时那个想当然的判断——"加了缓存,数据库就高枕无忧了"。这句话错在它把缓存理解成了一道挡在数据库前面的、密不透风的墙。可缓存根本不是墙,它更像一张网:命中的流量被它接住,不命中的流量直接漏过去。平时这张网很密,漏过去的只有 5%,数据库毫无压力,于是你产生了"有堵墙"的错觉。穿透、击穿、雪崩做的事,就是用三种不同的方式,在这张网上捅出一个大窟窿——让本该被接住的流量,哗啦一下全漏到数据库上。
所以做缓存,真正的工程量不在"查之前先看一眼 Redis"那一下。get 一下、没有就 set 回去,这部分 Demo 里谁都能写。真正的工程量,在于你有没有想清楚那些会让缓存失效的场景:有人专门查不存在的数据,你的缓存接得住吗?一个热点 key 过期的那一瞬间,会不会有几千个请求一起冲进数据库?你给一批 key 设的过期时间,会不会让它们在同一秒集体"自杀"?缓存层整个挂掉的那一刻,你的数据库是有兜底,还是赤裸裸地承受全部流量?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚缓存到底保护了什么,再分别看穿透、击穿、雪崩三种"捅窟窿"的方式和对应的补法,最后是一致性、预热、监控这几个把缓存真正做稳的工程细节。
你会发现,缓存这三大问题的思路,和我们处理任何"靠一个快速通道分流、但快速通道有边界"的工程经验都是相通的。一家热门餐厅,大堂能快速接待大多数客人(这是缓存命中);可总有客人要的菜大堂没有(穿透)、总有一道招牌菜卖完了要现做(击穿)、也总有可能整个大堂因故关闭(雪崩)。一家管理得好的餐厅,不会假设"大堂能扛住一切",它会为这些例外分别准备预案。缓存也是这样——你不能假设它密不透风,而要承认它会漏,然后为"它怎么漏"的每一种情况,都准备好接得住的那张网。
最后想说,缓存这三大问题做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你查的都是真实存在的数据,流量平稳,缓存命中率漂亮得像一条直线,有没有那些防护跑起来一模一样。它只在真实的流量、真实的恶意脚本、真实的热点和真实的故障面前才显形。那时候它会用最难堪的方式给你结账:数据库的连接数曲线在某一秒毫无征兆地拔地而起,慢查询日志疯狂滚动,接口大面积超时,而你的缓存命中率监控上,还显示着一个安详的 95%——因为穿透的流量,从来就没被算进"未命中"里。所以别等数据库冒烟了再回头补缓存,在你写下第一行 r.get() 的时候就该想清楚:有人查不存在的数据,我挡得住吗?热点 key 过期那一瞬,我扛得住吗?一批 key 会不会同时失效?Redis 挂了,我的数据库有人护着吗?这几个问题都有了答案,你的缓存才不只是 Demo 里那个命中率好看的样子,而是一道无论外面是细水长流还是恶意洪峰,都能稳稳护住数据库的可靠防线。
—— 别看了 · 2026