前端灰度发布与 A/B 测试体系:从特性开关到流量分流的工程化
一、全量发布在前端中的不可逆风险
一次 UI 重构将表单按钮从页面底部移到了顶部。QA 在 6 款设备上测试通过,代码合并上线。两小时后用户反馈激增:iPad 横屏模式下新按钮位置遮挡了内容区,老年用户找不到提交按钮,转化率下降 12%。团队紧急回滚,但损失已经发生。
如果这次改动通过灰度发布逐步放量——先 5% 用户 → 观察指标 → 50% → 全量,iPad 横屏问题在 5% 阶段就会被发现,影响范围可控。前端的灰度发布面临独特挑战:不像后端可以简单地按服务器分流,前端用户是无状态的,灰度策略需要在浏览器端持久化。
二、灰度发布的流量分层架构
graph TB
USER[用户请求] –> LB[负载均衡]
LB –> EDGE[Edge Middleware]
EDGE –> CHECK{灰度策略判断}
CHECK –>|规则1: Cookie| COOKIE[检查 _ab_test cookie]
CHECK –>|规则2: UserID 哈希| HASH[取 userId 后两位做哈希]
CHECK –>|规则3: 地理位置| GEO[按 IP 所属地区分流]
COOKIE –> TAG{分配分组}
HASH –> TAG
GEO –> TAG
TAG –>|对照组 A| VER_A[版本 A<br/>当前线上版本]
TAG –>|实验组 B| VER_B[版本 B<br/>灰度版本]
TAG –>|实验组 C| VER_C[版本 C<br/>备选方案]
VER_A –> ANALYTICS[数据采集]
VER_B –> ANALYTICS
VER_C –> ANALYTICS
ANALYTICS –> DASHBOARD[A/B 分析看板]
灰度策略的分配在 Edge Middleware 层完成——在请求到达应用服务器之前就已经决定了用户的版本分组。Cookie 策略适合已登录用户,UserID 哈希策略保证同一用户在多次访问中分到同一组,地理策略适合地域性功能的灰度。
三、Next.js Middleware 实现灰度分流
// middleware.ts
// 设计意图:Edge Middleware 在请求到达页面之前决定版本分组
// 运行在 Edge Runtime——延迟 < 50ms
import { NextRequest, NextResponse } from 'next/server';
// 灰度实验配置——可通过 API 动态更新而无需重新部署
interface ExperimentConfig {
id: string;
name: string;
// 流量分配比例(总和必须为 100)
variants: Array<{
name: string;
weight: number; // 0-100
// 该分组重写到的页面路径
rewritePath: string;
}>;
// 实验生效的路径匹配规则
pathPattern: string;
// 是否启用
enabled: boolean;
}
// 模拟从配置中心获取的实验配置
async function getExperiments(): Promise<ExperimentConfig[]> {
// 生产环境应从 Redis/配置中心读取
return [
{
id: 'exp_form_redesign_2024',
name: '表单按钮位置实验',
enabled: true,
pathPattern: '^/checkout$',
variants: [
{
name: 'control',
weight: 80, // 80% 流量保持原版
rewritePath: '/checkout',
},
{
name: 'variant_bottom',
weight: 20, // 20% 流量看到新版本
rewritePath: '/checkout/v2',
},
],
},
];
}
// 为用户生成稳定的分组哈希值
function getVariantIndex(
userId: string,
experimentId: string,
totalWeights: number,
): number {
// 设计意图:哈希确保同一用户始终分到同一组
// hash(userId + experimentId) 保证不同实验独立分配
const hashInput = `${userId}:${experimentId}`;
let hash = 0;
for (let i = 0; i < hashInput.length; i++) {
hash = ((hash << 5) – hash) + hashInput.charCodeAt(i);
hash |= 0; // 转为 32 位整数
}
// 映射到 [0, totalWeights) 的范围
return Math.abs(hash) % totalWeights;
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 获取新用户标识——已登录用 userId,未登录用匿名 ID
const userId = request.cookies.get('userId')?.value
|| request.cookies.get('anonymousId')?.value
|| crypto.randomUUID();
const experiments = await getExperiments();
for (const exp of experiments) {
if (!exp.enabled) continue;
// 检查路径是否匹配
if (!new RegExp(exp.pathPattern).test(pathname)) continue;
// 计算总权重
const totalWeight = exp.variants.reduce(
(sum, v) => sum + v.weight, 0
);
// 为用户分配分组
const variantIndex = getVariantIndex(
userId, exp.id, totalWeight
);
// 按权重区间找到对应的 variant
let cumulativeWeight = 0;
let selectedVariant = exp.variants[0];
for (const variant of exp.variants) {
cumulativeWeight += variant.weight;
if (variantIndex < cumulativeWeight) {
selectedVariant = variant;
break;
}
}
// 设置分组 Cookie——确保 CDN 缓存键中包含分组信息
const response = NextResponse.rewrite(
new URL(selectedVariant.rewritePath, request.url)
);
response.cookies.set(
`exp_${exp.id}`,
selectedVariant.name,
{
// 实验期间内有效
maxAge: 30 * 24 * 60 * 60,
// 不设置 httpOnly——前端 JS 需要读取做埋点
httpOnly: false,
// SameSite 保证跨站安全
sameSite: 'lax',
}
);
// 注入分组信息到响应头——供前端埋点使用
response.headers.set('X-Experiment-Id', exp.id);
response.headers.set('X-Variant', selectedVariant.name);
return response;
}
return NextResponse.next();
}
export const config = {
// Middleware 匹配的路径——全站匹配,实验通过 pathPattern 进一步筛选
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
// 前端埋点——将分组信息上报到分析平台
// 设计意图:自动采集当前页面所处的实验分组
function useExperimentTracking() {
useEffect(() => {
// 从 Cookie 中读取实验分组
const cookies = document.cookie.split('; ');
const expCookies = cookies.filter(c => c.startsWith('exp_'));
for (const cookie of expCookies) {
const [key, value] = cookie.split('=');
const experimentId = key.replace('exp_', '');
// 上报到分析平台(如 GA4、Amplitude)
window.gtag?.('event', 'experiment_impression', {
experiment_id: experimentId,
variant: value,
});
// 同步到全局上下文——供业务代码判断当前分组
window.__EXPERIMENTS__ = window.__EXPERIMENTS__ || {};
window.__EXPERIMENTS__[experimentId] = value;
}
}, []);
}
三个设计要点:
四、灰度发布的反模式与安全措施
分流不均的陷阱。如果通过 Math.random() 分配分组,同一用户在刷新页面后可能从实验组跳到对照组——这会导致数据分析时的"交叉污染"。必须使用稳定的哈希策略。
A/B 测试的时长陷阱。在日活 1000 的网站上进行 A/B 测试,需要至少 2 周才能获得统计学显著的结果。过早结束测试(如 3 天后看到实验组转化率上升 8% 就全量发布)会导致基于噪声数据的决策。
灰度期间的监控盲区。如果没有按分组拆分监控指标,所有用户的错误率会混合在一起。实验组 5% 的流量即使全部报错,在全局错误率中也只贡献了 0.25% 的增长——很容易被忽略。
五、总结
前端灰度发布的工程化要点:
落地步骤:
