Hotdry.
systems

用 Rust FFI 替换 Protobuf:PgDog 序列化性能优化实践

剖析 PgDog 如何通过直接 C-Rust 绑定替换 Protobuf 序列化,实现 5 倍解析性能提升的工程路径。

PgDog 是一个面向 PostgreSQL 的水平扩展代理,其核心职责是解析和理解 SQL 查询以实现自动分片等功能。在技术选型上,PgDog 采用 Rust 编写,通过 libpg_query C 库与 PostgreSQL 解析器交互。最初的设计使用 Protobuf 作为 Rust 与 C 之间的序列化层,这一选择保证了跨语言绑定的一致性,却在性能敏感的代理场景中造成了显著的开销。本文深入剖析 PgDog 团队如何通过直接 C-to-Rust 绑定替换 Protobuf,最终实现解析性能提升 5 倍、去解析性能提升近 10 倍的工程实践。

性能瓶颈的发现:采样分析定位热点

任何性能优化工作的起点都应该是精确的瓶颈定位。PgDog 团队使用 samply 作为采样分析工具,该工具与 Firefox profiler 深度集成,能够通过每秒数千次的调用栈采样,高频统计各函数消耗的 CPU 时间比例。这种方法不依赖代码插桩,对运行时性能影响极小,非常适合在线服务的性能诊断。

通过 samply 生成的火焰图,团队定位到 pg_query_parse_protobuf 函数占据了解析流程的绝大部分时间。这个函数是所有 pg_query 绑定的统一入口,但真正执行 PostgreSQL 语法解析的 pg_query_raw_parse 在火焰图中几乎不可见。这一发现颠覆了直觉:SQL 解析本身经过数十年优化已经非常高效,真正的瓶颈在于 Protobuf 序列化与反序列化过程。

火焰图的参数视图清晰地展示了 Protobuf 序列化在各类型节点上的开销分布。对于一个需要处理大量并发查询的代理服务而言,这种固定开销会在高 QPS 场景下被急剧放大。团队意识到,单纯优化解析算法或缓存查询结果无法从根本上解决问题,必须从序列化层的架构层面寻求突破。

缓存策略的尝试与局限性

面对已识别的高开销环节,团队首先尝试了缓存方案。这一策略的核心思想是用空间换时间:将解析后的抽象语法树缓存于内存中,避免对相同结构查询的重复解析。具体实现采用 LRU 淘汰策略的哈希表作为缓存后端,使用查询文本本身作为键,解析结果作为值。

缓存方案在 prepared statement 场景下效果显著。多数应用程序使用参数化查询,查询模板如 SELECT * FROM users WHERE id = $1 保持不变,仅参数值变化。这意味着相同的查询模板可以复用缓存的 AST,大幅降低解析频率。初步测试显示缓存命中率较高,性能有可观提升。

然而,缓存策略很快遇到两类难以回避的边界情况。第一类是 ORM 工具的缺陷实现:某些 ORM 会生成大量结构独特的查询,例如将 value IN ($1, $2, $3) 展开为独立参数而非使用 value = ANY($1) 数组语法,导致相同业务含义的查询产生数千种文本变体,缓存失去意义。第二类是老旧客户端驱动的不兼容问题:Python 的 psycopg2 等广泛使用的驱动不支持预处理语句,所有查询都以完整文本形式发送,缓存命中率急剧下降。

这些边界情况暴露了纯缓存方案的脆弱性:它无法覆盖所有真实工作负载,且在缓存失效时仍需承担完整的序列化开销。团队意识到,必须从根本上消除 Protobuf 序列化这一固定开销源,才能获得稳定可靠的性能收益。

直接绑定的技术方案:bindgen 与 AI 辅助代码生成

确定目标后,团队着手将 Protobuf 序列化层替换为直接的 C-Rust 绑定。这一工作的核心挑战在于:PostgreSQL 的 AST 结构极其复杂,包含数百种节点类型,类型间的层级关系和指针引用错综复杂,手工编写完整的转换代码既耗时又容易出错。

