退役不是从目录删一行
能力下线最常见的做法,是在目录里把状态改成 deprecated,然后在某个周五关掉旧版本。第二天,内容链路的摘要节点开始报 404,客服链路的回复缺了引用字段,交易链路的评测集整批判错。
问题不在关闭动作,而在关闭之前没有一条可执行的迁移流程。退役要回答四个问题:谁会受影响、替代版本是什么、每个调用方什么时候迁完、什么条件下才允许强制下线。缺任何一个,退役都会从计划变成事故。
2.1 的条目要求填写 retireAt,但一个日期本身不构成流程。本节把日期扩展成状态机:公告、双跑、下线三个阶段,每个阶段有准入条件与退出证据;下线不是按日期自动发生,而是四项判据同时成立。
四个阶段与准入条件
| 阶段 | 含义 | 允许的动作 | 进入下一阶段的条件 |
|---|---|---|---|
| active | 正常服务 | 发布补丁与次版本 | 公告日期到达 |
| announced | 已公告,仍全量服务 | 只修安全与迁移阻塞问题 | 双跑开始日期到达,且迁移工具就绪 |
| dual-run | 新旧并行,旧版本只接迁移流量 | 修复差异、回滚、逐批切流 | 下线日期到达,且四项下线判据全满足 |
| retired | 不再接收流量 | 只保留文档与回放 | 无 |
状态只能向前。不能从 active 直接跳到 dual-run,也不能在双跑未完成时标记 retired。每一次跃迁都需要证据,而不是一句「已经通知过了」。
公告期与双跑窗口是两个独立约束。公告期从 announcedAt 到 dualRunStart,保证调用方有时间排期;双跑窗口从 dualRunStart 到 retireAt,保证新旧版本有足够时间对比。示例策略取 90 天与 30 天,两个数字都可以按能力风险调整,但必须写进策略而不是每次临时决定。
公告期要交付什么
公告不是发一条群消息。一个可执行的公告至少包含五样东西:
- 依赖清单:哪些能力版本、哪些调用方、每个调用方的负责人。
- 替代方案:新版本号、契约差异、默认档位变化、计费单位是否变化。
- 迁移工具:命令、输入、输出、失败时的回滚方式。
- 时间表:公告日、双跑开始日、下线日,以及每个调用方的建议迁移窗口。
- 反馈渠道:迁移阻塞往哪里报,谁在规定时间内答复。
没有依赖清单,公告只能发给「知道的团队」;没有替代方案,调用方无法评估工作量;没有迁移工具,每个团队会自己写一版,差异在双跑时才暴露;没有时间表,退役日期会变成一次没有截止日的协商。
示例计划从 2026-06-01 公告到 2026-09-01 开始双跑,共 92 天;从双跑到达 2026-12-01 下线,共 91 天。两个窗口都满足 90 天与 30 天的策略下限。日期是日历日,不是时刻,比较时不需要时区换算。
迁移工具要输出什么
迁移工具不是一段「帮你改配置」的脚本,而是一份可回放的迁移记录。它至少要输出六类信息:
| 输出 | 用途 |
|---|---|
| 依赖清单 | 证明没有漏掉调用方,作为公告的附件 |
| 契约差异 | 字段新增、删除、类型变化、默认档位变化 |
| 改写结果 | 每个调用方的新版本范围与覆盖参数 |
| 回归样本 | 迁移前后同一批请求的输入与期望输出 |
| 回滚包 | 迁移失败时恢复旧配置的最小步骤 |
| 状态回写 | 把每个调用方的迁移比例写回退役计划 |
迁移工具必须幂等。同一个调用方重复执行,不应产生第二份配置或第二个工单。它还必须能在 dry-run 模式下只输出差异,让调用方先看再改。
工具的输出要能被退役状态机消费。计划里的 migratedRatio 不是人工填写的百分比,而是工具按调用量回写的实测比例;hasOwnerApproval 来自工单系统,不是「他说可以了」。
强制下线的四项判据
日期到达只是允许下线的必要条件,不是充分条件。以下四项必须同时成立:
- 到期:
today >= retireAt。 - 调用方迁移:每个调用方的迁移比例不低于 99.9%,按调用方数量计算迁移率。
- 残余流量:旧版本残余调用占比不超过 0.5%,按调用量加权,而不是按调用方数量平均。
- 负责人确认:每个调用方的负责人都留下书面确认。
此外,迁移工具不能为空。没有工具的双跑,等于让每个调用方手工试错。
四项判据里最容易作弊的是残余流量。五个调用方里有四个迁移率 100%,一个迁移率 98.2%,按调用方数量算迁移率是 80%,但按调用量加权,如果这个调用方占 60% 的流量,残余占比会远高于 0.5%。所以指标必须同时看「几个调用方迁完」和「多少流量迁完」,两者不能互相替代。
强制下线不是「到点就关」,而是「四项判据都满足才关」。如果判据不满足,正确动作是延期并记录原因,而不是关掉之后再说。
退役还要有业务理由。卷三的三能力编排把成本从 4,240 微元降到 3,786 微元,下降 10.71%,复用回本线是 28.47%;迁移后的新版本如果让复用率跌破这条线,退役就只是把成本从旧版本搬到新版本。收益核算必须沿用同一套整数微元口径,不能用「新版本更先进」代替。
把退役写成状态机
catalog-retirement.ts 提供四组能力:计划校验、迁移指标、强制下线判定与状态推进。计划校验检查日期顺序、公告与双跑窗口、迁移工具、调用方唯一性与比例范围;指标按调用量加权;状态推进只允许向前。
// file: src/gov/catalog-retirement.ts
// tier: L2
// verify: node --test test/gov/catalog.test.ts
import { isIsoDate } from "./catalog.ts";
export type RetirementStage = "active" | "announced" | "dual-run" | "retired";
export interface MigrationEntry {
consumer: string;
monthlyCalls: number;
/** Share of this consumer's calls already on the replacement, 0..1. */
migratedRatio: number;
hasOwnerApproval: boolean;
}
export interface RetirementPlan {
capability: string;
version: string;
stage: RetirementStage;
announcedAt: string;
dualRunStart: string;
retireAt: string;
migrationTool: string;
consumers: readonly MigrationEntry[];
}
export interface RetirementPolicy {
minNoticeDays: number;
minDualRunDays: number;
/** Maximum share of old-version traffic allowed at shutdown, 0..1. */
residualShareLimit: number;
}
export const DEFAULT_RETIREMENT_POLICY: RetirementPolicy = {
minNoticeDays: 90,
minDualRunDays: 30,
residualShareLimit: 0.005,
};
export type RetirementPlanIssueCode =
| "bad-date"
| "date-order"
| "notice-too-short"
| "dual-run-too-short"
| "missing-tool"
| "no-consumers"
| "duplicate-consumer"
| "invalid-calls"
| "invalid-ratio"
| "invalid-stage";
export interface RetirementPlanIssue {
field: string;
code: RetirementPlanIssueCode;
detail: string;
}
const STAGES: readonly RetirementStage[] = ["active", "announced", "dual-run", "retired"];
export function daysBetween(from: string, to: string): number | null {
if (!isIsoDate(from) || !isIsoDate(to)) return null;
return (Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000;
}
export function validateRetirementPlan(
plan: RetirementPlan,
policy: RetirementPolicy = DEFAULT_RETIREMENT_POLICY,
): RetirementPlanIssue[] {
const issues: RetirementPlanIssue[] = [];
const add = (field: string, code: RetirementPlanIssueCode, detail: string): void => {
issues.push({ field, code, detail });
};
if (!STAGES.includes(plan.stage)) add("stage", "invalid-stage", plan.stage);
const dates = [plan.announcedAt, plan.dualRunStart, plan.retireAt];
if (dates.some((date) => !isIsoDate(date))) {
add("dates", "bad-date", dates.join(", "));
} else if (plan.announcedAt >= plan.dualRunStart || plan.dualRunStart >= plan.retireAt) {
add("dates", "date-order", "announcedAt < dualRunStart < retireAt is required");
} else {
const notice = daysBetween(plan.announcedAt, plan.dualRunStart)!;
if (notice < policy.minNoticeDays) {
add("dualRunStart", "notice-too-short", `${notice} < ${policy.minNoticeDays} days`);
}
const dual = daysBetween(plan.dualRunStart, plan.retireAt)!;
if (dual < policy.minDualRunDays) {
add("retireAt", "dual-run-too-short", `${dual} < ${policy.minDualRunDays} days`);
}
}
if ((plan.stage === "dual-run" || plan.stage === "retired") && plan.migrationTool.trim() === "") {
add("migrationTool", "missing-tool", "dual-run needs a migration tool");
}
if (plan.consumers.length === 0 && plan.stage !== "active") {
add("consumers", "no-consumers", "no consumer can be migrated");
}
const seen = new Set<string>();
for (const consumer of plan.consumers) {
if (seen.has(consumer.consumer)) {
add("consumers", "duplicate-consumer", consumer.consumer);
}
seen.add(consumer.consumer);
if (!Number.isInteger(consumer.monthlyCalls) || consumer.monthlyCalls <= 0) {
add("monthlyCalls", "invalid-calls", `${consumer.consumer}: ${consumer.monthlyCalls}`);
}
if (!(consumer.migratedRatio >= 0 && consumer.migratedRatio <= 1)) {
add("migratedRatio", "invalid-ratio", `${consumer.consumer}: ${consumer.migratedRatio}`);
}
}
return issues;
}
export interface RetirementMetrics {
totalConsumers: number;
migratedConsumers: number;
migrationRatePct: number;
residualSharePct: number;
noticeDays: number;
dualRunDays: number;
daysUntilRetire: number;
}
const round1 = (value: number): number => Math.round(value * 10) / 10;
/** Weighted by calls so a tiny but stubborn consumer cannot dominate the view. */
export function retirementMetrics(
plan: RetirementPlan,
today: string = plan.retireAt,
): RetirementMetrics {
const totalCalls = plan.consumers.reduce((sum, item) => sum + item.monthlyCalls, 0);
const residual = plan.consumers.reduce(
(sum, item) => sum + item.monthlyCalls * (1 - item.migratedRatio), 0,
);
const migrated = plan.consumers.filter((item) => item.migratedRatio >= 0.999).length;
return {
totalConsumers: plan.consumers.length,
migratedConsumers: migrated,
migrationRatePct: plan.consumers.length === 0
? 0 : round1((migrated / plan.consumers.length) * 100),
residualSharePct: totalCalls === 0 ? 0 : round1((residual / totalCalls) * 100),
noticeDays: daysBetween(plan.announcedAt, plan.dualRunStart) ?? 0,
dualRunDays: daysBetween(plan.dualRunStart, plan.retireAt) ?? 0,
daysUntilRetire: daysBetween(today, plan.retireAt) ?? 0,
};
}
export type RetirementBlocker =
| "invalid-plan"
| "already-retired"
| "notice-not-started"
| "dual-run-not-started"
| "missing-tool"
| "no-consumers"
| "not-due"
| "unmigrated-consumers"
| "residual-traffic"
| "missing-approval";
export interface ForceOffDecision {
allowed: boolean;
blockers: RetirementBlocker[];
metrics: RetirementMetrics;
}
/** Hard gate for the final switch: all four conditions must hold. */
export function evaluateForceOff(
plan: RetirementPlan,
today: string,
policy: RetirementPolicy = DEFAULT_RETIREMENT_POLICY,
): ForceOffDecision {
const blockers: RetirementBlocker[] = [];
const metrics = retirementMetrics(plan);
if (plan.migrationTool.trim() === "") blockers.push("missing-tool");
if (plan.consumers.length === 0) blockers.push("no-consumers");
if (!isIsoDate(today) || today < plan.retireAt) blockers.push("not-due");
if (metrics.migratedConsumers < metrics.totalConsumers) blockers.push("unmigrated-consumers");
if (metrics.residualSharePct / 100 > policy.residualShareLimit) blockers.push("residual-traffic");
if (plan.consumers.some((item) => !item.hasOwnerApproval)) blockers.push("missing-approval");
return { allowed: blockers.length === 0, blockers, metrics };
}
export interface RetirementDecision {
ok: boolean;
from: RetirementStage;
to: RetirementStage;
blockers: RetirementBlocker[];
issues: RetirementPlanIssue[];
metrics: RetirementMetrics;
}
/** Forward-only state machine: active -> announced -> dual-run -> retired. */
export function nextStage(
plan: RetirementPlan,
today: string,
policy: RetirementPolicy = DEFAULT_RETIREMENT_POLICY,
): RetirementDecision {
const issues = validateRetirementPlan(plan, policy);
const metrics = retirementMetrics(plan);
if (issues.length > 0) {
return {
ok: false, from: plan.stage, to: plan.stage, blockers: ["invalid-plan"], issues, metrics,
};
}
if (plan.stage === "retired") {
return {
ok: false, from: plan.stage, to: plan.stage, blockers: ["already-retired"], issues, metrics,
};
}
if (plan.stage === "active") {
const reached = isIsoDate(today) && today >= plan.announcedAt;
return {
ok: reached, from: "active", to: reached ? "announced" : "active",
blockers: reached ? [] : ["notice-not-started"], issues, metrics,
};
}
if (plan.stage === "announced") {
if (!isIsoDate(today) || today < plan.dualRunStart) {
return {
ok: false, from: "announced", to: "announced",
blockers: ["dual-run-not-started"], issues, metrics,
};
}
if (plan.migrationTool.trim() === "") {
return {
ok: false, from: "announced", to: "announced", blockers: ["missing-tool"], issues, metrics,
};
}
return { ok: true, from: "announced", to: "dual-run", blockers: [], issues, metrics };
}
const gate = evaluateForceOff(plan, today, policy);
return {
ok: gate.allowed,
from: "dual-run",
to: gate.allowed ? "retired" : "dual-run",
blockers: gate.blockers,
issues,
metrics,
};
}
代码里有三个刻意选择。第一,公告期用 dualRunStart - announcedAt 计算,而不是 retireAt - announcedAt;公告期的目的是给调用方排期,排期的终点是双跑开始,不是下线。第二,残余流量按调用量加权,避免一个小调用方拖住整体判断,也避免一个大调用方被平均掉。第三,状态推进先校验计划,计划不合法时直接返回 invalid-plan,不会在半路发现日期顺序错误。
示例样本上的运行结果
示例计划退役 content.summarize@2.3.0,替代版本为 3.1.0。五个调用方分别是内容、客服、交易、搜索与运维团队,月调用量 12 万、4 万、2 万、1 万、1 万。以下是示例样本上的运行结果。
| 场景 | 结果 |
|---|---|
| 计划校验 | 0 个问题;公告 92 天、双跑 91 天 |
| 2026-10-15 推进 | 停在 dual-run,阻塞项 not-due;5/5 已迁移,迁移率 100%,残余 0.0%,距下线 47 天 |
| 2026-12-02 推进 | 进入 retired,无阻塞项 |
| 一个调用方迁移 98.2% | 4/5 已迁移,迁移率 80%,残余 1.1%,阻塞项为未迁移、残余流量、缺负责人确认 |
| 公告期只有 31 天 | 1 个 notice-too-short;双跑仍有 91 天,距下线 108 天 |
第一行说明计划本身合法,不代表可以下线。第二行说明日期未到,状态机不会因为「看起来都迁完了」就提前关闭。第四行说明三项阻塞可以同时出现,修复顺序应该是先迁流量、再补确认,而不是先改日期。
第五行的公告期只有 31 天,低于 90 天下限。注意双跑窗口仍然是 91 天,所以只报一个 notice-too-short,不会连带报双跑太短。校验按窗口分别判断,团队能准确知道该改哪个日期。
迁移期要看的指标
退役计划不是一张甘特图,它需要一组能在周报上更新的指标。
| 指标 | 口径 | 示例值 |
|---|---|---|
| 调用方迁移率 | 已迁调用方数 ÷ 总调用方数 | 100% 或 80% |
| 残余流量占比 | 旧版本调用量 ÷ 总调用量,按调用量加权 | 0.0% 或 1.1% |
| 公告天数 | 双跑开始日 − 公告日 | 92 天 |
| 双跑天数 | 下线日 − 双跑开始日 | 91 天 |
| 距下线天数 | 下线日 − 当天 | 47 天 |
| 双跑差异率 | 新旧版本同一批样本的结果差异比例 | 示例值 0.8% |
| 回滚次数 | 双跑期内回滚到旧版本的次数 | 示例值 0 次 |
前五个指标由状态机直接计算,后两个需要双跑平台回写。差异率不是越低越好,而是要解释差异来源:契约字段变化、模型档位变化、提示版本变化,三类差异的处理方式不同。回滚次数也不是越少越好,零回滚可能意味着没人敢试。
迁移期的周报要把「指标」与「阻塞」分开。指标回答进度,阻塞回答为什么停。把两者混在一张红黄绿灯表里,团队只知道红,不知道先修什么。
小结
退役是一次跨团队迁移项目,不是目录里的一次状态修改。它需要公告期、双跑窗口、迁移工具与四项下线判据:到期、调用方迁移率、残余流量、负责人确认。
示例样本上,合法计划公告 92 天、双跑 91 天;2026-10-15 停在 dual-run,距下线 47 天;2026-12-02 满足全部判据进入 retired;一个调用方迁移 98.2% 时,迁移率 80%、残余 1.1%,三项阻塞同时出现。数字不是重点,重点是每个数字都有明确分母,且状态机只向前走。
阅读导航:上一节:2.2 提供方与调用方的契约 · 下一节:3.1 契约版本与兼容规则 。
常见错误与边界
- 把退役当成改一个状态。没有迁移计划,关闭当天就是事故。
- 公告只发群消息。没有依赖清单与负责人,漏掉的调用方不会自己出现。
- 公告期从公告日算到下线日。排期的终点是双跑开始,不是下线。
- 双跑窗口太短。没有足够样本对比,差异率没有统计意义。
- 迁移工具不幂等。重复执行会产生第二份配置或第二个工单。
- 迁移比例人工填写。没有调用量回写,比例只是一句乐观估计。
- 只看调用方数量,不看调用量。一个大调用方可以被四个小调用方平均掉。
- 残余流量按调用方平均。正确口径是按调用量加权。
- 到点就强制下线。日期只是必要条件,四项判据必须同时成立。
- 判据不满足就改日期。延期要记录原因与新的阻塞项,而不是静默推迟。
- 双跑差异率不做归因。契约、档位、提示三类差异混在一起,无法修复。
- 退役后删除回放与文档。审计与事故复盘仍然需要旧版本的证据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。