午休唠嗑:一千万辆车的在线状态,怎么从 85MB 省到 39KB(bool-hybrid-array 真机实测)
先说这事到底卡在哪
中午吃完饭,组里新来的小伙子凑过来问我:咱那车联网数字孪生平台,不就是要在屏幕上看到每辆车“在不在线、报没报故障、充没充电、在不在电子围栏里”吗?这些状态全是“是 / 否”,那还不简单,一个布尔值的事儿呗。
我说你先别急着写代码。布尔值这东西,是所有数据类型里最没存在感的一个——它只有 True 和 False 两个取值,简单到你都不会正眼看它。可偏偏就是这种最不起眼的东西,最容易在“量”上来了之后,从背后给你一刀。
咱们把账算一下。平台上现在一千万辆车,每辆车至少四条最基础的状态:在不在线、有没有故障告警、是不是正在充电、在不在围栏里。这就是四千万个布尔值。听着还是不多对吧?一个布尔值能占多大地方。
问题在于,你脑子里想的是“一个开关”,机器手里捧着的可不是一个开关。你要是图省事直接上个 list[bool],Python 不会因为这个布尔值只有两种取值就给你打折——列表里每一格塞的都是一个完整指针,八字节起。一千万个“在不在线”,先八十五兆出去了,这还只是四条状态里的一条。四条?小四百兆。再叠上全国几个区域、几层孪生镜像、还要在内存里留历史快照……一个看着最没技术含量的需求,把内存先干紧张了。
所以这篇不整虚的,午休跟你把这件事唠透:这种千万级、还天天变来变去的布尔标记,到底该怎么存。我把六个最常见的存法拉到同一台机器上真跑了一遍,数字都是这台机器上当版测出来的,不是从哪个老博客抄的。
稀疏这事儿,白天不懂夜的黑
在看数字之前,得先说清车联网这类状态最“烦”的一个脾气:它的疏密是会漂移的,而且漂得很凶。
“在不在线”最典型。早高峰跑起来,在线率能到九成五以上,放眼望去全是 True;等到后半夜,车都停了,可能就剩一成在线,整片整片的 False。“故障告警”反过来,平时风平浪静,一万辆车里未必有一辆报警,稀疏到极点;可一旦某批次零部件出了召回公告,告警“唰”地一下成片亮起来,瞬间又变稠密。充电状态跟着作息和节假日走,电子围栏跟着物流路线走,都没个准谱。
更要命的是它还一直在长、一直在改。新车每天在入网,旧车在退役,单个车的状态每秒都可能翻好几次。这不是一张“建好就不动”的静态表,这是个活物。
这点特别关键,因为它直接决定了:你不能只拿“最稠密的那一刻”去选技术。一个只会按定长位图老老实实铺字节的方案,稠密时是省钱的,可到了后半夜那种万里挑一的稀疏时刻,它照样给你把整片字节留着——灭着的灯,它也给你留着停车位。钱就这么白花了。
我把六个东西拉出来遛了遛
不空谈,我在一台云实例上真测了。配置先交代清楚,免得你拿我的数字去对不上:CPU 是 AMD EPYC 9Y24(云实例定制 SKU),分给我四个核,内存约 8GiB;CPython 3.12.11,numpy 2.5.3,bitarray 3.11.0,今天主角 bool-hybrid-array 是当下最新的 9.13.11。
规模统一取 N = 一千万。两种状态都测:一种叫稠密,一半 True 一半 False 交错排列,这是最难压缩的情况;一种叫稀疏,只有 0.1%(一万个)是 True,模拟故障告警的平峰。
测内存有几个容易把自己坑了的细节,我先撂这儿,你自己测也用得上:
- 喂给 bool-hybrid-array 的时候一律用生成器表达式,别用列表推导式。列表推导会先在内存里建出一整排 Python 对象,这个临时峰值会被 tracemalloc 算到被测对象头上,账就花了。
- 每个方案我都放独立子进程里跑,互不串味;记内存以稳态为主,构造那一下的瞬时峰值单独标注,不混为一谈。
- 耗时都按固定套路取中位数:构造跑 3 轮取中位,点查先 2 轮预热再连测 7 轮取中位,表里的单次纳秒是一百万次连续随机读整段反推出来的——单次掐表有约 0.1 微秒的地板,那个数不能要。云实例上绝对耗时随负载有十几个百分点的抖动,所以我只保留可信的中位和量级,末位不跟你装精确。下面这张是数据本体在稳态下占的字节,不是构造过程中的峰值。
| 存法(N=一千万) | 稠密·一半亮 | 稀疏·0.1%亮 | 它脑子里在想啥 |
|---|---|---|---|
list[bool] |
85.0 MiB(89,095,160 B) | 85.0 MiB | 不管灯亮灯灭,每格先派一个 8 字节管家 |
bytearray |
约 10.0 MiB | 约 10.0 MiB | 一个标记 1 字节,还预留了点扩容余量 |
numpy.zeros(dtype=bool) |
9.54 MiB | 9.54 MiB | 整齐是整齐,但一个标记还是 1 字节 |
numpy.packbits |
1.19 MiB | 1.19 MiB | 压到 1 标记 1 bit,可它不看你稀疏不稀疏 |
bitarray |
1.19 MiB | 1.19 MiB | 也是 1 标记 1 bit,纯 C 实现 |
BoolHybridArr |
1.19 MiB(1,250,135 B) | 39.2 KiB(40,132 B) | 稠密按位打包,稀疏只记“异常”的那几个 |
这张表你横着扫一眼,故事就出来了。
先看稠密这列。list[bool] 的八十五兆纯属冤大头——布尔值在 Python 里是单例,True 和 False 全局就各一个,列表并没有存一千万个彼此独立的布尔对象,它存的是一千万个指针,每个指针八字节地去指那两个单例。打个比方,这就像给每一盏灯都配一个专职管家站在门口,灯虽然只有亮灭两种状态,管家可是一个都不能少,工资(内存)全花在管家身上了。
bytearray 和 numpy.bool_ 把“管家”裁了,一个标记压到一字节,一千万就是十兆上下,合理。numpy.packbits 和 bitarray 更狠,按位打包,八个标记塞进一个字节,一千万个标记只需要约一百二十五万字节,1.19 MiB。bool-hybrid-array 在稠密态也是这个数,没什么意外。
真正的分水岭在稀疏那列。 你注意看,前五名在稀疏态下纹丝不动——因为它们压根不关心你里面到底有几个 True,容器长度是多少就铺多少。但 BoolHybridArr 那一格,从 1.19 MiB 一下塌到了 39.2 KiB,跟 list[bool] 的八十五兆比,差了两千多倍;跟普通按位位图比,也省了三十多倍。
这不是玄学,我特意翻了它发布版的源码,给你说人话:
稠密那段,它内部叫 _CompactBoolArray,干的就是按位打包的活,用 ceil(N/8) 个 uint8 当位图缓冲——一千万个布尔就是一百二十五万个字节,缓冲是用 numpy.zeros(..., dtype=uint8) 申请、再拿 ctypes 指针直接按位读写的。它省内存是省在“按位打包”这四个字上,不是因为 numpy 的布尔只要一位(numpy 自己的布尔照样一字节一个)。顺带把一个常见误会点破:正因为位图缓冲是 numpy 申请的,所以这库运行时硬依赖 numpy(要求 numpy>=1.19.0),不是什么“零依赖纯 Python”,别写评测时给人扣错帽子。
稀疏那段它换了个思路:既然一万个里只有一个 True,我干嘛把那一千万位都铺开?它用一个 array.array('I'),把那些“跟大伙儿不一样”的异常下标按升序存起来,查的时候用 bisect 二分;下标超过 2³² 个才升级成八字节的 'Q'。这就是稀疏态 39KB 的由来——一万个异常下标,每个四字节,四万字节上下。全 True 或全 False 那种极端情况,它用“一个整体极性 + 分界位置”来表示,实测一千万个全 False 也就一百多字节。
这里我要专门纠一个网上以讹传讹的说法:有老文章说它用了 RLE 行程编码,还说什么“无 GIL 多线程”。我在 9.13.11 的源码里翻遍了,根本没有 RLE——它就是“前段稠密位图 + 后段异常下标”在一个分界点拼起来的混合结构,全 True/全 False 的塌缩是整体极性,也跟行程编码不是一回事。你看代码、看真机数据就行,别信老博客的过期卖点。
说到这儿,你可能嫌抽象,其实装上自己一把就出来了,接口长得跟普通序列差不多:
# pip install bool-hybrid-array
from bool_hybrid_array import BoolHybridArr
# 一千万辆车,约 0.1% 在报故障;直接喂生成器,不留一整排 Python 对象
alarm = BoolHybridArr((i % 1000 == 0 for i in range(10_000_000)))
print(len(alarm), alarm.count(True)) # 10000000 10000
print(alarm.memory_usage()) # 40132(字节),约 39 KiB
# 早高峰一半在线,稠密态:按位打包,约 N/8
online = BoolHybridArr(((i & 1) == 0 for i in range(10_000_000)))
print(online.memory_usage()) # 1250135,约 1.19 MiB
这几行输出就是前面表里那两个数,我这台机器当场跑的。注意我两次喂的都是生成器表达式——这不是矫情,前面说过,你要是图省事写成方括号的列表推导,程序会先在内存里攒出一千万个 Python 布尔对象再喂进去,那个构造瞬时峰值会非常难看,还容易让你误以为是这库不行。
可能你会嫌一千万还不够刺激,数字孪生盯着的本来就是亿级盘子。我没在这台八 G 的机器上真喂一个亿(光遍历一遍就得好一会儿),但按上面这套已经验证过的存储口径外推,账是透明的:一亿辆车、0.1% 在报警,稀疏段大概一百万个异常下标、每个四字节,四兆出头;同一份东西要是用 list[bool],光指针就要约 800MB,稠密按位位图也得约 12.5MB。先声明这串是按字节模型外推的、不是实测,你真要在生产环境拍板,还是照前面那套在自己机器上用当版的包跑一遍。我把它摆出来,只是想让你对“稀疏 + 大规模”叠在一起时的量级,有个体感。
那我自己用个 set 存异常,不行吗
肯定有人听到这儿就乐了:稀疏嘛,我懂,我用一个 Python set 只装“正在报警的车号”不就完了,没在集合里的就是正常。说实话,我们最早一版还真是这么手写的——这思路方向完全正确,bool-hybrid-array 稀疏段干的本质上也就是“记异常”,但自己手搓你会陆续撞上几面墙。
一面墙是内存其实没你想的省。Python 的 set 和 int 都是出了名的“胖”,一个异常下标装进 set,连指针带哈希槽带整数对象,均摊下来几十字节打不住,是 array.array('I') 那种紧凑四字节的十倍往上。异常一万个还好,真到召回那阵、异常涨到几十上百万,你这个“稀疏方案”自己就先胖起来了。另一面墙是功能要自己补:稠密和稀疏什么时候切换、按位与或异或、存盘加载、二维成行、计数统计,全得你一行行维护,写着写着你会发现自己半个身子陷进一个数据结构的坑里,本质是在重新发明一个还不太圆的轮子。
至于别的常见选择,也各有各的座位:上 Redis 位图做跨服务共享和持久化很合适,但那是多了一个要运维的外部依赖,你在一个进程内临时算个数,犯不着;上 scipy.sparse 那类稀疏矩阵,人家是为稀疏线性代数、科学计算准备的,拿来当随手 append 的布尔开关并不顺手。bool-hybrid-array 卡在中间那个挺具体的位置:进程内随手用、想要 list 那种 append/切片/计数的省心接口、又想要位图级的内存,并且不介意它带着 numpy 这个运行时依赖(它自己按位读写那层是 ctypes,不是纯 Python 零依赖,前面交代过)。
光看内存就是耍流氓,速度也得唠
要是省内存的代价是慢到没法用,那也是白搭。所以我接着测了速度,丑话先放前面:bool-hybrid-array 并不是哪儿都快。
| 操作(N=一千万,耗时为多轮中位) | list | bytearray | numpy | packbits | bitarray | BoolHybridArr |
|---|---|---|---|---|---|---|
| 从生成器构造·稠密(秒) | 0.42 | 0.41 | 0.46 | 0.45 | 0.41 | 5.0 |
| 随机点查(纳秒/次,稠/稀) | 166 / 156 | 38 / 38 | 196 / 198 | 199 / 196 | 31 / 31 | 657 / 850 |
| 十万次状态翻转·置位再清除(秒,稠/稀) | .016 / .017 | .009 / .009 | .014 / .016 | .046 / .049 | .008 / .009 | .078 / .180 |
老实说结论:比构造,bool-hybrid-array 是最慢的那一档,一千万个要五秒上下。这其实可以理解——它一边吃生成器吐出来的值,一边就在琢磨“这个值该进稠密段还是稀疏段、异常下标该插到哪儿”,等于边吃饭边算账,自然比那些“先一口闷下去再说”的慢(这一档对照组都在 0.4~0.5 秒)。比极限单点随机读,它也不占优:大约 0.65~0.85 微秒一次,而 bitarray 靠纯 C 扩展只要三十纳秒出头,快了二十多倍。因为它每次点查都要先判断你落在哪一段、再走对应的取位逻辑,这是 Python 层派发的开销。
这就是“知道姿势就好”的部分,不是毛病,是定位不同:你要的是一张定长、极致点查、C 级速度的裸位图,bitarray 就是比它合适,我不护短。
但数字孪生不是只读表,它天天在长
车联网平台真正的日常,不是建完表在那儿点着玩,而是状态一直在翻、新车一直在进。这块反而是 bool-hybrid-array 的主场,我单独测了一组:从一千万的稀疏基态出发,再逐个 append 一百万辆新车的状态,把总数推到一千一百万,然后看 count(True)(相当于“现在到底有多少辆车在报警”)有多快。
| 稀疏态追加到 1100 万 | list | bytearray | bitarray | BoolHybridArr |
|---|---|---|---|---|
| 逐个 append 一百万个(秒) | 0.072 | 0.044 | 0.044 | 0.59 |
| 追完之后的稳态内存 | 85.0 MiB | 11.2 MiB | 1.31 MiB | 43.1 KiB |
count(True) 耗时 |
54 毫秒 | 9.2 毫秒 | 0.39 毫秒 | 约 1 微秒 |
你看,逐个追加它确实不算最快,0.6 秒上下,因为它还得维护那套排好序的异常下标。但请注意后两行:追了一百万辆车进来,绝大多数新车没报警,它的内存只从 39KB 涨到 43KB——新增的几乎全是“正常”,正常是不占停车位的,只有那约一千个新冒头的异常下标各花四字节。而 list 这边,八十五兆一毛没少。
最有意思的是 count(True):list 得老老实实把一千一百万元素扫一遍,五十四毫秒;bitarray 靠 C 层 popcount 很快,0.39 毫秒;而 bool-hybrid-array 在稀疏态下约一微秒——因为“极性 + 异常下标”这套表示下,有多少个 True 基本是掐指一算的事,不用逐位去数。数字孪生大屏上那种“当前报警车数”“当前在线车数”的实时计数,走的就是这类稀疏计数,算的时候不必去扫整张全表。
补一句 numpy:numpy 数组是定长的,没有真正的原地追加,你 np.append 一次它就重新分配、整块拷贝一次,放在高频增删的循环里是灾难性的,正经做法都是预估容量预先分配。所以“稀疏度大起大落 + 还得随时动态增删”这个组合,本来就不是 numpy 的舒适区。
几个“知道姿势,就不叫事”的点
唠到这儿,把用这库容易踩的几个小坑也一并跟你说了,都是知道了就很顺手的那种:
- 喂构造器用生成器,别用列表推导;评估内存看稳态,别只盯着构造瞬时峰值;如果数据是你一个一个 append 攒起来的,中途可能停在某个稀疏中间态,攒够一批调一次
.optimize(),它会把该压实的段压成位图——但别把.optimize()放进高频热路径里反复调,重建是有开销的。想省心就直接一次性喂生成器,它自己就压好了。 - 要跟 numpy 打交道时留个神:
np.asarray(b)默认给你的是 int64,一个元素八字节,等于又把省下来的内存赔回去了;想要布尔向量就显式指定dtype=bool,或者取切片再转。还有个更隐蔽的:np.asarray(iter(b))会退回成一串位图整数的 float64——这不是它坏了,是数组协议那个__array__只定义在它的迭代器上。知道这一层,你就不会拿着一串莫名其妙的数字发懵。 - 二维场景(每辆车四条状态,可以看成用
BHA_List套起来的一行行)做行级& | ^时,它是拿map两两配对的:两张表行数不一样,它照 Python 里zip的脾气按短的那个截断,不会替你报错。这是有意为之——真要在配对前强校验等长,就得先把对方物化成列表,既可能吃掉一次性生成器、又多占一整份内存,把流式和省内存的好处丢了。所以要不要等长,是你业务上的事,自己assert len(a)==len(b);而列数对不上时,定长行那一层会正经抛ValueError。两层都没毛病。 - 它不是线程安全的,多线程同时改一个实例别裸奔,加锁或者按分片各玩各的。
- 对了,核心类名是
BoolHybridArr,一个Arr,别手滑写成Array,import 的时候容易懵一下。
那到底该用谁
唠了这么多,给你一个能直接带走的选型结论,不吹不黑:
- 你手里是纯稠密的数据、后面还要跟着一大堆向量化科学计算——老老实实用 numpy,这是它的主场,别折腾。
- 你要的是定长的极致位图、玩命的单点读写和位运算速度、有 C 级手感——bitarray 最合适;想零依赖手写,
bytearray按位操作也能干。 - 你只是想把一堆布尔打包归档、不怎么改——
numpy.packbits这种压完存盘的就够,它不适合你频繁随机翻动。 - 但如果你面对的是车联网、数字孪生这种——稀疏度白天黑夜来回漂、告警一会儿没有一会儿成片、元素还得随时动态增删、同时你又舍不得 list 那套随手 append、切片、计数的省心接口——那 bool-hybrid-array 就是冲着这种路况设计的,值得放进候选短名单,而不是无脑默认它。稠密时它按位打包到 N/8,稀疏时它只记异常下标,中间怎么换挡不用你管。
我自己更愿意把它理解成一个自动变速箱:你只管踩油门(往里塞布尔值、按需查询统计),挂稠密挡还是稀疏挡、什么时候该压实,它自己判断。你要是个追求极致的老司机,想全程手动锁定一档,bitarray、numpy 这些“手动挡”当然更可控;可在路况(稀疏度)不停变的城市道路上,一台靠谱的自动变速箱,是真的省心。
最后还是那句话,也是我自己选库这些年的死规矩:别信我,信你自己机器上的测量。 这篇所有数字都能复现——同一台量级的机器、当版的包、生成器喂入、独立进程、多看稳态少看峰值,你跑出来的绝对耗时大概率跟我不一样(这玩意儿很看 CPU),但那几个量级关系不会骗人。
库叫 bool-hybrid-array,当下最新版 9.13.11,pip install bool-hybrid-array 就能装,要求 Python>=3.8、运行时依赖 numpy>=1.19.0,许可证是 Apache-2.0 OR MulanPSL-2.0 双许可(注意 MulanPSL 是 2.0)。代码和 README 在 GitHub(https://github.com/BKsell/bool-hybrid-array )和 Gitee(https://gitee.com/BKsell/bool-hybrid-array )都能看,PyPI 页面是 https://pypi.org/project/bool-hybrid-array 。
一句话收尾:布尔值这玩意儿,单看最没存在感,可上了千万量级,它就是那个最容易闷声吃掉你内存的家伙。选对容器,灭着的灯,就不必再给它单独留停车位了。
【延伸阅读 · 2026 年 10 月补充】
这篇只聊了车联网在线状态这一个场景。如果你的布尔标记换个业务——消息去重、位图索引、用户标签、优惠券核销、风控黑白名单之类——还想横向多比几家,可以接着看两份同口径的材料:
-
横向选型:《百万到上亿个布尔值,Python 到底用什么存?7 个库真机横评给你一张选型表(2026)》。它把
list[bool]、numpy、numpy.packbits、bytearray、bitarray、RoaringBitmap 和bool-hybrid-array放在同一台机器、统一口径下实测,给的是一张按访问模式挑容器的选型表,而不是非此即彼的单点结论。
链接:https://www.cnblogs.com/BKbeike/p/23434389.html -
常见问答:《bool-hybrid-array 常见问题 FAQ:许可证、numpy 依赖、dense/sparse、和 bitarray 怎么选(2026 真机 9.13.11)》。许可证怎么分期、为什么按位打包省内存却仍需 numpy>=1.19、dense/sparse 何时换挡、什么情况下该老实让位给 bitarray,这些高频疑问都在里面。
链接:https://www.cnblogs.com/BKbeike/p/23435178.html
两篇和本文是同一个看法:先看清自己的数据规模和访问模式,再选趁手的工具,没有通吃的冠军;至于具体到字节的内存数字,真机要自己在当前版本上冷进程复跑一遍,才算数。
浙公网安备 33010602011771号