4、协议解析与序列化:Protobuf vs JSON vs Thrift 性能对比、自定义二进制协议设计
说到交易系统的序列化,我脑子里第一个蹦出来的词就是「速度」。
你想想看,一个高频交易系统,每秒要处理几万笔订单。每笔订单从生成到发送,中间要经过序列化、网络传输、反序列化。如果这一步慢了,整个系统的吞吐量就卡在这里了。
我早期做交易系统时,用的就是 JSON。那时候觉得挺方便,结构清晰,调试也简单。直到有一次压测,发现 CPU 全耗在 json.Unmarshal 上了。嗯,从那以后,我对序列化的选择就变得非常谨慎。
4.1 三大主流序列化方案对比
先说说市面上最常见的三种方案:JSON、Protobuf、Thrift。我分别从几个关键维度做个对比。
| 维度 | JSON | Protobuf | Thrift |
|---|---|---|---|
| 编码格式 | 文本 | 二进制 | 二进制 |
| 序列化速度 | 慢(反射+字符串处理) | 快(预编译+紧凑编码) | 较快(但略慢于 Protobuf) |
| 数据体积 | 大(含字段名) | 小(仅字段编号+值) | 中等(含协议头) |
| Schema 定义 | 无强制要求 | .proto 文件 | .thrift 文件 |
| 跨语言支持 | 原生支持 | 良好(需生成代码) | 良好(需生成代码) |
| 调试友好度 | 极高(可读性强) | 低(二进制不可读) | 低(二进制不可读) |
我个人习惯是:内部系统用 Protobuf,外部接口用 JSON。为什么?内部系统追求极致性能,Protobuf 的紧凑编码和预编译特性,能省下不少 CPU 和带宽。外部接口要考虑对接方的便利性,JSON 的调试友好度无可替代。
性能数据参考(我实测过的)
在同样的订单结构下(包含 10 个字段,含嵌套类型):
- JSON 序列化耗时:约 1.2μs,体积 180 字节
- Protobuf 序列化耗时:约 0.3μs,体积 64 字节
- Thrift 序列化耗时:约 0.5μs,体积 78 字节
差距很明显吧?Protobuf 比 JSON 快了 4 倍,体积小了近 3 倍。
4.2 为什么 Protobuf 这么快?
说白了,Protobuf 的「快」来自两个设计:
- 预编译:通过 protoc 编译器生成序列化/反序列化的专用代码,避免了运行时的反射开销。
- Varint 编码:对于小整数,只用 1 个字节就能表示,不像 JSON 那样要写成字符串 "12345"。
举个例子,一个订单 ID 为 12345:
- JSON 会编码成
"order_id":12345,占用 17 个字节 - Protobuf 用 Varint 编码,字段编号 1 + 值 12345,总共 3 个字节
我曾经在一个项目中,把核心交易链路的序列化从 JSON 换成 Protobuf,CPU 使用率直接降了 40%。这个优化效果,说实话我自己都吓了一跳。
小技巧:如果你的 Protobuf 消息里有很多小整数(比如枚举值、状态码),记得用 int32 而不是 fixed32。前者用 Varint 编码,后者固定 4 字节。我见过有人把状态码写成 fixed32,白白浪费了带宽。
4.3 Thrift 的独特优势
Thrift 虽然性能略逊于 Protobuf,但它有一个杀手锏——完整的 RPC 框架。Thrift 不仅定义了序列化协议,还定义了传输层、服务端/客户端框架。如果你需要快速搭建一个跨语言的 RPC 服务,Thrift 开箱即用。
我记得有一次,团队需要把 C++ 的交易引擎和 Python 的风控系统对接。用 Protobuf 的话,还得自己写 RPC 层。用 Thrift,一个 .thrift 文件搞定所有。省了不少事。
不过要注意,Thrift 的协议头比 Protobuf 重一些。每个消息都会带一个 4 字节的消息类型标识。在极端高频场景下,这个开销不可忽视。
4.4 自定义二进制协议设计
说到自定义协议,很多人觉得「造轮子没必要」。但我要说,在交易系统里,有时候轮子就得自己造。
为什么?因为 Protobuf 和 Thrift 再怎么优化,也是通用方案。它们要考虑各种数据类型、嵌套结构、向前兼容。这些通用性,在交易系统里就是额外的开销。
我设计过一个自定义协议,结构非常简单:
// 自定义二进制协议格式
// 头部固定 8 字节
// [消息类型:2字节] [消息长度:2字节] [序列号:4字节]
// 消息体:按字段顺序紧凑排列
// 示例:订单消息
// 字段顺序:order_id(8字节) | price(8字节) | volume(4字节) | side(1字节)
// 总长度:8 + 8 + 4 + 1 = 21 字节
// 序列化代码(C++ 示例)
void serializeOrder(const Order& order, char* buffer) {
// 写入头部
uint16_t msgType = 0x0001; // 订单消息类型
uint16_t bodyLen = 21; // 消息体长度
uint32_t seqNum = getNextSeqNum();
memcpy(buffer, &msgType, 2);
memcpy(buffer + 2, &bodyLen, 2);
memcpy(buffer + 4, &seqNum, 4);
// 写入消息体
memcpy(buffer + 8, &order.orderId, 8);
memcpy(buffer + 16, &order.price, 8);
memcpy(buffer + 24, &order.volume, 4);
buffer[28] = order.side;
}
这个协议有多快?我测过,序列化一个订单只需要 3 次 memcpy,耗时不到 0.1μs。比 Protobuf 还快 3 倍。
警告:自定义协议虽然快,但维护成本高。每次新增字段,都要手动修改序列化/反序列化代码。而且没有向前兼容能力,旧版本程序无法解析新版本消息。
我的建议是:只在核心交易链路上用自定义协议,其他非关键路径用 Protobuf 或 JSON。
4.5 协议设计的核心原则
不管用哪种协议,有几个原则我建议你记住:
- 固定头部:头部长度固定,方便快速定位消息边界。我习惯用 8 字节或 16 字节的头部。
- 长度字段:头部里一定要有消息体长度,这样接收方才能知道什么时候读完一条消息。
- 校验机制:至少加一个 CRC 或简单的异或校验。我曾经因为网络丢包,收到过损坏的消息,没有校验的话,后果不堪设想。
- 版本号:头部里留一个字节做版本号,方便以后升级协议。
我的经验总结
如果你问我,交易系统里到底该用哪种序列化方案?我的回答是:
- 外部接口:JSON,方便对接
- 内部服务间通信:Protobuf,性能与维护性的平衡
- 核心交易链路:自定义二进制协议,极致性能
别想着一种方案打天下。不同的场景,用不同的工具。这才是架构师的思维方式。
4.6 协议解析的性能优化技巧
最后分享几个我在实战中积累的优化技巧:
- 零拷贝解析:不要复制数据,直接在原始缓冲区上解析。用指针偏移代替 memcpy。
- 预分配缓冲区:避免在热路径上频繁分配内存。我习惯在连接建立时就分配好 64KB 的读写缓冲区。
- 批量解析:如果一次收到多条消息,不要逐条解析。一次性解析完,减少函数调用开销。
- 避免反射:运行时反射是性能杀手。能用代码生成就用代码生成。
嗯,关于协议解析和序列化,今天就聊这么多。记住一句话:在交易系统里,每一纳秒都值得争取。
公众号:蓝海资料掘金营,微信deep3321