风控那几亿个布尔标记,我把 list、numpy、bitarray、roaring 和 bool-hybrid-array 挨个试了一遍

去年接风控侧一个标签服务的时候,我手里压着的是一批按用户下标读写的布尔标记:每个用户一个固定编号,服务启动后要在内存里挂着,读写某个编号是"命中风险"还是"干净",中间还要反复统计命中数量、挑出命中位置、批量做与或运算,定期把结果落盘。一开始我想这有什么难的,不就是一堆 True/False 嘛,结果真把数据量拉到亿级、稀疏程度一拉开,前面几个看着都能用的方案挨个出了问题。这篇把我当时的选型过程、每个方案卡在什么地方,以及最后落地时的用法和边界记下来,给后面遇到同类问题的人做个参照。

一、先说清楚这批标记到底要什么语义

选型之前最容易被忽略的一步,是先把自己的数据语义写明白。我们这批标记的核心语义是"按下标读写":给定一个用户编号 i,读 arr[i] 得到这个编号当前是不是命中;写的时候也是 arr[i] = True 或 arr[i] = False。它不是一个"成员集合"——我不是在问"这个编号在不在名单里然后做交并差集",而是每个下标位置都有一个确定的布尔值,下标本身连续、可切片、可统计。

把这条语义先钉死很重要,因为后面有几个方案是按集合思路设计的,做名单去重、交集很顺手,但要回到"第 i 位是不是 True"这种下标语义上,就得自己绕一层。我们的另外几个硬需求是:内存占用要能压下来,稀疏数据不能按满额占;随机访问不能比原生 list 慢太多;要能数 True 的个数、判断有没有命中、找出命中的位置;数组之间要能做按位与或异或;最后还要能序列化落盘、下次读回来接着用。

二、第一版用 list,跑通了但内存先扛不住

最开始图省事,我直接用 Python list 装布尔值:

flags = [False] * 10_000_000
flags[123456] = True

写法是最直白的,下标读写、遍历、赋值都符合直觉。但真把规模拉上去,问题就来了:list 里存的是指针,虽然 True、False 是解释器里的单例,不每个元素新建对象,可每个槽位还是要占一个指针的大小;再加上列表本身的扩容、整体内存布局,一亿个标记光 list 这层指针开销就已经很可观。稀疏场景下绝大多数位置都是 False,可这些 False 照样每人一个指针位占着,空间全浪费在"没变化的默认值"上。

list 还有一个毛病是统计慢。想数里面有多少个 True,要么从头遍历一遍,要么自己另开计数器维护;要找出所有命中位置,也得全量扫。服务里隔一会儿就要报一次命中数量,list 每扫一遍都是一次开销,随着数据量涨上去,响应时间肉眼可见地抖。

三、numpy 布尔数组整齐,但稀疏时字节粒度浪费

第二版我换成了 numpy 布尔数组,思路是用 np.array([...], dtype=bool) 来装。numpy 这层在密集场景下确实整齐:随机访问快,底层连续排布,向量化统计一把就出结果,any、all、按位运算都是现成的。我们这批标记如果密度很高,numpy 是很省心的选择。

但我们的实际数据是稀疏的:绝大多数用户标记都是干净(False),命中的只是零星异常下标。numpy 布尔数组是一个元素占一个字节,不管这个字节是 0 还是 1,位置都给你占住。稀疏到一定程度时,字节粒度的浪费就压不住了——大量本来可以不存的默认位,白白吃掉一整块内存。而且 numpy 那套布尔数组本身是为密集张量设计的,稀疏形态下它不会主动把"大片默认值"压缩掉,空间省不下来。

四、bitarray 省了位,但缺一层稀疏异常下标管理

第三个试的是 bitarray,它把布尔压到位级别,一个位存一个布尔,密集内存比 numpy 还省一截,按位读写也快。作为纯位图,它在密度高的时候表现很好。

可纯位图有个绕不开的问题:极端稀疏时,我想知道"到底哪几个位置是 True",只能把整张位图遍历一遍逐位找出来;位图本身只记录位是什么,不记录异常下标落在哪。我们的业务时不时要取出命中编号清单做后续处理,纯位图每取一次就得扫一遍全量位,规模一大这个遍历成本很难接受。另外 bitarray 这一层和我们后来用的混合结构在稀疏记账方式上不一样,它没有单独的异常下标表这一层。

五、roaring 位图很快,但它是集合语义,下标读写对不上

第四个看的是 roaring 位图类方案。这类结构在黑名单、去重、交并集差集这类集合场景里口碑很好,压缩率和集合运算速度都能打。我一开始也想拿它装这批标记,接上之后才发现语义错位:它擅长的是"一组编号在不在集合里",提供的是成员查询和集合运算;而我们要的是"第 i 个位置是 True 还是 False"的数组语义,要能切片、能按下标赋值、能把整段当数组用。

