场景:同一账号,两台设备,两个进度
《潮汐消消》的玩家白天在平板上过了几关,晚上在手机上继续。手机当时在弱网里,关卡完成记录先落在本地队列;平板那边已经联网,服务端把进度推到了更前面。等手机恢复网络,两条时间线要合成一条:关卡等级、最高分、星星、货币、昵称与语言,每一项的合并规则都不一样。
如果所有字段都用「客户端最后写入获胜」,手机会把平板已经推进的进度覆盖回去;如果所有字段都用「服务端获胜」,玩家在手机上离线打出的星星会白打。冲突解决的第一步不是选一个赢家,而是承认不同字段有不同的所有权。
消消的弱网会话占比 22%,断线率 2.1%,重连后需要补状态的比例 8%;方舟分别是 14%、1.4% 与 31%。后者的补状态比例高,不是因为网络更差,而是因为战斗状态更重、可合并的余地更小。冲突策略必须跟产品形态对齐,而不是照搬一套通用规则。
四种字段策略
把存档字段按「谁能写、怎么写」分成四类,比逐字段写 if-else 更可维护。策略不是技术偏好,而是业务所有权的声明。
| 策略 | 适用字段 | 合并规则 | 典型失败模式 |
|---|---|---|---|
| 服务端权威 | 货币、订单、库存 | 离线值只展示,不写回 | 玩家以为离线赚到的货币已到账 |
| 取最大值 | 等级、最高分、星星、教程进度 | 取服务端与客户端的较大值 | 进度回退被误判为作弊 |
| 时间戳后写 | 昵称、签名、外观选择 | 比较更新时间,较晚者胜 | 设备时钟不准导致旧值覆盖新值 |
| 客户端偏好 | 语言、画质、音量 | 直接接受客户端值 | 多设备之间互相覆盖,但可接受 |
消消的货币属于服务端权威:广告 55% 加内购 45% 的收入结构决定了任何货币变动都必须走服务端账本,离线时只能显示「待确认」,不能把本地数字当成余额。等级与最高分适合取最大值:它们是单调的,回退没有业务意义。昵称适合时间戳后写,因为玩家可能在不同设备上改名;语言适合客户端偏好,因为它是本机体验,不需要跨设备一致。
「取最大值」看着简单,仍要处理边界:值必须是有限数值,且落在允许区间内。一个负数星星或超出上限的等级,不应该被静默夹取,而应该被拒绝并记录,否则会掩盖客户端状态损坏。
冲突分类:覆盖、合并、拒绝与暂缓
同一个字段的两次写入相遇时,结果不只有「谁赢」。把结果分成覆盖、合并、拒绝与暂缓四类,才能让日志和界面说清楚发生了什么。
| 结果 | 含义 | 例子 | 界面处理 |
|---|---|---|---|
| 合并 | 两个值可以合成一个更大的值 | 等级取较大者 | 直接显示合并结果 |
| 覆盖 | 一方按规则替换另一方 | 较新的昵称覆盖较旧昵称 | 显示新值,不提示冲突 |
| 拒绝 | 值不合法或字段无所有权 | 负数星星、未知字段 | 保留服务端值,记录原因 |
| 暂缓 | 客户端无权写,等待服务端确认 | 离线货币变动 | 显示「待确认」,不写成余额 |
「暂缓」是最容易被漏掉的一类。很多实现把服务端权威字段直接丢弃,玩家看到货币没变,却不知道为什么。更好的做法是保留一条待确认记录:界面显示「+N 待确认」,服务端确认后再转正,拒绝时给出原因。这样玩家不会误以为操作失败,也不会误以为已经到账。
拒绝也要区分「值不合法」与「无权写」。前者是客户端 bug 或状态损坏,应该上报;后者是正常业务规则,不应该当成错误刷日志。两种都返回拒绝,但监控口径不同。
可见性:乐观展示与确认后转正
离线队列回到服务端需要时间。玩家在这段时间里看到的应该是「乐观结果 + 明确的待确认标记」,而不是空白或旧值。可见性层要回答三个问题:哪些字段可以先显示、哪些必须等服务端、待确认状态怎么表达。
| 字段类型 | 本地能否先显示 | 待确认标记 | 服务端拒绝后 |
|---|---|---|---|
| 取最大值 | 可以,显示较大值 | 无或弱提示 | 回退到服务端值并说明 |
| 客户端偏好 | 可以,立即生效 | 无 | 一般不会拒绝 |
| 时间戳后写 | 可以,显示本地新值 | 弱提示 | 回退并说明时间冲突 |
| 服务端权威 | 不写成余额,只显示待确认 | 强提示 | 移除待确认并说明原因 |
乐观展示的边界是「不能把未确认的事实写成已确认」。等级可以先显示 12,因为即使服务端拒绝,回退到 10 也只是进度差异;货币不能先显示 900,因为玩家可能立刻拿它去消费,而服务端账本里只有 300。两者的差别不是数值大小,而是「玩家是否会基于这个数字做不可逆决策」。
可见性还要考虑多设备。玩家在手机上看到「+3 星待确认」,在平板上看到的可能已经是服务端合并后的最终值。两台设备不需要实时一致,但同一台设备上的「待确认」不能永远不转正;需要一个明确的超时或失败路径,把长期未确认的记录清掉。
可见性延迟怎么估
可见性延迟由三段时间组成:上行提交的往返、服务端批量处理的窗口、确认下发的往返。RTT P95 是已知输入:消消 310ms,方舟 240ms;批量窗口与批大小是策略参数,取决于服务端一次处理多少条。
| 组成 | 决定因素 | 消消 | 方舟 |
|---|---|---|---|
| 上行往返 | RTT P95 | 310ms | 240ms |
| 批量窗口 | 服务端批处理间隔 | 策略参数 | 策略参数 |
| 确认往返 | RTT P95 | 310ms | 240ms |
| 批次数 | 待确认条数 ÷ 批大小 | 由队列决定 | 由队列决定 |
估算的意义不是给出一个精确毫秒数,而是判断「玩家要等多久才能看到转正」。如果待确认条目多到需要多个批次,延迟会按批次数叠加;此时应该优先合并同一字段的多次改动,只提交最终值,而不是把每条中间状态都发上去。合并发生在客户端,成本远低于让服务端处理一长串中间态。
延迟也应该反馈到界面。等待时间在预期内时,显示「同步中」;超过预期时,显示「稍后自动重试」并提供手动重试。不要用一个永远转圈的加载态掩盖队列没有推进的事实。
可运行模型:conflict.ts
下面的 conflict.ts 把字段策略、冲突分类、乐观可见层与延迟估算放进同一个文件。它不访问网络,也不持久化;服务端快照与离线改动都由调用方注入。
// file: src/net/conflict.ts
// tier: L1
// verify: node --test test/net/conflict.test.ts
export type FieldPolicy = "max" | "server-authoritative" | "client-authoritative" | "lww";
export interface FieldRule {
readonly policy: FieldPolicy;
readonly min?: number;
readonly max?: number;
}
export const PROFILE_RULES: Readonly<Record<string, FieldRule>> = {
level: { policy: "max", min: 1, max: 999 },
bestScore: { policy: "max", min: 0 },
stars: { policy: "max", min: 0 },
tutorialStep: { policy: "max", min: 0 },
coins: { policy: "server-authoritative", min: 0 },
nickname: { policy: "lww" },
language: { policy: "client-authoritative" },
};
export interface ServerProfile {
readonly fields: Readonly<Record<string, unknown>>;
readonly updatedAt: Readonly<Record<string, number>>;
}
export interface OfflineChange {
readonly field: string;
readonly value: unknown;
readonly at: number;
}
export type Decision = "kept-server" | "took-client" | "merged-max" | "rejected";
export interface FieldResolution {
readonly field: string;
readonly decision: Decision;
readonly serverValue: unknown;
readonly clientValue: unknown;
readonly mergedValue: unknown;
readonly reason: string;
}
export interface Resolution {
readonly fields: readonly FieldResolution[];
readonly conflicts: number;
readonly applied: number;
readonly rejected: number;
}
function inRange(value: number, rule: FieldRule): boolean {
if (rule.min !== undefined && value < rule.min) return false;
if (rule.max !== undefined && value > rule.max) return false;
return true;
}
export function resolveOfflineChanges(
server: ServerProfile,
changes: readonly OfflineChange[],
): Resolution {
const working = new Map<string, unknown>(Object.entries(server.fields));
const updatedAt = new Map<string, number>(Object.entries(server.updatedAt));
const byField = new Map<string, FieldResolution>();
let conflicts = 0;
let applied = 0;
let rejected = 0;
const ordered = [...changes].sort((a, b) => a.at - b.at);
for (const change of ordered) {
const rule = PROFILE_RULES[change.field];
const serverValue = working.get(change.field);
if (rule === undefined) {
rejected += 1;
byField.set(change.field, {
field: change.field,
decision: "rejected",
serverValue,
clientValue: change.value,
mergedValue: serverValue,
reason: "未登记的字段,按未知字段拒绝",
});
continue;
}
if (rule.policy === "server-authoritative") {
const same = Object.is(serverValue, change.value);
if (!same) conflicts += 1;
byField.set(change.field, {
field: change.field,
decision: "kept-server",
serverValue,
clientValue: change.value,
mergedValue: serverValue,
reason: "服务端账本裁决,离线值只用于本地展示",
});
continue;
}
if (rule.policy === "client-authoritative") {
working.set(change.field, change.value);
updatedAt.set(change.field, change.at);
applied += 1;
byField.set(change.field, {
field: change.field,
decision: "took-client",
serverValue,
clientValue: change.value,
mergedValue: change.value,
reason: "客户端偏好字段,服务端接受覆盖",
});
continue;
}
if (rule.policy === "lww") {
const serverAt = updatedAt.get(change.field) ?? Number.NEGATIVE_INFINITY;
if (change.at > serverAt) {
working.set(change.field, change.value);
updatedAt.set(change.field, change.at);
applied += 1;
byField.set(change.field, {
field: change.field,
decision: "took-client",
serverValue,
clientValue: change.value,
mergedValue: change.value,
reason: `客户端时间 ${change.at} 晚于服务端 ${serverAt}`,
});
} else {
if (!Object.is(serverValue, change.value)) conflicts += 1;
byField.set(change.field, {
field: change.field,
decision: "kept-server",
serverValue,
clientValue: change.value,
mergedValue: serverValue,
reason: `客户端时间 ${change.at} 不晚于服务端 ${serverAt},保留服务端`,
});
}
continue;
}
if (typeof change.value !== "number" || !Number.isFinite(change.value)) {
rejected += 1;
byField.set(change.field, {
field: change.field,
decision: "rejected",
serverValue,
clientValue: change.value,
mergedValue: serverValue,
reason: "max 字段只接受有限数值",
});
continue;
}
if (!inRange(change.value, rule)) {
rejected += 1;
byField.set(change.field, {
field: change.field,
decision: "rejected",
serverValue,
clientValue: change.value,
mergedValue: serverValue,
reason: `数值 ${change.value} 超出允许区间`,
});
continue;
}
const base = typeof serverValue === "number" && Number.isFinite(serverValue) ? serverValue : rule.min ?? 0;
if (change.value > base) {
working.set(change.field, change.value);
applied += 1;
byField.set(change.field, {
field: change.field,
decision: "merged-max",
serverValue,
clientValue: change.value,
mergedValue: change.value,
reason: `取较大值 ${change.value} 覆盖 ${base}`,
});
} else {
if (!Object.is(serverValue, change.value)) conflicts += 1;
byField.set(change.field, {
field: change.field,
decision: "kept-server",
serverValue,
clientValue: change.value,
mergedValue: serverValue,
reason: `服务端值 ${base} 不小于客户端 ${change.value}`,
});
}
}
return { fields: [...byField.values()], conflicts, applied, rejected };
}
export interface VisibleProjection {
readonly fields: Readonly<Record<string, unknown>>;
readonly pendingFields: readonly string[];
readonly heldFields: readonly string[];
}
export function projectVisible(
server: ServerProfile,
pending: readonly OfflineChange[],
): VisibleProjection {
const fields: Record<string, unknown> = { ...server.fields };
const pendingFields: string[] = [];
const heldFields: string[] = [];
const seen = new Set<string>();
for (const change of pending) {
const rule = PROFILE_RULES[change.field];
if (rule === undefined) continue;
if (!seen.has(change.field)) {
seen.add(change.field);
pendingFields.push(change.field);
}
if (rule.policy === "server-authoritative") {
heldFields.push(change.field);
continue;
}
fields[change.field] = change.value;
}
return { fields, pendingFields, heldFields };
}
export interface VisibilityConfig {
readonly rttP95Ms: number;
readonly batchWindowMs: number;
readonly batchSize: number;
}
export function estimateVisibilityDelayMs(
pendingCount: number,
config: VisibilityConfig,
): number {
if (!Number.isInteger(pendingCount) || pendingCount < 0) {
throw new RangeError(`pendingCount must be a non-negative integer: ${pendingCount}`);
}
if (pendingCount === 0) return 0;
const batchSize = Math.max(1, config.batchSize);
const batches = Math.ceil(pendingCount / batchSize);
return config.rttP95Ms * (batches + 1) + config.batchWindowMs * batches;
}
export function describeResolution(resolution: Resolution): string {
const kept = resolution.fields.filter((field) => field.decision === "kept-server").length;
const merged = resolution.fields.filter((field) => field.decision === "merged-max").length;
const took = resolution.fields.filter((field) => field.decision === "took-client").length;
return `应用 ${resolution.applied} 项(取大 ${merged}、客户端覆盖 ${took}),保留服务端 ${kept} 项,拒绝 ${resolution.rejected} 项,冲突 ${resolution.conflicts} 项`;
}
模型导出五个入口:字段规则表、冲突裁决、可见投影、延迟估算与摘要。裁决按改动时间排序,逐字段应用策略;服务端权威字段只保留服务端值,取最大值字段校验有限性与区间,时间戳后写字段在时间相同时保留服务端,未知字段直接拒绝。配套测试覆盖十条路径:取大合并、服务端更高、货币拒绝、时间戳胜负与平局、客户端偏好、非法数值、未知字段、乐观投影、延迟估算与摘要。
模型里的时间都是调用方给的逻辑时间,不依赖本机时钟;生产环境仍要处理设备时钟偏差,常见做法是以服务端时间为准,客户端时间只用于同设备内的排序。
双案例对照:消消偏展示,方舟偏权威
消消的冲突面更宽:弱网会话占比 22%,断线率 2.1%,玩家经常在离线状态推进关卡。它的策略偏向「能合并就合并」:等级、最高分、星星取最大值,昵称按时间戳后写,货币服务端权威但保留待确认展示。这样玩家离线打出的进度不会白费,涉及钱的字段又不会被客户端改写。
方舟的冲突面更窄但更硬:弱网会话占比 14%,断线率 1.4%,重连后需要补状态的比例 31%。战斗状态以服务端快照为准,客户端不做字段级合并;离线队列只承载战斗外的赛季配置、外观与文本。它的策略偏向「服务端权威 + 少量客户端偏好」,因为一场 8–12 分钟的战斗里,任何客户端侧的状态覆盖都可能破坏可复盘性。
两者的共同点是都把「字段所有权」写进规则表,而不是散落在业务代码里;不同点是所有权划分的宽窄。消消可以让更多字段参与合并,方舟必须把战斗相关字段全部收归服务端。
与相邻章节的关系
4.1 划出哪些操作可以离线,4.2 保证每条操作只应用一次,本节处理「多条已应用的操作改到同一个字段」时怎么合并。3.3 的重连状态校准提供合并前的服务端基线;5.1 会进一步讨论强联网下客户端与服务端的同步责任划分。跨设备存档的合并可以参考云存档 ,本节只讨论字段级裁决与可见性,不讨论存储与传输。
小结
- 冲突解决先分字段所有权:服务端权威、取最大值、时间戳后写、客户端偏好;不要用一个策略覆盖所有字段。
- 冲突结果分四类:合并、覆盖、拒绝、暂缓;「暂缓」要保留待确认记录,而不是静默丢弃。
- 乐观展示的边界是「不能把未确认的事实写成已确认」;进度可以先显示,货币不能先当余额花。
- 可见性延迟由上行往返、批量窗口与确认往返组成;待确认条目多时,先在客户端合并同字段改动。
- 消消偏「能合并就合并」,方舟偏「服务端权威」;宽窄由产品形态与可复盘性要求决定。
阅读导航:上一节:4.2 操作队列的幂等与去重 · 下一节:5.1 状态同步与帧同步:客户端各做什么 。
常见错误与边界
- 所有字段都用「最后写入获胜」。手机会把平板已经推进的等级覆盖回去;不同字段必须先分所有权。
- 所有字段都用「服务端获胜」。玩家离线打出的星星会白费;单调字段应该取最大值。
- 把服务端权威字段静默丢弃。玩家不知道货币为什么没变;应保留待确认记录并说明原因。
- 把待确认的货币写成余额。玩家可能立刻消费,而服务端账本里并没有这笔钱;未确认事实不能当已确认用。
- 时间戳后写直接信客户端时钟。设备时钟偏差会让旧值覆盖新值;应以服务端时间为准,客户端时间只做同设备排序。
- 取最大值不做有限性与区间校验。负数或超限值会被静默接受,掩盖客户端状态损坏;应拒绝并上报。
- 平局时随机选赢家。同一时间戳的两次写入必须有确定规则;默认保留服务端,避免结果不可复现。
- 待确认记录没有超时或失败路径。界面会永远显示「同步中」;要有明确的转正、回退与重试出口。
- 多设备之间追求实时一致。离线设备不可能实时同步;应保证单设备内的待确认最终收敛,而不是强求跨设备同时刻一致。
- 边界:本节不处理服务端账本与反作弊。字段策略只决定客户端提交的值怎么合并;货币是否合法、战斗结果是否可信,仍由服务端校验与风控决定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。