陌上人如玉
公子世无双

8.9 Feature Flag 与灰度

8.9 Feature Flag 与灰度

一句话:Feature Flag 的价值是「把『发布代码』与『放出功能』解耦」——所以它的核心不是「一个开关」,而是「一致的灰度分桶(同一用户稳定在同组)+ 本地快速求值 + 拉取失败时的兜底 + A/B 的曝光记录」,而最大的风险是「开关变技术债」。

一、四种 Flag:只有两种能长期留

⭐⭐⭐ Feature Flag 的四类(⭐ 用错了会「技术债爆表」):

① 【Release Flag(发布开关)】⭐ 短期
   「新结账流程先给 5% 用户看」
   ⭐ 用途:**灰度发布 / 出问题快速关掉(⭐ 这是它最大的价值)**
   ⚠️ **必须「用完就删」**(发布完成后就该清理)——否则它会「永久留在代码里」

② 【Experiment Flag(实验开关)】短期
   「A/B 测试两种文案/布局」
   ⚠️ 实验结束就删(⭐ 保留 = 每次改代码都要考虑「两个分支」)

③ 【Ops Flag(运维开关)】⭐ 长期合法
   「紧急关闭评论功能」「降级到静态数据」「限流阈值」
   ✅ 这类**本来就应该长期存在**(它是「运维手段」,不是「发布手段」)

④ 【Permission Flag(权限开关)】⚠️ 长期但「应该换成权限系统」
   「只有付费用户能用导出」
   ⚠️ 它本质是「权限」,应该用「权限系统」而不是「flag」(见 6.12)
   ⭐ 用 flag 做权限的后果:「权限规则藏在 flag 配置里」,无法审计、无法按资源授权

⭐ 一句话:
   · **① ② 是「技术债」,必须「创建时定清理时间」**
   · **③ 是合理长期存在**
   · **④ 应该迁移到权限系统**
⭐⭐⭐ 「开关的技术债」有多真实(这是面试里最能体现经验的一点):

   场景:一个项目上线 2 年,积累了 300 个 flag:
     · 其中 200 个是「已经全量放出的发布开关」(⭐ 但没人删)
     · 代码里到处 `if (flags.newCheckout) { 新逻辑 } else { 老逻辑 }`
     ⚠️ 后果:
       · **每个功能有两套代码**(改一处要改两处,测试要测两条路径)
       · ⭐ **没人敢删**(「万一还有人没放开?」)
       · **新人无法理解**(不知道哪个是「活的」)
       · ⭐ 「老逻辑」在「无人测试」的情况下悄悄腐烂(仍然被打包进产物)
   → ⭐ 这就是「Feature Flag 从资产变成负债」的经典过程。

✅ 控制手段(四条,缺一不可):
   ① ⭐ **创建时必填「过期时间」(或「清理 issue 编号」)**
   ② ⭐ **定期扫描「长期未变更的 flag」并告警**(如「90 天没被改过 + 全量 100%」→ 提示删除)
   ③ ⭐ **「全量后自动清理」的流程**(「发布完成 → 建一个删 flag 的 issue」)
   ④ ⭐ **代码里能一眼看出「这是临时 flag」**(命名前缀如 `tmp_xxx` / `release_xxx`)

二、分桶:一致性哈希是核心

⭐⭐⭐ 灰度必须满足「三个一致性」:

   ① 【同一用户 → 同一组】⭐ 最重要
      用户 A 今天在「新版」,刷新后不能变成「老版」(⭐ 否则体验错乱 + 数据混乱)
   ② 【不同 flag → 不同分组】⭐ 防止「实验偏差」
      ⚠️ 如果「所有 flag 都用『user_id 的 hash』」→
        「被分到实验组的永远是同一批人」(⭐ 导致「实验结论不可信」)
      ✅ 把 **flag key 也参与哈希**(`hash(flagKey + userId)`)
   ③ 【增减用户 → 大体稳定】
      ⚠️ 如果用了「取模用户总数」的做法 → 「用户数变化会导致大面积重新分组」
      ✅ 用「哈希到固定区间(如 0~10000)」+「与百分比比较」→ 天然稳定
/** ⭐⭐ 一致性哈希分桶(零依赖,FNV-1a) */
function fnv1a(input: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash >>> 0;
}

/** ⭐ 把「flag + 用户」映射到「0~1 的桶」 */
export function bucketOf(flagKey: string, userId: string): number {
  // ⭐⭐ 关键:**flag key 参与哈希**(否则「所有 flag 的实验组是同一批人」)
  const hash = fnv1a(`${flagKey}:${userId}`);
  // ⭐ 映射到 [0, 1)(用 10000 个桶 → 精度 0.01%)
  return (hash % 10_000) / 10_000;
}

