A/B 测试(A/B Testing)是现代产品迭代的「科学仪器」:任何一次改版、推荐策略调整、定价实验,都要用实验数据而不是拍脑袋来决定上线与否。它的技术难点不在「随机分两组」,而在——分流必须稳定可复现、实验之间不能互相污染、指标要能正确归因、统计结论不能被系统性偏差带偏。本文按照系统设计面试的标准答题结构,设计一个支持多业务线、每秒百万级分流、支持互斥层与显著性检验的实验平台。
一句话:A/B 测试的核心是「同一用户每次进来都被稳定分到同一组」——分桶靠确定性哈希而非随机数,这是整个平台正确性的地基。
一、需求澄清与量级估算
1.1 需求澄清
- 实验类型:只有 A/B 两组的简单实验,还是要多组(A/B/C)、多因素(正交实验)?
- 分流维度:按用户 ID、设备 ID、还是请求 ID 分流?(决定一致性范围)
- 实验对象:前端 UI、后端策略(推荐/搜索/定价)、还是推送文案?
- 指标:只看单一主指标(如点击率),还是要有护栏指标(如崩溃率、延迟)?
- 流量管理:是否需要互斥层(同一用户不进入两个互斥实验)、流量分层复用?
明确假设(面向面试的合理假设):
| 需求项 | 假设 |
|---|---|
| 实验 | 支持多组、支持互斥层与流量分层 |
| 分流维度 | 用户 ID 为主,未登录降级设备 ID |
| 生效范围 | 前后端通用(SDK 拉取配置,服务端决策) |
| 指标 | 主指标 + 护栏指标 + 自定义指标 |
| 统计 | 频率派显著性检验 + SRM 检测 |
1.2 量级估算
| 指标 | 估算值 | 推导 |
|---|---|---|
| 日活用户 | 1 亿 | 多业务线合计 |
| 分流 QPS | ~100 万 | 每次请求都要判定实验组 |
| 同时运行实验 | ~2000 | 各业务线并行 |
| 指标事件 | ~1000 亿/天 | 曝光、点击、转化等埋点 |
| 实验配置大小 | ~MB 级 | 全量配置可本地缓存 |
| 分流延迟要求 | P99 < 1ms | 不能拖慢主请求 |
一句话:分流是热路径(每个请求都走),必须本地计算、零网络依赖;配置下发是冷路径,可异步拉取 + 缓存。两条路径分开设计。
二、高层架构设计
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 业务服务 │ │ 客户端 SDK│ │ 数据平台 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 分流(本地) │ 埋点上报 │ 查指标
┌────▼─────────────▼─────────────▼──────────┐
│ 实验平台 (Experiment Platform) │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ 实验管理 │ │ 分流引擎 │ │ 指标计算 │ │
│ │ (配置CRUD)│ │ (SDK/服务)│ │ (统计检验) │ │
│ └──────────┘ └──────────┘ └────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ 互斥层 │ │ 流量分层 │ │ SRM/AA 校验 │ │
│ └──────────┘ └──────────┘ └────────────┘ │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ 配置中心(推送) + 埋点管道(Kafka) + 数仓(OLAP) │
└─────────────────────────────────────────────┘
四层职责:
- 实验管理:实验创建、流量分配、上下线、审批流。
- 分流引擎:SDK/服务端按哈希分桶,本地决策,零网络依赖。
- 指标计算:从埋点管道聚合指标,跑显著性检验。
- 流量治理:互斥层、流量分层、SRM/AA 校验。
2.1 分流为什么必须在本地
分流在每个请求的热路径上,如果每次都要 RPC 问实验平台「我该进哪组」,延迟和可用性都无法接受。做法是:把实验配置(含分桶盐值、流量比例)全量下发到各业务进程/SDK 本地缓存,分流时纯本地哈希计算,实验平台挂了也不影响线上分流。
一句话:实验平台是「配置的生产者 + 结果的消费者」,而不是分流路径上的同步依赖——本地计算保证分流永远可用。
三、核心组件设计
3.1 哈希分桶(核心算法)
分桶要满足:确定性(同一用户每次结果相同)、均匀性(各组流量比例精确)、稳定性(扩缩流量时已入组用户不漂移)。
import hashlib
def bucket(unit_id: str, salt: str, n_buckets: int = 10000) -> int:
# 用 salt 隔离不同实验,避免同一用户在所有实验里都进同一组
h = hashlib.md5(f"{salt}:{unit_id}".encode()).hexdigest()
return int(h[:8], 16) % n_buckets # 落在 [0, n_buckets)
def assign(unit_id, exp):
b = bucket(unit_id, exp.salt, 10000)
acc = 0
for group in exp.groups: # 按组流量比例划分桶区间
acc += group.traffic_permille * 10 # 千分比 → 万分桶
if b < acc:
return group.name
return "control" # 未命中任何组 → 对照组
关键点:
- 盐值(salt):每个实验一个盐,保证不同实验的分桶独立(正交)。
- 分桶数:10000 桶,支持 0.01% 精细流量。
- 区间划分:组按累计流量区间切分,扩组时只扩大区间,已入组用户尽量不动(用一致性哈希思想)。
3.2 流量分层(Layer)与互斥(Mutual Exclusion)
| 机制 | 目的 | 做法 |
|---|---|---|
| 流量分层 | 让多个实验复用同一批流量但互不干扰 | 不同层用不同盐值,同层内流量互斥 |
| 互斥层 | 保证同一用户不同时进入互斥实验 | 互斥实验共用同一分桶空间,区间不重叠 |
分层: Layer1(UI层) 盐=ui ── 实验A占 [0,5000), 实验B占 [5000,10000)
Layer2(算法层) 盐=algo ── 实验C占 [0,3000) ← 与Layer1可重叠(正交)
互斥: 同层内,实验A与实验B区间不重叠 → 用户不会同时进A和B
分层的价值:不同层的实验维度不同(UI vs 算法),可以同时进行且互不污染;同层实验共享流量池,因此天然互斥。
3.3 实验配置下发
- 配置中心:实验配置(盐、组、流量、开关、受众定向)存配置中心,变更时推送到各业务节点。
- 本地缓存 + 版本号:节点本地缓存全量配置,带版本号;配置变更通过长连接/轮询增量下发。
- 兜底:拉取失败时用本地缓存旧版本;无缓存则全部进对照组(安全降级)。
- 受众定向:支持按城市、版本、用户分群定向,定向规则在分桶前评估。
3.4 指标采集与归因
- 埋点:曝光、点击、转化等事件带
unit_id+ 实验组标识(由分流时注入)上报到 Kafka。 - 归因:以分流时确定的组为准,而不是看埋点时刻的配置(防止实验中途改配置导致归因错乱)。
- 指标体系:主指标(如 CTR)、护栏指标(延迟、崩溃率、负反馈)、漏斗指标(曝光→点击→下单)。
埋点结构: { event, unit_id, exp_id, group, ts, ...props }
→ Kafka → 实时聚合(Flink) + 离线数仓(ClickHouse/Spark)
3.5 显著性检验
实验跑完要回答「差异是真实的还是随机波动」,核心是统计检验:
import math
from scipy import stats
def ab_test(control_conv, control_n, treat_conv, treat_n):
p1, p2 = control_conv / control_n, treat_conv / treat_n
p_pool = (control_conv + treat_conv) / (control_n + treat_n)
se = math.sqrt(p_pool * (1 - p_pool) * (1/control_n + 1/treat_n))
z = (p2 - p1) / se if se else 0
p_value = 2 * (1 - stats.norm.cdf(abs(z))) # 双尾
# 置信区间
se_diff = math.sqrt(p1*(1-p1)/control_n + p2*(1-p2)/treat_n)
ci = (p2 - p1 - 1.96*se_diff, p2 - p1 + 1.96*se_diff)
return {"lift": p2 - p1, "p_value": p_value, "ci95": ci,
"significant": p_value < 0.05}
| 概念 | 含义 | 常见陷阱 |
|---|---|---|
| p 值 | 差异由随机造成的概率 | p<0.05 不等于「效果大」 |
| 置信区间 | 效应的可能范围 | 区间跨 0 则不显著 |
| 统计功效 | 能检出真实效应的概率 | 样本不足导致「假阴性」 |
| 多重比较 | 同时看很多指标 | 不校正会假阳性暴涨(Bonferroni) |
| 序贯检验 | 边跑边看 | 随时看随时停会抬高假阳性 |
一句话:p<0.05 只说明「不太可能是随机」,不说明「差异重要」;平台必须同时给出效应量、置信区间和护栏指标,否则会诱导「只要显著就上线」。
四、数据模型
| 存储 | 用途 | 说明 |
|---|---|---|
| exp_config | 实验配置 | 盐、组、流量、定向、状态 |
| exp_layer | 流量分层 | 层名、盐、层内实验列表 |
| exp_mutex_group | 互斥组 | 互斥实验集合 |
| assignment_log | 分流日志 | unit_id→组的映射(可选,用于回溯) |
| metric_event | 指标事件 | Kafka,曝光/点击/转化 |
| exp_result | 实验结果 | 聚合后的指标与检验结果 |
配置表关键字段:exp_id、salt、status(草稿/运行/暂停/结束)、start_ts/end_ts、groups(jsonb)、audience(jsonb)。
五、关键流程
5.1 一次分流(热路径)
业务请求 → SDK.get_group(unit_id, exp_id)
1. 本地配置里查 exp_id(无则返回 control)
2. 检查状态:未运行 → control
3. 检查受众定向:不匹配 → control
4. 哈希分桶 → 命中区间 → 返回组名
5. 注入埋点上下文(exp_id, group)
全程无网络调用,P99 < 1ms
5.2 实验上线流程
1. 创建实验:定义假设、主指标、分流比例、受众
2. AA 校验:先让两组跑一段,验证无系统性差异
3. 灰度:5% → 20% → 50% 逐步放量,观察护栏指标
4. 正式运行:收集样本到足够功效
5. 分析:显著性检验 + 分群下钻 + 护栏检查
6. 决策:全量 / 迭代 / 下线
5.3 指标计算
实时: 埋点 → Flink 窗口聚合 → 每 5 分钟更新实验看板
离线: 埋点 → 数仓 → 每日跑完整检验(含分群、CUPED 方差缩减)
离线的每日全量检验、分群下钻、回溯重算都是典型的大批量定时作业,可复用 分布式任务调度 的编排能力,把「实验结束→自动出报告」串成 DAG。
六、可靠性与一致性
6.1 分流的一致性
- 确定性哈希:同一
unit_id+salt永远同一桶,跨机器、跨重启都一致(无状态)。 - 降级:配置拉不到时用旧缓存;完全没有则全进对照组,保证线上不因实验平台故障而异常。
- 未登录用户:用设备 ID 分流;设备 ID 也没有则用会话 ID(体验会抖动,可接受)。
6.2 SRM 与 AA 检测
- SRM(Sample Ratio Mismatch):实际分流比例与配置不符(如配置 50/50 实测 52/48),说明分流有 bug,此时实验结论不可信。用卡方检验检测。
- AA 测试:上线前让两组跑相同代码,验证指标无显著差异,排除埋点/分流偏差。
- 异常检测:某组样本量骤降、指标突变、护栏指标恶化时自动告警。
6.3 数据一致性
- 归因固定:以分流时的组为准,配置变更不影响已入组用户的归因。
- 事件去重:同一事件重复上报用
event_id幂等去重。 - 晚到数据:允许事件晚到(如离线补报),用事件时间窗口而非处理时间聚合。
一句话:实验结论的可信度建立在「分流比例正确(SRM 合格)+ 归因稳定 + 埋点完整」之上,任何一环出错,再漂亮的 p 值都是假的。
七、性能与扩展
- 本地分流:全量配置本地缓存,分流零网络,支撑百万 QPS。
- 配置增量下发:只推送变更的实验,减少带宽。
- 埋点异步:埋点走本地队列 + 批量上报,不阻塞主链路。
- OLAP 加速:指标聚合用 ClickHouse/Doris 列存,秒级响应多维下钻。
- 方差缩减:用 CUPED(用实验前数据做协变量)降低方差,小流量也能快速出结论。
容量与热点
- 2000 个并行实验 × 每实验几十组 ≈ 配置仅数 MB,全量缓存无压力。
- 埋点量 1000 亿/天,靠 Kafka 分区 + 列存压缩扛住。
八、权衡与备选
| 决策点 | 本文选型 | 备选 | 权衡说明 |
|---|---|---|---|
| 分流位置 | 本地 SDK | 服务端 RPC | 本地零延迟高可用;RPC 灵活但成瓶颈 |
| 分桶哈希 | MD5 + 取模 | 一致性哈希 | MD5 简单均匀;一致性哈希利于扩流量不漂移 |
| 检验方法 | 频率派(t/z 检验) | 贝叶斯 | 频率派通用;贝叶斯直观但需先验 |
| 指标存储 | ClickHouse | Druid/Spark | ClickHouse 快且便宜;Spark 适合重批处理 |
| 配置下发 | 推送 + 本地缓存 | 纯轮询 | 推送实时;轮询简单但延迟高 |
关键取舍
- 统计严格 vs 决策速度:严格检验要足够样本、跑够时间,与「快速迭代」冲突;可用序贯检验/贝叶斯做折中。
- 流量复用 vs 实验隔离:分层让流量复用(更多实验并行),但同层实验必须互斥。
- 实时 vs 准确:实时看板快但可能有晚到数据,最终结论以离线为准。
九、扩展场景与面试追问
9.1 推荐与广告的 A/B
推荐策略实验(召回/排序)天然适合 A/B,与 设计一个推荐系统 的在线排序结合,实验直接改排序模型;广告场景要额外看「广告收入 + 用户体验」双指标,参见 设计一个广告平台 。搜索排序实验同理,见 设计一个搜索引擎 。
9.2 正交实验与多因素
多个独立因素(如「按钮颜色 × 文案」)用正交实验(正交表)设计,用最少实验次数估计各因素主效应,避免全组合爆炸。
9.3 面试常见追问
| 追问 | 关键回答 |
|---|---|
| 怎么保证同一用户分到同一组? | 确定性哈希(unit_id + salt),无状态、跨机器一致 |
| 实验之间会互相干扰吗? | 同层实验互斥(共用分桶空间),不同层正交(不同盐) |
| p<0.05 就能上线吗? | 不能,还要看效应量、置信区间和护栏指标 |
| 为什么上线前要 AA 测试? | 排除分流/埋点系统偏差,验证两组同代码下无显著差异 |
| SRM 是什么? | 实际分流比例偏离配置,说明分流有 bug,结论不可信 |
| 小流量实验怎么快点出结论? | 用 CUPED 方差缩减 + 选择高灵敏指标 |
十、总结
| 模块 | 关键设计 | 一句话记忆 |
|---|---|---|
| 分桶 | 确定性哈希 + 盐 | 同人同组,跨机一致 |
| 流量治理 | 分层 + 互斥 | 不同层正交,同层互斥 |
| 下发 | 本地缓存 + 推送 | 分流零网络依赖 |
| 指标 | 埋点 + 列存聚合 | 归因固定、事件幂等 |
| 统计 | 显著性 + 护栏 | 显著≠重要 |
| 质量 | AA + SRM 检测 | 先验证系统无偏差 |
一句话:A/B 平台的面试核心是讲清楚「如何用确定性哈希保证分流稳定、分层与互斥如何管理流量、指标怎么采集归因、以及显著性检验的陷阱(显著≠重要、多重比较、序贯检验)」,把「分流本地化、AA/SRM 先验证」挂在嘴边,而不是只会跑个 t 检验。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。