本文目录导读:

- 最基础版(Cookie + 随机分流)
- 进阶版(Redis 存储 + 加权分流 + 数据埋点)
- 纯前端方案(JavaScript + 服务端 API)
- 生产级架构建议(PHP + 数据库 + 消息队列)
- 最佳实践与踩坑指南
- 开源工具推荐(可直接集成)
在 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;
最佳实践与踩坑指南
✅ 好的做法:
- 统一分流逻辑:所有实验通过同一个类/服务管理,确保同一用户每次请求拿到相同分组。
- 预注册实验:在生产环境发布前,先在数据库中注册实验,不要动态新增实验。
- 容量预留:实验开始前估算样本量(可使用统计功效分析工具)。
- 监控实验进度:设置最小样本量、显著性水平(如 P < 0.05),达到后停止实验。
- 多端一致性:如果同时有 Web 和 App,需要统一用户标识(如手机号/邮箱/设备ID映射)。
❌ 常见坑:
- 拿不到统计数据:忘记在关键转化点(如下单、注册)调用
track()方法。 - 分组不稳定:使用
rand()而不使用 Cookie/DB 持久化,导致用户刷新页面就换组。 - 多重检验问题:同时跑 10 个实验,总会有“假阳性”结果,需要做 Bonferroni 校正或整体显著性判断。
- 无视用户体验:实验可能导致页面布局闪跳(新旧版本交替渲染),需要前端配合做防闪烁加载。
- 数据量不足就下结论:至少等转化事件达到几百条再分析,避免小样本随机波动骗你。
开源工具推荐(可直接集成)
| 工具 | 特点 |
|---|---|
| Google Optimize | 免费(已停服,可参考),前端装载 |
| Vanity | 基于 Rails,但有 PHP 版本,简单易用 |
| GrowthBook | 开源,支持 PHP SDK,带可视化实验管理后台 |
| AWS CloudWatch Evidently | AWS 服务,支持 PHP SDK |
| 自研 | 灵活,适合复杂业务场景 |
对于 PHP 项目,推荐方案:
- 中小型项目:Redis + 用户 ID 哈希分组 + 异步事件埋点(方案二)
- 大型或复杂业务:数据库存储实验配置 + 独立统计服务 + BI 报表(方案四)
- 快速验证:本地 Cookie 随机分组(方案一),但要意识到数据质量不高
核心关键是:保证用户分组稳定 + 埋点覆盖完整漏斗 + 统计显著性判断,三者缺一不可。