/** ⭐ 判断「某用户是否在灰度范围内」 */
export function isInRollout(flagKey: string, userId: string, rolloutPercent: number): boolean {
  if (rolloutPercent >= 100) return true;
  if (rolloutPercent <= 0) return false;
  return bucketOf(flagKey, userId) < rolloutPercent / 100;
}
/** ⭐⭐ 完整的「规则求值」(支持多种定向条件) */
export interface FlagRule {
  /** ⭐ 规则类型(按「优先级」从上到下匹配,匹配到就返回) */
  kind: 'user' | 'email-domain' | 'region' | 'platform' | 'percent' | 'default';
  /** 白名单(用户 id 列表) */
  userIds?: string[];
  /** 邮箱域名(如 `@acme.com` → 内部用户总是优先体验) */
  emailDomains?: string[];
  /** 地区(来自「服务端解析的 IP 地区」,⭐ 不是前端猜的) */
  regions?: string[];
  /** 平台 */
  platforms?: Array<'ios' | 'android' | 'web'>;
  /** ⭐ 百分比灰度 */
  percent?: number;
  /** 该规则下「是否开启」 */
  value: boolean;
}

export interface FlagConfig {
  key: string;
  /** ⭐⭐ 「默认值」—— ⭐ 配置拉取失败时用它(绝不能白屏) */
  defaultValue: boolean;
  /** 规则列表(⭐ 顺序即优先级) */
  rules: FlagRule[];
  /** 元信息(⭐ 用于「治理」) */
  meta: {
    /** 类型(⭐ 决定「该不该长期存在」) */
    type: 'release' | 'experiment' | 'ops' | 'permission';
    owner: string;
    /** ⭐ 「过期时间」——release/experiment 必填(治理的关键) */
    expiresAt?: string;
    createdAt: string;
  };
}

/** ⭐⭐ 求值(纯函数,容易测试) */
export function evaluateFlag(
  config: FlagConfig,
  context: {
    userId: string;
    email?: string;
    region?: string;
    platform: 'ios' | 'android' | 'web';
  }
): boolean {
  for (const rule of config.rules) {
    switch (rule.kind) {
      case 'user':
        // ⭐ 白名单优先(「内部员工先体验」)
        if (rule.userIds?.includes(context.userId)) return rule.value;
        break;

      case 'email-domain':
        if (context.email && rule.emailDomains?.some((d) => context.email!.endsWith(d))) return rule.value;
        break;

      case 'region':
        if (context.region && rule.regions?.includes(context.region)) return rule.value;
        break;

      case 'platform':
        if (rule.platforms?.includes(context.platform)) return rule.value;
        break;

      case 'percent':
        // ⭐⭐ 一致性哈希(同一用户稳定)
        if (rule.percent !== undefined && isInRollout(config.key, context.userId, rule.percent)) return rule.value;
        break;

      case 'default':
        return rule.value;

      default: {
        // ⭐ 穷举保护(新增 kind 时 TS 会报错)
        const _exhaustive: never = rule.kind;
        void _exhaustive;
      }
    }
  }

  // ⭐ 没有任何规则匹配 → 用默认值
  return config.defaultValue;
}

三、完整实现:一个 Feature Flag SDK(250 行)

// flags.ts —— 前端 Feature Flag SDK(拉取 + 缓存 + 本地求值 + 兜底 + 曝光)
import { evaluateFlag, type FlagConfig } from './evaluate';

/** ============ 1. 用户上下文 ============ */
export interface FlagContext {
  userId: string;
  email?: string;
  /** ⭐ 地区由「服务端解析的 IP」给出(⚠️ 前端猜不准,且时区不等于地区) */
  region?: string;
  platform: 'ios' | 'android' | 'web';
}

/** ============ 2. SDK 选项 ============ */
export interface FlagsOptions {
  /** ⭐ 拉取配置的端点 */
  endpoint: string;
  release: string;
  /** ⭐⭐ 「兜底配置」—— ⭐ 首次加载(无缓存)且拉取失败时用它(绝不能白屏) */
  fallback: Record<string, boolean>;
  /** 缓存 key(localStorage) */
  cacheKey?: string;
  /** 缓存有效期(ms),⭐ 超期就用 fallback 但「仍然尝试拉取」 */
  cacheTtlMs?: number;
  /** ⭐ 暴露给「曝光上报」的回调(用于 A/B 分析) */
  onExposure?: (info: { flagKey: string; value: boolean; variant?: string }) => void;
  /** 拉取配置的函数(默认用 fetch) */
  fetchConfig?: () => Promise<{ flags: FlagConfig[] }>;
}

