场景:地铁里通关之后,商店按钮该不该亮
《潮汐消消》的玩家在早高峰地铁里打完一局。单局 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 操作队列的幂等与去重 。
常见错误与边界
- 把「请求能缓存」当成「操作能离线」。缓存解决传输,队列解决业务承诺;支付请求即使缓存了,也不能在离线时告诉玩家已发货。
- 只按操作名分类,不看上下文。同一个「领取」可能是每日签到,也可能是排行榜结算;前者可排队,后者必须在线。
- 用同一个年龄上限覆盖所有操作。限时助力与普通收集的窗口不同,应取类型上限与策略上限的较小者。
- 只限条目数或只限字节数。很多小操作会撑长队列,少数大负载会撑爆内存;两个维度都要限制。
- 队列满时淘汰最旧的操作。最旧的操作往往代表玩家已完成的进度,淘汰它等于让玩家白玩;应拒绝新操作并引导同步。
- 把「待刷新」画成最终结果。排行榜名次、好友助力余额都要等服务端确认,提前显示会制造错误预期。
- 离线时把只能在线操作静默置灰。玩家不知道原因;应允许点击并说明需要联网,恢复后引导重试。
- 忽略过期操作的服务端补发。离线记录放太久,服务端可能已通过其他路径发奖;合并前必须做幂等与状态校验。
- 把整份存档塞进每条队列记录。队列只应保存操作与必要负载,整份状态交给快照,否则低端机内存会被队列拖垮。
- 边界:本节不判断服务端是否接受某条操作。准入只回答「客户端能不能先记下来」,最终是否生效由 4.2 的幂等与 4.3 的冲突裁决决定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。