PHP 怎么A/B测试设计

wen PHP项目 1

本文目录导读:

PHP 怎么A/B测试设计

  1. 最基础版(Cookie + 随机分流)
  2. 进阶版(Redis 存储 + 加权分流 + 数据埋点)
  3. 纯前端方案(JavaScript + 服务端 API)
  4. 生产级架构建议(PHP + 数据库 + 消息队列)
  5. 最佳实践与踩坑指南
  6. 开源工具推荐(可直接集成)

在 PHP 中实现 A/B 测试,核心是分流(决定用户进入哪个版本)和数据收集(记录转化),下面是一套从基础到进阶的设计方案,包含代码示例。


最基础版(Cookie + 随机分流)

适用场景:简单的页面样式或文案测试,不依赖用户系统。

<?php
function get_variant($test_name, $variants = ['A', 'B'], $expire_days = 30) {
    // 1. 检查是否已有分配结果
    $cookie_name = 'ab_test_' . $test_name;
    if (isset($_COOKIE[$cookie_name])) {
        return $_COOKIE[$cookie_name];
    }
    // 2. 随机分配(50/50)
    $variant = $variants[array_rand($variants)];
    // 3. 写入 Cookie(稳定分组 + 过期时间)
    setcookie($cookie_name, $variant, time() + ($expire_days * 86400), '/');
    return $variant;
}
// 使用
$layout = get_variant('homepage_layout');
if ($layout === 'B') {
    // 显示新版布局
} else {
    // 显示旧版布局
}
?>

缺点

  • 无法精细控制流量比例(如 10% 流量进入测试组)
  • 无法知道用户真实身份,无法做用户分层
  • 数据统计需要自行埋点上报

进阶版(Redis 存储 + 加权分流 + 数据埋点)

适用场景:后端渲染、有用户系统、需要精确控制流量比例。

<?php
class ABTestService {
    private $redis;
    private $user_id;
    public function __construct($redis, $user_id) {
        $this->redis = $redis;
        $this->user_id = $user_id;
    }
    /**
     * 获取实验分组(保证同用户始终同组)
     */
    public function assign($experiment_key, $groups = ['A' => 50, 'B' => 50]) {
        // 1. 检查是否已分配
        $cache_key = "abtest:{$experiment_key}:user:{$this->user_id}";
        $cached = $this->redis->get($cache_key);
        if ($cached) return $cached;
        // 2. 哈希算法:同一 user_id 永远映射到同一组(也可使用 random 结合 DB 存储)
        $hash = crc32($this->user_id . $experiment_key);
        $total_weight = array_sum($groups);
        $threshold = $hash % $total_weight;
        $cumulative = 0;
        foreach ($groups as $group => $weight) {
            $cumulative += $weight;
            if ($threshold < $cumulative) {
                // 3. 存储结果(通常带过期时间)
                $this->redis->setex($cache_key, 86400 * 30, $group);
                return $group;
            }
        }
        return array_key_first($groups); // 兜底
    }
    /**
     * 上报转化事件
     */
    public function track($experiment_key, $event_name, $user_id = null) {
        $uid = $user_id ?: $this->user_id;
        $group = $this->redis->get("abtest:{$experiment_key}:user:{$uid}");
        // 写入统计队列(异步消费)
        $this->redis->lpush('abtest:events', json_encode([
            'experiment' => $experiment_key,
            'group' => $group,
            'event' => $event_name,
            'user_id' => $uid,
            'timestamp' => time()
        ]));
    }
}
// 使用示例
$ab = new ABTestService($redis, $user->id);
$variant = $ab->assign('checkout_button', ['A' => 90, 'B' => 10]); // 10% 流量看到新按钮
if ($variant === 'B') {
    // 渲染新按钮
} else {
    // 渲染旧按钮
}
// 用户点击“购买”时
$ab->track('checkout_button', 'purchase_click');
?>

优点

  • 可控流量比例(90/10)
  • 用户分组持久稳定
  • 支持事件埋点

纯前端方案(JavaScript + 服务端 API)

