《互联网产品的客户端架构·规模化与架构演进》12.1 绞杀者与渐进式替换

绞杀者替换的关键不是一次删掉旧代码,而是让新旧路径同时存在,并按用户、端与功能分桶逐步放量。本节把路由比例、白名单与 holdback、确定性哈希、接管进度和回退写成可测试的纯函数,让每一次请求都能解释自己为什么走新路径或旧路径,也说明如何判断迁移是否真正完成。

一次「50% 灰度」切出了两种账单

某 SaaS 客户端把账单页从旧渲染层迁到新渲染层。计划写的是 50% 灰度,实现却是在每次请求里执行 Math.random() < 0.5。上线当天客服收到投诉:同一个账号刷新三次,两次看到新账单、一次看到旧账单,金额口径还不同。

问题不在 50%,而在分桶键选错了。按请求随机,等于按「这一次」分桶;用户需要的是连续性,应该按「这个用户」分桶。更进一步,同一个用户在不同端、不同功能上可以处于不同阶段,所以分桶维度不能只有一个。

绞杀者模式(Strangler Fig)的原始比喻是让新藤蔓绕着老树生长,等新结构能独立支撑后再移除老树。工程上它不是一个开关,而是一张三段式的路由表:谁能进新路径、进多少、出问题怎么退。这张表如果只写在发布脚本里,就没有人能解释某一次请求为什么被切走。

路由是一张优先级表,不是一个百分比

把「50%」当成唯一输入,会忽略三个更具体的问题:谁必须留下、谁必须先进、先按哪个维度分。一个可执行的路由顺序应该是:

优先级匹配结果用途
1holdback旧路径保护名单,任何比例下都不切
2allowlist新路径内部验证、客户试点
3rules 声明顺序强制目标或规则比例按用户、端、功能分桶
4全局比例哈希分桶兜底放量

holdback 放在最前面,是因为它承担的是保护职责:只要命中,就不应该被任何后续规则覆盖。也正因为如此,同一个分桶同时出现在 allowlist 和 holdback 时不能靠顺序决定,而应该在计划校验阶段直接报冲突。

规则按声明顺序首个命中生效。这意味着规则表的顺序本身是契约的一部分:把「所有功能」的兜底规则写在最前面,后面的端规则永远不会生效。代码不能替团队猜意图,只能把顺序固定下来并要求评审。

分桶必须确定,且键要带维度

百分比分桶最常见的错误是使用随机数。随机数保证的是「每次请求有 50% 概率」,不是「50% 的用户稳定进入」。正确做法是对分桶键做确定性哈希,再映射到 0 到 99:

决定输入关键约束
分桶键kind:key同一用户、端、功能必须稳定
哈希FNV-1a不依赖进程、时间与随机种子
比例0 到 100 的整数0 全旧、100 全新,边界可测
覆盖force / allowlist / holdback显式意图优先于比例

键里必须带维度前缀。如果用户 ID 和端 ID 恰好都是 ios,不带前缀就会让两个维度共享同一个桶,端规则和用户规则互相污染。代码里用 user:ios、endpoint:ios 这样的形式区分。

把计划写成代码

下面的模块把计划校验、路由决策、接管统计、推进与回退分开实现。所有函数都是纯函数,不读时钟、不读随机数,因此同一份计划对同一批请求永远给出同一组结果。

// file: src/migration/strangler.ts
// tier: L1
// verify: node --test test/migration/strangler.test.ts

export type BucketKind = "user" | "endpoint" | "feature";

export type RouteTarget = "legacy" | "new";

export type BucketRef = {
  kind: BucketKind;
  key: string;
};

export type BucketRule = {
  kind: BucketKind;
  keys: string[] | null;
  percent: number | null;
  force: RouteTarget | null;
};

export type MigrationPlan = {
  feature: string;
  percent: number;
  rules: BucketRule[];
  allowlist: BucketRef[];
  holdback: BucketRef[];
};

export type RouteRequest = {
  user: string;
  endpoint: string;
  feature: string;
};

export type RouteMatcher =
  | "allowlist"
  | "holdback"
  | "rule:user"
  | "rule:endpoint"
  | "rule:feature"
  | "global";

