《移动游戏产品的架构·客户端与移动运行时》10.2 云存档冲突与合并

两台设备同时推进赛季,整包覆盖必然丢一边的数据。本节用字段级版本向量区分因果与并发,按 lww、max、union、sum 与服务端权威五种规则合并,明确消消弱联网与方舟强联网的不同策略、冲突提示边界,并给出可运行的 merge.ts 模型。

事故:后上传的那份赢了

赛季结算夜,《潮汐方舟》玩家在 iPhone 上打完一场异步 PvP,又在 iPad 上调整卡组。两台设备都缓存了旧快照,先后上传整包。云端按 updatedAt 取较新的一份,结果 iPad 的卡组覆盖了 iPhone 刚结算的赛季点数。玩家看到的是「我明明赢了,分没了」。

《潮汐消消》的弱联网场景更隐蔽:活动最后一天,两台设备各自领取一次登录奖励,合并时两份金币直接相加,等于重复领取。两个事故的共同点是:把「整份快照」当成一个不可分割的写单元。

版本向量:先问因果,再问时间

last-writer-wins 只在「同一字段只有一个写者」时成立。多设备并发时,墙上时间不可靠:设备时钟可能差几分钟,玩家也可能手动改时间。先用版本向量回答「谁见过谁」:

比较结果含义合并动作
equal两边看到同一版本值相同则保留,不同则按规则处理
local-after本地包含远端全部写入本地值胜出,无冲突
local-before远端包含本地全部写入远端值胜出,无冲突
concurrent两边都有对方没见过的写入进入字段规则与冲突提示

版本向量按字段维护,而不是整档一个。整档向量只能告诉你「哪份快照更新」,字段级向量才能回答「金币和卡组分别是谁改的」。

五条合并规则

规则适用字段并发时的行为风险
lww设置、布局等单写者字段时间晚者胜,时间相同用设备 id 与值稳定排序时钟回拨会选错
max只增不减的进度取较大值不能表达「重置」
union成就、战斗历史去重排序后取并集集合过大要截断
sum服务端确认过的计数增量仅同一 serverRevision 求和不能用于货币
server-authoritative等级、货币、卡组、赛季分取服务端版本较高的一侧服务端快照滞后时需等待

sum 的门槛必须写死:只有 additive: true 且两边 serverRevision 相同才求和,否则退回 lww 并标记冲突。两份离线货币直接相加,就是把作弊入口写进合并器。

冲突提示:只问玩家能理解的事

自动合并的目标是「不打断」,但有些冲突必须让玩家选:家园布局被两台设备改成不同样子,选错会直接毁掉创作。策略字段上的 prompt 决定是否弹窗;服务端权威字段不弹窗,因为客户端没有裁决权,等服务器下一次下发即可。

提示文案要给具体差异,而不是「存档冲突」。例如「iPhone 上的家园布局停留在 10 月 30 日,iPad 上有 12 件新家具」,让玩家能判断哪份是自己想要的。

双案例:消消按字段求和,方舟按服务端裁决

字段消消策略方舟策略
关卡/账号进度maxserver-authoritative
货币server-authoritativeserver-authoritative
家园/卡组lww + 提示server-authoritative
成就/战斗历史unionunion
设置lwwlww
活动计数sum,同 serverRevision不合并,服务端结算

消消弱联网、单局 90 秒,玩家断网也能推进关卡,所以需要字段级合并与提示;方舟强联网、单场 8–12 分钟,进度以服务端为准,客户端合并只负责设置与历史。

可运行模型:merge.ts

模型把版本向量、五种规则、冲突记录与两套产品策略放在一个文件里。SaveSnapshot 的 stamps 按字段保存向量与最后写入者;mergeSnapshots 先看因果,再按规则处理并发。

// file: src/save/merge.ts
// tier: L1
// verify: node --test test/save/merge.test.ts

import type { SavePayload, SaveValue } from "./store.ts";

export type MergeRule = "lww" | "max" | "union" | "sum" | "server-authoritative";
export interface FieldRule {
  readonly rule: MergeRule; readonly prompt?: boolean; readonly additive?: boolean;
}
export interface MergePolicy {
  readonly id: string; readonly defaultRule: FieldRule;
  readonly fields: Readonly<Record<string, FieldRule>>;
  readonly serverFields: readonly string[];
}
export type VectorComparison = "equal" | "local-before" | "local-after" | "concurrent";
export interface FieldStamp {
  readonly vector: Readonly<Record<string, number>>;
  readonly atMs: number; readonly deviceId: string;
}
export interface SaveSnapshot {
  readonly fields: SavePayload;
  readonly stamps: Readonly<Record<string, FieldStamp>>;
  readonly savedAtMs: number; readonly revision: number;
  readonly serverRevision: number; readonly deviceId: string;
}
export interface FieldConflict {
  readonly field: string; readonly localValue: SaveValue; readonly remoteValue: SaveValue;
  readonly winner: "local" | "remote"; readonly rule: MergeRule;
  readonly prompt: boolean; readonly reason: string;
}
export interface MergeResult {
  readonly policyId: string; readonly fields: SavePayload;
  readonly stamps: Readonly<Record<string, FieldStamp>>;
  readonly conflicts: readonly FieldConflict[]; readonly promptRequired: boolean;
  readonly revision: number; readonly serverRevision: number;
}

