Google 每天同时跑着成千上万个 A/B 实验——搜索、YouTube、Maps、Ads,每个产品都有自己的服务集群,每个集群又拆成几十个微服务。实验多了,问题就来了:用户在搜索页被分到实验 A,跳到结果页却被分到实验 B;曝光日志漏记了一条,结论就偏了;两个实验同时改同一个按钮的颜色,数据谁也说不清。
最近 Google 公开了它跨舰队的大规模 A/B 实验系统设计,核心目标只有三个词:标准化分配、统一曝光日志、一致配置下发。下面拆开看它怎么做的,以及我们自己能怎么落地。
实验分配:一个用户,一条确定性路径
最怕的是同一个用户在不同服务里被分到不同组。Google 的做法是在实验分配层引入一个全局实验框架(Experiment Framework),所有服务不再各自随机,而是通过统一的分配服务拿到分组结果。
关键设计点:
- 用户 ID 哈希 + 实验盐值:用
hash(user_id + experiment_salt) % 100决定分组,同一用户同一实验永远落到同一桶。换盐值就换分组,但不换盐值就不会漂移。 - 分层实验(Layered Experiments):把参数空间分成互不相交的层。同一层内实验互斥,不同层之间实验独立。这样"按钮颜色实验"和"排序算法实验"可以并行,不会互相污染。
- 冲突检测与自动回避:分配服务在注册新实验时检查是否与已有实验抢占同一参数层,如果冲突就拒绝或建议换层。
这意味着,一个用户从搜索首页到广告落地页,分组身份始终一致,数据可归因。
曝光日志:谁被谁触达,一条都不能丢
分配只是第一步,第二步是记录谁在什么时候被哪个实验触达了。Google 要求所有服务在实验参数生效的那一刻,必须写一条曝光日志(Exposure Log)。
曝光日志不是"用户访问了页面"那么粗,而是精确到:
- 哪个实验 ID
- 哪个参数名
- 参数值是什么
- 用户当时的分组
- 触达的时间戳与请求上下文
这些日志统一写入一个中央日志管道,下游的分析系统直接消费,不需要各团队自己拼数据。
一个容易踩的坑:延迟加载的参数。如果实验参数是在用户滚动到页面底部才生效的,曝光日志必须在生效点写,而不是在页面加载时写。Google 的框架要求参数消费方在真正读取参数值时才触发日志,而不是在分配时。
配置下发:实验参数像代码一样滚动发布
实验参数不是硬编码在服务里的,而是通过配置中心下发。Google 的做法类似内部版的 Feature Flag 服务:
- 实验平台定义实验 → 生成分组配置
- 配置中心将分组映射为参数值(比如
button_color: blue) - 配置以原子版本推送到各服务节点
- 服务启动或热加载时读取配置,按当前用户分组取对应参数值
配置下发要求原子性和版本一致性——不能让一半节点拿到新配置、一半还在用旧配置,否则同一个用户两次请求可能看到不同行为。Google 用的是类似 Fuchsia 配置分发的设计:配置包带版本号,节点只有确认完整拉取后才切换。
实战:用 Python + YAML 搭一个最小分层实验框架
下面给一个可以直接跑的迷你实现,演示分层分配、曝光日志和配置下发三个核心环节。生产环境你会换成 Redis/Kafka/Feature Flag 服务,但逻辑是一样的。
第一步:定义实验配置(YAML)
# experiments.yaml — 分层实验定义
layers:
ui_layer:
description: "界面相关参数,层内实验互斥"
experiments:
button_color_test:
salt: "btn_color_2024q3"
params:
button_color:
control: "gray"
treatment: "blue"
traffic_split: { control: 50, treatment: 50 }
font_size_test:
salt: "font_sz_2024q3"
params:
font_size_px:
control: 14
treatment: 18
traffic_split: { control: 50, treatment: 50 }
algo_layer:
description: "算法相关参数,与 ui_layer 互不影响"
experiments:
ranking_algo_test:
salt: "rank_algo_v2"
params:
ranking_weight:
control: 1.0
treatment: 1.3
traffic_split: { control: 70, treatment: 30 }
第二步:分配 + 曝光 + 配置读取(Python)
# experiment_framework.py — 可直接运行
import yaml, hashlib, json, time, logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
def load_config(path="experiments.yaml"):
with open(path) as f:
return yaml.safe_load(f)
def assign_group(user_id: str, experiment: dict) -> str:
"""确定性分组:同一用户同一实验永远同一组"""
salt = experiment["salt"]
hash_val = int(hashlib.sha256(f"{user_id}:{salt}".encode()).hexdigest(), 16)
bucket = hash_val % 100
splits = experiment["traffic_split"]
cumulative = 0
for group, pct in splits.items():
cumulative += pct
if bucket < cumulative:
return group
return list(splits.keys())[-1] # fallback
def resolve_params(user_id: str, config: dict) -> dict:
"""遍历所有层和实验,返回该用户的完整参数集"""
params = {}
for layer_name, layer in config["layers"].items():
for exp_name, exp in layer["experiments"].items():
group = assign_group(user_id, exp)
for param_name, values in exp["params"].items():
params[param_name] = values[group]
# 曝光日志:在真正读取参数时记录
log_exposure(user_id, exp_name, param_name, group, values[group])
return params
def log_exposure(user_id, exp_name, param_name, group, value):
"""写曝光日志(生产环境换成 Kafka/BigQuery 管道)"""
record = {
"timestamp": time.time(),
"user_id": user_id,
"experiment_id": exp_name,
"param_name": param_name,
"group": group,
"value": value,
}
logging.info(json.dumps(record, ensure_ascii=False))
# ---- 运行示例 ----
if __name__ == "__main__":
config = load_config()
# 模拟三个用户
for uid in ["user_001", "user_002", "user_003"]:
print(f"\n--- {uid} 的实验参数 ---")
params = resolve_params(uid, config)
print(params)
运行方式:
# 依赖:pip install pyyaml
python experiment_framework.py
你会看到每个用户拿到一组确定性参数,同时每条参数读取都产出一条曝光日志 JSON。换一个 user_id 再跑,分组结果稳定不变——这就是确定性分配的核心。
第三步:模拟配置热加载(Bash)
生产环境配置中心会推送版本化配置包,服务节点定期拉取。用简单脚本模拟这个循环:
# watch_config.sh — 每 30 秒检查配置版本,有更新就触发热加载
CONFIG_URL="http://config-center.internal/experiments.yaml"
LOCAL_PATH="/etc/app/experiments.yaml"
CURRENT_HASH=""
while true; do
NEW_HASH=$(curl -s "$CONFIG_URL" | sha256sum | cut -d' ' -f1)
if [ "$NEW_HASH" != "$CURRENT_HASH" ]; then
echo "配置变更 detected,拉取新版本..."
curl -s "$CONFIG_URL" -o "$LOCAL_PATH"
CURRENT_HASH="$NEW_HASH"
# 通知应用热加载(比如发信号或调 admin API)
kill -USR1 $(pgrep -f experiment_framework) 2>/dev/null || true
echo "已切换到配置版本 $NEW_HASH"
fi
sleep 30
done
注意:
kill -USR1只是示意,实际应用需要自己实现信号处理或 admin 接口来重新加载 YAML。
落地时的取舍与检查清单
Google 的系统是给全球舰队用的,复杂度极高。大多数团队不需要那么重,但有几个原则值得直接拿过来:
必须做的:
- ✅ 分组用确定性哈希,不要每次随机
- ✅ 实验分层,层内互斥、层间独立
- ✅ 曝光日志在参数消费点写,不是在分配点写
- ✅ 配置下发带版本号,节点确认完整后才切换
可以简化的:
- 🔲 冲突检测——实验少于 20 个时人工检查就行,不需要自动系统
- 🔲 全局分配服务——单体应用直接本地计算哈希,不需要 RPC
- 🔲 实时配置推送——小团队用定时拉取(30 秒间隔)足够,不需要长连接
容易踩坑的:
- ❌ 两个实验改同一个参数却没放在同一层 → 数据互相污染,谁也说不清效果
- ❌ 曝光日志写在页面加载时,但参数是懒加载的 → 触达率虚高
- ❌ 配置只推到一半节点就上线 → 同一用户两次请求行为不一致
Google 这套系统的核心洞察其实很简单:A/B 实验不是"加个随机数"就完了,它是一个分布式数据一致性问题。分配要一致,日志要一致,配置要一致。把这三个环节标准化了,剩下的分析才有根基。