适用场景:SPA(单页应用)或前端渲染的项目,后端只需提供配置和数据接口。

// PHP 后端 API:返回实验配置
// /api/abtest/config.php
<?php
header('Content-Type: application/json');
$experiments = [
    'pricing_page' => [
        'groups' => ['A' => 70, 'B' => 20, 'C' => 10],
        'active' => true
    ],
    'signup_form' => [
        'groups' => ['A' => 50, 'B' => 50],
        'active' => false
    ]
];
// 如果用户已登录,可获取 user_id 做一致性分组
$user_id = $_GET['user_id'] ?? $_COOKIE['user_id'] ?? null;
echo json_encode([
    'experiments' => $experiments,
    'user_id' => $user_id
]);
?>
// 前端 JavaScript
async function getABConfig() {
    const res = await fetch('/api/abtest/config.php');
    const data = await res.json();
    return data;
}
// 简单分组函数
function assignGroup(userId, experimentKey, groups) {
    const hash = hashCode(userId + experimentKey);
    const total = Object.values(groups).reduce((a, b) => a + b, 0);
    const threshold = hash % total;
    let cumulative = 0;
    for (const [group, weight] of Object.entries(groups)) {
        cumulative += weight;
        if (threshold < cumulative) return group;
    }
    return Object.keys(groups)[0];
}
// 应用实验
const config = await getABConfig();
if (localStorage.getItem('uid') === null) {
    localStorage.setItem('uid', Math.random().toString(36).substring(2, 15));
}
const uid = localStorage.getItem('uid');
if (config.experiments.pricing_page.active) {
    const variant = assignGroup(uid, 'pricing_page', config.experiments.pricing_page.groups);
    if (variant === 'B') {
        // 渲染B版本价格页
    } else if (variant === 'C') {
        // 渲染C版本价格页
    }
}
// 发送事件
function trackABEvent(experiment, event) {
    fetch('/api/abtest/track.php', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
            experiment,
            event,
            user_id: uid,
            group: localStorage.getItem('ab_' + experiment)
        })
    });
}

生产级架构建议(PHP + 数据库 + 消息队列)

数据模型设计

-- 实验表
CREATE TABLE ab_experiments (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) UNIQUE NOT NULL,
    description TEXT,
    status ENUM('draft', 'running', 'paused', 'completed') DEFAULT 'draft',
    traffic_percent TINYINT DEFAULT 100, -- 参与流量百分比
    start_date DATETIME,
    end_date DATETIME,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 变体表
CREATE TABLE ab_variants (
    id INT AUTO_INCREMENT PRIMARY KEY,
    experiment_id INT NOT NULL,
    variant_key VARCHAR(50) NOT NULL,  -- A/B/C
    weight INT DEFAULT 50,             -- 权重
    config JSON,                       -- 该变体的具体配置(如文案、样式)
    FOREIGN KEY (experiment_id) REFERENCES ab_experiments(id)
);
-- 用户分组表(记录用户被分配到哪个实验的哪个组)
CREATE TABLE ab_user_assignments (
    id INT AUTO_INCREMENT PRIMARY KEY,
    experiment_id INT NOT NULL,
    user_id INT NOT NULL,              -- 或使用设备ID
    variant_key VARCHAR(50) NOT NULL,
    assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY (experiment_id, user_id)
);
-- 事件表
CREATE TABLE ab_events (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    experiment_id INT NOT NULL,
    user_id INT NOT NULL,
    variant_key VARCHAR(50) NOT NULL,
    event_name VARCHAR(100) NOT NULL,  -- 'view', 'click', 'purchase'
    event_value DECIMAL(10,2) DEFAULT 0, -- 如金额
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_exp_event (experiment_id, event_name)
);

关键操作流程

