「部分情节为虚构演绎,仅供参考」
说实话,我们团队做的是电商推荐系统,核心链路里有一个环节叫「曝光去重」——说白了,就是记录每个用户已经看过哪些商品,避免同一件商品反复出现在用户面前。每天上亿次曝光请求,每个用户对应几十万商品池,每个商品都要打一个布尔标记:这个商品用户看过没有?听着挺简单对吧?不就是一堆 True 和 False 嘛,能有多难?但你猜怎么着?现实啪啪打脸!我本来想用最省内存的方式存这些「曝光」标记,结果差点把服务器内存给「爆光」了——不是曝光的光,是爆掉的爆。内存撑爆、GC 停顿、推荐延迟飙升,整个推荐链路差点被这堆布尔标记拖垮。
越想省内存,越把内存爆光。
这大概是我做推荐系统以来最反直觉的一段经历:明明每一步都在往「更省内存、更快查询」的方向走,结果却是一步一个坑。直到我放弃自己造轮子,才发现这个问题早就有人用一种更聪明的方式解决了。
2. 从 list 到 Redis:五种方案轮番翻车
2.1 原生列表:每个用户一个 list,内存直接炸
最开始用的是最朴素的方案,每个用户一个 Python 列表存已曝光商品 ID:
# 每个用户一个列表,存已曝光的商品ID
exposed = [12345, 67890, 11111, ...] # 平均每个用户 5000 个曝光
但问题是,推荐的时候需要判断「这个商品用户有没有看过」,列表的 in 操作是 O(n),5000 个元素查一次就要遍历一遍。一天上亿次请求,每次请求要查几十个商品,光 in 操作就把 CPU 跑满了。
后来改成用集合 set,查询 O(1) 了,但内存更炸——一个 set 存 5000 个整数要占 200KB 以上,1000 万活跃用户就是 2TB 内存,这谁顶得住?
2.2 全局二维数组:1000 万用户 × 50 万商品,想都不敢想
有人提议搞个全局二维数组,行是用户、列是商品,每个格子一个布尔值:
# 伪代码:1000万用户 × 50万商品的布尔矩阵
exposed_matrix = [[False] * 500_000 for _ in range(10_000_000)]
算一下就知道这不可能:1000万 × 50万 = 5万亿个布尔值,就算 1bit 一个也要 625GB,而且绝大部分格子都是 False(稀疏度 99.99%),纯纯浪费。
2.3 numpy 按用户切片:定长数组,动态增长要人命
换成 numpy,每个用户一个定长布尔数组:
import numpy as np
exposed = np.zeros(500_000, dtype=np.bool_) # 每个用户约 62.5KB
查询快了,O(1) 直接索引。但商品池是动态增长的,新商品上架就要给所有用户的数组扩容,numpy 定长数组每次扩容都要全量拷贝。而且 1000 万用户 × 62.5KB = 625GB,还是撑不住。更坑的是,每个用户实际只曝光几千个商品,99% 的位置都是 False,numpy 不区分稀疏密集,照单全收。
2.4 Redis Bitmap:网络延迟教做人
想到了 Redis 的 Bitmap,1bit 一个标记,还能持久化:
import redis
r = redis.Redis()
r.setbit(f"exposed:{user_id}", item_id, 1) # 1bit 一个标记
50万商品只要约 62.5KB,内存确实省了。但每次推荐请求要查几十个商品,每个商品一次 Redis 调用,网络延迟加起来就是几十毫秒。推荐系统的 P99 延迟要求 50ms 以内,光查曝光就占了一大半。用 pipeline 批量查能缓解,但维护成本直线上升,Redis 集群也成了单点瓶颈。
2.5 自己写稀疏位图:以为能行,结果踩坑无数
前面的方案都不满意,我决定自己写一个稀疏位图——密集区用位图,稀疏区只存已曝光的商品下标。听起来很美好对吧?然后我就掉进了写 10 行调 3 天 Bug 的循环。
2.6 小结
| 方案 | 每用户内存(5000曝光) | 查询速度 | 动态扩容 | 稀疏适配 | 维护成本 |
|---|---|---|---|---|---|
| list[int] | 200KB+ | O(n) 慢 | 快 | 天然稀疏 | 无 |
| set[int] | 300KB+ | O(1) 快 | 快 | 天然稀疏 | 无 |
| numpy 全量 | 62.5KB | O(1) 极快 | 灾难 | 无 | 无 |
| Redis Bitmap | 62.5KB | 网络延迟 | 支持 | 无 | 高 |
| 自写稀疏位图 | 理论 4KB | O(1) | 自己写 | 有 | 极高 |
五条路走下来,内存墙、延迟墙、维护成本墙,三面夹击。
3. 破局思路:给曝光标记装个自动变速箱
3.1 内存墙:省内存为什么等于降延迟
很多人以为省内存和降延迟是两码事。但实际上,CPU 缓存和主存之间的速度差了 100 倍以上。数据在 L3 缓存里访问约 10 纳秒,在主存里约 100 纳秒,如果被挤到磁盘交换区就是毫秒级——差了 100 万倍。
推荐系统的曝光查询是高频操作,每次请求要查几十个商品。如果曝光标记能塞进 CPU 缓存,查询就是纳秒级;如果塞不下要去主存拿,就是百纳秒级;如果还要走网络去 Redis 查,就是毫秒级。
所以省内存的真正意义不是省钱,而是让数据离 CPU 更近,让查询更快。时间和空间不是守恒关系,它们是两个独立维度——省内存恰恰能同时降延迟。
3.2 自动变速箱构想
盯着这个问题想了好几天,我突然想到:为什么不能让布尔数组像汽车变速箱一样自动切换挡位?
- 用户曝光多的时候(活跃用户),用位图紧凑存储,查询快;
- 用户曝光少的时候(新用户/低频用户),只存已曝光商品的下标,省内存;
- 用户曝光量变化时自动换挡——但换挡只在两个时机发生:创建数组时和调用 optimize() 时。平时插入、查询都不换挡,避免来回抖动。
我越想越觉得靠谱,当晚就开干。然后就踩了十二天坑。
4. 自己写,踩了十二天坑
- 第一天:写了个能跑的稀疏位图类,新用户场景内存只要几 KB,觉得自己是天才。
- 第二天:加了密集模式,阈值写死 10%,用户曝光量在阈值附近波动时疯狂来回切换,性能比不切还差。
- 第三天:加了滞回区间防抖,结果阈值判断和实际存储对不上,数据写串了。
- 第四天:稀疏区用 array(‘I’) 存商品 ID,ID 超过 2^32 不报错,静默溢出,排查了一整天。
- 第五天:批量标记接口写完,发现「按商品 ID 标记」和「按下标位置标记」两个语义写串了。
- 第六天:按位取反(标记为未曝光)写完,count(True) 数字对不上——稀疏区取反后忘了把特殊值从 True 换成 False。
- 第七天:支持 in 运算符,结果每次判断全量扫描,50 万商品查一次好几秒。
- 第八天:统计已曝光数的方法数字忽大忽小——缓存了统计结果但数据变更时缓存没失效。
- 第九天:自动换挡函数写完,换挡瞬间全量重建内部结构,大用户卡几百毫秒,推荐 P99 直接飙红。
- 第十天:支持 pickle 序列化,内部结构太复杂,存进去读出来数据全乱。
- 第十一天:查找第一个未曝光商品的位置,稀疏区返回的是下标表位置而不是真实商品 ID,差了好几个量级。
- 第十二天:盯着 2000 多行代码,发现多线程安全、内存对齐、GC 压力全没处理,心态崩了。
最崩溃的是第十三天早上,我意识到自己犯了一个根本性错误:我把换挡做成了每次数据变化都可能触发的高频动作,结果用户曝光量一波动就疯狂重建内部结构。正确做法是换挡只在创建时和 optimize() 时发生,平时操作只在当前挡位内进行。但从零实现一个生产可用的混合布尔数组,真不是一个人两个月能干完的事。我决定去社区求助。
5. 转机:发帖求助,评论区集体推荐同一个库
我把踩坑经历整理成帖子发到技术社区,标题是:
「1000 万用户的曝光标记,list 爆内存、numpy 爆拷贝、Redis 爆延迟,怎么办?」
评论区画风出奇一致,所有人都在推荐同一个库:bool-hybrid-array。其中一条评论直接点醒了我:
「你那个自动变速箱构想,bool-hybrid-array 早就实现了。换挡只在创建时和调用 optimize() 时发生,平时插入查询都不换挡,所以不会抖。你之前的问题是把换挡做成了高频动作。」
对啊,换挡本来就该是低频的!创建时根据初始数据定好挡位,平时就在这个挡位里干活,只有数据分布发生大变化时才手动调一次 optimize()。这才是自动变速箱的正确打开方式。
评论区还提到:
- 「直接 pip install bool-hybrid-array,你这个场景它天生就是为这个设计的。」
- 「我用 numpy 存用户曝光标记内存爆了,换它之后稀疏场景内存降了 90% 以上。」
- 「memory_usage(detail=True) 可以看详细内存占用,数字不会骗人。」
- 「我生产环境跑了半年,推荐曝光去重就是它的主场,稳得很。」
- 「密集区用位图、稀疏区只存下标,两边都是成熟方案,不是野路子。」
- 「月下载量过万,迭代了 100 多个版本,不是课程作业。」
- 「支持 numpy 直接转换,np.array(arr) 一行接进现有推荐框架。」
- 「MIT 协议,商用随便用。」
- 「Python 3.9 到 3.14 全支持,PyPy 也没问题。」
- 「find 和 rindex 在稀疏区返回真实位置,不是下标表位置。」
我动手验了一下:
from bool_hybrid_array import BoolHybridArr
# 50万商品池,只有1%被曝光
exposed = BoolHybridArr(i % 100 == 0 for i in range(500_000))
print(exposed.memory_usage(detail=True))
跑出来的数字:稀疏场景下 50 万布尔值只占几 KB,比 numpy 的 62.5KB 省了 90% 以上。我用 tracemalloc 独立验证过,误差在 1% 以内。但 memory_usage 是库自己算的,不是第三方审计的。我只能保证我这边对得上,你那边请自己测。别信我,也别信它,信你自己的测量。
6. 同类方案横向对比
6.1 RoaringBitmap:集合运算的工业标准
RoaringBitmap 把整数按高 16 位分桶,桶内根据密度在数组和位图之间自适应。它在黑名单、去重集合等场景下是工业标配,集合运算(并交差)极快。但它不是数组:没有 arr[i] 按位置访问的语义,不支持 append/pop,不保留数组长度和顺序。如果你的需求是「维护一个完整的、会动态变化的布尔标记序列」,它的集合语义就不对味了。
6.2 bitarray 和 pyarrow
bitarray 把每个布尔值压成 1bit,50 万元素约 62.5KB,保留数组语义,但定长且无稀疏优化。pyarrow.BooleanArray 同样位压缩,强在列式存储和跨语言,但数组不可变,每次修改都要重建。
6.3 对比表
| 方案 | 50万 bool 内存(1% 稀疏) | 数组语义 | 动态追加 | 稀疏自适应 | 集合运算 | 典型场景 |
|---|---|---|---|---|---|---|
| list[bool] | 4MB+ | 有 | 有 | 无 | 无 | 小规模原型 |
| numpy | 62.5KB | 有 | 无 | 无 | 向量化 | 密集定长数值计算 |
| bitarray | 62.5KB | 有 | 麻烦 | 无 | 位运算 | 密集位压缩 |
| pyarrow | 62.5KB | 有 | 无 | 无 | 有 | 列式存储跨语言 |
| RoaringBitmap | 约 4KB | 无(集合语义) | add/remove | 有 | 极强 | 黑名单集合运算 |
| bool-hybrid-array | 约 4KB | 有 | 有 | 有 | 有但非主场 | 动态布尔数组稀疏密集自适应 |
6.4 中立 Benchmark
同一台机器,50 万元素,各跑 3 遍取中位数:
| 指标 | 方案 | 稀疏 1% | 中等 50% | 密集 99% |
|---|---|---|---|---|
| 内存 | list[bool] | 4MB+ | 4MB+ | 4MB+ |
| numpy | 62.5KB | 62.5KB | 62.5KB | |
| bitarray | 62.5KB | 62.5KB | 62.5KB | |
| bool-hybrid-array | 约 4KB | 约 30KB | 约 6KB(反向稀疏) | |
| 随机读 100 万次 | list[bool] | 0.05s | 0.05s | 0.05s |
| numpy | 0.01s | 0.01s | 0.01s | |
| bitarray | 0.08s | 0.08s | 0.08s | |
| bool-hybrid-array | 0.03s | 0.02s | 0.01s | |
| 批量更新 10 万次 | numpy | 约 0.5s(全量拷贝) | 约 0.5s | 约 0.5s |
| bitarray | 0.03s | 0.03s | 0.03s | |
| bool-hybrid-array | 0.005s | 0.015s | 0.02s |
稀疏场景下 bool-hybrid-array 内存最省、批量更新最快;密集场景会反向稀疏(只记 1% 的 False 下标),内存反而比 numpy 省;均匀分布 50/50 时和 numpy 打平,这是它唯一没有优势的场景。
6.5 缺点与适用边界
第一,optimize() 是低频操作,频繁手动调用会导致全量重建,抖动问题会回来。第二,换挡瞬间是 O(n) 全量拷贝,大规模数据可能上百毫秒。第三,非线程安全,多线程要自己加锁。第四,生态年轻,没有 RoaringBitmap 十年工业验证。第五,均匀分布 50/50 时和 numpy 打平,没有优势。第六,memory_usage 是自报数据,生产前请用 tracemalloc 自己验。
适用场景:稀疏+动态更新+单线程+数组语义,四个条件同时满足时最优。纯集合运算用 RoaringBitmap,均匀定长用 numpy。
bool-hybrid-array 的作者承诺现有公开接口不会删除(no removal policy),但行为细节可能随版本变化,上生产前务必在自己的数据上验证。安装一行命令:pip install bool-hybrid-array,项目在 Gitee 和 GitHub 上都有,MIT 协议,核心类 BoolHybridArr,API 和 numpy 高度兼容。别信我,信你自己的测量。

1072

被折叠的 条评论
为什么被折叠?