/** ============ 3. SDK 主体 ============ */
export function createFlags(options: FlagsOptions) {
  const {
    endpoint,
    release,
    fallback,
    cacheKey = '__flags_cache',
    cacheTtlMs = 6 * 60 * 60 * 1000,       // ⭐ 6 小时
    onExposure,
    fetchConfig,
  } = options;

  /** 内存里的「已求值结果」(⭐ 求值一次,后续 O(1) 读取) */
  let evaluated: Record<string, boolean> = { ...fallback };
  let configs = new Map<string, FlagConfig>();
  let context: FlagContext | null = null;

  /** ⭐ 曝光记录(⭐ 每个 flag 只记一次,避免刷爆) */
  const exposed = new Set<string>();

  const listeners = new Set<() => void>();
  const notify = () => listeners.forEach((fn) => fn());

  /** ⭐⭐ 读缓存(「先渲染、后台刷新」—— 让首屏不受 flag 网络延迟影响) */
  function loadCache(): { flags: FlagConfig[]; at: number } | null {
    try {
      const raw = localStorage.getItem(cacheKey);
      if (!raw) return null;
      const parsed = JSON.parse(raw) as { release: string; at: number; flags: FlagConfig[] };
      // ⚠️ 「release 变化」时「缓存失效」(⭐ 因为 flag 配置可能整体变了)
      if (parsed.release !== release) return null;
      return parsed;
    } catch {
      return null;
    }
  }

  function saveCache(flags: FlagConfig[]) {
    try {
      localStorage.setItem(cacheKey, JSON.stringify({ release, at: Date.now(), flags }));
    } catch { /* 隐私模式/配额满 */ }
  }

  /** ⭐ 用「当前 context」重新求值所有 flag */
  function reevaluate() {
    if (!context) return;
    const next: Record<string, boolean> = {};
    for (const [key, config] of configs) {
      next[key] = evaluateFlag(config, context);
    }
    evaluated = { ...fallback, ...next };     // ⭐ 「配置里没有的 flag」用 fallback
    notify();
  }

  /** ⭐⭐ 拉取配置(⭐ 失败时「静默降级」——保留当前值,不能影响业务) */
  async function refresh(): Promise<void> {
    try {
      const data = fetchConfig
        ? await fetchConfig()
        : await fetch(`${endpoint}?release=${encodeURIComponent(release)}`, {
            credentials: 'same-origin',
          }).then((r) => {
            if (!r.ok) throw new Error(`HTTP ${r.status}`);
            return r.json() as Promise<{ flags: FlagConfig[] }>;
          });

      configs = new Map(data.flags.map((f) => [f.key, f]));
      saveCache(data.flags);
      reevaluate();
    } catch (err) {
      // ⭐⭐ 「拉取失败」→ 什么都不做(保持「缓存/fallback 的值」)
      //    ⚠️ 绝不能「把 flag 全设为 false」或「抛错」(那会让页面功能错乱/白屏)
      console.warn('[flags] 配置拉取失败,继续使用当前值', err);
    }
  }

  /** ⭐⭐⭐ 核心:设置用户上下文 → 求值 → 暴露 */
  function identify(next: FlagContext) {
    context = next;
    // ⭐ 用户变了 → 曝光记录要重置(否则「新用户」的曝光会被去重掉)
    exposed.clear();
    reevaluate();
  }

  /** ⭐ 读一个 flag(⭐ 业务只调它) */
  function getFlag(key: string): boolean {
    const value = evaluated[key] ?? fallback[key] ?? false;
    // ⭐ 曝光记录(⭐ A/B 分析的依据:「这个用户看到的是哪一组」)
    if (!exposed.has(key)) {
      exposed.add(key);
      onExposure?.({ flagKey: key, value });
    }
    return value;
  }

  /** ⭐⭐ 启动:先用缓存(同步)→ 后台拉取(异步) */
  function bootstrap(ctx: FlagContext) {
    context = ctx;

    // ① ⭐ 先用「缓存」或「fallback」求出「首屏可用的值」(同步,零延迟)
    const cached = loadCache();
    if (cached) {
      configs = new Map(cached.flags.map((f) => [f.key, f]));
      reevaluate();

      // ⭐ 缓存「过期」→ 仍然先用(⭐ 旧值好过没值),但「立刻去拉」
      if (Date.now() - cached.at > cacheTtlMs) void refresh();
    } else {
      // ⭐ 没缓存 → 用 fallback(⭐ 绝不白屏),并「立刻拉」
      evaluated = { ...fallback };
      notify();
      void refresh();
    }
  }

  return {
    bootstrap,
    identify,
    getFlag,
    refresh,
    /** ⭐ 订阅变化(供 React 的 useSyncExternalStore 用) */
    subscribe: (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; },
    getSnapshot: () => evaluated,
    /** ⭐ 治理:列出「已过期但仍在用」的 flag(⭐ 用于「清理技术债」) */
    listExpired: () => [...configs.values()].filter((c) =>
      (c.meta.type === 'release' || c.meta.type === 'experiment') &&
      c.meta.expiresAt !== undefined &&
      new Date(c.meta.expiresAt).getTime() < Date.now()
    ).map((c) => ({ key: c.key, type: c.meta.type, owner: c.meta.owner, expiresAt: c.meta.expiresAt })),
  };
}
/** ============ 4. React 集成 ============ */
import { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';

type FlagsClient = ReturnType<typeof createFlags>;

const FlagsContext = createContext<FlagsClient | null>(null);

export function FlagsProvider({ client, context, children }: { client: FlagsClient; context: FlagContext; children: React.ReactNode }) {
  useEffect(() => { client.bootstrap(context); }, [client, context]);
  return <FlagsContext.Provider value={client}>{children}</FlagsContext.Provider>;
}

export function useFlag(key: string): boolean {
  const client = useContext(FlagsContext);
  if (!client) throw new Error('useFlag 必须在 <FlagsProvider> 内使用');

  // ⭐ 用 useSyncExternalStore「订阅 flag 变化」(⭐ 与 11.13 的 store 是同一模式)
  const snapshot = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);

  // ⚠️ 注意:`snapshot` 是「整个求值结果对象」→ 它变了就重渲染
  //    ✅ 本项目里「flag 变化很少」,所以可接受;
  //    如果 flag 很多且「频繁刷新」,应该「按 key 订阅」(⭐ 更细的粒度)
  void snapshot;
  return client.getFlag(key);
}

