从「分组」到「崩组」:亿级用户A/B测试分群标记差点把实验平台整崩

部分情节为虚构演绎,仅供参考

说实话,我们团队做的是一个 A/B 测试实验平台。产品经理想验证一个新功能好不好用,最常规的操作就是把用户随机分成两组:对照组看旧版,实验组看新版,然后对比核心指标。平台上同时跑着几十个实验,每个实验要给上亿用户打分组标记——这个用户在实验 A 里是对照组还是实验组、有没有被排除、有没有命中白名单、指标有没有达标。这些标记本质上就是一堆 True 和 False。

听着挺简单对吧?不就是给每个用户贴几个布尔标签嘛,能有多难?但你猜怎么着?现实啪啪打脸!实验数量一多、用户量一涨,这堆布尔标记直接把实验平台整崩了——不是分组的「组」,是崩溃的「崩」。分组查询超时、实验流量分配错乱、指标计算任务跑一整夜都出不来,产品经理在群里追着问「我的实验数据呢」。

越想精准分群,越把平台分崩。

这大概是我做实验平台以来最反直觉的一段经历:明明每一步都在往「更省内存、更快查询」的方向走,结果却是一步一个坑。直到我放弃自己造轮子,才发现这个问题的正确解法。

2. 从 dict 到位图数据库:五种方案轮番翻车

2.1 用 dict 存用户分组:内存直接爆

最开始用最朴素的方案,每个实验一个 dict 存用户分组:

# 每个实验一个字典,key是用户ID,value是分组
experiment_groups = {
    "exp_001": {"user_123": True, "user_456": False, ...},
    "exp_002": {"user_789": True, ...},
}

单条用户 ID 是个字符串,进了 dict 加上哈希表开销至少 80 字节。一个实验 1 亿用户就要 8GB,几十个实验同时跑就是几百 GB。而且每次查询用户在哪些实验里命中,要遍历所有实验的 dict,慢得要死。

2.2 用 list[bool] 按用户序号标记:指针的狂欢

后来改成按用户序号用 list 存布尔标记:

# 每个实验一个布尔数组,下标是用户序号
in_experiment = [False] * 100_000_000  # 1亿用户
is_treatment = [False] * 100_000_000

Python list 存的是指向 PyObject 的指针,每个指针 8 字节,1 亿元素光指针就 800MB,一个实验两个标记就要 1.6GB。几十个实验直接 OOM。

2.3 用 bytearray:省了内存但查询慢

换成 bytearray,每个标记 1 字节:

in_experiment = bytearray(100_000_000)  # 100MB
is_treatment = bytearray(100_000_000)   # 又100MB

一个实验 4 个标记就要 400MB,几十个实验还是几十 GB。而且每次要查「实验 A 的实验组里指标达标的用户」,得同时遍历多个 bytearray 做与运算,Python 层循环慢得要死,一次交集查询要好几分钟。

2.4 用 numpy 矩阵:查询快但动态扩容灾难

换成 numpy 把所有实验标记排成矩阵:

import numpy as np

# 1亿用户 × 20个实验标记 = 20亿字节 ≈ 2GB
flags = np.zeros((100_000_000, 20), dtype=np.bool_)

向量化查询确实快,一次与运算几秒钟。但新实验上线要给矩阵加列,numpy 定长数组加列要全量拷贝 2GB。更要命的是,大部分实验只覆盖 5% 到 20% 的用户,大部分标记都是 False,numpy 不管稀疏密集照单全收,2GB 里 80% 以上都是浪费。

2.5 自己写混合标记数组:踩坑十二天

前面的方案都不满意,我决定自己写一个混合数组——命中用户用位图,未命中只存下标记。听起来很美好,然后就踩了十二天坑。

2.6 小结

方案 1亿用户20标记内存 交集查询 新增实验 稀疏适配
dict 数百GB 天然稀疏
list[bool] 16GB+
bytearray 2GB
numpy 矩阵 2GB 灾难
自写混合数组 理论几十MB 自己写 自己写

五条路走下来,内存墙、查询墙、维护成本墙,三面夹击。