方案利用了三个有利条件作为实施基础。其一,pg_query 本身提供了完整的 Protobuf 规范文档,Claude 能够从中提取所有结构定义和预期数据类型。其二,pg_query.rs 已集成 bindgen 工具,只需调整配置即可将 AST 结构体纳入 bindgen 的输出范围。其三,原有的 Protobuf 实现提供了完整的参考实现和测试用例,可作为新实现的验证基准。

团队采用 AI 辅助的方式完成绑定代码生成。初始提示简洁明确:要求 Claude 将 Protobuf 替换为 bindgen 生成的 Rust 结构体,实现与 Postgres AST 的直接映射。经过两天的迭代调优,Claude 生成了约 6000 行递归 Rust 代码,涵盖 parsedeparsefingerprintscan 四个核心方法。这四个方法在 PgDog 的分片、查询重写等关键路径中高频调用,优化收益可直接传导至整体系统性能。

实现细节上,转换代码使用 unsafe Rust 函数包装 C 结构体指针,将 C 类型直接传递给 libpg_query API 完成 AST 构造。返回时采用递归算法遍历 AST 树:每个节点对应一个转换函数,接收不安全的 C 指针并返回安全的 Rust 结构体。AST 以数组形式存储在内存中,递归算法逐个处理列表元素,对包含子节点的节点递归调用转换函数,直至抵达叶子节点。

对于标量类型数据如整数和字符串,算法直接复制到对应的 Rust 类型。对于复合节点,算法首先检查指针是否为 null(防御性编程),然后根据节点标签匹配具体的结构体类型,将 C 指针转换为 Rust 可访问的结构体实例。这种模式在数百种 SQL 语法节点上重复执行,最终组合成完整的 Rust AST 表示。

机器可验证的测试策略与迭代优化

代码生成过程中最关键的一环是测试验证。团队利用 Rust 的 PartialEq 派生 trait,将 Protobuf 实现与直接绑定实现的输出进行字节级比较。对于每个测试用例,同时调用 parseparse_raw,若两者结果存在任何差异,Claude Code 必须重新生成代码直至完全一致。

这种机器可验证的测试策略带来了双重保障。一方面,它确保了直接绑定实现的语义正确性不会劣于原有实现,避免了优化反而引入 bug 的风险。另一方面,它为 AI 代码生成提供了清晰的收敛标准:测试通过即表示代码可用,无需人工逐行审查数千行递归转换代码的正确性。

团队还尝试将递归算法改写为迭代实现以规避潜在的栈溢出风险,但测试结果显示迭代版本反而更慢。深入分析表明,迭代版本引入了额外的内存分配、哈希表查找和多轮树遍历开销。而递归算法每个 AST 节点恰好处理一次,完全利用程序启动时分配的栈空间,CPU 缓存局部性也更优 —— 同一函数的连续调用使指令已预加载至 L1/L2 缓存。这一结果印证了递归在树结构转换场景中的天然优势。

性能收益与工程启示

切换至直接绑定后的性能提升数据如下:解析吞吐量从每秒 613 次提升至 3357 次,提升幅度达 5.45 倍;去解析(将 AST 还原为 SQL 字符串)从每秒 759 次提升至 7319 次,提升近 10 倍。在 PgDog 的整体 pgbench 基准测试中,这一改动直接带来了约 25% 的性能提升。

从工程角度看,这一案例提供了若干可复用的经验。首先,跨语言绑定的序列化层开销在高频调用场景下可能成为主要瓶颈,尤其是当被调用方本身已高度优化时。其次,缓存策略虽然有效,但存在固有的覆盖面限制,无法替代对根本开销的消除。第三,AI 辅助代码生成在结构化、机器可验证的任务中表现出色,6000 行复杂代码的生成和调优在两天内完成。最后,递归算法在树结构转换中仍有独特价值,不应因对栈溢出的过度担忧而盲目采用迭代方案。

对于构建高性能数据库代理或中间件的团队而言,PgDog 的实践表明:精心设计的 FFI 边界可以直接传递 C 库的性能优势,同时保持 Rust 侧的安全性和表达能力。这种零拷贝设计思路值得在类似场景中借鉴。


参考资料

查看归档