export type RouteDecision = {
  target: RouteTarget;
  matchedBy: RouteMatcher;
  percent: number;
};

export type TakeoverReport = {
  total: number;
  newCount: number;
  legacyCount: number;
  newPercent: number;
  legacyPercent: number;
  complete: boolean;
};

export type PromoteResult =
  | { ok: true; plan: MigrationPlan }
  | { ok: false; reason: "range" | "regression" };

const KINDS: BucketKind[] = ["user", "endpoint", "feature"];

function roundPercent(value: number): number {
  return Math.round(value * 100) / 100;
}

function clonePlan(plan: MigrationPlan): MigrationPlan {
  return {
    feature: plan.feature,
    percent: plan.percent,
    rules: plan.rules.map((rule) => ({
      kind: rule.kind,
      keys: rule.keys === null ? null : [...rule.keys],
      percent: rule.percent,
      force: rule.force,
    })),
    allowlist: plan.allowlist.map((ref) => ({ kind: ref.kind, key: ref.key })),
    holdback: plan.holdback.map((ref) => ({ kind: ref.kind, key: ref.key })),
  };
}

export function bucketPercent(key: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < key.length; i += 1) {
    hash ^= key.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0) % 100;
}

export function bucketKeyOf(request: RouteRequest, kind: BucketKind): string {
  if (kind === "user") return request.user;
  if (kind === "endpoint") return request.endpoint;
  return request.feature;
}

export function sameBucket(ref: BucketRef, request: RouteRequest): boolean {
  return ref.key === bucketKeyOf(request, ref.kind);
}

export function validatePlan(plan: MigrationPlan): string[] {
  const errors: string[] = [];
  if (plan.feature.trim() === "") errors.push("feature");
  if (!Number.isInteger(plan.percent) || plan.percent < 0 || plan.percent > 100) {
    errors.push("percent");
  }
  plan.rules.forEach((rule, index) => {
    if (!KINDS.includes(rule.kind)) errors.push(`rule:${index}:kind`);
    if (rule.keys !== null && rule.keys.length === 0) {
      errors.push(`rule:${index}:keys`);
    }
    if (
      rule.percent !== null &&
      (!Number.isInteger(rule.percent) || rule.percent < 0 || rule.percent > 100)
    ) {
      errors.push(`rule:${index}:percent`);
    }
  });
  for (const held of plan.holdback) {
    const conflict = plan.allowlist.some(
      (allowed) => allowed.kind === held.kind && allowed.key === held.key,
    );
    if (conflict) {
      errors.push(`conflict:${held.kind}:${held.key}`);
    }
  }
  return errors;
}

export function decideRoute(
  plan: MigrationPlan,
  request: RouteRequest,
): RouteDecision {
  if (plan.holdback.some((ref) => sameBucket(ref, request))) {
    return { target: "legacy", matchedBy: "holdback", percent: 0 };
  }
  if (plan.allowlist.some((ref) => sameBucket(ref, request))) {
    return { target: "new", matchedBy: "allowlist", percent: 100 };
  }
  for (const rule of plan.rules) {
    const key = bucketKeyOf(request, rule.kind);
    if (rule.keys !== null && !rule.keys.includes(key)) continue;
    const matchedBy = `rule:${rule.kind}` as RouteMatcher;
    if (rule.force !== null) {
      return {
        target: rule.force,
        matchedBy,
        percent: rule.force === "new" ? 100 : 0,
      };
    }
    const percent = rule.percent ?? plan.percent;
    const hash = bucketPercent(`${rule.kind}:${key}`);
    return {
      target: hash < percent ? "new" : "legacy",
      matchedBy,
      percent,
    };
  }
  const hash = bucketPercent(`user:${request.user}`);
  return {
    target: hash < plan.percent ? "new" : "legacy",
    matchedBy: "global",
    percent: plan.percent,
  };
}

export function takeoverReport(decisions: RouteDecision[]): TakeoverReport {
  const total = decisions.length;
  const newCount = decisions.filter((item) => item.target === "new").length;
  const legacyCount = total - newCount;
  return {
    total,
    newCount,
    legacyCount,
    newPercent: total === 0 ? 0 : roundPercent((newCount * 100) / total),
    legacyPercent: total === 0 ? 0 : roundPercent((legacyCount * 100) / total),
    complete: total > 0 && legacyCount === 0,
  };
}

