《互联网产品的客户端架构·交易、增长与平台化》10.2 实验配置的本地缓存与回退

实验开关、活动文案和入口配置都走远端下发,但拉取失败、版本回退、冷启动无配置才是客户端真正要处理的场景。本节把版本、ETag、有效期、默认值与交易字段隔离写成可测试的配置仓库,给出失败回退路径,并复盘一次旧版本覆盖新配置、导致活动入口消失的事故。

配置失败时,页面不能跟着失败

远端配置承担实验开关、运营文案与界面布局三类职责,它们都不应该让交易主链路停摆。真实网络里,配置请求会超时、返回 500、返回比本地更旧的版本,也可能在冷启动时来不及返回。配置是增强项,不是交易的前置条件。

这一节回答四个问题:远端文档凭什么覆盖本地,缓存何时过期,失败时读什么,哪些字段不能由配置写。

版本、ETag 与 304

字段作用客户端动作
version单调递增的业务版本小于本地版本直接拒绝
etag同一版本的内容指纹相同版本不同 etag 视为异常
expiresAt本地缓存有效期超过后仍可读,但标记 stale

只用版本号不够:服务端回滚、灰度或多机房可能给同一版本配不同内容。只用 ETag 也不够:它不表达先后关系,旧内容的 ETag 同样合法。两者一起用,才能同时防「旧覆盖新」和「同版本漂移」。请求时带 If-None-Match,304 只延长有效期,不重新解析整份文档。

缓存、过期与默认值

读取顺序固定为:当前快照 → 本地默认值 → 缺失。

  • 快照有效期内正常使用,stale=false。
  • 快照过期但拉取失败:继续使用,stale=true,由业务决定是否降级。
  • 没有快照:使用内置默认值,origin=default。
  • 默认值也没有:返回 missing,调用方必须显式处理。

「过期」不等于「不可用」。弱网下继续用旧文案,比展示空白更合理;但旧配置不能悄悄变成新事实,所以 stale 必须传给上层。

默认值应该是关闭实验、保留原有交易能力的那一组:开关默认 false,新入口默认不展示,文案用本地兜底,数值给出有限范围。默认值还必须复制后保存,否则调用方后续修改会改变冷启动行为。

配置不能碰交易金额

远端配置可以改文案、布局、实验分组,不能改金额、价格、优惠与支付参数。 实现分两层:

  • 入口拒绝:文档出现 price.、trade.、payment.、order.amount 等前缀时,整份文档拒绝应用,而不是只忽略单键。
  • 读取拒绝:即使调用方直接读这些键,仓库也抛错,而不是返回默认值,让越界在开发期暴露。

金额只能来自交易接口,并经过第 1、2 章的所有权模型。配置系统不能成为第二个金额来源。

一个可运行的配置仓库

下面的模块属于 L2:有异步拉取,但网络客户端由调用方注入,时间由参数传入,测试不需要真实网络或定时器。

// file: src/experiment/config.ts
// tier: L2
// verify: node --test test/experiment/config.test.ts

export type ConfigValue = string | number | boolean;
export type ConfigScope = "experiment" | "copy" | "layout";
export type ConfigOrigin = "snapshot" | "default" | "missing";

export interface ConfigDocument {
  version: number;
  etag: string;
  values: Record<string, ConfigValue>;
  scopes: Record<string, ConfigScope>;
  expiresAt: number;
}

export type FetchOutcome =
  | { kind: "ok"; doc: ConfigDocument }
  | { kind: "not-modified"; etag: string; expiresAt: number }
  | { kind: "error"; message: string; status?: number };

export interface RefreshReport {
  status: "updated" | "not-modified" | "rejected" | "failed";
  version: number | null;
  etag: string | null;
  errors: string[];
}

export interface Resolved {
  key: string;
  value: ConfigValue | null;
  origin: ConfigOrigin;
  stale: boolean;
  version: number | null;
}