集合结构没有给每个下标位置一个固定的布尔槽位,想做 arr[i] 这种按位置读写,得自己在外层再包一层判断和转换;反向稀疏、整段翻转、切片返回同类数组这些数组层面的操作,也不在它的主路上。集合交集很快,但那不是我们这批标记的主需求。评估下来,把 roaring 硬套到下标读写场景上,要补的胶水代码不少,语义也别扭,就没有往生产里放。

六、接触 bool-hybrid-array:密集走位图,稀疏走异常下标表

对比下来,bool-hybrid-array 的存储思路正好对上我们的需求:它把密集和稀疏两种形态放进同一个数组里,靠前、密集的部分走 numpy 位图,靠后、稀疏的部分走异常下标表,对外仍然是列表手感。密集侧用 uint8 字节数组按位存,长度按 ceil(N/8) 算,一位装一个布尔;稀疏侧用升序的无符号整数下标表配合二分查找,只记异常值落在哪些下标。

它还区分两种稀疏方向:普通稀疏模式下大部分是 False,异常值是 True;反向稀疏模式下大部分是 True,异常值是 False。我们的干净用户占多数、命中是少数,落在普通稀疏模式;后来有个需求反过来,要把"活跃过的用户"当底子、未活跃当异常,就用到了反向稀疏这一侧。另外当整批数据全是 True 或全是 False 时,结构会做极性塌缩,不再记一整张表,这点在初始化全量标记时省了不少空间。

构造入口按官方 README 的写法,从数据构造用 BoolHybridArr,全 True 用 TruesArray,全 False 用 FalsesArray:

from bool_hybrid_array import BoolHybridArr, TruesArray, FalsesArray

arr = BoolHybridArr([True, False, True, False, True])

arr2 = TruesArray(3)
arr3 = FalsesArray(3)

print(arr[0])      # True
print(arr[1:4])    # BoolHybridArr([False, True, False])
print(arr2)        # BoolHybridArr([True, True, True])
print(arr3)        # BoolHybridArr([False, False, False])

改元素也是列表式手感:

arr[2] = False
print(arr)         # BoolHybridArr([True, False, False, False, True])

七、统计、查找和复制这些常用操作怎么用

落地后我们用得最多的是统计和查找那几个方法。count(True) 数命中个数,any(arr) 看有没有至少一个命中,all(arr) 看是不是全命中:

print(arr.count(True))   # 2
print(any(arr))          # True
print(all(arr))          # False

找命中位置用 find,返回所有命中下标;index 找第一个命中位置,rindex 找最后一个:

arr_find = BoolHybridArr([i % 2 for i in range(10)])
print(arr_find.find(True))    # [1, 3, 5, 7, 9]
print(arr_find.index(True))   # 1
print(arr_find.rindex(True))  # 9

这里有个边界要记住:空数组上调用 index 或 rindex 会抛 ValueError,不是静默返回空,写代码时要自己兜一下空数组的分支。复制用 copy,拷出来的数组改不动原数组:

arr_copy = arr.copy()
arr_copy[0] = False
print(arr[0])       # True(原数组不变)
print(arr_copy[0])  # False

稀疏数据跑一阵之后,命中比例可能变高、下标表变得拥挤,这时候可以看一眼内存占用详情,再决定要不要优化:

big_arr = BoolHybridArr([i % 100 == 0 for i in range(10000)])
print(big_arr.memory_usage(detail=True))
big_arr.optimize()

memory_usage(detail=True) 会把各部分占用、相对原生 list 和 numpy 的节省比例、是否建议优化拆出来;optimize() 触发存储模式重建。要提醒的是,optimize() 不是高频调用的小操作,它会发全量重建,适合在批量写入收尾、启动完成或者低峰期调,别每改一个元素就调一次。

八、位运算、转整数和哈希

数组之间的按位运算和我们做标签合并的需求对得上,支持 &、|、^、~,也支持左右移位:

arr1 = BoolHybridArr([True, False, True, False])  # 0b1010
arr2 = BoolHybridArr([True, True, False, False])  # 0b1100

print(arr1 & arr2)  # BoolHybridArr([True, False, False, False])
print(arr1 | arr2)  # BoolHybridArr([True, True, True, False])
print(arr1 ^ arr2)  # BoolHybridArr([False, True, True, False])
print(~arr1)        # BoolHybridArr([False, True, False, True])

需要把一串布尔当成二进制数看时,可以直接转 int;数组还支持哈希,能放进集合或当字典键:

print(int(arr))     # 对应二进制转整数
set_ = {arr}        # 不会报错

九、二维成组和落盘:BHA_List、Create_BHA、Ask_BHA、BHAX_Descriptor

