跳转到内容

Capn Proto — 数据布局即 wire format 的零拷贝序列化 + RPC

待复核

Cap’n Proto 是一套让数据在内存里长什么样、在网线上传出去就长什么样的二进制格式,再外加一个调用还没回来就能用『未来结果』继续调用的 RPC 系统。日常类比:

  • 普通序列化(JSON / Protobuf)像搬家打包——每件东西要装箱(serialize)、卸车要拆箱(deserialize)
  • Cap’n Proto 像集装箱标准化——东西从一开始就放在标准箱里,搬到哪儿都是直接读箱子,不开箱

举个具体的:

struct Person {
name @0 :Text;
age @1 :UInt16;
friends @2 :List(Person);
}

C++ 里读这个 buffer:

auto person = msg.getRoot<Person>();
auto name = person.getName(); // 直接指向 buffer 内偏移,没有 memcpy

getName() 不分配内存、不解码——它就是个带类型的指针运算

不理解 Cap’n Proto,下面这些事就解释不了:

  • 为什么 Cloudflare Workers 跨 isolate 调用比”绕一圈 JSON”快几个数量级
  • 为什么”零拷贝”在网络协议圈被反复吹,到底省了哪一步
  • 为什么 RPC 老问题(多跳串行 = 多次 RTT)有比 batch API 更优雅的解
  • 为什么 schema 演化比想象的难——Cap’n Proto 严格到能让你切身感受到

Cap’n Proto 的两条主线:wire formatRPC

  1. 内存布局 = wire format:没有 varint(Protobuf 那种”小数字省字节”的编码),所有字段对齐到 8 字节边界。指针字段存的是相对偏移,运行库做边界检查后用指针运算访问字段。代价:占空间稍多;收益:省掉逐字段解码成新对象的步骤

  2. Schema 编译生成绑定:写 .capnp 文件 → capnp compile → 生成 C++ / Rust / TS / Go / Python 头文件,类型安全、字段 ID 显式(@0 / @1)。

  3. Promise pipelining(时间旅行):客户端调 a = server.foo()不等结果就调 b = a.bar()。两次调用打包成一个 RPC 请求一次发出去,服务端在 foo 返回的瞬间立即用结果调 bar。N 次串行调用从 N 个 RTT 压成 1 个。

三块加起来:序列化几乎免费 + 多跳 RPC 几乎免费。

案例 1:Cloudflare Workers 跨 isolate 调用

Section titled “案例 1:Cloudflare Workers 跨 isolate 调用”

Workers 里你写:

const result = await env.MY_KV.get("key");

底层不是传统 HTTP/JSON,而是大量使用 Cap’n Proto RPC 和 capability 模型。请求方把”调用 KV 的 get 方法”打包成二进制消息,跨 isolate 边界交给目标服务;运行时省掉 JSON parse 和手写路由胶水。注意:跨 isolate 仍有调度、权限检查和必要拷贝,不是纳秒魔法,优势在于热路径少做无用转换。

把数据库行序列化成 Cap’n Proto 写文件:

capnp::MallocMessageBuilder msg;
auto user = msg.initRoot<User>();
user.setName("Jason");
user.setAge(25);
writeMessageToFd(fd, msg);

重启进程时读取这个文件,不用再转成 JSON / dict 中间对象

capnp::PackedFdMessageReader reader(fd);
auto user = reader.getRoot<User>();
// 读 user.getName() 时由 reader 按 Cap'n Proto 布局取字段

如果想做真正的 mmap 直读,文件要按未 packed 的 flat array 布局保存,再配合 FlatArrayMessageReader;packed 格式要先解包。工程收益通常来自”按需读字段、少建对象”,具体能快多少取决于缓存大小和访问模式。

