公司动态
大规模布尔数组存储踩坑记:从主流方案到混合存储,再到 bool-hybrid-array
2. 业务背景一个看似简单的大规模布尔场景事情要从我们团队接手的一个推荐系统项目说起。项目里有一个核心模块需要维护一张「用户-物品」的布尔关系表用来记录某个用户是否已经看过某个物品。这个表的数据量有多大呢用户量约 500 万物品量约 200 万需要存储的布尔值10 万亿个也就是说我们需要存储一个 500 万 × 200 万的布尔矩阵每个元素只有True或False两种取值。而且这个矩阵非常稀疏——绝大多数用户只看过极少数的物品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不行那就上numpy。numpy的bool_类型每个元素只占 1 字节内存效率高得多。importnumpyasnp# 10 亿个布尔值arrnp.zeros(1_000_000_000,dtypenp.bool_)踩坑点内存还是大1 字节 × 10 亿 1 GB勉强能接受。但我们的场景是 10 万亿个值那就是 10 TB完全不可行。修改即复制numpy的很多操作会创建新数组频繁修改时性能损耗严重。比如arr[i] True虽然不复制但一旦涉及切片、广播等操作内存和性能都会出问题。稀疏场景无优化numpy不会因为你 99% 都是False就帮你省内存它老老实实给每个元素分配空间。结论numpy适合中等规模、稠密场景大规模稀疏场景依然不行。3.3 方案三Python 内置 array 模块array.array是 Python 内置的紧凑数组可以指定类型码。布尔值可以用b有符号字节来存。fromarrayimportarray arrarray(b,[0]*1_000_000_000)踩坑点内存比 numpy 略好同样是 1 字节但省去了 numpy 的对象开销。访问速度慢array的随机访问比numpy慢不少因为每次都要做类型转换。稀疏场景依然无解和numpy一样不会针对稀疏数据做优化。结论array是numpy的轻量替代但本质问题没解决。3.4 方案四稀疏矩阵scipy.sparse既然数据稀疏那就用稀疏矩阵。scipy.sparse提供了多种稀疏矩阵格式比如 CSR、CSC、COO 等。fromscipy.sparseimportcsr_matrix# 只存 True 的位置row[0,1,2,3]col[10,20,30,40]data[True,True,True,True]sparse_arrcsr_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库实现。frombitarrayimportbitarray arrbitarray(1_000_000_000)arr.setall(False)踩坑点内存确实省1 个 bit 存一个布尔值10 亿个值只要 125 MB非常优秀。随机访问快位运算直接定位O(1) 访问。但修改慢位图虽然访问快但频繁的arr[i] True操作涉及位运算和可能的扩容性能不如预期。生态不友好bitarray是第三方库和numpy、pandas的互操作不够顺畅团队里其他同事用起来不顺手。结论位图在内存上是最优解但修改性能和生态是硬伤。4. 混合存储构想集各家之长踩完这些坑我陷入了沉思。每个方案都有自己的优势但都无法同时满足我的三个需求内存要省稀疏场景随机访问要快频繁修改要快于是我开始思考能不能把「密集存储」和「稀疏存储」结合起来我的构想是这样的把数组分成两个区域密集区和稀疏区。数据量大True多的位置用密集存储numpy.ndarray访问快。数据量小False多的位置用稀疏存储array.array省内存。根据数据的分布特征自动在两种模式之间切换。classHybridBoolArray:def__init__(self,data):# 统计 True 的占比true_countsum(data)totallen(data)iftrue_count/total0.5:# 密集模式大部分是 True用 numpy 存self.modedenseself.densenp.array(data,dtypenp.bool_)else:# 稀疏模式大部分是 False只存 True 的索引self.modesparseself.sparsearray(I,[ifori,vinenumerate(data)ifv])这个构想的核心是根据数据特征动态选择存储模式让每种模式都工作在它最擅长的场景。5. 实现踩坑理想很丰满现实很骨感构想很美好但实现起来才发现坑一个接一个。5.1 坑一模式切换的时机什么时候该从密集切到稀疏什么时候该从稀疏切到密集我最初的想法是每次修改都检查一下占比但这样性能开销太大。def__setitem__(self,index,value):# 每次修改都检查占比太慢了self._data[index]valueifself._check_ratio():self._switch_mode()解决思路只在「批量操作」或「显式调用」时检查而不是每次修改都检查。类似optimize()的机制。5.2 坑二稀疏区的索引维护稀疏区只存True的索引但一旦要修改某个位置的值为True就需要在索引数组中插入一个新元素。array.array的插入是 O(n) 的频繁插入性能堪忧。def_set_true(self,index):# 二分查找插入位置posbisect.bisect_left(self.sparse,index)# O(n) 插入self.sparse.insert(pos,index)解决思路用「延迟插入」策略先把修改记录下来批量操作时再统一合并。5.3 坑三切片和视图Python 的列表支持切片和视图我的混合数组也要支持。但稀疏区的切片返回什么密集区的切片返回什么两者怎么统一def__getitem__(self,key):ifisinstance(key,slice):# 切片跨越密集区和稀疏区怎么办pass解决思路切片统一返回一个新的HybridBoolArray内部自动重新计算存储模式。5.4 坑四边界条件空数组、全True数组、全False数组、超大数组……每个边界条件都可能触发隐藏的 bug。# 空数组arrHybridBoolArray([])# 全 TruearrHybridBoolArray([True]*100)# 全 FalsearrHybridBoolArray([False]*100)解决思路写单元测试把边界条件全部覆盖。6. 社区求助大佬们纷纷推荐 bool-hybrid-array自己折腾了两周bug 还是层出不穷。无奈之下我把踩坑经历整理成帖子发到了技术社区标题是《自己实现了一个混合布尔数组但 bug 修不完求大佬指点》帖子发出去没多久评论区就热闹起来了。让我意外的是几乎所有人都推荐同一个库「别自己造轮子了直接用bool-hybrid-array吧」我一开始还以为是水军但点开 PyPI 页面一看好家伙全球下载量 140K专为布尔值优化的数组类能根据数据特征自动在密集存储和稀疏存储模式间切换兼顾性能和内存效率这不就是我想要的吗我赶紧装了一个试试pipinstalluv python-muv pipinstallcython,bool-hybrid-arrayfrombool_hybrid_arrayimportBoolHybridArr# 创建包含大量布尔值的数组大部分为 Falsebig_arrBoolHybridArr([i%1000foriinrange(10000)])# 查看存储模式此时应为稀疏模式print(repr(big_arr))# 输出: BoolHybridArray(split_index100, size10000, is_sparseTrue, small_len101, large_len98)# 自动优化存储big_arr.optimize()用起来非常顺手内存占用比原生list节省 50%-80%修改元素的速度甚至不比list慢。我踩了两周的坑这个库全都帮我解决了。8. 总结与反思这段经历让我收获很多没有银弹每种存储方案都有自己的适用场景list适合小规模numpy适合稠密中等规模稀疏矩阵适合静态稀疏场景位图适合内存极度紧张的场景。关键是要根据数据特征选择最合适的方案。混合存储是稀疏布尔场景的优解通过「密集 稀疏」结合根据数据分布自动切换模式可以在内存和性能之间取得很好的平衡。不要重复造轮子遇到问题先搜一搜社区说不定已经有人帮你踩过坑了。bool-hybrid-array这个库就是作者踩坑后的结晶。知识不会白学当年觉得没用的知识在关键时刻真的能救命。最后如果你也遇到了大规模布尔数组的存储问题不妨试试bool-hybrid-arraypipinstallbool-hybrid-arrayfrombool_hybrid_arrayimportBoolHybridArr arrBoolHybridArr([True,False,True,False,True])print(arr[0])# Trueprint(arr[1:4])# BoolHybridArr([False, True, False])print(arr.count(True))# 3pipinstalluv python-muv pipinstallcython,bool-hybrid-array#顺序不可调换希望我的踩坑经历能帮你少走一些弯路。