布隆过滤器误判坑了我三次?Python用混合布尔数组做精确去重层的踩坑实录

「部分情节为虚构演绎,仅供参考」

做后端的都知道布隆过滤器(Bloom Filter)是个好东西。缓存穿透?布隆过滤器挡一层。URL去重?布隆过滤器先过一遍。海量数据判重?布隆过滤器500MB能判10亿条。

from pybloom_live import BloomFilter

bf = BloomFilter(capacity=1_000_000_000, error_rate=0.001)
bf.add("user_12345")

if "user_12345" in bf:
    # 可能存在,去查缓存/数据库
    pass

布隆过滤器说"不存在",那一定不存在。但它说"存在",那可能存在——这就是假阳性(false positive)。0.1%的误判率听起来很低,但10亿次查询就是100万次误判。这100万次误判在业务上是什么概念?

  • 第一次:推荐系统把没看过的文章判定为"已看过",用户刷到重复内容,投诉。
  • 第二次:去重服务把新URL判定为"已爬取",漏掉了3万个新页面,数据缺失。
  • 第三次:风控系统把正常用户判定为"已封禁",误封了5000个账号,P0事故。

「布隆过滤器」变成了「布隆坑人器」

各种去重方案,10亿规模全翻车

方案一:set 精确去重

seen = set()
seen.add("user_12345")

if "user_12345" not in seen:
    # 一定不存在,精确
    pass

set零误判,但10亿个字符串内存爆炸。每个字符串平均20字节,加上set的哈希表开销(每个entry约72字节),总内存轻松超过20GB。

方案二:布隆过滤器

bf = BloomFilter(capacity=1_000_000_000, error_rate=0.001)

500MB左右,O(1)查询,速度极快。但0.1%误判率在10亿规模下就是100万次误判。降低误判率到0.001%?内存要翻好几倍,还是有误判。

方案三:布隆过滤器 + set 双层

bf = BloomFilter(capacity=1_000_000_000, error_rate=0.001)
exact = set()

def might_exist(key):
    if key not in bf:
        return False
    if key in exact:
        return True
    # 布隆说可能存在,但exact里没有,这就是误判
    return False  # 需要回源确认

思路是布隆挡大部分,set存布隆误判的那些key。但问题是:误判的key是哪些?你不知道。你只能把所有布隆说"存在"的都回源查数据库,那布隆过滤器就白加了。

方案四:布谷鸟过滤器

# 布谷鸟过滤器支持删除,误判率更低

布谷鸟过滤器比布隆过滤器好一点:支持删除、空间效率更高。但本质还是概率数据结构,永远存在假阳性。对零误判要求的业务(风控、计费),概率性方案就是不行。

方案五:numpy 布尔数组精确标记

import numpy as np

# 如果key能映射到整数ID空间
seen = np.zeros(1_000_000_000, dtype=np.bool_)
seen[user_id] = True

if not seen[user_id]:
    # 精确判重,零误判
    pass

numpy布尔数组零误判,930MB。但前提是你的key能映射到连续的整数ID空间。如果key是UUID、URL、字符串,你需要一个哈希映射,而哈希映射本身就占大量内存。而且numpy定长不支持动态扩展。

方案六:bytearray 位图

seen = bytearray(1_000_000_000)  # 930MB
seen[user_id] = 1

和numpy一样930MB,但没有向量化运算,查询和标记都要Python层面操作,慢。

方案七:bitarray 1bit/元素

from bitarray import bitarray

seen = bitarray(1_000_000_000)  # 116MB
seen[user_id] = 1

116MB,零误判,比numpy省8倍内存。但bitarray定长不支持稀疏——如果你的ID空间是10亿但实际只有100万个key,bitarray还是116MB,纯浪费。

小结

方案 10亿规模内存 零误判 稀疏支持 动态扩展 查询速度
set ~20GB O(1)
布隆过滤器 ~500MB ❌ 假阳性 O(k)
布谷鸟过滤器 ~300MB ❌ 假阳性 O(1)
numpy ~930MB O(1)
bytearray ~930MB O(1)
bitarray ~116MB ⚠️ O(1)

核心矛盾:零误判的方案(set/numpy/bitarray)要么内存爆炸要么不支持稀疏,支持稀疏的(布隆)又有误判。

破局思路:去重为什么不能「精确又省内存」

内存墙:500MB和20GB之间差的不只是钱

布隆过滤器500MB,set 20GB,差了40倍。但在实际业务中,这40倍的差距不是钱的问题,是能不能用的问题。20GB的set,一台64GB内存的机器只能放3个。500MB的布隆过滤器,一台机器能放100个。但布隆有误判,业务不敢用。

