《移动游戏产品的架构·网络、资源与热更新》4.1 离线可做与不可做:队列边界

弱联网产品最容易犯的错,是把「能点」当成「能离线」。本节用消消的关卡、收集与商店操作划出队列边界:哪些可以排队、哪些只能在线、哪些排队后必须刷新,并给出类型、年龄与预算三重准入判据和一个可运行的分类模型,边界外的操作会带着明确原因被拒绝,让离线体验可控。

场景:地铁里通关之后,商店按钮该不该亮

《潮汐消消》的玩家在早高峰地铁里打完一局。单局 90 秒,一局里有 14–22 次提交,每次 600B–1.2KB;隧道里信号断了,客户端没有立刻弹错误,而是把最后一次提交放进本地队列,界面照常结算,玩家看到「本局 +3 星」。

接着玩家点开商店,想用刚拿到的奖励买一个皮肤。这个按钮如果也照常亮着,问题就来了:支付与发货必须走服务端,离线时既不能扣款,也不能承诺发货。队列边界要回答的不是「网络库能不能缓存请求」,而是「哪些操作允许玩家在无网时继续,哪些必须当场拦住」。

消消的弱网会话占比 22%,断线率 2.1%。这两个数字决定了离线路径不是边缘功能,而是日常体验的一部分;但休闲产品的收入结构是广告 55% 加内购 45%,也决定了涉及钱和随机性的操作绝不能靠客户端「先记下来」。

四类操作:本地、可排队、排队后刷新、只能在线

把所有操作摊开,会发现它们并不是「在线」与「离线」两档,而是四档。分档的依据有三条:操作是否可重放、是否触碰服务端独占资源、失败后玩家能否感知。

档位判据消消中的例子离线时的界面行为
本地操作只影响本机表现,不产生服务端事实音量、画质、语言偏好立即生效,不排队
可排队结果幂等,服务端可按 id 合并开局、收集星星乐观显示,进队列
排队后刷新可提交,但会改变服务端独占的计数关卡完成、好友助力乐观显示,并标记「待刷新」
只能在线涉及支付、概率、排行结算或库存购买、抽卡、排行领奖置灰或提示联网,不排队

「可排队」与「排队后刷新」的差别不在传输,而在展示承诺。关卡完成后,本地可以立刻把星星加到界面上,但排行榜名次必须等服务端合并后才能刷新;如果客户端把「待刷新」也画成最终结果,玩家会误以为名次已经变化。排队后刷新是一种诚实的乐观:先给进度,晚点给排名。

「只能在线」也不是永远置灰。更好的做法是让按钮可点,但点击后明确提示「需要联网完成」,并把玩家意图留在界面上,等恢复网络后引导重试。这样既不让玩家白点,也不假装操作已经成功。

三条准入判据:类型、年龄、预算

一个操作进入队列前要过三道门:类型是否允许排队、等待时间是否还在窗口内、负载是否在容量预算内。任何一道不满足,都应该被拒绝,而不是静默丢弃。

判据检查内容拒绝码玩家可见后果
类型操作种类是否登记、是否只能在线unknown-kind / online-only提示该操作需要联网
年龄创建时间到当前时间是否超过类型上限stale / expired提示进度过旧,需要重新进入
预算单条负载与队列总字节是否超限payload-too-large / budget-exceeded先同步已有队列,再重试
容量队列条目数是否达到上限queue-full暂停继续积累,提示整理

年龄判据最容易被忽略。消消的会话时长中位是 6.5 分钟,但离线队列服务的是「离开又回来」的玩家,不是单次会话;一条关卡完成记录如果放了两天,服务端可能已经通过其他设备或活动补发了奖励,此时再合并会制造重复。类型上限与策略上限取更小者:限时助力只有很短的窗口,普通收集可以放得更久。

预算判据同样不是形式主义。低端机占 35%,内存水位 520MB,队列如果按「每次操作一份完整状态」存,几十条就能吃掉可观的常驻内存。正确做法是只存操作与必要的负载,不存整份存档;整份状态由快照负责,队列只负责「还没告诉服务端的那几步」。

队列不是无限口袋:容量与字节预算

容量要同时限制条目数与总字节,只限其中一个都会留下缺口。条目上限防止「很多小操作」把队列撑爆,字节上限防止「少数大负载」把内存吃光。两者都触发时,先拒绝新操作,而不是淘汰旧操作:旧操作代表玩家已经完成的进度,淘汰它等于让玩家白玩。

策略只限条目数只限字节数同时限制
很多小操作队列无限长,内存缓慢上涨能兜住条目先触发
少数大负载能兜住单条就可能超限字节先触发
混合负载两种风险都在两种风险都在按先到者拒绝

拒绝之后还要给玩家一个可恢复的动作。队列满时,界面不应只说「失败」,而应提示「正在同步 N 条记录」,并在恢复网络后优先清空队列。如果玩家在队列满时继续玩,客户端可以进入「只读离线」模式:允许查看,不允许产生新的可排队操作。这比悄悄丢掉操作更可预期。

可运行模型:offline-policy.ts

