《互联网产品的客户端架构·规模化与架构演进》12.3 迁移的退出条件与回滚

迁移的终点不是新路径上线,而是旧路径被安全删除。本节给出流量、错误、数据一致与观察期四类退出判据,把回滚资产检查、宣布结束、宽限期与旧路径删除写成状态机函数,并说明每条判据的边界与反例:指标刚好达标算不算过,回滚资产存在但过期算不算就绪,以及删除后无法回滚的代价。

一次「指标全绿」却删不掉的旧接口

某团队完成客户端配置迁移,新路径承接了 99.9% 的流量,错误率 0.1%,一致性 99.99%。周会上有人提议下周删除旧接口,理由是「数据已经证明新路径没问题」。

真正动手前,值班同学翻了翻回滚清单:上一版客户端制品已经过期四十天,配置回退开关的责任人已经转岗,数据恢复脚本只在半年前的演练里跑过。更麻烦的是,还有一个小渠道客户端在调用旧接口,它的发版节奏比主端慢两个月。

这次没有删。不是因为新路径不好,而是因为退出条件从来不止「新路径表现好」。删掉旧路径意味着放弃回滚能力,也意味着所有仍在调用旧路径的客户端会被立即打断。退出是一次不可逆决策,必须同时满足质量、流量、数据与可回滚四类条件。

退出是五条判据,加三道门

把退出压缩成一个总健康分,会掩盖不同维度的风险。更可执行的拆法是五条独立判据:

判据比较方式示例阈值单独看的原因
new-traffic大于等于99.5%新路径确实在承接主流量
legacy-traffic小于等于0.5%残余调用仍然可见
error小于等于0.5%新路径自身质量
consistency大于等于99.95%双写双读后的数据差异
observation大于等于7 天覆盖完整的业务周期

比较边界必须明确:等于阈值算通过。如果「小于等于 0.5%」在实际实现里写成「小于 0.5%」,那么 0.5% 就成了一个永远无法确认的数字。阈值是合同,不是参考线。

样本量是前置门。指标再漂亮,样本不足也不能下结论;因此 evaluateExit 先看样本数,样本不足时 ready 为假、原因是 insufficient-samples,但失败判据仍然列在 blockers 里,方便定位。

第二道门是回滚资产。退出前必须证明「还能回去」:

资产证据失效条件
artifact可部署的上一版制品从未验证或超过有效期
config可回退的开关与配置从未验证或超过有效期
data可恢复的数据快照从未验证或超过有效期
runbook有责任人的操作手册责任人为空

「存在」不等于「就绪」。一个四十天没验证过的制品、一个没有责任人的手册,都不能支撑回滚。checkRollback 同时返回缺失、过期与未归属三类问题,而不是只给一个布尔值。

第三道门是时间。宣布结束不等于立即删除,中间需要一段宽限期,让慢发版渠道、缓存与下游任务完成切换。状态机只有三个状态:

状态进入条件允许动作
draft尚未宣布只能评估,不能删除
announced退出判据与回滚资产都就绪等待宽限期
removed宽限期结束且复核仍就绪清空旧路径

删除前要复核,而不是复用宣布时的结论。宽限期内可能发生指标回退、制品过期或责任人变更;removeLegacy 重新检查退出判据与回滚就绪,两者任一不满足都拒绝删除。

把判据、回滚与状态机写成代码

下面的模块把五项判据、回滚资产检查、退役状态与宣布/删除动作分开实现。所有时间都使用毫秒时间戳,状态转换返回新对象,不修改输入。

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

export type ExitCriterion =
  | "new-traffic"
  | "legacy-traffic"
  | "error"
  | "consistency"
  | "observation";

export type ExitThresholds = {
  minNewTrafficPercent: number;
  maxLegacyTrafficPercent: number;
  maxErrorRate: number;
  minConsistencyRate: number;
  minObservationMs: number;
};

export type ExitObservation = {
  newTrafficPercent: number;
  legacyTrafficPercent: number;
  errorRate: number;
  consistencyRate: number;
  observedMs: number;
  samples: number;
};

export type CriterionResult = {
  id: ExitCriterion;
  passed: boolean;
  actual: number;
  threshold: number;
  mode: "min" | "max";
};

export type ExitReason = "ready" | "criteria" | "insufficient-samples";

export type ExitVerdict = {
  ready: boolean;
  reason: ExitReason;
  results: CriterionResult[];
  blockers: ExitCriterion[];
};

export type RollbackKind = "artifact" | "config" | "data" | "runbook";

export type RollbackAsset = {
  id: string;
  kind: RollbackKind;
  owner: string;
  verifiedAt: number | null;
};

