找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2753

积分

0

好友

366

主题
发表于 14 小时前 | 查看: 1| 回复: 0

前两天,PgDog 团队发布了一篇技术博客,分享了一个有趣的性能优化案例。他们发现,在自家的 PostgreSQL 代理中,SQL 查询解析的性能瓶颈并非来自 PostgreSQL 解析器本身,而是源于一个看似“标准”的中间层——Protobuf 序列化。于是,他们做了一个大胆的决定:抛弃 Protobuf,直接使用 Rust 通过 FFI 绑定 C 代码,最终将解析速度提升了 5 倍以上。

一个代理为什么需要关注 SQL 解析?

PgDog 是一个用 Rust 编写的 PostgreSQL 代理,部署在应用程序与数据库之间。它的核心任务之一,就是接收 SQL 查询,将其解析为抽象语法树(AST),以便进行查询指纹生成、查询重写等操作,有时甚至需要将 AST 转换回 SQL 文本。

作为中间层代理,其延迟预算非常紧张,任何额外的开销都可能被最终用户感知到。PgDog 最初使用 pg_query.rs 库来完成解析工作,这个库底层调用的是 PostgreSQL 的原生解析器 libpg_query。然而,为了兼容 Ruby、Go 等其他语言(共享同一套结构定义),pg_query.rs 在 C 结构体和 Rust 结构体之间增加了一层 Protobuf 序列化。

Protobuf 本身很快,但在这个场景下,它需要先将 C 结构体序列化为字节流,再在 Rust 端反序列化为可用的结构体。PgDog 团队使用 samply 工具采样火焰图后发现,pg_query_parse_protobuf 这个函数占据了大量的 CPU 时间,而真正的 PostgreSQL 解析器调用 pg_query_raw_parse 几乎可以忽略不计。

打个比方,这就像让同事传一份电子文档,他不直接发送文件,而是先打印出来,再扫描成 PDF 发给你,效率自然低下。

缓存并非万能药

面对性能问题,增加缓存通常是第一反应。PgDog 团队实现了基于 LRU 的缓存,以查询文本为键,解析后的 AST 为值。在使用 Prepared Statement(预处理语句)的场景下,效果确实不错,因为查询文本固定,无需重复解析。

但缓存会在两种情况下失效:

  1. 某些 ORM(对象关系映射框架)会生成大量参数数量不等的查询,导致无法命中缓存。
  2. 一些老旧的客户端驱动可能根本不支持 Prepared Statement,每次都发送不同的 SQL 文本。

尽管缓存有所帮助,但火焰图显示,Protobuf 序列化/反序列化依然是主要的性能开销。

抛弃 Protobuf,拥抱直接 FFI 连接

既然瓶颈在于 Protobuf 这一层,PgDog 团队决定彻底移除它。他们 Fork 了 pg_query.rs 项目,将 Protobuf 序列化层替换为直接从 C 到 Rust 的绑定。具体步骤如下:

  1. 使用 bindgen 工具直接从 libpg_query 的 C 头文件生成对应的 Rust 结构体定义。
  2. 手动编写 unsafe 包装函数,将 C 语言的 AST 节点逐层转换为 Rust 结构体。
  3. 保留原有的 Protobuf 结构体路径,用于对比测试和验证。

这是一项相当枯燥的工作,需要编写约 6000 行的递归 Rust 代码,手动映射 C 类型到 Rust 类型。但这样做的好处是结果完全可验证:他们对每个测试用例同时运行新旧两种路径(parseparse_raw),确保输出的 AST 在字节级别完全一致。

在这个过程中,他们利用 AI 工具(如 Claude)生成了大量重复性的胶水代码,在给定明确规范的前提下,让 AI 处理这类可验证的重复劳动,效率提升明显。

为什么选择递归实现?

在转换 C 结构体到 Rust 结构体的过程中,PgDog 选择了递归的方式。因为 AST 本身就是一棵树,每个节点都可能包含子节点,遇到子节点时递归调用转换函数是非常自然的做法。

