基于 bool-hybrid-array9.13.11真机实测,环境
requires-python >= 3.8,pip install -U bool-hybrid-array。仓库:PyPI · GitHub · Gitee,License 为 Apache-2.0 OR MulanPSL-2.0。本文所有内存/耗时数字均在本机实跑得到,口径写在前面,不做“一库通吃”的夸大。
背景:签到/打卡位图的真实冷热分布
做一个日活千万级 App 的签到功能,最朴素的结构是“用户 ID → 今天是否打卡”的布尔位图。直觉上这是位图(bitmap)的主场,但真实数据有个很坑的特征:
- 每天打卡是极少数。非强运营日,当天打卡率可能只有万分之一甚至更低;凌晨冷启动那一刻,整张图几乎全是
False。 - 越往天末越密。晚高峰后打卡率升高,极端活动日可能逼近全员。
- 要支持随机点查(用户进页面看自己打没打卡)、批量重建(离线把昨日结果灌进来)、以及落盘冷启动。
如果用固定位图,无论今天几个人打卡都得为全部用户付一份位图内存;如果用纯稀疏集合,到了高打卡率又不划算。我想找一个能随稀疏度自适应、且点查仍是 O(1) 的东西,于是把bool-hybrid-array拉进了选型,和list、numpy.bool_、bitarray放在一起实测。下面是 1000 万规模的真实结果。
一、四种承载方式,1000 万布尔到底各占多少内存
口径:N = 10,000,000;list[bool]用sys.getsizeof,numpy 用.nbytes,bitarray 按位折算,BHA 用库自带的memory_usage(detail=True)。
importsys,numpyasnp,bitarrayfrombool_hybrid_arrayimportBoolHybridArr,FalsesArray,TruesArray N=10_000_000print(sys.getsizeof([False]*N)/1024/1024)# list[bool]print(np.zeros(N,dtype=bool).nbytes/1024/1024)# numpy.bool_print((len(bitarray.bitarray(N))+7)//8/1024/1024)# bitarray 1bit实跑结果(MB):
| 承载方式 | 1000 万内存 | 说明 |
|---|---|---|
list[bool] | 76.29 MB | 每个元素是完整 Python 对象,开销最大 |
numpy.bool_ | 9.54 MB | 紧凑,但按 1 字节/值,不是 1 bit |
bitarray(1 bit/值) | 1.19 MB | 真位图,约等于理论下限 N/8 |
| 理论位图下限 | 1.19 MB | 10^7 bit = 1.25×10^6 B |
| BHA(见下,随稀疏度变) | 136 B ~ 1.19 MB | dense/sparse 混合,自动两段切换 |
关键差距在最后一行:普通位图的下限是 N/8,和有多少人打卡无关;而 BHA 的占用会跟着“异常位”数量走。
二、随打卡率变化的真实内存:它确实在 dense / sparse 两段之间切换
我用 numpy 按不同稀疏度造数据再喂给BoolHybridArr(批量重建走 numpy 向量构造,比逐个下标赋值快得多——逐个b[i]=True在百万级会非常慢,这也是个实测出来的坑):
importnumpyasnpfrombool_hybrid_arrayimportBoolHybridArr,FalsesArray,TruesArraydefmem(b):returnb.memory_usage(detail=True)['总占用(字节)']N=10_000_000print(mem(FalsesArray(N,Type=bool)))# 全 Falsea=np.zeros(N,dtype=bool);a[np.arange(0,N,10000)]=Trueprint(mem(BoolHybridArr(a)))# 万分之一命中a=np.zeros(N,dtype=bool);a[np.arange(0,N,32)]=Trueprint(mem(BoolHybridArr(a)))# 1/32 命中a=np.zeros(N,dtype=bool);a[::2]=Trueprint(mem(BoolHybridArr(a)))# 五五交替print(mem(TruesArray(N,Type=bool)))# 全 True实跑(字节):
| 打卡分布 | True 数 | BHA 内存 | 对比 numpy | 内部形态 |
|---|---|---|---|---|
| 全 False(冷启动) | 0 | 136 B | 省 ~100% | 极性塌缩 |
| 万分之一(1/10000) | 1,000 | 4,132 B(≈4 KB) | 省 99.96% | dense 96B + sparse 4036B |
| 1/32 | 312,500 | 1,250,132 B(≈1.19 MB) | 与位图持平 | 落到 dense 位图 |
| 五五交替 | 500 万 | 1,250,135 B(≈1.19 MB) | 与位图持平 | dense 位图 |
| 全 True(全员打卡) | 1000 万 | 136 B | 省 ~100% | 反向极性塌缩 |
几个值得讲清楚的点:
- 冷启动/深夜的全 False 和极端活动日的全 True,都塌缩到 136 B,这是固定位图给不了的。
- 万分之一打卡率下,1000 万用户在内存里只要约4 KB(
memory_usage明细:密集区 96 B + 稀疏区 4036 B),相对原生list省99.9948%、相对 numpy 省99.9587%。 - 到 1/32 附近它就主动切回 dense 位图了(约 1.19MB),因为稀疏区是"每个异常下标 4 字节",312,500×4 ≈ 1.25MB 已经不比对整段位图打包便宜。这个盈亏点大概就在 p ≈ 1/32,和理论一致。换句话说:越稀疏它越赚,稠密到一定程度它就老老实实退化成位图,不会硬撑稀疏结构。
- 点查仍是数组式随机访问,不需要像
set/有序表那样 O(log n) 查找。
这套"低索引区 uint8 位图打包 + 高索引异常区逐下标 4 字节、按 split_index 和极性自动两段切换"的模型,正好匹配签到数据"平时极冷、偶发高热"的分布。注意它的稀疏侧是逐异常下标存储,不是 RLE 行程编码——这两件事别混。
三、optimize()能重划边界,但别放在热路径
导入或增删之后,边界不一定最优,可以调optimize()重新划分:
b=BoolHybridArr(a)# 比如刚批量灌入b.optimize()实测在 N = 1000 万上optimize()耗时约 48 秒。它是 O(n) 的全量重排,千万级要几十秒量级。所以正确姿势是:在离线批量重建、或低峰维护窗口里调一次,而不是每次用户打卡后都 optimize。热路径上靠它写入时的自动切换即可。
四、落盘冷启动:这里有个必须如实说的坑(.bha 不是稀疏压缩快照)
我原本以为既然内存里能压到几 KB,落成.bha也应该是个几 KB 的快照、冷启动秒回。实测 9.13.11 的Create_BHA/Ask_BHA(BHA 格式,9.10.0 引入)并不是这么回事,这也是这篇复盘最想提醒后来者的地方:
frombool_hybrid_arrayimportCreate_BHA,Ask_BHA Create_BHA('checkin.bha',b)# 落盘ld=Ask_BHA('checkin.bha')# 冷启动读回不同规模实测(都是"约 100 人打卡"的极稀疏对象,内存里仅 532 B):
| 规模 N | 内存占用 | .bha文件大小 | Create_BHA | Ask_BHA |
|---|---|---|---|---|
| 100,000 | 532 B | 25,000 B(≈24 KB) | 0.42 s | 0.26 s |
| 1,000,000 | 532 B | 250,000 B(≈244 KB) | 23.4 s | 11.0 s |
| 全 False(N=100 万) | 136 B | 1,000,000 B(=N) | — | — |
| 全 True(N=100 万) | 136 B | 250,000 B(≈N/4) | — | — |
| 10,000,000 | 4 KB | 存盘 >60 s 未完成(随 N 线性) | — | — |
结论很直接:这个版本的.bha文件大小主要随 N 走(全 False 甚至按 N 字节写),并不保留内存里那套极端稀疏压缩;存/读耗时也基本随 N 线性增长,千万级谈不上"秒级冷启动"。所以:
- 想要的是"内存里随打卡率自适应省内存、点查 O(1)"——BHA 非常合适;
- 想要的是"把极稀疏大图压成几 KB 快照、跨进程秒级冷启动"——别想当然地指望
.bha,要么按自己的规模先实测,要么单独导出异常下标表,要么评估更新的通用描述符BHAX_Descriptor(9.12.0 引入,.bhax)。落盘这件事请以你自己数据规模的实测为准,本文只报我测到的现象。
五、那到底什么时候用它,什么时候上 RoaringBitmap / Redis
不踩一捧一,按场景给判据:
纯 Python、单机、等长用户 ID 掩码、打卡率冷热悬殊、主要诉求是省内存 + 随机点查 + 原地做交并补:BHA 很省心。它还支持
& | ^ ~ << >>等位运算和二维BHA_List,多份活动标签求交求并不用搬家。ID 高度不连续、需要变长容器自适应压缩、频繁做大规模集合交并差:专业压缩位图RoaringBitmap更对口(array/bitmap/run 容器自动选择)。BHA 的位运算要求两段等长,异长会直接抛
ValueError,它不是通用压缩位图引擎。分布式、高并发、要按天天然分片、多服务共享:直接用Redis Bitmap(
SETBIT/GETBIT/BITCOUNT,一天一个 key),别在单机进程内存里硬扛。稠密且要重度向量化数值计算:留 numpy;只追求最紧的 1-bit 且逻辑简单:bitarray 也够。
我自己的落地形态是:在线服务里用 BHA 承载当天签到(吃它冷启动和极稀疏的内存优势),离线低峰批量重建后做一次optimize;落盘冷启动不依赖.bha的稀疏压缩,而是按上面的实测另行选择;跨天/分布式统计交给 Redis Bitmap。
本文基于 bool-hybrid-array 9.13.11 真机实测,内存与耗时随机器、数据分布会有浮动,所有代码可直接复跑;版本特性以各版本 changelog 与 PyPI 元数据为准。项目以 Apache-2.0 OR MulanPSL-2.0 双协议开源。