土法炼钢兴趣小组的算法知识备份

序列化格式的真正代价:protobuf vs flatbuffers vs capnproto

目录

每次讨论序列化格式,都会有人贴一张 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 真的有用

  1. 收到消息只看几个字段就转发 – 路由、过滤、日志前置。不需要解码整条消息。
  2. mmap 文件直接当数据库用 – 不需要 decode 整个文件,直接按偏移量读取记录。
  3. 消息体非常大(>10KB) – 解码 10KB 的 protobuf 需要分配内存 + 逐字段解码,zero-copy 只需要验证一次。

大多数 RPC 场景中,消息体 <1KB,字段会被全部读取。这时 protobuf 的总体性能(encode + decode + read all fields)通常和 FlatBuffers 差不多,甚至更好。

三、Schema Evolution:真正的杀手

序列化格式一旦用了,你的数据可能在磁盘上存十年,在消息队列里存三天,在不同版本的服务之间传递。schema 变更的兼容性决定了你的运维痛苦指数。

protobuf

protobuf 的 schema evolution 是经过 Google 十几年生产检验的。规则简单,陷阱少。

FlatBuffers

FlatBuffers 的 zero-copy 设计使得 schema evolution 比 protobuf 严格得多。一旦你定义了字段顺序,就不能改。

Cap’n Proto

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 证明了序列化是瓶颈时,才值得换。


延伸阅读:

参考资料:

  1. Protocol Buffers Encoding – protobuf wire format 详解
  2. FlatBuffers Internals – vtable 和内存布局
  3. Varda, K. Cap’n Proto – 设计文档和性能分析

By .