unsafe fn convert_node(node_ptr: *mut bindings_raw::Node) -> Option<protobuf::Node> {
    if node_ptr.is_null() {
        return None;
    }

    match (*node_ptr).type_ {
        bindings_raw::NodeTag_T_SelectStmt => {
            let stmt = node_ptr as *mut bindings_raw::SelectStmt;
            Some(protobuf::node::Node::SelectStmt(
                Box::new(convert_select_stmt(&*stmt))
            ))
        }
        // ... 还有上百种其他节点类型需要处理
        _ => None,
    }
}

在这个特定场景下,递归比迭代更快。栈空间在程序启动时就已分配,无需额外的堆分配;同一函数的指令可以很好地利用 CPU 缓存;并且递归写法更符合树形结构的直觉。他们尝试过迭代版本,但反而更慢,因为引入了额外的 HashMap 查找和内存分配开销。

性能数据对比

PgDog 在 GitHub 上公布了详细的基准测试结果,性能提升非常显著:

Protobuf 序列化开销与直接 FFI 连接性能对比图

功能 Protobuf 路径 直接 FFI 路径 提升倍数
解析 (parse) 613 查询/秒 3,357 查询/秒 5.45倍
反解析 (deparse) 759 查询/秒 7,319 查询/秒 9.64倍

在实际的 pgbench 测试中,整体吞吐量提升了约 25%。解析速度的提升,也减轻了后续查询重写引擎的压力,并使得缓存命中更加稳定。

实践中的挑战与经验

ABI 漂移问题:PostgreSQL 的升级可能会修改 AST 节点的结构定义。bindgen 生成的代码会随之改变,如果不锁定版本,升级数据库后可能导致编译失败。PgDog 的解决方案是在 CI 中固定 PostgreSQL 头文件的版本。

空指针安全:在 FFI 编程中,必须谨慎对待每一个从 C 端传入的指针。如果不检查 is_null() 就直接使用,极易引发问题。PgDog 的实践是尽量缩小 unsafe 代码块的范围,在块内首先验证指针,最终对外只暴露安全的 Rust 类型。

跨语言兼容性:如果团队中还有其他语言(如 Ruby、Go)的开发者依赖 Protobuf 结构定义,那么 Protobuf 的 schema 仍需保留。PgDog 所做的优化仅在其内部绕过了 Protobuf 流程。

值得借鉴的经验总结

PgDog 团队分享了几个实用的建议,对于进行类似优化很有参考价值:

  • 先测量,后优化:使用 samply 等性能剖析工具生成火焰图,而不是凭空猜测瓶颈所在。
  • 保持可验证性:保留旧路径作为对照,确保新旧实现的功能输出完全一致。
  • 锁定依赖版本:在 CI 中固定关键库(如 PostgreSQL 头文件)的版本,避免因依赖更新导致构建中断。
  • 提供回滚机制:通过特性标志(Feature Flag)或环境变量控制使用哪条路径,便于在出现问题时快速切换回稳定版本,而无需重新部署。
  • 隔离不安全代码:将 unsafe 代码块限制在最小范围,对外提供安全的 API 接口。

结论

这个案例揭示了性能优化的一个常见盲区:瓶颈往往不在你认为最可能的地方。在这个 PostgreSQL 代理的场景中,性能瓶颈不是数据库解析器,而是为了跨语言兼容而引入的 Protobuf 序列化层。在高频调用的场景下,每一层抽象都可能带来可观的开销。PgDog 团队为了极致性能,选择放弃这层便利,直接通过 FFI 连接 C 和 Rust ,取得了立竿见影的效果。

当然,这并不是说 Protobuf 不好。如果你的应用对解析性能不敏感,或者跨语言互操作性至关重要,那么 Protobuf 带来的微小开销完全在可接受范围内。工程本质上就是一系列的权衡与取舍。

这个实战案例展示了深入底层、直面复杂性所带来的性能收益。如果你也在开发数据库中间件或对性能有严苛要求的系统服务,或许可以从中获得一些启发。欢迎在 云栈社区 的技术论坛分享你在项目中遇到的那些“看似不起眼却影响巨大”的性能问题。




上一篇:Chrome浏览器集成Gemini 3:AI侧边栏与自动浏览如何重塑Web交互体验
下一篇:MCP vs Skill:深入解析Token成本,AI Agent工具协议的效率之争
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-4 23:12 , Processed in 0.343751 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表