跳转至

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 不透明的两个理由:

  1. §22.3 规定服务端对 E2EE 密文「只做搬运与批内去重编码,不解密」——正文必须是不透明的;
  2. §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`)