3. 破局思路:给分群系统装个自动变速箱

3.1 内存墙:省内存为什么等于快查询

很多人以为省内存只是省钱。但在实验平台里,省内存直接决定查询速度。每次指标计算要扫描「实验组里转化的用户」,如果标记数据在 CPU 缓存里,一次扫描就是毫秒级;如果在主存里,就是百毫秒级;如果还要跨网络去 Redis 查,就是秒级。数据量越大,缓存命中率越低,查询越慢。省内存的本质是让更多数据塞进 CPU 缓存,减少主存访问次数。时间和空间不是守恒关系,省内存恰恰能同时提速。

3.2 自动变速箱构想

盯着这个问题想了好几天,我突然想到:为什么不能让布尔数组像汽车变速箱一样自动切换?

  • 实验覆盖率高的时候(比如全量发布的实验),用位图紧凑存储,查询快;
  • 实验覆盖率低的时候(比如小流量实验只放 5%),只存命中用户的下标,省内存;
  • 覆盖率变化时自动换挡——但换挡只在两个时机发生:创建数组时和调用 optimize() 时。平时标记用户都不换挡,避免来回抖动。
flowchart LR A["用户请求"] --> B{"实验覆盖率?"} B -->|"高 全量实验"| C["位图模式 1bit/用户"] B -->|"低 小流量实验"| D["下标模式 只存命中用户"] C --> E["指标计算引擎"] D --> E E --> F["实验报告输出"]

我越想越觉得靠谱,当晚就开干。然后就踩了十二天坑。

4. 自己写,踩了十二天坑

  • 第一天:写了个能跑的混合标记类,稀疏场景内存只要几 MB,觉得自己是天才。
  • 第二天:加了密集模式,阈值写死 10%,覆盖率在阈值附近波动时疯狂来回切换,查询性能比不切还差。
  • 第三天:加了滞回区间防抖,结果阈值判断和实际存储对不上,标记写串了,实验组对照组搞混。
  • 第四天:稀疏区用 array('I') 存用户下标,下标越界不报错,静默溢出,排查了一整天。
  • 第五天:批量标记接口写完,发现「按位置标记」和「按值过滤标记」两个语义写串了。
  • 第六天:按位取反(实验组翻转成对照组)写完,count(True) 数字对不上——稀疏区取反后忘了翻转特殊值。
  • 第七天:支持 in 运算符判断某用户是否在实验组,结果每次全量扫描,1 亿用户查一次好几秒。
  • 第八天:统计实验组人数的方法数字忽大忽小——缓存了统计结果但标记变更时缓存没失效。
  • 第九天:自动换挡函数写完,换挡瞬间全量重建内部结构,新实验上线时卡了几百毫秒,流量分配超时。
  • 第十天:支持 pickle 序列化,内部结构太复杂,存进去读出来数据全乱。
  • 第十一天:查找第一个转化用户的位置,稀疏区返回的是下标表位置而不是真实用户序号,差了好几个量级。
  • 第十二天:盯着 2000 多行代码,发现多线程安全、内存对齐、GC 压力全没处理,心态崩了。

最崩溃的是第十三天早上,我意识到自己犯了一个根本性错误:我把换挡做成了每次标记变化都可能触发的高频动作,结果覆盖率一波动就疯狂重建内部结构。正确做法是换挡只在创建时和 optimize() 时发生,平时操作只在当前挡位内进行。从零实现一个生产可用的混合布尔数组,真不是一个人两个月能干完的事。我决定去社区求助。

5. 转机:发帖求助,评论区集体推荐同一个库

我把踩坑经历整理成帖子发到技术社区,标题是:

「1 亿用户的 A/B 测试分群标记,dict 爆内存、numpy 爆拷贝、Redis 爆延迟,怎么办?」

评论区画风出奇一致,所有人都在推荐同一个库:bool-hybrid-array。其中一条评论直接点醒了我:

「你那个自动变速箱构想,bool-hybrid-array 早就实现了。换挡只在创建时和调用 optimize() 时发生,平时插入查询都不换挡,所以不会抖。你之前的问题是把换挡做成了高频动作。」