下面的 offline-policy.ts 把操作分类、三重准入、容量与预算放进同一个文件。它不访问网络,也不读写存储;时间由调用方注入,因此每次判断都是确定的。

// file: src/net/offline-policy.ts
// tier: L1
// verify: node --test test/net/offline-policy.test.ts

export type OfflineVerdict = "queue" | "queue-refresh" | "online";

export interface OperationKindSpec {
  readonly verdict: OfflineVerdict;
  readonly maxAgeMs: number;
  readonly reason: string;
}

const HOUR = 3_600_000;

export const OPERATION_KINDS: Readonly<Record<string, OperationKindSpec>> = {
  "level-start": { verdict: "queue", maxAgeMs: 12 * HOUR, reason: "本地开局,服务端只记进度" },
  "level-complete": { verdict: "queue-refresh", maxAgeMs: 24 * HOUR, reason: "进度可合并,排行榜需要重拉" },
  "star-collect": { verdict: "queue", maxAgeMs: 24 * HOUR, reason: "收集结果幂等,可合并" },
  "friend-help": { verdict: "queue-refresh", maxAgeMs: 6 * HOUR, reason: "助力次数有限,提交后需刷新余额" },
  "shop-purchase": { verdict: "online", maxAgeMs: 0, reason: "支付与发货必须在线" },
  "gacha-pull": { verdict: "online", maxAgeMs: 0, reason: "概率与库存由服务端裁决" },
  "rank-claim": { verdict: "online", maxAgeMs: 0, reason: "排行榜奖励按服务端结算" },
};

export interface QueueRequest {
  readonly opId: string;
  readonly kind: string;
  readonly createdAt: number;
  readonly expiresAt?: number;
  readonly payloadBytes: number;
}

export interface QueuePolicy {
  readonly maxOps: number;
  readonly maxAgeMs: number;
  readonly maxPayloadBytes: number;
  readonly totalBytes: number;
}

export const DEFAULT_QUEUE_POLICY: QueuePolicy = {
  maxOps: 32,
  maxAgeMs: 24 * HOUR,
  maxPayloadBytes: 4 * 1024,
  totalBytes: 64 * 1024,
};

export type RejectCode =
  | "unknown-kind"
  | "online-only"
  | "expired"
  | "stale"
  | "payload-too-large"
  | "budget-exceeded"
  | "queue-full";

export interface AcceptedRequest extends QueueRequest {
  readonly verdict: OfflineVerdict;
}

export interface RejectedRequest {
  readonly opId: string;
  readonly code: RejectCode;
  readonly detail: string;
}

export interface QueuePlan {
  readonly accepted: readonly AcceptedRequest[];
  readonly rejected: readonly RejectedRequest[];
  readonly queuedBytes: number;
  readonly oldestAgeMs: number;
}

export function classifyOperation(kind: string): OperationKindSpec | null {
  return Object.hasOwn(OPERATION_KINDS, kind) ? OPERATION_KINDS[kind] : null;
}

export function planOfflineQueue(
  requests: readonly QueueRequest[],
  now: number,
  policy: QueuePolicy = DEFAULT_QUEUE_POLICY,
): QueuePlan {
  const accepted: AcceptedRequest[] = [];
  const rejected: RejectedRequest[] = [];
  let queuedBytes = 0;
  let oldestAgeMs = 0;

  for (const request of requests) {
    const spec = classifyOperation(request.kind);
    if (spec === null) {
      rejected.push({ opId: request.opId, code: "unknown-kind", detail: `未登记的离线操作类型:${request.kind}` });
      continue;
    }
    if (spec.verdict === "online") {
      rejected.push({ opId: request.opId, code: "online-only", detail: spec.reason });
      continue;
    }
    if (request.expiresAt !== undefined && request.expiresAt <= now) {
      rejected.push({ opId: request.opId, code: "expired", detail: `过期时间 ${request.expiresAt} 已到` });
      continue;
    }
    const age = Math.max(0, now - request.createdAt);
    const maxAge = Math.min(policy.maxAgeMs, spec.maxAgeMs);
    if (age > maxAge) {
      rejected.push({ opId: request.opId, code: "stale", detail: `已等待 ${age}ms,超过上限 ${maxAge}ms` });
      continue;
    }
    if (!Number.isInteger(request.payloadBytes) || request.payloadBytes <= 0 || request.payloadBytes > policy.maxPayloadBytes) {
      rejected.push({ opId: request.opId, code: "payload-too-large", detail: `负载 ${request.payloadBytes}B 不在 1..${policy.maxPayloadBytes}B` });
      continue;
    }
    if (accepted.length >= policy.maxOps) {
      rejected.push({ opId: request.opId, code: "queue-full", detail: `队列已有 ${accepted.length} 条` });
      continue;
    }
    if (queuedBytes + request.payloadBytes > policy.totalBytes) {
      rejected.push({ opId: request.opId, code: "budget-exceeded", detail: `再写入将超过 ${policy.totalBytes}B` });
      continue;
    }
    accepted.push({ ...request, verdict: spec.verdict });
    queuedBytes += request.payloadBytes;
    oldestAgeMs = Math.max(oldestAgeMs, age);
  }

  return { accepted, rejected, queuedBytes, oldestAgeMs };
}