export interface TypedRead<T> {
  value: T;
  origin: ConfigOrigin;
  stale: boolean;
  version: number | null;
  usedFallback: boolean;
}

export interface ConfigClient {
  fetch(etag: string | null): Promise<FetchOutcome>;
}

// 交易字段永远不接受远端配置下发;前缀同时覆盖整段与单键。
export const DEFAULT_PROTECTED_PREFIXES = [
  "trade.",
  "price.",
  "payment.",
  "order.amount",
  "order.total",
];

const SCOPES: ConfigScope[] = ["experiment", "copy", "layout"];

function isPlainRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isConfigValue(value: unknown): value is ConfigValue {
  if (typeof value === "string" || typeof value === "boolean") return true;
  return typeof value === "number" && Number.isFinite(value);
}

function hasOwn(target: object, key: string): boolean {
  return Object.prototype.hasOwnProperty.call(target, key);
}

function isProtected(key: string, prefixes: string[]): boolean {
  return prefixes.some((prefix) => {
    if (key === prefix) return true;
    if (prefix.endsWith(".")) return key.startsWith(prefix);
    return key.startsWith(`${prefix}.`);
  });
}

export function validateDocument(
  doc: ConfigDocument,
  protectedPrefixes: string[] = DEFAULT_PROTECTED_PREFIXES,
): string[] {
  const errors: string[] = [];
  if (!Number.isSafeInteger(doc.version) || doc.version < 1) {
    errors.push("version 必须是正整数");
  }
  if (typeof doc.etag !== "string" || doc.etag.length === 0) {
    errors.push("etag 不能为空");
  }
  if (!Number.isSafeInteger(doc.expiresAt) || doc.expiresAt < 0) {
    errors.push("expiresAt 非法");
  }
  if (!isPlainRecord(doc.values)) {
    errors.push("values 必须是对象");
    return errors;
  }
  if (!isPlainRecord(doc.scopes)) {
    errors.push("scopes 必须是对象");
    return errors;
  }
  for (const [key, value] of Object.entries(doc.values)) {
    if (key.trim().length === 0) errors.push("配置键不能为空");
    if (isProtected(key, protectedPrefixes)) {
      errors.push(`禁止下发交易字段:${key}`);
    }
    if (!isConfigValue(value)) errors.push(`配置值类型非法:${key}`);
    if (!hasOwn(doc.scopes, key)) errors.push(`缺少作用域:${key}`);
  }
  for (const key of Object.keys(doc.scopes)) {
    if (!hasOwn(doc.values, key)) errors.push(`作用域指向不存在的键:${key}`);
    if (!SCOPES.includes(doc.scopes[key])) {
      errors.push(`未知作用域:${key}=${String(doc.scopes[key])}`);
    }
  }
  return errors;
}

function cloneDocument(doc: ConfigDocument): ConfigDocument {
  return {
    version: doc.version,
    etag: doc.etag,
    values: { ...doc.values },
    scopes: { ...doc.scopes },
    expiresAt: doc.expiresAt,
  };
}

export class ConfigStore {
  private cache: ConfigDocument | null = null;
  private defaults: Record<string, ConfigValue>;
  private protectedPrefixes: string[];

  constructor(
    defaults: Record<string, ConfigValue>,
    options: { protectedPrefixes?: string[] } = {},
  ) {
    this.defaults = { ...defaults };
    this.protectedPrefixes = [
      ...(options.protectedPrefixes ?? DEFAULT_PROTECTED_PREFIXES),
    ];
  }

  get version(): number | null {
    return this.cache?.version ?? null;
  }

  get etag(): string | null {
    return this.cache?.etag ?? null;
  }

  private report(
    status: RefreshReport["status"],
    errors: string[] = [],
  ): RefreshReport {
    return { status, version: this.version, etag: this.etag, errors };
  }

