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

基于 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% 反向极性塌缩

几个值得讲清楚的点:

  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() 重新划分:

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 双协议开源。

相关推荐
2601_966949652 小时前
如何根据研究的起止时间截取分钟级行情?Python 实战与 QuantDash 数据处理指南
开发语言·python·pandas·量化交易·1024程序员节·股票数据·quantdash
Flynt5 小时前
我把微信运动的排行榜和定点推送复刻了一遍:100万次步数更新只要4秒,坑在别处
redis·后端·性能优化
在世修行7 小时前
智学文枢:页面切换与跨页数据预填
python·桌面应用·signal·1024程序员节·pyside·跨页跳转·状态预填
wuyk5557 小时前
防逆向基础:在量产固件中关闭调试接口并设置读保护(RDP)的正确姿势
stm32·单片机·嵌入式硬件·性能优化·重构
2601_962885727 小时前
如何用 Python 把 K 线重采样成任意周期?
开发语言·python
打工仔折腾 AI7 小时前
在 Ubuntu ARM64 部署 Q1 V3.0 MuJoCo 模型:加载、单步推理与执行器响应解析
linux·运维·人工智能·python·深度学习·ubuntu·性能优化
信誓旦旦的程序猿8 小时前
【Python量化实战宝典 #21】北向资金跟随手册:聪明钱择时信号(Python 可跑)
python·1024程序员节·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
严谨的麻辣烫8 小时前
静态 ISP 代理请求超时怎么排查?Python 与 curl 实战
python·http·requests·1024程序员节·网络排障
KeepRobots10 小时前
11-01 温哥华不回拨:用 60 行 Python 查你的工单系统会不会差一小时(KeepRobots 工程笔记)
python