Hotdry.
systems

用 Rust FFI 替换 Protobuf:PgDog 的五倍解析性能优化实践

PgDog 通过直接 C-to-Rust 绑定替换 Protobuf 序列化,实现了 5.45 倍的查询解析加速。本文剖析从性能定位到 AI 辅助代码生成的完整工程路径。

在数据库中间件的竞争格局中,延迟往往决定了产品的生死。PgDog 是一个用 Rust 编写的 PostgreSQL 水平扩展代理,它的核心职责是解析 SQL 查询、理解其语义,然后根据分片规则将请求路由到正确的后端实例。这个看似简单的流程背后,隐藏着一个被 Protobuf 序列化拖慢的性能瓶颈。本文将从性能剖析出发,逐步拆解 PgDog 团队如何用直接 FFI 绑定替换 Protobuf,最终实现五倍以上的解析加速。

跨语言兼容的代价:Protobuf 的隐性开销

PgDog 的解析能力来源于 libpg_query,这是一个封装了 PostgreSQL 官方解析器的 C 库。为了在 Rust 项目中使用这个 C 库,PgDog 最初采用了 pg_query.rs 这个官方维护的 Rust 绑定。这个绑定设计精良,支持多种编程语言,Ruby 的 pg_query gem 也使用同一套实现。然而,这种跨语言兼容性是有代价的:它依赖 Protobuf 来完成 C 与 Rust 之间的数据序列化与反序列化。

具体来说,当 PgDog 调用 pg_query::parse 函数时,底层发生了以下步骤:首先,Rust 代码将查询文本传递给 C 侧的 pg_query_parse_protobuf 函数;接着,C 函数返回一个 Protobuf 格式的字节流;然后,Rust 侧的 Prost 库将这些字节反序列化为 Rust 结构体;最后,PgDog 才能获得完整的抽象语法树(AST)用于后续的路由决策。这套流程在功能上无可挑剔,但在高频调用的场景下,序列化和反序列化带来的 CPU 开销开始变得刺眼。

定位瓶颈:samply 火焰图与缓存方案的局限性

性能优化的第一步永远是找到真正的瓶颈所在。PgDog 团队使用了 samply 这款采样分析器,它与 Firefox 性能分析器深度集成,能够精确显示每个函数在 CPU 执行时间中的占比。火焰图清晰地揭示了一个关键事实:pg_query_parse_protobuf 是最热的函数入口点,而真正执行语法解析的 pg_query_raw_parse 几乎在图中不可见。这意味着问题的根源不在于 PostgreSQL 解析器本身,而在于 Protobuf 的编解码开销。

面对这一发现,团队的第一反应是引入缓存机制。这是一个符合直觉的方案:SQL 查询文本作为键,解析后的 AST 作为值,相同的查询可以直接命中缓存,省去重复解析的成本。PgDog 实现了一个基于哈希表的 LRU 缓存,使用互斥锁保证线程安全。对于使用预编译语句的应用场景,这个方案效果显著,因为占位符查询的文本是稳定的,可以被反复复用。

然而,缓存策略很快遇到了两个现实的挑战。某些 ORM 框架存在缺陷,它们倾向于生成大量唯一语句,例如将简单的 IN 查询展开为 value IN ($1, $2, $3, $4),导致同一个模板生成成百上千个不同的查询文本,缓存命中率急剧下降。另一个问题是老旧的 PostgreSQL 客户端驱动不支持预编译语句,最典型的例子是 Python 的 psycopg2 早期版本,它们每次都发送完整的查询文本,缓存完全失效。缓存无法根治问题,团队开始寻找更根本的解决方案。

替换 Protobuf:直接 C-to-Rust 绑定的实现路径

既然 Protobuf 是瓶颈,最直接的思路就是绕过它。PgDog 团队决定用纯 Rust 代码直接与 libpg_query 的 C API 交互,省去中间的序列化步骤。这个方案的关键在于正确地将 C 侧的结构体映射到 Rust 侧。

技术实现的核心挑战在于 AST 的数据结构转换。PostgreSQL 的解析器内部使用 C 结构体来表示语法树,这些结构体包含指针、变体标签和嵌套的节点列表。要在 Rust 中使用这些数据,必须精确了解每个结构体的内存布局,包括字段偏移量、类型定义和对齐方式。bindgen 工具可以自动从 C 头文件生成 Rust FFI 绑定,但这只解决了一半的问题 —— 拿到了类型定义,还需要手写递归转换逻辑,将 C 侧的指针和结构体转换为内存安全的 Rust 结构。