服务器在 100 个房间之间转发玩家状态。状态结构体定义在 .capnp,每帧产生一个消息。转发层如果只看少数字段(例如房间号、版本号),可以不把整包解成 JSON 对象;需要改字段时再进入 builder。这样能把 CPU 花在游戏逻辑上,而不是花在重复编码 / 解码上。

  1. Schema 演化比 Protobuf 严格:字段 ID(@N)一旦发布绝不能改,旧编号也不能复用;新增字段要用新的编号,文本位置可以调整但兼容性只看编号。Protobuf 还允许”重命名 + 同 ID 继续兼容”,Cap’n Proto 这条更死板。

  2. “零拷贝”是营销词:反序列化省了,但跨进程/跨网络该拷贝还是拷贝。如果你以为 Cap’n Proto 让网络传输也变零拷贝,会失望。

  3. RPC 生态远不如 gRPCcapnp-rpc 在 C++ / Rust 上能用,TS / Go / Python 客户端质量参差。生产上很多团队只用 wire format,RPC 自己包一层 HTTP。

  4. Promise pipelining 调试难:错误堆栈跨好几个未完成的 promise,定位”到底哪一跳挂了”比同步调用难得多。新人常被绕晕。

适用

  • 同机进程间 / 跨 isolate 高频通信(Cloudflare Workers 是教科书案例)
  • 需要把序列化成本压到接近零的热路径(游戏、HFT、实时分析)
  • 有”先调 A、用 A 的结果调 B”这种链式 RPC 模式
  • 数据持久化到磁盘需要快速冷启动加载(mmap 直读)

不适用

  • 需要人类可读 / curl 能调 → 用 JSON
  • 需要丰富的工具链 / 大量第三方库 / 多语言客户端齐全 → 用 Protobuf + gRPC
  • 数据量小、延迟不敏感的普通后端 API → 优化错地方了
  • schema 经常变、需要频繁字段重命名 → Cap’n Proto 死板会扎手
  • 2008 年前后:Kenton Varda 在 Google 主导 Protobuf v2,把 Protobuf 推成内部默认 RPC 格式
  • 2013 年:Varda 离职做 Sandstorm(个人服务器平台),实现时嫌 Protobuf 解码太慢,造了 Cap’n Proto
  • 2017 年:Sandstorm 商业化失败,Varda 加入 Cloudflare
  • 2018 年:Cloudflare Workers 上线,跨 isolate 通信底层就是 Cap’n Proto
  • 至今:Workers 的许多内部边界继续使用 Cap’n Proto——这套小众格式因为 Cloudflare 的规模,获得了远超普通开源库的实战压力测试
  1. “数据布局 = wire format” 是值得记住的设计哲学——只要能接受字段对齐的空间代价,就能换来”免费序列化”
  2. Promise pipelining 把『时间』当成可优化对象——这是同步调用永远做不到的事,本质上是让代码描述依赖图,让运行时压平 RTT
  3. Schema 严格度是双刃剑:演化困难是代价,但它换来的是”二进制兼容性可以靠编译器保证而不是靠口头约定”
  4. 架构决定生态:Cap’n Proto 技术上比 Protobuf 优雅,但 gRPC 的生态势能让它始终是小众工具
  • 官网首页有完整 wire format 图解:capnproto.org
  • 作者博文《Cap’n Proto: Infinity Times Faster》——营销梗的来源,但解释 RPC pipelining 很到位
  • Cloudflare 博客《How Workers Sandbox is Built》——讲 Cap’n Proto 在 isolate 间怎么用
  • grpc-go —— 对比对象:成熟生态 vs 极致性能
  • connect-rpc —— 现代 RPC 协议另一种思路(HTTP/JSON 友好)
  • grpc-go —— 同生态位竞品;gRPC 胜在工具链,Cap’n Proto 胜在零开销和 pipelining
  • thrift —— Facebook 出品的更老一辈跨语言 RPC,定位类似 Protobuf
  • quic —— 网络层面减少 RTT;Cap’n Proto 在应用层做同样的事(pipelining)
  • connect-rpc —— 让 gRPC 兼容 HTTP/JSON 的协议;与 Cap’n Proto 是相反方向的取舍
  • hewitt-actor-model —— Cap’n Proto 的 capability RPC 模型本质上是 actor 思想的工程化
  • raft —— 分布式系统底层;Cap’n Proto 常被用作 Raft 节点间的消息编码

(暂无反向链接)