部分情节为虚构演绎,仅供参考
说实话,我做的是一个消息推送系统。平台有2亿注册用户,每个用户对每个话题都有订阅状态:订阅了是True、没订阅是False。推送服务要在毫秒级判断「这个用户订阅了这个话题没」,还要支持用户随时订阅、退订、新注册用户追加。说白了,订阅状态就是海量的布尔标记——2亿用户乘以几十个话题,就是几十亿个True和False。听着挺简单对吧?不就是一堆True和False嘛,set存订阅用户ID不就行了?但你猜怎么着?现实啪啪打脸!就是这一堆True和False,差点把我给整「退订」了——不是退订消息的「订」,是订正的「订」,订阅状态数组把推送服务整到反复订正数据,最后把我自己也给整退订了——离职申请都写了一半。一次大V发话题,几千万人同时订阅,状态数组OOM,推送服务全挂,2亿用户收不到推送通知,产品经理在群里@了我一百多次。越想精确标记每个用户的订阅状态,越把推送服务整到反复订正。 我认为这大概是我做推送以来最反直觉的经历:明明每一步都在往「更省内存、更快判定」的方向走,结果却是一步一个坑。直到我放弃自己造轮子,才真正找到解药。## 1. 从list到各种主流方案:数据一涨,全线OOM/TLE!### 1.1 list[bool]:内存黑洞pythonsubscribed = [False] * 200_000_000 # 2亿用户2亿元素,每个指针8字节,光指针1.6GB,加上对象头轻松突破2GB。服务器一共16GB,光这一个数组干掉八分之一。### 1.2 array(‘b’):省内存但慢pythonfrom array import arraysubscribed = array('b', [0]) * 200_000_000内存降到200MB,但每次索引访问要装箱拆箱,推送高峰期每秒几十万次订阅判定,P99延迟直接飙到3秒。### 1.3 numpy.ndarray:判定快但订阅退订灾难pythonimport numpy as npsubscribed = np.zeros(200_000_000, dtype=np.bool_)向量化筛选快,但用户订阅/退订是高频动态操作,numpy定长数组insert/append全量拷贝,2亿元素拷贝一次200MB,高峰期CPU直接打满。### 1.4 scipy.sparse:语义错位存非零坐标和值,布尔值冗余;int64坐标8字节;API是矩阵那套不是数组操作。### 1.5 小结| 方案 | 内存(2亿bool) | 订阅判定 | 订阅/退订 | 稀疏场景 ||—|—|—|—|—|| list[bool] | ~1.6GB+ | 慢 | 快 | 浪费 || array(‘b’) | ~200MB | 慢 | 慢 | 浪费 || numpy.ndarray | 200MB | 快 | 灾难 | 浪费 || scipy.sparse | 看稀疏度 | 慢 | 慢 | 语义错位 |## 2. 破局思路:混合存储### 2.1 内存墙CPU每秒几十亿次运算,内存每秒几GB读写,差两个数量级。数据塞不进缓存就得跑远路去主存,慢100倍;再大就Swap慢100万倍。时间和空间是独立维度,省内存的本质是让数据离CPU更近。### 2.2 构想:自动变速箱订阅状态有个特点:大部分话题只有少数用户订阅(稀疏),热门话题大部分用户订阅(密集),新话题刚上线时几乎没人订阅(极稀疏)。不同话题密度不同,同一话题密度也随时间变化。但密度变化不是每秒发生的——一个话题从冷变热要几小时,从热变冷要几天。mermaidflowchart LR A["订阅数据"] --> B{"订阅密度有多高?"} B -->|"高密度"| C["三档:位图紧凑存储"] B -->|"低密度"| D["一档:只存订阅者下标"] C --> E["自动换挡器"] D --> E E --> F["对外统一接口"]换挡应该是低频的:创建时定挡,话题上了热门或热度消退时手动调一次optimize()。同事问我「来回抖怎么办」,我又噎住了。## 3. 自己做,十几天疼到怀疑人生- 第一天:写了混合订阅数组,稀疏场景几MB,觉得自己天才。- 第二天:阈值写死50%,话题热度在阈值附近抖动,疯狂来回切。- 第三天:滞回区间和实际存储对不上,订阅状态错乱,没订阅的用户收到推送。- 第四天:稀疏区array(‘I’)存用户ID,越界不报错静默写错位置。- 第五天:批量订阅接口把「按位置标记」和「按值过滤」写串了。- 第六天:退订后count(True)对不上,稀疏区删除后索引没压缩。- 第七天:in运算符每次全量扫描,2亿元素查一次好几秒。- 第八天:统计订阅数的方法缓存没失效,数字忽大忽小。- 第九天:换挡瞬间重建内部结构,大V发帖时卡了几百毫秒。- 第十天:pickle序列化存进去读出来全乱了。- 第十一天:查找第一个订阅者,稀疏区返回下标表位置不是真实位置。- 第十二天:2000多行代码一堆边界条件没处理,心态崩了。第十三天我意识到换挡不该是高频动作。从零锤生产可用的混合布尔数组不是一个人两个月的事。## 4. 转机:被一句话点醒发帖后评论区全在安利bool-hybrid-array。一条评论点醒我:> 「换挡只在创建时和调用optimize()时发生,平时insert、pop、赋值都不换挡,根本不会来回抖。你把换挡时机搞错了——换挡是低频动作,不是高频动作。」对啊!话题上热门时调一次optimize(),热度消退后再调一次,就够了。pythonfrom bool_hybrid_array import BoolHybridArr# 2亿用户,只有5%订阅了这个话题subscribed = BoolHybridArr(i % 20 == 0 for i in range(200_000_000))print(repr(subscribed))print(subscribed.memory_usage(detail=True))100万元素10%密度场景下list约1MB,BoolHybridArray约100KB,省约90%。我用tracemalloc验证过。memory_usage(detail=True)是库自己算的,别信我也别信它,信你自己的测量。BoolHybridArr是工厂函数,转成BoolHybridArray实例。内部索引小的位置用numpy.ndarray密集存储,索引大的位置用array.array稀疏存储,split_index决定分界点。这个设计源于作者做线性筛时的真实痛点。## 5. 同类开源方案横向对比### 5.1 RoaringBitmap:订阅者集合的工业标配pythonfrom roaringbitmap import RoaringBitmapsubscribers = RoaringBitmap()subscribers.add(123456)print(123456 in subscribers)优势:稀疏极省空间,并交差运算极强(查两个话题的共同订阅者、给订阅了A但没订阅B的人推消息极快)。局限:不是数组没有arr[i]语义,不支持动态append/pop,不保留长度。### 5.2 bitarray和pyarrowbitarray每值1bit,2亿元素25MB,保留数组语义但定长无稀疏优化。pyarrow.BooleanArray位压缩,强在列式跨语言,但不可变每次修改重建。### 5.3 对比表| 方案 | 2亿bool内存(5%订阅) | 数组语义 | 动态追加 | 稀疏自适应 | 集合运算 | 典型场景 ||—|—|—|—|—|—|—|| list[bool] | ~1.6GB+ | 有 | 有 | 无 | 无 | 小规模 || numpy.ndarray | 200MB | 有 | 无 | 无 | 有 | 密集定长 || bitarray | 25MB | 有 | 麻烦 | 无 | 有 | 密集位压缩 || pyarrow.BooleanArray | 25MB | 有 | 无 | 无 | 有 | 列式存储 || scipy.sparse | 看稀疏度 | 无 | 无 | 有 | 弱 | 数值矩阵 || RoaringBitmap | ~5MB | 无 | add/remove | 有 | 极强 | 订阅者集合、交叉推送 || bool-hybrid-array | ~5MB | 有 | 有 | 有 | 有 | 布尔数组、动态增删 |### 5.4 两种思路- RoaringBitmap适合「集合」:数据本质是「一堆订阅者ID」,整天问「这个ID在不在集合里」,还要做交集差集(共同订阅者、差集推送)。选RoaringBitmap。- bool-hybrid-array适合「数组」:数据本质是「一个很长的布尔序列」,总在关心「第i个用户订阅没」,序列要动态增删。选bool-hybrid-array。前者是集合,后者是数组。认清边界比会用工具更重要。## 6. 缺点与适用边界第一,optimize()是低频操作,话题上热门和热度消退时各调一次,别每次订阅退订都调。第二,换挡瞬间O(n)全量拷贝,2亿规模可能几百毫秒,别在推送高峰期调。第三,不是线程安全的,订阅线程和推送线程并发要加锁。第四,生态年轻,196个版本迭代快,没有RoaringBitmap十年验证。第五,密集场景反向稀疏——大部分为True时只记少数False下标,空间反而比numpy省。均匀分布才打平。第六,memory_usage(detail=True)数字是库自己算的,生产前自己验证。适用:稀疏+动态更新+单线程+数组语义。纯集合运算用RoaringBitmap,均匀定长用numpy。pip install bool-hybrid-array,MIT协议,核心类BoolHybridArray,工厂函数BoolHybridArr,依赖numpy。别信我,信你自己的测量。

536

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