<?php
// 1. 初始化实验(管理后台)
function create_experiment($name, $variants) {
    $pdo->beginTransaction();
    $pdo->exec("INSERT INTO ab_experiments (name, status) VALUES ('$name', 'draft')");
    $exp_id = $pdo->lastInsertId();
    foreach ($variants as $variant) {
        $pdo->exec("INSERT INTO ab_variants (experiment_id, variant_key, weight, config) 
                    VALUES ($exp_id, '{$variant['key']}', {$variant['weight']}, '{$variant['config']}')");
    }
    $pdo->commit();
}
// 2. 获取用户分组
function get_user_variant($user_id, $experiment_name) {
    $exp = $pdo->query("SELECT id FROM ab_experiments WHERE name = '$experiment_name'")->fetch();
    // 查找已有分配
    $assigned = $pdo->query("SELECT variant_key FROM ab_user_assignments 
                             WHERE experiment_id = {$exp['id']} AND user_id = $user_id")->fetch();
    if ($assigned) return $assigned['variant_key'];
    // 随机分配(加锁防止并发)
    $variants = $pdo->query("SELECT * FROM ab_variants 
                             WHERE experiment_id = {$exp['id']}")->fetchAll();
    $pool = [];
    foreach ($variants as $v) {
        for ($i = 0; $i < $v['weight']; $i++) $pool[] = $v['variant_key'];
    }
    shuffle($pool);
    $variant = $pool[array_rand($pool)];
    // 记录分配
    $pdo->exec("INSERT INTO ab_user_assignments (experiment_id, user_id, variant_key) 
                VALUES ({$exp['id']}, $user_id, '$variant')");
    return $variant;
}
// 3. 数据统计(后台报表)
// 示例:点击率对比
SELECT 
    variant_key,
    COUNT(DISTINCT CASE WHEN event_name = 'view' THEN user_id END) AS views,
    COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN user_id END) AS purchases,
    (COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN user_id END) * 100.0 /
     NULLIF(COUNT(DISTINCT CASE WHEN event_name = 'view' THEN user_id END), 0)) AS conversion_rate
FROM ab_events
WHERE experiment_id = 1
GROUP BY variant_key;

最佳实践与踩坑指南

✅ 好的做法:

  1. 统一分流逻辑:所有实验通过同一个类/服务管理,确保同一用户每次请求拿到相同分组。
  2. 预注册实验:在生产环境发布前,先在数据库中注册实验,不要动态新增实验。
  3. 容量预留:实验开始前估算样本量(可使用统计功效分析工具)。
  4. 监控实验进度:设置最小样本量、显著性水平(如 P < 0.05),达到后停止实验。
  5. 多端一致性:如果同时有 Web 和 App,需要统一用户标识(如手机号/邮箱/设备ID映射)。

❌ 常见坑:

  1. 拿不到统计数据:忘记在关键转化点(如下单、注册)调用 track() 方法。
  2. 分组不稳定:使用 rand() 而不使用 Cookie/DB 持久化,导致用户刷新页面就换组。
  3. 多重检验问题:同时跑 10 个实验,总会有“假阳性”结果,需要做 Bonferroni 校正或整体显著性判断。
  4. 无视用户体验:实验可能导致页面布局闪跳(新旧版本交替渲染),需要前端配合做防闪烁加载。
  5. 数据量不足就下结论:至少等转化事件达到几百条再分析,避免小样本随机波动骗你。

开源工具推荐(可直接集成)

工具 特点
Google Optimize 免费(已停服,可参考),前端装载
Vanity 基于 Rails,但有 PHP 版本,简单易用
GrowthBook 开源,支持 PHP SDK,带可视化实验管理后台
AWS CloudWatch Evidently AWS 服务,支持 PHP SDK
自研 灵活,适合复杂业务场景

对于 PHP 项目,推荐方案:

  • 中小型项目:Redis + 用户 ID 哈希分组 + 异步事件埋点(方案二)
  • 大型或复杂业务:数据库存储实验配置 + 独立统计服务 + BI 报表(方案四)
  • 快速验证:本地 Cookie 随机分组(方案一),但要意识到数据质量不高

核心关键是:保证用户分组稳定 + 埋点覆盖完整漏斗 + 统计显著性判断,三者缺一不可。

抱歉,评论功能暂时关闭!