一个2026年2月才上传的库,凭什么敢碰2009年就有人写烂的题目

一个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 分配器缓存会略高,我记的是下界。)

相关推荐
贝壳6901 小时前
同一道三亿位选型题,三个AI给了我三个答案,我把它们拉进同一台机器复跑
算法
青少儿编程课堂3 小时前
蓝桥杯青少组图形化 + 全国青少年信息素养大赛 + 电子学会图形化考级:校园大富翁经营闯关解析
c++·python·算法·bfs·信息学竞赛
在所不辞兄4 小时前
为什么在新能源汽车电驱与热管理开发中,AI驱动的数字孪生和数据驱动仿真正在部分替代对物理台架与实车标定的依赖?
人工智能·深度学习·神经网络·算法·机器学习
I_belong_to_jesus14 小时前
C++关于分支预测的优化1
开发语言·c++·算法
Code_Solitude21 小时前
C语言动态内存 + 柔性数组 + 数组名退化
c语言·算法·柔性数组
上海潭龙东海科技有限公司21 小时前
客运站管控之动态孪生技术方案(新增一屏管控模块
数据仓库·人工智能·算法·重构·动态规划
YLCHUP1 天前
【思维&图论建模】题解:债权(debt)_图论_建模_思路转化_思维题目_C++算法竞赛
c++·笔记·算法·数学建模·贪心算法·深度优先·图论
思麟呀1 天前
背包DP(01背包+完全背包)
c++·算法