一次「指标全绿」却删不掉的旧接口
某团队完成客户端配置迁移,新路径承接了 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,
},
};
}
判据与反例
判断退出流程是否可靠,先看八条可复现的判据:
- 五项指标恰好等于阈值时,判据必须通过。
- 样本数低于下限时,即使五项全过也不能退出。
- 多个判据失败时,
blockers按固定顺序列出,便于看板对齐。 - 回滚资产缺失、未验证、超过有效期或责任人为空,任一情况都让检查不就绪。
- 验证时间恰好等于有效期边界时仍然有效;超过一天才算过期。
- 退出判据或回滚未就绪时,宣布动作必须拒绝。
- 宣布是幂等的:重复宣布不覆盖首次时间。
- 宽限期内、判据回退、回滚资产过期,任一情况都拒绝删除;删除是幂等的。
对应的反例:
只看流量,不看残余旧流量。 新路径 99.9% 可能意味着旧路径还有 0.1%,对一个大产品来说仍是可观的调用量。legacy-traffic 必须单独设上限。
观察期按自然日算。 代码里用毫秒差值与阈值比较;展示层可以写「七天」,但跨时区、跨夏令时的时候,自然日会让边界漂移。
回滚资产「存在」就签字。 制品存在但从未部署验证、手册存在但责任人已转岗,都不是可回滚证据。检查要覆盖新鲜度与归属。
宣布后立即删除。 宣布是通知,宽限期是让外部系统反应的时间。跳过宽限期等于把内部迁移节奏强加给所有调用方。
删除后才发现需要回滚。 旧路径删除后,回滚资产只能恢复数据,恢复不了已经下线的客户端兼容性。删除前的复核必须同时看判据与回滚,而不是只看其中一个。
小结
退出的本质是放弃回滚能力,所以它需要证据,而不是信心。
- 五条判据分开看:新流量、旧残余、错误、一致性、观察期;等于阈值算通过。
- 样本不足是前置否决项,不能用漂亮的比例绕过。
- 回滚资产要检查缺失、过期与归属;存在不等于就绪。
- 宣布与删除分离:宣布后进入宽限期,删除前重新复核判据与回滚。
- 删除是幂等的;重复调用不改变首次删除时间与最终状态。
当退出判据、回滚检查和状态机都能被测试复现,删除旧路径才是一次可审计的决策,而不是一次勇敢的尝试。
阅读导航:上一节:12.2 双写双读与兼容窗口 · 下一节:13.1 端到端:多端同一笔交易的体验一致性 。
常见错误与边界
- 把
legacy-traffic与new-traffic当同一个指标。 两者之和不一定等于 100%,采样、缓存与灰度规则都会造成偏差;必须分别设阈值。 - 用总请求数代替样本数。 退出判据的样本应该是「参与迁移比对的样本」,而不是全站 QPS 累计。
- 回滚资产没有有效期。 制品、配置、快照都会腐化;
maxAgeMs是必填的治理参数,不是可选优化。 - 责任人写成团队名。
owner为空或只有团队名都无法在凌晨三点找到人;检查只能识别空字符串,流程要要求具体值班人。 - 宣布后停止监控。 宽限期内指标仍可能回退;删除前的复核就是最后一道监控闸门。
- 删除动作没有审计字段。
removedAt必须保留;它回答「什么时候失去回滚能力」,是事故复盘的关键时间点。 - 把宽限期当成固定常量。 不同渠道发版节奏不同,宽限期应按最慢调用方设定,并在宣布时记录依据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。