同一条 300ms 延迟,两种处理
同一条 300ms 的延迟,放在消消和方舟里,处理方式完全不同。
消消是回合制:玩家点一下,客户端提交 600B–1.2KB 的回合数据,单局 14–22 次提交,等待服务端确认后进入下一回合。300ms 只是让动画多转一小会儿,客户端完全可以先把表现放完,再处理确认。
方舟是强联网:服务端 10Hz 下发快照,每条 350B;客户端 20Hz 上行输入,每条 48B。300ms 的延迟意味着客户端看到的画面落后服务端若干拍,输入到达服务端时战局已经变化。客户端不能「先表现再说」,必须做预测、校正与补偿。
如果只按「延迟高低」来区分两种形态,就会得出一个错误结论:延迟低的更强、延迟高的更弱。真正的区别不在延迟,而在谁对状态负责、断线后怎么补、哪些操作可以离线排队。
责任边界是四张表
把两种形态拆开,至少要看四张表:
| 维度 | 弱联网(消消) | 强联网(方舟) |
|---|---|---|
| 状态权威 | 经济、进度、结算归服务端;表现归客户端 | 战斗结果、命中、伤害归服务端;预测与表现归客户端 |
| 心跳 | 25 秒,全场景 | 战斗内 5 秒,大厅 20 秒 |
| 断线补偿 | 重放未确认提交;8% 的重连需要服务端下发增量 | 服务端下发增量或完整状态;31% 的重连需要补状态 |
| 离线队列 | 允许,提交按业务序号排队 | 不用于战斗输入;只允许非战斗类操作 |
四张表里最容易做错的是第一张。很多团队把「弱联网」理解成「服务端可以少管一点」,于是把结算放到客户端,最后用日志事后对账。这不是弱联网,这是没有权威。弱联网的正确含义是:实时性可以降,权威不能降。
第二张表决定电量与断线发现速度。消消 25 秒心跳覆盖全场景,是因为它的操作间隔本来就长;方舟战斗内 5 秒心跳,是因为 8–12 分钟一场的战斗里,超过几秒没有服务端消息,客户端就无法判断是卡顿还是断线。
第三张表决定重连后的体验。消消只有 8% 的重连需要补状态,方舟是 31%;这个差异不是实现好坏,而是玩法决定的。回合制天然可以把「补状态」简化为「重放最后一次提交」,强联网则必须维护一份可增量下发的事实状态。
第四张表决定离线边界。消消允许离线排队,是因为提交有明确业务序号,重放不会改变结果;方舟的战斗输入不能排队,因为输入与当时的战局绑定,迟到就没有意义。
把边界写成可审计的声明
边界写在文档里很容易变成口号。更可靠的做法,是把它写成一份声明,再用审计函数检查声明有没有违反形态约束。
// file: src/net/online-mode.ts
// tier: L1
// verify: node --test test/net/online-mode.test.ts
export type OnlineMode = "weak" | "strong";
export type StateOwner = "client" | "server";
export type Compensation = "none" | "replay" | "delta" | "full";
export interface ModeDeclaration {
readonly mode: OnlineMode;
readonly stateOwner: StateOwner;
readonly heartbeatCombatSeconds: number;
readonly heartbeatLobbySeconds: number;
readonly snapshotHz: number;
readonly inputHz: number;
readonly compensation: Compensation;
readonly offlineQueue: boolean;
}
export interface AuditResult {
readonly valid: boolean;
readonly violations: readonly string[];
}
export interface ResolvedMode {
readonly mode: OnlineMode;
readonly heartbeatCombatSeconds: number;
readonly heartbeatLobbySeconds: number;
readonly snapshotHz: number;
readonly inputHz: number;
readonly compensation: Compensation;
readonly offlineQueue: boolean;
}
export interface Responsibility {
readonly feature: string;
readonly owner: StateOwner;
readonly reason: string;
}
export const MODE_BASELINES = {
weak: {
heartbeatCombatSeconds: 25,
heartbeatLobbySeconds: 25,
snapshotHz: 0,
inputHz: 0,
compensation: "replay",
offlineQueue: true,
},
strong: {
heartbeatCombatSeconds: 5,
heartbeatLobbySeconds: 20,
snapshotHz: 10,
inputHz: 20,
compensation: "delta",
offlineQueue: false,
},
} as const;
export const RESPONSIBILITY_MATRIX: readonly Responsibility[] = [
{ feature: "economy", owner: "server", reason: "货币与付费不能由客户端裁决" },
{ feature: "combat-outcome", owner: "server", reason: "命中、伤害与结算必须单点权威" },
{ feature: "progress", owner: "server", reason: "进度与奖励需要可回滚的审计链" },
{ feature: "input", owner: "client", reason: "输入采样在本地完成,服务端只校验结果" },
{ feature: "settings", owner: "client", reason: "画质与音量允许本地先行、异步同步" },
{ feature: "presentation", owner: "client", reason: "表现层可按设备能力降级" },
];
export function resolveResponsibility(mode: OnlineMode): ResolvedMode {
return { mode, ...MODE_BASELINES[mode] };
}
export function auditDeclaration(decl: ModeDeclaration): AuditResult {
const violations: string[] = [];
const heartbeatFields: Array<[string, number]> = [
["heartbeatCombatSeconds", decl.heartbeatCombatSeconds],
["heartbeatLobbySeconds", decl.heartbeatLobbySeconds],
];
for (const [name, value] of heartbeatFields) {
if (!Number.isFinite(value) || value <= 0) {
violations.push(`invalid:${name}`);
}
}
const rateFields: Array<[string, number]> = [
["snapshotHz", decl.snapshotHz],
["inputHz", decl.inputHz],
];
for (const [name, value] of rateFields) {
if (!Number.isFinite(value) || value < 0) {
violations.push(`invalid:${name}`);
}
}
if (decl.stateOwner !== "server") {
violations.push("state-owner:must-be-server");
}
if (decl.mode === "weak") {
if (decl.snapshotHz !== 0) {
violations.push("weak:no-periodic-snapshot");
}
if (decl.inputHz !== 0) {
violations.push("weak:no-input-tick");
}
if (!decl.offlineQueue) {
violations.push("weak:offline-queue-required");
}
if (decl.compensation === "none") {
violations.push("weak:reconnect-compensation-required");
}
} else {
if (decl.snapshotHz <= 0) {
violations.push("strong:snapshot-required");
}
if (decl.inputHz <= 0) {
violations.push("strong:input-tick-required");
}
if (decl.compensation === "none") {
violations.push("strong:reconnect-compensation-required");
}
if (decl.heartbeatCombatSeconds > decl.heartbeatLobbySeconds) {
violations.push("strong:combat-slower-than-lobby");
}
}
return { valid: violations.length === 0, violations };
}
export function isCompensationSufficient(mode: OnlineMode, strategy: Compensation): boolean {
if (strategy === "none") {
return false;
}
if (mode === "strong") {
return strategy === "delta" || strategy === "full";
}
return strategy === "replay" || strategy === "delta" || strategy === "full";
}
export function describeDeclaration(decl: ModeDeclaration): string {
const audit = auditDeclaration(decl);
const state = audit.valid ? "valid" : `invalid(${audit.violations.join("|")})`;
return `mode=${decl.mode} owner=${decl.stateOwner} heartbeat=${decl.heartbeatCombatSeconds}s/${decl.heartbeatLobbySeconds}s snapshot=${decl.snapshotHz}Hz input=${decl.inputHz}Hz compensation=${decl.compensation} offlineQueue=${decl.offlineQueue} audit=${state}`;
}
这段代码把两种形态的基线固化成 MODE_BASELINES,把责任归属固化成 RESPONSIBILITY_MATRIX,再用 auditDeclaration 检查一份声明是否自洽。
审计规则只做三类判断:
- 权威不能下放。 两种形态都要求
stateOwner为server。经济、战斗结果、进度这些状态不允许由客户端裁决。 - 形态不能混用。 弱联网不允许周期快照与输入 tick,必须保留离线队列;强联网必须有快照与输入 tick,且战斗心跳不能慢于大厅心跳。
- 补偿不能缺失。 两种形态都不接受
none;弱联网接受重放,强联网只接受增量或完整状态。
describeDeclaration 把声明与审计结果压成一行,便于进日志或在评审会上直接贴出来:
mode=strong owner=server heartbeat=5s/20s snapshot=10Hz input=20Hz compensation=delta offlineQueue=false audit=valid
这行的作用不是替代设计文档,而是让「声明是否自洽」变成一次可重复的检查。文档会过期,检查不会。
弱联网:把实时换成可重放
消消的网络事实是:RTT P50 78ms、P95 310ms,丢包率中位会话 0.4%,弱网会话占比 22%,断线率 2.1%,30 秒内重连成功率 97.2%。
这组数字支持一个明确的设计:不追求实时,追求可重放。
- 单局 90 秒,会话时长中位 6.5 分钟,玩家一天在线 26 分钟。操作节奏允许客户端把多次提交合并、排队、延后发送。
- 每会话流量 3.2MB,每 DAU 日流量 3.6MB,不含资源下载。流量预算不紧张,没必要为了省几百字节牺牲确认与重放能力。
- 上行均值 180B、下行均值 620B,单条消息很小,重放的成本主要不是带宽,而是服务端去重与状态机设计。
可重放的核心是业务序号。客户端为每次提交生成稳定序号,服务端按序号去重并持久化结果;客户端没收到确认时,用同一序号重发,而不是生成新序号。这样即使 22% 的弱网会话里出现重复发送,也不会产生二次结算。
心跳 25 秒与这个设计匹配:服务端有足够窗口等待重放,客户端也不必为了保活而频繁唤醒。低端机占比 35%,减少唤醒对续航和稳定性的收益,比缩短心跳发现断线更大。
强联网:把实时换成可补偿
方舟的网络事实是:RTT P50 62ms、P95 240ms,丢包率中位会话 0.6%,弱网会话占比 14%,断线率 1.4%,30 秒内重连成功率 98.1%。
单看 RTT,方舟比消消更好;但它的恢复成本更高:
- 战斗内 5 秒心跳,大厅 20 秒心跳。战斗内的心跳频率是消消的五分之一,任何一次漏拍都会迅速被客户端发现。
- 服务端 10Hz 快照、每条 350B;客户端 20Hz 输入、每条 48B。上下行频率不对称,客户端发得比服务端快,意味着输入必须先本地预测,再等服务端校正。
- 重连后 31% 的会话需要服务端补状态。这个比例远高于消消的 8%,因为战斗状态是连续演进的,断线期间的事实无法靠重放输入完整重建。
- 会话时长中位 34 分钟,人均日在线 62 分钟,每会话流量 18MB,每 DAU 日流量 21MB。长会话放大了任何一次状态不一致的代价。
强联网的设计原则因此是:不追求「断线无感」,追求「恢复可信」。
客户端可以做预测,但不能把预测当成事实;服务端下发的快照是权威,客户端收到后要能做平滑校正。重连时,客户端先上报最后确认的帧号或快照序号,服务端据此决定发增量还是完整状态。31% 的补状态比例说明增量协议必须是一等公民,而不是异常分支。
两种形态的对照表
把两个案例放在一起,可以得到一张用于设计评审的对照表:
| 指标 | 消消(弱联网) | 方舟(强联网) |
|---|---|---|
| RTT P50 / P95 | 78ms / 310ms | 62ms / 240ms |
| 丢包率(中位会话) | 0.4% | 0.6% |
| 弱网会话占比 | 22% | 14% |
| 断线率(每会话) | 2.1% | 1.4% |
| 30 秒内重连成功率 | 97.2% | 98.1% |
| 重连后需补状态 | 8% | 31% |
| 心跳 | 25 秒全场景 | 战斗 5 秒 / 大厅 20 秒 |
| 会话时长中位 | 6.5 分钟 | 34 分钟 |
| 每会话流量 | 3.2MB | 18MB |
| 单条消息上行 / 下行 | 180B / 620B | 240B / 1.4KB |
这张表不能用来判断谁「更好」。消消的弱网会话更多,但恢复更便宜;方舟的网络质量更好,但一次断线的代价更高。架构要回答的是:在自己这组数字下,把实时性换成可重放还是可补偿,代价更小。
判定清单:五个问题
给一个新项目定形态时,按顺序回答五个问题:
- 状态权威归谁? 如果答案不是服务端,先停下来改设计,形态讨论没有意义。
- 一次操作的有效期有多长? 过期就无意义的输入(战斗、实时移动)偏强联网;可以延后提交的操作(回合、领奖)偏弱联网。
- 断线期间丢的是什么? 丢的是「未确认的提交」,用重放;丢的是「连续演进的状态」,用增量补偿。
- 心跳能不能覆盖最长的合法静默? 消消 25 秒覆盖全场景,方舟战斗内必须压到 5 秒,这个差异由操作间隔决定。
- 离线队列会不会改变结果? 幂等提交可以排队;与当时战局绑定的输入不能排队。
五问里只要有一问答错,后面的心跳、重连、补状态都会跟着错。形态不是选出来的,是由这五个约束推出来的。
与相邻章节的关系
本节把 1.1 的三种网络形态收束成两种责任模型:弱联网把实时性换成可重放,强联网把实时性换成可补偿。1.3 会把这两套基线做成实验夹具,用同一套探针跑通「画像 → 门禁 → 报告」的骨架。
再往后,第 2 章的消息信封要同时服务两种形态:弱联网需要业务序号与幂等,强联网需要帧号与增量。第 3 章的心跳与重连会直接复用本节的四张表;如果这里的边界没定清楚,后面的协议设计就只能在实现里打补丁。
小结
- 弱联网与强联网的差别不在延迟高低,而在状态权威、补偿方式与离线队列。
- 两种形态都要求服务端拥有经济、战斗结果与进度的最终权威;实时性可以降,权威不能降。
- 消消用业务序号实现可重放,8% 的重连补状态比例说明重放覆盖了大多数情况。
- 方舟用快照与增量实现可补偿,31% 的补状态比例说明增量协议必须是主路径。
- 心跳间隔由操作间隔决定:25 秒覆盖全场景,还是战斗 5 秒、大厅 20 秒,不能照搬。
- 离线队列只适用于幂等且不依赖当时战局的操作。
- 用可审计的声明替代口头约定,让边界在每次评审里都能被检查。
阅读导航:上一节:1.1 弱网、切换与丢包:客户端要面对的三种网络 · 下一节:1.3 mga-lab-net 的网络实验骨架与验收口径 。
常见错误与边界
- 把弱联网理解成服务端少管。 弱联网降的是实时性,不是权威;结算、经济、进度仍然必须由服务端最终裁决。
- 用同一套心跳覆盖两种形态。 25 秒全场景适合回合制,放到 8–12 分钟一场的强联网战斗里,断线发现会明显滞后。
- 强联网只做全量重连。 方舟有 31% 的重连需要补状态,全量下发会放大恢复时间与流量,增量协议必须是主路径。
- 弱联网不做幂等。 重放未确认提交是弱联网的核心手段,没有业务序号就会重复结算。
- 把离线队列用到战斗输入上。 战斗输入与当时战局绑定,迟到即无效;离线队列只适合幂等且可延后的操作。
- 客户端把预测当成事实。 预测只用于表现,服务端快照才是权威;不校正会让客户端与服务端越走越远。
- 声明与实现两张皮。 只在文档里写「服务端权威」,代码里却让客户端结算,审计函数就是用来抓这种不一致的。
- 忽略长会话的累积误差。 方舟会话中位 34 分钟,任何一次未校正的偏差都会在长会话里放大。
- 用心跳频率代替断线检测。 心跳只是发现手段,恢复是否可信取决于补偿协议,不要把两者混为一谈。
- 把对照表当成优劣排名。 消消弱网会话更多但恢复更便宜,方舟网络更好但断线代价更高;表是用来做取舍的,不是用来打分的。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。