/** ⭐⭐ 声明式组件(⭐ 让「分支逻辑」显式可见,便于「清理时定位」) */
export function Flag({
  name, on, off = null, children,
}: {
  name: string;
  on: React.ReactNode;
  off?: React.ReactNode;
  children?: React.ReactNode;
}) {
  const enabled = useFlag(name);
  return <>{enabled ? (children ?? on) : off}</>;
}

// 用法
function CheckoutButton() {
  return (
    // ⭐ 用 `<Flag>` 而不是「内联 if」—— 好处:
    //    ① 「哪些地方用了这个 flag」可以被静态搜索(`<Flag name="new_checkout">`)
    //    ② 清理时「删掉 `<Flag>` 包裹、只留 on 分支」语义清晰
    <Flag
      name="release_new_checkout"
      on={<NewCheckoutButton />}
      off={<LegacyCheckoutButton />}
    />
  );
}
/** ============ 5. 与后端的一致性(⭐ 关键纪律) ============ */
/**
 * ⭐⭐⭐ 「前端判断 UI,后端判断业务」——不能只靠前端:
 *
 *   ❌ 错误做法:
 *      前端:if (flag('new_checkout')) { 调用「新接口」 }
 *      后端:没有「新接口」的开关 → 「老用户也能调新接口」
 *     → ⚠️ 后果:「绕过 UI 就能用新功能」(不是安全问题,但「实验数据被污染」)
 *
 *   ✅ 正确做法:
 *      ① 前端 flag 只控制「UI 呈现」
 *      ② ⭐ 请求带上「上下文」(或后端也读同一份 flag 配置)→ 后端「按同一个规则判断」
 *      ③ ⭐ 关键业务(扣款/权限)**必须后端判断**(见 6.12 的铁律)
 *
 *   ⭐ 实现方式:
 *     · 后端也接「flag 服务」(同一份配置 + 同一套分桶算法)
 *     · 或「前端把 flag 结果放进请求头/body」+ ⭐ 后端「校验」(而不是「信任」)
 */
export function attachFlagContext(headers: Record<string, string>, client: FlagsClient, keys: string[]) {
  // ⭐ 把「参与业务判断的 flag」放进请求头(⚠️ 后端要「用它做校验」而不是「盲目信任」)
  const values = keys.map((k) => `${k}=${client.getFlag(k) ? '1' : '0'}`).join(',');
  return { ...headers, 'x-flag-context': values };
}

四、治理:让开关不变技术债

⭐⭐⭐ Flag 治理的四条机制(⭐ 这是「大厂做法」与「小团队做法」的分水岭):

① 【创建时强制元信息】
   每个 flag 必须填:`type`(release/experiment/ops/permission)、`owner`、
   ⭐ **`expiresAt`(release/experiment 必填)**
   → ⭐ 在「创建入口」(管理后台/代码 PR 模板)强制