export function promotePlan(
  plan: MigrationPlan,
  nextPercent: number,
): PromoteResult {
  if (
    !Number.isInteger(nextPercent) ||
    nextPercent < 0 ||
    nextPercent > 100
  ) {
    return { ok: false, reason: "range" };
  }
  if (nextPercent < plan.percent) {
    return { ok: false, reason: "regression" };
  }
  const next = clonePlan(plan);
  next.percent = nextPercent;
  return { ok: true, plan: next };
}

export function rollbackPlan(plan: MigrationPlan): MigrationPlan {
  const next = clonePlan(plan);
  next.percent = 0;
  next.allowlist = [];
  next.rules = next.rules.map((rule) => ({
    kind: rule.kind,
    keys: rule.keys === null ? null : [...rule.keys],
    percent: null,
    force: rule.force === "new" ? "legacy" : rule.force,
  }));
  return next;
}

判据与反例

判断这套路由是否可信,不需要看线上大盘,先看五条可复现的判据:

  1. 比例为 0 时,任意样本都走旧路径;比例为 100 时,任意样本都走新路径。
  2. 同一个用户连续请求二十次,落点必须完全一致。
  3. takeoverReport 只有在样本非空且旧路径计数为 0 时才标记完成。
  4. 回退后,即使规则里写过 force: "new",样本也必须全部回到旧路径。
  5. 白名单与 holdback 重叠时,校验必须报错,而不是靠优先级静默处理。

对应的反例同样具体:

用随机数或请求 ID 分桶。 前者让同一用户来回跳,后者让同一会话跨路径。两种都会造成「一半数据在新库、一半在旧库」的读写错位。

只按用户分桶。 端和功能无法独立推进,一个小程序上的问题会逼你把所有端一起回退。

把 100% 当成迁移完成。 计划里可能仍有 holdback 或强制旧路径的规则。完成与否要看真实流量统计,不看配置里的百分比。

回退只改 percent。 如果不清空 allowlist、不翻转 force: "new",回退后仍有用户被规则送进新路径。回退必须是一份新计划,而不是一个数字。

小结

绞杀者替换的核心是并存,不是删除。

  • 路由是一张优先级表:holdback、allowlist、分桶规则、全局比例,顺序要固定并经过评审。
  • 分桶必须确定:用带维度前缀的键做哈希,不能用随机数或请求 ID。
  • 完成要看流量证据:样本非空且旧路径计数为零,才能宣布接管完成。
  • 回退是一等公民:它要清空覆盖规则并翻转强制目标,而不只是把比例调回 0。

当一张计划能解释每一次请求的去向,迁移才从「发版玄学」变成可运营的过程。

阅读导航:上一节:11.3 技术债台账与治理节奏 · 下一节:12.2 双写双读与兼容窗口 。

常见错误与边界

  1. 把 holdback 当永久白名单。 holdback 是临时保护,必须有到期时间与责任人;否则它会变成永远不迁移的暗账。
  2. 规则表没有兜底。 所有规则都不命中时会落到全局比例,这是刻意设计;如果团队期望「未声明就走旧路径」,应显式加一条 percent: 0 的兜底规则。
  3. 用端 ID 当用户 ID。 分桶键必须带维度前缀,否则不同维度的同名值会互相污染。
  4. 推进时跳档。 promotePlan 只拒绝倒退与越界,不拒绝跳档;1% 直接到 100% 在代码上合法,在流程上仍然危险,需要发布门禁另行约束。
  5. 忽略空样本。 空数组的接管比例是 0,且不会被标记为完成;把「没有流量」当成「迁移完成」是最常见的误判。
  6. 回退后不复核。 回退计划生成后应重新跑一批样本,确认全部落到旧路径,而不是只看 percent 字段。
  7. 只记录比例,不记录命中规则。 排查时最有用的是 matchedBy:它告诉你是 holdback、白名单、哪条规则还是全局比例做的决定。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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