Google 全球服务舰队上的大规模 A/B 实验系统:如何让分布式实验不再打架

2026-06-03 24 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:11 分钟

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 服务:

  1. 实验平台定义实验 → 生成分组配置
  2. 配置中心将分组映射为参数值(比如 button_color: blue
  3. 配置以原子版本推送到各服务节点
  4. 服务启动或热加载时读取配置,按当前用户分组取对应参数值

配置下发要求原子性和版本一致性——不能让一半节点拿到新配置、一半还在用旧配置,否则同一个用户两次请求可能看到不同行为。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 实验不是"加个随机数"就完了,它是一个分布式数据一致性问题。分配要一致,日志要一致,配置要一致。把这三个环节标准化了,剩下的分析才有根基。


相关推荐