资讯动态

布隆过滤器:原理、误判率计算与缓存穿透等实战应用

发布时间:2026/10/14 22:52:07 来源:尧图企业网站定制
1. 布隆过滤器到底是个什么东西第一次听说布隆过滤器的时候我脑子里冒出来的画面是一个筛子。你往里面倒一堆东西有些留下来了有些漏下去了。布隆过滤器做的事情差不多但它筛的不是石头和沙子而是这个数据之前见过没有。它的正式定义是一个概率型数据结构专门用来回答一个问题某个元素是不是在一个集合里。它给出的答案有两种绝对不在或者大概率在。注意这个大概率这就是它和传统集合最本质的区别。像哈希表、有序数组、平衡树这些结构都能精确告诉你在或不在但代价是存储空间跟着数据量走。数据量一上来内存就吃紧。布隆过滤器换了个思路用极小的空间换一个可容忍的误判率换来了极高的查询效率。我用一个生活化的例子帮你建立直觉。假设你是门口保安需要在极短的时间内判断来访者是不是小区业主。你不可能把几万业主的身份证照片全记在脑子里但你可以在门口贴一张纸条上面写着凡是名字里带红字的、身高超过一米七的、穿蓝色外套的全都放行。这张纸条占不了多少空间但大部分业主都能被认出来。代价是什么偶尔有个碰巧穿蓝色外套的外卖小哥也被放进来了这就是误判。布隆过滤器就是这张纸条只不过它用的是哈希函数和二进制数组。当年我接触这个结构的时候第一反应是这玩意儿靠谱吗后来在真实项目里用了几次才发现它真正的价值不在于完美而在于用极小的代价过滤掉绝大部分不必要的工作。就像你查一个坏词是否在屏蔽词库里如果用精确匹配几百万个词条逐个比对请求一多直接卡死。如果用布隆过滤器绝大多数合法词在第一步就会被判断为不在根本不需要查数据库。这就是它最核心的价值用极少的内存挡住大量无效查询。2. 布隆过滤器的核心原理拆解2.1 从哈希函数到二进制数组布隆过滤器的底层结构非常简单一个长度为 m 的位数组bit array初始全部置0加上 k 个互相独立的哈希函数。插入一个元素的时候用这 k 个哈希函数分别计算出 k 个位置然后把位数组里这些位置全部置1。查询一个元素的时候同样算出 k 个位置如果这些位置全部是1就认为这个元素可能存在只要有一个位置是0就判定一定不存在。这里有个关键点为什么用多个哈希函数而不是一个因为单个哈希函数容易碰撞两个不同的元素可能映射到同一个位置误判率会高得离谱。多个哈希函数把元素映射到 k 个分散的位置只有当这 k 个位置全部被占位时才会误判概率大幅下降。你可以把它想象成给每个元素发 k 把钥匙只有当 k 把钥匙都能打开对应的锁时才放行。举个例子假设位数组长度 m 16哈希函数个数 k 3。插入元素猫时三个哈希函数分别算出位置 3、7、11于是把这三个位都置1。再插入狗算出位置 5、7、13把 5、7、13 置1。注意位置7被两个元素共享了。这时查询猫检查 3、7、11 —— 全是1判定存在。查询鸟算出位置 3、7、12 —— 位置12是0立即判定不存在。这很好。但假如查询一个不存在的鱼哈希算出位置 3、7、13恰好这三位都已经被猫和狗置1了于是布隆过滤器说可能存在这就是误判。2.2 误判率是怎么算出来的误判率不是瞎猜的有一套经典的数学公式。在位数组长度 m、哈希函数个数 k、已插入元素个数 n 的条件下某一位仍然是0的概率约为p (1 - 1/m)^(kn) ≈ e^(-kn/m)误判率假阳性率约为ε (1 - e^(-k*n/m))^k从这个公式能看出三个变量之间的关系m 越大误判率越低n 越大误判率越高k 存在一个最优值。对给定的 m 和 n最优哈希函数个数是k (m/n) * ln2这个值大约是 0.693 * (m/n)。也就是说如果你想让每个元素平均占用 10 个比特位m/n 10那么最优的 k 约等于 6.93取整就是 7 个哈希函数。我见过很多初学者一上来就拍脑袋定参数结果要么空间浪费严重要么误判率高得没法用。实际上我习惯先定两个值能接受的最大误判率 ε以及预估的数据量 n。然后反推位数组长度 mm - (n * ln ε) / (ln2)^2举个例子假设我要处理一亿个 URLn 1亿要求误判率不超过 1%ε 0.01那么m - (10^8 * ln 0.01) / (0.693)^2 ≈ - (10^8 * (-4.605)) / 0.480 ≈ 9.58 * 10^8 比特换算成字节大约是 120 MB。如果我用哈希表精确存储这 1 亿个 URL每个 URL 平均 100 字节光原始数据就要 10 GB再加上哈希表本身的负载因子开销少说也得 15~20 GB。布隆过滤器用 120 MB 就解决了问题差了将近两个数量级。这就是它最打动我的地方。2.3 两个关键特性不可删除与不可扩展布隆过滤器有一个广为人知的缺陷不能删除元素。原因很直白因为多个元素会共享同一个位。你想把猫删掉把位置 3、7、11 全部置0但位置7同时也是狗的位这样一来狗就会被误判为不存在。这是绝对不可接受的。所以标准布隆过滤器只能插入和查询不支持删除。那如果业务场景确实需要删除怎么办有变种方案。最常用的是计数布隆过滤器Counting Bloom Filter它把位数组里的每一位从 bit 扩展成计数器比如用 4 位整数插入时计数器加1删除时减1只有计数器归0才真正释放。代价是空间直接翻好几倍而且计数器溢出问题需要额外处理。另一种思路是定期重建过滤器当误判率超过阈值时把已有数据重新插入一个新的布隆过滤器旧直接废弃。这种方式适合能接受短暂停服务的场景。至于不可扩展指的是布隆过滤器一旦确定了位数组长度 m就不能再增加太多元素。因为 n 继续增大的话位数组里被置1的比例越来越高误判率会急剧上升。还是拿门卫的纸条打比方纸条上能写的特征数量是固定的业主越来越多特征条目被占满后谁来都像业主。如果预先不知道数据量可以预估一个上限比如峰值流量的 1.2 倍或者干脆用可扩展布隆过滤器但那个复杂度就上去了我一般建议先做好容量评估。3. 布隆过滤器的优点和缺点谁也别想糊弄过去3.1 优点清单优点其实已经被说烂了但落到实处确实有东西空间效率极高。这是最大卖点。同样数据量下布隆过滤器占用内存通常是精确数据结构的几十分之一甚至几百分之一。查询时间固定。无论集合里有多少元素每次查询只需要计算 k 次哈希然后访问 k 个位时间复杂度是 O(k)k 是常数。不会因为数据量增长而变慢。插入操作同样 O(k)没有动态扩容的困扰。完全不需要存储原始数据。这一点很多人忽略但极其重要。布隆过滤器只保存元素指纹的映射位置不保存元素本身因此对数据隐私要求高的场景反而友好因为泄露的只有一些哈希位置。3.2 缺点清单误判率不可消除只能降低。这是概率型结构的宿命。不能删除元素。标准版本彻底不支持删除需要变种方案。无法获取元素列表。因为根本没有存原始元素你只能问在不在不能问都有谁。数据量预估不准会导致退化。n 超过设计值时误判率飙升甚至可能到不可用的程度。哈希函数选择有讲究。用错了哈希函数会让结果分散度极差误判率反而更高。我刚开始用布隆过滤器的时候犯过一个典型错误以为布隆过滤器可以替代 Set 来去重但又没意识到误判会导致把不存在的东西当成存在结果就是某些合法请求被当成重复请求拦掉了。后来我学乖了凡是布隆过滤器说可能存在的场景后面必须接一个精确校验兜底。布隆过滤器从来不是单独解决问题的它永远扮演的是前置过滤器角色。4. 哪些场景适合用布隆过滤器4.1 缓存穿透防护这是布隆过滤器最经典的应用也是我最早接触它的原因。在高并发系统里查询一个不存在的数据缓存里没有于是请求直接打到数据库数据库也没有于是这次查询结果不会被缓存。如果恶意攻击者利用这个漏洞用大量不存在的 key 刷请求数据库瞬间被打爆。这就是缓存穿透。布隆过滤器解决思路是在缓存前面放一个布隆过滤器里面存的是所有合法 key。请求进来先查布隆过滤器如果判定不存在直接返回空根本不会碰到缓存和数据库。如果判定可能存在再走缓存缓存没有再去数据库。因为布隆过滤器的极小误判率只会放进来极少量的无效请求数据库压力减小了 99% 以上。我在某公司做过一个真实案例原来缓存穿透高峰时数据库 QPS 飙到 2 万加上布隆过滤器以后穿透的无效请求基本被拦截数据库 QPS 降到 200 左右。这里有个实践建议布隆过滤器里的 key 集合需要和数据源保持一致。如果你的合法 key 有新增需要同步插入布隆过滤器如果 key 有删除标准布隆过滤器处理不了删除常见的做法是定期重建比如每 30 分钟重建一次用数据库里的全量 key 重新初始化过滤器。注意重建期间服务不能停我就遇到过全量重建时新老过滤器交替的问题。后来我采用的方案是双缓冲维护两个布隆过滤器实例 A 和 BA 用于线上查询B 在后台重建重建完成后原子切换。这样既不丢数据也不阻塞服务。4.2 网页 URL 去重爬虫系统里需要判断一个 URL 是否已经抓取过。抓过的 URL 可能有几亿条如果用数据库存储并做精确查询每来一个 URL 都得查一次库成本太高。布隆过滤器只需要把每个 URL 变成几个哈希位几亿条 URL 也就几百 MB 内存查询速度是纳秒级别。但需要注意布隆过滤器会把未抓取判断成已抓取也就是误判会导致漏抓。爬虫漏抓一个页面通常可以接受尤其是当你的爬取优先级是尽量不重复而非必须全抓时。如果是搜索引擎那种对完整性要求极高的场景可以再加上一个精确的已抓取表做兜底只有布隆过滤器判定已抓取的 URL 才去查精确表这样既能利用空间优势又不会漏掉重要页面。4.3 垃圾邮件与内容黑名单过滤邮件系统判断一封邮件是不是垃圾邮件最暴力的方法是把所有垃圾邮件的特征比如发件人地址、标题哈希存进数据库每来一封邮件就查一次。这个方案在邮件量大的时候撑不住。布隆过滤器可以把几千万垃圾邮件特征压缩到几十 MB 内存在网关层直接过滤。被判为可能存在的邮件再去后台用规则引擎精确判断大多数正常邮件第一关就放行了。内容安全方面也一样比如屏蔽词库。假设屏蔽词有几万条你把它们全部放进布隆过滤器用户发一条评论先跑一遍命中可能存在的再去查完整屏蔽词表。这样 99% 的合规内容不需要查库性能提升非常明显。而且这里有个天然优势误判只会导致一条合规评论被多检查一次不会误杀因为后面还有精确校验兜着。4.4 数据库与存储系统的空值判断有些系统在写入数据前想快速判断某个主键是否已存在但数据库主键索引的查询是有 IO 成本的。布隆过滤器可以直接挡在前面。比如分布式数据库在插入前检查主键冲突先用布隆过滤器判断如果不存在就可以直接插入如果可能存在再走主键索引确认。这样大部分不冲突的插入不需要走索引写入吞吐量能提升不少。类似的还有 RocksDB 这类 LSM 存储引擎它在查询时也用布隆过滤器减少不必要的磁盘 IO。实际上很多存储引擎的内部本来就集成了布隆过滤器只是大多数人没注意到。你写的每一行数据都有可能被一个布隆过滤器保护着。4.5 去重计数的前置判断在大数据统计场景比如统计 UV独立访客如果精确记录每个用户 ID内存根本扛不住。布隆过滤器可以帮忙先判断这个用户 ID 是否已经出现过如果没有出现过才写入精确列表并且计数器加一。这里要注意误判会导致某个真实的新用户被当成老访客UV 会被少统计所以对精度要求极高的场景不能直接用标准布隆过滤器。我一般建议用计数布隆过滤器或者布隆过滤器加 Redis 兜底保证最终一致性。5. 手写一个极简布隆过滤器你也能看懂写代码是实现理解的最佳方式。我直接用 Python 写一个最简版本不依赖任何第三方库方便你跑起来看效果。import math import mmh3 class BloomFilter: def __init__(self, capacity, error_rate0.01): # 根据容量和误判率计算位数组长度比特数 self.m int(- (capacity * math.log(error_rate)) / (math.log(2) ** 2)) # 计算最优哈希函数个数 self.k max(1, int(round((self.m / capacity) * math.log(2)))) # 位数组用 bytearray 模拟每个字节 8 个 bit self.bit_array bytearray((self.m 7) // 8) self.capacity capacity def _set_bit(self, index): byte_index index // 8 bit_offset index % 8 self.bit_array[byte_index] | (1 bit_offset) def _get_bit(self, index): byte_index index // 8 bit_offset index % 8 return (self.bit_array[byte_index] bit_offset) 1 def add(self, item): # 用 mmh3 的两个种子模拟多个哈希函数双重哈希扩展 base_hash mmh3.hash128(item) h1 base_hash 0xFFFFFFFF h2 (base_hash 64) 0xFFFFFFFF for i in range(self.k): combined_hash (h1 i * h2) % self.m self._set_bit(combined_hash) def contains(self, item): base_hash mmh3.hash128(item) h1 base_hash 0xFFFFFFFF h2 (base_hash 64) 0xFFFFFFFF for i in range(self.k): combined_hash (h1 i * h2) % self.m if not self._get_bit(combined_hash): return False return True注意我这里用了双重哈希扩展这是业界常见的做法不必真的实现 k 个独立哈希函数只需要一个基础哈希值拆分出两个种子然后用(h1 i * h2) % m生成第 i 个位置的哈希值效果近似于独立哈希性能也更好。mmh3 是 MurmurHash3 的 Python 绑定你可以先用pip install mmh3装上。验证一下bf BloomFilter(capacity100000, error_rate0.001) for i in range(50000): bf.add(fuser_{i}) # 测试已存在元素 print(bf.contains(user_12345)) # 大概率 True # 测试不存在元素统计误判率 false_positive 0 for i in range(50000, 100000): if bf.contains(fuser_{i}): false_positive 1 print(误判率:, false_positive / 50000)在我机器上跑出来的误判率通常在 0.001 附近波动和理论值很接近。你也可以调大capacity和error_rate观察空间变化。有一点要提醒你bytearray的位操作在下标很大时可能稍微有点绕建议封装成_set_bit和_get_bit别在主逻辑里直接算位偏移否则代码很难维护。6. 工程落地时那些坑一次说清楚6.1 哈希函数的选择MurmurHash 和 xxHash 是最常用的两个哈希族。MurmurHash3 在平衡性、速度和分布质量上都比较稳适合布隆过滤器xxHash 更快但某些极端输入下分布稍差。千万不要用hash()自带的随机哈希因为进程重启后种子变化布隆过滤器里的所有位就都失效了。也不要用密码学哈希比如 SHA-256虽然分布极好但速度太慢百万级数据插入时会明显拖后腿。6.2 容量预估宁可偏大不要偏小布隆过滤器空间预估要留缓冲。我见过一个惨痛案例某团队预估数据量是 500 万结果实际跑了 800 万误判率从设计的 1% 暴涨到 5%上线一周后缓存穿透率异常升高排查了半天才发现是布隆过滤器容量超限了。所以如果你预估的数据量是 X建议按 1.2~1.5 倍 X 来设计容量。多的钱买不了后悔药多的比特能给你安全感。6.3 序列化与持久化布隆过滤器是个纯内存结构但你可以把位数组导出成二进制文件启动时直接加载避免每次重启都重新插入数据。做法很简单序列化bit_array数据加上m和k的元信息。注意跨语言时字节序的问题我用 Java 和 Python 对接时就踩过坑Java 默认大端序Python 的bytearray按小端序访问直接读取会错位。解决方式是统一约定转换为网络字节序。布隆过滤器的设计倾向于贪心的权衡你只要在空间、误判率和数据量之间找到那个甜蜜点它就能成为你系统里最不起眼但最可靠的一道防线。我个人在实际项目中用过很多次每次把布隆过滤器换成精确数据结构之后都能直观感受到内存和延迟的改善甚至原始数据结构的很多老问题也一并消失了。如果你还没在自己的项目里试过建议先用一个小功能跑一跑看看真实收益你大概率会喜欢上这种短小精悍的解决方案。最后再分享一个小细节。布隆过滤器经常被误解为不精确但在它说不存在的时候它是百分之百确定的这个确定性比任何精确数据结构都硬。理解这一点你就能更好地把它放到系统架构中的正确位置该挡的挡该放的放。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