  apply(outcome: FetchOutcome, now: number): RefreshReport {
    if (outcome.kind === "error") {
      return this.report("failed", [outcome.message]);
    }
    if (outcome.kind === "not-modified") {
      if (this.cache === null) return this.report("rejected", ["304 响应缺少本地缓存"]);
      if (outcome.etag !== this.cache.etag) {
        return this.report("rejected", ["304 的 etag 与本地缓存不一致"]);
      }
      if (!Number.isSafeInteger(outcome.expiresAt) || outcome.expiresAt <= now) {
        return this.report("rejected", ["304 的 expiresAt 非法或已过期"]);
      }
      this.cache = { ...this.cache, expiresAt: outcome.expiresAt };
      return this.report("not-modified");
    }
    const errors = validateDocument(outcome.doc, this.protectedPrefixes);
    if (errors.length > 0) return this.report("rejected", errors);
    if (outcome.doc.expiresAt <= now) {
      return this.report("rejected", ["下发的文档已经过期"]);
    }
    if (this.cache !== null) {
      if (outcome.doc.version < this.cache.version) {
        return this.report("rejected", ["版本回退,保留本地缓存"]);
      }
      if (outcome.doc.version === this.cache.version) {
        if (outcome.doc.etag !== this.cache.etag) {
          return this.report("rejected", ["同一版本出现不同 etag,拒绝覆盖"]);
        }
        return this.report("not-modified");
      }
    }
    this.cache = cloneDocument(outcome.doc);
    return this.report("updated");
  }

  resolve(key: string, now: number): Resolved {
    if (isProtected(key, this.protectedPrefixes)) {
      throw new Error(`交易关键字段不能从实验配置读取:${key}`);
    }
    if (this.cache !== null && hasOwn(this.cache.values, key)) {
      return {
        key,
        value: this.cache.values[key],
        origin: "snapshot",
        stale: now > this.cache.expiresAt,
        version: this.cache.version,
      };
    }
    if (hasOwn(this.defaults, key)) {
      return {
        key,
        value: this.defaults[key],
        origin: "default",
        stale: false,
        version: this.version,
      };
    }
    return { key, value: null, origin: "missing", stale: false, version: this.version };
  }

  readNumber(key: string, now: number, fallback: number): TypedRead<number> {
    return this.typed(key, now, fallback, (value) => typeof value === "number");
  }

  readString(key: string, now: number, fallback: string): TypedRead<string> {
    return this.typed(key, now, fallback, (value) => typeof value === "string");
  }

  private typed<T>(
    key: string,
    now: number,
    fallback: T,
    guard: (value: ConfigValue) => boolean,
  ): TypedRead<T> {
    const resolved = this.resolve(key, now);
    const matched = resolved.value !== null && guard(resolved.value);
    return {
      value: matched ? (resolved.value as T) : fallback,
      origin: resolved.origin,
      stale: resolved.stale,
      version: resolved.version,
      usedFallback: !matched,
    };
  }
}

// L2:异步拉取只负责把结果交给 apply,时间由调用方注入。
export async function refresh(
  store: ConfigStore,
  client: ConfigClient,
  now: number,
): Promise<RefreshReport> {
  let outcome: FetchOutcome;
  try {
    outcome = await client.fetch(store.etag);
  } catch (error) {
    outcome = {
      kind: "error",
      message: error instanceof Error ? error.message : String(error),
    };
  }
  return store.apply(outcome, now);
}