PgDog 团队采用了一个务实的方法:利用 Claude(一个大语言模型)来生成这六千行映射代码。提示词非常直接,要求模型根据 pg_query.rs 现有的 Protobuf 定义,用 bindgen 生成的 Rust 结构体替换掉 Protobuf 序列化层。模型的输出需要满足一个严格的机器可验证标准:对于每个测试用例,parse(Protobuf)和 parse_raw(直接 FFI)的输出必须逐字节完全一致。这种自动化验证机制极大地降低了人工审查的负担,模型可以反复迭代,直到所有测试通过。

生成的实现大量使用了 unsafe Rust 代码。转换函数接收来自 C 侧的裸指针,将其解引用为对应的 C 结构体,然后根据节点类型标签分发到具体的转换函数。对于包含子节点的复杂结构,递归调用继续向下遍历,直到抵达叶子节点 —— 那些只包含标量值(如整数、字符串)的最底层结构。整个过程没有额外的内存分配,全部工作空间就是调用栈,栈空间在程序启动时就已经分配就绪。

性能收益:从五倍加速到渐进式迁移策略

迁移完成后的基准测试结果令人振奋。在解析操作(parse)中,Protobuf 版本的吞吐量是每秒 613 个查询,而直接 FFI 版本达到了 3357 个查询,提升幅度达到 5.45 倍。更引人注目的是反序列化(deparse)操作,即从 AST 重新生成 SQL 字符串的逆过程,Protobuf 版本为每秒 759 个查询,直接 FFI 版本达到每秒 7319 个查询,加速比高达 9.64 倍。这些数据表明,Protobuf 在这个场景下的开销占比远超预期。

PgDog 随后在真实的 pgbench 基准测试中验证了这一改进,整体性能提升了约 25%。对于一个位于数据库前端的代理服务来说,这意味着更低的 CPU 占用率和更高的吞吐量上限,直接转化为运营成本的节约。

工程迁移策略同样值得借鉴。PgDog 没有选择一次性替换所有接口,而是保留了原有的 parsedeparse 函数(它们仍然使用 Protobuf,主要服务于跨语言绑定生态),同时新增了 parse_rawdeparse_raw 作为直接 FFI 的入口。这种渐进式切换允许内部代码逐步迁移到新接口,同时不影响外部依赖(如 Ruby gem)的正常使用。这种设计思想在大型系统的性能改造中尤为关键,它降低了风险,提高了可观测性。

递归遍历与迭代实现的性能对比

在 AST 转换的实现细节上,PgDog 团队还进行了一次有趣的对比实验。他们尝试用迭代算法重写转换逻辑,结果发现迭代版本反而比递归版本慢。团队分析认为,迭代实现引入了不必要的内存分配(如动态数组和哈希表用于记录已访问节点的状态),以及多次遍历树结构的额外开销。相比之下,递归算法只需要一次遍历,CPU 缓存的局部性也更好 —— 连续调用同一函数时,后续指令很可能已经驻留在 L1 或 L2 缓存中。

这个发现提醒我们,在处理树形结构时,递归往往不是「优雅但危险」的过度设计,而是经过验证的高效选择。当然,前提是树的深度不会超出栈空间的承受能力。对于深度可控的 SQL AST(通常不会超过几十层嵌套),递归是合理的默认选项。

局限性:维护成本与内存安全的权衡

这项优化并非没有代价。首先,六千行手写的映射代码增加了维护负担。PostgreSQL 的语法树有上百种节点类型,每一次语言特性的更新都可能引入新的节点结构,需要同步更新 Rust 侧的转换代码。虽然 AI 可以在一定程度上辅助生成新代码,但人工审查和测试仍然是不可或缺的环节。其次,直接 FFI 意味着彻底放弃 Rust 的内存安全保证,所有指针操作都必须在 unsafe 块中谨慎进行,任何空指针解引用或悬垂指针都可能导致未定义行为。

尽管存在这些局限性,对于 PgDog 这样对性能有极致追求的项目来说,这个权衡是值得的。项目在 GitHub 上开源了修改后的 pg_query.rs 绑定,供其他有类似需求的开发者参考。

工程启示:性能优化的系统方法论

回顾整个优化历程,有几个值得沉淀的工程经验。第一,缓存不是万能药方,只有当输入存在高度重复性时,缓存才能发挥最大价值;对于输入分布不可控的场景,必须从根本算法上寻找突破。第二,大语言模型在处理有明确输入输出规范、且可自动化验证的编程任务时,效率惊人;六千行代码两天完成,且通过严格的测试验证,这在传统开发流程中难以想象。第三,渐进式迁移是大型系统改造的标配策略,保留向后兼容的接口可以显著降低风险,提高可观测性。

资料来源:PgDog 官方博客(https://pgdog.dev/blog/replace-protobuf-with-rust)、PgDog GitHub 仓库(https://github.com/pgdogdev/pg_query.rs)。

查看归档