function same(left: SaveValue, right: SaveValue): boolean {
  return JSON.stringify(left) === JSON.stringify(right);
}

export function compareVectors(
  local: Readonly<Record<string, number>>,
  remote: Readonly<Record<string, number>>,
): VectorComparison {
  const devices = new Set([...Object.keys(local), ...Object.keys(remote)]);
  let localAhead = false;
  let remoteAhead = false;
  for (const device of devices) {
    const left = local[device] ?? 0;
    const right = remote[device] ?? 0;
    if (left > right) localAhead = true;
    if (right > left) remoteAhead = true;
  }
  if (localAhead && remoteAhead) return "concurrent";
  return localAhead ? "local-after" : remoteAhead ? "local-before" : "equal";
}

function maxVector(
  left: Readonly<Record<string, number>>,
  right: Readonly<Record<string, number>>,
): Readonly<Record<string, number>> {
  const merged: Record<string, number> = { ...left };
  for (const [device, counter] of Object.entries(right)) {
    merged[device] = Math.max(merged[device] ?? 0, counter);
  }
  return merged;
}

function pickLater(
  local: FieldStamp, remote: FieldStamp, localValue: SaveValue, remoteValue: SaveValue,
): "local" | "remote" {
  if (local.atMs !== remote.atMs) return local.atMs > remote.atMs ? "local" : "remote";
  if (local.deviceId !== remote.deviceId) {
    return local.deviceId.localeCompare(remote.deviceId) >= 0 ? "local" : "remote";
  }
  return JSON.stringify(localValue).localeCompare(JSON.stringify(remoteValue)) >= 0
    ? "local"
    : "remote";
}

function resolveValue(
  local: SaveValue,
  remote: SaveValue,
  localStamp: FieldStamp,
  remoteStamp: FieldStamp,
  localServerRevision: number,
  remoteServerRevision: number,
  rule: FieldRule,
): { winner: "local" | "remote"; value: SaveValue; reason: string } {
  const winner = pickLater(localStamp, remoteStamp, local, remote);
  if (rule.rule === "lww") {
    return { winner, value: winner === "local" ? local : remote, reason: "lww" };
  }
  if (rule.rule === "max" && typeof local === "number" && typeof remote === "number") {
    return local >= remote
      ? { winner: "local", value: local, reason: `max ${local}>=${remote}` }
      : { winner: "remote", value: remote, reason: `max ${remote}>${local}` };
  }
  if (rule.rule === "union" && Array.isArray(local) && Array.isArray(remote)) {
    const merged = [...new Set([...local, ...remote])].sort();
    return { winner: "local", value: merged, reason: `union ${merged.length} entries` };
  }
  if (rule.rule === "sum" && rule.additive === true
    && typeof local === "number" && typeof remote === "number"
    && localServerRevision === remoteServerRevision) {
    return { winner: "local", value: local + remote, reason: `sum on serverRevision ${localServerRevision}` };
  }
  if (rule.rule === "server-authoritative") {
    if (localServerRevision !== remoteServerRevision) {
      return localServerRevision > remoteServerRevision
        ? { winner: "local", value: local, reason: `serverRevision ${localServerRevision}` }
        : { winner: "remote", value: remote, reason: `serverRevision ${remoteServerRevision}` };
    }
    return { winner: "local", value: local, reason: "same serverRevision; hold local" };
  }
  return {
    winner,
    value: winner === "local" ? local : remote,
    reason: `rule ${rule.rule} not applicable; fell back to lww`,
  };
}