三个设计点:apply 是唯一写入口,首次拉取、304 与失败都返回结构化报告;版本只前进,服务端回滚必须发更高版本号;304 也要校验缓存存在、etag 匹配、新有效期未过期。

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/experiment/config.test.ts
✔ 冷启动没有缓存时读取默认值 (0.628375ms)
✔ 应用合法文档后版本与 etag 可见 (0.223ms)
✔ 快照值在有效期内正常读取 (0.09175ms)
✔ 超过 expiresAt 后读取标记 stale 但仍返回旧值 (0.069125ms)
✔ 低版本文档被拒绝且不覆盖缓存 (0.128833ms)
✔ 同版本同 etag 视为未变更 (0.085917ms)
✔ 同版本不同 etag 被拒绝 (0.089125ms)
✔ 拉取失败保留缓存,过期后降级为 stale (0.073166ms)
✔ 没有缓存时收到 304 被拒绝 (0.068083ms)
✔ 304 的 etag 与缓存不一致时被拒绝 (0.103542ms)
✔ 304 只延长有效期,不改变版本 (0.080375ms)
✔ 包含交易字段的文档被整体拒绝 (0.065708ms)
✔ 读取交易字段直接抛错 (0.215125ms)
✔ 未下发的键回退默认值,未知键标记 missing (0.0775ms)
✔ 类型不匹配时使用调用方回退值 (0.062834ms)
✔ refresh 把当前 etag 交给客户端 (0.454708ms)
✔ refresh 捕获客户端异常并保留缓存 (0.093625ms)
✔ 下发即过期的文档被拒绝 (0.039542ms)
✔ 文档结构错误被一次列出 (0.056958ms)
✔ 默认值表不会被调用方后续修改影响 (0.043459ms)
ℹ tests 20
ℹ suites 0
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 157.491958

二十条用例覆盖:冷启动、合法应用、有效期、过期 stale、版本回退、同版本 etag、失败保留、异常 304、续期、交易字段拒绝、读取抛错、默认回退、类型回退、etag 透传、异常捕获、过期文档、结构校验、默认值隔离。

一次旧版本覆盖新配置的复盘

某交易客户端把活动入口开关放在远端配置里。大促当天,服务端为回滚文案把版本从 42 退到 41,但内容里仍带着「入口开启」。客户端没有版本比较,直接以「后到者赢」覆盖本地缓存。

更糟的是,两个 CDN 节点返回的 41 版本内容不同:一个带入口,一个不带。用户刷新几次,入口忽隐忽现,客服收到「活动页面一会儿有一会儿没有」的反馈。

复盘发现三个问题:客户端只比较「是否有响应」,旧版本可以覆盖新版本;服务端回滚复用旧版本号却没换 ETag;读取失败直接返回空对象,组件把空对象当成关闭,没有走默认值。

修复做了四件事:版本只前进;同版本不同 ETag 直接拒绝;失败保留旧快照并标记 stale;默认值改为关闭入口并写进冷启动测试。回归后,CDN 不一致时客户端最多停留在旧快照,不再出现入口闪烁。

这个复盘说明:配置系统真正难的不是「拉取」,而是「拒绝」。 拒绝旧版本、异常 304、交易字段与类型不符,才是客户端该做的事。

小结

远端配置要有版本、ETag 与有效期;读取顺序是快照、默认值、缺失;过期可以继续用但必须标记 stale;拉取失败保留缓存,冷启动使用安全默认值;交易金额相关字段在入口与读取两处都被拒绝。

下一节讨论多个实验同时运行时更难的问题:同一用户跨端、跨版本进入不同分组,以及互斥组与污染检测。

阅读导航:上一节:10.1 实验分流与曝光口径 · 下一节:10.3 实验污染与互斥 。

常见错误与边界

  • 把「配置没拉到」当成功能不可用,直接白屏或隐藏交易入口。
  • 只用版本号不用 ETag,服务端回滚或 CDN 不一致时同版本内容漂移。
  • 只用 ETag 不用版本号,旧内容可以合法覆盖新内容。
  • 收到 304 就无条件续期,不校验缓存是否存在、etag 是否匹配。
  • 配置过期后直接丢弃,弱网下把可用页面打成空白。
  • 默认值填成「开启实验」或空字符串,冷启动直接进入未验证分支。
  • 配置读取失败返回空对象,组件把「缺失」误判成「关闭」。
  • 允许配置下发 price. 或 order.amount,让金额出现第二个来源。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查