PHP项目A/B测试与优化完整指南
A/B测试基础架构
基本测试引擎设计
<?php
class ABTestEngine {
private $redis;
private $experiments = [];
public function __construct() {
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->loadExperiments();
}
// 加载实验配置
private function loadExperiments() {
$this->experiments = [
'homepage_layout' => [
'variants' => ['control' => 50, 'variant_a' => 25, 'variant_b' => 25],
'tracking' => ['conversion', 'click', 'time_on_page']
],
'pricing_page' => [
'variants' => ['control' => 50, 'variant_a' => 50],
'tracking' => ['signup', 'revenue']
]
];
}
// 为用户分配实验组
public function assignVariant($userId, $experimentName) {
$cacheKey = "ab_test:{$experimentName}:{$userId}";
// 检查是否已有分配
$variant = $this->redis->get($cacheKey);
if ($variant) {
return $variant;
}
// 新分配
$experiment = $this->experiments[$experimentName];
$variant = $this->weightedRandom($experiment['variants']);
// 缓存分配结果(24小时过期)
$this->redis->setex($cacheKey, 86400, $variant);
return $variant;
}
// 加权随机分配
private function weightedRandom($variants) {
$totalWeight = array_sum($variants);
$random = mt_rand(1, $totalWeight);
$cumulative = 0;
foreach ($variants as $variant => $weight) {
$cumulative += $weight;
if ($random <= $cumulative) {
return $variant;
}
}
return 'control';
}
// 记录事件
public function trackEvent($userId, $experimentName, $event, $data = []) {
$variant = $this->assignVariant($userId, $experimentName);
$eventData = [
'user_id' => $userId,
'experiment' => $experimentName,
'variant' => $variant,
'event' => $event,
'data' => json_encode($data),
'timestamp' => time()
];
// 异步记录到数据库或消息队列
$this->logEventAsync($eventData);
}
private function logEventAsync($eventData) {
// 使用消息队列(如RabbitMQ/Redis队列)
$this->redis->lPush('ab_test_events', json_encode($eventData));
}
}
中间件集成(Laravel示例)
<?php
namespace App\Http\Middleware;
use Closure;
use App\Services\ABTestEngine;
class ABTestMiddleware {
protected $abTest;
public function __construct(ABTestEngine $abTest) {
$this->abTest = $abTest;
}
public function handle($request, Closure $next) {
$userId = $request->user() ? $request->user()->id : session()->getId();
// 将实验组信息添加到请求
$request->attributes->add([
'ab_test_variant' => $this->abTest->assignVariant($userId, 'homepage_layout'),
'ab_test_user_id' => $userId
]);
$response = $next($request);
// 添加实验标识到响应头(用于调试)
$response->headers->set('X-AB-Test', $request->attributes->get('ab_test_variant'));
return $response;
}
}
前端集成方案
Blade模板集成(Laravel)
{{-- resources/views/home.blade.php --}}
@php
$variant = request()->attributes->get('ab_test_variant');
@endphp
@extends('layouts.app')
@section('content')
<div class="container">
@if($variant === 'variant_a')
{{-- 新布局 A --}}
<div class="new-layout-a">
<h1>优化版首页</h1>
<button class="cta-button cta-red" onclick="trackEvent('cta_click')">
立即体验
</button>
</div>
@elseif($variant === 'variant_b')
{{-- 新布局 B --}}
<div class="new-layout-b">
<h1>全新设计</h1>
<button class="cta-button cta-blue" onclick="trackEvent('cta_click')">
免费试用
</button>
</div>
@else
{{-- 默认布局 --}}
<div class="old-layout">
<h1>欢迎回来</h1>
<a href="/register" class="btn btn-primary">注册</a>
</div>
@endif
</div>
<script>
function trackEvent(eventName) {
fetch('/api/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
experiment: 'homepage_layout',
event: eventName,
variant: '{{ $variant }}'
})
});
}
</script>
@endsection
JavaScript追踪器
// resources/js/ab-testing.js
class ABTestTracker {
constructor(options = {}) {
this.apiEndpoint = options.apiEndpoint || '/api/track';
this.experimentName = document.querySelector('meta[name="ab-experiment"]')?.content;
this.variant = document.querySelector('meta[name="ab-variant"]')?.content;
this.sessionId = this.getSessionId();
// 自动追踪页面浏览
if (this.experimentName) {
this.track('pageview');
}
}
track(event, data = {}) {
if (!this.experimentName) return;
const payload = {
experiment: this.experimentName,
variant: this.variant,
event: event,
data: {
...data,
url: window.location.href,
user_agent: navigator.userAgent,
timestamp: Date.now()
}
};
// 使用Beacon API确保页面关闭时也能发送
if (navigator.sendBeacon) {
navigator.sendBeacon(this.apiEndpoint, JSON.stringify(payload));
} else {
// 降级使用fetch
fetch(this.apiEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true
});
}
}
getSessionId() {
let sessionId = sessionStorage.getItem('ab_session_id');
if (!sessionId) {
sessionId = 'sess_' + Math.random().toString(36).substr(2, 9);
sessionStorage.setItem('ab_session_id', sessionId);
}
return sessionId;
}
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
window.ABTest = new ABTestTracker();
// 追踪点击事件
document.addEventListener('click', (e) => {
const target = e.target.closest('[data-ab-track]');
if (target) {
window.ABTest.track('click', {
element: target.getAttribute('data-ab-track'),
text: target.innerText
});
}
});
});
数据收集与存储
数据库表结构
-- 实验配置表
CREATE TABLE ab_experiments (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
status ENUM('draft', 'running', 'paused', 'completed') DEFAULT 'draft',
start_date DATETIME,
end_date DATETIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 实验变体表
CREATE TABLE ab_variants (
id INT PRIMARY KEY AUTO_INCREMENT,
experiment_id INT NOT NULL,
name VARCHAR(50) NOT NULL,
weight INT DEFAULT 50,
config JSON,
FOREIGN KEY (experiment_id) REFERENCES ab_experiments(id)
);
-- 事件日志表(分区表)
CREATE TABLE ab_events (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
experiment_id INT NOT NULL,
variant_id INT NOT NULL,
user_id VARCHAR(100) NOT NULL,
session_id VARCHAR(100),
event_name VARCHAR(50) NOT NULL,
event_data JSON,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_experiment_event (experiment_id, event_name),
INDEX idx_user_experiment (user_id, experiment_id),
INDEX idx_created_at (created_at)
) PARTITION BY RANGE (TO_DAYS(created_at)) (
PARTITION p202301 VALUES LESS THAN (TO_DAYS('2023-02-01')),
PARTITION p202302 VALUES LESS THAN (TO_DAYS('2023-03-01')),
-- 按月分区
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- 分析汇总表
CREATE TABLE ab_analysis_results (
id INT PRIMARY KEY AUTO_INCREMENT,
experiment_id INT NOT NULL,
variant_id INT NOT NULL,
metric_name VARCHAR(50) NOT NULL,
value DECIMAL(10,4),
confidence_level DECIMAL(5,4),
sample_size INT,
calculated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_experiment (experiment_id)
);
异步事件处理
<?php
// 消费者处理队列中的事件
class ABEventConsumer {
private $redis;
private $db;
public function __construct() {
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->db = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
}
public function processEvents() {
while (true) {
// 阻塞获取事件
$event = $this->redis->brPop('ab_test_events', 5);
if ($event) {
$eventData = json_decode($event[1], true);
$this->storeEvent($eventData);
}
}
}
private function storeEvent($eventData) {
try {
$stmt = $this->db->prepare(
"INSERT INTO ab_events
(experiment_id, variant_id, user_id, event_name, event_data, created_at)
VALUES (
(SELECT id FROM ab_experiments WHERE name = ?),
(SELECT id FROM ab_variants WHERE name = ? AND experiment_id = (SELECT id FROM ab_experiments WHERE name = ?)),
?, ?, ?, NOW()
)"
);
$stmt->execute([
$eventData['experiment'],
$eventData['variant'],
$eventData['experiment'],
$eventData['user_id'],
$eventData['event'],
json_encode($eventData['data'])
]);
} catch (Exception $e) {
// 记录错误到日志
error_log("Failed to store AB event: " . $e->getMessage());
}
}
}
统计分析
核心分析引擎
<?php
class ABTestAnalyzer {
private $db;
public function __construct() {
$this->db = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
}
// 计算转化率
public function calculateConversionRate($experimentId) {
$sql = "
SELECT
v.name as variant,
COUNT(DISTINCT CASE WHEN e.event_name = 'conversion' THEN e.user_id END) as conversions,
COUNT(DISTINCT e.user_id) as total_users,
COUNT(DISTINCT CASE WHEN e.event_name = 'conversion' THEN e.user_id END) /
COUNT(DISTINCT e.user_id) * 100 as conversion_rate
FROM ab_variants v
LEFT JOIN ab_events e ON v.id = e.variant_id
AND v.experiment_id = e.experiment_id
WHERE v.experiment_id = ?
GROUP BY v.id, v.name
";
$stmt = $this->db->prepare($sql);
$stmt->execute([$experimentId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// 计算统计显著性(Z检验)
public function calculateSignificance($experimentId, $metric = 'conversion') {
$results = $this->calculateConversionRate($experimentId);
// 使用控制组作为基准
$control = array_filter($results, function($r) { return $r['variant'] === 'control'; });
$control = reset($control);
$significances = [];
foreach ($results as $variant) {
if ($variant['variant'] === 'control') continue;
$p1 = $control['conversion_rate'] / 100;
$p2 = $variant['conversion_rate'] / 100;
$n1 = $control['total_users'];
$n2 = $variant['total_users'];
// 计算Z分数
$p = ($p1 * $n1 + $p2 * $n2) / ($n1 + $n2);
$se = sqrt($p * (1 - $p) * (1/$n1 + 1/$n2));
$z = ($p2 - $p1) / $se;
// 计算p值(使用正态分布近似)
$p_value = 2 * (1 - $this->normalCDF(abs($z)));
$significances[$variant['variant']] = [
'lift' => (($p2 - $p1) / $p1) * 100,
'z_score' => $z,
'p_value' => $p_value,
'significant' => $p_value < 0.05
];
}
return $significances;
}
// 正态分布CDF(近似)
private function normalCDF($x) {
$a1 = 0.254829592;
$a2 = -0.284496736;
$a3 = 1.421413741;
$a4 = -1.453152027;
$a5 = 1.061405429;
$p = 0.3275911;
$sign = 1;
if ($x < 0) {
$sign = -1;
}
$x = abs($x) / sqrt(2);
$t = 1.0 / (1.0 + $p * $x);
$y = 1.0 - ((((($a5 * $t + $a4) * $t) + $a3) * $t + $a2) * $t + $a1) * $t * exp(-$x * $x);
return 0.5 * (1.0 + $sign * $y);
}
// 计算贝叶斯概率
public function bayesianAnalysis($experimentId) {
$results = $this->calculateConversionRate($experimentId);
// 使用贝塔分布进行贝叶斯推断
$variants = [];
foreach ($results as $v) {
$variants[$v['variant']] = [
'alpha' => $v['conversions'] + 1,
'beta' => $v['total_users'] - $v['conversions'] + 1
];
}
// 蒙特卡洛模拟
$simulations = 100000;
$wins = array_fill_keys(array_keys($variants), 0);
for ($i = 0; $i < $simulations; $i++) {
$samples = [];
foreach ($variants as $name => $params) {
// 使用Box-Muller变换生成贝塔分布样本
$samples[$name] = $this->betaSample($params['alpha'], $params['beta']);
}
$maxVariant = array_keys($samples, max($samples))[0];
$wins[$maxVariant]++;
}
// 计算每个变体是最优的概率
$probabilities = [];
foreach ($wins as $variant => $count) {
$probabilities[$variant] = $count / $simulations;
}
return $probabilities;
}
private function betaSample($alpha, $beta) {
$x = $this->gammaSample($alpha);
$y = $this->gammaSample($beta);
return $x / ($x + $y);
}
private function gammaSample($shape) {
// Marsaglia & Tsang方法生成Gamma分布样本
if ($shape < 1) {
$u = mt_rand() / mt_getrandmax();
return $this->gammaSample($shape + 1) * pow($u, 1 / $shape);
}
$d = $shape - 1/3;
$c = 1 / sqrt(9 * $d);
while (true) {
$x = $this->normalSample();
$v = 1 + $c * $x;
if ($v <= 0) continue;
$v = $v * $v * $v;
$u = mt_rand() / mt_getrandmax();
if ($u < 1 - 0.0331 * $x * $x * $x * $x) return $d * $v;
if (log($u) < 0.5 * $x * $x + $d * (1 - $v + log($v))) return $d * $v;
}
}
private function normalSample() {
// Box-Muller变换
$u1 = mt_rand() / mt_getrandmax();
$u2 = mt_rand() / mt_getrandmax();
return sqrt(-2 * log($u1)) * cos(2 * M_PI * $u2);
}
}
管理员数据分析面板
<?php
// routes/web.php
Route::middleware(['auth', 'admin'])->group(function () {
Route::get('/admin/ab-testing', 'ABTestController@dashboard');
Route::get('/admin/ab-testing/{id}/results', 'ABTestController@results');
Route::post('/admin/ab-testing/{id}/stop', 'ABTestController@stopExperiment');
});
// app/Http/Controllers/ABTestController.php
class ABTestController extends Controller {
public function dashboard() {
$experiments = ABExperiment::with('variants')->where('status', 'running')->get();
$data = [];
foreach ($experiments as $experiment) {
$analyzer = new ABTestAnalyzer();
$data[] = [
'experiment' => $experiment,
'conversion_rates' => $analyzer->calculateConversionRate($experiment->id),
'significance' => $analyzer->calculateSignificance($experiment->id),
'bayesian' => $analyzer->bayesianAnalysis($experiment->id)
];
}
return view('admin.ab-testing.dashboard', compact('data'));
}
public function results($id) {
$experiment = ABExperiment::with('variants')->findOrFail($id);
$analyzer = new ABTestAnalyzer();
return view('admin.ab-testing.results', [
'experiment' => $experiment,
'conversion_rates' => $analyzer->calculateConversionRate($id),
'significance' => $analyzer->calculateSignificance($id),
'bayesian' => $analyzer->bayesianAnalysis($id)
]);
}
}
性能优化
减少数据库查询
<?php
// 使用Redis缓存实验结果
class OptimizedABTestEngine {
private $redis;
public function getVariant($userId, $experimentName) {
// 批量获取
$variants = $this->redis->mget([
"ab_test:{$experimentName}:{$userId}",
"ab_test:{$experimentName}:config"
]);
if ($variants[0]) {
return $variants[0];
}
// 缓存实验配置
if (!$variants[1]) {
$config = $this->loadConfig($experimentName);
$this->redis->setex("ab_test:{$experimentName}:config", 3600, json_encode($config));
} else {
$config = json_decode($variants[1], true);
}
// 分配并缓存
$variant = $this->assignVariantFromConfig($userId, $config);
$this->redis->setex("ab_test:{$experimentName}:{$userId}", 86400, $variant);
return $variant;
}
}
批量事件处理
<?php
// 批量写入事件
class BatchEventProcessor {
private $buffer = [];
private $bufferSize = 100;
public function addEvent($event) {
$this->buffer[] = $event;
if (count($this->buffer) >= $this->bufferSize) {
$this->flush();
}
}
public function flush() {
if (empty($this->buffer)) return;
DB::transaction(function () {
$chunks = array_chunk($this->buffer, 50);
foreach ($chunks as $chunk) {
$insertData = array_map(function($event) {
return [
'experiment_id' => $event['experiment_id'],
'variant_id' => $event['variant_id'],
'user_id' => $event['user_id'],
'event_name' => $event['event_name'],
'event_data' => json_encode($event['event_data'])
];
}, $chunk);
ABEvent::insert($insertData);
}
});
$this->buffer = [];
}
public function __destruct() {
$this->flush();
}
}
完整优化策略
多变量测试(MVT)
<?php
// 同时测试多个元素组合
class MVTEngine {
private $factors = [
'headline' => ['Welcome', 'Get Started', 'Join Now'],
'cta_color' => ['red', 'blue', 'green'],
'image' => ['hero1.jpg', 'hero2.jpg', 'hero3.jpg']
];
public function getCombination($userId) {
// 使用用户ID的哈希决定组合
$hash = crc32($userId);
$combinations = [];
foreach ($this->factors as $factor => $options) {
$index = abs($hash) % count($options);
$combinations[$factor] = $options[$index];
$hash = crc32($hash);
}
return $combinations;
}
}
渐进式部署
<?php
// 渐进式流量分配
class ProgressiveDeployment {
private $redis;
public function rampUpTraffic($experimentName, $targetPercentage = 100, $days = 7) {
$startTime = time();
$this->redis->setex("ab_test:{$experimentName}:ramp_up", $days * 86400, json_encode([
'start' => $startTime,
'target' => $targetPercentage,
'end' => $startTime + ($days * 86400)
]));
}
public function getCurrentTraffic($experimentName) {
$rampUp = $this->redis->get("ab_test:{$experimentName}:ramp_up");
if (!$rampUp) return 100; // 默认全量
$config = json_decode($rampUp, true);
$elapsed = time() - $config['start'];
$total = $config['end'] - $config['start'];
$progress = min(1, $elapsed / $total);
return $config['target'] * $progress;
}
}
实时监控与告警
<?php
class ABTestMonitor {
public function checkForAnomalies($experimentId) {
$hourlyData = $this->getHourlyData($experimentId);
foreach ($hourlyData as $variant => $data) {
// 检查是否有显著下降
if (count($data) >= 2) {
$recent = array_slice($data, -2);
$change = ($recent[1] - $recent[0]) / $recent[0];
if ($change < -0.1) { // 下降超过10%
$this->sendAlert($experimentId, $variant, $change);
}
}
}
}
private function getHourlyData($experimentId) {
return DB::table('ab_events')
->select(
'variant_id',
DB::raw("DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00') as hour"),
DB::raw('COUNT(DISTINCT user_id) as unique_users')
)
->where('experiment_id', $experimentId)
->where('created_at', '>=', now()->subHours(24))
->groupBy('variant_id', 'hour')
->get()
->groupBy('variant_id')
->toArray();
}
private function sendAlert($experimentId, $variantId, $change) {
// 发送邮件/Slack/Webhook通知
$message = "异常告警:实验 #{$experimentId} 变体 #{$variantId} 在最近一小时内转化率下降 {$change}%";
// 记录告警日志
Log::warning($message, [
'experiment_id' => $experimentId,
'variant_id' => $variantId,
'change_percentage' => $change
]);
}
}
最佳实践建议
- 样本量计算:使用统计软件(如G*Power)提前计算所需样本量
- 多重比较校正:使用Bonferroni或FDR校正控制假阳性率
- 连续性监测:采用序贯分析避免过早停止实验
- 分层抽样:按用户特征(新/老用户、地域)分层确保组间均衡
- 版本控制:每个实验变体应该对应一个Git分支或配置文件
常用工具集成
# 安装PHP统计库 composer require stats/stats # 集成Google Optimize composer require google/optimize # 使用数据分析库 composer require php-ml/php-ml
这个完整的A/B测试方案涵盖了从基础架构到高级统计分析的各个方面,可以根据项目需求选择性地实施各部分功能。
