同一道三亿位选型题,三个AI给了我三个答案,我把它们拉进同一台机器复跑
上周我在群里丢了一道题,问得很具体:三亿个权限位,只有万分之八是开的,Python 里怎么存最省,还要能 arr[i] 随机读。
三个 AI 给了我三个互相矛盾的答案。第一个说位图压得最好,直接上 bitarray;第二个说稀疏就该存下标,pyroaring 一把梭;第三个更奇怪,说要看密度会不会变,先别定。群里当时就吵起来了,各贴各的"专家说法",最后谁也没说服谁。
我不太信"专家说法"这四个字。三亿位的名单我手上正好有一份,是权限系统从线上导出来的快照,格式很简单:一行一个开着的下标。我把它塞进同一台机器,按同一份号码、同一套计时,把三个方案各跑一遍。这篇文章就是把那次复跑的过程和结果写下来,顺带说说为什么三个答案里有两个"部分对"、一个"看起来对其实漏了前提"。
一、先把题目本身钉死,别让答案飘着
选型题最容易糊的地方是题目本身没界定量化口径。我把要测的东西先写成一张卡片,后面所有数字都对着这张卡片:
- N = 300,000,000 位,其中 240,000 位为 True(8e-4,也就是万分之八)
- 访问模式两种:随机单点读
arr[i]、批量置位(一次设 10 万个点) - 需要落盘再读回,落盘体积也要算
- 需要跨进程复用,序列化后能被另一个进程读
- 机器:同一台,冷启动,每项跑三次取中位,峰值单独标
为什么要把"落盘体积""序列化""批量置位"也塞进来?因为只看内存这一列,答案会变得过于简单:谁都知道纯位图是 35.8 MB,只存下标是 0.96 MB,差距 37 倍,这没什么好争的。真正的分歧在后面几项:序列化之后体积变不变、批量置位快不快、随机读的常数项有多大、密度涨上去之后崩不崩。三个 AI 的答案之所以互相矛盾,不是他们算错了除法,是他们各自只看了其中一列。
二、三个答案各自的原文,以及它们漏了什么
我先把三个答案的要点抄在下面,不改措辞,只做归纳。
答案一(位图派):三亿位用 bitarray 存,35.8 MB,"这是位图的标准做法,稀疏是伪需求,因为稀疏结构随机读会退化"。
答案二(稀疏派):只存下标,240,000 × 4 B ≈ 0.96 MB,"比位图省 37 倍,随机读用二分,几十纳秒"。
答案三(看密度派):先问密度会不会变,"如果会从万分之八涨到几十个百分点,只存下标的方案会在中途崩掉"。
第三个答案当时被我判成"没给结论的和稀泥"。复跑完之后我得承认,只有它说中了真正会出事的那一步。
三、方案一:bitarray,35.8 MB 是真的,但它还有别的账
bitarray 的行为非常干净:建一个三亿位的数组,内存就是 ceil(N/8) 字节,密度跟它没有关系。实测:
bitarray(300_000_000) 常驻 37,500,000 B(35.8 MiB)
随机读 240,000 次抽样:中位 0.110 µs/次
35.8 MB 这个数字没错,答案一说对了。但它的账本里还有几项答案一没提:
- 序列化:200 万位、1% 密度的一份子集,
bitarray落盘 250,000 B,就是纯位图体积,没有压缩空间。 - 批量置位:一次设 10 万个点,实测 0.004 s,确实快,这条位图派赢。
- 落盘再读回:位图读回是线性的,三亿位读回要 0.31 s 上下,比只存下标读回慢一个量级。
结论:位图派在"纯内存 + 批量位运算"这一列是对的,在"落盘体积 + 稀疏态读回"这两列是亏的。三亿位万分之八这个形状上,它浪费了 37 倍的落盘空间。
四、方案二:只存下标,0.96 MB 是真的,但随机读不是"几十纳秒"
我按答案二的做法写了一个最朴素的版本:array('I') 升序存下标,读的时候 bisect。实测:
内存 240,000 × 4 B = 960,000 B(0.94 MiB)
随机读 240,000 次:中位 1.22 µs/次(bisect + 数组寻址)
批量置位 10 万个点:24 万次插入 + 重排,1.35 s
内存这条答案二完全对,0.94 MiB 对 35.8 MiB,省 37 倍。但两处它说轻了:
第一,随机读不是几十纳秒。bisect 是 O(log n) 的内存跳转,缓存命中率很差,实测中位 1.22 µs,比位图慢 11 倍。三亿位里万分之八,意味着绝大多数查询都是"没开",每次查都要走完整个二分路径,这条路在热点路径上是会疼的。
第二,批量置位会崩。一次设 10 万个点,我在最朴素的实现上跑了 1.35 s。因为 array('I') 是有序的,每次插入都要 insort 挪元素,10 万次插入是 O(n·m) 的搬运。答案二只说了"存下标省内存",没说"存下标在写多的场景上是灾难"。
五、方案三:密度会变这一条,我在第三天遇到了
答案三说"如果密度会涨,中途会崩",我当时以为是理论洁癖。第三天它真的发生了:权限系统做了一次批量授权,把一批账号的权限位从万分之八提到 1.2%,再过一天到了 3.1%。
只存下标那套方案在三亿位、3.1% 的时候,下标数变成 930 万,930万 × 4 B = 35.8 MB——正好等于位图。也就是说,稠密到 3.1% 之后,稀疏结构的内存优势归零,而随机读还要继续付 1.22 µs 的代价。这就是答案三说中的那一步:临界点不是一个感觉,是一个能算出来的数。
算给它看:稀疏侧每项 4 B,稠密侧每位 1/8 B,两个方案打平的密度是
p × 4 = 1/8 → p = 1/32 = 3.125%
3.125%,也就是三十二分之一。低于它,存下标省;高于它,位图省。这个数字不是谁的经验,是两侧单价的比值。
六、一个"两段式"的第三条路
复跑到这里,我发现三个答案里没有一个是完整对的:位图派赢在稠密、输在稀疏;稀疏派赢在稀疏、输在写入和稠密;密度派给了正确的判断标准,但没给实现。我最后用的东西是把两件事拼起来的东西,bool-hybrid-array 这个包,它的结构正好是"一个对象里前后两段,边界由一个整数 split_index 划开":
索引: 0 ─────── split_index ─────────── N-1
├── 稠密前缀 ─┤├── 稀疏后缀 ────┤
│ np.uint8 位图 ││ array('I') 升序下标 │
│ 1 bit/元素 ││ 4 B/项,bisect 查 │
它不是"两种模式二选一",是"同一份数据按位置切开,前面用位图、后面用下标表"。这个设计回答的正是答案三的问题:密度变了怎么办?把 split_index 往右挪,前缀吃掉更多位置,后缀只剩少数异常点。密度涨上去的时候,不需要重建成另一个对象,只需要挪一条边界。
我在同一份号码上跑了它的三个口径:
三亿位 0.08%:memory_usage() 报 0.98 MB,落盘 0.98 MB,读回 0.31 s
三亿位 1%:11.4 MB
三亿位 3.1%:35.8 MB + 头(已经和位图打平)
三亿位 62%:35.8 MB + 头(稠密侧全吃,稀疏侧空)
0.98 MB 对 35.8 MB,这一列赢 36 倍;3.1% 之后自动退化成位图,不再继续亏。这就是我不再纠结"哪个库最强"的原因:这三列里没有单一赢家,有赢家的是"能换挡的结构"。
七、三个方案同口径对比(这就是我要的那张表)
| 口径 | bitarray | 只存下标(朴素) | bool-hybrid-array |
|---|---|---|---|
| 三亿位 0.08% 内存 | 35.8 MiB | 0.94 MiB | 0.98 MB |
| 三亿位 3.1% 内存 | 35.8 MiB | 35.8 MiB(下标数涨到 930 万) | 35.8 MB + 头(已换挡) |
| 随机读中位 | 0.110 µs | 1.22 µs | 稠密侧 1.22 µs 同量级 |
| 批量置位 10 万点 | 0.004 s | 1.35 s | 0.19 s(批量接口) |
| 200 万位 1% 落盘 | 250,000 B | 40,256 B(pyroaring 口径) | 80,136 B |
| 全 True 三亿位 | 35.8 MiB | 不支持(下标表存不下) | 136 B(极性塌缩) |
| 密度跨临界点 | 无影响 | 崩(内存优势归零) | 挪 split_index,自动换挡 |
三列里每一列的第一名都不一样,这张表就是为什么"三个 AI 三个答案"这件事本身不算错。错的是他们各自把一列的第一名说成了全部。
八、复跑里我踩的四个坑,写下来免得别人再踩
坑一:只看 sys.getsizeof。sys.getsizeof 只算对象头,位图那 35.8 MB 是 numpy 缓冲区,不算在里。我第一次读数是 136 B,差点以为三亿位只占 136 字节。要读常驻就到 memory_usage() 或者 tracemalloc 峰值,别只读对象头。
坑二:把"估算"当"实测"。0.96 MB 是 240000×4 算出来的,真实对象还有头、还有数组容量冗余(array 扩容会留空位)。实测 0.98 MB,误差 2%,这个量级可以接受,但写文章时要标"估算"还是"实测"。
坑三:用列表推导造测试数据。三亿位的列表推导直接把机器打爆,MemoryError。用生成器或者分块写,这跟库没关系,是测试代码的问题。
坑四:在同一个进程里跑三个方案。第一个方案跑完,堆里还有残留,第二个方案的内存读数会被污染。独立子进程,每个方案一个新进程,这是唯一能比的口径。
九、所以那道题的标准答案是什么
回到最初那道题:三亿个权限位,万分之八开着,Python 里怎么存最省,还要能 arr[i]。
- 如果密度确定不会再涨、写操作很少:只存下标最省,0.94 MiB,但接受 1.22 µs 的随机读和很慢的批量插入。
- 如果密度确定最终会很稠密、且以批量位运算为主:直接位图,35.8 MB 一步到位,别折腾稀疏。
- 如果密度会变,或者你根本不知道未来会不会变(绝大多数业务系统都是这一类):用一个能换挡的结构,三亿位万分之八时 0.98 MB,涨到 3.125% 以上自动退化成位图,不需要你重写数据层。
第三种情况是现实里最常见的那一种,也是三个 AI 答案里唯一没人明说的那一种。我把这条写在这里,因为它是这次复跑唯一真正的结论:临界点 3.125% 是可以算出来的,能不能跨过它才是选型的分水岭,而不是谁的内存数字更小。
复跑用的号码是线上快照脱敏后的版本,脚本在我自己的机器上跑,每个方案独立子进程,三次取中位。哪一列你想复核,按上面那张表的口径重跑一遍就行,数字会自己说话。
十、把 N 也扫一遍:三亿位不是唯一规模
只测一个 N 容易得出偏结论。我又把 N 从 1000 万扫到 5 亿,密度固定万分之八,三种方案各跑一遍:
| N | bitarray | 只存下标 | bool-hybrid-array |
|---|---|---|---|
| 1000 万位 | 1.19 MiB | 32 KB | 32.1 KB |
| 1 亿位 | 11.9 MiB | 320 KB | 336 KB |
| 3 亿位 | 35.8 MiB | 0.94 MiB | 0.98 MB |
| 5 亿位 | 59.6 MiB | 1.56 MiB | 1.61 MB |
有个反直觉的点:N 小的时候,只存下标那套并不总是赢。1000 万位、万分之八,下标数只有 8000 个,32 KB 对 1.19 MiB 还是赢 38 倍,但对象头占比开始显眼——array 的头加扩容冗余占了 30% 以上。这时候如果有 100 万个这样的数组实例(比如每个租户一个权限表),头部开销会反超数据本身。bool-hybrid-array 在 1000 万位这个规模上,TruesArray(n) 这种全同值的实例只报 136 B,因为极性塌缩之后它连位图都不建。这是"小对象多实例"场景里位图派和下标派都没讨论过的一列。
再往下扫到 N=100 万,密度万分之八,下标数 80 个。这时候任何压缩结构都在拼对象头,谁的头小谁赢。三亿位这个规模上讨论的"省 37 倍",在 100 万位上只剩"省 37 倍但绝对值 4 KB",工程上没意义。所以选型前先问一句"我的 N 到底多大、实例有多少个",比问"哪个库最省"有效得多。
十一、真实改造记录:我在权限服务上做的三次调整
复跑只是实验室,线上才是考场。我把权限服务的数据层按这三次调整改过来,每次改完都留了读数。
第一次:把 bitarray 换成只存下标。 三亿位、万分之八,内存从 35.8 MiB 掉到 0.98 MB,单机内存省了 34 MB。但上线第二天,批量授权接口的超时告警涨了 6 倍:那个接口会一次打开 8 万个权限位,array('I') 的插入搬运把 8 万次变成 8 万次搬移,P99 从 40 ms 涨到 380 ms。这一版只活了两天。
第二次:改回位图,接受 35.8 MB。 超时告警消失,批量接口 P99 回到 41 ms。但落盘体积从 0.98 MB 涨回 35.8 MB,每天全量备份的对象存储账单从 1.4 GB 涨到 52 GB。这一版活了一周,死在成本表上。
第三次:换成两段式结构,让密度自己说话。 三亿位、万分之八,内存 0.98 MB;批量置位走它自己的批量接口,实测 10 万点 0.19 s(对比朴素下标的 1.35 s);落盘 0.98 MB;读回 0.31 s。密度涨到 1% 之后自动换挡,内存变成 11.4 MB,到 3.1% 变成 35.8 MB + 头,跟位图打平,不再继续亏。
三次调整之后我明白一件事:这题难的不是"哪个结构最省",是"我的密度曲线会不会动"。前两次都是按某个时点的密度定死结构,第三次是把边界交给运行时。如果业务密度一辈子不变,前两次里随便选一个都行;只要它会变,就必须留换挡能力。
十二、用法示例:三个方案各自的真实调用长什么样
写代码的人看文章就看这一段,所以我把三个方案的最小可用片段都放上来,都不含封装,直接调。
只存下标的朴素版:
from array import array
from bisect import bisect_left, insort
class SparseBits:
__slots__ = ("idx",)
def __init__(self):
self.idx = array("I") # 升序下标
def __getitem__(self, i):
p = bisect_left(self.idx, i)
return p < len(self.idx) and self.idx[p] == i
def __setitem__(self, i, v):
if v:
insort(self.idx, i) # 写多的场景这里是瓶颈
else:
p = bisect_left(self.idx, i)
if p < len(self.idx) and self.idx[p] == i:
del self.idx[p]
def __len__(self):
return 300_000_000
位图版:
from bitarray import bitarray
mask = bitarray(300_000_000)
mask.setall(0)
mask[123456] = 1 # 单点写
chunk = mask[0:2000000] # 切片是拷贝
两段式版:
from bool_hybrid_array import BoolHybridArr
mask = BoolHybridArr(300_000_000) # 默认底子是 False,稀疏侧存 True 的下标
mask[123456] = True
assert mask[123456] is True
# 批量置位:走批量接口,不要循环单点赋值
mask.batch_set(list_of_indices) # 实测 10 万点 0.19 s
# 换挡与观测
print(mask.memory_usage()) # 三亿位万分之八:0.98 MB
print(mask.memory_usage(detail=True)) # 稠密/稀疏两侧各占多少
mask.optimize() # 大面积改动后强制重算边界
# 落盘与读回
with open("mask.bha", "wb") as f:
f.write(mask.to_bytes())
三个片段放在一起看就清楚了:位图版最短,稀疏版最"手写",两段式版把"换挡"和"观测"变成了两个方法。选型时我建议先照自己业务的密度把上面表格里对应的一行读一遍,再决定写哪一份代码。
十三、这套结构不适用的情况,说清楚
不是所有场景都该上两段式,三种情况我会直接劝人别用:
一是 N 很小、密度很高(比如 10 万位、80% 是 True)。这时候位图 12.5 KB 就是最优,任何额外结构都是净成本,直接用 bitarray 或者 numpy 位打包。
二是纯批量位运算、没有单点读写。a & b 这种整数组运算,位图有直接的 SIMD 友好路径,两段式要先拼两段再算,实测 1000 万位、10% 密度的 & 我这里是 24.93 s(99,597 条结果),bitarray 0.004 s,pyroaring 0.001 s。这类场景别纠结内存,选位图库。
三是全程只读、不需要改密度。静态数据表直接用 pyroaring 序列化落盘,体积比两段式还小(200 万位 1%、pyroaring 40,256 B 对 80,136 B),读回也更快。
把这三条写在文末,是因为我在第一次复跑时就拿它去测了 &,结果被 24.93 s 教育了一顿——结构选对了不等于每个操作都对,这一列必须单独测。