尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Python数据结构:2亿用户订阅状态标记,list爆内存numpy定长灾难,bool-hybrid-array混合存储方案

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