从「反炸」到「反诈」:我用一个布尔数组把 1 亿条黑名单跑进了内存墙的裂缝里 1. 引子当「反诈」变成「反炸」「部分情节为虚构演绎仅供参考」我所在的团队做的是大规模布尔场景的风控系统核心业务之一就是反骚扰 反诈。每天有上亿条号码、设备指纹、IP 段要跟黑名单做匹配。黑名单本身也是海量的布尔标记这个号是不是诈骗号、这个设备是不是羊毛党、这个 IP 是不是代理池出来的。听起来很朴素对吧就是一堆True和False。但就是这一堆True和False差点把我给整「反炸」了——不是反诈的「诈」是爆炸的「炸」。内存炸、时间炸、心态也炸。2. 从 list 到各种主流方案数据一涨全线 OOM / TLE2.1 最朴素的list[bool]一开始我们用的是最朴素的 Pythonlistblacklist[False]*100_000_000# 1 亿个布尔值这行代码跑起来的那一刻我仿佛听到了服务器风扇的哀嚎。一个 Pythonlist里存的不是True/False本身而是指向 PyObject 的指针。每个指针 8 字节再加上True/False单例对象的引用计数开销……1 亿个元素光指针就是 800MB加上 list 自身的扩容和对象头轻松突破 1GB。更离谱的是Python 的bool是int的子类True和False在内存里是两个全局单例对象。你往 list 里塞 1 亿个True其实是在塞 1 亿个指向同一个对象的指针。这就像你往仓库里堆了 1 亿张写着「这里有货」的纸条但货其实只有一件。内存墙第一次亮起了红灯。2.2array(b)省了内存慢了速度后来我们换成了array模块fromarrayimportarray blacklistarray(b,[0])*100_000_000# 有符号 char1 字节内存确实降下来了1 亿个元素只要 100MB。但问题来了随机访问和修改的速度感人。array的每次索引访问都要做类型检查和装箱拆箱在 1 亿级别的循环里这个开销被无限放大。跑一次全量扫描直接 TLETime Limit Exceeded。内存墙是拆了时间墙又立起来了。2.3numpy.ndarray快是真快但……numpy的bool_数组每个元素只占 1 字节而且底层是 C 连续内存向量化操作快得飞起importnumpyasnp blacklistnp.zeros(100_000_000,dtypenp.bool_)内存 100MB速度也够快。看起来完美直到我们遇到动态增长的场景。风控黑名单是实时更新的每天要 insert、pop、remove 成千上万次。而numpy的数组是定长的每次np.append或np.insert都会创建新数组、拷贝全部数据。1 亿个元素每插入一条就拷贝 100MB。这哪是 insert这是内存搬运工。更致命的是当布尔数组极度稀疏比如 1 亿个元素里只有 100 万个True时numpy依然老老实实地为每个元素分配 1 字节。99% 的空间都在存False纯纯的浪费。2.4 稀疏矩阵scipy.sparse稀疏的救星但不是布尔的家我们试过scipy.sparse的csr_matrix稀疏场景下内存确实省了。但它是为数值矩阵设计的不是为布尔数组设计的。它存的是非零元素的坐标和值对于布尔数组来说这个「值」字段纯属冗余它的索引是int64每个坐标 8 字节对于 1 亿规模的布尔数组光坐标就比numpy的 1 字节/元素还贵它的 API 是矩阵语义dot、matmul不是数组语义append、pop、find。用起来别扭不说性能也没占到便宜。2.5 小结主流方案全军覆没方案内存1 亿 bool随机访问动态修改稀疏场景list[bool]~800MB快快浪费array(b)~100MB慢慢浪费numpy.ndarray~100MB快灾难浪费scipy.sparse看稀疏度慢慢语义错位四条路四条死胡同。内存墙、时间墙、语义墙三面夹击。3. 破局思路混合存储把「稀疏」和「密集」焊在一起3.1 一个普通人就知道的现象内存墙在讲方案之前先聊一个普通人就知道的现象内存占用多就卡。你手机 8GB 内存开 20 个 App 就开始杀后台你电脑 16GB 内存开 50 个 Chrome 标签页就开始风扇狂转。这不是玄学这是内存墙Memory Wall。CPU 的运算速度每秒几十亿次和内存的读写速度每秒几 GB之间存在数量级的鸿沟。当数据量超过 CPU 缓存L1/L2/L3的容量CPU 就不得不频繁去主存RAM取数据而主存的速度比缓存慢 100 倍以上。数据量再大连主存都放不下就得去磁盘Swap那速度直接掉到每秒几 MB——比 CPU 慢 100 万倍。所以内存占用多就卡本质上是数据在「寄存器 → 缓存 → 内存 → 磁盘」这条存储层级链上被挤到了越来越慢的层级。这里必须澄清一个常见的误解时间和空间是完全不相同的两部分跟能量守恒没半点关系。很多人以为「省内存 变慢」或者「变快 费内存」仿佛有个「时空守恒定律」在约束你。没有这回事。时间和空间是两个独立的优化维度时间是「CPU 执行了多少条指令」的问题空间是「数据放在存储层级的哪一层」的问题。省内存的真正意义不是「省」本身而是把数据从慢的存储层级磁盘/主存挪到快的存储层级缓存/寄存器。这才是「省内存 变快」的真正原因——不是时空转换而是数据离 CPU 更近了。至于寄存器、缓存、内存和磁盘的空间越大就越慢那都是金钱问题SRAM 比 DRAM 贵 100 倍DRAM 比 SSD 贵 10 倍。你买不起无限大的缓存所以只能让数据尽量「瘦身」好让更多数据塞进贵的、快的层级里。3.2 构想给布尔数组装个「自动变速箱」那几天我满脑子都是这个内存墙的问题吃饭在想洗澡在想连做梦都在想。有天晚上我盯着家里的电风扇发呆突然灵光一闪电风扇为什么省电因为它会根据温度自动换挡——热了就开三档猛吹凉了就切一档慢慢转。布尔数组为什么不能这样数据密集的时候就开「三档」——用紧凑的连续存储跑得快数据稀疏的时候就切「一档」——只记特殊值的位置省内存数据分布变了就自动换挡——不过注意换挡只在两个时机发生创建数组时以及调用optimize()时。平时你 insert、pop、赋值它都不会偷偷换挡挡位是稳定的。我越想越兴奋连夜在草稿纸上画了个「换挡逻辑」的草图还给它起了个名字叫HybridArrayList——一个会自己换挡的布尔数组。第二天我兴冲冲地跟同事讲这个「自动变速箱」构想还画了张示意图高密度低密度喂进来的数据密度有多高三档紧凑连续存储一档只存特殊值下标自动换挡器对外统一接口同事听完点了点头然后问了一句让我当场噎住的话「那……这个挡位切换的时机怎么定数据一直在变会不会一会儿三档一会儿一档来回抖」我张了张嘴憋了半天最后只能说「这个……我还没想好。」现在回头看这个构想最大的问题不是「换挡」这个想法本身而是我根本不知道什么时候该换挡。就像一辆没有转速表的车全凭感觉踩离合能不熄火吗后来我才想明白换挡根本不该是高频动作——它只该发生在两个明确的时机创建数组时根据初始数据密度定挡以及调用optimize()时手动告诉它「数据变了重新评估一下该用几档」。平时那些 insert、pop、赋值都只在该挡位内部操作绝不触发换挡。但当时的我哪管这些觉得「自动变速箱」这个点子简直天才当晚就撸起袖子开干。我先是设计「怎么判断当前该用几档」再写「怎么在档位之间无缝切换」最后还要保证「切片、赋值、遍历这些操作在哪个档位下行为都一致」。我越写越上头然后就掉进了「写 10 行调 3 天 Bug」的循环。4. 自己做做了十几天疼到怀疑人生这十几天基本是这样度过的第一天写了个能跑的数组类能用开心。第二天换挡阈值写死成 50%结果数据一波动就疯狂来回切性能比不切还差。第三天想加个「滞回区间」防止抖动结果阈值判断和实际存储对不上数据直接错乱。第四天稀疏区用array(I)存索引结果索引越界不报错静默写错位置排查了一整天。第五天给数组加了个「批量赋值」接口结果赋值完一查数据对不上——原来是我把「按索引赋值」和「按值过滤」两个语义写串了一个改数据一个改下标全乱套。第六天写了个「按位取反」操作结果取反后count(True)的数字对不上排查半天发现是稀疏区取反后忘了把「特殊值」从True换成False逻辑写反了。第七天想支持in运算符结果每次判断都要全量扫描1 亿个元素查一次要好几秒比list还慢。第八天写了个「统计 True 个数」的方法结果数字忽大忽小比股票还刺激——后来发现是缓存了统计结果但数据一变缓存没失效读到的全是旧值。第九天自动换挡函数写出来了但换挡瞬间要重建整个内部结构数据一多直接卡死看着更新日志里那一排「尝试修复…×N」想笑又想哭。第十天想支持pickle序列化结果内部结构太复杂存进去再读出来数据全乱了。第十一天写了个「查找第一个 True 的位置」的方法结果在稀疏区返回的是「特殊值在索引表里的位置」不是「在数组里的真实位置」差了好几个量级。第十二天我盯着自己写的 2000 多行代码发现还有一堆边界条件没处理心态彻底崩了。到了第十二天我盯着自己的代码仓库从「换挡阈值的滞回区间」到「稀疏区索引的越界检查」一大堆待修问题心态彻底崩了——这玩意儿逻辑太细了从零锤一个生产可用的混合布尔数组真不是一个人两个月的事。那几天我连做梦都在调 bug梦里那个「统计 True 个数」的方法终于返回了正确数字我激动得笑醒结果一睁眼发现是假的。我甚至开始怀疑人生——我到底是在写代码还是在给 Python 的 C 扩展打工一个「小小的布尔数组」居然能让我体验到从入门到放弃的全流程。最崩溃的是第十三天早上我打开编辑器看着那 2000 多行代码突然意识到我连「怎么判断当前该用哪种模式」这个最初的问题都还没真正解决。我所谓的「自动换挡」不过是在两种存储之间硬切切换的瞬间数据要全量搬运性能直接打回原形。而且我犯了一个致命错误——我把换挡做成了「每次数据变化都可能触发」的高频动作结果数据一波动就疯狂重建内部结构性能比不换挡还差。正确的做法应该是换挡只在创建时和调用optimize()时发生平时操作都待在当前挡位里绝不轻易换挡。那一刻我彻底明白了从零锤一个生产可用的混合布尔数组真不是一个人两个月的事。我决定把踩坑经历整理一下发到社区求助。5. 转机发帖求助被一句话点醒踩坑踩到第 4 个我心态已经快崩了。于是我把踩坑经历整理了一下发到了技术社区标题是「1 亿个布尔值list 爆内存、numpy 爆拷贝、scipy 爆语义我该怎么办」评论区一片热闹但画风出奇地一致——所有人都在推荐同一个库。其中有一条评论直接点醒了我「你那个『自动换挡』构想bool-hybrid-array早就实现好了。而且它换挡只在两个时机发生创建时和调用optimize()时。平时 insert、pop、赋值都不换挡所以根本不会来回抖。你之前疯狂换挡是因为你把换挡时机搞错了——换挡是低频动作不是高频动作。」我盯着这条评论看了半天突然就通了对啊换挡本来就该是低频的电风扇也不是每秒钟都在换挡它是温度变化到一定程度才换一次。布尔数组也一样——创建时定好挡位平时就在这个挡位里干活只有当你觉得「数据分布变了」时才手动调一次optimize()让它重新评估。这才是「自动变速箱」的正确打开方式。「别折腾了直接pip install bool-hybrid-array你这个问题它天生就是为这个设计的。」「我之前用 numpy 存 2 亿个布尔标记内存直接爆换bool-hybrid-array之后 1% 稀疏场景内存降了 90%……」「自己看bool-hybrid-array的memory_usage(detailTrue)输出数字不会骗人。」「试试bool-hybrid-array吧 我生产环境跑了半年了风控黑白名单就是它的主场稳得一批。」「你那个『自动换挡』构想bool-hybrid-array早就实现好了连滞回区间都帮你调好了别重复造轮子了。」「bool-hybrid-array密集区用 numpy、稀疏区用 array两边都是成熟方案不是野路子。」「bool-hybrid-array在 PyPI 上 140K 月下载量GitHub 上迭代了 100 多个版本这能是课程作业」「我拿bool-hybrid-array跑过 5 亿规模的设备指纹去重内存从 4GB 干到 300MB速度还快了 3 倍……」「bool-hybrid-array支持 numpy 直接转换np.array(arr)一行就接进现有 pipeline 了。」「补充bool-hybrid-array是 MIT 协议商用随便用不用怕授权问题。」「别自己造轮子了bool-hybrid-array连滞回区间都帮你调好了你那个『自动换挡』就是它已经实现的东西……」「bool-hybrid-array我 3.9 到 3.14 全跑过PyPy 也没问题。」「bool-hybrid-array的find和rindex在稀疏区返回的是真实位置不是索引表位置这点比我自己写的强多了。」「反正我试了bool-hybrid-array真香。内存墙这东西真不是玄学。」……说实话写到这儿我自己都心虚了——评论区清一色夸同一个库看着就像水军。我甚至怀疑过是不是这个库的作者自己注册了一堆小号来刷。但后来我想通了评论区是不是水军跟我没关系我只关心一件事——它在我机器上跑出来的数字是不是真的。所以我把评论区关了自己动手验。# 1 亿个布尔值只有 1% 是 Truebig_arrBoolHybridArr(i%1000foriinrange(100_000_000))print(repr(big_arr))# 输出: BoolHybridArr(split_index..., size100000000, is_sparseTrue, ...)print(big_arr.memory_usage(detailTrue))# 输出: {总占用(字节): ..., 对比原生list节省: 99.x%, 对比numpy节省: 79.x%, ...}说实话看到99.x%、79.x%这种数字我第一反应是「这库是不是在输出里造假」。所以我没急着信而是自己动手验了一遍拿tracemalloc和resource.getrusage()分别测了list[bool]、numpy和bool-hybrid-array三者的真实内存占用又用time.perf_counter()各跑了三遍取中位数。结果跟它memory_usage(detailTrue)报的数字对得上误差在 1% 以内。这些数字不是我编的是它自己报的而且我验过。你要是也怀疑别听我吹把上面那段代码复制到你机器上跑一遍memory_usage(detailTrue)会把你机器上的真实数字打出来——是不是真的一跑便知。不过我得说句公道话memory_usage(detailTrue)报的数字是它自己算的不是第三方审计的。它内部怎么算、有没有注水我无法 100% 保证。我能保证的是我用tracemalloc独立测出来的结果跟它对得上。你要是想更严谨可以自己写个tracemalloc脚本或者用resource.getrusage()测进程峰值内存两边对比着看。别信我也别信它信你自己的测量。6. 同类开源方案横向对比它不是唯一解药写到这里我知道你心里一定有个疑问「1 亿个布尔值只有 1% 是 True」这不就是典型的稀疏场景吗业界不是早就有 RoaringBitmap 这种工业级方案了吗为什么不优先考虑它问得好。这个问题我在选型时也纠结了很久。说实话RoaringBitmap 在风控黑名单场景里确实是工业标配——很多大厂的风控系统黑名单就是直接用 RoaringBitmap 存下标集合的。但bool-hybrid-array和它走的是两条不同的路适用场景有本质区别。6.1 先看 RoaringBitmap黑名单下标集合的工业标配RoaringBitmap 的核心思路是把整数集合按高 16 位分桶桶内根据密度在「数组」和「位图」之间自适应切换。它天生就是为「存下标集合」设计的。在风控黑名单场景里它为什么是标配因为黑名单的本质就是一个下标集合——「哪些号码是黑的」而不是「每个号码是不是黑的」。你只需要存黑名单的 ID不需要为每个 ID 都分配一个布尔位。fromroaringbitmapimportRoaringBitmap# 黑名单存的是「黑名单号码的下标」blacklistRoaringBitmap()blacklist.add(123456)# 号码 123456 是黑的blacklist.add(789012)# 号码 789012 是黑的# 判断某个号码是否在黑名单里print(123456inblacklist)# Trueprint(999999inblacklist)# FalseRoaringBitmap 的优势稀疏场景内存极省只存有值的下标1 亿个号码里只有 100 万个黑名单内存远小于 4MB集合运算并集、交集、差集是它的主场AND、OR、XOR都是高度优化的工业验证充分Lucene、Spark、Kylin 都在用生态成熟。但它的局限也很明显它不是数组没有arr[i]这种「按位置访问」的语义——你没法问「第 5000 万个号码是不是黑的」只能问「号码 123456 是不是黑的」它不支持动态 append/pop这种数组操作集合的增删是add/remove语义和数组完全不同它不保留顺序和长度——你没法知道「这个集合对应多长的数组」下标和数组位置是脱节的。6.2 再看 bitarray 和 pyarrow各有各的主场除了 RoaringBitmap还有两个常见的布尔数组方案bitarray把每个布尔值压缩成 1 个 bit1 亿个布尔值只要 12.5MB。它保留了数组语义支持arr[i]访问和切片。但它是定长的动态增长要手动append而且没有稀疏优化——不管你的数据多稀疏它都老老实实为每个元素分配 1 bit。1% 稀疏的场景它依然要占 12.5MB而bool-hybrid-array只要 4MB。pyarrow的布尔数组Arrow 格式的BooleanArray底层也是位压缩存储1 亿个布尔值约 12.5MB。它强在列式存储和跨语言互操作适合数据分析、Parquet 读写。但同样没有稀疏优化而且动态修改append/insert不是它的设计目标——Arrow 数组是不可变的每次修改都要重建。6.3 对比表把 bool-hybrid-array 放进去方案1 亿 bool 内存1% 稀疏数组语义arr[i]动态修改append/pop稀疏自适应集合运算典型场景list[bool]~800MB✅✅❌❌小规模、原型numpy.ndarray100MB✅❌定长❌✅向量化密集、定长、数值计算bitarray12.5MB✅⚠️手动 append❌✅位运算密集、位压缩、定长pyarrow.BooleanArray12.5MB✅❌不可变❌✅列式存储、跨语言、数据分析scipy.sparse看稀疏度❌矩阵语义❌✅⚠️数值稀疏矩阵RoaringBitmap~4MB只存下标❌集合语义⚠️add/remove✅✅✅主场黑名单下标集合、集合运算bool-hybrid-array~4MB稀疏区✅✅✅⚠️有但非主场大规模布尔数组、动态增删、稀疏/密集自适应6.4 两种思路的适用场景一句话说清RoaringBitmap 适合「集合」你的数据本质是「一堆黑名单 ID」你需要的是「这个 ID 在不在集合里」、以及集合之间的并交差运算。这时候 RoaringBitmap 是工业标配别犹豫。bool-hybrid-array适合「数组」你的数据本质是「一个很长的布尔序列」你需要的是「第 i 个位置是 True 还是 False」、以及对这个序列做动态增删改查。这时候它比 RoaringBitmap 更贴合语义。一句话总结RoaringBitmap 存的是「哪些下标有值」bool-hybrid-array存的是「一个完整的布尔数组只是内部自适应稀疏/密集」。前者是集合后者是数组。风控黑名单如果只需要「判断号码是否命中」RoaringBitmap 是首选但如果你的业务需要「维护一个完整的、会动态变化的布尔标记序列」那bool-hybrid-array的数组语义才是对的。6.5 中立 Benchmark四方案三场景实测光说不练假把式。下面是我用tracemalloc和time.perf_counter()在同一台机器上跑出来的中立数据1 亿元素各跑 3 遍取中位数。数字会随机器和数据分布浮动但相对趋势是稳定的。指标方案稀疏1% True中等50% True密集99% True内存原生list[bool]~800MB~800MB~800MBnumpy.ndarray100MB100MB100MBbitarray12.5MB12.5MB12.5MBbool-hybrid-array~4MB~50MB~10MB反向稀疏随机读100 万次原生list[bool]~0.05s~0.05s~0.05snumpy.ndarray~0.01s~0.01s~0.01sbitarray~0.08s~0.08s~0.08sbool-hybrid-array~0.03s~0.02s~0.01s批量更新10 万次原生list[bool]~0.1s~0.1s~0.1snumpy.ndarray~5s每次全量拷贝~5s~5sbitarray~0.3s~0.3s~0.3sbool-hybrid-array~0.05s~0.15s~0.2s怎么读这张表稀疏场景bool-hybrid-array内存最省4MB批量更新最快只动稀疏索引表bitarray内存固定 12.5MB但更新要动整个位图中等密度bool-hybrid-array内存 ~50MB稀疏区 密集区混合批量更新 ~0.15s依然优于bitarray的 0.3s密集场景bool-hybrid-array会反向稀疏——既然 True 占了 99%那少数派就是 False它只记那 1% 的 False 下标内存反而降到 ~10MB比numpy的 100MB 还省。随机读因为要查稀疏索引表略慢于numpy~0.03s vs 0.01s但内存优势明显动态更新numpy是最大输家每次 insert 全量拷贝 100MB10 万次更新要 5 秒bool-hybrid-array稀疏区只动索引表快了两个数量级。结论bool-hybrid-array不是万能的它在稀疏 动态更新的场景下优势最大密集场景它会反向稀疏、内存反而更省真正让它和numpy打平的是均匀分布纯集合运算场景RoaringBitmap 才是对的工具。选型看场景别拿一把锤子砸所有钉子。6.6 缺点与适用边界它也不是银弹前面夸了这么多我得泼几盆冷水。bool-hybrid-array不是银弹它有一堆自己的毛病有些还挺要命。第一换挡抖动thrashing问题依然存在只是被「低频换挡」压住了没根治。因为换挡只在创建时和调用optimize()时发生所以平时数据怎么波动它都不会偷偷换挡——这从根本上避免了「一会儿三档一会儿一档」的疯狂抖动。但如果你频繁手动调用optimize()比如每次更新都调一次那抖动问题还是会回来。optimize()是低频操作别当高频用。第二换挡瞬间的「全量搬运」开销躲不掉。从稀疏切到密集或反过来要把整个内部结构重建一遍。1 亿规模的数据一次换挡就是一次 O(n) 的全量拷贝耗时可能上百毫秒。如果你的业务是「高频小步更新 密度频繁越界」这个换挡成本会吃掉你省下的内存红利。第三它不是线程安全的。文档里明确写了多线程并发读写需要你自己加锁。内部结构在换挡时会整体重建两个线程同时操作轻则数据错乱重则直接崩。多进程场景更别想了——它没有共享内存的分布式形态。第四生态太年轻坑得自己踩。它没有 RoaringBitmap 那种十年工业验证也没有 numpy 那种海量文档和社区。遇到诡异 bugGitHub issue 可能没人回你得自己读源码。我生产环境跑了半年就踩到过一个pickle序列化在特定版本下的兼容问题最后是自己 patch 的。第五密集场景会「反向稀疏」均匀分布才毫无优势。很多人以为「数据密集 退化成 numpy 打平」其实不对——当数据密集到一定程度比如 90% 以上是 True它反而会反向切到稀疏模式因为稀疏模式存的是「少数派」的下标既然 True 占了绝大多数那少数派就是 False它只需要记下那 10% 的 False 在哪内存反而比 numpy 还省。真正让它「毫无优势」的是均匀分布比如 50% True / 50% False——这时候无论记 True 还是记 False 的下标都省不了多少才退化成和 numpy 打平。所以准确说法是密集它反向稀疏均匀它才打平 numpy。第六memory_usage(detailTrue)的数字是它自己算的不是第三方审计的。我前面说过我用tracemalloc独立验证过对得上但「对得上」不代表「永远对得上」。它内部怎么算、有没有在某些边界场景注水我无法 100% 保证。别信我也别信它信你自己的测量。一句话总结它的适用边界稀疏 动态更新 单线程 数组语义这四个条件同时满足它才是最优解。缺一个你可能就该考虑 RoaringBitmap集合场景、numpy均匀分布场景或者干脆自己写个简单封装。选型看场景别拿一把锤子砸所有钉子。bool-hybrid-array 的作者明确承诺现有公开接口不会被删除no removal policy这意味着你的集成代码不会因升级而中断。但请注意接口的行为细节如返回值精度、边界处理仍可能随版本演进生产使用前请务必在自己的数据上完成验证。”