01 连接协议¶
状态:可实施 更新日期:2026-08-14 上游契约:
docs/PLAN.md§5.3、§6.8、§15、§27.1、附录 A(含 A.4.1)、附录 B 实施决策:ADR-0002(接入协议)、ADR-0006(Rust + Redpanda)
0. 文档边界¶
本文定义:opcode 字节值、帧头字节布局、载荷编码格式、压缩、分片与多路复用的 线上表示、TLS/ALPN 参数、WebSocket 承载细节、Rust 侧实现约束。
本文不得重新定义(改动须先改 docs/PLAN.md 或新增 ADR):
附录 A 的帧集合与每帧语义
附录 A.6 的错误码含义
FrameHeader 的字段集(本文只定字节偏移,不增删字段)
PONG 不携带任何水位字段(§15.1、§6.10.1)
附录 A.4.1 的同步条目结构
本文出现的所有数值均引用附录 B,不自行定义默认值。
1. 传输层¶
1.1 ALPN 与回落链路¶
细化 §5.3.5 的强制回落顺序。三档共用同一套帧与同一份 codec 实现。
档 1 ALPN "qim/1" TCP:443 + TLS 1.3,握手后直接收发本文的二进制帧
档 2 WSS 443 ALPN "http/1.1" → HTTP Upgrade,Sec-WebSocket-Protocol: qim.v1
档 3 代理 CONNECT + WSS 443 经系统 HTTP 代理建隧道后同档 2
每档超时 transport_fallback_step_timeout(附录 B.4)
成功档位按 (网络类型, 运营商 MCC/MNC 或 WiFi BSSID 哈希) 缓存
缓存 TTL transport_choice_cache_ttl(附录 B.4),命中则跳过前置档位
ALPN 协商失败(服务端不返回 qim/1)即视为档 1 不可用,立刻降档,不重试档 1——
中间设备对未知 ALPN 的处理通常是静默丢弃,重试只会累加超时。
1.2 TLS 参数¶
细化 §5.3.6,取值不变:
版本 TLS 1.3 only(不接受 1.2 回落)
终止位置 ConnectionNode;L4 四层直通 + PROXY protocol v2 透传源地址
0-RTT 关闭(§5.3.6)。early_data 一律拒绝
mTLS 默认关闭,私有化租户可开
证书 pinning 双 pin(当前 + 备用),pin 集合经独立配置通道下发,不硬编码进包
SNI 可携带 user_bucket 提示以避免"握手完再重定向"(§5.3.2),
私有化部署可关闭该优化
握手期的 slowloris 防护:TCP 建连到收到首个合法 AUTH 帧之间受
unauth_connection_timeout(附录 B.4)限制,超时直接 RST,不发任何应用层帧。
1.3 WebSocket 承载¶
子协议 Sec-WebSocket-Protocol: qim.v1(服务端必须回显,否则客户端断开)
帧类型 仅 Binary(0x2)。收到 Text(0x1)一律断连
消息边界 一个 WS Binary Message = 一个完整的 QIM 帧(含 20 字节帧头)
WS 分片 允许(continuation frames),由 WS 层透明重组后再交给 QIM codec
permessage-deflate **禁止协商**。压缩由 QIM 帧层承担(§4),双层压缩纯属浪费 CPU
Ping/Pong WS 控制帧不用于心跳。心跳一律走 QIM 的 PING/PONG(§6.2)
WS 消息体包含完整 QIM 帧头(而不是省掉它复用 WS 的长度字段),是为了满足 §5.3.5「TCP 与 WebSocket 两条路径强制复用同一 codec 实现」—— codec 的输入输出在两条路径上逐字节相同,CI 用同一组用例向量跑两条路径。
2. 帧¶
2.1 帧头字节布局¶
固定 20 字节,大端序(与 §6.1「所有 ID 大端编码」一致)。
offset size field 说明
------ ---- ------------ --------------------------------------------
0 2 magic 0x514D("QM")
2 1 version 应用协议大版本,见 §27.1.1
3 1 flags 位定义见 2.2
4 2 opcode 见 2.3
6 4 request_id 上行请求与下行响应配对;服务端主动帧为 0
10 2 stream_id 多路复用流,见 §5
12 4 body_len 本帧 body 字节数,<= max_frame_bytes(附录 B.5)
16 4 header_crc CRC32C over bytes[0..16)
20 ... body
header_crc 为什么存在(TLS 已保证链路完整性):它防的不是网络损坏,
而是实现层的分帧错位。body_len 直接驱动缓冲区分配,一次错位就可能读出
一个 4 GiB 的长度并触发巨额分配。收到 magic 或 header_crc 不匹配时,
接收方必须立即断连而不是尝试重新同步——流已不可信,任何"找下一个 magic"的
恢复逻辑都可能把 body 内容误判为帧头。
2.2 flags 位定义¶
bit0 COMPRESSED body 已压缩,见 §4
bit1 FRAG 本帧是一个逻辑帧的非最终分片,见 2.4
bit2 ENCRYPTED body 中的 payload 字段是 E2EE 密文
bit3 NEED_ACK 保留,当前协议**不使用**,置 1 即为实现缺陷
bit4-7 保留,必须置 0;接收方发现非 0 按未知版本处理
ENCRYPTED 的语义:标记该帧携带 E2EE 密文载荷,服务端与任何中间层
不得尝试解析或改写 payload 字段,只做搬运与批内去重编码(§22.3)。
该标志同时禁用压缩(§4)。
NEED_ACK 为什么保留不用:帧层 ACK 会与应用层的游标 + 幂等机制重复。
§5.3.5 拒绝 MQTT 的理由正是「QoS 语义与"至少一次投递 + mailbox_seq 幂等"重复且冲突,
两套重传与去重机制叠加会互相掩盖故障」——在自己的协议里引入帧层 ACK 是同一个错误。
可靠性一律由 §6.8 的游标与 §3 禁令 8 的幂等去重保证。
2.3 opcode 字节值¶
分段沿用附录 A.2。段内约定:0x_01 起顺序分配;双向帧共用一个 opcode,
字段集按方向不同(附录 A.3 / A.4 分别定义)。
| opcode | 帧 | 方向 |
|---|---|---|
0x0001 |
AUTH |
↑ |
0x0002 |
AUTH_OK |
↓ |
0x0003 |
PING |
↑ |
0x0004 |
PONG |
↓ |
0x0005 |
REDIRECT |
↓ |
0x0006 |
KICKED |
↓ |
0x0007 |
ERROR |
↓ |
0x0101 |
PULL_MAILBOX |
↑ |
0x0102 |
MAILBOX_BATCH |
↓ |
0x0103 |
SYNC_COMPLETE |
↑ |
0x0104 |
ONLINE_READY |
↓ |
0x0201 |
SEND_MESSAGE |
↑ |
0x0202 |
SEND_ACK |
↓ |
0x0203 |
PUSH_EVENTS |
↓ |
0x0301 |
PULL_HISTORY |
↑ |
0x0302 |
HISTORY_BATCH |
↓ |
0x0303 |
PULL_SESSION_LIST |
↑ |
0x0304 |
SESSION_LIST_BATCH |
↓ |
0x0305 |
SESSION_DELTA |
↓ |
0x0306 |
BADGE_UPDATE |
↓ |
0x0401 |
MARK_READ |
↑ |
0x0402 |
RECALL |
↑ |
0x0403 |
EDIT |
↑ |
0x0404 |
TYPING |
↕ |
0x0405 |
PRESENCE_SUB |
↕ |
0x0501 |
ROOM_JOIN |
↑ |
0x0502 |
ROOM_LEAVE |
↑ |
0x0503 |
ROOM_REPLAY |
↑ |
0x0504 |
ROOM_BATCH |
↓ |
0x0601 |
RTC_SIGNAL |
↕ |
0x0701 |
MEDIA_TICKET |
↕ |
0x0801 |
PREKEY_PUBLISH |
↑ |
0x0802 |
PREKEY_FETCH |
↕ |
0x0901 |
REACT |
↑ |
0x0902 |
REACTION_UPDATE |
↓ |
0x0903 |
PULL_REACTIONS |
↑ |
0x0904 |
REACTION_LIST |
↓ |
0x0A01 |
PULL_MEMBERS |
↑ |
0x0A02 |
MEMBER_LIST_BATCH |
↓ |
0x0A03 |
CREATE_GROUP |
↑ |
0x0A04 |
LEAVE_GROUP |
↑ |
新增帧只能在所属段内追加,不得复用已分配值,且必须先进附录 A.3/A.4。 未知 opcode 的处理见 §27.1.2(跳过整帧、计数、继续处理后续帧,禁止断连)。
2.4 分片(FRAG)¶
分片是多路复用的实现手段,不是应用层分页的替代品。
发送方可把任意逻辑帧切成多个分片:
除最后一片外 FRAG=1,最后一片 FRAG=0
每片自带完整 20 字节帧头,body_len 为该片的字节数
同一逻辑帧的所有分片 opcode / request_id / stream_id 相同
重组规则:
按 stream_id 归并(不需要 request_id)
**同一 stream 上任意时刻至多一个逻辑帧在传输中** —— 这条约束使重组无需状态机
重组超时 frag_assembly_timeout(附录 B.5)→ 丢弃并断连
重组后总长度仍受 max_frame_bytes 约束
应用层的续传一律用 has_more / next_offset(附录 A),不要用 FRAG 做分页:
FRAG 不携带任何应用语义,接收方在重组完成前无法开始处理。
3. 载荷编码¶
3.1 选型:Protobuf 信令 + 正文透传¶
帧 body = Protobuf 编码的信令结构
其中 payload_or_ciphertext / signal_payload 等正文字段类型为 bytes(不透明)
选 Protobuf 的决定性理由是 §27.1.2 的一条硬规则:
消息体中的未知字段 tag:按 tag-length 编码跳过并保留原始字节,重写该结构时原样回写。 禁止丢弃未知字节(会导致主备/新旧节点物化结果分叉)。
Protobuf 的 TLV 编码天然满足「按 tag-length 跳过」,是少数几种能直接兑现这条规则的格式。 FlatBuffers 的 vtable 虽然也能跳过未知字段,但不保留原始字节,且 schema 演进约束更强。
正文用 bytes 不透明的两个理由:
- §22.3 规定服务端对 E2EE 密文「只做搬运与批内去重编码,不解密」——正文必须是不透明的;
- §10.3 要求「公共正文只编码一次」——
bytes字段可以从正文 LRU 零拷贝切片后 直接拼进 N 个收件人的帧(Rust 侧即bytes::Bytes的 refcount 切片,见 §7)。
3.2 字段编号与演进¶
每个帧一个 message,字段号一经分配永不复用(含已删除字段,用 reserved 标记)
1-15 号留给高频字段(Protobuf 单字节 tag)
所有新增字段必须 optional 且有安全默认值(§27.1.2:未知内容可安全忽略)
枚举必须保留 0 值为 UNKNOWN,接收方对未知枚举值按 UNKNOWN 处理而非报错
3.3 Rust 侧的 unknown field 约束¶
这是一处必须写明的实现陷阱:prost(Rust 最常用的 Protobuf 实现)
默认不保留未知字段,直接与 §27.1.2 冲突。
处理规则(按优先级):
1. 服务端**不得对任何客户端上行结构做"解析后重写"**。
服务端要么整体透传(payload 已是 bytes),要么只读取自己认识的字段并
生成全新的服务端结构——这样 unknown field 是否保留就与正确性无关。
这是本文采纳的主路径。
2. 若某处确实需要"读入 → 修改 → 回写"同一结构,该处必须改用
rust-protobuf(支持 unknown field 保留),或把可扩展部分收敛为 bytes。
3. CI 断言:对所有服务端"读入并回写"的代码路径做静态检查,出现即阻断合并。
第 1 条同时简化了服务端:客户端上行结构与服务端下行结构是两套 message, 不共用定义,避免"同一结构在两个方向上语义不同"的经典坑。
4. 压缩¶
算法 zstd(level 3)。TLS 1.3 已移除传输层压缩,应用层是唯一选项
协商 AUTH.capabilities 宣告支持的算法集合;服务端在 AUTH_OK 中确认生效算法
未协商成功则全程不压缩
触发门槛 body_len >= frame_compress_min_bytes(附录 B.5,默认 1 KiB)
低于门槛不压缩 —— IM 正文均值约 600 B,小帧压缩得不偿失
禁止压缩 flags.ENCRYPTED = 1 的帧一律不压缩:
密文压缩比接近 1,纯浪费 CPU
且对可变长明文做"先压缩后加密"存在 CRIME/BREACH 类侧信道
标志 压缩后置 flags.COMPRESSED = 1,body_len 为**压缩后**长度
解压上限 解压后长度必须 <= max_frame_bytes,超出立即断连(zip bomb 防护)
收益最大的是批量帧(MAILBOX_BATCH / HISTORY_BATCH / SESSION_LIST_BATCH),
它们的条目结构高度重复。实时单帧(PUSH_EVENTS 单条)通常低于门槛,不压缩。
5. 多路复用¶
5.1 stream 划分¶
沿用附录 A.5,不增删:
stream 0 控制流:AUTH / AUTH_OK / PING / PONG / ERROR / KICKED / REDIRECT
stream 1 实时流:PUSH_EVENTS / SEND_ACK / SESSION_DELTA / BADGE_UPDATE /
TYPING / PRESENCE_SUB / REACTION_UPDATE / RTC_SIGNAL
stream 2 批量流:MAILBOX_BATCH / HISTORY_BATCH / SESSION_LIST_BATCH /
MEMBER_LIST_BATCH / REACTION_LIST
stream 3 房间流:ROOM_BATCH
上行请求帧走与其应答相同的 stream。
5.2 调度:为什么必须分片写出¶
A.5 的目标是「一个 4 MiB 的 MAILBOX_BATCH 不得阻塞 PONG」。
但仅按帧调度做不到这一点——帧一旦开始写就无法中途让位:
4 MiB 帧在 1 Mbps 移动链路上需要约 32 秒
期间 PONG 排在其后 → 必然超过 idle_timeout → 心跳误判断连
因此写出调度必须在分片粒度让位:
写出循环:
1. stream 0 严格优先:只要有待发帧,立即写出(控制帧都很小,不会造成饥饿)
2. stream 1/2/3 按权重轮转(6 : 3 : 1),权重对应 §11.3 的帧优先级
3. 每次最多写出 stream_write_chunk_bytes(附录 B.5,64 KiB)后**重新调度**
未写完的逻辑帧置 FRAG=1 续写(§2.4)
stream 0 的等待上界 = stream_write_chunk_bytes / 链路带宽
1 Mbps 链路:64 KiB ≈ 0.5 s,远小于 idle_timeout(附录 B.4)
这条把 §2.4 的 FRAG、A.5 的多路复用、§15 的心跳三者绑成了一个自洽的整体:
没有分片写出,多路复用对大帧无效,心跳就会被业务流量误伤。
5.3 背压¶
每 stream 独立的待发队列与字节水位
连接总量受 conn_send_soft/hard_watermark(附录 B.5)约束
超软水位:停止推送积压,只发 mailbox_dirty(§11.3)
超硬水位:关闭连接 —— 消息已在邮箱,断开不丢(§11.3)
节点总发送缓冲受 node_send_buffer_budget 约束,超出按帧优先级丢弃
6. 连接生命周期¶
6.1 建连到 ONLINE_READY¶
TCP+TLS 握手(ALPN qim/1)
│ 此后受 unauth_connection_timeout 约束
├─→ AUTH{access_token, device_id, client_version, capabilities, mailbox_cursor}
│
│ 服务端按 §9.3.1 的固定顺序做游标判定:
│ 游标无效/越界 → ERROR{CURSOR_INVALID}
│ 游标 < effective_trim → ERROR{CURSOR_EXPIRED} 走 §9.6 REBUILD
│ shard_epoch 落后 → ERROR{CURSOR_REBASED}
│ 分片不在本节点 → REDIRECT{route_token, ...} 单次使用,TTL ≤ 60 s
│
├─← AUTH_OK{session_epoch, lane_id, lane_watermark, trim_watermark, sync_to_seq,
│ has_offline, pending_*_hint, total_unread, ..., next_ping_interval_ms}
│ 帧头 version 回写生效版本 Ve = min(Vc, Vs)(§27.1.1)
│
├─→ PULL_MAILBOX × N(流水线窗口 pull_mailbox_window,附录 B.3)
├─← MAILBOX_BATCH × N
├─→ SYNC_COMPLETE{sync_to_seq}
└─← ONLINE_READY ← 解除登录屏障,服务端开始推 PUSH_EVENTS
has_offline = false 时客户端可直接发 SYNC_COMPLETE,不发任何 PULL_MAILBOX。
6.2 心跳¶
全部走 stream 0。间隔由服务端在 PONG.next_ping_interval_ms 下发,客户端必须遵从。
自适应区间与回退规则见 §15.1 与附录 B.4,本文不重复。
判死(§15.1.2):
服务端 超过 idle_timeout 未收到任何合法帧 → 关闭连接
客户端 连续 ping_probe_timeout 未收到 PONG → 主动重连,不得无限等待
PONG 不携带任何水位字段(§6.10.1)。客户端仅凭
mailbox_dirty == true 或 last_applied_mailbox_seq < last_pushed_user_seq
发起 PULL_MAILBOX;**严禁与分片级水位比较**。
6.3 断开与重连¶
主动关闭前若有可告知的原因,先发 KICKED{reason} 再关,让客户端区分
"被替换/被封禁/令牌吊销" 与 "网络断开"——前者不应触发重连风暴。
重连:带抖动指数退避(reconnect_backoff,附录 B.4)
网络切换:允许立即快速重连一次
接管期:服务端按 takeover_admit_rate 分批放行,超额回 ERROR{RATE_LIMITED, retry_after_ms}
AUTH_OK.sync_delay_hint_ms 让重连用户错峰发起拉取
重连后的发送侧义务(ADR-0008 登录对账):
重发任何 local_pending 之前必须先完成邮箱同步并按 client_message_id 对账——
自发条目(Entry.client_message_id,仅发送者本人的条目携带)命中 pending
即原位升级为已确认;对账完成仍未命中才按原 ID 重发。
禁止重连后未对账即盲重发(幂等窗口只有 2 小时,PLAN §8.3/§27.3.2)。
7. Rust 实现约束¶
运行时 tokio(ADR-0006)
codec 实现 tokio_util::codec::{Decoder, Encoder},TCP 与 WS 共用同一实现
缓冲 bytes::{Bytes, BytesMut};body 一律以 Bytes 持有
CRC32C crc32c crate(SSE4.2 / ARMv8 硬件加速),不要手写查表实现
Protobuf prost(注意 §3.3 的 unknown field 约束)
zstd zstd crate,复用 Compressor/Decompressor 实例避免每帧重建上下文
「公共正文只编码一次」的落地方式(§10.3):
1. MailboxNode 对同一 message_id 只 join 与编码一次,得到 Bytes(一次堆分配)
2. 每个收件人的帧只持有该 Bytes 的 slice(refcount +1,零拷贝)
3. 写出时用 writev / Buf::chunks_vectored 把「个性化帧头 + 共享正文」
一次 syscall 写出,避免为每个收件人拼接完整缓冲
一条 10 万人群消息的正文因此只有 1 份堆内存与 1 次编码,
与 §10.2 的 O(1) 正文成本一致。
禁止:为每个收件人 clone() 正文字节;那会把 §10.2 的成本模型从 O(1) 退化为 O(N)。
8. 验收¶
以下为协议层验收,与 §26 的用例互补(§26 覆盖语义,本节覆盖线上表示)。
P-1【发布阻断】codec 双路径一致性
同一组用例向量分别经 TCP 与 WebSocket 路径编解码,
输出字节逐字节相同;差异数 == 0(§5.3.5 的强制要求)
P-2【发布阻断】stream 0 不被大帧阻塞
在 1 Mbps 限速链路上,stream 2 持续发送 4 MiB MAILBOX_BATCH,
同时测量 PONG 的往返延迟:P99 < idle_timeout / 4
断言写出分片粒度 <= stream_write_chunk_bytes
P-3 未知 opcode 与未知字段
注入未分配 opcode(如 0x0FFF)与带未知 tag 的 message:
接收方 unknown_opcode_dropped 计数增加,连接保持,后续帧正常处理
断连次数 == 0,游标推进未停止(§27.1.2)
P-4 帧头防御
注入错误 magic / 错误 header_crc / body_len 超 max_frame_bytes:
三种情况均立即断连,且**未发生按错误 body_len 的缓冲分配**
(断言分配器峰值不随注入的 body_len 增长)
P-5 压缩边界
body < frame_compress_min_bytes 的帧 COMPRESSED == 0
flags.ENCRYPTED == 1 的帧 COMPRESSED == 0(任意大小)
解压后超 max_frame_bytes 的畸形帧被拒绝且不触发大额分配
P-6 正文零拷贝
10 万收件人的同一条群消息:正文堆分配次数 == 1、编码次数 == 1
(断言 Bytes 的 refcount 增长而非新分配)
P-7 分片重组
构造被切成 N 片的逻辑帧,验证按 stream_id 重组正确;
注入乱序/缺片:frag_assembly_timeout 后断连,不产生半个逻辑帧的处理
9. 待办¶
[x] .proto 文件落地(每帧一个 message,字段号分配表进版本控制,`crates/qim-proto/proto/qim/`)
[ ] §3.3 第 3 条的 CI 静态检查规则实现
[ ] 用例向量集(P-1 依赖它,需覆盖全部 40 个 opcode)
[x] 与 docs/02 对齐 A.4.1 条目结构的 Protobuf 定义(两文档共用同一 message,`common.proto`)