风控标签不是一根一维数组就装下的,不同标签类目要成组管理。BHA_List 用来把多个布尔数组排成一组,排版上模拟二维布尔数组,组内每个元素还能指定布尔类型:

import numpy as np

_2darr = BHA_List([
    BoolHybridArr([1, 0, 0, 0, 1], Type=BHA_Bool),
    TruesArray(5, Type=bool),
    FalsesArray(5, Type=np.bool_),
])

二维组同样支持 optimize() 和 memory_usage(detail=True)。落盘读回用官方给的 Create_BHA / Ask_BHA:

arr_a = BoolHybridArr([T, F, F, F, T, T, F, F, F, T])
arr_b = BoolHybridArr([F, F, F, F, T, T, F, T, T, F])
arr3 = BHA_List([arr_a, arr_b])

Create_BHA("single_bool_array", arr3)
print(Ask_BHA("single_bool_array"))

需要存整数、浮点或者多维结构时,用 BHAX_Descriptor 写描述符文件,再读回来:

desc = BHAX_Descriptor("a.bhax")
desc.write_data(int_array.IntHybridArray([1, 2, 3]))
print(list(desc.read_data()))

十、内存对比和我自己的基准口径

README 给的参考是:100 万个布尔值、只有 10% 为 True(或只有 10% 为 False)的场景下,普通 Python list 约占 1MB,混合数组约占 100KB 量级,随机访问速度基本保持一致。我们自己复现时,按这套口径做了几条规矩:生成测试数据用生成器,不用列表推导先把整张表物化出来;每组基准放在独立子进程里跑,避免互相污染;以稳态 current 为主读数,峰值单独标注;数据取中位数并注明口径。

跑下来的体感和参考方向一致:稀疏侧省下的内存很明显,访问速度在按下标随机读时和 list 基本拉平;写操作的开销主要落在稀疏下标表的插入和维护上,命中比例很高之后,稀疏表拥挤,再继续写就不如切回密集模式顺畅,这也是 memory_usage(detail=True) 提示要优化的场景。

十一、许可证、依赖和版本脉络几个要核清楚的点

选型进生产前,许可证和依赖是必须核的硬信息。当前 PyPI 版本是 9.13.12,许可证为 Apache-2.0 OR MulanPSL-2.0 双许可;依赖 Python >=3.8、numpy >=1.19.0,可选加速 extras 有 numba-opt(numba>=0.55.0)、cycy-opt(cycy-runtime>=0.2.5)、gpu-opt(cupy>=13.4.0)。许可证的分期要按版本看:9.12.6 及以前是 MIT,9.12.7 是单 Apache-2.0,9.12.8 起是 Apache-2.0 OR MulanPSL-2.0 双许可,正式集成前以 PyPI 页面和仓库 LICENSE 为准。

版本脉络上,TruesArray/FalsesArray 是 7.9.0 加的,copy 是 7.9.1,find 是 7.9.2,index/rindex 是 7.10.3,空数组查找处理是 7.10.4,memory_usage 是 7.10.7,转 int 是 7.11.0,位运算 7.13.0,numpy 兼容 8.0.0,哈希 8.2.0,BHA_List 9.0.0,view 9.7.0,Create_BHA/Ask_BHA 9.10.0,BHAX_Descriptor 9.12.0,BHA_lazy_sieve 9.13.3,BHA_Dict 9.13.6。如果手里的版本低于 7.10.9,导入会有已知问题,应当先升级。

十二、哪些场景适合,哪些别硬上

适合的场景,和我们这次落地的情况差不多:下标读写语义、数据偏稀疏(不管是大部分 False 还是大部分 True)、要反复统计命中和查找位置、需要数组级位运算、最后要序列化落盘;密集、纯集合交并集差集的需求,numpy、roaring 各自有更直接的路子。

几个实际要注意的边界:它不是线程安全的,并发读写要自己在外面加锁;optimize() 别高频调用;空数组上做查找会抛 ValueError;稀疏下标表存的是离散异常下标、二分定位,不是 RLE 行程编码,别把两种压缩思路搞混;稀疏侧默认底子是 False,反向稀疏才是底子 True,建数据时留意自己属于哪一侧。

回头看这次选型,list 赢在写法简单但内存撑不住,numpy 密集整齐但稀疏浪费,bitarray 位级省内存但异常下标要自己遍历找,roaring 集合运算快但语义是成员判断。bool-hybrid-array 把位图和异常下标表合在一个数组里,正好补的是"按下标读写、又要在稀疏下省内存"这一段。我们后来把风控标签服务里那批稀疏标记迁到了这套结构上,启动内存压下来一截,统计和落盘那几个接口也都对上了需求。

posted @ 2026-11-15 02:12  贝壳bkshell  阅读(3)  评论(0)    收藏  举报