设计前端灰度发布系统
问题
如何设计一个完整的前端灰度发布系统?从 Feature Flag 管理、用户分桶策略、SDK 设计到 A/B 测试与数据分析,请详细说明核心模块的设计思路与关键技术实现。
答案
灰度发布系统是前端工程化的核心基础设施,它让团队能够在不重新部署的情况下安全、可控地发布新功能。一个完整的灰度发布系统涉及 Feature Flag 配置管理、用户分桶与流量分配、前端 SDK 设计、A/B 测试与数据分析、实时配置推送、回滚机制等多个关键模块。其核心价值在于将代码部署与功能发布解耦,实现"Deploy ≠ Release"。
一、需求分析
1.1 核心概念
在深入架构设计之前,先理清灰度发布领域的核心概念:
| 概念 | 英文 | 说明 | 典型场景 |
|---|---|---|---|
| Feature Flag | Feature Flag / Feature Toggle | 通过配置开关控制功能的开启/关闭 | 新功能开关、运营活动控制 |
| 灰度发布 | Gradual Rollout | 将新功能逐步放量给部分用户 | 从 1% → 10% → 50% → 100% |
| A/B 测试 | A/B Testing | 对比实验组和对照组的指标差异 | 按钮颜色 A vs B 的转化率对比 |
| 金丝雀发布 | Canary Release | 先向小部分用户发布,观察是否异常 | 新版本先给 1% 用户,无异常再扩大 |
| 蓝绿部署 | Blue-Green Deployment | 两套环境交替上线,一套运行旧版一套运行新版 | 零停机切换 |
Deploy ≠ Release。代码部署到生产环境不等于功能对用户可见。通过 Feature Flag,团队可以随时控制功能的可见性,降低发布风险。
1.2 功能需求
| 模块 | 功能点 |
|---|---|
| Flag 管理 | 创建/编辑/删除 Flag、多种 Flag 类型、生命周期管理 |
| 用户分桶 | 基于用户 ID Hash、白名单、地域、设备等多维度分桶 |
| 流量控制 | 百分比放量、定向投放、互斥/正交实验分组 |
| A/B 测试 | 创建实验、分组、指标采集、统计分析、P 值显著性检验 |
| 实时更新 | 配置变更实时推送到前端 SDK,无需重新部署 |
| 回滚机制 | 一键关闭功能、自动回滚(基于异常指标) |
| 管理后台 | 可视化配置、流量分配、数据看板、审计日志 |
1.3 非功能需求
| 指标 | 目标 |
|---|---|
| 高可用 | 配置服务 99.99% 可用,SDK 降级不影响主业务 |
| 低延迟 | Flag 求值 < 1ms,配置更新延迟 < 5s |
| 一致性 | 同一用户多次访问命中相同分桶(分桶一致性) |
| 可扩展 | 支持百万级用户、千级 Flag 同时运行 |
| 安全性 | 配置变更需审批、操作审计、敏感 Flag 加密 |
灰度发布系统必须保证分桶一致性:同一用户在同一实验中,无论何时何地访问,都应命中相同的分组。这是 A/B 测试结果可信的前提。
二、整体架构
2.1 系统架构全景
2.2 数据流转过程
- 管理后台与配置中心分离,管理后台只负责 CRUD,配置中心负责存储、缓存和推送
- SDK 内置本地缓存和降级策略,即使配置中心不可用也能正常工作
- 数据分析平台独立部署,不影响 Flag 求值的性能
三、核心模块设计
3.1 Feature Flag 类型设计
Feature Flag 不仅仅是布尔开关,还需要支持多种类型以应对不同场景:
/** Flag 值类型 */
type FlagValueType = 'boolean' | 'string' | 'number' | 'json';
/** Flag 基础配置 */
interface FeatureFlag {
/** Flag 唯一标识 */
key: string;
/** Flag 名称 */
name: string;
/** 描述信息 */
description: string;
/** 值类型 */
valueType: FlagValueType;
/** 默认值(Flag 未命中任何规则时返回) */
defaultValue: unknown;
/** 是否启用 */
enabled: boolean;
/** 定向规则列表(按优先级排序) */
rules: TargetingRule[];
/** 百分比放量配置 */
rollout?: RolloutConfig;
/** 所属环境 */
environment: 'development' | 'staging' | 'production';
/** 标签 */
tags: string[];
/** 创建/更新时间 */
createdAt: number;
updatedAt: number;
}
/** 定向规则 */
interface TargetingRule {
/** 规则 ID */
id: string;
/** 规则名称 */
name: string;
/** 匹配条件(AND 关系) */
conditions: Condition[];
/** 命中后返回的值 */
value: unknown;
/** 优先级(数字越小越优先) */
priority: number;
}
/** 匹配条件 */
interface Condition {
/** 用户属性名 */
attribute: string;
/** 操作符 */
operator: ConditionOperator;
/** 目标值 */
values: string[];
}
type ConditionOperator =
| 'equals' // 等于
| 'not_equals' // 不等于
| 'contains' // 包含
| 'not_contains' // 不包含
| 'in' // 在列表中
| 'not_in' // 不在列表中
| 'gt' // 大于
| 'lt' // 小于
| 'regex' // 正则匹配
| 'semver_gt' // 语义化版本大于
| 'semver_lt'; // 语义化版本小于
/** 百分比放量配置 */
interface RolloutConfig {
/** 放量百分比 0-100 */
percentage: number;
/** 分桶 key(通常是 userId) */
bucketBy: string;
/** 放量时返回的值 */
value: unknown;
}
3.2 Flag 类型使用场景
| Flag 类型 | 值类型 | 使用场景 | 示例 |
|---|---|---|---|
| 布尔开关 | boolean | 功能开/关 | 是否展示新版导航栏 |
| 多值 Flag | string | A/B/C 多组实验 | 按钮文案:"立即购买" / "加入购物车" / "马上下单" |
| 数值 Flag | number | 参数调优 | 列表每页展示条数:10 / 20 / 50 |
| JSON Flag | json | 复杂配置 | 新版首页布局配置对象 |
3.3 Flag 求值流程
Flag 求值是整个系统的核心逻辑,需要按优先级依次匹配规则:
/** Flag 求值引擎 */
class FlagEvaluator {
/**
* 求值入口:获取 Flag 的最终值
*/
evaluate(flag: FeatureFlag, user: UserContext): EvaluationResult {
// 1. Flag 未启用,直接返回默认值
if (!flag.enabled) {
return { value: flag.defaultValue, reason: 'FLAG_DISABLED' };
}
// 2. 按优先级遍历定向规则
const sortedRules = [...flag.rules].sort(
(a, b) => a.priority - b.priority
);
for (const rule of sortedRules) {
if (this.matchRule(rule, user)) {
return {
value: rule.value,
reason: 'RULE_MATCH',
ruleId: rule.id,
};
}
}
// 3. 检查百分比放量
if (flag.rollout) {
const bucketValue = user[flag.rollout.bucketBy] as string;
if (bucketValue && this.isInRollout(flag.key, bucketValue, flag.rollout.percentage)) {
return {
value: flag.rollout.value,
reason: 'ROLLOUT',
percentage: flag.rollout.percentage,
};
}
}
// 4. 兜底返回默认值
return { value: flag.defaultValue, reason: 'DEFAULT' };
}
/** 匹配单条规则(所有条件 AND 关系) */
private matchRule(rule: TargetingRule, user: UserContext): boolean {
return rule.conditions.every((cond) =>
this.matchCondition(cond, user)
);
}
/** 匹配单个条件 */
private matchCondition(cond: Condition, user: UserContext): boolean {
const userValue = String(user[cond.attribute] ?? '');
switch (cond.operator) {
case 'equals':
return cond.values.includes(userValue);
case 'not_equals':
return !cond.values.includes(userValue);
case 'contains':
return cond.values.some((v) => userValue.includes(v));
case 'in':
return cond.values.includes(userValue);
case 'not_in':
return !cond.values.includes(userValue);
case 'gt':
return Number(userValue) > Number(cond.values[0]);
case 'lt':
return Number(userValue) < Number(cond.values[0]);
case 'regex':
return new RegExp(cond.values[0]).test(userValue);
default:
return false;
}
}
/**
* 百分比放量判断
* 使用 MurmurHash 保证分桶一致性
*/
private isInRollout(flagKey: string, bucketValue: string, percentage: number): boolean {
const hashInput = `${flagKey}:${bucketValue}`;
const hashValue = this.murmurHash(hashInput);
// 将 hash 值映射到 0-99 的范围
const bucket = hashValue % 100;
return bucket < percentage;
}
/** MurmurHash3 - 32bit 实现 */
private murmurHash(key: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0; // 转为无符号 32 位整数
}
}
/** 求值结果 */
interface EvaluationResult {
value: unknown;
reason: 'FLAG_DISABLED' | 'RULE_MATCH' | 'ROLLOUT' | 'DEFAULT';
ruleId?: string;
percentage?: number;
}
/** 用户上下文 */
interface UserContext {
userId: string;
[key: string]: string | number | boolean | undefined;
}
四、用户分桶策略
用户分桶是灰度发布系统中最关键的技术环节,直接决定了实验结果的可信度。
4.1 分桶策略对比
| 策略 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| userId Hash | 对 userId 做 Hash 取模 | 分布均匀、一致性好 | 需要用户登录 | 登录态功能灰度 |
| 白名单 | 指定用户 ID 列表 | 精确控制 | 无法大规模放量 | 内测、VIP 体验 |
| IP 段 | 基于客户端 IP 地址 | 不需要登录 | IP 不稳定、精度差 | 区域限制功能 |
| 地域 | 基于 GPS/IP 定位地域 | 满足区域化需求 | 定位精度有限 | 城市级灰度 |
| 设备指纹 | 基于设备特征生成唯一 ID | 匿名用户可用 | 精度和隐私问题 | 未登录场景 |
| Cookie/UUID | 分配随机 UUID 存入 Cookie | 简单可靠 | 清除 Cookie 后失效 | 通用匿名场景 |
4.2 一致性 Hash 分桶实现
分桶一致性是灰度发布系统的基石。必须保证:
- 确定性:同一用户 + 同一实验 → 永远命中同一分桶
- 均匀性:用户在各分桶中的分布接近均匀
- 独立性:不同实验的分桶结果相互独立
/**
* 用户分桶引擎
* 核心思路:flagKey + userId → Hash → bucket (0-9999)
* 使用万分位精度,支持 0.01% 的粒度控制
*/
class BucketEngine {
private readonly BUCKET_SIZE = 10000; // 万分位
/**
* 计算用户所在分桶
* @param flagKey - Flag 唯一标识(保证不同实验独立)
* @param bucketKey - 分桶依据(通常是 userId)
* @returns 0 到 9999 之间的整数
*/
getBucket(flagKey: string, bucketKey: string): number {
const input = `${flagKey}:${bucketKey}`;
const hash = this.murmurHash3(input);
return hash % this.BUCKET_SIZE;
}
/**
* 判断用户是否在放量范围内
* @param percentage - 放量百分比 (0-100)
*/
isInPercentage(flagKey: string, bucketKey: string, percentage: number): boolean {
const bucket = this.getBucket(flagKey, bucketKey);
// 百分比转万分位:10% → 1000
return bucket < percentage * 100;
}
/**
* A/B 测试分组
* 将用户分配到多个实验组中的一个
*/
assignGroup(
experimentKey: string,
bucketKey: string,
groups: ExperimentGroup[]
): ExperimentGroup | null {
const bucket = this.getBucket(experimentKey, bucketKey);
let accumulated = 0;
for (const group of groups) {
accumulated += group.weight * 100; // weight 是百分比
if (bucket < accumulated) {
return group;
}
}
return null; // 未分配(剩余流量)
}
/**
* MurmurHash3 - 32bit
* 选择 MurmurHash 的原因:
* 1. 分布均匀性好
* 2. 计算速度快
* 3. 碰撞率低
*/
private murmurHash3(key: string, seed: number = 0): number {
let h = seed;
const len = key.length;
const nblocks = Math.floor(len / 4);
for (let i = 0; i < nblocks; i++) {
let k =
(key.charCodeAt(i * 4) & 0xff) |
((key.charCodeAt(i * 4 + 1) & 0xff) << 8) |
((key.charCodeAt(i * 4 + 2) & 0xff) << 16) |
((key.charCodeAt(i * 4 + 3) & 0xff) << 24);
k = Math.imul(k, 0xcc9e2d51);
k = (k << 15) | (k >>> 17);
k = Math.imul(k, 0x1b873593);
h ^= k;
h = (h << 13) | (h >>> 19);
h = Math.imul(h, 5) + 0xe6546b64;
}
let k = 0;
const tailStart = nblocks * 4;
switch (len & 3) {
case 3:
k ^= (key.charCodeAt(tailStart + 2) & 0xff) << 16;
// falls through
case 2:
k ^= (key.charCodeAt(tailStart + 1) & 0xff) << 8;
// falls through
case 1:
k ^= key.charCodeAt(tailStart) & 0xff;
k = Math.imul(k, 0xcc9e2d51);
k = (k << 15) | (k >>> 17);
k = Math.imul(k, 0x1b873593);
h ^= k;
}
h ^= len;
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return h >>> 0;
}
}
interface ExperimentGroup {
id: string;
name: string;
/** 流量权重百分比 */
weight: number;
/** 该组对应的 Flag 值 */
value: unknown;
}
4.3 正交与互斥实验
当系统同时运行多个实验时,需要考虑实验之间的关系:
| 类型 | 说明 | 实现方式 | 适用场景 |
|---|---|---|---|
| 互斥实验 | 同一用户只能参与一个实验 | 共享同一个流量层,按范围划分 | 同一功能的不同方案对比 |
| 正交实验 | 同一用户可参与多个实验 | 不同实验使用不同的 Hash 种子 | 不相关功能的独立测试 |
/**
* 实验流量层管理
* 互斥实验共享同一个层的流量,正交实验位于不同层
*/
interface TrafficLayer {
id: string;
name: string;
/** 该层下的互斥实验 */
experiments: Experiment[];
}
interface Experiment {
id: string;
name: string;
/** 在所属 layer 中的流量范围 [start, end),万分位 */
trafficRange: [number, number];
groups: ExperimentGroup[];
}
class ExperimentManager {
private layers: Map<string, TrafficLayer> = new Map();
private bucketEngine = new BucketEngine();
/**
* 分配用户到实验组
* 1. 用 layerId + userId 计算在该层的分桶
* 2. 检查分桶是否落在某个实验的流量范围内
* 3. 用 experimentId + userId 计算组内分组
*/
assignUser(layerId: string, userId: string): AssignmentResult | null {
const layer = this.layers.get(layerId);
if (!layer) return null;
// 步骤 1:在该层中计算用户分桶
const layerBucket = this.bucketEngine.getBucket(layerId, userId);
// 步骤 2:找到命中的实验
const experiment = layer.experiments.find(
(exp) =>
layerBucket >= exp.trafficRange[0] &&
layerBucket < exp.trafficRange[1]
);
if (!experiment) return null;
// 步骤 3:在实验内部分组(使用不同的 key 保证独立性)
const group = this.bucketEngine.assignGroup(
experiment.id,
userId,
experiment.groups
);
return group ? { experimentId: experiment.id, group } : null;
}
}
interface AssignmentResult {
experimentId: string;
group: ExperimentGroup;
}
五、SDK 设计
5.1 SDK 核心架构
5.2 SDK 核心实现
interface FFClientConfig {
/** 服务端 API 地址 */
serverUrl: string;
/** SDK Key(区分环境) */
sdkKey: string;
/** 用户上下文 */
user: UserContext;
/** 是否启用实时更新 */
realtime?: boolean;
/** 配置刷新间隔(毫秒),默认 60s */
refreshInterval?: number;
/** 初始化超时(毫秒),默认 5s */
initTimeout?: number;
/** 离线模式下使用的默认配置 */
defaults?: Record<string, unknown>;
/** 配置变更回调 */
onConfigChange?: (changes: FlagChange[]) => void;
}
interface FlagChange {
key: string;
oldValue: unknown;
newValue: unknown;
}
class FFClient {
private config: FFClientConfig;
private flags: Map<string, FeatureFlag> = new Map();
private evaluator: FlagEvaluator;
private cache: CacheManager;
private reporter: EventReporter;
private updater: RealtimeUpdater | null = null;
private ready: boolean = false;
private readyPromise: Promise<void>;
private listeners: Map<string, Set<(value: unknown) => void>> = new Map();
constructor(config: FFClientConfig) {
this.config = config;
this.evaluator = new FlagEvaluator();
this.cache = new CacheManager(config.sdkKey);
this.reporter = new EventReporter(config.serverUrl, config.sdkKey);
// 初始化流程
this.readyPromise = this.initialize();
}
/**
* SDK 初始化流程
* 1. 先尝试从本地缓存加载(保证离线可用)
* 2. 再从服务端拉取最新配置
* 3. 启动实时更新通道
*/
private async initialize(): Promise<void> {
// 步骤 1:加载本地缓存(毫秒级完成)
const cached = this.cache.load();
if (cached) {
this.flags = new Map(Object.entries(cached));
}
// 步骤 2:从服务端拉取最新配置(有超时保护)
try {
const remote = await this.fetchWithTimeout(
this.config.initTimeout ?? 5000
);
this.updateFlags(remote);
} catch (error) {
console.warn('[FFClient] 初始化拉取配置失败,使用缓存:', error);
// 降级使用缓存或默认值,不阻塞业务
}
// 步骤 3:启动实时更新
if (this.config.realtime !== false) {
this.updater = new RealtimeUpdater(
this.config.serverUrl,
this.config.sdkKey,
(flags) => this.updateFlags(flags)
);
this.updater.connect();
}
// 步骤 4:启动定时刷新(作为实时更新的兜底)
this.startPolling();
this.ready = true;
}
/** 等待 SDK 初始化完成 */
waitUntilReady(): Promise<void> {
return this.readyPromise;
}
/**
* 获取 Flag 布尔值
* 这是最常用的 API
*/
getBooleanFlag(key: string, defaultValue: boolean = false): boolean {
return this.getFlag(key, defaultValue) as boolean;
}
/** 获取 Flag 字符串值 */
getStringFlag(key: string, defaultValue: string = ''): string {
return this.getFlag(key, defaultValue) as string;
}
/** 获取 Flag 数值 */
getNumberFlag(key: string, defaultValue: number = 0): number {
return this.getFlag(key, defaultValue) as number;
}
/** 获取 Flag JSON 值 */
getJsonFlag<T>(key: string, defaultValue: T): T {
return this.getFlag(key, defaultValue) as T;
}
/**
* 获取 Flag 值(通用方法)
* 优先级:规则匹配 > 百分比放量 > 默认值 > 传入的 fallback
*/
private getFlag(key: string, defaultValue: unknown): unknown {
const flag = this.flags.get(key);
if (!flag) {
// Flag 不存在,使用配置的默认值或传入的默认值
return this.config.defaults?.[key] ?? defaultValue;
}
const result = this.evaluator.evaluate(flag, this.config.user);
// 上报曝光事件(异步,不阻塞)
this.reporter.trackExposure(key, result);
return result.value;
}
/** 监听特定 Flag 的变化 */
onFlagChange(key: string, callback: (value: unknown) => void): () => void {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key)!.add(callback);
// 返回取消监听函数
return () => {
this.listeners.get(key)?.delete(callback);
};
}
/** 更新用户上下文(例如用户登录后) */
identify(user: UserContext): void {
this.config.user = { ...this.config.user, ...user };
// 用户变更后需要重新求值所有 Flag,通知监听器
this.notifyAllListeners();
}
/** 更新 Flag 配置并通知变更 */
private updateFlags(remote: Record<string, FeatureFlag>): void {
const changes: FlagChange[] = [];
for (const [key, newFlag] of Object.entries(remote)) {
const oldFlag = this.flags.get(key);
const oldValue = oldFlag
? this.evaluator.evaluate(oldFlag, this.config.user).value
: undefined;
const newValue = this.evaluator.evaluate(newFlag, this.config.user).value;
if (oldValue !== newValue) {
changes.push({ key, oldValue, newValue });
}
this.flags.set(key, newFlag);
}
// 持久化到本地缓存
this.cache.save(Object.fromEntries(this.flags));
// 通知变更
if (changes.length > 0) {
this.config.onConfigChange?.(changes);
for (const change of changes) {
this.listeners.get(change.key)?.forEach((cb) => cb(change.newValue));
}
}
}
/** 带超时的配置拉取 */
private async fetchWithTimeout(timeout: number): Promise<Record<string, FeatureFlag>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(
`${this.config.serverUrl}/api/flags`,
{
headers: {
'Authorization': `Bearer ${this.config.sdkKey}`,
'X-User-Context': JSON.stringify(this.config.user),
},
signal: controller.signal,
}
);
return response.json();
} finally {
clearTimeout(timer);
}
}
/** 定时轮询(兜底机制) */
private startPolling(): void {
const interval = this.config.refreshInterval ?? 60000;
setInterval(async () => {
try {
const remote = await this.fetchWithTimeout(10000);
this.updateFlags(remote);
} catch {
// 轮询失败静默处理
}
}, interval);
}
/** 通知所有监听器重新求值 */
private notifyAllListeners(): void {
for (const [key, callbacks] of this.listeners) {
const flag = this.flags.get(key);
if (flag) {
const value = this.evaluator.evaluate(flag, this.config.user).value;
callbacks.forEach((cb) => cb(value));
}
}
}
/** 销毁 SDK,清理资源 */
destroy(): void {
this.updater?.disconnect();
this.reporter.flush();
this.listeners.clear();
}
}
5.3 缓存管理
/**
* 本地缓存管理
* 保证 SDK 在网络不可用时仍能正常工作
*/
class CacheManager {
private storageKey: string;
constructor(sdkKey: string) {
this.storageKey = `ff_cache_${sdkKey}`;
}
save(flags: Record<string, FeatureFlag>): void {
try {
const data: CacheData = {
flags,
timestamp: Date.now(),
version: this.generateVersion(flags),
};
localStorage.setItem(this.storageKey, JSON.stringify(data));
} catch {
// localStorage 写入失败(容量满等),静默处理
}
}
load(): Record<string, FeatureFlag> | null {
try {
const raw = localStorage.getItem(this.storageKey);
if (!raw) return null;
const data: CacheData = JSON.parse(raw);
// 缓存有效期 24 小时,过期后丢弃
const isExpired = Date.now() - data.timestamp > 24 * 60 * 60 * 1000;
if (isExpired) {
localStorage.removeItem(this.storageKey);
return null;
}
return data.flags;
} catch {
return null;
}
}
/** 生成配置版本号,用于增量更新 */
private generateVersion(flags: Record<string, FeatureFlag>): string {
const content = JSON.stringify(flags);
let hash = 0;
for (let i = 0; i < content.length; i++) {
hash = ((hash << 5) - hash + content.charCodeAt(i)) | 0;
}
return hash.toString(36);
}
}
interface CacheData {
flags: Record<string, FeatureFlag>;
timestamp: number;
version: string;
}
5.4 实时更新
- SSE(推荐)
- WebSocket
/**
* 基于 SSE(Server-Sent Events)的实时更新
* 优势:原生支持、自动重连、轻量
*/
class RealtimeUpdater {
private eventSource: EventSource | null = null;
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
constructor(
private serverUrl: string,
private sdkKey: string,
private onUpdate: (flags: Record<string, FeatureFlag>) => void
) {}
connect(): void {
const url = `${this.serverUrl}/api/flags/stream?sdkKey=${this.sdkKey}`;
this.eventSource = new EventSource(url);
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.onUpdate(data.flags);
// 连接成功,重置重连延迟
this.reconnectDelay = 1000;
} catch (error) {
console.error('[FFClient] 解析 SSE 数据失败:', error);
}
};
this.eventSource.onerror = () => {
this.eventSource?.close();
// 指数退避重连
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
this.maxReconnectDelay
);
};
}
disconnect(): void {
this.eventSource?.close();
this.eventSource = null;
}
}
/**
* 基于 WebSocket 的实时更新
* 优势:双向通信、可推送用户上下文变更
*/
class RealtimeWSUpdater {
private ws: WebSocket | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectDelay = 1000;
constructor(
private serverUrl: string,
private sdkKey: string,
private onUpdate: (flags: Record<string, FeatureFlag>) => void
) {}
connect(): void {
const wsUrl = this.serverUrl.replace(/^http/, 'ws');
this.ws = new WebSocket(`${wsUrl}/api/flags/ws?sdkKey=${this.sdkKey}`);
this.ws.onopen = () => {
this.reconnectDelay = 1000;
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'config_update') {
this.onUpdate(data.flags);
}
};
this.ws.onclose = () => {
this.stopHeartbeat();
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
};
}
private startHeartbeat(): void {
this.heartbeatTimer = setInterval(() => {
this.ws?.send(JSON.stringify({ type: 'ping' }));
}, 30000);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
}
}
disconnect(): void {
this.stopHeartbeat();
this.ws?.close();
this.ws = null;
}
}
六、前端集成
6.1 React Hook 封装
import { useState, useEffect, useContext, createContext, useMemo } from 'react';
/** SDK Context */
const FFContext = createContext<FFClient | null>(null);
/** Provider 组件 */
function FFProvider({
client,
children,
}: {
client: FFClient;
children: React.ReactNode;
}) {
const [ready, setReady] = useState(false);
useEffect(() => {
client.waitUntilReady().then(() => setReady(true));
return () => client.destroy();
}, [client]);
if (!ready) return null; // 或展示 loading
return (
<FFContext.Provider value={client}>
{children}
</FFContext.Provider>
);
}
/** 获取布尔 Flag */
function useFeatureFlag(key: string, defaultValue: boolean = false): boolean {
const client = useContext(FFContext);
const [value, setValue] = useState<boolean>(() =>
client?.getBooleanFlag(key, defaultValue) ?? defaultValue
);
useEffect(() => {
if (!client) return;
// 监听 Flag 变更,实现实时更新 UI
const unsubscribe = client.onFlagChange(key, (newValue) => {
setValue(newValue as boolean);
});
return unsubscribe;
}, [client, key]);
return value;
}
/** 获取字符串 Flag(A/B 测试多变体) */
function useStringFlag(key: string, defaultValue: string = ''): string {
const client = useContext(FFContext);
const [value, setValue] = useState<string>(() =>
client?.getStringFlag(key, defaultValue) ?? defaultValue
);
useEffect(() => {
if (!client) return;
const unsubscribe = client.onFlagChange(key, (newValue) => {
setValue(newValue as string);
});
return unsubscribe;
}, [client, key]);
return value;
}
/** 获取 JSON Flag */
function useJsonFlag<T>(key: string, defaultValue: T): T {
const client = useContext(FFContext);
const [value, setValue] = useState<T>(() =>
client?.getJsonFlag<T>(key, defaultValue) ?? defaultValue
);
useEffect(() => {
if (!client) return;
const unsubscribe = client.onFlagChange(key, (newValue) => {
setValue(newValue as T);
});
return unsubscribe;
}, [client, key]);
return value;
}
6.2 业务集成示例
- 组件级灰度
- A/B 测试
- 路由级灰度
- 配置级灰度
function Navbar() {
const showNewNavbar = useFeatureFlag('new-navbar-v2', false);
if (showNewNavbar) {
return <NewNavbar />;
}
return <OldNavbar />;
}
function CheckoutButton() {
const buttonVariant = useStringFlag('checkout-btn-text', 'buy');
const textMap: Record<string, string> = {
buy: '立即购买',
cart: '加入购物车',
order: '马上下单',
};
return (
<button className="checkout-btn">
{textMap[buttonVariant] ?? textMap.buy}
</button>
);
}
import { lazy, Suspense } from 'react';
const OldDashboard = lazy(() => import('./OldDashboard'));
const NewDashboard = lazy(() => import('./NewDashboard'));
function DashboardRoute() {
const useNewDashboard = useFeatureFlag('new-dashboard', false);
return (
<Suspense fallback={<Loading />}>
{useNewDashboard ? <NewDashboard /> : <OldDashboard />}
</Suspense>
);
}
interface ListConfig {
pageSize: number;
showRating: boolean;
layout: 'grid' | 'list';
}
function ProductList() {
const listConfig = useJsonFlag<ListConfig>('product-list-config', {
pageSize: 20,
showRating: false,
layout: 'grid',
});
return (
<div className={`layout-${listConfig.layout}`}>
{products.slice(0, listConfig.pageSize).map((product) => (
<ProductCard
key={product.id}
product={product}
showRating={listConfig.showRating}
/>
))}
</div>
);
}
6.3 应用初始化
import { FFProvider, FFClient } from '@myorg/feature-flag-sdk';
const ffClient = new FFClient({
serverUrl: 'https://ff.example.com',
sdkKey: 'sdk-prod-xxx',
user: {
userId: getCurrentUserId(),
platform: 'web',
country: 'CN',
appVersion: '2.1.0',
},
realtime: true,
refreshInterval: 60000,
defaults: {
'new-navbar-v2': false,
'checkout-btn-text': 'buy',
},
onConfigChange: (changes) => {
console.log('[FeatureFlag] 配置变更:', changes);
},
});
function App() {
return (
<FFProvider client={ffClient}>
<Router>
<Routes />
</Router>
</FFProvider>
);
}
七、A/B 测试
7.1 实验设计流程
7.2 数据采集与事件上报
/** 事件类型 */
interface FFEvent {
/** 事件类型 */
type: 'exposure' | 'conversion' | 'custom';
/** Flag key */
flagKey: string;
/** 用户 ID */
userId: string;
/** Flag 求值结果 */
flagValue: unknown;
/** 求值原因 */
reason: string;
/** 实验组 ID */
groupId?: string;
/** 自定义指标名 */
metricName?: string;
/** 自定义指标值 */
metricValue?: number;
/** 时间戳 */
timestamp: number;
}
/**
* 事件上报器
* 使用批量上报 + 本地队列,减少网络请求
*/
class EventReporter {
private queue: FFEvent[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private readonly BATCH_SIZE = 50;
private readonly FLUSH_INTERVAL = 10000; // 10s
private exposureCache: Set<string> = new Set();
constructor(
private serverUrl: string,
private sdkKey: string
) {
// 页面关闭前发送剩余事件
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => this.flush());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush();
}
});
}
}
/**
* 上报曝光事件
* 同一用户 + 同一 Flag 只上报一次(去重)
*/
trackExposure(flagKey: string, result: EvaluationResult): void {
const dedupeKey = `${flagKey}:${result.value}`;
if (this.exposureCache.has(dedupeKey)) return;
this.exposureCache.add(dedupeKey);
this.enqueue({
type: 'exposure',
flagKey,
userId: '', // 由 SDK 层填充
flagValue: result.value,
reason: result.reason,
timestamp: Date.now(),
});
}
/** 上报转化事件 */
trackConversion(flagKey: string, metricName: string, metricValue: number = 1): void {
this.enqueue({
type: 'conversion',
flagKey,
userId: '',
flagValue: null,
reason: 'CONVERSION',
metricName,
metricValue,
timestamp: Date.now(),
});
}
/** 入队列 */
private enqueue(event: FFEvent): void {
this.queue.push(event);
if (this.queue.length >= this.BATCH_SIZE) {
this.flush();
} else if (!this.flushTimer) {
this.flushTimer = setTimeout(() => this.flush(), this.FLUSH_INTERVAL);
}
}
/** 批量发送 */
flush(): void {
if (this.queue.length === 0) return;
const events = [...this.queue];
this.queue = [];
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
// 使用 sendBeacon 保证页面关闭时也能发送
if (typeof navigator?.sendBeacon === 'function') {
navigator.sendBeacon(
`${this.serverUrl}/api/events`,
JSON.stringify({ events, sdkKey: this.sdkKey })
);
} else {
fetch(`${this.serverUrl}/api/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events, sdkKey: this.sdkKey }),
keepalive: true,
}).catch(() => {
// 发送失败,放回队列
this.queue.unshift(...events);
});
}
}
}
7.3 统计分析
A/B 测试的核心是假设检验,判断实验组与对照组之间的指标差异是否具有统计学意义。
- P 值:在原假设(两组无差异)成立的前提下,观察到当前或更极端结果的概率
- P < 0.05:拒绝原假设,认为差异具有统计学显著性
- 置信区间:真实效果大小以 95% 概率落在的范围
/**
* A/B 测试统计分析
* 使用 Z 检验(大样本比例检验)
*/
interface ExperimentMetrics {
/** 样本量 */
sampleSize: number;
/** 转化数 */
conversions: number;
/** 转化率 */
conversionRate: number;
}
interface AnalysisResult {
/** 对照组指标 */
control: ExperimentMetrics;
/** 实验组指标 */
treatment: ExperimentMetrics;
/** 相对提升 */
relativeUplift: number;
/** P 值 */
pValue: number;
/** 是否显著 (P < 0.05) */
isSignificant: boolean;
/** 95% 置信区间 */
confidenceInterval: [number, number];
/** 统计功效 */
power: number;
}
function analyzeExperiment(
control: ExperimentMetrics,
treatment: ExperimentMetrics
): AnalysisResult {
const p1 = control.conversionRate;
const p2 = treatment.conversionRate;
const n1 = control.sampleSize;
const n2 = treatment.sampleSize;
// 合并比例
const pPooled = (control.conversions + treatment.conversions) / (n1 + n2);
// Z 统计量
const se = Math.sqrt(pPooled * (1 - pPooled) * (1 / n1 + 1 / n2));
const z = (p2 - p1) / se;
// P 值(双尾检验)
const pValue = 2 * (1 - normalCDF(Math.abs(z)));
// 相对提升
const relativeUplift = p1 > 0 ? (p2 - p1) / p1 : 0;
// 95% 置信区间
const seDiff = Math.sqrt((p1 * (1 - p1)) / n1 + (p2 * (1 - p2)) / n2);
const ci: [number, number] = [
(p2 - p1) - 1.96 * seDiff,
(p2 - p1) + 1.96 * seDiff,
];
return {
control,
treatment,
relativeUplift,
pValue,
isSignificant: pValue < 0.05,
confidenceInterval: ci,
power: calculatePower(p1, p2, n1, n2),
};
}
/** 标准正态分布 CDF 近似计算 */
function normalCDF(x: number): number {
const a1 = 0.254829592;
const a2 = -0.284496736;
const a3 = 1.421413741;
const a4 = -1.453152027;
const a5 = 1.061405429;
const p = 0.3275911;
const sign = x < 0 ? -1 : 1;
x = Math.abs(x) / Math.SQRT2;
const t = 1.0 / (1.0 + p * x);
const y = 1.0 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return 0.5 * (1.0 + sign * y);
}
/** 统计功效计算 */
function calculatePower(
p1: number,
p2: number,
n1: number,
n2: number,
alpha: number = 0.05
): number {
const se = Math.sqrt((p1 * (1 - p1)) / n1 + (p2 * (1 - p2)) / n2);
const zAlpha = 1.96; // 对应 alpha = 0.05
const effectSize = Math.abs(p2 - p1) / se;
return 1 - normalCDF(zAlpha - effectSize);
}
7.4 最小样本量计算
在启动实验前,需要预估所需的最小样本量,以保证实验结果的可信度:
/**
* 计算 A/B 测试最小样本量
* @param baselineRate - 当前转化率(对照组预期转化率)
* @param mde - 最小可检测效应(Minimum Detectable Effect)
* @param alpha - 显著性水平,默认 0.05
* @param power - 统计功效,默认 0.8
* @returns 每组所需最小样本量
*/
function calculateMinSampleSize(
baselineRate: number,
mde: number,
alpha: number = 0.05,
power: number = 0.8
): number {
const p1 = baselineRate;
const p2 = baselineRate * (1 + mde); // 例如提升 5%
const zAlpha = getZScore(1 - alpha / 2); // 1.96
const zBeta = getZScore(power); // 0.842
const numerator = (zAlpha * Math.sqrt(2 * p1 * (1 - p1)) +
zBeta * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2;
const denominator = (p2 - p1) ** 2;
return Math.ceil(numerator / denominator);
}
function getZScore(p: number): number {
// 使用近似公式
if (p <= 0 || p >= 1) throw new Error('p must be between 0 and 1');
if (p === 0.5) return 0;
const t = Math.sqrt(-2 * Math.log(p < 0.5 ? p : 1 - p));
const c0 = 2.515517;
const c1 = 0.802853;
const c2 = 0.010328;
const d1 = 1.432788;
const d2 = 0.189269;
const d3 = 0.001308;
let z = t - (c0 + c1 * t + c2 * t * t) / (1 + d1 * t + d2 * t * t + d3 * t * t * t);
return p < 0.5 ? -z : z;
}
// 使用示例:
// 当前转化率 5%,希望检测出 10% 的提升
// calculateMinSampleSize(0.05, 0.10) → 每组约 31,234 个样本
八、配置管理后台
8.1 核心功能模块
8.2 Flag 生命周期管理
一个 Feature Flag 从创建到清理,经历完整的生命周期:
Feature Flag 如果不及时清理,会导致代码中充斥大量条件判断,增加维护成本。建议:
- 全量发布后 2 周内完成 Flag 代码清理
- 在管理后台设置 Flag 过期提醒
- 定期进行 Flag 审计,清理过期和废弃的 Flag
8.3 变更审批与审计
/** 审计日志 */
interface AuditLog {
id: string;
/** 操作人 */
operator: string;
/** 操作类型 */
action: AuditAction;
/** 目标 Flag */
flagKey: string;
/** 变更前 */
before: Partial<FeatureFlag> | null;
/** 变更后 */
after: Partial<FeatureFlag> | null;
/** 操作环境 */
environment: string;
/** 操作时间 */
timestamp: number;
/** 审批人 */
approver?: string;
/** 备注 */
comment?: string;
}
type AuditAction =
| 'flag.created'
| 'flag.updated'
| 'flag.enabled'
| 'flag.disabled'
| 'flag.deleted'
| 'rollout.changed'
| 'experiment.started'
| 'experiment.stopped';
/**
* 生产环境变更需要审批
* 审批流程:
* 1. 开发者提交变更请求
* 2. 审批人(Tech Lead / 产品)审批
* 3. 审批通过后自动生效
*/
interface ChangeRequest {
id: string;
flagKey: string;
environment: 'production';
changes: Partial<FeatureFlag>;
requester: string;
status: 'pending' | 'approved' | 'rejected';
approver?: string;
createdAt: number;
resolvedAt?: number;
}
九、发布策略与回滚
9.1 常见发布策略对比
| 策略 | 说明 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 金丝雀发布 | 先发给 1% 用户,逐步扩大 | 风险最低、可及时发现问题 | 发布周期长 | 核心功能变更 |
| 滚动发布 | 按批次逐步替换实例 | 零停机、过程平滑 | 短时间存在新旧版本共存 | 服务端部署 |
| 蓝绿部署 | 两套环境瞬间切换 | 切换快、回滚快 | 资源成本翻倍 | 重大版本升级 |
| Feature Flag | 代码已部署,功能按需开放 | 最灵活、秒级回滚 | 需要 Flag 管理和清理 | 前端功能灰度 |
9.2 金丝雀发布流程
9.3 回滚机制
/**
* 自动回滚机制
* 监控关键指标,异常时自动关闭 Flag
*/
interface RollbackConfig {
/** 要监控的 Flag */
flagKey: string;
/** 监控指标 */
metrics: RollbackMetric[];
/** 检查间隔(秒) */
checkInterval: number;
/** 是否启用自动回滚 */
autoRollback: boolean;
/** 回滚通知 */
notifyChannels: ('email' | 'slack' | 'webhook')[];
}
interface RollbackMetric {
/** 指标名称 */
name: string;
/** 指标类型 */
type: 'error_rate' | 'latency_p99' | 'conversion_rate' | 'custom';
/** 阈值 */
threshold: number;
/** 比较方式 */
comparator: 'gt' | 'lt'; // gt: 大于阈值触发回滚, lt: 小于阈值触发回滚
}
class AutoRollbackMonitor {
private timers: Map<string, ReturnType<typeof setInterval>> = new Map();
startMonitoring(config: RollbackConfig): void {
const timer = setInterval(async () => {
const shouldRollback = await this.checkMetrics(config);
if (shouldRollback) {
// 1. 立即关闭 Flag
await this.disableFlag(config.flagKey);
// 2. 发送告警通知
await this.notify(config);
// 3. 停止监控
this.stopMonitoring(config.flagKey);
// 4. 记录审计日志
await this.logRollback(config);
}
}, config.checkInterval * 1000);
this.timers.set(config.flagKey, timer);
}
private async checkMetrics(config: RollbackConfig): Promise<boolean> {
for (const metric of config.metrics) {
const currentValue = await this.fetchMetricValue(
config.flagKey,
metric.name
);
const triggered =
metric.comparator === 'gt'
? currentValue > metric.threshold
: currentValue < metric.threshold;
if (triggered) {
console.error(
`[AutoRollback] ${config.flagKey} 指标异常: ` +
`${metric.name}=${currentValue}, 阈值=${metric.threshold}`
);
return true;
}
}
return false;
}
/** 关闭 Flag(一键回滚) */
private async disableFlag(flagKey: string): Promise<void> {
await fetch(`/api/flags/${flagKey}/disable`, { method: 'POST' });
// 配置中心会通过 SSE 推送变更到所有前端 SDK
}
private async notify(config: RollbackConfig): Promise<void> {
for (const channel of config.notifyChannels) {
// 发送告警到对应渠道
await fetch('/api/notifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel,
message: `[自动回滚] Flag "${config.flagKey}" 因指标异常已自动关闭`,
}),
});
}
}
stopMonitoring(flagKey: string): void {
const timer = this.timers.get(flagKey);
if (timer) {
clearInterval(timer);
this.timers.delete(flagKey);
}
}
private async fetchMetricValue(flagKey: string, metricName: string): Promise<number> {
const response = await fetch(
`/api/metrics?flagKey=${flagKey}&metric=${metricName}`
);
const data = await response.json();
return data.value;
}
private async logRollback(config: RollbackConfig): Promise<void> {
await fetch('/api/audit-logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'flag.disabled',
flagKey: config.flagKey,
operator: 'system:auto-rollback',
comment: '指标异常触发自动回滚',
}),
});
}
}
十、性能优化
10.1 SDK 性能优化策略
| 优化点 | 策略 | 效果 |
|---|---|---|
| Flag 求值 | 预计算 + 缓存结果,避免重复求值 | 求值耗时 < 0.1ms |
| 网络请求 | 初始化加载全量,后续增量更新 | 减少 90% 网络传输 |
| 本地缓存 | localStorage 持久化,内存缓存加速 | 离线可用,启动 < 5ms |
| 事件上报 | 批量上报 + 去重 + sendBeacon | 减少 HTTP 请求数 |
| 包体积 | Tree Shaking + 核心/插件分离 | Core SDK < 5KB gzipped |
| 实时更新 | SSE 长连接 + 轮询兜底 | 配置更新延迟 < 3s |
10.2 求值缓存优化
/**
* Flag 求值缓存
* 避免同一用户上下文下重复计算
*/
class EvaluationCache {
private cache: Map<string, { value: unknown; timestamp: number }> = new Map();
private readonly TTL = 5000; // 缓存 5 秒
get(flagKey: string, userHash: string): unknown | undefined {
const key = `${flagKey}:${userHash}`;
const entry = this.cache.get(key);
if (!entry) return undefined;
if (Date.now() - entry.timestamp > this.TTL) {
this.cache.delete(key);
return undefined;
}
return entry.value;
}
set(flagKey: string, userHash: string, value: unknown): void {
const key = `${flagKey}:${userHash}`;
this.cache.set(key, { value, timestamp: Date.now() });
}
/** 配置更新时清空缓存 */
invalidate(flagKey?: string): void {
if (flagKey) {
for (const key of this.cache.keys()) {
if (key.startsWith(`${flagKey}:`)) {
this.cache.delete(key);
}
}
} else {
this.cache.clear();
}
}
}
10.3 增量更新
/**
* 增量更新策略
* 只传输变更的 Flag,减少网络传输
*/
interface IncrementalUpdate {
/** 服务端配置版本号 */
version: number;
/** 变更的 Flag 列表 */
changes: FlagChange[];
/** 删除的 Flag key 列表 */
deleted: string[];
}
class IncrementalUpdateManager {
private currentVersion: number = 0;
async fetchUpdates(): Promise<IncrementalUpdate | null> {
const response = await fetch(
`/api/flags/updates?since=${this.currentVersion}`
);
if (response.status === 304) {
// 无变更
return null;
}
const update: IncrementalUpdate = await response.json();
this.currentVersion = update.version;
return update;
}
applyUpdates(
currentFlags: Map<string, FeatureFlag>,
update: IncrementalUpdate
): Map<string, FeatureFlag> {
// 应用变更
for (const change of update.changes) {
currentFlags.set(change.key, change.newValue as FeatureFlag);
}
// 删除已移除的 Flag
for (const key of update.deleted) {
currentFlags.delete(key);
}
return currentFlags;
}
}
十一、主流方案对比
| 维度 | LaunchDarkly | Unleash | Flagsmith | 自建方案 |
|---|---|---|---|---|
| 类型 | 商业 SaaS | 开源 + 商业 | 开源 + 商业 | 内部研发 |
| 价格 | 按 MAU 计费,较贵 | 开源免费,企业版收费 | 开源免费,云版收费 | 人力成本 |
| SDK 支持 | 25+ 语言/框架 | 15+ 语言/框架 | 10+ 语言/框架 | 按需开发 |
| 实时更新 | SSE 流式推送 | 轮询 + Webhook | 轮询 + SSE | 自行实现 |
| A/B 测试 | 内置完善 | 基础支持 | 基础支持 | 自行实现 |
| 数据看板 | 功能强大 | 基础 | 中等 | 自行实现 |
| 私有部署 | 企业版支持 | 支持 | 支持 | 天然私有 |
| 适用场景 | 大型企业、预算充足 | 中大型企业、注重开源 | 中小型企业 | 定制化需求强 |
- 初创团队:直接用 LaunchDarkly 或 Flagsmith 云版,快速上线
- 中型团队:部署 Unleash 开源版,按需扩展
- 大型团队:自建系统或基于 Unleash 二次开发,满足定制化需求
常见面试问题
Q1: Feature Flag 的核心原理是什么?它解决了什么问题?
答案:
Feature Flag(功能开关)的核心原理是将代码部署与功能发布解耦。通过在代码中嵌入条件判断,配合远程配置中心动态控制功能的开启/关闭,实现"Deploy ≠ Release"。
核心工作流程:
// 传统方式:部署即发布,无法控制
function renderPage() {
return <NewFeature />; // 部署后所有用户立即可见
}
// Feature Flag 方式:部署和发布解耦
function renderPage() {
const showNewFeature = featureFlag.getBooleanFlag('new-feature', false);
if (showNewFeature) {
return <NewFeature />; // 只有 Flag 开启时才可见
}
return <OldFeature />; // Flag 关闭时展示旧版
}
Feature Flag 解决的核心问题:
| 问题 | 传统方式 | Feature Flag 方式 |
|---|---|---|
| 发布风险 | 全量发布,出问题影响所有用户 | 灰度放量,逐步验证 |
| 回滚速度 | 需要重新部署,分钟级 | 关闭 Flag,秒级生效 |
| 功能测试 | 只能在测试环境验证 | 生产环境定向给内测用户 |
| A/B 测试 | 需要额外系统支持 | 天然支持多变体分组 |
| 长周期开发 | 功能分支合并困难 | 主干开发 + Flag 控制 |
Feature Flag 的四种用途类型:
- Release Flag(发布开关):控制新功能上线节奏,短期使用
- Experiment Flag(实验开关):A/B 测试,中期使用
- Ops Flag(运维开关):运行时调整系统行为(如降级开关),长期使用
- Permission Flag(权限开关):控制特定用户群体的功能可见性,长期使用
Q2: 如何保证分桶的一致性?为什么不能用 Math.random()?
答案:
分桶一致性是指同一用户在同一实验中,无论何时何地访问,都应命中相同的分组。这是 A/B 测试可信的前提。
为什么不能用 Math.random():
// 错误:使用 Math.random()
function assignGroupWrong(userId: string): 'A' | 'B' {
return Math.random() < 0.5 ? 'A' : 'B'; // 每次结果不同!
}
// 用户第一次访问可能分到 A 组,刷新页面可能分到 B 组
// 导致用户看到的功能反复变化(闪烁),且统计数据不可信
// 正确:使用确定性 Hash
function assignGroupCorrect(userId: string, experimentKey: string): 'A' | 'B' {
const hash = murmurHash3(`${experimentKey}:${userId}`);
return (hash % 100) < 50 ? 'A' : 'B'; // 同一用户永远返回相同结果
}
保证分桶一致性的三个核心原则:
| 原则 | 说明 | 实现方式 |
|---|---|---|
| 确定性 | 相同输入永远产生相同输出 | Hash 函数(MurmurHash/MD5) |
| 均匀性 | 用户在各分桶中分布均匀 | 选择高质量 Hash 算法 |
| 独立性 | 不同实验的分桶互不影响 | Hash 输入包含 experimentKey |
Hash 函数选择对比:
| Hash 算法 | 均匀性 | 性能 | 适用场景 |
|---|---|---|---|
| MurmurHash3 | 优秀 | 极快 | 首选,前端分桶标准方案 |
| MD5 | 优秀 | 较慢 | 服务端分桶 |
| SHA-256 | 优秀 | 最慢 | 安全场景 |
| 简单取模 | 差 | 最快 | 不推荐,分布不均匀 |
Q3: 灰度发布和 A/B 测试有什么区别?
答案:
虽然灰度发布和 A/B 测试都涉及流量分配,但它们的目的、方法和决策依据完全不同:
| 维度 | 灰度发布 | A/B 测试 |
|---|---|---|
| 目的 | 安全地发布新功能,降低风险 | 对比方案优劣,数据驱动决策 |
| 分组 | 通常只有两组:灰度组 + 对照组 | 可以有多个实验组(A/B/C/...) |
| 放量方式 | 逐步扩大(1% → 10% → 100%) | 固定比例,直到样本量足够 |
| 关注点 | 错误率、崩溃率、性能指标 | 业务指标(转化率、留存率等) |
| 决策依据 | "有没有问题"(故障检测) | "哪个更好"(统计显著性) |
| 持续时间 | 短期(确认稳定后即全量) | 中期(需要足够样本量) |
| 结束方式 | 全量或回滚 | 基于 P 值判断后选择最优方案 |
实际中两者通常结合使用:
Q4: 如何设计实时配置更新?SSE 和 WebSocket 如何选择?
答案:
实时配置更新的目标是让管理后台的配置变更秒级生效到所有前端客户端。主要有三种方案:
| 方案 | 原理 | 延迟 | 可靠性 | 实现复杂度 |
|---|---|---|---|---|
| 轮询 | 客户端定时拉取 | 高(取决于间隔) | 高 | 低 |
| SSE | 服务端单向推送 | 低(秒级) | 中(自动重连) | 中 |
| WebSocket | 双向通信 | 低(秒级) | 中(需心跳保活) | 高 |
推荐方案:SSE + 轮询兜底
原因分析:
- Feature Flag 配置更新是典型的"服务端向客户端推送"场景,不需要双向通信,SSE 天然匹配
- SSE 基于 HTTP,天然支持重连,浏览器内置
EventSourceAPI 自动处理断线重连 - SSE 资源消耗远低于 WebSocket,更适合大量客户端的场景
- 轮询作为兜底,当 SSE 不可用时(如某些代理环境),保证配置仍能更新
/**
* 多层更新策略
* 优先级:SSE 实时推送 > 轮询兜底 > 本地缓存
*/
class MultiLayerUpdater {
private sseConnected = false;
constructor(
private serverUrl: string,
private sdkKey: string,
private onUpdate: (flags: Record<string, FeatureFlag>) => void
) {}
start(): void {
// 层级 1:SSE 实时推送
this.connectSSE();
// 层级 2:轮询兜底(SSE 断开时启用)
setInterval(async () => {
if (!this.sseConnected) {
const flags = await this.fetchFlags();
if (flags) this.onUpdate(flags);
}
}, 30000);
}
private connectSSE(): void {
const es = new EventSource(
`${this.serverUrl}/api/flags/stream?key=${this.sdkKey}`
);
es.onopen = () => {
this.sseConnected = true;
};
es.onmessage = (event) => {
const data = JSON.parse(event.data);
this.onUpdate(data.flags);
};
es.onerror = () => {
this.sseConnected = false;
// EventSource 会自动重连
};
}
private async fetchFlags(): Promise<Record<string, FeatureFlag> | null> {
try {
const res = await fetch(`${this.serverUrl}/api/flags`, {
headers: { Authorization: `Bearer ${this.sdkKey}` },
});
return res.json();
} catch {
return null;
}
}
}
SSE vs WebSocket 选择决策树:
相关链接
- Martin Fowler - Feature Toggles - Feature Flag 权威指南
- LaunchDarkly 文档 - 业界标杆 Feature Flag 平台
- Unleash 文档 - 主流开源 Feature Flag 方案
- MDN - EventSource - SSE 浏览器 API
- MDN - sendBeacon - 页面关闭时数据上报
- Google - A/B Testing - Google 官方 A/B 测试指南
- 前端 SDK 通用架构设计 - SDK 架构设计详解
- 前端 CI/CD 部署系统 - CI/CD 与灰度发布实践
- 前端监控与埋点 - 埋点与数据上报
- 设计前端埋点监控系统 - 监控系统架构设计