更关键的是缓存局部性。500MB的数据能放进L3缓存(虽然L3通常只有几十MB,但至少能在主存里快速访问),20GB的数据要靠Swap,速度差1000倍。这就是内存墙

省内存的本质是让数据结构更紧凑,访问更快。时间和空间不守恒——省内存不会自动变快,但省内存让数据离CPU更近,缓存命中率更高,这才是变快的原因。

「自动变速箱」构想

我盯着对比表想:能不能搞一个零误判的布尔数组,内存还能根据数据密度自动调节?

  • ID密集的时候,用位图,1bit/元素,零误判;
  • ID稀疏的时候,只记录已见的ID(True的位置),内存和set一样省,但零误判;
  • 密度变了就自动「换挡」。
flowchart LR A["key → ID映射"] --> B{"ID密度判断"} B -->|"密集(位图模式)"| C["1bit/ID,零误判"] B -->|"稀疏(稀疏模式)"| D["只存已见ID,零误判"] C --> E["统一数组API"] D --> E E --> F["seen[i] 精确查询/标记"]

关键设计:换挡只在创建数组和调用 optimize() 时发生。去重标记是高频操作(每秒可能标记几万个),如果每次标记都检查密度并可能触发换挡,性能就完了。所以标记时待在当前挡位,等批量导入后调 optimize() 再换挡。

和布隆过滤器的本质区别:零误判。布隆说"可能存在",这个说"存在"或"不存在",没有第三种答案。我觉得这个设计解决了所有问题,当晚就开写。

自己造轮子,十二天踩坑日记

  • 第一天:写了个ExactDedupArray,密集用bytearray,稀疏用array('Q')存已见ID,零误判,能跑。
  • 第二天:换挡阈值50%,ID分布在阈值附近时来回切,性能比不切还差。
  • 第三天:加了滞回区间,但密集区和稀疏区数据对不上,标记了的ID查不到。
  • 第四天:稀疏区用array('Q')存64位ID,但ID映射到32位空间时溢出。
  • 第五天:想支持 in 操作,密集区直接查位,稀疏区二分查找,两条路径返回值不一致。
  • 第六天:批量标记后缓存的已见数量没更新,count(True) 数字忽大忽小。
  • 第七天:optimize() 写好了,但10亿数据一换挡就卡几秒,期间去重服务全阻塞。
  • 第八天:想支持删除(布隆过滤器不支持删除是硬伤),稀疏区删除ID后数组没压缩,内存碎片。
  • 第九天:pickle序列化存快照,读回来内部结构全乱。
  • 第十天:想支持多维(用户×内容类型去重),稀疏区和密集区的多维索引完全对不上。
  • 第十一天:rindex(找最后一个已见ID)在稀疏区返回的是下标表位置,不是真实ID。
  • 第十二天:发现还要处理并发安全、内存对齐、大端小端、GC压力……心态崩了。

第十二天晚上,我意识到一个人写一个生产级的零误判混合布尔数组不是十二天能搞定的。去社区发帖。

转机:发帖求助,评论区集体推荐

帖子标题:

「布隆过滤器有误判、set内存炸、numpy不支持稀疏,要零误判又要省内存怎么办?」

第一条高赞直接点醒我:

「你要的就是 bool-hybrid-array。它换挡只在创建和 optimize() 时发生,平时标记不换挡,所以不会抖。而且它是精确的布尔数组,不是概率数据结构——零误判。你之前的问题是把布隆当精确去重用,它天生就不是干这个的。」

后面全是推荐:

  • pip install bool-hybrid-array,去重场景天生适合。」
  • 「稀疏场景只存已见ID,内存和set一样省,但零误判。」
  • 「密集场景自动用位图,1bit/元素,和bitarray一样快。」
  • memory_usage(detail=True) 看真实内存。」
  • 「支持删除,布隆做不到的它能做到。」
  • 「月下载过万,不是玩具。」
  • np.array(arr) 直接转numpy,接你现有pipeline。」
  • 「MIT协议,商用随便。」
  • 「Python 3.9到3.14全支持。」
  • findrindex返回真实ID,不是下标表位置。」

我直接跑代码验:

from bool_hybrid_array import BoolHybridArr

# 10亿ID空间,实际只有100万个已见(稀疏0.1%)
seen = BoolHybridArr(False for _ in range(1_000_000_000))
for uid in seen_ids:
    seen[uid] = True
seen.optimize()

print(seen.memory_usage(detail=True))

# 精确查询,零误判
print(seen[12345])  # True或False,没有"可能"
print(seen.count(True))  # 精确的已见数量

