ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

亿级用户签到位图选型复盘:用 bool-hybrid-array 扛冷启动打卡,也把它和 Roaring/Redis 的边界测清楚了

亿级用户签到位图选型复盘:用 bool-hybrid-array 扛冷启动打卡,也把它和 Roaring/Redis 的边界测清楚了

基于 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 MB10^7 bit = 1.25×10^6 B
BHA(见下,随稀疏度变)136 B ~ 1.19 MBdense/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(冷启动)0136 B省 ~100%极性塌缩
万分之一(1/10000)1,0004,132 B(≈4 KB)省 99.96%dense 96B + sparse 4036B
1/32312,5001,250,132 B(≈1.19 MB)与位图持平落到 dense 位图
五五交替500 万1,250,135 B(≈1.19 MB)与位图持平dense 位图
全 True(全员打卡)1000 万136 B省 ~100%反向极性塌缩

几个值得讲清楚的点:

  1. 冷启动/深夜的全 False 和极端活动日的全 True,都塌缩到 136 B,这是固定位图给不了的。
  2. 万分之一打卡率下,1000 万用户在内存里只要约4 KB(memory_usage明细:密集区 96 B + 稀疏区 4036 B),相对原生list省99.9948%、相对 numpy 省99.9587%。
  3. 到 1/32 附近它就主动切回 dense 位图了(约 1.19MB),因为稀疏区是"每个异常下标 4 字节",312,500×4 ≈ 1.25MB 已经不比对整段位图打包便宜。这个盈亏点大概就在 p ≈ 1/32,和理论一致。换句话说:越稀疏它越赚,稠密到一定程度它就老老实实退化成位图,不会硬撑稀疏结构。
  4. 点查仍是数组式随机访问,不需要像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_BHAAsk_BHA
100,000532 B25,000 B(≈24 KB)0.42 s0.26 s
1,000,000532 B250,000 B(≈244 KB)23.4 s11.0 s
全 False(N=100 万)136 B1,000,000 B(=N)——
全 True(N=100 万)136 B250,000 B(≈N/4)——
10,000,0004 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 双协议开源。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进