1. 从一次缓存穿透说起:布隆过滤器到底解决了什么
先说个我前两年在业务里真实遇到过的问题:某个核心查询接口,每天请求量千万级,背后的存储是数据库。为了保证性能,我用 Redis 做了缓存,所有查询先走缓存,缓存没命中再读库回填。上线第一天一切正常,第二天一早监控报警:数据库连接数直接被打满,慢查询数量飙升。原因是当天正好上线了一个运营活动,大量请求在查一个还没来得及写进缓存的“空数据”——缓存里没有,数据库里也没有,于是每一次请求都直接落到数据库,把实例打崩了。这就是典型的“缓存穿透”。
缓存穿透的标准解法里,最简单的是把“查不到”的结果也缓存起来,比如空值缓存,但空值缓存有个问题:如果恶意请求或爬虫构造大量不存在的 ID,缓存里会堆满无用的空 key,既占内存,又没法真正拦住不断变化的非法参数。另一个思路是“参数合法性校验”,比如 ID 必须满足某种格式,但业务里很多 key 并不是严格自增的,做不了强校验。真正通用、可靠、成本又低的方案,就是我今天要展开聊的:布隆过滤器(Bloom Filter)。
布隆过滤器解决的核心问题只有一句话:用极少的内存,快速判断“一个元素是否在某个大集合里”。它允许偶尔出现误判(把不在集合里的元素判定为存在),但绝不漏判(在集合里的元素必然能被判定为存在)。放在缓存穿透的场景里,就是先把业务里所有合法的 key(比如商品 ID)提前灌进布隆过滤器,查询前先问过滤器:这个 key 存在吗?如果过滤器说“不存在”,我就直接返回,压根不去碰缓存和数据库。如果过滤器说“存在”,才走原来的查询链路。
所以,这篇文章适合谁看?如果你是后端开发、架构师,或者正在做涉及缓存、去重、爬虫、安全风控方向的项目,这篇文章会帮你把布隆过滤器的原理彻底搞清楚,并且给你一套在 Redis 里直接可落地的实现方案,包括参数怎么算、坑在哪里、误判怎么排查。我不打算讲太多数学推导,但关键的公式和计算过程会保留,因为参数选错,布隆过滤器真的会变成“过滤器里的猪队友”。
2. 原理拆解:一个位数组加几个哈希函数
2.1 数据结构和两个核心操作
第一次接触布隆过滤器的时候,我觉得这玩意儿很“反直觉”——它居然不需要存储元素本身。整个数据结构就是一个位数组(bit array),再加一组哈希函数。初始状态,位数组的所有位都是 0。
当你要把一个元素加进去时,做的事情很简单:
- 用k个不同的哈希函数分别计算这个元素的哈希值。
- 每个哈希值对位数组长度m取模,得到 k 个下标。
- 把这 k 个下标对应的位全部置为 1。
比如元素 “商品ID:10086”,经过 3 个哈希函数,算出来下标是 5、17、29,你把这 3 个位全置 1。再插入一个 “商品ID:10087”,算出来下标是 17、31、45,你把这 3 个位置 1(其中 17 已经置过了,不用变)。
查询一个元素时,重复同样的计算,拿到 k 个下标,然后检查这 k 个位:
- 如果有任何一位是 0,那这个元素必然不在集合里。
- 如果k 个位全是 1,那这个元素大概率在集合里,但也不一定——因为那些 1 可能是其他元素置上去的。
2.2 误判从哪来:为什么是“可能存在”
布隆过滤器最核心的特性就是“宁放过,不误杀”的反面:它宁可把“不存在的”误判为“存在”,也绝不把“存在的”判成“不存在”。
这背后的原因是多个元素共享了同一个位。当集合里的元素越来越多,位数组里被置成 1 的位也越来越多。一个新元素查询时,它的 k 个哈希位置可能全部“碰巧”被其他元素占用了,于是它就被误判为“在集合里”。这种误判在学术上叫False Positive(假阳性)。反过来,如果一个元素真的在集合里,它插入时置 1 的那些位只可能保持为 1,绝不会被其他操作清回 0,所以查询必然命中,这就是没有假阴性(False Negative)的由来。
如果你把布隆过滤器的空间开得非常大,比如每个元素平均对应几百个位,那误判率会趋近于 0,但内存会浪费。如果空间开得太小,很快位数组全被置成 1,那过滤器就退化成一个“永远回答存在”的摆设。所以,位数组大小 m、哈希函数个数 k、预期元素数量 n,三者之间的关系,是使用布隆过滤器的核心功课。
2.3 三个关键参数:m、n、k 之间的关系
先说结论,业界通用的公式有三个:
预估二进制位数:
m = - (n * ln(p)) / (ln(2) ^ 2)其中n是预期插入的元素数量,p是你愿意接受的误判率。注意这里的 ln 是以 e 为底的自然对数。
最优哈希函数个数:
k = (m / n) * ln(2)也就是每个元素平均分配的位数乘以 ln(2),约等于 0.693。
实际误判率估算:
p_actual = (1 - e^(-(k * n) / m)) ^ k公式看着唬人,实际算一遍就懂了。我举个具体例子:假设你现在有100 万条商品 ID要放进过滤器,你能接受的误判率是1%(也就是一万个不存在的查询里,允许 100 个误判为存在)。
先算 m:
ln(0.01) ≈ -4.60517,ln(2) ≈ 0.693。
m = -(1000000 × (-4.60517)) / (0.693 × 0.693) ≈ 4605170 / 0.480453 ≈ 958 万位。958 万位换算成字节是 958 万 / 8 = 约 120 万字节,也就是 1.14 MB。100 万个元素,只用了 1.14 MB 内存,这就是布隆过滤器的恐怖之处——如果用 Redis 的 Set 存这 100 万个 ID,哪怕每个 ID 只有 8 字节,也要几十 MB。
再算 k:
k = (958 万 / 100 万) × 0.693 ≈ 6.64,向上取整到7。
也就是说,100 万数据量、1% 误判率,对应的是 1.14 MB 位数组 + 7 个哈希函数。
这里有个工程上非常实用的换算表,我直接列出来供你参考:
| 预期数据量 n | 可容忍误判率 p | 位数组大小 m | 占用内存 | 最优哈希函数个数 k |
|---|---|---|---|---|
| 10 万 | 1% | 96 万位 | 117 KB | 7 |
| 100 万 | 1% | 958 万位 | 1.14 MB | 7 |
| 100 万 | 0.1% | 1437 万位 | 1.71 MB | 10 |
| 1000 万 | 1% | 9580 万位 | 11.4 MB | 7 |
| 1000 万 | 0.01% | 1.91 亿位 | 22.8 MB | 14 |
从表里可以看出来:误判率每降低一个数量级,位数组的空间大约要增加 50% 左右。这个“空间换误判率”的曲线是布隆过滤器的核心权衡逻辑。
3. Redis 里的两种落地姿势
原理搞清楚了,接下来就是怎么在 Redis 里实现。我在实际项目里用过两种方式:一种是用 Redis 原生的 BITMAP(SETBIT/GETBIT)自己实现,另一种是用 RedisBloom 模块。两种方式各有优劣,我分别拆开讲。
3.1 方案一:用原生 BITMAP 手动实现
Redis 的 String 类型底层的位操作支持非常完善,SETBIT、GETBIT、BITCOUNT 这些命令可以直接操作单个位。布隆过滤器本质上就是一个位数组 + 一组哈希函数,所以完全可以用 BITMAP 来模拟。
第一步,计算出位数组大小 m 和哈希函数个数 k。第二步,在代码里实现 k 个哈希函数。
这里有一个工程技巧:实际写代码时,不需要真的实现 k 个完全不同的哈希算法,只需要实现 2 个哈希函数,然后用“双重哈希(double hashing)”的方式派生出一系列哈希值。公式是:
hash_i(x) = (h1(x) + i * h2(x)) % m其中 i 从 0 到 k-1。这样既能保证 k 个哈希值之间相对独立,又不用引入 k 个算法实现。
我用 Python 写过一个很典型的小工具,核心逻辑就是这样的(配合 redis-py):
import math import mmh3 from redis import Redis class RedisBloomFilter: def __init__(self, redis: Redis, key: str, capacity: int, error_rate: float = 0.01): self.redis = redis self.key = key # 根据 n 和 p 反推 m 和 k self.capacity = capacity self.error_rate = error_rate self.bit_size = int(-(capacity * math.log(error_rate)) / (math.log(2) ** 2)) self.num_hashes = int((self.bit_size / capacity) * math.log(2)) if self.num_hashes < 1: self.num_hashes = 1 # 初始时确保位数组长度合适 # Redis BITMAP 设置高位 offset 会自动扩展,但最好提前预定需要占用的字节数 required_bytes = (self.bit_size + 7) // 8 if self.redis.strlen(self.key) < required_bytes: self.redis.setbit(self.key, self.bit_size, 0) def _indexes(self, item: str): h1 = mmh3.hash(item, seed=0) h2 = mmh3.hash(item, seed=1) for i in range(self.num_hashes): yield (h1 + i * h2) % self.bit_size def add(self, item: str): for idx in self._indexes(item): self.redis.setbit(self.key, idx, 1) def contains(self, item: str) -> bool: for idx in self._indexes(item): if self.redis.getbit(self.key, idx) == 0: return False return True这段代码里有两个细节值得说一下:
我用的是
mmh3这个库,也就是 MurmurHash3。为什么选它?因为布隆过滤器对哈希函数的要求是分布均匀、计算速度快,MurmurHash3 是这一类场景的标准选择,比用 MD5、SHA-1 这类密码学哈希要快一个数量级,且分布质量足够好。在初始化
RedisBloomFilter时,我多了一步setbit(self.key, self.bit_size, 0)。这个操作的目的是强制 Redis 提前把 String 扩展到足够长度,避免后续反复扩容。如果 Redis 的 String 比较长,SETBIT 写入高位 offset 时底层会重新分配内存,虽然对一致性没有影响,但高频写入的第一天如果指针不停跳跃,性能会有可感知的抖动。
加入元素用add,判断是否存在用contains,缓存穿透场景里的查询链路就变成了:
请求进来 -> bloom.contains(商品ID) ? 如果返回 False,直接返回“不存在” 如果返回 True,继续查 Redis 缓存 -> 没命中再查 DB这里我要特别提醒一点:过滤器返回 True 不代表业务上一定存在,只是“大概率存在”。所以查 DB 的那条链路不能省,DB 仍然是最终正确性的来源。布隆过滤器扮演的是一个“快速拦截不存在请求”的守门员角色。
3.2 方案二:RedisBloom 模块
如果你想省掉自己维护哈希函数的麻烦,RedisBloom 模块是另一种选择。它在 Redis 服务端实现了布隆过滤器相关的数据结构,对外提供一套独立命令,性能和稳定性都要好不少。
常用的命令主要有这几个:
| 命令 | 作用 |
|---|---|
| BF.RESERVE key error_rate capacity | 创建一个布隆过滤器,并指定误判率和预期容量 |
| BF.ADD key item | 添加单个元素,返回 1 表示成功 |
| BF.MADD key item1 item2 ... | 批量添加,返回一个数组,1 成功 0 失败 |
| BF.EXISTS key item | 检查单个元素是否存在,返回 1 / 0 |
| BF.MEXISTS key item1 item2 ... | 批量检查存在性 |
| BF.INFO key | 查看过滤器的位数组大小、哈希函数个数、已插入元素数等元信息 |
使用方式非常简单。先创建一个 100 万容量、1% 误判率的过滤器:
BF.RESERVE product_filter 0.01 1000000然后批量灌入商品 ID:
BF.MADD product_filter "product:10001" "product:10002" "product:10003"查询:
BF.EXISTS product_filter "product:10001" # 返回 (integer) 1 BF.EXISTS product_filter "unknown_product" # 返回 (integer) 0RedisBloom 版本对容量的处理比原生 BITMAP 方案更稳健。你指定capacity=1000000之后,如果实际插入超过这个量,误判率会开始向上飘,但它不会报错——这一点非常重要,我建议在业务监控里对每个过滤器的插入量加一个告警,比如当BF.INFO里的Items逼近Capacity的 85% 时就提醒扩容。
至于 RedisBloom 怎么装,不同的 Redis 发行版差异比较大。有的云厂商已经内置了这个模块,直接在控制台开通即可;有的需要自己编译加载。我个人的建议是:如果你用的是云厂商托管的 Redis,优先看官方文档确认是否支持模块。如果只是本地测试,用 Docker 拉取带模块的镜像是最省事的路径,几行命令就能起一个带 RedisBloom 的实例。但请注意,模块是运行在 Redis 服务端的,走的是二进制协议,所有校验都是 O(k) 的时间复杂度,不存在算法层面的瓶颈。
3.3 Lua 脚本和原子性
原生 BITMAP 方案里,add操作包含了多次SETBIT命令,contains操作包含了多次GETBIT命令。如果同一个 key 被多个线程同时读写,理论上存在“读到中间态”的可能吗?
其实不会,因为 Redis 是单线程执行命令的,两个SETBIT中间不会插入其他客户端的操作。你一次性把 k 个位写完,整个过程对别的客户端是不可见的“半成品”。查询也是同理,连续读 k 个位,每个位都是当前时刻的最新值。所以天然没有并发问题。
但在某些偏执场景下,比如你想保证“查询并添加”这个复合操作是一个原子动作,那就需要用 Lua 脚本来实现了。举个例子,你想实现:如果元素不存在,就添加进去,整个逻辑不能让两个客户端同时添加导致重复。原生命令做不到,Lua 可以:
-- keys[1]: bloom key -- argv[1]: 元素 -- argv[2] ... : 哈希函数个数等参数 local bit_size = tonumber(ARGV[2]) local num_hashes = tonumber(ARGV[3]) local h1 = redis.call('HGET', KEYS[1], 'seed0') ...不过说实话,布隆过滤器本身就不适合做精确去重控制,因为存在误判,两个客户端同时检查同一个 key 会同时得到“可能不存在”,然后在 Lua 里同时尝试 add,结果就是重复数据被业务侧拦住了。这是布隆过滤器的天然边界,不是原子性可以解决的。所以我对 Lua 方案的总结是:能做,但在绝大多数业务里不需要。真要追求精确判重,直接用 Redis 的SET NX或者数据库唯一约束才是正道。
4. 参数调优与容量规划
4.1 根据业务容忍度反推空间
打开任何一篇讲布隆过滤器的文章,都会告诉你 m、n、k 的公式。但真正到了工程里,最难的不是套公式,而是明确业务到底能容忍多少误判率。
这个误判率不是拍脑袋定的,而是取决于“误判后会发生什么”。我举两个例子对比:
场景 A:缓存穿透拦截。商品 ID 查布隆过滤器,如果误判为存在,那这个请求就会继续走到缓存和 DB 查一遍,最终发现查无此数据。业务后果是“多一次无谓的数据库查询”。这个代价非常低,所以误判率可以放宽到 1% 甚至 2%,空间省下来才是硬道理。
场景 B:风控黑名单拦截。如果布隆过滤器里有黑名单手机号,一个正常用户被误判为“在黑名单里”,那你直接就把人给拦截了,后果是客诉和误伤。这种场景误判率就必须压得很低,比如 0.01% 甚至更低,或者更建议用确切的存储结构(比如 Redis Set)而不要用布隆过滤器。
所以我的建议是:先明确误判的代价函数,再反推误判率,最后才算位数组大小。不要为了炫技把误判率设成 10^-9,那样内存占用会涨到离谱,收益却微乎其微。
具体到计算环节,我一般直接用现成的在线计算器或者写个小脚本。公式本身很死,关键是参数输入:
import math def bloom_params(capacity: int, error_rate: float): bit_size = int(-(capacity * math.log(error_rate)) / (math.log(2) ** 2)) num_hashes = int((bit_size / capacity) * math.log(2)) return bit_size, num_hashes # 示例:预期 500 万元素,误判率 0.5% bit_size, num_hashes = bloom_params(5_000_000, 0.005) print(bit_size, num_hashes) # 输出示例:53586406, 7 print(f"内存占用: {bit_size / 8 / 1024 / 1024:.2f} MB")每次评估新场景时,我会把容量预估要放 2 到 3 倍余量。比如业务现在有 100 万商品,半年后可能涨到 300 万,那我就按 300 万来算参数。为什么?因为布隆过滤器最尴尬的情况就是容量提前打满——插入量超过预估后,误判率会以指数级别上升,而你几乎没办法“动态扩容”一个已经建立好的过滤器。
4.2 哈希函数挑选的细节
挑选哈希函数的时候,国内很多技术博客喜欢说“用 MD5 对它截断”,这个做法在工程上是非常不推荐的。MD5 和 SHA-1 是密码学哈希,计算开销大,而且截断之后的分布特性并不一定比非密码学哈希更均匀。布隆过滤器需要的是快和散,不需要防碰撞。
我的标准选择是:
MurmurHash3。分布均匀,速度极快,在 64 位平台上用 32 位版本和 64 位版本都可以。Python 里有
mmh3库,Java 里有 Guava 自带的Hashing.murmur3_128(),Go 里有github.com/spaolacci/murmur3。FNV-1a。没有第三方依赖时,用十几行代码手写一个也很快。它的分布质量不如 MurmurHash3,但对于内网 ID 这种特征明显的输入来说,完全够用。
双重哈希扩展。用两个种子不同的哈希函数衍生出 k 个哈希值,这个方案我前面已经讲过了,比直接调用 k 次不同的哈希函数更快,且分布特性有理论保障。
另外还有一个必须注意的坑:哈希函数的输出范围。如果你用 32 位哈希,最大值是 4.29 亿左右,如果位数组 m 远大于这个数,取模之后就会出现“某些位永远无法被命中”的情况,导致空间浪费。所以当 m 超过 2^32 时,优先改用 64 位哈希版本。我的经验值是:m 在 5 亿以内,32 位哈希安全;超过 5 亿,一律换 64 位。
5. 真实项目中的坑位清单
5.1 不能删除的坑
原生布隆过滤器不支持删除元素。因为一个位可能被多个元素共享,你把一个元素对应的 k 个位清零,很可能把其他元素命中的位也清掉了,导致其他元素查不到——这就破坏了“无假阴性”的核心保证。
所以我的第一个建议是:使用布隆过滤器之前,先确认业务数据是“只增不减”的。比如商品 ID 集合,旧的商品下架后 ID 还留在集合里,没问题。但如果是用户浏览记录去重,今天收藏明天取消,那布隆过滤器就不合适。
如果真的遇到“需要删除”的场景,业界有“计数布隆过滤器”的概念——把每个位换成一个计数器,插入时加 1,删除时减 1,当计数器归零变成 0 才真正清空位。但说实话,在 Redis 的 BITMAP 上实现计数布隆过滤器的成本非常高,实战里我见过的大部分所谓“可删布隆过滤器”,最后都退化成定期重建整个过滤器。更简单的替代方案是:用多个时段性的布隆过滤器叠加——比如“七天去重”就建 7 个过滤器,按天轮换,过期直接删掉整个 key。
5.2 大 key 和偏移量上限的坑
BITMAP 的底层是 String,Redis 单个 String 的最大限制是 512 MB。对应到位数,大约是 2^32 的 BITMAP,即 42.9 亿位。这意味着如果你是超大容量场景——比如 10 亿用户、误判率 0.1%,按公式算 m ≈ 21.5 亿位,勉强能放下;但如果数据量再翻几倍,原生 BITMAP 方案就触顶了。
即使没到上限,我也建议你要警惕“大 key”问题。一个 100 MB 的 BITMAP key 在 Redis 里做持久化、主从同步、扩容迁移时,都会带来明显压力。SETBIT写入高位时,Redis 可能需要一次性分配较多的内存,这期间实例的响应延迟会有抖动。所以,容量规划时必须同步考虑:单个过滤器的 key 不要超过 20 MB。如果预估空间超过 20 MB,拆成多个分片 key,比如按业务前缀拆成filter:product:0、filter:product:1,每个 key 负责一部分哈希范围。
5.3 误判排查的实例
有一次性能测试中,我的原生 BITMAP 过滤器出现了“比较严重”的误判:测试数据是 200 万个随机 ID,误判率跑到 7%,远高于预设的 1%。我开始以为是公式算错了,后来一步步排查,发现问题出在哈希函数的种子上。
我当时图省事,用了一个简单的字符串哈希函数,然后给种子传了 1 到 7。结果这 7 个哈希之间相关性极强,算出来的下标大量集中在有限区间内,导致某些位被过早沾满。换成mmh3.hash(item, seed=0)和seed=1作双重哈希之后,误判率立刻回到 1% 左右。哈希函数质量对误判率的影响,在某些情况下比容量还大,这个经验值一定要记下来。
另一个排查经验是:如果你用的是 RedisBloom 模块,可以通过BF.INFO查看实际插入的数量Items、位数组大小Size和哈希函数个数Hashes,然后和理论值对比。如果发现Items还没到Capacity的 80%,误判率就已经显著超标,那基本可以断定是参数残留问题——很可能你之前创建过滤器时用的是别的容量参数,后来数据量涨上去了,但没有重建。
5.4 扩容与迁移的常规操作
前面提到布隆过滤器不能动态扩容,那数据量真的超出预期了怎么办?我的做法比较土但非常有效:建立“双层布隆过滤器”,定期重建。
举个例子,线上有一个过滤器 A,承载了最近 3 个月的所有商品 ID。每月月初,我新建一个过滤器 B,容量按未来 3 个月峰值预估;然后从数据库全量捞一遍合法 ID,灌进 B;灌完确认无误后,把业务代码里的过滤器 key 从 A 切到 B;最后删掉 A。整个过程对线上影响非常小,核心就是“预热 + 切流 + 清理”。
如果你在云厂商的托管 Redis 上,还可以利用定时任务在业务低峰期自动完成这个操作,完全不影响主流程。
6. 个人经验:布隆过滤器并不是银弹
最后分享一点我在多次实践中沉淀下来的判断标准。布隆过滤器适合的场景非常明确:集合很大、检查频率很高、能容忍一定的假阳性。你可以用它拦住缓存穿透、给爬虫做 URL 去重、给推送系统做消息已读过滤、给风控系统做快速前面拦截。但它不适合做“最后一道防线”,比如账户余额判断、订单状态校验,这种事一旦出误判就是事故,必须用精确结构。
实操里我还有一条小技巧:把布隆过滤器和 Redis 缓存放在同一个键空间,但用不同的 key 前缀,比如bloom:product和cache:product。这样运维侧排查问题的时候,一眼能看到两类 key 的作用,做容量规划、过期清理都方便。
另外一个感受比较深的是:布隆过滤器这种数据结构的性能瓶颈,往往不在 Redis,而在你的业务代码构造哈希函数的方式。很多人会掉进“为了哈希而哈希”的漩涡,觉得哈希算法越复杂越好,其实对布隆过滤器来说,MurmurHash3 已经属于配置拉满,再往上加密码学哈希只会增加 CPU 开销,不会降低误判率。
如果你打算在项目里落地布隆过滤器,我建议你先从一个简单的 BITMAP 版本开始跑起来,观察一周的误判率和内存占用,再决定要不要升级到 RedisBloom 模块。很多时候,原生 BITMAP 已经足够稳了,模块的价值主要体现在让你少维护那几行哈希逻辑,以及服务端内置的内存管理更成熟。说到底,工具是次要的,真正值钱的是你对“用空间换误判率”这个底层权衡的理解深度。