IM 协议设计:消息类型、字段与版本兼容
IM 协议要同时满足三件事:解析要快(每台接入机每秒收几十万帧,多一次字符串查找都是成本)、字段要能长大(今天没有的字段明天要加,且不能强制全量客户端升级)、错误要可定位(线上只看到一坨二进制时能说清是哪个字段出了问题)。本章给出帧格式、字段规范、命令号分配与兼容策略,并列出容易踩的坑。
帧格式
定长头 + 变长体的组合最适合长连接:头固定 16 字节,一次读完即可取到 body 长度,再按长度读体,不需要扫描分隔符。
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | Magic | 2B | 固定 0x494D,用于快速识别错帧 |
| 2 | Version | 1B | 高 4 位主版本(不兼容变更时 +1),低 4 位次版本 |
| 3 | Type | 1B | 1=请求 2=响应 3=推送 4=ACK |
| 4 | Cmd | 2B | 命令号,分配见下表 |
| 6 | Flags | 2B | bit0 压缩、bit1 加密、bit2 分片,其余保留位必须为 0 |
| 8 | Seq | 4B | 客户端递增序号,用于请求响应配对;服务端推送填 0 |
| 12 | BodyLen | 4B | body 字节数,上限 1MB |
const (
MagicIM = 0x494D
MaxBodyBytes = 1 << 20 // 1MB:超过直接断连,防止畸形帧造成内存放大
)
type Header struct {
Magic uint16
Version uint8
Type uint8
Cmd uint16
Flags uint16
Seq uint32
BodyLen uint32
}
// 编解码必须成对,网络字节序统一大端,避免跨语言歧义
func (h Header) Encode() []byte {
buf := make([]byte, 16)
binary.BigEndian.PutUint16(buf[0:2], MagicIM)
buf[2], buf[3] = h.Version, h.Type
binary.BigEndian.PutUint16(buf[4:6], h.Cmd)
binary.BigEndian.PutUint16(buf[6:8], h.Flags)
binary.BigEndian.PutUint32(buf[8:12], h.Seq)
binary.BigEndian.PutUint32(buf[12:16], h.BodyLen)
return buf
}
func DecodeHeader(buf []byte) (Header, error) {
if len(buf) < 16 {
return Header{}, ErrShortFrame
}
h := Header{
Magic: binary.BigEndian.Uint16(buf[0:2]), Version: buf[2], Type: buf[3],
Cmd: binary.BigEndian.Uint16(buf[4:6]), Flags: binary.BigEndian.Uint16(buf[6:8]),
Seq: binary.BigEndian.Uint32(buf[8:12]), BodyLen: binary.BigEndian.Uint32(buf[12:16]),
}
switch {
case h.Magic != MagicIM:
return Header{}, ErrBadMagic
case h.BodyLen > MaxBodyBytes:
return Header{}, ErrBodyTooLarge
}
return h, nil
}
Flags 里的保留位必须校验为 0:不校验的话,将来启用某个 bit 时老客户端会静默按旧语义解析,出的是最难查的那类 bug。
命令号分配
命令号按区段划分,区段本身携带语义,排障时看一眼号段就知道方向:
| 区段 | 用途 | 示例 |
|---|---|---|
| 0x0001~0x00FF | 连接控制 | 0x0001 auth、0x0002 auth_ack、0x0003 heartbeat、0x0004 kick、0x0005 reconnect |
| 0x0100~0x01FF | 消息收发 | 0x0101 send_chat、0x0102 send_ack、0x0103 push_chat、0x0104 read_receipt |
| 0x0200~0x02FF | 同步与拉取 | 0x0201 sync_req、0x0202 sync_resp |
| 0x0300~0x03FF | 会话与关系 | 0x0301 conv_list_req、0x0302 conv_update |
| 0x7F00~0x7FFF | 灰度与实验 | 仅下发给新版本,客户端未识别时忽略 |
规则三条:命令号只增不改语义;废弃命令保留至少一个发布周期(约 1 年)并在服务端返回明确错误码;收到未知命令必须回 not_supported 而不是断连,否则新旧版本混跑时老客户端会被直接踢掉。
消息体字段设计
body 的字段语义在 JSON 与 Protobuf 两种编码下必须完全一致:
{"client_msg_id":"b7c1e2f0-3a44-4c1e-9f22-1a2b3c4d5e6f","conv_id":"c_1001_2002","conv_type":1,
"msg_type":1,"content":"{\"text\":\"在吗\"}","quote_msg_id":"7239847561029384756","client_ts":1716345600123}
| 字段 | 必填 | 说明 |
|---|---|---|
| client_msg_id | 是 | 客户端生成的 UUID,服务端作为幂等键使用 |
| conv_id | 是 | 会话 ID,单聊用双方 uid 排序拼接,群聊用群 ID |
| conv_type | 是 | 1=单聊 2=群聊,决定后续路由与扩散策略 |
| msg_type | 是 | 1=文本 2=图片 3=语音 4=文件 5=撤回 6=自定义 |
| content | 是 | 结构化内容的字符串形态,单条上限 8KB |
| client_ts | 是 | 客户端毫秒时间戳,仅作展示参考 |
两条硬规则:所有 ID 一律用字符串承载(int64 传到 JavaScript 会丢精度);时间戳统一毫秒,服务端在响应里回写 server_ts(排序以它为准)并记录两端差值,用于客户端时钟校正。
编码方式取舍
| 方案 | 单条 200 字消息体积 | 解析开销 | 兼容性成本 | 建议 |
|---|---|---|---|---|
| JSON | 约 500B | 中 | 低(忽略未知字段即可) | 起步、内部接口、信令类 |
| Protobuf | 约 180B | 低 | 低(字段号机制) | 消息体主链路 |
| TLV | 约 160B | 最低 | 高(需自研版本矩阵) | 带宽极度敏感的场景 |
实践建议:帧头永远是自定义定长二进制,body 按命令号选编码——信令类(心跳、ACK、在线态)用紧凑结构,聊天消息用 Protobuf,管理类与调试类用 JSON。压缩只在 body > 1KB 时启用(Flags.bit0),小包压缩的收益抵不过 CPU。
版本兼容策略
主版本不一致 → 返回 upgrade_required;次版本不一致 → 忽略未知字段正常通信;未知命令号 → 回 not_supported 且不断连
| 协议变更 | 是否安全 | 需要灰度 | 说明 |
|---|---|---|---|
| 新增可选字段 | 安全 | 否 | 老客户端忽略,服务端给默认值 |
| 新增命令号 | 安全 | 否 | 老客户端按未知命令回 not_supported |
| 修改字段语义 | 危险 | 必须 | 等于新增字段,旧字段标记废弃 |
| 删除字段 | 危险 | 必须 | 先停止下发、观察一个版本,再删除 |
| 修改帧头布局 | 危险 | 全量 | 只能通过主版本升级 |
服务端按 AppVersion 做能力判断,而不是"猜客户端能不能处理":例如撤回消息的新卡片形态只在 AppVersion >= 5.12 时下发,否则退化成一条文本提示。这张版本下限表要放在配置中心,而不是硬编码在代码里。
工程注意点
- 分片帧要限制并发:
Flags.bit2打开时长消息可拆片,但必须限制单连接同时存在的分片组数(建议 4 组)与总缓冲(建议 4MB),否则是现成的内存放大漏洞。 - Seq 用 uint32 会回绕,UTF-8 截断要按字符边界:配对逻辑用"最近窗口"而不是严格自增比较(或按小时重置 Seq);内容超长时不要按字节切,否则尾部是半个汉字,客户端渲染成乱码。
- 错误码要分层:
1xxx协议错误(重试无效)、2xxx鉴权错误(需重登)、3xxx业务错误(提示用户)、4xxx服务端错误(可重试),客户端按段位决定重试、重登还是弹提示。 - 协议文档要可执行:把帧头表与命令号表写成 IDL 并生成两端代码,比手写文档可靠——文档会过期,生成的代码不会。
小结:协议用 16 字节定长头加变长体,头里放版本、命令号、标志位、序号与长度并校验保留位;命令号按区段分配、只增不改、未知命令回 not_supported;body 的字段规范是 ID 用字符串、时间戳用毫秒、client_msg_id 作幂等键;编码上帧头用二进制、聊天体用 Protobuf、信令用紧凑结构、超过 1KB 才压缩;兼容性靠"只增字段、废弃先标记、能力按 AppVersion 判断"三条守住。