2021 年我做一个用户系统的查询接口:前端拿用户 ID 来查用户资料,QPS 不低。怎么扛住这个查询量?这件事我没多想就有了方案——加缓存。第一版我做得很顺手:查询先走 Redis,缓存命中就直接返回,没命中才查数据库,查到了再回填进缓存。就完事了。本地、压测环境一跑——真不错:第一次查走数据库,之后全是缓存命中,数据库的压力低得几乎看不见。我心里很笃定:"缓存稳稳地挡在数据库前面,所有查询都先走缓存,热数据全在 Redis 里,数据库还能有什么压力?"可等这套查询接口真正上线、扛起生产的真实流量,一串问题冒了出来。第一种最先把我打懵:数据库的 CPU 周期性地飙高,我去看慢查询、看连接数,业务查询明明都很正常,可数据库就是莫名其妙地累。第二种最难缠:我抓了一段流量分析,发现有大量请求,查的是一些根本不存在的用户 ID——这些请求,缓存里永远是空的,每一条都直接打到了数据库上。第三种最头疼:有人拿着一长串随机生成的、压根不存在的 ID 来刷我的接口,我的缓存形同虚设,数据库被打得喘不过气。第四种最莫名其妙:我想了个补救办法——"那就把查不到的也缓存起来,缓存一个空值"——结果攻击者用的随机 ID 是无穷无尽的,我的 Redis 内存反而被这些空值缓存撑爆了。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"缓存挡在数据库前面,数据库就安全了"。这句话默认了缓存能挡住所有查询。可它根本挡不住。我脑子里,缓存是一面挡在数据库前面的墙,所有查询都得先撞这面墙,撞上了(命中)就被墙挡回去,撞不上(没命中)才放进去查数据库。我以为绝大多数查询都会被墙挡住。可我漏掉了一个根本的事实:缓存这面墙,只能挡住"数据真实存在"的那部分查询。缓存的工作方式,是"第一次查数据库、查到了、把结果存进缓存,以后再查同一个 key 就命中"。这套机制有一个隐含前提——这个 key 对应的数据,得真实存在,你才查得到、才存得进缓存。可如果一个 key 对应的数据根本不存在呢?第一次查,数据库返回空,缓存里什么也存不进去;第二次查同一个不存在的 key,缓存里依然是空的,又一次穿过缓存、打到数据库;第三次、第一万次,每一次都是如此。对于"不存在的数据"的查询,缓存的命中率永远是 0,它形同虚设,每一个这样的请求都会"穿透"缓存、直达数据库。这个现象有个专门的名字,叫缓存穿透。而它一旦被恶意利用——有人故意拿海量不存在的 key 来查——就成了一种能绕过你全部缓存、直接打垮数据库的攻击。要堵住这个漏洞,得在缓存之前,再加一道关卡,这道关卡要能极快地回答一个问题:"这个 key 对应的数据,到底存不存在?"如果它能斩钉截铁地说"不存在",我就当场把这个请求拦下,根本不放它去碰缓存和数据库。可这道关卡自己不能太重——你不能为了这个,真的在内存里存一份"所有存在的 key"的完整名单,当 key 有上亿个时,这份名单本身就会把内存吃光。我需要的,是一个用极小的内存、就能高速回答"这个 key 一定不存在 / 可能存在"的数据结构。这个东西,就是布隆过滤器。真正把这种高并发查询接口做扎实,核心不是"加个缓存就万事大吉",而是认清缓存只能挡住存在的数据、对不存在的数据的查询会全部穿透,认清缓存空值在面对无穷的随机 key 时会撑爆内存,理解布隆过滤器用极小内存换来"一定不存在 / 可能存在"的快速判断,理解它只会误判"存在"绝不漏判、所以能安全地用来做拦截,学会把它接在缓存之前挡住穿透,并把穿透量、误判率这些信号接进监控。这篇文章就把布隆过滤器与缓存穿透这个坑梳理一遍:为什么缓存挡不住查不存在的数据、缓存空值为什么不够、布隆过滤器的原理是什么、它为什么只会误判不会漏判、怎么用它挡住缓存穿透,以及一些把它做扎实要避开的工程坑。
问题背景
这个坑普遍,是因为"加缓存挡数据库"这个套路太深入人心了——它确实有效,以至于人们默认它无条件有效,从不去想它有没有挡不住的死角。它错得隐蔽,是因为面对正常流量,缓存的表现堪称完美:正常用户查的都是真实存在的数据,缓存命中率高得漂亮,数据库一片祥和,你开发、压测、上线初期,都看不到任何问题。它只在出现大量"查不存在的数据"的请求时才暴露——可能是上游传了脏数据、可能是爬虫乱扫、也可能是有人蓄意攻击,而那一刻,这些请求像缓存不存在一样,全部直直地穿透到数据库上。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:数据库 CPU 周期性飙高;大量查不存在 ID 的请求直达数据库;有人用随机 ID 刷接口打垮数据库;缓存空值后 Redis 内存被撑爆。
- 错误认知一:以为缓存能挡住所有查询。真相是缓存只能挡住"数据真实存在"的重复查询,对不存在的 key 命中率永远是 0。
- 错误认知二:以为缓存空值能解决穿透。真相是面对无穷无尽的随机不存在 key,空值缓存会把内存吃光。
- 错误认知三:以为要拦截就得存一份完整的"存在 key 名单"。真相是 key 上亿时这份名单本身就放不下,需要布隆过滤器这种极省内存的概率结构。
- 真相:在缓存之前加一道布隆过滤器,它说"一定不存在"就当场拦截,用极小内存堵住穿透。
一、为什么缓存挡不住"查不存在的数据"
先把第一版那个缓存查询逻辑摆出来。它就是教科书式的 cache-aside——先查缓存,没有再查库,查到回填:
# 第一版:标准 cache-aside,但挡不住查不存在的数据(反面教材)
import redis
r = redis.Redis()
def get_user(user_id: str):
# 1. 先查缓存
cached = r.get(f"user:{user_id}")
if cached is not None:
return cached
# 2. 缓存没有,查数据库
user = db.query_user(user_id)
# 3. 查到了,回填缓存
if user is not None:
r.set(f"user:{user_id}", user, ex=3600)
return user
# 正常情况:user_id 真实存在,第一次查库回填,之后全走缓存,完美。
# 但只要 user_id 根本不存在:
# db.query_user 返回 None -> 第 3 步的 if 不成立 -> 缓存里什么都没存
# 下一次再查同一个不存在的 id,缓存依然是空 -> 又查一次库
# 查这个不存在的 id 一万次,就实实在在地查了一万次数据库
这段代码没有任何语法错误,面对正常流量它工作得很好。它唯一的问题藏在第 3 步那个 if user is not None 里:只有查到了数据,才会回填缓存。这意味着,一个不存在的 key,永远没有机会被写进缓存。它每一次被查询,都是一次缓存 miss,每一次 miss 都老老实实地走到数据库那一步。正常流量下这无所谓——正常用户不会去查不存在的数据。可一旦有大量针对不存在 key 的请求涌进来,缓存这道防线就等于不存在。把这个穿透的过程画出来:
[mermaid]
flowchart TD
A[请求查询一个 key] --> B{缓存里有吗}
B -->|命中 数据存在| C[直接返回 数据库无压力]
B -->|未命中| D[查数据库]
D --> E{数据库里有吗}
E -->|有| F[回填缓存 下次就命中]
E -->|没有 key 不存在| G[什么都回填不进缓存]
G --> H[下次查同一个 key 依然未命中]
H --> D
看懂这张图,"数据库周期性被打高"那个怪现象就有了答案:有一类请求,查的是不存在的 key,它们在图里走的是 G → H → D 这个死循环——永远回填不进缓存,永远在下一次重新穿透到数据库。缓存对它们,完全失效。
这里要建立的第一个、也是最重要的认知是:任何一个优化手段,都有它生效的"前提条件";当你享受这个优化带来的好处时,你必须同时把它的前提条件,清清楚楚地记在心里——因为一旦实际场景跑出了这个前提之外,优化不仅会失效,还可能变成你毫无防备的漏洞。缓存能挡住数据库压力,这个结论是对的,但它有一个默默生效的前提:被查询的数据,是"真实存在、且会被重复查询"的热数据。缓存的整个工作机制——查到了才回填、回填了下次才命中——是完完全全建立在这个前提上的。我第一版的错误,不是用错了缓存,而是我把"缓存有效"当成了一个无条件的真理,完全忘了它脚下还踩着一个前提。当真实流量里混进了"查不存在的数据"这种跑出前提之外的请求时,缓存对它们的命中率直接归零,我那道引以为傲的防线,在这类请求面前是透明的。这个认知能迁移到几乎所有的技术选型里:你用了一个索引来加速查询,它的前提是查询条件能命中索引——一旦有人发来一个无法走索引的查询,它就退化成全表扫描;你用了一个连接池来复用连接,它的前提是连接的使用是"借了快还"的——一旦有慢操作长时间占着连接不放,连接池会迅速被耗干;你用了一个限流器保护后端,它的前提是你限流的那个维度,正好是攻击者发力的那个维度——维度选错了,限流形同虚设。它们的共性是:每一个优化、每一个防护,都不是万能的,它们都在某个"假设的世界"里才成立。所以,当你引入任何一个优化手段时,养成一个习惯:别只记住"它能带来什么好处",更要逼问自己一句——"它在什么前提下才成立?如果真实世界跑出了这个前提,会发生什么?"。把每个优化的"适用边界"和优化本身一起记牢,你才不会在某一天,被一个"明明加了优化"的系统,从那个你从没设防的边界外,狠狠捅一刀。
二、缓存空值为什么不够
发现穿透之后,我的第一个补救念头很自然:既然问题出在"查不到就回填不进缓存",那我让查不到的也回填不就行了?查到 None,我就在缓存里存一个特殊的"空值"标记,下次再查这个不存在的 key,就能在缓存里命中这个空值标记,直接返回,不再穿透:
# 补救尝试:缓存空值 —— 能挡一部分,但挡不住"无穷的随机 key"
NULL_FLAG = "__NULL__" # 一个特殊标记,表示"这个 key 查过了,不存在"
def get_user_v2(user_id: str):
cached = r.get(f"user:{user_id}")
if cached is not None:
# 命中的是空值标记,说明这个 key 确认不存在,直接返回
return None if cached == NULL_FLAG else cached
user = db.query_user(user_id)
if user is not None:
r.set(f"user:{user_id}", user, ex=3600)
else:
# 关键改动:查不到,也往缓存里写一个空值标记
r.set(f"user:{user_id}", NULL_FLAG, ex=300) # 空值给个短过期
return user
# 这能挡住"同一个不存在 key 被反复查"的情况。
# 但它挡不住真正的攻击:攻击者每次都用一个全新的、随机的、
# 不存在的 user_id。每个随机 key 都是第一次见,都会穿透一次库,
# 还会在 Redis 里新增一条空值缓存 —— 随机 key 是无穷的,
# 这些空值缓存会不断堆积,最终把 Redis 的内存撑爆
缓存空值,能挡住"同一个不存在的 key 被反复查"这种情况——比如某个前端 bug 反复用一个错误 ID 来查。但它挡不住真正的攻击。攻击者不会傻到反复用同一个 ID,他会让每一次请求都带一个全新的、随机的、不存在的 ID。对这样的请求,每个 ID 都是缓存里的"新面孔",都会穿透一次数据库,还会往 Redis 里塞一条新的空值缓存。随机 ID 的空间是无穷的,于是数据库被持续穿透,Redis 的内存也被这些只用一次的空值缓存越堆越满。补救,变成了一个新的麻烦。
这里要建立的认知是:缓存空值这个补救方案的失败,要教给你一个评估方案时极其重要的视角——一个方案够不够好,不能只看它在"普通情况"下表现如何,必须把它放到"最坏情况"下,看它会不会崩、会怎么崩。缓存空值在"普通情况"下是有效的:面对的是有限的、会重复的不存在 key,它能很好地把它们挡住。如果我只用普通流量去衡量它,我会给它打一个高分。可"缓存穿透"这个问题,它的本质就不是一个普通问题——它一旦严重起来,往往伴随着恶意,而恶意意味着,你面对的是一个会主动挑选"最坏情况"来打击你的对手。攻击者不会按你设想的"普通方式"来用你的系统,他会专门去找你方案里那个最脆弱的假设,然后往死里踩。缓存空值的脆弱假设是"不存在的 key 是有限的、会重复的",攻击者就反过来用"无穷的、绝不重复的随机 key",精准地击穿了这个假设。这里要建立的通用认知是:凡是和"对抗"沾边的设计——安全防护、限流、风控、容量规划——你都绝不能用"平均情况""通常情况"去评估它,你必须切换到一种"假想敌"的思维:假设有一个最聪明、最有耐心、专门想搞垮你的对手,他会怎么用你这个方案?他会从哪个假设下手?能扛住这种最坏情况推演的方案,才是真正可靠的方案。这个思维不只用于防攻击:你设计一个数据结构,要想它在最坏的数据分布下复杂度会不会退化;你做一个容量规划,要按业务峰值的最坏叠加来估,而不是按日常均值;你写一段并发代码,要假想出最刁钻的线程交错顺序去检验它。从"它通常能工作"升级到"它在最坏情况下也不会崩",是一个工程师从"能把功能做出来"走向"能把系统做可靠"的关键一步。
三、布隆过滤器的原理:bit 数组 + 多个哈希
缓存空值的死穴,是它要为每一个见过的不存在 key,都实打实地存一条记录,所以内存会被无穷的随机 key 拖垮。那能不能有一种结构,不管来多少个 key,它占用的内存都是固定的、极小的?这就是布隆过滤器。它的内部,只有两样东西:一个很长的、初始全是 0 的 bit 数组(位数组),和 k 个哈希函数。先看它怎么工作:
布隆过滤器的原理:一个 bit 数组 + k 个哈希函数
初始:一个长度为 m 的 bit 数组,全部是 0
bits: [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] (这里 m=16 做示意)
【加入 "u1001"】
用 3 个哈希函数算出 3 个位置:hash1=2, hash2=7, hash3=11
把这 3 个位置全部置 1:
bits: [0 0 1 0 0 0 0 1 0 0 0 1 0 0 0 0]
^ ^ ^
【加入 "u1002"】
算出位置:hash1=4, hash2=7, hash3=13 (注意 7 和上面撞了)
把这 3 个位置置 1(位置 7 已经是 1,保持):
bits: [0 0 1 0 1 0 0 1 0 0 0 1 0 1 0 0]
【查询 "u9999" 在不在】
算出位置:hash1=2, hash2=5, hash3=11
去看这 3 个位:位置 2 是 1,位置 5 是 0,位置 11 是 1
只要有一个位是 0(这里位置 5),就 100% 断定:u9999 一定不存在
关键:加入元素 = 把它哈希出的 k 个位都置 1;
查询元素 = 看它哈希出的 k 个位是否全为 1。
全为 1 -> 可能存在;有一个为 0 -> 一定不存在。
核心就是这套"置位"和"看位"的机制。无论你往里加 100 个 key 还是 1 亿个 key,这个 bit 数组的长度 m 是固定不变的——加 key 只是把更多的位从 0 变成 1,它不会让结构本身变大。这就是它省内存的根。下面是一个能跑的布隆过滤器实现:
# 一个能跑的布隆过滤器实现
import hashlib
class BloomFilter:
def __init__(self, m: int, k: int):
self.m = m # bit 数组的总位数
self.k = k # 哈希函数的个数
self.bits = bytearray(m // 8 + 1) # 用 bytearray 存 bit,每字节 8 位
def _positions(self, key: str):
# 双哈希法:用两个哈希值组合出 k 个位置,不必真的写 k 个哈希函数
data = key.encode()
h1 = int.from_bytes(hashlib.md5(data).digest()[:8], "big")
h2 = int.from_bytes(hashlib.sha1(data).digest()[:8], "big")
for i in range(self.k):
yield (h1 + i * h2) % self.m
def add(self, key: str):
# 加入元素:把 k 个位置全部置 1
for pos in self._positions(key):
self.bits[pos // 8] |= (1 << (pos % 8))
def contains(self, key: str) -> bool:
# 查询元素:看 k 个位置是否全为 1
for pos in self._positions(key):
if not (self.bits[pos // 8] & (1 << (pos % 8))):
return False # 有一位是 0 —— 这个 key 一定不存在
return True # 所有位都是 1 —— 可能存在
这里要建立的认知是:布隆过滤器最值得你品味的,不是它的代码,而是它做的那笔交易——它用"放弃精确性",换来了"内存占用的暴跌"。一个普通的 HashSet,要精确地记住每一个 key,所以它必须把每个 key 的完整信息(或一个足够长、不会碰撞的指纹)都存下来,内存占用和元素个数、元素大小成正比。布隆过滤器做了一个大胆的决定:我不存任何 key 的原文,我只在一个共享的 bit 数组上,留下几个"它来过"的痕迹(置 1)。这个决定带来一个直接后果——不同的 key,它们留下的痕迹会重叠(就像示意图里位置 7 被两个 key 共用),于是结构里的信息变得"模糊"了,它再也没法精确地回答"这个 key 到底在不在",只能回答"可能在 / 一定不在"。但正是这个"放弃精确"的妥协,换来了惊人的内存效率:存 1000 万个 key,HashSet 可能要好几百 MB,布隆过滤器十几 MB 就够了。这是一笔非常划算的交易——前提是,你的场景真的能容忍这点不精确。这个"用精度换资源"的思想,是一整类数据结构的灵魂,它们有个统称叫"概率型数据结构":HyperLogLog 用极小的内存估算海量数据的"基数"(去重计数),代价是结果有个百分之几的误差;Count-Min Sketch 用很小的空间估算每个元素出现的频次,代价是会高估。它们和布隆过滤器是同一个家族,信奉的是同一句话:当精确的代价高到无法承受、而你的业务又恰好能容忍一点误差时,主动地、有控制地放弃一部分精确性,是一种极其高明的工程智慧。所以下次当你觉得"某个统计、某个判断,精确做起来太贵了"的时候,别急着认命,先问一句:这件事,真的需要 100% 精确吗?如果答案是"差一点点没关系",那么很可能,就存在一个像布隆过滤器这样、用一点点误差换来巨大资源节省的漂亮方案,在等着你。
四、为什么布隆过滤器只会误判"存在"、绝不漏判"不存在"
上一节说布隆过滤器"不精确",但这个不精确是有方向的、单边的——这一点至关重要,它正是布隆过滤器能被安全使用的根基。把它的两种回答和真实情况对一下:
# 演示:布隆过滤器的误判是单向的 —— 会误报"存在",绝不漏报
bf = BloomFilter(m=100, k=3) # 故意把数组设小,好让误判暴露出来
for uid in ["u1001", "u1002", "u1003"]:
bf.add(uid)
# 情况一:查一个确实加过的 key
print(bf.contains("u1001")) # True —— 一定正确。
# 因为 u1001 加入时,它的 3 个位都被亲手置成了 1,
# 这 3 个位绝不可能再变回 0,所以查它必然全 1、必然返回 True。
# == 结论:加过的 key,查询一定返回 True,绝不会漏判 ==
# 情况二:查一个没加过的 key
print(bf.contains("u9999")) # 多半 False,但有小概率 True
# 如果 u9999 的 3 个位里有任意一个是 0 -> 返回 False,正确。
# 但如果 u9999 的 3 个位,恰好都被 u1001/u1002/u1003 置过 1,
# 那它就会返回 True —— 这就是"误判存在"(假阳性)。
# == 结论:没加过的 key,可能被误判为 True,但这是唯一的错误方向 ==
把这个不对称的性质说透:一个 key 如果真的被加入过,那么它对应的 k 个位,在加入的那一刻就被置成了 1,而布隆过滤器只有置 1、永远没有清 0 的操作,所以这 k 个位会永远保持 1。于是查询一个加入过的 key,必然全 1、必然返回 True——它绝不会把一个存在的 key 误判成不存在。反过来,一个没被加入过的 key,它对应的 k 个位,有可能恰好都被别的 key 给置成了 1,这时它就会被误判成"可能存在"。所以布隆过滤器的回答只有两种含义:返回 False,意味着"一定不存在"——这个回答 100% 可信;返回 True,意味着"可能存在"——这个回答有小概率是误判。
这里要建立的认知是:布隆过滤器这个"误判单向"的性质,要教给你一个设计和评估系统时极深刻的道理——当一个系统注定会犯错时,你要做的不是徒劳地追求"零错误",而是去精心设计"让它只往安全的那个方向犯错"。布隆过滤器是会犯错的,但它的犯错方式被设计得恰到好处:它只会把"不存在"误判成"存在",绝不会把"存在"误判成"不存在"。为什么这个方向是安全的?回到我们的场景:我用布隆过滤器来拦截"查不存在数据"的请求。如果它误判了——把一个不存在的 key 说成"可能存在"——后果是什么?后果只是这个请求被放行了,去走了一遍缓存和数据库,最后查了个空。这不过是"少拦了一个",我没有因此损失任何东西,只是没拿到本该拿到的那点好处。可反过来,假如布隆过滤器会朝另一个方向犯错——把一个真实存在的 key 误判成"不存在"——那后果就是灾难性的:一个真实用户来查自己的数据,被布隆过滤器当场拦下,他会得到一个"查无此人"的错误结果。一个是"少优化了一点"的小遗憾,一个是"返回了错误数据"的严重故障。布隆过滤器的精妙,就在于它把自己的错误,死死地锁定在了前一个、那个无害的方向上。这种"把错误导向安全方向"的设计思想,有个名字叫"故障安全"(fail-safe),它无处不在:一个电梯的控制系统,一旦失电、失控,它的设计是"抱闸停住",而不是"自由下坠"——它把故障导向了"停"这个安全方向;一个保险丝,它"犯错"(熔断)的方向永远是"切断电路",而不是"接通更大的电流";一个权限系统出了 bug,正确的设计是"默认拒绝"(fail-closed),宁可错误地挡住一个合法用户,也不能错误地放行一个非法用户。它们的共性是:都承认系统不可能永不出错,于是把全部的设计精力,投入到"控制出错的方向"上,确保任何一次故障,都倒向损失最小、最安全的那一边。所以当你设计一个可能失败的环节时,别再把目标定成那个不切实际的"它永远不能错",而要换成一个清醒得多、也强大得多的目标:"当它错的时候,我要保证它只朝着安全的方向错"。
五、用布隆过滤器挡住缓存穿透
原理和性质都清楚了,现在把布隆过滤器真正接进系统。在那之前,先解决一个实际问题:bit 数组到底要开多大、用几个哈希函数?这两个参数(m 和 k)由"要装多少元素"和"能容忍多大误判率"决定,有现成的公式:
# 先算参数:根据"元素个数 n"和"可容忍误判率 p",算出 m 和 k
import math
def optimal_params(n: int, p: float):
# n: 预计要装入的元素总数;p: 能容忍的误判率,比如 0.01 表示 1%
m = math.ceil(-(n * math.log(p)) / (math.log(2) ** 2)) # bit 数组长度
k = max(1, round((m / n) * math.log(2))) # 哈希函数个数
return m, k
# 例:要装 1000 万个 user_id,误判率控制在 1%
m, k = optimal_params(10_000_000, 0.01)
print(f"m={m} 位 (约 {m / 8 / 1024 / 1024:.1f} MB), k={k}")
# 输出约:m=95850584 位 (约 11.4 MB), k=7
# 对比:用 HashSet 存 1000 万个 user_id,轻松要 500 MB 以上 ——
# 布隆过滤器用 11.4 MB 干了同样的"判断在不在"的活
参数定了,就把布隆过滤器接在缓存之前,作为查询的第一道关卡。它说"一定不存在",就当场拦截,碰都不碰缓存和数据库;它说"可能存在",才放行去走正常的缓存加数据库流程:
# 把布隆过滤器接在缓存之前,作为查询的第一道关卡
# 全局布隆过滤器(参数由上面 optimal_params 算出)
user_bloom = BloomFilter(m=95_850_584, k=7)
def get_user_v3(user_id: str):
# 第一道关卡:布隆过滤器。它说"一定不存在",直接拦死
if not user_bloom.contains(user_id):
return None # 一定不存在,根本不碰缓存和库
# 它说"可能存在"(也可能是误判),才走正常的缓存 + 数据库
cached = r.get(f"user:{user_id}")
if cached is not None:
return None if cached == NULL_FLAG else cached
user = db.query_user(user_id)
if user is not None:
r.set(f"user:{user_id}", user, ex=3600)
else:
# 走到这,要么是布隆过滤器误判、要么是数据刚被删,
# 缓存个空值兜一下 —— 因为穿透量已被布隆过滤器砍掉绝大部分
r.set(f"user:{user_id}", NULL_FLAG, ex=300)
return user
# 攻击者拿海量随机 id 来刷:这些 id 几乎都没被 add 进布隆过滤器,
# contains 会对它们返回 False,在第一行就被拦下 ——
# 数据库和 Redis 根本感知不到这波攻击
还有最后一环不能忘:布隆过滤器里的数据从哪来。它必须在服务启动时,被"预热"——把数据库里所有真实存在的 key 全部 add 进去;并且,以后每新增一条数据,都要同步 add:
# 布隆过滤器的数据维护:启动预热 + 新增同步
def warmup_bloom():
# 服务启动时:把数据库里所有存在的 user_id 全部灌进布隆过滤器
for user_id in db.scan_all_user_ids(): # 分批游标扫描,别一次全捞进内存
user_bloom.add(user_id)
def create_user(user_data) -> str:
user_id = db.insert_user(user_data)
# 关键:新增数据时,必须同步把新 id 加进布隆过滤器。
# 漏了这一步,刚注册的新用户去查自己,会被布隆过滤器
# 误判成"一定不存在"而拦死 —— 这是绝不能漏判的方向出了错
user_bloom.add(user_id)
return user_id
# 记住第四节的结论:布隆过滤器绝不漏判"存在"的前提,是
# 这个 key 真的被 add 过。所以"所有存在的 key 都必须被 add"
# 是这套机制成立的命根子 —— 预热要全、新增要同步,一个都不能少
这里要建立的认知是:把布隆过滤器接进系统的这个过程,要教给你一个关于"引入一个新组件"的通用心法——一个数据结构或中间件,它的"算法原理"只是冰山一角,真正决定它能不能在生产环境里站住脚的,是它周边那一整圈"工程配套":它的数据从哪来、怎么和主数据保持同步、参数怎么定、它自己出问题了怎么办。第四节我们讲清了布隆过滤器"绝不漏判存在"这个黄金性质。但请注意,这个性质有一个被写在前提里的、容易被忽略的条件:"一个被 add 过的 key"。布隆过滤器只对"add 过的 key"保证不漏判;一个真实存在、但因为某种原因没被 add 进去的 key,布隆过滤器照样会对它返回 False、把它误杀。于是,那个漂亮的算法性质,瞬间就和一个朴素的工程问题死死绑在了一起:你必须保证"所有真实存在的 key,都被 add 进了布隆过滤器"。这就引出了预热(把存量数据全灌进去)和新增同步(增量数据实时灌进去)这两件事——它们不是算法的一部分,但它们是这个算法能正确工作的命根子。漏掉新增同步,你那个"绝不漏判"的承诺就当场作废,新用户会查不到自己。这件事的通用启示是:当你打算引入任何一个新东西——一个缓存、一个搜索引擎、一个布隆过滤器、一个消息队列——千万不要只看它的"原理介绍"或"性能测评"就拍板,你必须把它放进你的真实系统里,完整地推演一圈它的工程配套:它的数据怎么初始化(预热/冷启动)?它和你的主数据源怎么保证一致(同步/失效)?它的关键参数依据什么来定(容量/误判率)?它满了、坏了、重启了会怎样(降级/重建)?这些问题,每一个都和"它的原理"同等重要,甚至更重要——因为一个原理再优美的组件,只要某一项工程配套没做好,它在生产环境里就是一颗哑弹,甚至是一颗反伤自己的雷。会评估一个组件的"原理",只是入门;能想清楚它落地所需的全部"工程配套",才是真正能把它用好、用稳的功夫。
六、工程里那些布隆过滤器的坑
布隆过滤器的主线理顺了,落地时还有几个工程坑反复咬人。第一个,标准布隆过滤器不支持删除。因为一个 bit 位可能被多个 key 共用,你删 A 时把某个位清 0,可能就误伤了共用这个位的 B。需要删除时,要用"计数布隆过滤器"(每个位置从 1 bit 换成一个小计数器),或者干脆定期整体重建。第二个,容量一定要预估准。布隆过滤器装的元素越接近设计容量 n,bit 数组里 1 越多,误判率会急剧上升;一旦装超了,误判率会高到没法用。所以 n 要按未来一段时间的增长来留足余量。第三个,分布式场景要用共享的布隆过滤器。如果你的服务有多个实例,各自在内存里维护一个布隆过滤器,会有一致性问题(一个实例 add 了,别的实例不知道);生产上一般用 Redis 的 RedisBloom 模块,做一个全局共享的布隆过滤器。第四个,重建期间要保证不漏判。定期重建布隆过滤器时,新的还没建好、旧的可能已经不准,这期间要让查询走"放行"而不是"拦截",宁可暂时挡不住穿透,也绝不能误杀真实请求。第五个,误判率和内存是一对要权衡的量——想要更低的误判率,就得开更大的 bit 数组、用更多哈希函数,内存和计算都更贵,要按业务实际能容忍的程度来定。把这些信号都接进监控,你才有数据判断它健不健康:
布隆过滤器 + 缓存上线后必须盯死的几个指标:
bloom_reject_rate 被布隆过滤器直接拦下的请求占比
cache_penetration 穿透到数据库的"查空"请求数,接入后应大幅下降
bloom_false_positive 抽样估算的实际误判率,远超预设值说明该扩容重建
bloom_fill_ratio bit 数组里 1 的占比,超过一半误判率会快速恶化
bloom_element_count 已装入的元素数,逼近设计容量 n 就要扩容
db_qps_for_missing 数据库上"查不存在 key"的 QPS,是穿透的直接体现
rebuild_duration 布隆过滤器重建一次的耗时,影响重建窗口策略
这里要建立的认知是:把这一节的坑串起来看,会浮现一个对"布隆过滤器"乃至所有"精巧方案"的总体判断——一个方案越是精巧、越是在某个维度上做到了极致,它往往就越是"专才"而非"通才",它会在别的维度上有明确的、你必须清楚知道的局限。布隆过滤器是一个极其精巧的方案,它在"用最小内存判断存在性"这个点上做到了惊艳。但正因为它把全部的设计都押在了这一个点上,它在别的方面就有了一连串硬性的限制:它不能删除、它不能告诉你具体存了哪些元素、它的答案天生带误判、它装满了就会性能崩坏。这些不是布隆过滤器的"bug",它们是它为了那个极致的内存效率,所必须付出的、写在设计里的代价。我第一版的思维毛病,是容易被一个方案的"亮点"冲昏头,以为找到了一个银弹,却不去了解它的"暗面"。而成熟的工程判断,恰恰是建立在对一个方案"暗面"的清醒认知上的:你必须既知道它擅长什么,也同样清楚地知道它不擅长什么、它在什么场景下会失效、用它要付出哪些配套成本。只有同时掌握了"亮点"和"暗面",你才能做出真正负责任的技术选型——判断这个方案的局限,是否落在你的业务能接受的范围内。这个认知能让你避开无数次"选型踩坑":一个为高吞吐写入而优化到极致的存储引擎,它的暗面可能是不擅长复杂查询;一个为强一致性设计的数据库,它的暗面可能是牺牲了部分可用性;一个为极致性能而生的无锁数据结构,它的暗面可能是实现复杂、极难调试。它们都不是完美的,它们都是在某个维度登峰造极、同时在另一些维度做了妥协的"专才"。所以,这里要建立的通用认知是:当你学到一个让你眼前一亮的精巧方案时,先别急着到处套用,要逼着自己再多走一步,去把它的局限、它的代价、它的失效场景,了解得和它的优点一样透彻。一个方案,你只有同时看清了它的光和它的影,才算真正掌握了它,也才有资格决定,到底该不该在你的系统里用它。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据导致请求直达数据库 | 缓存对不存在的 key 命中率永远是 0 |
| cache-aside | 先查缓存未命中再查库并回填的模式 | 查不到就回填不进缓存 是穿透的根 |
| 缓存空值 | 把查空的结果也缓存一个特殊标记 | 挡不住无穷的随机 key 会撑爆内存 |
| 布隆过滤器 | 用 bit 数组判断元素是否存在的结构 | 内存固定极小 不随元素个数增长 |
| bit 数组 | 布隆过滤器的核心存储 初始全 0 | 加元素只置 1 标准版没有清 0 操作 |
| 误判 假阳性 | 把没加过的 key 误报为可能存在 | 是布隆过滤器唯一的错误方向 |
| 不漏判 | 加过的 key 查询一定返回存在 | 故 False 即一定不存在 可放心拦截 |
| m 与 k | bit 数组长度与哈希函数个数 | 由元素数 n 和容忍误判率 p 算出 |
| 预热与同步 | 启动灌入存量 新增实时灌入增量 | 所有存在的 key 必须都被 add |
| 计数布隆过滤器 | 每位换成计数器以支持删除 | 标准布隆过滤器不支持删除元素 |
避坑清单
- 别以为缓存能挡住所有查询,它只挡住存在的数据,查不存在的 key 会全部穿透。
- 缓存空值挡不住随机 key 攻击,无穷的随机不存在 key 会把空值缓存堆爆内存。
- 用布隆过滤器做缓存前的第一道关卡,它说一定不存在就当场拦,碰都不碰库。
- 记住误判是单向的,只会误报存在绝不漏报,所以返回 False 可 100% 放心拦截。
- 容量 n 要预估并留余量,装得越满误判率越高,装超了误判率会飙到不可用。
- 所有存在的 key 必须都 add 进去,启动要全量预热,新增数据要同步 add。
- 新增数据漏了同步 add 是重大故障,新数据会被误判为不存在而查不到。
- 标准布隆过滤器不能删除,要删用计数布隆过滤器,或定期整体重建。
- 多实例要用 RedisBloom 等共享方案,各实例各存一份会有一致性问题。
- 把穿透量和误判率接进监控,误判率远超预设或填充率过半就该扩容重建。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为缓存挡在数据库前面,数据库就安全了。可缓存的工作机制——查到了才回填、回填了下次才命中——有一个隐含前提:数据得真实存在。对一个不存在的 key,第一次查库返回空、什么也回填不进缓存,下一次查它依然 miss、依然穿透到数据库。缓存对"不存在的数据"的命中率,永远是 0。于是大量查不存在 key 的请求,像缓存不存在一样,直直地砸在数据库上——这就是缓存穿透,被恶意利用时,就是一种能绕过全部缓存打垮数据库的攻击。
真正把这种查询接口做扎实,工作量不在"加个缓存",而在认清缓存的边界、并在它前面补一道关卡:用布隆过滤器,在请求碰到缓存之前,先极快地判断这个 key 到底存不存在。一旦接受这一点,该做的事就都浮现出来了——用 bit 数组加多哈希的布隆过滤器,用极小内存装下"所有存在的 key";利用它"绝不漏判存在、只会误判不存在为存在"的单向特性,放心地用它的 False 来做拦截;按元素数和容忍误判率算好参数;启动时全量预热、新增时同步 add;把穿透量和误判率接进监控。每一步都不复杂,难的是先承认:缓存不是一面能挡住一切的墙,它有一个清清楚楚的死角,而这个死角,需要你亲手去补。
我后来常拿一场大型活动的入口安检来想这件事。布隆过滤器,就像门口那份特殊的"嘉宾核对表"。它不像普通名单那样,工工整整地记下每位嘉宾的名字——那样太占地方,几百万人的名单是一本搬不动的厚书。它的做法很巧:门口挂一张布满格子的大表,每来一位真嘉宾,就按他名字算出的几个格子,在那几个格子上各画一个勾。来了一个人要进场,就照样算出他的格子去看:只要有一个格子是空的,就 100% 断定他不在嘉宾之列,当场拦下;如果他的那几个格子恰好全都被画了勾,就放他进去,由场内的人工核验台(缓存和数据库)再细查。这张"画勾表"省地方省得惊人。它偶尔会放进一个其实没被邀请的人——那个人碰巧每个格子都被别的嘉宾画过勾(这就是误判),但放进去也无妨,里面还有一道核验。而它有一条铁律:绝不会拦下一个真正的嘉宾——因为真嘉宾的格子是被亲手画上勾的,绝不会凭空消失。对于"把绝大多数不速之客挡在门外、把场内核验台的压力卸掉一大半"这个目标,这张小小的画勾表,绰绰有余。
这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你测试时用的 ID 都是真实存在的,缓存命中率漂亮得很,数据库一片祥和,缓存穿透这个词根本不会在你脑子里出现。它只在生产环境里、在真实流量混进了脏数据或恶意请求之后才暴露——上游传来一批错误 ID、爬虫乱扫一通、或者干脆有人拿海量随机 ID 蓄意来刷,而这些查不存在数据的请求,没有一个会在功能测试里喊疼,它只是让你的数据库在某个时刻毫无征兆地被打到飙高。所以别等数据库开始周期性告警、等有人用随机 ID 把库刷垮,才想起缓存还有这个死角:设计每一个"带缓存的查询接口"的那一刻,就该问自己一句——如果有人来查一个根本不存在的东西,会发生什么?把"缓存只挡得住存在的数据、不存在的数据需要布隆过滤器来挡"这件事在一开始就认下来,你才算真正跳出了那个把缓存当成万能挡箭牌、出了事还在盯着慢查询百思不解的坑。
—— 别看了 · 2026