export function describeQueuePlan(plan: QueuePlan): string {
  const refresh = plan.accepted.filter((item) => item.verdict === "queue-refresh").length;
  const tags = new Map<RejectCode, number>();
  for (const item of plan.rejected) {
    tags.set(item.code, (tags.get(item.code) ?? 0) + 1);
  }
  const reasons = [...tags.entries()]
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([code, count]) => `${code}×${count}`)
    .join("、");
  const ageMinutes = Math.round(plan.oldestAgeMs / 60_000);
  const rejectPart = plan.rejected.length === 0 ? "无拒绝" : `拒绝 ${plan.rejected.length} 条(${reasons})`;
  return `采纳 ${plan.accepted.length} 条(需刷新 ${refresh} 条),${rejectPart};占用 ${plan.queuedBytes}B,最老 ${ageMinutes} 分钟`;
}

模型导出五个入口:操作类型表、默认策略、单条分类、整批规划与一行摘要。planOfflineQueue 按输入顺序处理,先判类型,再判过期与年龄,最后判负载、容量与预算;每条拒绝都带拒绝码与原因,调用方可以直接映射成界面文案。配套测试覆盖九条路径:已知与未知类型、普通入队、三类在线操作、过期、年龄上限、负载与预算、队列满,以及摘要输出。

这里的判断是模型,不是线上数据;真实项目应把操作频率、单条负载与低端机内存代进参数,判据本身不变。

双案例:消消的关卡与方舟的赛季

消消的离线边界围绕「单局产物」:开局、通关、收集星星可以排队,购买、抽卡、排行领奖只能在线。原因是单局短、提交次数有限,排队成本低;而广告与内购各占一半收入,任何涉及支付与概率的操作都不能由客户端先承诺。

方舟的边界更窄。它的战斗是强联网,弱网会话占比 14%,单场 8–12 分钟,重连后需要补状态的比例达到 31%。因此方舟的离线队列主要服务战斗之外的赛季配置、外观与文本,不服务战斗输入;战斗内断线走重连与状态校准,而不是把输入排进队列。

两个案例的共同点是「队列只装可重放的事实」,不同点是可重放的范围:消消可以把一整局的结果排队,方舟只能把战斗外的低风险变更排队。边界由产品形态决定,不由网络库能力决定。

与相邻章节的关系

3.3 的重连后状态校准回答「回来之后怎么对齐」,本节回答「离开期间哪些事可以做」。4.2 会把本节接受的条目变成可幂等重发的队列,处理重复提交与确认丢失;4.3 则处理这些条目回到服务端后与现有状态的冲突。跨设备的存档合并可以参考云存档 ,本节只讨论准入,不讨论合并策略。

小结

  • 离线操作分四档:本地、可排队、排队后刷新、只能在线;分档依据是可重放性、服务端独占资源与失败代价。
  • 准入要同时检查类型、年龄、负载、容量与总预算;任何一项不满足都应带原因拒绝,而不是静默丢弃。
  • 「排队后刷新」是一种诚实的乐观:先显示进度,明确标记排名等服务端结果。
  • 容量要同时限制条目数与字节数;满了先拒绝新操作,不淘汰玩家已完成的旧操作。
  • 消消可以把单局产物排队,方舟只把战斗外的低风险变更排队;边界由产品形态决定。

阅读导航:上一节:3.3 重连后的状态校准 · 下一节:4.2 操作队列的幂等与去重 。

常见错误与边界

  1. 把「请求能缓存」当成「操作能离线」。缓存解决传输,队列解决业务承诺;支付请求即使缓存了,也不能在离线时告诉玩家已发货。
  2. 只按操作名分类,不看上下文。同一个「领取」可能是每日签到,也可能是排行榜结算;前者可排队,后者必须在线。
  3. 用同一个年龄上限覆盖所有操作。限时助力与普通收集的窗口不同,应取类型上限与策略上限的较小者。
  4. 只限条目数或只限字节数。很多小操作会撑长队列,少数大负载会撑爆内存;两个维度都要限制。
  5. 队列满时淘汰最旧的操作。最旧的操作往往代表玩家已完成的进度,淘汰它等于让玩家白玩;应拒绝新操作并引导同步。
  6. 把「待刷新」画成最终结果。排行榜名次、好友助力余额都要等服务端确认,提前显示会制造错误预期。
  7. 离线时把只能在线操作静默置灰。玩家不知道原因;应允许点击并说明需要联网,恢复后引导重试。
  8. 忽略过期操作的服务端补发。离线记录放太久,服务端可能已通过其他路径发奖;合并前必须做幂等与状态校验。
  9. 把整份存档塞进每条队列记录。队列只应保存操作与必要负载,整份状态交给快照,否则低端机内存会被队列拖垮。
  10. 边界:本节不判断服务端是否接受某条操作。准入只回答「客户端能不能先记下来」,最终是否生效由 4.2 的幂等与 4.3 的冲突裁决决定。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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