基于 bool-hybrid-array 9.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)。
python
import sys, numpy as np, bitarray
from bool_hybrid_array import BoolHybridArr, FalsesArray, TruesArray
N = 10_000_000
print(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 在百万级会非常慢,这也是个实测出来的坑):
python
import numpy as np
from bool_hybrid_array import BoolHybridArr, FalsesArray, TruesArray
def mem(b): return b.memory_usage(detail=True)['总占用(字节)']
N = 10_000_000
print(mem(FalsesArray(N, Type=bool))) # 全 False
a = np.zeros(N, dtype=bool); a[np.arange(0,N,10000)] = True
print(mem(BoolHybridArr(a))) # 万分之一命中
a = np.zeros(N, dtype=bool); a[np.arange(0,N,32)] = True
print(mem(BoolHybridArr(a))) # 1/32 命中
a = np.zeros(N, dtype=bool); a[::2] = True
print(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() 重新划分:
python
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 引入)并不是这么回事,这也是这篇复盘最想提醒后来者的地方:
python
from bool_hybrid_array import Create_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 双协议开源。