export type RollbackReadiness = {
  ready: boolean;
  missing: RollbackKind[];
  stale: string[];
  unowned: string[];
};

export type RetirementStatus = "draft" | "announced" | "removed";

export type RetirementState = {
  feature: string;
  legacyPaths: string[];
  announcedAt: number | null;
  removedAt: number | null;
};

export type RetirementReason =
  | "allowed"
  | "already-announced"
  | "already-removed"
  | "not-ready"
  | "rollback-not-ready"
  | "not-announced"
  | "grace-period";

export type RetirementDecision = {
  allowed: boolean;
  reason: RetirementReason;
  state: RetirementState;
};

export const EXIT_CRITERIA: ExitCriterion[] = [
  "new-traffic",
  "legacy-traffic",
  "error",
  "consistency",
  "observation",
];

export const REQUIRED_ROLLBACK_KINDS: RollbackKind[] = [
  "artifact",
  "config",
  "data",
  "runbook",
];

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

function cloneState(state: RetirementState): RetirementState {
  return {
    feature: state.feature,
    legacyPaths: [...state.legacyPaths],
    announcedAt: state.announcedAt,
    removedAt: state.removedAt,
  };
}

export function evaluateExit(
  observation: ExitObservation,
  thresholds: ExitThresholds,
  minSamples: number = 1000,
): ExitVerdict {
  const results: CriterionResult[] = [
    {
      id: "new-traffic",
      passed: observation.newTrafficPercent >= thresholds.minNewTrafficPercent,
      actual: roundValue(observation.newTrafficPercent),
      threshold: thresholds.minNewTrafficPercent,
      mode: "min",
    },
    {
      id: "legacy-traffic",
      passed: observation.legacyTrafficPercent <= thresholds.maxLegacyTrafficPercent,
      actual: roundValue(observation.legacyTrafficPercent),
      threshold: thresholds.maxLegacyTrafficPercent,
      mode: "max",
    },
    {
      id: "error",
      passed: observation.errorRate <= thresholds.maxErrorRate,
      actual: roundValue(observation.errorRate),
      threshold: thresholds.maxErrorRate,
      mode: "max",
    },
    {
      id: "consistency",
      passed: observation.consistencyRate >= thresholds.minConsistencyRate,
      actual: roundValue(observation.consistencyRate),
      threshold: thresholds.minConsistencyRate,
      mode: "min",
    },
    {
      id: "observation",
      passed: observation.observedMs >= thresholds.minObservationMs,
      actual: observation.observedMs,
      threshold: thresholds.minObservationMs,
      mode: "min",
    },
  ];
  const blockers = results
    .filter((item) => !item.passed)
    .map((item) => item.id);
  let reason: ExitReason = "ready";
  if (observation.samples < minSamples) reason = "insufficient-samples";
  else if (blockers.length > 0) reason = "criteria";
  return {
    ready: reason === "ready",
    reason,
    results,
    blockers,
  };
}

export function checkRollback(
  assets: RollbackAsset[],
  now: number,
  maxAgeMs: number = 7 * 24 * 60 * 60 * 1000,
): RollbackReadiness {
  const missing = REQUIRED_ROLLBACK_KINDS.filter(
    (kind) => !assets.some((asset) => asset.kind === kind),
  );
  const relevant = assets.filter((asset) =>
    REQUIRED_ROLLBACK_KINDS.includes(asset.kind),
  );
  const stale = relevant
    .filter(
      (asset) =>
        asset.verifiedAt === null || now - asset.verifiedAt > maxAgeMs,
    )
    .map((asset) => asset.id);
  const unowned = relevant
    .filter((asset) => asset.owner.trim() === "")
    .map((asset) => asset.id);
  return {
    ready: missing.length === 0 && stale.length === 0 && unowned.length === 0,
    missing,
    stale,
    unowned,
  };
}

export function retirementStatus(state: RetirementState): RetirementStatus {
  if (state.removedAt !== null) return "removed";
  if (state.announcedAt !== null) return "announced";
  return "draft";
}

export function announceRetirement(
  state: RetirementState,
  verdict: ExitVerdict,
  rollback: RollbackReadiness,
  now: number,
): RetirementDecision {
  if (state.removedAt !== null) {
    return { allowed: false, reason: "already-removed", state: cloneState(state) };
  }
  if (state.announcedAt !== null) {
    return { allowed: false, reason: "already-announced", state: cloneState(state) };
  }
  if (!verdict.ready) {
    return { allowed: false, reason: "not-ready", state: cloneState(state) };
  }
  if (!rollback.ready) {
    return {
      allowed: false,
      reason: "rollback-not-ready",
      state: cloneState(state),
    };
  }
  return {
    allowed: true,
    reason: "allowed",
    state: { ...cloneState(state), announcedAt: now },
  };
}

