灰度期的一次快照错位
《潮汐方舟》每周做一次平均 45MB 的热更,商店整包版本每 6 周才发一次。这意味着任何一个周三的晚上,线上至少同时存在两代客户端:刚更新完的,以及还没更新、或者更新失败后继续游戏的。服务端不能假设所有人都已经是新版本。
一次典型的故障是这样发生的:服务端在战斗快照末尾追加了一个字段,客户端按旧模式解析,把新增的字节当成下一条消息的长度前缀。战斗快照以 10Hz 下发、每条 350B,几十秒内客户端就会连续读错,表现成血量跳变、单位瞬移,最后触发断线重连。更麻烦的是,方舟的会话时长中位数是 34 分钟,玩家往往已经打完半场,才意识到状态不对。
问题不在某个字段,而在「消息的边界和身份没有先于内容被确定」。信封就是为此存在的:它不解释业务,只回答四个问题——这条消息属于哪个模式、哪个版本、哪个序号、负载有多长。把这四个问题固定下来,业务字段才有机会谈兼容。
信封先于消息:固定头承担什么
信封采用固定的二进制头,后面跟模式名和负载。固定头只有 14 字节,字段少到可以在断点里一眼读完,但它承担了所有解析器都必须先知道的信息。
| 字段 | 宽度 | 作用 | 缺失时的后果 |
|---|---|---|---|
| magic | 2 字节 | 标记这是本协议的数据 | 把 TCP 粘包里的其它字节误当消息 |
| major | 1 字节 | 协议主版本 | 无法判断能否解析 |
| kind | 1 字节 | 请求、响应、推送、快照 | 不知道要不要回包 |
| flags | 1 字节 | 压缩、加密、分片等标记 | 负载被当作明文 |
| sequence | 4 字节 | 连接内单调递增的消息号 | 无法发现乱序或重复 |
| schemaLen | 1 字节 | 模式名的 UTF-8 长度 | 无法切出模式名 |
| payloadLen | 4 字节 | 负载字节数 | 无法确定消息边界 |
这里有一个刻意的取舍:模式名放在头之后、负载之前,而不是塞进固定头。模式名是变长的,放进固定头会让头变成变长结构,解析器第一步就要读长度,等于把「先读头」的规则破坏掉。把它放在固定头之后,解析顺序仍然是「定长头 → 变长模式名 → 负载」,每一步都能用上一步的结果做边界检查。
模式名同时承担了「消息类型」和「契约身份」两个角色。ark.battle.input 和 ark.battle.snapshot 是两个不同的模式,即使它们同属战斗域。用模式名而不是数字 opcode,是为了让日志、抓包和契约测试都能直接读出语义;代价是每条消息多几个字节,对 48B 的战斗输入来说可以接受,对 350B 的快照更不算负担。
主版本与次版本:两种升级不是一回事
版本号只有两个数字,但它们的语义必须提前写死,否则团队会在第一次紧急发版时把它们用坏。
| 改动 | 版本动作 | 旧客户端的结果 |
|---|---|---|
| 新增可选字段 | minor + 1 | 忽略未知字段,继续工作 |
| 删除可选字段 | minor + 1 | 不再收到该字段,使用默认值 |
| 新增必需字段 | major + 1 | 拒绝解析,提示更新或走降级通道 |
| 删除必需字段 | major + 1 | 旧客户端缺少输入,必须拒绝 |
| 字段类型改变 | major + 1 | 字节解释不同,必须拒绝 |
| 只改字段语义、不改结构 | minor + 1,并双写过渡 | 新旧客户端各读各的语义 |
规则看起来简单,真正难的是执行。最常见的事故是「新增了一个必需字段,但只升了 minor」,因为开发同学觉得「这个字段很重要,必须带上」。对旧客户端来说,重要与否没有意义,它只知道自己不认识这个字段,而发送方又声明它是必需的。此时唯一安全的动作是拒绝整条消息,而不是猜一个默认值继续跑。
反过来,删除字段也要看它是否必需。删除一个可选字段可以只在 minor 上升级,因为旧客户端本来就可能没收到它;删除一个必需字段则必须升 major,因为旧客户端仍在等待这个输入。兼容性不是看字段重不重要,而是看「旧解析器能否在不理解新数据的情况下仍然做出正确行为」。
兼容判定要给出方向,而不是布尔值
很多团队把兼容检查写成一个 isCompatible 布尔函数,然后在 CI 里打印一行绿字。这个信息量不够:同样是「不兼容」,旧客户端读新消息和新客户端读旧消息,处理方式完全不同。
我们的实现返回四个级别:identical 表示双方模式完全一致;forward 表示本地较旧、远端较新,旧解析器可以忽略未知的可选字段继续工作;backward 表示本地较新、远端较旧,新解析器要为缺失字段准备默认值;breaking 表示必须拒绝并走更新或降级通道。除了级别,结果还带 reasons 和 unknownFields,让 CI 能直接指出是哪个字段导致断裂。
判定顺序也有讲究。先比模式名,再比主版本;任何一个不同都直接 breaking,不需要继续比对字段。只有主版本相同,才进入次版本和字段集的比较。这样可以把「协议身份不同」和「同一协议内的演进」分开,错误信息也不会被一堆字段差异淹没。
对于 forward 的情况,未知字段必须被允许跳过。信封的 payloadLen 让解析器可以在不认识的字段上直接前进,而不是逐字段猜测。这也是为什么信封层不负责字段级解析:它只需要知道整条消息有多长,剩下的交给模式层。
编解码实现
下面的文件把上述规则落成可执行代码。它只做三件事:按固定布局编码、按固定布局解码并做边界检查、比较两个模式契约给出兼容级别。没有第三方依赖,也不依赖工程里的其它文件。
// file: src/net/message-envelope.ts
// tier: L1
// verify: node --test test/net/message-envelope.test.ts
export const ENVELOPE_MAGIC = 0x4d47;
export const PROTOCOL_MAJOR = 2;
export const HEADER_BYTES = 14;
export type MessageKind = "request" | "response" | "push" | "snapshot";
const KIND_TO_CODE: Record<MessageKind, number> = {
request: 1,
response: 2,
push: 3,
snapshot: 4,
};
const CODE_TO_KIND: Record<number, MessageKind> = {
1: "request",
2: "response",
3: "push",
4: "snapshot",
};
export type EnvelopeErrorCode =
| "short"
| "magic"
| "version"
| "kind"
| "schema"
| "length";
export class EnvelopeError extends Error {
readonly code: EnvelopeErrorCode;
constructor(code: EnvelopeErrorCode, message: string) {
super(message);
this.name = "EnvelopeError";
this.code = code;
}
}
export interface Envelope {
readonly major: number;
readonly kind: MessageKind;
readonly flags: number;
readonly sequence: number;
readonly schema: string;
readonly payload: Uint8Array;
}
export interface SchemaField {
readonly name: string;
readonly required: boolean;
readonly sinceMinor: number;
}
export interface SchemaContract {
readonly name: string;
readonly major: number;
readonly minor: number;
readonly fields: readonly SchemaField[];
}
export type CompatibilityLevel =
| "identical"
| "backward"
| "forward"
| "breaking";
export interface Compatibility {
readonly ok: boolean;
readonly level: CompatibilityLevel;
readonly reasons: readonly string[];
readonly unknownFields: readonly string[];
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function kindCode(kind: MessageKind): number {
const code = KIND_TO_CODE[kind];
if (code === undefined) {
throw new EnvelopeError("kind", `unknown message kind: ${String(kind)}`);
}
return code;
}
function isMessageKind(value: number): value is MessageKind {
return CODE_TO_KIND[value] !== undefined;
}
export function encodeEnvelope(envelope: Envelope): Uint8Array {
if (!Number.isInteger(envelope.major) || envelope.major < 0 || envelope.major > 255) {
throw new EnvelopeError("version", `major out of range: ${envelope.major}`);
}
const schemaBytes = encoder.encode(envelope.schema);
if (schemaBytes.length === 0 || schemaBytes.length > 255) {
throw new EnvelopeError("schema", `schema length out of range: ${schemaBytes.length}`);
}
if (!Number.isInteger(envelope.sequence) || envelope.sequence < 0) {
throw new EnvelopeError("length", `sequence out of range: ${envelope.sequence}`);
}
const bodyBytes = HEADER_BYTES + schemaBytes.length + envelope.payload.length;
const buffer = new Uint8Array(bodyBytes);
const view = new DataView(buffer.buffer);
view.setUint16(0, ENVELOPE_MAGIC, false);
view.setUint8(2, envelope.major);
view.setUint8(3, kindCode(envelope.kind));
view.setUint8(4, envelope.flags & 0xff);
view.setUint8(5, schemaBytes.length);
view.setUint32(6, envelope.sequence, false);
view.setUint32(10, envelope.payload.length, false);
buffer.set(schemaBytes, HEADER_BYTES);
buffer.set(envelope.payload, HEADER_BYTES + schemaBytes.length);
return buffer;
}
export function decodeEnvelope(bytes: Uint8Array): Envelope {
if (bytes.length < HEADER_BYTES) {
throw new EnvelopeError("short", `buffer ${bytes.length} < ${HEADER_BYTES}`);
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const magic = view.getUint16(0, false);
if (magic !== ENVELOPE_MAGIC) {
throw new EnvelopeError("magic", `bad magic: 0x${magic.toString(16)}`);
}
const major = view.getUint8(2);
if (major !== PROTOCOL_MAJOR) {
throw new EnvelopeError("version", `unsupported major: ${major}`);
}
const kindCodeValue = view.getUint8(3);
if (!isMessageKind(kindCodeValue)) {
throw new EnvelopeError("kind", `unknown kind code: ${kindCodeValue}`);
}
const flags = view.getUint8(4);
const schemaLength = view.getUint8(5);
if (schemaLength === 0) {
throw new EnvelopeError("schema", "empty schema name");
}
const sequence = view.getUint32(6, false);
const payloadLength = view.getUint32(10, false);
const expected = HEADER_BYTES + schemaLength + payloadLength;
if (bytes.length < expected) {
throw new EnvelopeError("length", `buffer ${bytes.length} < ${expected}`);
}
const schema = decoder.decode(bytes.subarray(HEADER_BYTES, HEADER_BYTES + schemaLength));
const payloadStart = HEADER_BYTES + schemaLength;
const payload = bytes.slice(payloadStart, payloadStart + payloadLength);
return {
major,
kind: CODE_TO_KIND[kindCodeValue] as MessageKind,
flags,
sequence,
schema,
payload,
};
}
export function checkCompatibility(
local: SchemaContract,
remote: SchemaContract,
): Compatibility {
const reasons: string[] = [];
if (local.name !== remote.name) {
reasons.push(`schema ${remote.name} != ${local.name}`);
}
if (local.major !== remote.major) {
reasons.push(`major ${remote.major} != ${local.major}`);
}
if (reasons.length > 0) {
return { ok: false, level: "breaking", reasons, unknownFields: [] };
}
const localFields = new Map(local.fields.map((field) => [field.name, field]));
const remoteFields = new Map(remote.fields.map((field) => [field.name, field]));
for (const field of local.fields) {
if (field.required && !remoteFields.has(field.name)) {
reasons.push(`remote drops required field ${field.name}`);
}
}
const unknownFields: string[] = [];
for (const field of remote.fields) {
if (!localFields.has(field.name)) {
unknownFields.push(field.name);
if (field.required && field.sinceMinor > local.minor) {
reasons.push(`remote requires unknown field ${field.name}`);
}
}
}
if (reasons.length > 0) {
return { ok: false, level: "breaking", reasons, unknownFields };
}
if (local.minor === remote.minor && unknownFields.length === 0) {
return { ok: true, level: "identical", reasons: [], unknownFields: [] };
}
if (remote.minor > local.minor) {
return { ok: true, level: "forward", reasons: [], unknownFields };
}
return { ok: true, level: "backward", reasons: [], unknownFields };
}
export function describeEnvelope(envelope: Envelope): string {
const flags = `0x${(envelope.flags & 0xff).toString(16).padStart(2, "0")}`;
return `MG/${envelope.major} ${envelope.kind} seq=${envelope.sequence} schema=${envelope.schema} payload=${envelope.payload.length}B flags=${flags}`;
}
export function describeCompatibility(result: Compatibility): string {
if (result.ok) {
const unknown = result.unknownFields.length > 0
? ` unknown=${result.unknownFields.join(",")}`
: "";
return `compatible:${result.level}${unknown}`;
}
return `breaking: ${result.reasons.join("; ")}`;
}
编码函数的顺序是「先校验入参,再一次性分配缓冲区,最后按偏移写入」。这样不会出现「写到一半发现模式名超长」的部分写入问题。解码函数则相反:每一步都先检查剩余长度,再读取;任何越界都会抛出带错误码的 EnvelopeError,而不是返回一个半成品。
decodeEnvelope 里的 bytes.slice 会复制负载,而不是返回原缓冲区的视图。这是一个有意的选择:网络层通常会复用接收缓冲区,如果信封持有视图,下一帧数据到达时负载会被悄悄改写。复制的成本是每条消息一次内存分配,但对 350B 的快照和 48B 的输入来说,远小于一次错误的调试成本。
运行结果与边界
测试覆盖了正常往返、头部偏移、短包、坏 magic、错误主版本、未知 kind、截断负载、相同契约、可选字段前进兼容、必需未知字段断裂这十类情况。describeEnvelope 的输出形如:
MG/2 snapshot seq=1042 schema=ark.battle.snapshot payload=4B flags=0x00
这行输出在抓包和日志里比一串十六进制更有用:它同时给出了版本、类型、序号、模式名和负载长度。排障时先看模式名对不对,再看序号是否连续,最后才需要展开负载。
方舟的战斗输入是 20Hz、每条 48B,快照是 10Hz、每条 350B,上下行均值分别是 240B 和 1.4KB。信封的 14 字节固定头在输入消息上占比并不低,看起来不便宜,但它换来的是每条消息都能独立定位边界。相比之下,消消是回合制提交,每次 600B 到 1.2KB,单局 14 到 22 次提交,头部占比可以忽略;两类产品对信封开销的敏感度并不相同,不能用同一套阈值去要求。
边界也要写清楚。模式名长度用 1 字节,意味着上限 255 字节;负载长度用 4 字节,单条消息上限是 4GB,但实际会远低于这个值。信封不负责压缩和加密,flags 只表达「负载经过了什么处理」,具体算法由连接层协商。信封也不保证送达和顺序,sequence 只是让上层能发现乱序,重传与去重属于请求跟踪和连接状态机的职责。
与相邻章节的关系
2.2 会在这个信封之上讨论请求、响应与推送的语义:哪些消息需要回包、超时后如何重试、推送如何与请求区分。3.1 会把连接状态机和信封的 major 检查接起来,决定版本不匹配时是断开、降级还是提示更新。本节只解决「一条消息如何被安全地切开和识别」,不展开会话层的策略。
如果要把断线后的状态重建讲完整,可以对照客户端卷里关于恢复流程的讨论(断线恢复 ),但本节不依赖那部分结论。
小结
- 信封先于业务:固定头回答版本、类型、序号和长度,业务字段才有兼容的空间。
- 主版本表示结构断裂,次版本表示可忽略的演进;新增必需字段必须升主版本。
- 兼容判定要返回方向(identical、forward、backward、breaking),而不是一个布尔值。
- 未知字段必须能跳过,未知的必需字段必须拒绝;两者不能混为一谈。
- 解码时先检查长度再读取,负载要复制而不是持有网络缓冲区视图。
阅读导航:上一节:1.3 mga-lab-net 的网络实验骨架与验收口径 · 下一节:2.2 请求响应、推送与超时语义 。
常见错误与边界
- 把版本号写进负载。解析器必须先解负载才能知道版本,而解负载又需要版本,形成循环;版本必须在固定头里。
- 每次发版都把主版本加一。主版本一旦膨胀,兼容判断就失去意义,所有旧客户端都会被判为断裂。
- 新增必需字段只升次版本。旧客户端不认识这个字段,却被告知必须理解,只能拒绝整条消息。
- 把未知字段当错误丢弃整包。这会让所有次版本升级都变成硬断裂,灰度期无法共存。
- 只在服务端做兼容判断。客户端同样需要判断自己能否解析服务端的消息,否则会在本地读出错误状态。
- 用 JSON 字段名充当协议身份。字段名会随着重构改变,模式名应该是稳定的契约标识。
- 忽略字节序和长度校验。跨平台客户端混用大小端,或者把粘包当成一条消息,都会得到看似随机的结果。
- 用全局自增数做请求 id。请求、推送和快照共用序号池时,重连后序号回绕会造成误匹配;请求 id 应由请求跟踪器单独分配。
- 边界:信封不负责压缩、加密和分片。这些属于连接层,
flags只记录处理结果,不定义算法。 - 边界:信封不保证送达、顺序和去重。它只让上层能发现乱序,真正的重传和幂等由请求跟踪器与业务状态机负责。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。