Hotdry.
systems

用直接 FFI 替换 Protobuf:为 PgDog 赢得 5 倍解析性能

PgDog 在优化 PostgreSQL 代理性能时发现,Protobuf 序列化层成为瓶颈。通过 bindgen 直接 FFI 连接 C 库,解析性能提升 5.45 倍,反解析提升近 10 倍。

高性能数据库代理的优化往往发生在意想不到的地方。PgDog 是一个用 Rust 编写的 PostgreSQL 代理,用于连接池管理、负载均衡和查询路由。在追求更高吞吐量的过程中,开发团队发现真正的瓶颈并非数据库交互本身,而是 Protobuf 序列化层带来的开销。这个发现促使他们重新思考 Rust 与 C 库交互的最佳方式,并最终通过直接 FFI 实现了数量级的性能跃升。

问题的发现:火焰图上的异常热点

任何性能优化工作的起点都是找到真正的瓶颈所在。PgDog 团队使用 samply 进行性能分析,这是一款与 Firefox 剖析器深度集成的采样剖析工具。samply 通过每秒数千次采样 CPU 指令执行情况,精确统计每个函数(或 spans)的耗时占比。耗时越长的代码段,在火焰图上占据的面积就越大,也就越值得优化。

剖析结果显示,pg_query_parse_protobuf 函数占据了显著的 CPU 时间。这个函数是所有 pg_query 绑定库的入口点,负责调用底层 Postgres 解析器。真正执行语法分析的 pg_query_raw_parse 反而几乎不占用 CPU 资源。这个发现令人惊讶:Postgres 解析器经过数十年优化已经极其高效,真正的开销来自 Protobuf 的序列化和反序列化过程。

更具体地说,当 Rust 代码需要与 C 库 libpg_query 交互时,数据必须经过 Protobuf 格式的转换。查询语句需要编码成 Protobuf 消息才能传递给 C 库,解析结果又需要从 Protobuf 解码回 Rust 结构体。这种转换在单次查询中发生两次:一次用于解析,一次用于将 AST 反解析回 SQL 字符串。虽然 Protobuf 本身是高效的序列化格式,但在高性能场景下,任何不必要的转换都是可以消除的开销。

第一次尝试:缓存策略

面对发现的热点,团队的第一反应是保守治疗:如果解析本身很快,那为什么不让结果被多次复用呢?他们实现了一个基于 LRU 算法的缓存层,使用互斥锁保护,底层是哈希表存储。缓存的键是查询语句文本,值是解析后的抽象语法树(AST)。

这个方案符合大多数使用场景的预期。应用程序通常使用预编译语句(prepared statements),查询模板是固定的,只有参数值会变化。例如 SELECT * FROM users WHERE id = $1 这类查询,无论实际传入什么 id 值,AST 结构都是相同的。通过缓存,可以完全跳过解析过程,直接返回预先计算好的结果。

然而,缓存策略在某些场景下表现不佳。一些 ORM 工具存在缺陷,会生成大量唯一的查询语句。例如本应使用 value = ANY($1) 的简单 IN 查询,却可能被错误地展开为 value IN ($1, $2, $3, $4, ...),导致每个查询都有独特的模板,缓存命中率急剧下降。此外,某些老旧的 PostgreSQL 客户端驱动不支持预编译语句,也享受不到缓存的收益。

缓存有效,但不足以彻底解决问题。团队意识到,必须从根本上去除 Protobuf 序列化层,才能释放 Rust 代码的真正性能潜力。

根本解法:直接 FFI 而非 Protobuf

pg_query.rs 库最初设计为多语言兼容的绑定方案。Protobuf 提供了一种统一的数据交换格式,使得不同编程语言的客户端都能以相同方式调用 libpg_query。Ruby 的 pg_query gem 也使用同样的 Protobuf 接口,保证了跨语言的一致性。

但 PgDog 的场景不同。它是一个单一语言的 Rust 应用,只需要与 C 库直接交互,不需要跨语言的互操作性。因此,团队决定 fork pg_query.rs,用直接 FFI 替换 Protobuf 方案。

实现方式是使用 bindgen 工具自动生成 Rust 到 C 的绑定代码。bindgen 解析 C 头文件,生成安全的 Rust FFI 包装器,避免了手动编写 FFI 代码的繁琐和易错。团队还借助 Claude(LLM)辅助生成了部分包装器代码,加快了开发进度。

关键变化在于数据流向的改变。原来的流程是:Rust 字符串 → Protobuf 编码 → C 库调用 → Protobuf 解码 → Rust AST。新的流程是:Rust 字符串 → 直接内存传递 → C 库调用 → 直接返回结果 → Rust AST。中间不再有任何序列化 / 反序列化步骤,数据以原始内存形式在两种语言之间传递。

性能结果与工程启示

优化后的 benchmark 结果令人印象深刻。在 parse 操作中,pg_query::parse(Protobuf 版本)每秒处理 613 个查询,而 pg_query::parse_raw(直接 FFI 版本)每秒处理 3357 个查询,性能提升 5.45 倍。在 deparse 操作中,性能提升更为显著:从每秒 759 个查询提升到 7319 个查询,达到 9.64 倍。

这些数字代表了生产级别的性能跃升。对于每秒需要处理数万条查询的 PgDog 实例来说,5 倍的解析性能提升意味着 CPU 利用率的显著下降,或者相同硬件条件下更大的吞吐能力。

这个案例揭示了几个重要的工程原则。首先,抽象层次并非越多越好。Protobuf 是优秀的跨语言序列化方案,但它增加了不必要的中间层。在单一语言场景下,直接 FFI 是更自然的选择。其次,性能优化应该基于数据而非直觉。samply 的火焰图清晰地指向了 Protobuf 编码 / 解码函数,如果凭直觉优化解析器本身,只会徒劳无功。第三,渐进式优化是务实的策略。团队先尝试了缓存方案,这个方案实现简单、风险低,在大多数场景下有效。当缓存无法覆盖全部场景时,再进行更根本的重构。

对于 Rust 生态系统而言,这个案例也说明了 Rust 的性能优势来源于对底层资源的直接控制,而非某种神秘的语言魔法。Rust 本身并不比 C 更快,但 Rust 的零成本抽象允许开发者写出安全且高效的代码,同时保留手动优化的空间。PgDog 团队正是利用了这一点,通过直接操作内存和系统调用,消除了 Protobuf 带来的抽象税。

实际应用中的参数建议

如果你的项目也面临类似的 C-Rust 互操作性能问题,以下参数和策略可供参考。使用 bindgen 时,确保在 build.rs 中正确配置 CFLAGS 环境变量,以便 bindgen 正确解析头文件路径。对于需要频繁调用的 C 函数,考虑使用 #[inline] 属性或在 C 代码中使用 static inline,减少函数调用开销。内存对齐也是重要考量,确保 Rust 和 C 结构体的内存布局一致,避免不必要的数据拷贝。

在 PgDog 的 benchmark 中,测试查询是中等复杂度的 SQL 语句,包含 JOIN 和 WHERE 子句。对于极简单或极复杂的查询,性能提升比例可能有所不同。建议在生产环境中使用真实的查询样本进行基准测试,而不是使用人工构造的测试用例。

序列化方案的选择应当匹配实际需求。Protobuf 仍然是跨语言 API 交互的优秀选择,但它不适合作为同一进程内 FFI 调用的数据格式。如果你确定只需要 Rust 与 C/C++ 交互,直接 FFI 是性能最优解。如果项目需要未来支持其他语言,或者数据需要通过网络传输,保留 Protobuf 层则是更明智的权衡。


参考资料

查看归档