AB 实验平台设计方案

背景

变好/变差都希望有一个科学的衡量指标, 确保我们都在朝着同一个方向努力!

目标

  • 需要支持灵活/快捷地创建新实验
  • 需要支持数十个, 甚至上百个实验同时进行
  • 需要支持准实时, 以及 T+1 的实验分析

核心问题

  1. 如何做用户分组, 以及流量调控
  2. 如何做数据埋点, 与实验解耦
  3. 如何做数据存储, 支持准实时/T+1实验分析

用户故事

  1. 创建实验的 Flag, 并设置默认值, (此时还没有实验组的概念), 开发代码, 上线服务
  2. 创建实验的 Layer, 关联 Flag (可以多个), 不同实验组是 Flag 不同取值的组合
  3. 上线实验, 控制实验组的流量
  4. 通过实验 LayerId 查看分析报告

实现细节

Flag 说明

  1. AB 实验的基本原理是 “控制变量法”, 这里的 Flag 就对应实验的变量, 取值可能是布尔或整型.
  2. 一个实验只关联一个 Flag, 那么就是 “单因素实验设计”; 关联多个 Flag, 那么就是 “多因素实验设计”.
  3. Flag 的默认值对应的处理逻辑与对照组保持一致, 这样即使带实验控制的逻辑上线, 也不会影响线上效果.
  4. 在整个实验平台中, Flag Name 全局唯一, 这样的好处是方便 Flag 与 Layer 解耦, 甚至可以先在代码逻辑中使用 Flag Name, 而后再到实验平台配置.
if (getBoolFlagValueOrDefaultValueByUser(Flag Name, User ID)) {
  // 实验组逻辑
} else {
  // 对照组逻辑
}

为什么用 Layer 来表示实验

为了让同时进行的实验, 互不干扰, 我们可以先对用户 (随机) 分层, 每一层仅进行一个实验. 拿切蛋糕举例, 你要做实验了, 先按你的需求横切一片给你, 然后你自己在这一片上分组, 开实验, 这样肯定互不干扰, 这一片就是一个 Layer, 因此用 Layer 这个英文单词来表示实验.

这种理想方式不一定适用, 首先需要用户体量很大, 其次是实验结束, 如何放回. 对于公司现状是不适合用这种方式的, 我们需要在每次实验中都用完所有的用户流量. 在这种情况下, 我们要让不同实验的实验组是正交的, 这就好比切方形蛋糕, 竖着切一刀, 左边染成红色, 右边染成绿色; 再切一刀, 希望保证两部分的红/绿占比一致, 那么我们需要垂直第一刀切; 如果再切一刀, 还希望保证两部分的四种组成比例一致, 就需要同时垂直前两刀, 就是要横着切.

当两部分各种组成比例一致, 就可以认为在实验前两者是平衡的, 通过调节变量, 控制实验组的表现, 与对照组进行对照, 引入显著性检验.

回到前面的切蛋糕, 然后需要再增加一个实验, 再切一刀, 还是希望保证两部分的八种组成比例一致, 我们要怎么实现呢? 只需要在切之前, 把蛋糕揉搓一番, 即先将这个 Layer 统一打散, 对应代码如下

public static int getBucketNo(String paramId, int layerId, int bucketTotalNum, String seed) {
    if (0 == bucketTotalNum) {
        bucketTotalNum = BUCKET_TOTAL_NUM;
    }
    // 计算 MD5
    String destKey = (StringUtils.isNotBlank(paramId) ? paramId : "") + layerId;
    if (StringUtils.isNotBlank(seed)) {
        destKey = destKey + seed;
    }
    String md5Hex = DigestUtils.md5Hex(destKey);

    long hash = Long.parseLong(md5Hex.substring(md5Hex.length() - 16, md5Hex.length() - 1), 16);
    if (hash < 0) {
        hash = hash * (-1);
    }

    // 取模
    return (int) (hash % bucketTotalNum) + 1;
}

如何验证这种方式的有效性, 那就统计一下分布占比.

获取实验组的配置信息

  • Layer (实验)
    • Exp (实验组)
      • 分桶 list
      • Flag (控制因子, 是实验的附加信息)
      • WhiteList (白名单)

注意: Flag Name 全局唯一

Client 通过 Caffeine 缓存每 10 秒同步一次实验平台的全部配置信息, 如下逻辑, 详情参见 com.xiaohongshu.imitation.client.service.ExperimentData.

private LoadingCache<String, ExpDicInfo> expInfoCache = Caffeine.newBuilder()
        .refreshAfterWrite(10, TimeUnit.SECONDS)
        .expireAfterAccess(25, TimeUnit.SECONDS)
        .maximumSize(4)
        .build(k -> updateExpInfo());

Client getXxxFlagValueOrDefaultValueByUser 方法 Flag Name + User ID 获取控制因子的值, 计算逻辑如下:

  1. 由于 Flag 是全局唯一的, 根据 Flag 反查出 Layer ID

  2. User ID + Layer ID (字符串拼接) => md5 => 取后 15 个字符, 转为 Long => 取模 => 分桶 => 分桶映射 => 哪个实验组 => Flag 配置信息 将 ID 映射为分桶的过程参见 com.xiaohongshu.imitation.common.utils.BucketUtils, 逻辑如下

  3. 实验组流量控制, 用户先分桶, 在映射到对应的实验组, 连续 id, 压缩存放, 分组的变换, 全部存成历史记录

数据打点

线上 SDK 打点, 不管实验信息, 仅记录如下数据

user_id, timestamp, event, end, action...

打点数据发送到 kafka, 数据收集模块, 消费 kafka 消息, 然后做如下操作

  1. 对于打点数据进行校验/清洗, 过滤出我们需要的点位数据
  2. join 用户画像, 带上核心的用户人群画像字段
  3. 根据实验元信息, 计算出用户此时所在的所有实验组 expId List
  4. 将得到的数据
    1. 发送到 influxdb or ClickHouse 做准实时的实验监控
    2. 数据落到 hive 表, 做离线分析
    3. 数据落文件, 用于模型训练

统计分析

将用户打点日志, 通过离线计算, 形成用户维度的统计明细表, 放入 ClickHouse, 同时 join 上用户所在的所有分组 exp list. 具体看实验结果时, 就是一个聚合运算, 类似于

select
  expi,
  count(1) as dau,
  sum(imp) as imp
  ...
from ck
where exp list contains exp1, exp2
group by exp
  • 对于次留, 次7日留, 都是在 ck 之前, hive 先计算好, 形成一个 bool 值, 就是前用户昨天是否访问过
  • 如果在一天之内, 用户的分组存在变动, 这种数据会作为脏数据, 丢弃
  • 实验平台是 T+1 的