② 【自动化扫描】⭐
   · 「已过期但仍在生效」→ 告警给 owner
   · 「100% 全量超过 N 天」→ 提示「该清理了」
   · ⭐ 「代码里引用了但配置里已删除的 flag」→ 编译/lint 报错
   ✅ 实现:SDK 提供 `listExpired()`(见上文)+ CI 里跑一个检查脚本

③ 【代码侧的可发现性】⭐
   用**命名前缀**(`release_` / `exp_` / `ops_`)+ 声明式组件 `<Flag name="...">` →
   ⭐ 「清理时」可以「全局搜索 `release_new_checkout`」一次找全所有引用

④ 【「清理」进入发布流程】
   ⭐ 「全量发布完成」→ 立刻建「删 flag」的任务(而不是「等有空再说」)
   ✅ 做法:把「删 flag」写进「发布 checklist」
/** ⭐⭐ CI 检查脚本:找出「应该清理的 flag」 */
// scripts/check-flags.mjs
import { readFileSync } from 'node:fs';

const config = JSON.parse(readFileSync('flags.json', 'utf8'));

const RELEASE_MAX_AGE_DAYS = 60;

const problems = [];

for (const flag of config.flags) {
  const { key, meta, rules } = flag;

  // ⭐ ① release/experiment 必须有过期时间
  if ((meta.type === 'release' || meta.type === 'experiment') && !meta.expiresAt) {
    problems.push(`[缺 expiresAt] ${key}(type=${meta.type})—— release/experiment 必须设置过期时间`);
  }

  // ⭐ ② 已过期
  if (meta.expiresAt && new Date(meta.expiresAt).getTime() < Date.now()) {
    problems.push(`[已过期] ${key}(${meta.expiresAt})—— owner=${meta.owner},请清理或续期`);
  }

  // ⭐ ③ 「全量 100%」的 release flag(已经没有分流价值了)
  const isFullRollout = rules.some((r) => r.kind === 'percent' && r.percent === 100 && r.value === true);
  if (meta.type === 'release' && isFullRollout) {
    const ageDays = (Date.now() - new Date(meta.createdAt).getTime()) / 86_400_000;
    if (ageDays > RELEASE_MAX_AGE_DAYS) {
      problems.push(`[可清理] ${key} 已全量 ${Math.round(ageDays)} 天 —— 建议删除 flag 并清理代码分支`);
    }
  }
}

if (problems.length) {
  console.log(`\n⚠️ 发现 ${problems.length} 个 flag 治理问题:`);
  problems.forEach((p) => console.log(`   · ${p}`));
  // ⭐ 「已过期」可以让 CI 失败(⚠️ 但要给「续期」的路径,否则会「为了过 CI 而乱删」)
  const hasExpired = problems.some((p) => p.startsWith('[已过期]'));
  if (hasExpired && process.env.CI_FAIL_ON_EXPIRED === '1') process.exit(1);
} else {
  console.log('✅ flag 治理检查通过');
}

五、本篇特有的坑

// ① 「分桶」不含 flag key(所有实验都是同一批用户)⭐
fnv1a(userId);                                                // ❌
// ✅ fnv1a(`${flagKey}:${userId}`)

// ② 「分桶」用「取模用户总数」(用户数变化 → 大面积重新分组)
userId.charCodeAt(0) % 100;                                   // ❌
// ✅ 哈希到固定区间(0~10000)→ 与百分比比较

// ③ 「同一用户刷新后换了组」(没有一致性哈希)
// ⚠️ 症状:用户「刷新一次变成另一版」
// ✅ 幂等的分桶函数(关键:输入相同 → 结果相同)

// ④ ⭐ 「配置拉取失败」时「把 flag 全设为 false」(功能全没了)
catch { evaluated = {}; }                                     // ❌ 灾难
// ✅ 「保留当前值」+ 用 fallback(⭐ 绝不因「flag 服务不可用」而影响业务)

// ⑤ 「首屏等 flag 拉取」(拖慢首屏)
const flags = await fetchFlags(); render(flags);              // ⚠️ 首屏被 flag 网络延迟绑架
// ✅ 「先用缓存/fallback 渲染,后台刷新」(或「SSR 注入 flag」,见下文)

// ⑥ 「flag 变化导致 UI 闪一下」(先用 fallback 渲染,flag 到了再切)
// ⚠️ 症状:「先显示旧版,50ms 后换成新版」
// ✅ ① SSR/边缘注入 flag;② 用「本地缓存的上次值」(大多数情况无闪);
//    ③ 对「关键 flag」用「骨架」而不是「错误的默认值」

