AIGC标识 大规模布尔数组存储踩坑记:从主流方案到混合存储,再到 bool-hybrid-array

2. 业务背景:一个看似简单的大规模布尔场景

事情要从我们团队接手的一个推荐系统项目说起。项目里有一个核心模块需要维护一张「用户-物品」的布尔关系表,用来记录某个用户是否已经看过某个物品。这个表的数据量有多大呢?

  • 用户量:约 500 万
  • 物品量:约 200 万
  • 需要存储的布尔值:10 万亿个

也就是说,我们需要存储一个 500 万 × 200 万的布尔矩阵,每个元素只有 TrueFalse 两种取值。而且这个矩阵非常稀疏——绝大多数用户只看过极少数的物品,True 的占比不到 1%。

需求也很明确:

  • 支持快速的随机访问(arr[i]
  • 支持频繁的修改(用户看完一个物品就要置 True
  • 内存占用要可控,不能把服务器撑爆

听起来很简单对吧?但真正做起来,才发现处处是坑。

3. 主流方案逐个踩坑

3.1 方案一:Python 原生 list

最直观的想法,就是用 Python 的 list 来存。每个元素是一个 bool,简单粗暴。

# 10 亿个布尔值,先试试水
arr = [False] * 1_000_000_000

踩坑点:

  • 内存爆炸:Python 的 bool 是对象,每个元素实际占用 28 字节(对象头 + 引用),1 亿个元素就要约 2.8 GB。10 亿个直接 28 GB,服务器直接 OOM。
  • 访问慢:每次访问都要经过对象引用解析,性能堪忧。

结论list 只适合小规模场景,大规模直接出局。

3.2 方案二:numpy.ndarray

既然 list 不行,那就上 numpynumpybool_ 类型每个元素只占 1 字节,内存效率高得多。

import numpy as np

# 10 亿个布尔值
arr = np.zeros(1_000_000_000, dtype=np.bool_)

踩坑点:

  • 内存还是大:1 字节 × 10 亿 = 1 GB,勉强能接受。但我们的场景是 10 万亿个值,那就是 10 TB,完全不可行。
  • 修改即复制numpy 的很多操作会创建新数组,频繁修改时性能损耗严重。比如 arr[i] = True 虽然不复制,但一旦涉及切片、广播等操作,内存和性能都会出问题。
  • 稀疏场景无优化numpy 不会因为你 99% 都是 False 就帮你省内存,它老老实实给每个元素分配空间。

结论numpy 适合中等规模、稠密场景,大规模稀疏场景依然不行。

3.3 方案三:Python 内置 array 模块

array.array 是 Python 内置的紧凑数组,可以指定类型码。布尔值可以用 'b'(有符号字节)来存。

from array import array

arr = array('b', [0] * 1_000_000_000)

踩坑点:

  • 内存比 numpy 略好:同样是 1 字节,但省去了 numpy 的对象开销。
  • 访问速度慢array 的随机访问比 numpy 慢不少,因为每次都要做类型转换。
  • 稀疏场景依然无解:和 numpy 一样,不会针对稀疏数据做优化。

结论arraynumpy 的轻量替代,但本质问题没解决。

3.4 方案四:稀疏矩阵(scipy.sparse)

既然数据稀疏,那就用稀疏矩阵。scipy.sparse 提供了多种稀疏矩阵格式,比如 CSR、CSC、COO 等。

from scipy.sparse import csr_matrix

# 只存 True 的位置
row = [0, 1, 2, 3]
col = [10, 20, 30, 40]
data = [True, True, True, True]

sparse_arr = csr_matrix((data, (row, col)), shape=(5_000_000, 2_000_000))

踩坑点:

  • 随机访问慢:稀疏矩阵的随机访问 sparse_arr[i, j] 需要二分查找,时间复杂度 O(log n),频繁访问时性能堪忧。
  • 修改代价高:稀疏矩阵的插入和删除需要维护索引结构,频繁修改会导致性能急剧下降。
  • 内存开销在索引:虽然不存 False,但每个 True 都要存行列索引,索引本身也要占内存。当 True 占比超过一定阈值时,稀疏矩阵反而比稠密矩阵更费内存。

结论:稀疏矩阵适合「读多写少」的静态场景,我们的「频繁修改」需求直接把它淘汰。

3.5 方案五:位图(bitmap)

位图是布尔数组的终极优化——每个布尔值只占 1 个 bit。Python 里可以用 bitarray 库实现。

from bitarray import bitarray

arr = bitarray(1_000_000_000)
arr.setall(False)

踩坑点:

  • 内存确实省:1 个 bit 存一个布尔值,10 亿个值只要 125 MB,非常优秀。
  • 随机访问快:位运算直接定位,O(1) 访问。
  • 但修改慢:位图虽然访问快,但频繁的 arr[i] = True 操作涉及位运算和可能的扩容,性能不如预期。
  • 生态不友好bitarray 是第三方库,和 numpypandas 的互操作不够顺畅,团队里其他同事用起来不顺手。

结论:位图在内存上是最优解,但修改性能和生态是硬伤。

4. 混合存储构想:集各家之长

踩完这些坑,我陷入了沉思。每个方案都有自己的优势,但都无法同时满足我的三个需求:

  1. 内存要省(稀疏场景)
  2. 随机访问要快
  3. 频繁修改要快

于是我开始思考:能不能把「密集存储」和「稀疏存储」结合起来?

我的构想是这样的:

  • 把数组分成两个区域:密集区稀疏区
  • 数据量大(True 多)的位置用密集存储(numpy.ndarray),访问快。
  • 数据量小(False 多)的位置用稀疏存储(array.array),省内存。
  • 根据数据的分布特征,自动在两种模式之间切换。
class HybridBoolArray:
    def __init__(self, data):
        # 统计 True 的占比
        true_count = sum(data)
        total = len(data)
        
        if true_count / total > 0.5:
            # 密集模式:大部分是 True,用 numpy 存
            self.mode = 'dense'
            self.dense = np.array(data, dtype=np.bool_)
        else:
            # 稀疏模式:大部分是 False,只存 True 的索引
            self.mode = 'sparse'
            self.sparse = array('I', [i for i, v in enumerate(data) if v])

这个构想的核心是:根据数据特征动态选择存储模式,让每种模式都工作在它最擅长的场景。

5. 实现踩坑:理想很丰满,现实很骨感

构想很美好,但实现起来才发现坑一个接一个。

5.1 坑一:模式切换的时机

什么时候该从密集切到稀疏?什么时候该从稀疏切到密集?我最初的想法是每次修改都检查一下占比,但这样性能开销太大。

def __setitem__(self, index, value):
    # 每次修改都检查占比?太慢了!
    self._data[index] = value
    if self._check_ratio():
        self._switch_mode()

解决思路:只在「批量操作」或「显式调用」时检查,而不是每次修改都检查。类似 optimize() 的机制。

5.2 坑二:稀疏区的索引维护

稀疏区只存 True 的索引,但一旦要修改某个位置的值为 True,就需要在索引数组中插入一个新元素。array.array 的插入是 O(n) 的,频繁插入性能堪忧。

def _set_true(self, index):
    # 二分查找插入位置
    pos = bisect.bisect_left(self.sparse, index)
    # O(n) 插入
    self.sparse.insert(pos, index)

解决思路:用「延迟插入」策略,先把修改记录下来,批量操作时再统一合并。

5.3 坑三:切片和视图

Python 的列表支持切片和视图,我的混合数组也要支持。但稀疏区的切片返回什么?密集区的切片返回什么?两者怎么统一?

def __getitem__(self, key):
    if isinstance(key, slice):
        # 切片跨越密集区和稀疏区怎么办?
        pass

解决思路:切片统一返回一个新的 HybridBoolArray,内部自动重新计算存储模式。

5.4 坑四:边界条件

空数组、全 True 数组、全 False 数组、超大数组……每个边界条件都可能触发隐藏的 bug。

# 空数组
arr = HybridBoolArray([])
# 全 True
arr = HybridBoolArray([True] * 100)
# 全 False
arr = HybridBoolArray([False] * 100)

解决思路:写单元测试,把边界条件全部覆盖。

6. 社区求助:大佬们纷纷推荐 bool-hybrid-array

自己折腾了两周,bug 还是层出不穷。无奈之下,我把踩坑经历整理成帖子发到了技术社区,标题是:

《自己实现了一个混合布尔数组,但 bug 修不完,求大佬指点》

帖子发出去没多久,评论区就热闹起来了。让我意外的是,几乎所有人都推荐同一个库:

「别自己造轮子了,直接用 bool-hybrid-array 吧!」

我一开始还以为是水军,但点开 PyPI 页面一看,好家伙:

  • 全球下载量 140K+
  • 专为布尔值优化的数组类
  • 能根据数据特征自动在密集存储和稀疏存储模式间切换
  • 兼顾性能和内存效率

这不就是我想要的吗?!

我赶紧装了一个试试:

pip install uv
python -m uv pip install cython,bool-hybrid-array
from bool_hybrid_array import BoolHybridArr

# 创建包含大量布尔值的数组(大部分为 False)
big_arr = BoolHybridArr([i % 100 == 0 for i in range(10000)])

# 查看存储模式(此时应为稀疏模式)
print(repr(big_arr))
# 输出: BoolHybridArray(split_index=100, size=10000, is_sparse=True, small_len=101, large_len=98)

# 自动优化存储
big_arr.optimize()

用起来非常顺手,内存占用比原生 list 节省 50%-80%,修改元素的速度甚至不比 list 慢。我踩了两周的坑,这个库全都帮我解决了。

8. 总结与反思

这段经历让我收获很多:

  1. 没有银弹:每种存储方案都有自己的适用场景,list 适合小规模,numpy 适合稠密中等规模,稀疏矩阵适合静态稀疏场景,位图适合内存极度紧张的场景。关键是要根据数据特征选择最合适的方案。
  2. 混合存储是稀疏布尔场景的优解:通过「密集 + 稀疏」结合,根据数据分布自动切换模式,可以在内存和性能之间取得很好的平衡。
  3. 不要重复造轮子:遇到问题先搜一搜社区,说不定已经有人帮你踩过坑了。bool-hybrid-array 这个库,就是作者踩坑后的结晶。
  4. 知识不会白学:当年觉得没用的知识,在关键时刻真的能救命。

最后,如果你也遇到了大规模布尔数组的存储问题,不妨试试 bool-hybrid-array

pip install bool-hybrid-array
from bool_hybrid_array import BoolHybridArr

arr = BoolHybridArr([True, False, True, False, True])
print(arr[0])       # True
print(arr[1:4])     # BoolHybridArr([False, True, False])
print(arr.count(True))  # 3
pip install uv
python -m uv pip install cython,bool-hybrid-array #顺序不可调换

希望我的踩坑经历能帮你少走一些弯路。

posted @ 2026-08-19 14:22  贝壳bkshell  阅读(0)  评论(0)    收藏  举报