export function removeLegacy(
  state: RetirementState,
  input: {
    now: number;
    graceMs: number;
    verdict: ExitVerdict;
    rollback: RollbackReadiness;
  },
): RetirementDecision {
  if (state.removedAt !== null) {
    return { allowed: false, reason: "already-removed", state: cloneState(state) };
  }
  if (state.announcedAt === null) {
    return { allowed: false, reason: "not-announced", state: cloneState(state) };
  }
  if (input.now - state.announcedAt < input.graceMs) {
    return { allowed: false, reason: "grace-period", state: cloneState(state) };
  }
  if (!input.verdict.ready) {
    return { allowed: false, reason: "not-ready", state: cloneState(state) };
  }
  if (!input.rollback.ready) {
    return {
      allowed: false,
      reason: "rollback-not-ready",
      state: cloneState(state),
    };
  }
  return {
    allowed: true,
    reason: "allowed",
    state: {
      ...cloneState(state),
      legacyPaths: [],
      removedAt: input.now,
    },
  };
}

判据与反例

判断退出流程是否可靠,先看八条可复现的判据:

  1. 五项指标恰好等于阈值时,判据必须通过。
  2. 样本数低于下限时,即使五项全过也不能退出。
  3. 多个判据失败时,blockers 按固定顺序列出,便于看板对齐。
  4. 回滚资产缺失、未验证、超过有效期或责任人为空,任一情况都让检查不就绪。
  5. 验证时间恰好等于有效期边界时仍然有效;超过一天才算过期。
  6. 退出判据或回滚未就绪时,宣布动作必须拒绝。
  7. 宣布是幂等的:重复宣布不覆盖首次时间。
  8. 宽限期内、判据回退、回滚资产过期,任一情况都拒绝删除;删除是幂等的。

对应的反例:

只看流量,不看残余旧流量。 新路径 99.9% 可能意味着旧路径还有 0.1%,对一个大产品来说仍是可观的调用量。legacy-traffic 必须单独设上限。

观察期按自然日算。 代码里用毫秒差值与阈值比较;展示层可以写「七天」,但跨时区、跨夏令时的时候,自然日会让边界漂移。

回滚资产「存在」就签字。 制品存在但从未部署验证、手册存在但责任人已转岗,都不是可回滚证据。检查要覆盖新鲜度与归属。

宣布后立即删除。 宣布是通知,宽限期是让外部系统反应的时间。跳过宽限期等于把内部迁移节奏强加给所有调用方。

删除后才发现需要回滚。 旧路径删除后,回滚资产只能恢复数据,恢复不了已经下线的客户端兼容性。删除前的复核必须同时看判据与回滚,而不是只看其中一个。

小结

退出的本质是放弃回滚能力,所以它需要证据,而不是信心。

  • 五条判据分开看:新流量、旧残余、错误、一致性、观察期;等于阈值算通过。
  • 样本不足是前置否决项,不能用漂亮的比例绕过。
  • 回滚资产要检查缺失、过期与归属;存在不等于就绪。
  • 宣布与删除分离:宣布后进入宽限期,删除前重新复核判据与回滚。
  • 删除是幂等的;重复调用不改变首次删除时间与最终状态。

当退出判据、回滚检查和状态机都能被测试复现,删除旧路径才是一次可审计的决策,而不是一次勇敢的尝试。

阅读导航:上一节:12.2 双写双读与兼容窗口 · 下一节:13.1 端到端:多端同一笔交易的体验一致性 。

常见错误与边界

  1. 把 legacy-traffic 与 new-traffic 当同一个指标。 两者之和不一定等于 100%,采样、缓存与灰度规则都会造成偏差;必须分别设阈值。
  2. 用总请求数代替样本数。 退出判据的样本应该是「参与迁移比对的样本」,而不是全站 QPS 累计。
  3. 回滚资产没有有效期。 制品、配置、快照都会腐化;maxAgeMs 是必填的治理参数,不是可选优化。
  4. 责任人写成团队名。 owner 为空或只有团队名都无法在凌晨三点找到人;检查只能识别空字符串,流程要要求具体值班人。
  5. 宣布后停止监控。 宽限期内指标仍可能回退;删除前的复核就是最后一道监控闸门。
  6. 删除动作没有审计字段。 removedAt 必须保留;它回答「什么时候失去回滚能力」,是事故复盘的关键时间点。
  7. 把宽限期当成固定常量。 不同渠道发版节奏不同,宽限期应按最慢调用方设定,并在宣布时记录依据。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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