// ⑦ ⭐ 「前端判断了但后端没判断」(绕过 UI 就能用新功能)
// ✅ 前端只控 UI;⭐ 业务判断必须「后端也用同一规则」

// ⑧ 「flag 当权限用」(无法审计、无法按资源授权)
if (flag('can_export')) { ... }                               // ⚠️
// ✅ 用权限系统(见 6.12);⭐ 把 flag 类型的 permission 迁走

// ⑨ 「load-bearing flag」(一个 flag 承担多种含义)
if (flag('new_checkout') && flag('new_payment')) { ... }      // ⚠️ 组合爆炸
// ✅ 一个 flag 一个明确语义;⭐ 组合逻辑要能「被清理时一次看清」

// ⑩ 「flag 嵌套」(`if (a) { if (b) ... }`)
// ⚠️ 清理时「不敢动」
// ✅ 尽量扁平;⭐ 复杂逻辑用「求值函数」(可测)而不是「散落的 if」

// ⑪ ⭐ 「flag 名不统一」(`newCheckout` / `new_checkout` / `checkout_v2`)
// ✅ 命名规范 + 类型约束(TS 里定义 flag key 联合类型)

// ⑫ 「实验结束后没删 flag」(技术债)⭐
// ✅ 过期时间 + 扫描 + 发布 checklist

// ⑬ 「本地开发」用「生产的 flag 配置」(本地行为与线上不同/误改线上)
// ✅ ⭐ 「本地强制覆盖」(localStorage 或 query 参数 `?flag_new_checkout=1`)
//    ⚠️ 但这个「覆盖能力」在生产必须禁用(否则用户能自己开功能)

// ⑭ 「曝光」没记录(无法做 A/B 分析)
// ✅ 每次「读 flag」时记录一次曝光(⭐ 见上文 `getFlag`)

// ⑮ 「曝光」重复记录(每次渲染都记)
// ✅ 「按 flag key 去重」+「用户切换时重置」

// ⑯ ⭐ 「A/B 的指标口径」不清晰(实验结论不可信)
// ⚠️ 「实验组指标变好」可能只是「因为实验组是活跃用户」
// ✅ 「随机分桶 + 留足样本 + 明确主指标 + 注意『新奇效应』」;
//    ⭐ 「不要把「灰度发布」当成「A/B 测试」」(前者目的是「安全」,后者是「验证假设」)

// ⑰ 「flag 服务」是「单点」(它挂了 → 所有功能受影响的概率上升)
// ✅ 前端有缓存/fallback;⭐ 后端也必须有本地兜底

// ⑱ 「flag 拉取」用「每次请求都拉」(浪费 + 慢)
// ✅ 「本地缓存 + TTL + 主动刷新」;⭐ 需要「秒级生效」时用「WebSocket/SSE 推送变更」(见 7.14)

// ⑲ 「flag 变更」没有审计(谁改的、什么时候、为什么)
// ✅ 管理后台记录「变更日志」(⭐ 且「改 flag」要能「快速回滚」)

// ⑳ 「紧急关闭」时「找不到开关」(事故时手忙脚乱)
// ✅ ⭐ 「runbook 里列出『关键功能的 ops flag 名』」+「一键关闭的入口」
// ⑤⑥ 的完整处理(SSR/边缘注入 flag —— 彻底消除「首屏延迟」与「闪烁」)
// 服务端(SSR / 边缘函数):
// ⭐ 在「渲染 HTML 之前」就算好 flag(因为服务端有用户上下文,且没有网络往返)
function injectFlagsIntoHtml(html: string, flags: Record<string, boolean>) {
  const script = `<script>window.__FLAGS__=${JSON.stringify(flags)}</script>`;
  return html.replace('</head>', `${script}</head>`);
}

// 前端 SDK 启动时:
function bootstrapWithSsrFlags(ctx: FlagContext) {
  const ssrFlags = (window as unknown as { __FLAGS__?: Record<string, boolean> }).__FLAGS__;
  if (ssrFlags) {
    // ⭐⭐ 用「服务端算好的值」→ 「首屏渲染出来的就是正确的版本」(零闪烁)
    evaluated = { ...fallback, ...ssrFlags };
    notify();
  }
  // ⭐ 仍然「后台刷新」配置(用于「配置变更后」的后续会话)
  void refresh();
}
// ⑬ 的完整处理(本地覆盖 flag,⚠️ 生产禁用)
export function getDevFlagOverride(): Record<string, boolean> {
  // ⭐ 只在开发/预发生效
  if (import.meta.env.PROD) return {};
  const out: Record<string, boolean> = {};
  // ① query 参数:?flag_new_checkout=1
  for (const [k, v] of new URLSearchParams(location.search)) {
    if (k.startsWith('flag_')) out[k.slice(5)] = v === '1' || v === 'true';
  }
  // ② localStorage:`__flag_overrides` = {"new_checkout": true}
  try {
    const raw = localStorage.getItem('__flag_overrides');
    if (raw) Object.assign(out, JSON.parse(raw) as Record<string, boolean>);
  } catch { /* 忽略 */ }
  return out;
}