export function mergeSnapshots(
  local: SaveSnapshot,
  remote: SaveSnapshot,
  policy: MergePolicy,
): MergeResult {
  const fields: Record<string, SaveValue> = {};
  const stamps: Record<string, FieldStamp> = {};
  const conflicts: FieldConflict[] = [];
  const names = new Set([
    ...Object.keys(local.fields), ...Object.keys(remote.fields),
    ...Object.keys(local.stamps), ...Object.keys(remote.stamps),
  ]);

  for (const field of [...names].sort()) {
    const localStamp = local.stamps[field];
    const remoteStamp = remote.stamps[field];
    if (localStamp === undefined && remoteStamp === undefined) continue;
    if (localStamp === undefined) {
      fields[field] = remote.fields[field];
      stamps[field] = remoteStamp as FieldStamp;
      continue;
    }
    if (remoteStamp === undefined) {
      fields[field] = local.fields[field];
      stamps[field] = localStamp;
      continue;
    }

    const localValue = local.fields[field];
    const remoteValue = remote.fields[field];
    const comparison = compareVectors(localStamp.vector, remoteStamp.vector);
    let winner: "local" | "remote";
    let mergedValue: SaveValue | null = null;
    let reason: string;
    if (comparison === "local-after") {
      winner = "local";
      reason = "local vector dominates";
    } else if (comparison === "local-before") {
      winner = "remote";
      reason = "remote vector dominates";
    } else {
      const rule = policy.fields[field] ?? policy.defaultRule;
      const resolved = resolveValue(localValue, remoteValue, localStamp, remoteStamp,
        local.serverRevision, remote.serverRevision, rule);
      winner = resolved.winner;
      mergedValue = resolved.value;
      reason = resolved.reason;
      if (!same(localValue, remoteValue)) {
        conflicts.push({
          field, localValue, remoteValue, winner,
          rule: rule.rule, prompt: rule.prompt === true, reason,
        });
      }
    }

    fields[field] = mergedValue ?? (winner === "local" ? localValue : remoteValue);
    stamps[field] = {
      vector: maxVector(localStamp.vector, remoteStamp.vector),
      atMs: Math.max(localStamp.atMs, remoteStamp.atMs),
      deviceId: winner === "local" ? localStamp.deviceId : remoteStamp.deviceId,
    };
  }

  return {
    policyId: policy.id, fields, stamps, conflicts,
    promptRequired: conflicts.some((conflict) => conflict.prompt),
    revision: Math.max(local.revision, remote.revision) + 1,
    serverRevision: Math.max(local.serverRevision, remote.serverRevision),
  };
}

export const MATCH3_MERGE_POLICY: MergePolicy = {
  id: "match3-weak-network",
  defaultRule: { rule: "lww", prompt: true },
  fields: {
    levelProgress: { rule: "max" },
    coins: { rule: "server-authoritative", prompt: true },
    homeLayout: { rule: "lww", prompt: true },
    achievements: { rule: "union" },
    settings: { rule: "lww" },
    adViews: { rule: "sum", additive: true, prompt: true },
  },
  serverFields: ["coins"],
};

export const ARK_MERGE_POLICY: MergePolicy = {
  id: "ark-strong-network",
  defaultRule: { rule: "server-authoritative" },
  fields: {
    accountLevel: { rule: "server-authoritative" },
    currencies: { rule: "server-authoritative" },
    deck: { rule: "server-authoritative" },
    seasonPoints: { rule: "server-authoritative" },
    settings: { rule: "lww" },
    battleHistory: { rule: "union" },
  },
  serverFields: ["accountLevel", "currencies", "deck", "seasonPoints"],
};

测试 test/save/merge.test.ts 覆盖 11 条路径:字段级合并、向量支配压过时钟、四种向量关系、lww 平局确定性、方舟服务端权威、同 revision 求和、非 additive 回退、未知字段默认规则、同值无冲突、同向量异值冲突、并集去重。命令:node --test test/save/merge.test.ts。

模型不判断服务端快照是否最新,也不替代服务端结算;serverRevision 是输入,不是信任来源。

与相邻章节的关系

10.1 定义单份存档的结构,本节定义两份存档如何变成一份;10.3 处理合并后的完整性与回滚。离线队列与重试可以参考冲突同步队列 ,云存档接入可以参考云存档 。

小结

  • 整包覆盖在多设备下必然丢数据;写单元要细到字段。
  • 先用版本向量判断因果,再对真正并发的字段应用规则。
  • lww 只适合单写者字段,时钟不可信;货币与进度不能让客户端裁决。
  • sum 必须绑定同一 serverRevision,否则就是重复领取入口。
  • 冲突提示只问玩家能理解、能决定的事;服务端权威字段等服务器。

阅读导航:上一节:10.1 存档格式与迁移 · 下一节:10.3 防篡改与回滚 。

常见错误与边界

  • 用 updatedAt 比较整份存档。两台设备的时钟差几分钟就会选错;先比向量,时间只做并发平局。
  • 把货币放进 sum。两份离线货币相加等于凭空造钱;货币只能服务端权威。
  • 版本向量只维护整档。整档向量无法回答字段归属,最后还是会退化成整包覆盖。
  • 所有冲突都弹窗。玩家会被无意义提示打断;只有用户可见、不可自动裁决的字段才提示。
  • 合并后不提升 revision。下一次上传会被服务端当成旧版本拒绝,或者再次触发同一冲突。
  • 边界:本节不定义服务端结算与反作弊。serverRevision 由服务端提供,客户端只消费;签名与时间校验见 10.3。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查