事故:后上传的那份赢了
赛季结算夜,《潮汐方舟》玩家在 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 件新家具」,让玩家能判断哪份是自己想要的。
双案例:消消按字段求和,方舟按服务端裁决
| 字段 | 消消策略 | 方舟策略 |
|---|---|---|
| 关卡/账号进度 | max | server-authoritative |
| 货币 | server-authoritative | server-authoritative |
| 家园/卡组 | lww + 提示 | server-authoritative |
| 成就/战斗历史 | union | union |
| 设置 | lww | lww |
| 活动计数 | 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。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。