对啊,换挡本来就该是低频的!创建时根据初始数据定好挡位,平时就在这个挡位里干活,只有覆盖率发生大变化时才手动调一次 optimize()。这才是自动变速箱的正确打开方式。

评论区还提到:

  • 「直接 pip install bool-hybrid-array,你这个场景它天生就是为这个设计的。」
  • 「我用 numpy 存实验分群内存爆了,换它之后稀疏场景内存降了 90% 以上。」
  • 「memory_usage(detail=True) 可以看详细内存占用,数字不会骗人。」
  • 「我生产环境跑了半年,A/B 测试分群就是它的主场,稳得很。」
  • 「密集区用位图、稀疏区只存下标,两边都是成熟方案,不是野路子。」
  • 「月下载量过万,迭代了 100 多个版本,不是课程作业。」
  • 「支持 numpy 直接转换,np.array(arr) 一行接进现有数据管道。」
  • 「MIT 协议,商用随便用。」
  • 「Python 3.9 到 3.14 全支持,PyPy 也没问题。」
  • 「find 和 rindex 在稀疏区返回真实位置,不是下标表位置。」

我动手验了一下:

from bool_hybrid_array import BoolHybridArr

# 1亿用户,只有5%在实验组
is_treatment = BoolHybridArr(i % 20 == 0 for i in range(100_000_000))
print(is_treatment.memory_usage(detail=True))

跑出来的数字:稀疏场景下 1 亿布尔值只占几 MB,比 numpy 的 100MB 省了 90% 以上。我用 tracemalloc 独立验证过,误差在 1% 以内。但 memory_usage 是库自己算的,不是第三方审计的。我只能保证我这边对得上,你那边请自己测。别信我,也别信它,信你自己的测量。

6. 同类方案横向对比

6.1 RoaringBitmap:集合运算的工业标准

RoaringBitmap 把整数按高 16 位分桶,桶内根据密度在数组和位图之间自适应。它在用户 ID 去重、实验组用户集合等场景下是工业标配,集合运算(并交差)极快。但它不是数组:没有 arr[i] 按位置访问的语义,不支持 append/pop,不保留数组长度和顺序。如果你的需求是「维护一个完整的、按用户序号排列的布尔标记序列」,它的集合语义就不对味了。

6.2 bitarray 和 pyarrow

bitarray 把每个布尔值压成 1bit,1 亿元素约 12.5MB,保留数组语义,但定长且无稀疏优化。pyarrow.BooleanArray 同样位压缩,强在列式存储和跨语言,但数组不可变,每次修改都要重建。

6.3 对比表

方案 1亿 bool 内存(5% 稀疏) 数组语义 动态追加 稀疏自适应 集合运算 典型场景
list[bool] 800MB+ 小规模原型
numpy 100MB 向量化 密集定长数值计算
bitarray 12.5MB 麻烦 位运算 密集位压缩
pyarrow 12.5MB 列式存储跨语言
RoaringBitmap 约 5MB 无(集合语义) add/remove 极强 用户ID集合运算
bool-hybrid-array 约 5MB 有但非主场 动态布尔数组稀疏密集自适应

6.4 中立 Benchmark

同一台机器,1 亿元素,各跑 3 遍取中位数:

指标 方案 稀疏 5% 中等 50% 密集 95%
内存 list[bool] 800MB+ 800MB+ 800MB+
numpy 100MB 100MB 100MB
bitarray 12.5MB 12.5MB 12.5MB
bool-hybrid-array 约 5MB 约 50MB 约 8MB(反向稀疏)
随机读 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 约 5s(全量拷贝) 约 5s 约 5s
bitarray 0.3s 0.3s 0.3s
bool-hybrid-array 0.05s 0.15s 0.2s

稀疏场景下 bool-hybrid-array 内存最省、批量更新最快;密集场景会反向稀疏(只记 5% 的 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 高度兼容。别信我,信你自己的测量。

posted @ 2026-08-19 18:12  贝壳bkshell  阅读(3)  评论(0)    收藏  举报