跑出来的结果:稀疏0.1%场景下10亿布尔数组只占几MB,和set差不多但更紧凑。我用 tracemalloc 独立验证,数字对得上。

memory_usage(detail=True) 是库自己算的。 我用 tracemalloc 验证过,但「一致」不等于「永远一致」。

别信我,别信它,信你自己的测量。

同类方案横向对比

RoaringBitmap:精确去重的工业标准

如果去重本质上就是「维护一个已见ID集合」,RoaringBitmap是这个领域的王者:

from roaringbitmap import RoaringBitmap

seen = RoaringBitmap()
seen.add(12345)
print(12345 in seen)  # 零误判

它的优势:稀疏场景内存极省,集合运算(并交差)极快,零误判,Lucene/Spark/Redis都在用。

但它的局限:不是数组。没有 seen[i] = True 这种按位置赋值的数组语义,不支持 append/pop,不支持多维数组,不支持和numpy无缝互转。

去重如果只需要集合操作,RoaringBitmap完美;但如果你需要数组语义(比如按ID范围批量标记、求未见ID列表、和numpy数组做逻辑运算),用起来就别扭。

完整对比表

方案 稀疏(0.1%)内存 零误判 数组语义 支持删除 集合运算 多维数组
set ~80MB ⚠️
布隆过滤器 ~500MB ❌ 假阳性
布谷鸟过滤器 ~300MB ❌ 假阳性
numpy ~930MB
bitarray ~116MB ⚠️ ⚠️
RoaringBitmap ~几百KB ❌ 集合
bool-hybrid-array ~几MB

中立Benchmark

指标 set 布隆(0.1%) numpy bitarray RoaringBitmap bool-hybrid-array
稀疏(0.1%)内存 ~80MB ~500MB 930MB 116MB ~几百KB ~几MB
误判率 0 0.1% 0 0 0 0
单次查询 O(1)哈希 O(k)位运算 O(1) O(1) O(1) O(1)~O(log n)
单次标记 O(1) O(k) O(1) O(1) O(1) O(1)
支持删除 ⚠️
遍历已见 O(n) 不支持 O(n)全扫 O(n)全扫 O(k) O(k)只扫稀疏区

怎么读bool-hybrid-array 在稀疏场景内存和RoaringBitmap一个量级(几MB),但保留了数组语义和多维数组支持;密集场景自动切位图,速度和numpy一样;零误判,支持删除。

反向稀疏:如果场景反过来(99%已见,1%未见),它会只记那1%的False下标,内存同样省。

注意:均匀分布(50/50)是它和numpy打平的场景。但去重场景天然是稀疏的(已见的永远是少数),所以优势极大。

缺点与适用边界

第一,需要key能映射到整数ID。 如果你的key是UUID/URL/字符串,需要先做哈希映射(比如用字典或哈希函数),这个映射本身要占内存。布隆过滤器直接吃字符串,不需要映射。

第二,optimize() 是低频操作。 批量导入后调一次,别在每次标记时调。频繁调等于频繁全量重建。

第三,非线程安全。 多线程并发标记要加锁。

第四,生态年轻。 文档和社区不如numpy/RoaringBitmap成熟。

第五,均匀分布打平。 50/50场景和numpy内存差不多。但去重场景不存在均匀分布。

第六,memory_usage 是自报数据。 我用 tracemalloc 验证过,但生产环境请自己测。

适用场景:需要零误判的精确去重 + key能映射到整数ID + 稀疏/密集混合 + 需要数组语义。用户去重、URL去重、内容去重、风控精确标记、消息已读状态。

不适用场景:key是纯字符串且无法映射ID(用布隆+回源)、允许误判且内存极度敏感(用布隆)、纯集合运算不需要数组语义(用RoaringBitmap)、均匀分布定长数组(用numpy)。

写在最后

bool-hybrid-array 替换布隆过滤器做精确去重后,10亿ID空间只占几MB(稀疏场景),零误判,支持删除,再也没有P0误封事故。我甚至用它做了布隆过滤器的"精确验证层"——布隆说"可能存在"的,再用bool-hybrid-array精确确认,误判率直接归零。

安装就一行:

pip install bool-hybrid-array

项目在Gitee和GitHub上都有(搜 bool-hybrid-array),MIT协议。核心类 BoolHybridArr,API和numpy高度兼容,np.array(arr) 无缝接入现有代码。作者承诺no removal policy,现有公开接口不会删。但行为细节可能随版本变化,上生产前务必在你自己的数据上跑一遍

别信我,信你自己的测量。

posted @ 2026-08-19 18:11  贝壳bkshell  阅读(1)  评论(0)    收藏  举报