《互联网产品的客户端架构·规模化与架构演进》12.2 双写双读与兼容窗口

双写不是把同一份数据写两遍就结束,真正决定成败的是写入顺序、次写失败后的降级、双读比对口径,以及兼容窗口何时关闭。本节把顺序、差异率、窗口状态与到期决策写成纯函数,用可复现的样本回答两个问题:数据到底差多少,旧路径什么时候可以删,以及删除之前还需要哪些证据。

一次「双写成功」后的静默缺口

某客户端把用户偏好从旧配置服务迁到新配置服务。上线时采用双写:先写旧服务,再写新服务。请求成功率没有变化,发布看起来顺利完成。

三周后,新路径开始承接读流量,客服发现一部分用户的偏好设置「回到默认值」。排查结果是:双写期间新服务偶发超时,代码捕获异常后只打了日志,没有补写队列;用户请求因为旧服务成功而正常返回,于是没人察觉新服务缺了一批记录。兼容窗口按日历到期后,旧路径被关掉,缺口才暴露。

这个事故里没有一处「写错」。问题在于双写被当成了一个布尔开关:写了两次,但没有定义失败语义、比对口径和窗口退出条件。

顺序、比对、窗口是三件事

双写双读经常被压成一句「两边都写、两边都读」,落地时至少有三个独立决定:

维度关键选择函数失败表现
写入顺序当前真相源作为主写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" };
}

判据与反例

判断双写是否真的可退出,先看七条可复现的判据:

  1. legacy-first 的调用顺序必须是先旧后新;主写失败时不得继续写次写。
  2. 次写失败返回 degraded,用户请求不失败,但必须标记 repairNeeded。
  3. 两边都是 null 视为一致;一边 null、一边有值是 mismatch。
  4. 空样本的差异率为 0,一致率也为 0;不能把「没有证据」当成 100% 一致。
  5. 窗口开始与结束时间都包含在开启区间内,超过结束时间才算过期。
  6. 已关闭窗口不再允许写入,到期决策返回 none。
  7. 到期后差异率超阈值,或最近仍有不一致,都应延长窗口;两者都满足才允许移除旧路径。

对应的反例:

次写失败让用户重试。 用户看到失败后重新提交,主写会执行两次,产生重复记录。次写失败的正确处理是降级返回加异步补写,而不是把内部一致性问题转嫁给用户。

只统计成功读取的样本。 读失败被排除后,差异率的分母变小,指标看起来更漂亮,但缺口仍在。差异率必须按「计划比对的样本总数」计算。

把窗口到期当成自动删除。 到期只是允许做决策,不是决策本身。decideExpiry 要求差异率低于阈值,并且距离最近一次不一致超过静默期。

关闭窗口后继续双写。 writesAllowed 只认开启状态;任何绕过它的写入口都会让「窗口」失去意义。关闭动作应该同时撤掉双写开关,而不是只改一个展示字段。

小结

双写的难点不在写两次,而在定义失败与退出。

  • 主写跟随真相源:切换前旧路径优先,切换后新路径优先;主写失败就是用户可见失败。
  • 次写失败是降级,不是成功:请求可以返回,但补写与告警必须留下。
  • 双读要区分单边失败与值不一致:前者是可用性问题,后者才是数据问题。
  • 兼容窗口是有起止和关闭动作的对象;到期后仍要用差异率与静默期决定是否删除旧路径。

当差异率、窗口状态和补写记录都能被同一套函数解释,双写才从「先写着」变成可退出的迁移阶段。

阅读导航:上一节:12.1 绞杀者与渐进式替换 · 下一节:12.3 迁移的退出条件与回滚 。

常见错误与边界

  1. 主写与次写都用同一个超时。 次写通常可以更短并异步重试;与主写共用长超时会拖慢用户请求,也掩盖次写失败。
  2. 把 degraded 当成功。 它表示用户请求成功但数据不完整,必须进入补写队列并计入迁移看板。
  3. 差异率分母口径不一致。 报表按成功读取算、代码按全部样本算,两边永远对不上;以 diffReport.total 为唯一口径。
  4. 窗口结束时间用自然日。 代码接收毫秒时间戳,展示层可以按自然日解释,但边界判断必须用同一时钟。
  5. 关闭窗口后立刻删除旧数据。 关闭只停止双写,删除旧路径还要经过 12.3 的退出判据与回滚检查。
  6. 忽略单边读取失败。 legacy-only 与 new-only 不计入 mismatch,但必须单独监控;它们往往先于真实差异出现。
  7. 补写没有幂等键。 重试补写可能重复插入;补写记录应带业务主键与版本号,按最后写入版本收敛。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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