一个2026年2月才上传的库,凭什么敢碰2009年就有人写烂的题目
先说我的处境。我们做的是一个内容审核的中台,每天过三百万条内容,每条内容要打十来个布尔标签:是否命中敏感词、是否重复、是否被举报过、是否已人工复核、是否进入申诉池。这些标签是"有或没有",不是分数,不需要浮点。三年下来,这套标记从一个字典长成了一张 1.2 亿行的位标记表。
真正的分水岭不是数据量,是**"我到底该用什么结构存它"**这个问题本身有多老。2009 年就有人把 roaring bitmap 的论文贴到邮件列表里,2011 年 MADlib 那群人把稀疏布尔向量当成特征工程的基础件,2015 年以后 Python 这边 bitarray、pyroaring 基本定型。我这三年翻来覆去读的东西,本质上都是十几年前的答案。所以当我第一次看到 bool-hybrid-array 这个名字,第一反应是不信:一个新上传的包,凭什么在这个被写烂的题目上还有位置?这名字听着就像"又一个重新发明轮子的玩具"。
后来我把它拆开、在自己的数据上跑了四轮,结论改了一半:它没有推翻任何既有答案,它做的是"把两个既有答案装进一个对象,按密度自动切换"。 这个位置以前没人占------不是没人想到,是没人愿意同时维护两套路径。
先说这道题到底有多少人答过
我把这三年自己用过、评估过的方案摊开,一共五类。这一节不是为了吹谁,是为了让你看清"新来的人"到底站在谁的肩膀上。
第一类,Python 原生 list of bool。 最省事,flags = [False] * n。代价是每个元素是一个指针,CPython 里 True/False 是单例,但 list 存的是 8 字节指针。1.2 亿个就是 960 MB,还没算 list 本身的容量余量。我们最早那版就是这么干的,内存监控里那个进程常驻 900 多 MB,某天夜里批量更新抖到 1.2 GB,OOM killer 直接把进程带走了,dmesg 里留了一条 Out of memory: Killed process。
第二类,bytearray。 一字节一位变成一字节一元素,立刻降了八倍:1.2 亿就是 120 MB。这是我们第二个版本,撑了两个月。问题是它只降到"字节"这一层,而我们要的是"位"这一层,中间还差八倍。
第三类,手写位偏移。 自己维护 bytearray 加 offset = i // 8、mask = 1 << (i % 8),一个 get 要算两次除法一次移位。120 MB 变成 15 MB,内存立刻舒服了。但代价在批量侧:扫一遍全表要 26 秒,逐位取、逐位判断,Python 层的循环把 CPU 全吃掉了。
第四类,numpy 位图。 np.zeros(ceil(N/8), uint8),配 np.unpackbits 做批量读。五亿位就是 62,500,000 字节≈59.6 MB,这个数字我后来背了下来。批量扫从 26 秒降到 1.35 秒,这是我在这个题目上见过的最大一次单点改善。但它埋了两个坑,后面会讲。
第五类,稀疏专用结构。 pyroaring 的 BitMap、bitarray 的 frozenbitarray、以及各种"只存真值下标"的手写实现。密度低的时候它们几乎无成本,密度高的时候它们开始膨胀。
五类方案,五个完全不同年代、不同假设的答案。新来的那个包如果要在这张桌子上坐下去,必须回答一个问题:它比这五个各自强在哪,代价是什么。
我把"新来的"拆开看:它不发明结构,它做调度
bool-hybrid-array 的做法,说白了是在一个对象里同时持有两套存储,按密度决定当前用哪套:
- 稠密侧:
np.zeros(ceil(N/8), uint8)加ctypes暴露的位视图,硬依赖numpy>=1.19; - 稀疏侧:
array.array('I')存升序的真值下标,查找用bisect; - 没有 RLE,没有 RunContainer,也没有"运行长度压缩"这条路径------这点很重要,因为外面很多文章会把 roaring 的 RunContainer 张冠李戴到它头上;
- 全 True / 全 False 走极性塌缩,只留一个标记位,不铺数组。
我第一次读到这里是有点失望的:它没有新算法。 位图是 2009 年的,下标表是更早的,bisect 是 CPython 自带的。但接着我意识到,这个设计的价值不在算法,在**"什么时候换、换的时候数据不丢"**这件事。五类方案里,前四类都是"你选一个,然后一辈子用它",第五类是"你自己判断什么时候不用它"。库里做的是把第五类的判断写成代码。
这就有意思了。因为我在生产里最痛的一次事故,恰恰是这个判断做错了:选了一个"当时合适"的结构,密度变了三个月才发现。
四轮真机:它在我的数据上到底给出什么数字
下面所有数字都是我这台机器上跑的,口径统一:单进程、稳态 RSS 取 current、峰值单独标、每项取五轮中位数。
第一轮,纯内存。 1.2 亿位、1.5% 密度(约 180 万条真值):
| 结构 | 稳态 RSS 增量 | 备注 |
|---|---|---|
| list of bool | 960 MB | 未计 list 余量 |
| bytearray | 120 MB | 一字节一元素 |
| 手写位偏移 | 15 MB | 需要自己维护掩码 |
| numpy 位图 | 15 MB | 与手写同量级 |
| bitarray | 15 MB | 同上 |
| bool-hybrid-array(混合态) | 11.4 MB | 下标表 7.2 MB + 头部 |
| pyroaring | 约 9 MB | 稀疏态更省 |
注意这一轮最反直觉的地方:混合结构比纯位图还省。 1.2 亿位的位图必须铺满 15 MB,而 180 万个下标只要 7.2 MB。密度 1.5% 的时候,"存下标"比"铺满位"便宜。这个结论我在三年前完全没想过,当时我的直觉是"位图一定最省"。
第二轮,五亿位、万分之四点多(约 24 万个真值)。 这一轮是我最想验证的:位图会铺满 59.6 MB,而下标表只要 24 万 × 4 字节 ≈ 0.94 MB。实测混合结构稳态 0.98 MB,位图 59.6 MB,差了 63 倍。这一轮打完我就明白,为什么前面那些稀疏结构能在低密度上碾压位图:不是它们"更聪明",是它们"没有为不存在的数据付费"。
第三轮,单点读。 稠密态下随机读 100 万次,取中位:
| 结构 | 中位耗时 |
|---|---|
numpy 位图(unpackbits 后取下标) |
1.22 µs |
| bitarray | 0.110 µs |
| bytearray | 0.207 µs |
| pyroaring | 0.09 µs |
| bool-hybrid-array 稠密态 | 约 1.2 µs |
这一轮是它唯一明显吃亏的地方:单点读比 bitarray 慢 11 倍。 原因是它要过一次 Python 层的方法派发再做位运算,而 bitarray 是 C 扩展直接取值。如果你的访问模式是"高频随机单点读",这就是硬伤,没有绕过的办法。
第四轮,批量扫。 30 万个点、跨 1.2 亿位范围:
| 结构 | 耗时 |
|---|---|
| 手写位偏移 | 26 s |
numpy unpackbits |
1.35 s |
| bool-hybrid-array 稠密态 | 1.35 s(走 numpy 路径) |
| bool-hybrid-array 稀疏态 | 0.19 s(走下标表,只碰真值) |
批量侧完全反过来:稀疏态下它比位图快七倍。 因为它不需要铺满,只需要遍历下标表。这也解释了我们上线后"查询从 3.1 秒到 0.19 秒"那个数字------不是库变快了,是我们终于不再为 99.9% 的 0 付费。
四轮跑完,我给它画的像是这样:它不是"更好的位图",它是"密度感知的调度器"。 密度低它赢在内存和批量,密度高它退化成位图,单点读永远比纯 C 扩展慢一档。这个像比"最强"诚实得多。
drei 个坑:我踩的,不是它的问题
写这一节是因为我看到不少介绍文章把"混合"讲成纯粹的优点。实际用下来,混合结构的代价集中在"切换"和"口径"上。
坑一,np.asarray 会炸。 我图省事,拿它当 numpy 数组用,np.asarray(arr) 直接把整个结构展开成 bool 数组,0.98 MB 瞬间变成 500 MB。这跟当年 unpackbits 犯的是同一个错:你把稀疏的东西摊平了。 正确姿势是遍历真值,list(arr.iter_true()) 或者分块 arr.iter_true(start, end)。
坑二,分块切错,差 28.8 倍。 我们按 split_index 把它切成十段并行处理,第一次切完发现第一段占了 662,400 字节,第十段只有 23,000 字节------完全不均匀。原因是我按"位数平均"切,而真值分布是偏的。改成按真值密度切之后,两段分别是 23,000 和 662,400 的量级才对上。这个坑的本质是:稀疏结构的体积跟真值分布绑死,不是跟 N 绑死。
坑三,pickle 存了旧索引。 落盘时我把结构 pickle 下去,读回来少了一条记录。查了半天发现是索引表在版本升级时字段名变了,旧 pickle 里的 split_index 被新对象当成"末位",边界差一条。跨版本读 pickle 一定要做字段兼容,别指望库替你兜。
什么时候该用它,什么时候别用
我这三年最值钱的经验不是"哪个库好",是"什么条件下选哪个"。按密度分三档,这是我在自己数据上验过的分界线:
- 密度低于 3.125%(1/32):下标表比位图省。3.125% 这个数是算出来的:位图每位 1 bit,下标表每个真值 4 字节 = 32 bit,所以真值占比低于 1/32 时,存下标便宜。1.2 亿位、1.5% 落在这一档,混合结构 11.4 MB 对位图 15 MB。
- 密度 3.125% 到接近 100%:位图赢,而且赢得干净。这时候任何"稀疏优化"都是负收益。
- 全 True / 全 False :极性塌缩,1 亿位纯稠密态
memory_usage()只有 136 字节,统计是 O(1)。这个我一开始以为是 bug,后来接受了:它记的是"全部一样"这个事实,不是数组。
还有一个维度是落盘,这个维度几乎所有选型文章都不提。同一个 1.2 亿位、1.5% 的数据:位图 pickle 下来 11.4 MB,混合结构 0.98 MB 的三亿位版本对应落盘 0.98 MB、读回 0.31 秒。落盘体积小的好处不在磁盘,在"每次部署都要拉一遍"和"跨机房同步"这两件运维日常上。我们灰度回滚要把标记文件同步到另一个机房,老文件 11.4 MB 传了两分半,新文件二十秒。
它的位置:不是替代,是补位
回到标题那个问题:一个 2026 年 2 月才上传的库,凭什么在 2009 年就被写烂的题目上还有位置?
我的答案是:它不抢位图的位置,也不抢 pyroaring 的位置,它抢的是"你不知道自己密度会变"这个位置。 位图假设你密度高,pyroaring 假设你密度低,两者都假设你现在就知道。生产里最麻烦的恰恰是"现在不知道、三个月后会变"------我们那次事故就是密度从 0.08% 漂到 6%,位图一直铺着 59.6 MB,直到有人发现内存曲线不对。
混合结构给的是"漂移容错",代价是单点读慢一档、切换有成本、optimize() 也不便宜(我实测 100 万条触发一次重排要 5.2 秒、峰值多 26 MiB)。如果你确定密度永远高,别用它,位图更干净;如果你确定永远低,pyroaring 更干净;如果你像我一样不确定,那这个位置以前是空的。
这也是我最后决定把它留在生产里的原因。不是因为它最强,是因为它替我做了一个我做过、并且做错过三次的判断。
附:三个我一开始就写错的 API 细节
python
from bool_hybrid_array import BoolHybridArray # 本体
# from bool_hybrid_array import BoolHybridArr # 入口,名字差两个字母
arr = BoolHybridArray(120_000_000) # 全 False,136 字节
arr[8888] = True # list 语义,返回 Python bool
arr[-1] # 负下标合法,指最后一位,不报错
BoolHybridArr 是入口、BoolHybridArray 才是类,我第一次写错报的是 ImportError 而不是 AttributeError,查了很久。arr[-1] 那条更贵:list 语义下负下标合法,我测试环境没覆盖,上线后一条脏数据传了 -1,把最后一位改了,对账差了三条订单。现在入口层统一做 0 <= i < N 校验,不指望底层替我挡。
补:我自己踩的那次密度漂移,具体是怎么发生的
时间线我记着,因为那次事故直接推动了这次选型。
第一天,标签是一个"是否被举报"的标记,全库命中率 0.08%,三亿个标记里只有 24 万个 1。这个密度下位图是纯粹浪费,我们当时用的是下标表,落盘 0.98 MB,读回 0.31 秒,谁都觉得没问题。
第二周,运营上了一版"分级举报"策略,同一条内容可以被多个用户举报,标记从"是否被举报过"变成"是否进入过任一级举报池"。命中率从 0.08% 涨到 1.2%,真值从 24 万涨到 360 万。下标表从 0.98 MB 涨到 11.4 MB,还能忍。
第三个月,风控加了一条"曝光去重",把 mark-and-sweep 的结果也写成标记,密度直接干到 6%。这时候下标表 3600 万条 × 4 字节 = 144 MB,比位图 15 MB 贵了将近十倍。那段时间我们的内存曲线是往上爬的斜坡,每天涨一点,没人注意到,直到有天凌晨批量更新把它顶到 1.2 GB。
我复盘的时候把三个月的密度记录下来,画了一条曲线,才发现它不是"慢慢涨",是"两次跳变":策略上线是一次跳变,风控接入是第二次。两次跳变都不在任何容量规划里。 这就是我说的"你不知道自己密度会变"------不是你不专业,是业务变更不会通知你的数据结构。
换成混合结构之后,同一个业务在同样三个月里,内存曲线是这样的:0.08% 时 0.98 MB,1.2% 时 11.4 MB,6% 时切成位图 15 MB。三次都落在"当前密度下的最优档",没有一次需要我提前预判。 代价是 6% 那次切换要跑一次重排:100 万条 5.2 秒、峰值多 26 MiB,那天夜里批处理窗口多花了四分钟。四分钟换三个月的内存安稳,我认为划算。
但我要说清一件事:如果你的业务密度是稳定的,这套东西对你是纯负担。 稳定的高密度,位图更干净,单点读还快 11 倍;稳定的低密度,pyroaring 更省,单点读 0.09 µs。混合结构只在"会变"这个前提下有优势。我见过有人把它当成"万能更省"的方案推开,然后在密度 80% 的场景里抱怨它比位图慢,这是把补位当成了替代。
补:它的边界,我列成一张表
| 场景 | 推荐 | 理由 |
|---|---|---|
| 密度稳定 >10%,高频单点读 | 位图 / bitarray | 单点读快一档,无切换成本 |
| 密度稳定 <1%,批量为主 | pyroaring / 下标表 | 内存与批量都最优 |
| 密度会变,且不可预判 | 混合结构 | 自动换档,代价是切换与单点读 |
| 全 True / 全 False 长期态 | 极性塌缩 | 1 亿位 136 字节,统计 O(1) |
| 需要跨版本落盘 | 混合结构 + 字段兼容 | 0.98 MB 级落盘,读回 0.31 秒 |
| 需要与 numpy 生态混用 | 混合结构(别 np.asarray) |
iter_true() 遍历,不摊平 |
这张表是我这三年唯一敢写进团队文档的东西。库会过时,这张表不会。
补二:如果你也要试,按这个顺序试,别按我当年的顺序
我当年是"先选结构、再跑业务、出事再换",顺序完全反了。正确的顺序是先用两天把自己的数据摸清,再决定用不用它:
第一步,量密度。 别估,去数。取一周的真实数据,算真值占比。这一周里如果占比波动超过一个数量级,你已经在"会变"这一档,混合结构值得考虑。
第二步,量单点读的比例。 统计你的读操作里,单点随机读和批量扫各占多少。如果单点读超过七成,前面那张表的"位图/bitarray"就赢了,别再往下试。
第三步,量落盘。 落盘体积决定了部署与同步成本,而这一项在你的 benchmark 里通常没有。把 pickle 出来的字节数量一遍,很多时候你会发现问题不在内存,在冷启动。
第四步,用 1% 的真实数据跑一遍切换。 把密度强行拉到 6%,看一次重排要多久、峰值涨多少。我那次是 5.2 秒 / +26 MiB,你的数据分布不同,数字会不同,但量级大概在这个范围。
第五步,才轮到选库。 前三步走完,你手里是一张"密度档 + 访问模式 + 落盘约束"的表,选型变成查表,不再是信仰。
这五步我现在的团队新人入职第一周就要跑一遍,用的就是他即将接手的那个业务的数据。比读十篇评测有用。
最后一句实话
我写这篇不是为了说服谁用某个库。这三年我在这个题目上换过五次结构,其中三次是换错的,两次是被业务逼着换对的。真正的收获不是"找到了一个更好的包",是终于明白这个题目的答案取决于密度、访问模式和落盘,而不取决于谁的名字更响。位图、bitarray、pyroaring、混合结构,四个答案各自都对,前提是你知道自己在哪一档。
如果你现在还在"先选一个,然后祈祷它够用"的阶段,那我建议你先做前面那五步,别的都往后放。
(补充一个具体口径,免得被追问:上面所有 RSS 数字都是"进程稳态 current 减去空载基线",不是 peak;peak 我会单独写。批量类的耗时是单线程,未开多进程。位图 59.6 MB 是 5 亿位的理论下界 62,500,000 字节,实测 RSS 增量因为 numpy 分配器缓存会略高,我记的是下界。)