1. 从一次线上抖动说起:布隆过滤器到底补上了什么短板
1.1 缓存穿透的场景复盘
如果你维护过稍有流量的后端服务,大概率遇到过这种诡异的线上抖动:某个接口的 QPS 并没有明显上涨,数据库 CPU 却突然飘到大几十,慢查询一条接一条。逐层排查后发现,罪魁祸首是一批“合法但不存在”的请求——比如有人枚举用户 ID,循环请求GET /user/1000001、GET /user/1000002,这些 ID 格式完全正常,但数据库里根本没有对应记录。
正常情况下,一个热门的查询路径应该是:请求先打到 Redis,Redis 命中就直接返回;没有命中就查 MySQL,查到后回填缓存。但这类“不存在的数据”有个致命特性:缓存里永远不会有它,因为压根儿没有可缓存的内容。于是每个请求都老老实实穿透到数据库,数据库每次也老老实实返回“查无此人”。当这种请求变成自动化脚本、每秒几百上千次地打过来,数据库就是一台免费的扫描机,而你的服务在替攻击者打工。
这种问题叫缓存穿透,和缓存击穿、缓存雪崩并称“缓存三兄弟”。击穿是热点 key 过期瞬间的大量并发打到数据库;雪崩是一大批 key 同时过期或 Redis 挂了导致流量全部压到数据库。这两个是“有数据但缓存暂时失效”的问题,穿透则是数据本身就不存在的问题,常规缓存手段对它天然无解。
1.2 常规三板斧为什么不够
对付缓存穿透,大部分团队的第一反应是“缓存空值”。逻辑很简单:查不到数据时,往 Redis 里写一个 null 占位符,TTL 设个几十秒或几分钟。这确实能挡住绝大多数穿透流量,但它有三个副作用。
第一个副作用是缓存空间被大量空 key 浪费。攻击者可以枚举出几百万个不存在的 ID,每个 ID 都占一个缓存 key,几十个字节起步,大规模枚举时内存消耗很快。第二个副作用是TTL 设短了没效果,设长了数据不一致。TTL 太短,攻击者只要等缓存过期再打一轮就行;TTL 太长,万一某个 ID 在源库里真的被创建了,用户要等 TTL 结束才能看到自己的新资源,这种体验很糟糕。第三个副作用更隐蔽:如果 key 量太大,Redis 的内存淘汰策略可能把正常业务的数据挤掉,反而影响缓存命中率。
第二个常规方案是参数校验,简单粗暴地拦截非法 ID——比如要求 ID 必须是数字、必须落在某个区间。这能挡住一部分格式不合适的请求,但穿透攻击用的往往就是格式合法、语义不存在的 ID,校验形同虚设。
第三个方案是限流熔断,把异常高频的 IP 封掉。问题在于分布式系统里限制单 IP 很容易被换 IP 绕过,而且限流是有损降级,可能误伤正常用户。说到底,这些方案都在“堵漏”而不是“预防”。
1.3 布隆过滤器在这个问题里的位置
布隆过滤器解决的是**“一个元素一定不在集合中”的判断**。它的核心结论有两个:如果过滤器说“这个元素不在”,那就100% 不在,可以直接拒绝查询;如果它说“可能在”,才允许进一步查缓存和数据库。这种特性恰好卡在缓存穿透的咽喉上——我没必要知道你具体是谁,我只需要在你进门之前知道“你是不是一定不在”。
你可以把它理解成一个比较马虎的保安:他记性不好,但手上有块小黑板,上面打了各种标记。每个来访者进门时,保安按照几个固定规则在黑板上一顿比对,只要有一条对不上,他笃定地说“你不在名单上,别进”;如果每条都对得上,他说“嗯,你像是名单上的人”,但偶尔也会认错人,放进一个不该进的人。对缓存场景来说,放进一个不该进的请求,顶多是让数据库多查一次,代价极小;挡住真正的穿透流量,才是收益所在。
这个“宁可放错,不可杀错”的判断逻辑,决定了布隆过滤器不是一个通用的精确数据结构,而是配合精确存储使用的前置过滤器。理解了它的定位,接下来看原理就顺了。
2. 原理不玄乎:一个位数组加 K 个哈希函数
2.1 从 BitMap 到布隆过滤器:一根香蕉的演变
要理解布隆过滤器,先看它的前身 BitMap(位图)。假设你要记录一亿个用户 ID 是否存在,最粗暴的方式是开一个布尔数组:每个 ID 对应一个 true/false。但每个布尔值在 Java 里至少占 1 字节,一亿个就是 100MB,用 int 数组更夸张。
BitMap 的思路是:把数组压缩到每个元素只占 1 个 bit,靠下标本身来编码信息。比如我想标记数字 7 存在,就把第 7 个 bit 置为 1;查询时看一眼第 7 个 bit 是不是 1。一亿个 bit 只有约 12MB,内存一下子降了一个量级。
但 BitMap 有一个硬伤:下标和元素是一一对应的,你得能直接算出“数字 7 在第 7 位”——这要求元素本身能转化成连续整数数组下标。用户 ID 恰好是自增数字时没问题,可如果元素是 URL、邮箱、手机号、图片哈希这类字符串,你没法用字符串直接当数组下标。解决办法是哈希,先用哈希函数把字符串映射成一个整数,再用这个整数当下标。到这里,BitMap 已经能处理字符串了,布隆过滤器和 BitMap 的哲学在此分道扬镳:BitMap 追求一一对应、零误判,代价是没法处理哈希碰撞;布隆过滤器则坦然接受了哈希碰撞的存在,并且用“多个哈希 + 多个位”来降低碰撞带来的误判率。
2.2 插入与查询的全流程
布隆过滤器的数据结构就是一个长度为 m 的位数组,初始全部为 0,以及 k 个相互独立的哈希函数。插入元素 X 时,依次用这 k 个哈希函数算出 k 个位置,把这些位置全部置为 1。查询元素 Y 时,同样算出 k 个位置,如果其中有任意一个位置是 0,就可以断定 Y 不在集合里;如果所有位置都是 1,则只能说“Y 极有可能在集合里”。
为什么“有一个 0”就能判定不存在?因为一个元素要想存在于集合中,它插入时必然把自己的 k 个位置全部置为 1。如果现在某个位置是 0,那说明这个元素从来没被完整插入过。这是集合论层面的确定性结论。
为什么“全部是 1”也不能判定存在?因为这几个位置可能是其他元素置的 1。举个极端例子:A 元素把第 1、2 位置 1,B 元素把第 3、4 位置 1,如果 Y 的哈希结果恰好是 1、2、3、4,那么 Y 查询时四个位置全是 1,但 Y 从未被插入过——这就是假阳性(误判)。
整个流程写出来只有三步:
- 初始化一个长度为 m 的 bit 数组,全部置 0;
- 插入:对元素做 k 次哈希,把这 k 个位置置 1;
- 查询:对元素做 k 次哈希,检查 k 个位置是否全为 1。
时间复杂度是 O(k),空间复杂度是 O(m),完全不随集合大小线性增长。这也是布隆过滤器能在海量数据场景中站稳脚跟的根本原因。
2.3 哈希个数和位数组长度怎么算
布隆过滤器有个反直觉的地方:位数组开得越大,误判率越低;哈希函数越多,误判率先降后升。这很好理解——位数组大,1 的密度就低,碰撞概率就小;哈希函数太少,每个元素占的坑少,判断依据不足;但哈希函数过多,每个元素要置很多个 1,整体 1 的密度上升,反而更容易误判。
经典论文给出了两组近似公式:
- 最优哈希函数个数:
k = (m / n) * ln2 - 位数组长度:
m = - n * ln(p) / (ln2)^2
其中 n 是预计存储的元素数量,p 是你能接受的最大误判率。注意,m 的单位是 bit,换算成字节要除以 8。
我拿实际数据算给你看:假设预计存储 1 亿个元素,要求误判率不超过 1%。那么:
m = - 10^8 * ln(0.01) / (ln2)^2 ≈ - 10^8 * (-4.6052) / 0.4805 ≈ 9.585 * 10^8 bit ≈ 119.8 MB k = (m / n) * ln2 ≈ 9.585 * 0.6931 ≈ 6.64,取整为 7也就是说,1 亿个元素、1% 误判率,只需要约 120MB 内存和 7 个哈希函数。对比一下:把这一亿个字符串直接放进 HashSet,按每个 64 字节算,怎么也要 6~7GB 内存。布隆过滤器把空间压缩了 50 倍以上,这个性价比相当惊人。
如果要求更严格,比如误判率降到 0.1%,重算一下:
m = - 10^8 * ln(0.001) / 0.4805 ≈ -10^8 * (-6.9078) / 0.4805 ≈ 14.37 * 10^8 bit ≈ 179.6 MB k ≈ 14.37 / 10 * 0.6931 ≈ 9.96,取整为 10误判率降低一个数量级,内存只增加了约 50%,这再次说明布隆过滤器在“低误判 + 海量数据”场景下的统治力。
2.4 为什么“能插入不能删除”
这是一个很容易被忽略、却在工程上至关重要的特性。布隆过滤器不支持删除操作,原因在于位数组里的 1 是多个元素共享的。你想删掉元素 A,把它的 k 个位置从 1 改回 0,但如果 B 元素恰好也占用其中某个位置,B 的标记就被你抹掉了,之后查询 B 会得到“不存在”的错误结论——这是比误判更严重的假阴性。
为了解决这个问题,业界有Counting Bloom Filter的变体:把每个 bit 换成一个小计数器,插入时加 1,删除时减 1,减到 0 才算真正释放。空间开销会成倍增加,而且计数器还会溢出,需要做定长循环计数或定期重建,工程复杂度明显上升。
更务实的选择是双层布隆过滤器:一个主过滤器承载查询,另一个备用过滤器接收新增数据,当主过滤器满了或误判率超过阈值时,把备用过滤器切换为主,然后离线重建。后面在工程落地部分我会展开讲。
3. 优点、缺点和边界:它解决 80% 的问题,也制造 20% 的新问题
3.1 优点确实硬核
布隆过滤器的优点不是宣传出来的,是结构决定的。
第一,空间效率极高。上文的例子已经说明,亿级数据只需要百 MB 级内存。在内存比硬盘贵得多的时代,这个特性是它被大规模采用的第一理由。第二,查询时间稳定。不管集合里有多少元素,查询都只需要固定 k 次哈希和 k 次位运算,时间复杂度 O(k),k 通常在 6~10 之间,是个可忽略的小常数。在高并发环境下,CPU 时间可以精确预估。
第三,不存储原始数据。布隆过滤器里只有一个个 bit,没有 URL、邮箱、手机号等原文。对于隐私敏感场景,这意味着即使整个位数组被 dump,攻击者也无法还原原始元素。这一条经常被忽视,但在合规视角下是个不小的优势。第四,天然契合并行与无锁设计。因为多个元素写入的是不同哈希位置(可能有重叠),但置 1 操作本身不依赖先验状态,插入顺序完全不影响最终结构。实际开发中可以对位数组做分段加锁,甚至直接用无锁 CAS 位操作,吞吐量很可观。
3.2 缺点也别回避
第一个缺点就是误判率。布隆过滤器说“存在”,不一定是真的存在;说“不存在”,一定是真的不存在。这是个单向不精确结构。任何业务使用它之前,都得先追问一句:把不存在的元素误放进来,代价是什么?
第二个缺点是无法删除。前面已经分析过,删除会导致假阴性。而现实业务中“数据失效”几乎是无处不在的——用户注销、商品下架、链接失效。你没法干净地移除一个元素,只能建新过滤器,这给运维增加了不少成本。
第三个缺点是不支持枚举。你只知道“某个元素可能在”,但无法遍历出集合里的所有元素,也没法统计实际数量。这排除了很多需要全量盘点场景,比如“我要看现在黑名单里到底有哪些 URL”,布隆过滤器给不了答案。
第四个缺点是参数需要提前预估。m 和 k 的选取依赖 n 的估算值。如果上线后实际数据量远超预期,误判率会急剧上升,而位数组长度是初始化时定死的,唯一出路是扩大数组重建过滤器——重建期间服务还需要保持可用,必须做双缓冲。
3.3 最容易踩的三个坑
我在实践中见过太多人把布隆过滤器当“精确去重工具”用。最典型的错误:用布隆过滤器判断“这个用户名是否已被注册”。布隆过滤器误判会把一个可注册的好名字拦下来,用户换名字体验变差;更糟的是,用户改名后旧名字释放了,但布隆过滤器“无法删除”,导致这个释放的名字永远不能被新用户注册。这就是误判和删除两个缺点叠加后的灾难场景。
第二个常见错误是数据量预估严重偏差。有人拿 100 万预估量设计过滤器,上线半年数据涨到 1 亿,误判率从 1% 飙升到 20% 以上,正常的查询大量涌到数据库做二次校验,缓存收益消失殆尽。初始化参数必须结合业务增长曲线留出至少两到三倍余量。
第三个坑是把布隆过滤器和精确数据做强一致校验时,不考虑同步延迟。数据源写入数据库后,异步任务还没把元素插进布隆过滤器,这时客户端的查询到了,过滤器说“不在”,直接短路返回 null——用户刚刚提交的数据查不到。这类问题需要靠“写流程直接更新过滤器 + 异步兜底补偿”来做一致性保障。
4. 使用场景全景:缓存穿透只是冰山一角
4.1 缓存穿透防护:最经典的落地组合
布隆过滤器在缓存场景下的经典拓扑是:应用层在查询缓存之前先过布隆过滤器,为“不存在”的 key 直接短路。
伪代码大概长这样:
def query_user(user_id): if not bloom_filter.contains(user_id): return None # 一定不存在,直接返回 user = redis.get(user_id) if user: return user user = mysql.select_one(user_id) if user: redis.set(user_id, user, ttl=3600) return user数据初始化有两种常见方式。离线全量方式适合冷启动:凌晨流量低峰,扫描一次全量用户表,把所有存在的 ID 批量 build 进布隆过滤器,或直接从 Hive/Binlog 导出文件做离线构建。增量实时方式负责日常维护:用户在注册成功时,异步往布隆过滤器里添加 ID;用户删除时,直接忽略删除操作——反正只追加不删除。
这个组合并不要求 100% 精确,因为漏掉一个“存在”的请求,代价是顺着缓存和数据库查一次,多几百微秒而已;而拦截掉海量“不存在”的请求,收益是数据库压力大幅下降。业务上完全不对称,非常适合引入布隆过滤器。
4.2 爬虫 URL 去重:内存装不下时的选择
做爬虫的朋友对 URL 去重应该不陌生。一个爬取任务动辄几千万上亿条 URL,如果全部放进内存里的 HashSet,很快就会撑爆内存。放进 Redis Set,内存同样吃不消;放关系数据库做唯一索引,查询耗时又不可控。
布隆过滤器在这里表现得异常顺手。爬虫每看到一个 URL 链接,先问“这个 URL 爬过没”,过滤器说“没有”——那肯定没有,直接入队抓取;过滤器说“有”——那可能是误判,也可能真的爬过,保守做法是放掉或放进延时队列二次确认。误判导致的结果不过是错过几个本来要抓的页面,对整体数据覆盖率影响极小,但省下的内存是按 GB 算的。
有人会担心误判漏掉重要页面,这种情况可以叠加一层精确索引:对小众 URL 用布隆过滤器粗筛,一旦过滤器说“存在”,再去精确的磁盘数据库里二次核对。大多数流量被内存粗筛挡住,只有少量“疑似存在”的链接才会查盘。
4.3 存储引擎的读放大优化:SSTable 里的布隆过滤器
这是布隆过滤器在底层基础设施中应用最广、但对普通开发者最隐秘的阵地。很多 LSM-Tree 架构的存储引擎(典型如各类受 RocksDB 启发的存储组件)在 SSTable 文件里内置了布隆过滤器。
LSM 树的读路径有个与生俱来的问题:一个 key 可能分布在多层 SSTable 里,查询时要从上到下逐层扫描。每层 SSTable 少则几十 MB、多则几百 MB,全量扫描的 I/O 代价极高,术语上叫读放大。布隆过滤器在这里的作用是“每层扫之前先问一句,这个 key 在不在本层”。如果过滤器说不在,就安全跳过这一层,只有它可能在时,才真正读取索引块和数据块。一个多层过滤器配合下来,能将点查的平均读放大因子从几十降到接近个位数,收益极其可观。
同时,这里还避开了一个麻烦:SSTable 是只读的,数据变更靠新文件覆盖旧文件。这恰好回避了布隆过滤器“不支持删除”的短板——旧文件里的过滤器随文件一起过期,新文件的过滤器天然只包含存活数据。这大概是我见过布隆过滤器与应用模型结合得最自然的一处。
4.4 账号和黑名单快速校验:适合与不适合的边界
垃圾邮箱黑名单、恶意 IP 库、被封禁设备指纹,这类场景天然是“增量为大、偶尔失效”的数据结构。黑名单的本质是确定性的:一个 IP 如果被封,就应该被永久拒之门外。但黑名单数据集通常极大,加载到内存不现实,存远程数据库又太慢。
布隆过滤器可以担任黑名单的前置关卡:维护一个“所有黑名单数据”的过滤器,判断时先把它当拦截器用,只有过滤器说“可能存在”时,才去精确存储里二次确认。绝大多数流量会在第一关被终止,精确存储的压力骤降。
但另一类场景——比如“判断一个新注册用户名是否重复”就不适合。原因前面说过:误判会拦住合法用户,删除问题会让释放的名字无法复用。与之相对的是“判断一个手机号是否已注册并发送营销短信”,这类业务的成本主要在于避免重复发送,偶尔漏掉一个无伤大雅,布隆过滤器就很合适。适合布隆过滤器的场景,误判代价必须足够小,删除需求必须可以忽略,数据量必须可预估——这三个条件缺一个都要重新思考选型。
5. 工程落地笔记:参数计算、代码实现和避坑清单
5.1 三个公式和一组示例数据
实际做选型时,纠结最多的就是三个参数:n(预期元素量)、m(位数组长度)、k(哈希函数个数)。我建议按固定流程走:先根据业务增长预估 n(留出 2~3 倍余量),再根据业务容忍度确定 p(缓存场景通常取 1%~5%),最后算 m 和 k。
汇总几个常用配置供参考:
| 预期元素 n | 容忍误判率 p | 位数组长度 m | 内存占用 | 哈希函数个数 k |
|---|---|---|---|---|
| 100 万 | 1% | 9.6Mbit | 1.2MB | 7 |
| 1000 万 | 1% | 96Mbit | 12MB | 7 |
| 1 亿 | 1% | 958Mbit | 120MB | 7 |
| 1 亿 | 0.1% | 1.43Gbit | 180MB | 10 |
| 10 亿 | 0.01% | 19.1Gbit | 2.4GB | 13 |
注意 k 的取整只做“向上取整”或“就近取整”影响不大,但位数组长度如果低于理论值,误判率会明显恶化。宁可在 m 上加 20% 余量,也不要卡着下限做。
5.2 自己写一个 Bloom Filter
虽然很多第三方库已经封装好了,但自己实现一遍对理解原理很有帮助,生产上也方便针对业务做裁剪。这里给一个简化版 Python 实现(实际可用,建议替换成带指纹的增强哈希):
import math import mmh3 class BloomFilter: def __init__(self, expected_size, false_positive_rate): self.m = int(-expected_size * math.log(false_positive_rate) / (math.log(2) ** 2)) self.k = int((self.m / expected_size) * math.log(2)) self.bits = [0] * self.m def _positions(self, item): return [mmh3.hash(item, seed=i) % self.m for i in range(self.k)] def add(self, item): for pos in self._positions(item): self.bits[pos] = 1 def contains(self, item): return all(self.bits[pos] for pos in self._positions(item)) # 用法示例 filter = BloomFilter(expected_size=1_000_000, false_positive_rate=0.01) filter.add("user_10001") print(filter.contains("user_10001")) # True print(filter.contains("user_99999")) # 大概率 False,极低概率 True有几个细节值得提醒。第一,哈希函数要用强分布型哈希,比如 MurmurHash3、xxHash,不要直接用普通字符串哈希。第二,用同一个哈希函数加不同的 seed 模拟 k 个独立哈希是工程界的通用做法,比维护 k 个真哈希函数更省算力。第三,实际部署时,Python 的 list 存 int 并不是 bit 级压缩,大规模场景建议用bytearray或底层位数组库。
Java 生态里我个人推荐直接用 Guava 的BloomFilter,底层的策略实现经过充分优化,常见用法一行代码:
BloomFilter<String> filter = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 1_000_000, 0.01); filter.put("user_10001"); filter.mightContain("user_10001");5.3 中间件方案对比:Guava、Redis 模块、自研位图
中小团队直接在应用内存里用 Guava 或自研过滤器就够了,但如果服务是分布式的、有多个实例,每个实例自己维护一份全量过滤器,数据同步就是灾难。常见思路是引入独立的布隆过滤器服务,或者用中间件自带的位图能力。
Redis 本身支持SETBIT/GETBIT命令,可以在 Redis 里实现一个共享布隆过滤器。思路是把位数组拆到多个 key 上,用 Lua 脚本批量执行 k 次哈希和位写入,避免跨请求多次网络往返。更进一步,Redis 的布隆过滤器模块(RedisBloom)原生提供BF.RESERVE、BF.ADD、BF.EXISTS指令,内部实现就是布隆过滤器,支持动态扩容,省去自研大量细节。
三种方案的取舍可以参考这个对照:
| 方案 | 优点 | 缺点 | 适用情况 |
|---|---|---|---|
| 应用本地实现 | 零额外部署、性能最高 | 多实例数据不一致 | 单机任务、爬虫、离线计算 |
| Guava/自研 | 稳定、易用 | 不跨进程、不跨机器 | 单实例服务 |
| Redis 位图/模块 | 分布式共享、持久化 | 额外网络开销、部署成本 | 多实例在线服务、缓存穿透 |
如果采用 Redis 位图方案,务必把大位数组拆分成多个分片 key。单个 key 存 1GB 位数组,会导致单次命令执行时间过长,Redis 主线程被拖住,整个集群性能都会受牵连。常见的拆分粒度是每个 key 存 1M~10M 个 bit,按元素的哈希高位来路由分片。
5.4 数据初始化与误判率监控
布隆过滤器上线最容易出问题的环节不在过滤器本身,而在初始化过程。假设你有 1 亿个 ID 要初始化,一次性循环插入,单线程可能要跑十几分钟甚至更久。处理方式有三种:
增量慢跑适用于可以灰度放量的场景,上线新过滤器后,先不接受任何查询,逐步把存量数据灌进去,灌完再切换流量。双缓冲适用于不能停服的场景,同时维护新旧两个过滤器,新增数据写入新过滤器,存量数据还在旧过滤器里,迭代一段时间后扔掉旧过滤器。离线构建适用于数据存在数仓或文件里的场景,用 Spark/Flink 之类的离线任务把全量数据批量构建成过滤器的二进制位图文件,发布时直接加载进内存或 Redis,初始化时间从分钟级降到秒级。
上线之后别只当它是个黑盒,要监控误判率。实测误判率有一个简单办法:定期拿一批“确定不存在但格式合法”的测试元素去查过滤器,统计过滤器说“存在”的比例,这个比例应当接近理论 p。如果发现远超阈值,通常意味着 n 超出预期,位数组 1 的密度过高。及时扩大 m 并重建过滤器,比事后补救经济得多。
监控布隆过滤器还有一个容易忽略的指标:空置率,即位数组中 0 的比例。如果已知当前实际插入数量,可以反推 1 的密度,并利用这个密度估算当前的误判率,不必真的去大量试错。我习惯把过滤器的构建时间、元素数量、m 和 k,以及实测误判率打进监控大盘,和缓存命中率、数据库负载放在同一张图上,一旦穿透流量异常,一眼就能看出是过滤器的问题还是数据同步的问题。
最后聊点个人体会。布隆过滤器最打动我的不是它的数学公式,而是它点破了一个工程真相:很多问题不需要 100% 精确的答案,只需要一个足够便宜、足够可靠的过滤器。我每次做技术方案选型时都会问自己三个问题:这个业务能不能接受极低概率的误判?数据规模是不是相对可预估?删除操作在数据生命周期里重不重要?把这三个问题想清楚,布隆过滤器该不该用、怎么用、配多少参数,答案基本自己就浮出来了。希望这篇笔记对你也有同样的帮助。