《移动游戏产品的架构·网络、资源与热更新》4.3 冲突解决与可见性延迟

离线改动回到服务端时,冲突不是「谁覆盖谁」一个问题。本节把等级、最高分、货币、昵称与语言拆成四种合并策略,比较服务端权威、取最大值、时间戳后写与客户端偏好,并给出乐观可见层、暂缓字段与可见性延迟的估算模型,用消消与方舟两种强度做对照,最后收口到可执行的规则表。

场景:同一账号,两台设备,两个进度

《潮汐消消》的玩家白天在平板上过了几关,晚上在手机上继续。手机当时在弱网里,关卡完成记录先落在本地队列;平板那边已经联网,服务端把进度推到了更前面。等手机恢复网络,两条时间线要合成一条:关卡等级、最高分、星星、货币、昵称与语言,每一项的合并规则都不一样。

如果所有字段都用「客户端最后写入获胜」,手机会把平板已经推进的进度覆盖回去;如果所有字段都用「服务端获胜」,玩家在手机上离线打出的星星会白打。冲突解决的第一步不是选一个赢家,而是承认不同字段有不同的所有权。

消消的弱网会话占比 22%,断线率 2.1%,重连后需要补状态的比例 8%;方舟分别是 14%、1.4% 与 31%。后者的补状态比例高,不是因为网络更差,而是因为战斗状态更重、可合并的余地更小。冲突策略必须跟产品形态对齐,而不是照搬一套通用规则。

四种字段策略

把存档字段按「谁能写、怎么写」分成四类,比逐字段写 if-else 更可维护。策略不是技术偏好,而是业务所有权的声明。

策略适用字段合并规则典型失败模式
服务端权威货币、订单、库存离线值只展示,不写回玩家以为离线赚到的货币已到账
取最大值等级、最高分、星星、教程进度取服务端与客户端的较大值进度回退被误判为作弊
时间戳后写昵称、签名、外观选择比较更新时间,较晚者胜设备时钟不准导致旧值覆盖新值
客户端偏好语言、画质、音量直接接受客户端值多设备之间互相覆盖,但可接受

消消的货币属于服务端权威:广告 55% 加内购 45% 的收入结构决定了任何货币变动都必须走服务端账本,离线时只能显示「待确认」,不能把本地数字当成余额。等级与最高分适合取最大值:它们是单调的,回退没有业务意义。昵称适合时间戳后写,因为玩家可能在不同设备上改名;语言适合客户端偏好,因为它是本机体验,不需要跨设备一致。

「取最大值」看着简单,仍要处理边界:值必须是有限数值,且落在允许区间内。一个负数星星或超出上限的等级,不应该被静默夹取,而应该被拒绝并记录,否则会掩盖客户端状态损坏。

冲突分类:覆盖、合并、拒绝与暂缓

同一个字段的两次写入相遇时,结果不只有「谁赢」。把结果分成覆盖、合并、拒绝与暂缓四类,才能让日志和界面说清楚发生了什么。

结果含义例子界面处理
合并两个值可以合成一个更大的值等级取较大者直接显示合并结果
覆盖一方按规则替换另一方较新的昵称覆盖较旧昵称显示新值,不提示冲突
拒绝值不合法或字段无所有权负数星星、未知字段保留服务端值,记录原因
暂缓客户端无权写,等待服务端确认离线货币变动显示「待确认」,不写成余额

「暂缓」是最容易被漏掉的一类。很多实现把服务端权威字段直接丢弃,玩家看到货币没变,却不知道为什么。更好的做法是保留一条待确认记录:界面显示「+N 待确认」,服务端确认后再转正,拒绝时给出原因。这样玩家不会误以为操作失败,也不会误以为已经到账。

拒绝也要区分「值不合法」与「无权写」。前者是客户端 bug 或状态损坏,应该上报;后者是正常业务规则,不应该当成错误刷日志。两种都返回拒绝,但监控口径不同。