六、面试延伸

  1. 「Feature Flag 有什么用?分几类?」
    ⭐ 核心价值是「把『发布代码』与『放出功能』解耦」——于是可以「灰度发布」「出问题秒级关闭(不用回滚代码)」「做 A/B 测试」。四类:① Release Flag(发布开关)——⭐ 短期,灰度发布与快速关闭的主力,必须用完就删;② Experiment Flag(实验开关)——短期,实验结束就删;③ Ops Flag(运维开关)——⭐ 「紧急关闭某功能」「降级模式」,这类长期存在是合理的;④ Permission Flag(权限开关)——⚠️ 长期但应该迁移到权限系统(它本质是「权限」,用 flag 做会导致「规则藏在配置里、无法审计」)。⭐ 一句话:「①② 是技术债、③ 是合理长期、④ 该迁走」。

  2. 「灰度分桶要注意什么?」
    ⭐ 三个一致性:① 同一用户 → 同一组(否则「刷新一下变成另一版」,体验错乱 + 数据混乱);② ⭐ 不同 flag → 不同分组(必须把 flag key 参与哈希,否则「所有 flag 的实验组都是同一批人」→ 实验结论不可信);③ 增减用户 → 大体稳定(⚠️ ❌ 「取模用户总数」的做法会让「用户数变化导致大面积重新分组」,✅ 用「哈希到固定区间(如 0~10000)+ 与百分比比较」)。实现:bucket = hash(flagKey + ':' + userId) % 10000 / 10000,再与 percent/100 比较(FNV-1a 这类非加密哈希即可,⭐ 关键是「输入相同 → 结果相同」)。

  3. 「flag 配置拉取失败怎么办?」
    ⭐ 绝不能「把 flag 全设为 false」或「抛错」——那会让「功能全没了」甚至「白屏」。正确做法:① ⭐ 保留当前值(内存里的)+ 用 fallback 默认值;② 静默降级(只 console.warn,不影响业务);③ ⭐ 本地缓存(localStorage,带 release 校验与 TTL)——这样「服务挂了」也能撑住。另外:① 前端 SDK 必须有「兜底配置」(fallback: Record<string, boolean>),⭐ 它是「首次加载(无缓存)+ 拉取失败」时的唯一依靠;② 后端也必须有本地兜底(flag 服务是「单点」,它挂了你不能「什么都做不了」)。

  4. 「首屏渲染时 flag 还没到,怎么办?」
    三种方案:① 阻塞渲染(await fetchFlags())——⚠️ 首屏被 flag 的网络延迟绑架(最差);② ⭐ 先用「缓存/fallback」渲染,后台刷新——✅ 不阻塞首屏,⚠️ 但可能「闪一下」(先显示旧版,flag 到了切成新版);③ ⭐⭐ SSR/边缘注入——在「渲染 HTML 之前」用服务端(有用户上下文、无网络往返)算好 flag,写进 HTML(window.__FLAGS__)→ 首屏就是正确的版本,零闪烁(⭐ 最优)。实践组合:「SSR 注入(主)+ 本地缓存(降级)+ fallback(兜底)」。⚠️ 如果只能选 ②,那么对「关键 flag」应该用「骨架」而不是「用一个可能是错的默认值」。

  5. 「为什么『前端判断了但后端也要判断』?」
    因为 ⚠️ 前端 flag 只控制「UI 呈现」,绕过 UI 就能用新功能(直接调接口)。两个后果:① ⭐ 实验数据被污染(「实验组」里混进了「其实没看到新 UI 但调了新接口」的用户,结论不可信);② ⭐ 业务逻辑错乱(「新结账流程」需要「新接口」的支持,但如果「老用户也能调新接口」,可能出现「数据不一致」)。正确做法:① 前端只控 UI;② ⭐ 后端也用同一份 flag 配置与同一套分桶算法(「同一用户 → 同一结论」);③ 关键业务(扣款、权限)永远后端判断(见 6.12 的铁律)。实践:把 flag 服务「同时接给前端与后端」,或「前端把 flag 结果放请求头 + ⭐ 后端用它做校验而不是盲目信任」。

  6. 「Feature Flag 会变成技术债吗?怎么治理?」
    ⭐ 会,而且是「最容易被忽视的债」——典型画面:「项目上线 2 年积累 300 个 flag,其中 200 个是已全量但没人删的发布开关,代码里到处 if/else 两套逻辑,没人敢删(怕还有人没放开),新人无法理解,老逻辑在无人测试下腐烂但仍被打包」。治理四机制:① ⭐ 创建时强制元信息(type/owner/expiresAt,release/experiment 必须有过期时间);② ⭐ 自动化扫描(「已过期仍在生效」→ 告警 owner;「100% 全量超过 60 天」→ 提示清理;「代码引用了但配置已删」→ CI 失败);③ ⭐ 代码侧可发现(命名前缀 release_/exp_/ops_ + 声明式 <Flag name="...">,⭐ 这样「清理时全局搜索一次就能找全」);④ ⭐ 「清理」进入发布流程(全量完成后「立刻」建删 flag 的任务)。

  7. 「灰度发布和 A/B 测试是一回事吗?」
    ⭐ 不是——「灰度发布」目的是「安全」(逐步放量、出问题快速关闭,不需要统计显著性);「A/B 测试」目的是「验证假设」(需要「随机分桶 + 足够样本 + 明确主指标 + 统计检验 + 注意新奇效应」)。混用的后果:① ⚠️ 把「灰度」当实验 → 「实验组是『活跃用户优先』」之类的不公平分组 → 结论不可信;② ⚠️ 把「实验」当灰度 → 「为了让两组可比,不敢快速关掉有问题的实验组」→ 风险上升。工程上的区别:灰度要「快速放量/快速关闭」;实验要「分桶随机、样本量足、曝光记录完整」(⭐ 所以「曝光上报」是实验的基础设施,而灰度不需要)。

  8. 「如果让你设计一套 Feature Flag 系统,关键决定有哪些?」
    六个:① ⭐ 求值在哪——「客户端求值」(快、无网络延迟,但「配置要下发」+ 需处理「首屏时机」)vs「服务端求值」(能拿到服务端才有的条件,但有网络往返);⭐ 实践是「客户端求值 + 服务端下发配置 + SSR 注入关键 flag」;② ⭐ 分桶算法——一致性哈希(flagKey + userId 进哈希),且「客户端与服务端用同一个算法」(⭐ 否则「两边结论不一致」);③ ⭐ 降级策略——「缓存 + fallback + 静默降级」(⭐ 绝不让 flag 服务影响业务可用性);④ ⭐ 治理能力——「元信息 + 过期 + 扫描 + 清理流程」(⭐ 决定「长期是否可维护」);⑤ ⭐ 曝光记录——「谁看到了哪一组」(A/B 的基础);⑥ 变更的审计与回滚(「谁在什么时候改了哪个 flag」+ 「一键回滚」)。

