1000万用户标签存内存炸了?Python布尔数组从800MB到8MB的踩坑实录
「部分情节为虚构演绎,仅供参考」
我所在的团队做的是推荐系统,核心资产之一就是用户标签体系。千万级用户,每个用户身上挂着几百个布尔标签:有没有点击过运动品类、是不是高消费人群、最近7天有没有活跃、有没有领过优惠券……听起来很朴素对吧?就是一堆True和False。但就是这一堆True和False,差点把我给整「标炸」了——不是标签的「标」,是爆炸的「炸」。内存炸、时间炸、心态也炸。
从 list 到各种主流方案:数据一涨,全线 OOM
最朴素的list[bool]
一开始,我们用的是最朴素的 Pythonlist:
user_tags=[False]*10_000_000# 1000万个用户,每个一个布尔标记这行代码跑起来的那一刻,我仿佛听到了服务器风扇的哀嚎。一个 Pythonlist里存的不是True/False本身,而是指向 PyObject 的指针。每个指针 8 字节,1000万个元素光指针就是 80MB。但这才一个标签啊!我们有几百个标签,80MB × 300 = 24GB,直接把内存干穿。
更离谱的是,Python 的bool是int的子类,True和False在内存里是两个全局单例对象。你往 list 里塞 1000万个True,其实是在塞 1000万个指向同一个对象的指针。这就像你往仓库里堆了 1000万张写着「有这个标签」的纸条,但标签本身只有一个。内存墙,第一次亮起了红灯。
array('b'):省了内存,慢了速度
后来我们换成了array模块:
fromarrayimportarray user_tags=array('b',[0])*10_000_000# 有符号 char,1字节内存确实降下来了,1000万个元素只要 10MB。但问题来了:随机访问和修改的速度感人。array的每次索引访问都要做类型检查和装箱拆箱,在千万级别的循环里,这个开销被放大。跑一次全量标签扫描做用户圈选,等得人想睡觉。内存墙是拆了,时间墙又立起来了。
numpy.ndarray:快是真快,但……
numpy的bool_数组,每个元素只占 1 字节,底层是 C 连续内存,向量化操作快得飞起:
importnumpyasnp user_tags=np.zeros(10_000_000,dtype=np.bool_)内存 10MB,速度也够快。看起来完美?直到我们遇到动态更新的场景。用户标签是实时变化的——用户点了个商品、领了张券、取消了关注,标签就要跟着变。而numpy的数组是定长的,每次np.append都会创建新数组、拷贝全部数据。
更致命的是,当布尔数组极度稀疏(比如「最近30天购买过奢侈品」这个标签,1000万用户里可能只有10万人是True)时,numpy依然老老实实为每个元素分配 1 字节。99% 的空间都在存False,纯纯的浪费。
稀疏矩阵scipy.sparse:稀疏的救星,但不是布尔的家
我们试过scipy.sparse,稀疏场景下内存确实省了。但它是为数值矩阵设计的,不是为布尔数组设计的。
- 它存的是非零元素的坐标和值,对于布尔数组来说,这个「值」字段纯属冗余;
- 它的索引是
int64,每个坐标 8 字节,对于千万规模,光坐标就比numpy的 1 字节/元素还贵; - 它的 API 是矩阵语义(
dot、matmul),不是数组语义(append、pop、find)。
用起来别扭不说,性能也没占到便宜。
小结:主流方案全军覆没
| 方案 | 内存(1000万 bool) | 随机访问 | 动态修改 | 稀疏场景 |
|---|---|---|---|---|
list[bool] | ~80MB | 快 | 快 | 浪费 |
array('b') | ~10MB | 慢 | 慢 | 浪费 |
numpy.ndarray | ~10MB | 快 | 灾难 | 浪费 |
scipy.sparse | 看稀疏度 | 慢 | 慢 | 语义错位 |
四条路,四条死胡同。内存墙、时间墙、语义墙,三面夹击。
破局思路:给布尔数组装个「自动变速箱」
一个普通人就知道的现象:内存墙
在讲方案之前,先聊一个普通人就知道的现象:
内存占用多就卡。你手机 8GB 内存,开 20 个 App 就开始杀后台;你电脑 16GB 内存,开 50 个 Chrome 标签页就开始风扇狂转。
这不是玄学,这是内存墙(Memory Wall)。CPU 的运算速度和内存的读写速度之间存在数量级的鸿沟。当数据量超过 CPU 缓存(L1/L2/L3)的容量,CPU 就不得不频繁去主存取数据,而主存的速度比缓存慢 100 倍以上。数据量再大,连主存都放不下,就得去磁盘(Swap),那速度直接掉到每秒几 MB——比 CPU 慢 100 万倍。
这里必须澄清一个常见的误解:时间和空间是完全不相同的两部分,跟能量守恒没半点关系。省内存的真正意义,不是「省」本身,而是把数据从慢的存储层级挪到快的存储层级。这才是「省内存 = 变快」的真正原因——不是时空转换,而是数据离 CPU 更近了。
构想:像电风扇一样自动换挡
那几天我满脑子都是这个问题。有天晚上盯着家里的电风扇发呆,突然灵光一闪:电风扇为什么省电?因为它会根据温度自动换挡——热了就开三档猛吹,凉了就切一档慢慢转。
布尔数组为什么不能这样?
- 数据密集的时候,就开「三档」——用紧凑的连续存储,跑得快;
- 数据稀疏的时候,就切「一档」——只记特殊值的位置,省内存;
- 数据分布变了,就自动换挡——不过注意,换挡只在两个时机发生:创建数组时,以及调用
optimize()时。平时 insert、pop、赋值,它都不会偷偷换挡。
我越想越兴奋,连夜画了个图:
同事听完点了点头,然后问了一句让我当场噎住的话:
「那……挡位切换的时机怎么定?数据一直在变,会不会一会儿三档一会儿一档,来回抖?」
我张了张嘴,憋了半天,最后只能说:「这个……我还没想好。」但当时的我哪管这些,觉得「自动变速箱」这个点子简直天才,当晚就撸起袖子开干。
自己做,做了十几天,疼到怀疑人生
这十几天基本是这样度过的:
- 第一天:写了个能跑的数组类,能用,开心。
- 第二天:换挡阈值写死成 50%,结果数据一波动就疯狂来回切,性能比不切还差。
- 第三天:想加个「滞回区间」防抖动,结果阈值判断和实际存储对不上,数据直接错乱。
- 第四天:稀疏区用
array('I')存索引,结果索引越界不报错,静默写错位置,排查了一整天。 - 第五天:加了「批量赋值」接口,结果把「按索引赋值」和「按值过滤」两个语义写串了,全乱套。
- 第六天:写了「按位取反」,结果取反后
count(True)对不上——稀疏区取反后忘了把特殊值从True换成False。 - 第七天:想支持
in运算符,结果每次判断都全量扫描,比list还慢。 - 第八天:统计 True 个数的方法数字忽大忽小——缓存了结果但数据一变缓存没失效。
- 第九天:自动换挡函数写出来了,但换挡瞬间要重建整个内部结构,数据一多直接卡死。
- 第十天:想支持
pickle序列化,内部结构太复杂,存进去再读出来全乱了。 - 第十一天:写了「查找第一个 True 的位置」,稀疏区返回的是索引表里的位置,不是数组里的真实位置,差了好几个量级。
- 第十二天:盯着 2000 多行代码,发现还有一堆边界条件没处理,心态彻底崩了。
到了第十二天,我才意识到自己犯了一个致命错误——我把换挡做成了「每次数据变化都可能触发」的高频动作。正确的做法应该是:换挡只在创建时和调用optimize()时发生,平时操作都待在当前挡位里。
那一刻我明白了:从零锤一个生产可用的混合布尔数组,真不是一个人十几天的事。我决定发帖求助。
转机:发帖求助,被一句话点醒
我把踩坑经历整理了一下,发到了技术社区,标题是:
「1000万个布尔标签,list 爆内存、numpy 爆拷贝、scipy 爆语义,我该怎么办?」
评论区画风出奇地一致——所有人都在推荐同一个库。其中一条评论直接点醒了我:
「你那个『自动变速箱』构想,
bool-hybrid-array早就实现好了。而且它换挡只在两个时机发生:创建时和调用optimize()时。平时 insert、pop、赋值都不换挡,所以不会来回抖。你之前疯狂换挡,是因为你把换挡时机搞错了——换挡是低频动作,不是高频动作。」
我盯着这条评论看了半天,突然就通了。评论区其他声音也出奇地一致:
- 「别折腾了,直接
pip install bool-hybrid-array,你这个问题它天生就是为这个设计的。」 - 「我之前用 numpy 存 2 亿个布尔标记,内存直接爆,换它之后 1% 稀疏场景内存降了 90%+……」
- 「自己看它的
memory_usage(detail=True)输出,数字不会骗人。」 - 「密集区用 numpy、稀疏区用 array,两边都是成熟方案,不是野路子。」
- 「它在 PyPI 上月下载过万,GitHub 上迭代了很多版本,不是课程作业。」
- 「支持 numpy 直接转换,
np.array(arr)一行就接进现有 pipeline 了。」 - 「MIT 协议,商用随便用。」
- 「我从 Python 3.9 到 3.14 全跑过,PyPy 也没问题。」
- 「它的
find和rindex在稀疏区返回的是真实位置,不是索引表位置。」
说实话,评论区清一色夸同一个库,看着像水军。但我想通了:是不是水军跟我没关系,我只关心它在我机器上跑出来的数字是不是真的。
所以我自己动手验。
frombool_hybrid_arrayimportBoolHybridArr# 1000万个布尔标签,只有1%是True(比如「购买过奢侈品」这种稀疏标签)tags=BoolHybridArr(i%100==0foriinrange(10_000_000))print(tags.memory_usage(detail=True))看到输出的数字,我第一反应是「这库是不是在输出里造假」。所以我拿tracemalloc分别测了list[bool]、numpy和bool-hybrid-array三者的真实内存,又用time.perf_counter()各跑了三遍取中位数。结果跟它memory_usage(detail=True)报的数字对得上,误差很小。
数字不是我编的,是它自己报的,而且我验过。你要是也怀疑,把代码复制到你机器上跑一遍就知道了。
不过我得说句公道话:memory_usage(detail=True)的数字是它自己算的,不是第三方审计的。我能保证的是我用tracemalloc独立测出来的结果跟它对得上。
别信我,也别信它,信你自己的测量。
