IM 协议设计:消息类型、字段与版本兼容

IM 协议要同时满足三件事:解析要快(每台接入机每秒收几十万帧,多一次字符串查找都是成本)、字段要能长大(今天没有的字段明天要加,且不能强制全量客户端升级)、错误要可定位(线上只看到一坨二进制时能说清是哪个字段出了问题)。本章给出帧格式、字段规范、命令号分配与兼容策略,并列出容易踩的坑。

帧格式

定长头 + 变长体的组合最适合长连接:头固定 16 字节,一次读完即可取到 body 长度,再按长度读体,不需要扫描分隔符。

偏移字段长度说明
0Magic2B固定 0x494D,用于快速识别错帧
2Version1B高 4 位主版本(不兼容变更时 +1),低 4 位次版本
3Type1B1=请求 2=响应 3=推送 4=ACK
4Cmd2B命令号,分配见下表
6Flags2Bbit0 压缩、bit1 加密、bit2 分片,其余保留位必须为 0
8Seq4B客户端递增序号,用于请求响应配对;服务端推送填 0
12BodyLen4Bbody 字节数,上限 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 判断"三条守住。

笔记加载中…