一句话速记

Feature Flag 的核心价值是「把『发布代码』与『放出功能』解耦」(灰度发布 + 秒级关闭 + A/B);四类:Release(短期,主力)/ Experiment(短期)/ ⭐ Ops(长期合理)/ Permission(⚠️ 该迁到权限系统),⭐ Release/Experiment 是技术债,必须「创建时定过期」;⭐ 分桶的三个一致性:同一用户稳定同组、⭐ 不同 flag 不同分组(必须 hash(flagKey + ':' + userId),否则实验结论不可信)、增减用户大体稳定(⚠️ 别用「取模用户总数」,用「哈希到固定区间 + 与百分比比较」);⭐ 拉取失败时绝不「全设 false/抛错」(✅ 保留当前值 + fallback + 缓存 + 静默降级,且后端也要有本地兜底);⭐ 首屏与 flag 的三个方案(阻塞渲染最差 / 缓存+fallback 会闪 / ⭐⭐ SSR·边缘注入 window.__FLAGS__ = 零闪烁);⭐ 前端只控 UI,后端必须用同一规则判断业务(否则「绕过 UI 就能用新功能」→ 实验数据污染 + 逻辑错乱);治理四机制:创建强制 owner/expiresAt → 自动化扫描(过期/全量超 60 天/引用已删)→ 命名前缀 + 声明式 <Flag> 便于搜索 → 「全量后立刻建删 flag 任务」;A/B 与灰度不是一回事(灰度求「安全」、实验求「假设」,⭐ 实验必须有曝光记录与统计显著性);本地要能强制覆盖 flag(?flag_x=1),但生产必须禁用覆盖。

赞(0) 打赏
未经允许不得转载:陌上寒 » 8.9 Feature Flag 与灰度

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