一次「双写成功」后的静默缺口
某客户端把用户偏好从旧配置服务迁到新配置服务。上线时采用双写:先写旧服务,再写新服务。请求成功率没有变化,发布看起来顺利完成。
三周后,新路径开始承接读流量,客服发现一部分用户的偏好设置「回到默认值」。排查结果是:双写期间新服务偶发超时,代码捕获异常后只打了日志,没有补写队列;用户请求因为旧服务成功而正常返回,于是没人察觉新服务缺了一批记录。兼容窗口按日历到期后,旧路径被关掉,缺口才暴露。
这个事故里没有一处「写错」。问题在于双写被当成了一个布尔开关:写了两次,但没有定义失败语义、比对口径和窗口退出条件。
顺序、比对、窗口是三件事
双写双读经常被压成一句「两边都写、两边都读」,落地时至少有三个独立决定:
| 维度 | 关键选择 | 函数 | 失败表现 |
|---|---|---|---|
| 写入顺序 | 当前真相源作为主写 | primaryTarget | 主写失败却继续写次写 |
| 次写失败 | 降级返回并登记补写 | runDualWrite | 用户成功,新路径缺数据 |
| 双读比对 | 区分单边失败与值不一致 | compareReads | 把读失败当成一致 |
| 差异率 | 不一致数除以总样本 | diffReport | 分母只算成功样本 |
| 兼容窗口 | 起止时间加显式关闭 | windowState | 日历到期就删旧路径 |
| 到期处理 | 差异率与静默期都满足 | decideExpiry | 带着差异下线 |
写入顺序取决于当前哪一边是真相源。切换前旧服务是真相源,应该 legacy-first:旧写失败就直接失败,新写失败则降级并补写。切换后新服务成为真相源,顺序反过来。顺序不是团队偏好,而是数据责任的映射。
双读比对要区分三种「不一致」:两边都读失败是 both-missing;只有一边失败是 legacy-only 或 new-only;两边都成功但值不同才是 mismatch。把读失败直接算成差异,会让差异率被基础设施抖动污染;把读失败算成一致,则会掩盖真实缺口。
兼容窗口不是「计划里写了三个月」。它是一个有起点、终点和显式关闭动作的对象。窗口内允许双写;窗口结束后是否删除旧路径,要看差异率与最近一次不一致的时间,而不是看日历。
把顺序、比对与窗口写成代码
下面的模块把双写执行、双读判定、差异统计、窗口状态与到期决策分开实现。写入函数由调用方注入,模块本身不依赖具体存储,因此可以用内存桩完整测试失败路径。
// file: src/migration/dual-write.ts
// tier: L1
// verify: node --test test/migration/dual-write.test.ts
export type WriteTarget = "legacy" | "new";
export type WriteOrder = "legacy-first" | "new-first";
export type WriteAttempt = {
target: WriteTarget;
ok: boolean;
error?: string;
};
export type DualWriteStatus = "consistent" | "degraded" | "primary-failed";
export type DualWriteReport = {
status: DualWriteStatus;
order: WriteOrder;
primary: WriteTarget;
attempts: WriteAttempt[];
failed: WriteTarget[];
userVisibleFailure: boolean;
repairNeeded: boolean;
};
export type ReadValue = string | null;
export type ReadOutcome = {
ok: boolean;
value: ReadValue;
};
export type ReadVerdict =
| "match"
| "mismatch"
| "legacy-only"
| "new-only"
| "both-missing";
export type DiffSample = {
key: string;
legacy: ReadValue;
next: ReadValue;
};
export type DiffReport = {
total: number;
matched: number;
mismatched: number;
diffRate: number;
mismatchKeys: string[];
};
export type CompatibilityWindow = {
startsAt: number;
endsAt: number;
closedAt: number | null;
};
export type WindowState = "not-started" | "open" | "expired" | "closed";
export type ExpiryAction = "none" | "keep-open" | "extend" | "remove-legacy";
export type ExpiryReason =
| "closed"
| "not-expired"
| "above-threshold"
| "unstable"
| "ready";
export type ExpiryDecision = {
action: ExpiryAction;
reason: ExpiryReason;
};
function roundRate(value: number): number {
return Math.round(value * 100) / 100;
}
export function primaryTarget(order: WriteOrder): WriteTarget {
return order === "legacy-first" ? "legacy" : "new";
}
export function secondaryTarget(order: WriteOrder): WriteTarget {
return order === "legacy-first" ? "new" : "legacy";
}
export function runDualWrite(
order: WriteOrder,
write: (target: WriteTarget) => { ok: boolean; error?: string },
): DualWriteReport {
const primary = primaryTarget(order);
const secondary = secondaryTarget(order);
const attempts: WriteAttempt[] = [];
const first = write(primary);
attempts.push({ target: primary, ok: first.ok, error: first.error });
if (!first.ok) {
return {
status: "primary-failed",
order,
primary,
attempts,
failed: [primary],
userVisibleFailure: true,
repairNeeded: true,
};
}
const second = write(secondary);
attempts.push({ target: secondary, ok: second.ok, error: second.error });
if (!second.ok) {
return {
status: "degraded",
order,
primary,
attempts,
failed: [secondary],
userVisibleFailure: false,
repairNeeded: true,
};
}
return {
status: "consistent",
order,
primary,
attempts,
failed: [],
userVisibleFailure: false,
repairNeeded: false,
};
}
export function compareReads(
legacy: ReadOutcome,
next: ReadOutcome,
): ReadVerdict {
if (!legacy.ok && !next.ok) return "both-missing";
if (!legacy.ok) return "new-only";
if (!next.ok) return "legacy-only";
return legacy.value === next.value ? "match" : "mismatch";
}
export function diffReport(samples: DiffSample[]): DiffReport {
const mismatchKeys: string[] = [];
for (const sample of samples) {
if (sample.legacy !== sample.next) mismatchKeys.push(sample.key);
}
const total = samples.length;
const mismatched = mismatchKeys.length;
return {
total,
matched: total - mismatched,
mismatched,
diffRate: total === 0 ? 0 : roundRate((mismatched * 100) / total),
mismatchKeys,
};
}
export function consistencyRate(report: DiffReport): number {
if (report.total === 0) return 0;
return roundRate((report.matched * 100) / report.total);
}
export function windowState(
window: CompatibilityWindow,
now: number,
): WindowState {
if (window.closedAt !== null) return "closed";
if (now < window.startsAt) return "not-started";
if (now <= window.endsAt) return "open";
return "expired";
}
export function writesAllowed(
window: CompatibilityWindow,
now: number,
): boolean {
return windowState(window, now) === "open";
}
export function closeWindow(
window: CompatibilityWindow,
now: number,
): CompatibilityWindow {
if (window.closedAt !== null) return { ...window };
return { ...window, closedAt: now };
}
export function extendWindow(
window: CompatibilityWindow,
extraMs: number,
): CompatibilityWindow {
if (!Number.isFinite(extraMs) || extraMs <= 0) return { ...window };
return { ...window, endsAt: window.endsAt + extraMs };
}
export function decideExpiry(input: {
window: CompatibilityWindow;
now: number;
diffRate: number;
maxDiffRate?: number;
lastMismatchAt?: number | null;
quietMs?: number;
}): ExpiryDecision {
const state = windowState(input.window, input.now);
if (state === "closed") return { action: "none", reason: "closed" };
if (state !== "expired") {
return { action: "keep-open", reason: "not-expired" };
}
const maxDiffRate = input.maxDiffRate ?? 0.1;
if (input.diffRate > maxDiffRate) {
return { action: "extend", reason: "above-threshold" };
}
const quietMs = input.quietMs ?? 24 * 60 * 60 * 1000;
const lastMismatchAt = input.lastMismatchAt ?? null;
if (
lastMismatchAt !== null &&
input.now - lastMismatchAt < quietMs
) {
return { action: "extend", reason: "unstable" };
}
return { action: "remove-legacy", reason: "ready" };
}
判据与反例
判断双写是否真的可退出,先看七条可复现的判据:
legacy-first的调用顺序必须是先旧后新;主写失败时不得继续写次写。- 次写失败返回
degraded,用户请求不失败,但必须标记repairNeeded。 - 两边都是
null视为一致;一边null、一边有值是mismatch。 - 空样本的差异率为 0,一致率也为 0;不能把「没有证据」当成 100% 一致。
- 窗口开始与结束时间都包含在开启区间内,超过结束时间才算过期。
- 已关闭窗口不再允许写入,到期决策返回
none。 - 到期后差异率超阈值,或最近仍有不一致,都应延长窗口;两者都满足才允许移除旧路径。
对应的反例:
次写失败让用户重试。 用户看到失败后重新提交,主写会执行两次,产生重复记录。次写失败的正确处理是降级返回加异步补写,而不是把内部一致性问题转嫁给用户。
只统计成功读取的样本。 读失败被排除后,差异率的分母变小,指标看起来更漂亮,但缺口仍在。差异率必须按「计划比对的样本总数」计算。
把窗口到期当成自动删除。 到期只是允许做决策,不是决策本身。decideExpiry 要求差异率低于阈值,并且距离最近一次不一致超过静默期。
关闭窗口后继续双写。 writesAllowed 只认开启状态;任何绕过它的写入口都会让「窗口」失去意义。关闭动作应该同时撤掉双写开关,而不是只改一个展示字段。
小结
双写的难点不在写两次,而在定义失败与退出。
- 主写跟随真相源:切换前旧路径优先,切换后新路径优先;主写失败就是用户可见失败。
- 次写失败是降级,不是成功:请求可以返回,但补写与告警必须留下。
- 双读要区分单边失败与值不一致:前者是可用性问题,后者才是数据问题。
- 兼容窗口是有起止和关闭动作的对象;到期后仍要用差异率与静默期决定是否删除旧路径。
当差异率、窗口状态和补写记录都能被同一套函数解释,双写才从「先写着」变成可退出的迁移阶段。
阅读导航:上一节:12.1 绞杀者与渐进式替换 · 下一节:12.3 迁移的退出条件与回滚 。
常见错误与边界
- 主写与次写都用同一个超时。 次写通常可以更短并异步重试;与主写共用长超时会拖慢用户请求,也掩盖次写失败。
- 把
degraded当成功。 它表示用户请求成功但数据不完整,必须进入补写队列并计入迁移看板。 - 差异率分母口径不一致。 报表按成功读取算、代码按全部样本算,两边永远对不上;以
diffReport.total为唯一口径。 - 窗口结束时间用自然日。 代码接收毫秒时间戳,展示层可以按自然日解释,但边界判断必须用同一时钟。
- 关闭窗口后立刻删除旧数据。 关闭只停止双写,删除旧路径还要经过 12.3 的退出判据与回滚检查。
- 忽略单边读取失败。
legacy-only与new-only不计入mismatch,但必须单独监控;它们往往先于真实差异出现。 - 补写没有幂等键。 重试补写可能重复插入;补写记录应带业务主键与版本号,按最后写入版本收敛。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。