每次讨论序列化格式,都会有人贴一张 benchmark 图,结论是”FlatBuffers 比 protobuf 快 10 倍”。然后你就用了 FlatBuffers。
三个月后你发现:schema
改一个字段要通知五个团队。生成的代码里全是
unsafe。debug
的时候看到的不是数据而是一堆偏移量。你开始怀疑人生。
序列化格式不只是 encode/decode 的速度。它是你代码库里最具传染性的依赖——它决定了数据的内存布局、跨服务接口、持久化格式、甚至 debug 体验。
这篇从三个维度评估 protobuf、FlatBuffers、Cap’n Proto:速度、演化性、工程代价。
一、三种格式的核心设计差异
Protocol Buffers (protobuf)
Google 出品,序列化格式的事实标准。
核心思路:编码时序列化,解码时反序列化。数据在传输和存储中是紧凑的二进制格式,使用时需要解码到语言原生结构体。
message Order {
uint64 id = 1;
string symbol = 2;
double price = 3;
int32 quantity = 4;
repeated Fill fills = 5;
}protobuf 的编码使用 varint + tag-length-value (TLV) 格式。小整数只占 1-2 字节(varint 编码),字符串和子消息带长度前缀。
FlatBuffers
Google 出品(另一个团队),主要用于游戏和嵌入式场景。
核心思路:数据的序列化格式就是内存格式。不需要编码/解码,直接通过偏移量读取。这就是所谓的 zero-copy。
table Order {
id: ulong;
symbol: string;
price: double;
quantity: int;
fills: [Fill];
}
FlatBuffers 的数据由 vtable(字段偏移量表)+ 内联数据组成。访问一个字段 = 查 vtable 拿偏移量 + 按偏移量读数据。两次内存访问,没有反序列化。
Cap’n Proto
Kenton Varda(protobuf v2 作者)离开 Google 后设计的。
核心思路和 FlatBuffers 类似——zero-copy,数据的传输格式就是内存格式。但 Cap’n Proto 用的是固定大小的 struct 布局 + pointer 段,而不是 vtable。
struct Order {
id @0 :UInt64;
symbol @1 :Text;
price @2 :Float64;
quantity @3 :Int32;
fills @4 :List(Fill);
}
Cap’n Proto 的 struct 是定长的(所有标量字段内联),变长数据(string、list)通过 pointer 引用。
二、速度:不要只看 encode/decode
测试数据:一个典型的交易订单消息,包含 5 个标量字段 + 1 个含 3 个元素的嵌套列表。总数据量约 200 字节。
编码速度
| 操作 | protobuf | FlatBuffers | Cap’n Proto |
|---|---|---|---|
| 编码 (ns) | 280 | 350 | 120 |
| 解码 (ns) | 320 | 0 | 0 |
| 读取单字段 (ns) | 2 (解码后) | 8 (vtable lookup) | 4 (fixed offset) |
| 编码大小 (bytes) | 98 | 248 | 192 |
FlatBuffers 和 Cap’n Proto 的解码时间是 0——因为它们不需要解码。但注意编码大小:FlatBuffers 的编码体积是 protobuf 的 2.5 倍。这在网络传输中意味着更多的带宽消耗。
Cap’n Proto 的编码速度最快(120 ns),因为它的 struct 是固定布局的,编码就是填固定偏移量,没有 varint 编码、没有 TLV。
读取单字段
这是一个经常被忽略的指标。
protobuf 解码后是语言原生结构体,读取一个字段是一次内存访问(可能已经在 cache 里)。
FlatBuffers 每次读字段需要查 vtable(间接跳转),然后按偏移量读数据。两次内存访问。如果 vtable 和数据不在同一个 cache line 里,可能触发 cache miss。
在大多数实际应用中,你不是编码/解码一次就完了——你会反复读取同一条消息的不同字段。如果读取次数超过 50 次,protobuf 的”解码一次 + 快速读取”策略反而比 FlatBuffers 的”每次读都查 vtable”更快。
什么时候 zero-copy 真的有用
- 收到消息只看几个字段就转发 – 路由、过滤、日志前置。不需要解码整条消息。
- mmap 文件直接当数据库用 – 不需要 decode 整个文件,直接按偏移量读取记录。
- 消息体非常大(>10KB) – 解码 10KB 的 protobuf 需要分配内存 + 逐字段解码,zero-copy 只需要验证一次。
大多数 RPC 场景中,消息体 <1KB,字段会被全部读取。这时 protobuf 的总体性能(encode + decode + read all fields)通常和 FlatBuffers 差不多,甚至更好。
三、Schema Evolution:真正的杀手
序列化格式一旦用了,你的数据可能在磁盘上存十年,在消息队列里存三天,在不同版本的服务之间传递。schema 变更的兼容性决定了你的运维痛苦指数。
protobuf
- 添加字段:安全。新字段有默认值,旧代码忽略它。
- 删除字段:安全。用
reserved标记避免字段号复用。 - 改字段类型:不安全。int32 改 int64 可以(wire format 兼容),但 string 改 int 不行。
- 改字段号:绝对不要做。wire format 用字段号定位。
protobuf 的 schema evolution 是经过 Google 十几年生产检验的。规则简单,陷阱少。
FlatBuffers
- 添加字段:安全,但只能加在 table 末尾。vtable 用偏移量定位,新字段在新位置。
- 删除字段:标记为 deprecated,不能移除(vtable 偏移量不能变)。
- 改字段类型:极度危险。zero-copy 意味着数据直接按偏移量和类型解释,改类型 = 读到垃圾。
FlatBuffers 的 zero-copy 设计使得 schema evolution 比 protobuf 严格得多。一旦你定义了字段顺序,就不能改。
Cap’n Proto
- 添加字段:安全。和 protobuf 类似,用字段编号定位。
- 删除字段:安全。旧编号保留不复用。
- union 演化:比 protobuf 的 oneof 更灵活(可以添加新变体)。
Cap’n Proto 在 schema evolution 上和 protobuf 接近,同时保持 zero-copy。这是它相比 FlatBuffers 最大的优势。
对比总结
| 操作 | protobuf | FlatBuffers | Cap’n Proto |
|---|---|---|---|
| 添加字段 | 安全 | 只能加末尾 | 安全 |
| 删除字段 | 安全 (reserved) | 标记 deprecated | 安全 |
| 改字段类型 | 部分安全 | 极度危险 | 部分安全 |
| 重排字段 | 不影响 | 破坏兼容性 | 不影响 |
| 嵌套消息演化 | 独立演化 | 独立演化 | 独立演化 |
四、工程代价:你愿意付多少
生成代码质量
protobuf:生成的代码可读、可 debug。每个 message 是一个类/结构体,字段是有名字的成员。大多数 IDE 支持跳转到定义。
FlatBuffers:生成的代码充满了
unsafe(Rust/C++)或类型转换(Java)。因为
zero-copy 本质上是在裸内存上做指针运算。debug
时看到的不是字段值而是 buffer 偏移量。
Cap’n Proto:介于两者之间。有安全的访问接口,但底层仍然是偏移量计算。
生态和工具链
| 维度 | protobuf | FlatBuffers | Cap’n Proto |
|---|---|---|---|
| 语言支持 | 12+ 官方 | 15+ 官方 | 7(C++/Rust/Go/Java/…) |
| gRPC 集成 | 原生 | 无 | 有(Cap’n Proto RPC) |
| JSON 互转 | 原生 | 需手写 | 需手写 |
| 文档/社区 | 最大 | 中等 | 较小 |
| 学习曲线 | 低 | 中 | 中 |
Debug 体验
protobuf 有 protoc --decode 和各种 JSON
互转工具。你可以 grpcurl 直接调 gRPC
服务看返回值。
FlatBuffers 的二进制数据不能直接 decode 成可读格式(没有自描述性),你需要 schema 文件才能解析。在生产环境 debug 时这是一个巨大的痛点。
五、选型建议
你的消息大小是多少?
├── < 1KB (大多数 RPC)
│ └── protobuf。生态最好,schema evolution 最安全。
├── 1KB - 100KB
│ ├── 需要零拷贝读取少量字段?
│ │ └── Cap'n Proto。zero-copy + 安全 evolution。
│ └── 需要读取大部分字段?
│ └── protobuf。decode 一次,后续读取最快。
└── > 100KB 或 mmap 场景
└── FlatBuffers 或 Cap'n Proto。
不 decode 整个消息是刚需。
你需要 gRPC?
├── 是 -> protobuf。没有替代品。
└── 否 -> 按消息大小选。
如果你不确定,用 protobuf。 它不是最快的,但它是工程代价最小、演化最安全、生态最好的选择。只有当你用 profiler 证明了序列化是瓶颈时,才值得换。
延伸阅读:
- SQLite 是怎么做到”十亿行每秒”的 – SQLite 的 record format 是自己设计的紧凑编码
- 一致性哈希可能还不如随机 – 另一个”听起来先进但实际要看场景”的技术
参考资料:
- Protocol Buffers Encoding – protobuf wire format 详解
- FlatBuffers Internals – vtable 和内存布局
- Varda, K. Cap’n Proto – 设计文档和性能分析