可见性:乐观展示与确认后转正

离线队列回到服务端需要时间。玩家在这段时间里看到的应该是「乐观结果 + 明确的待确认标记」,而不是空白或旧值。可见性层要回答三个问题:哪些字段可以先显示、哪些必须等服务端、待确认状态怎么表达。

字段类型本地能否先显示待确认标记服务端拒绝后
取最大值可以,显示较大值无或弱提示回退到服务端值并说明
客户端偏好可以,立即生效无一般不会拒绝
时间戳后写可以,显示本地新值弱提示回退并说明时间冲突
服务端权威不写成余额,只显示待确认强提示移除待确认并说明原因

乐观展示的边界是「不能把未确认的事实写成已确认」。等级可以先显示 12,因为即使服务端拒绝,回退到 10 也只是进度差异;货币不能先显示 900,因为玩家可能立刻拿它去消费,而服务端账本里只有 300。两者的差别不是数值大小,而是「玩家是否会基于这个数字做不可逆决策」。

可见性还要考虑多设备。玩家在手机上看到「+3 星待确认」,在平板上看到的可能已经是服务端合并后的最终值。两台设备不需要实时一致,但同一台设备上的「待确认」不能永远不转正;需要一个明确的超时或失败路径,把长期未确认的记录清掉。

可见性延迟怎么估

可见性延迟由三段时间组成:上行提交的往返、服务端批量处理的窗口、确认下发的往返。RTT P95 是已知输入:消消 310ms,方舟 240ms;批量窗口与批大小是策略参数,取决于服务端一次处理多少条。

组成决定因素消消方舟
上行往返RTT P95310ms240ms
批量窗口服务端批处理间隔策略参数策略参数
确认往返RTT P95310ms240ms
批次数待确认条数 ÷ 批大小由队列决定由队列决定

估算的意义不是给出一个精确毫秒数,而是判断「玩家要等多久才能看到转正」。如果待确认条目多到需要多个批次,延迟会按批次数叠加;此时应该优先合并同一字段的多次改动,只提交最终值,而不是把每条中间状态都发上去。合并发生在客户端,成本远低于让服务端处理一长串中间态。

延迟也应该反馈到界面。等待时间在预期内时,显示「同步中」;超过预期时,显示「稍后自动重试」并提供手动重试。不要用一个永远转圈的加载态掩盖队列没有推进的事实。

可运行模型: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 状态同步与帧同步:客户端各做什么 。

常见错误与边界

  1. 所有字段都用「最后写入获胜」。手机会把平板已经推进的等级覆盖回去;不同字段必须先分所有权。
  2. 所有字段都用「服务端获胜」。玩家离线打出的星星会白费;单调字段应该取最大值。
  3. 把服务端权威字段静默丢弃。玩家不知道货币为什么没变;应保留待确认记录并说明原因。
  4. 把待确认的货币写成余额。玩家可能立刻消费,而服务端账本里并没有这笔钱;未确认事实不能当已确认用。
  5. 时间戳后写直接信客户端时钟。设备时钟偏差会让旧值覆盖新值;应以服务端时间为准,客户端时间只做同设备排序。
  6. 取最大值不做有限性与区间校验。负数或超限值会被静默接受,掩盖客户端状态损坏;应拒绝并上报。
  7. 平局时随机选赢家。同一时间戳的两次写入必须有确定规则;默认保留服务端,避免结果不可复现。
  8. 待确认记录没有超时或失败路径。界面会永远显示「同步中」;要有明确的转正、回退与重试出口。
  9. 多设备之间追求实时一致。离线设备不可能实时同步;应保证单设备内的待确认最终收敛,而不是强求跨设备同时刻一致。
  10. 边界:本节不处理服务端账本与反作弊。字段策略只决定客户端提交的值怎么合并;货币是否合法、战斗结果是否可信,仍由服务端校验与风控决定。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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