4. 序列化与反序列化:Google Protobuf、FlatBuffers、Cap'n Proto对比,零拷贝序列化原理
说到行情数据的序列化,我估计不少朋友第一反应就是 Protobuf。确实,它在业界太流行了。但做低延迟交易的朋友,慢慢会发现 Protobuf 在某些场景下有点「力不从心」。今天我们就来掰扯掰扯,为什么会有 FlatBuffers 和 Cap'n Proto 这些后来者,以及它们背后那个听起来很玄乎的「零拷贝」到底是怎么回事。
4.1 为什么需要序列化?
说白了,行情数据在内存里是一堆结构体,有整型、浮点、字符串。但你要把它扔到网络上,或者写到磁盘里,就得把它变成一串连续的字节。这个过程就是序列化。反过来,从字节流还原成结构体,就是反序列化。
我刚开始做行情网关的时候,用的就是最朴素的 memcpy,把结构体直接当字节流发出去。嗯,当时觉得挺爽,速度飞快。但后来发现,不同机器、不同编译器,结构体的内存对齐方式不一样,直接 memcpy 就是给自己挖坑。跨平台?想都别想。
所以,我们需要一个标准化的序列化方案。
4.2 三大主流方案概览
目前市面上最主流的三个方案,我列个表,大家先有个直观印象。
| 特性 | Protobuf | FlatBuffers | Cap'n Proto |
|---|---|---|---|
| 序列化方式 | 编码/解码 | 直接内存访问 | 直接内存访问 |
| 是否需要解析步骤 | 是 | 否 | 否 |
| 内存分配 | 动态分配 | 预分配/零拷贝 | 预分配/零拷贝 |
| 语言支持 | 极多 | 多 | 中等 |
| 延迟表现 | 中等 | 极低 | 极低 |
| 典型场景 | RPC、存储 | 游戏、高频交易 | 高性能RPC |
看到这个表,你可能会问:为什么 Protobuf 延迟是中等?它不也挺快的吗?
这里有个关键点:Protobuf 的序列化和反序列化,本质上是一个「编码-解码」过程。它需要把字段名或字段编号映射成二进制,再按照特定规则打包。反序列化时,又要解析这个二进制流,重建对象。这个过程涉及大量的内存分配、拷贝和 if-else 判断。
而 FlatBuffers 和 Cap'n Proto 的思路完全不同。它们把序列化后的数据,直接当作一个「内存布局」来用。你不需要解析,直接通过偏移量访问字段。这就引出了我们今天的核心概念——零拷贝。
4.3 零拷贝序列化原理
零拷贝,听起来很唬人。其实原理很简单:避免不必要的数据复制。
传统的序列化流程是这样的:
- 你有一个结构体 A,里面存着行情数据。
- 序列化时,把 A 的数据复制到一块连续的字节缓冲区 B。
- 网络发送 B。
- 接收端收到 B,反序列化,把 B 的数据复制到新的结构体 C。
你看,数据从 A 到 B 到 C,至少复制了两次。如果数据量大,这个开销很可观。
零拷贝的思路是:能不能让接收端直接读取发送端的内存布局?
FlatBuffers 和 Cap'n Proto 就是这么干的。它们定义了一种「自描述」的二进制格式。序列化后的数据,本身就是一块内存。你拿到这块内存的指针,就可以通过偏移量直接访问里面的字段,不需要再解析和复制。
我画个图,你一看就明白。
你看,零拷贝方案省掉了两次内存拷贝。在行情解码这种高频场景下,这个优势是决定性的。
4.4 三大方案深度对比
4.4.1 Protobuf:成熟稳定,但不够极致
Protobuf 的好处是生态好,文档多,几乎什么语言都支持。我早期做量化系统时,内部通信全用的 Protobuf。但后来做行情网关,发现一个问题:每次反序列化都要 new 对象。
在 C++ 里,new 一个对象意味着堆内存分配。堆分配是有锁的,在高并发下会竞争。而且反序列化过程中,字符串字段还要再分配内存。这一套下来,延迟抖动就上去了。
我曾经在一个项目中,用 Protobuf 解码行情,平均延迟 500 纳秒,但 P99 延迟能到 5 微秒。差了 10 倍。后来一查,就是内存分配导致的。
4.4.2 FlatBuffers:为游戏和高频交易而生
FlatBuffers 是 Google 推出的,初衷是为了解决游戏中的序列化性能问题。它的核心思想就是零拷贝。
使用 FlatBuffers,你不需要先解析再访问。拿到字节流后,直接通过偏移量读取字段。比如:
// 假设收到了行情数据 buffer
auto quote = GetQuote(buffer);
// 直接读取,无需解析
double price = quote->price();
int64_t timestamp = quote->timestamp();
你看,没有 ParseFromString,没有 new。就是这么直接。
但 FlatBuffers 也有缺点:写入数据比较麻烦。你需要先创建一个 Builder,然后往里面填数据,最后生成 buffer。这个过程不像 Protobuf 那样直观。
4.4.3 Cap'n Proto:更现代的零拷贝方案
Cap'n Proto 是 Protobuf 的作者 Kenton Varda 离开 Google 后做的。他吸取了 Protobuf 的教训,从零开始设计了一个零拷贝方案。
Cap'n Proto 的思路和 FlatBuffers 类似,但更激进。它把序列化格式直接定义成了内存布局。也就是说,你定义的消息结构,在内存里就是那个样子。不需要任何转换。
而且 Cap'n Proto 支持 RPC,它的 RPC 也是基于零拷贝设计的。这在分布式系统中很有优势。
不过,Cap'n Proto 的生态不如 Protobuf 成熟。我在项目中用过一段时间,发现 C++ 的 API 设计得不错,但 Python 的支持就弱一些。如果你是全栈用 C++,那 Cap'n Proto 很值得一试。
4.5 实战选择建议
说了这么多,到底该选哪个?我根据实际经验,给几个建议:
- 如果你做的是内部系统,对延迟不敏感:用 Protobuf。生态好,坑少,团队上手快。
- 如果你做的是行情网关、交易引擎:优先考虑 FlatBuffers 或 Cap'n Proto。零拷贝带来的延迟优势是实打实的。
- 如果你需要跨语言通信:Protobuf 支持最好。FlatBuffers 和 Cap'n Proto 虽然也支持多语言,但某些语言的绑定可能不够完善。
- 如果你需要 RPC:Cap'n Proto 的 RPC 设计很优雅,值得研究。Protobuf 的 gRPC 也很成熟,但延迟会高一些。
我个人习惯是:行情解码用 FlatBuffers,内部服务间通信用 Protobuf,跨服务 RPC 用 Cap'n Proto。当然,这只是一个参考,具体还要看你的系统架构。
4.6 避坑指南
最后,分享几个我踩过的坑:
- 不要迷信零拷贝:零拷贝虽然快,但它的数据是「只读」的。如果你需要修改数据,还是得拷贝出来。我曾经在一个项目中,为了追求零拷贝,强行用 FlatBuffers 做读写频繁的操作,结果代码写得非常别扭,性能反而下降了。
- 注意字节序:FlatBuffers 和 Cap'n Proto 默认是小端序。如果你的系统是大端序,需要做转换。虽然现在 x86 和 ARM 都是小端,但网络协议里偶尔会遇到大端的情况。
- 版本兼容性:Protobuf 的版本兼容性做得最好。FlatBuffers 和 Cap'n Proto 在这方面稍弱。如果你需要长期维护一个系统,要考虑升级成本。
嗯,关于序列化,今天就聊这么多。记住一句话:没有银弹,只有最适合你场景的方案。