重构 Postgres 分析速度提升 300 倍:批处理、操作符融合和 SIMD 优化揭秘
2026/8/8 12:43:08 网站建设 项目流程

主菜单

malisper.me

常规

关于我
Twitter
Postgres 文章目录
RSS

通过电子邮件订阅博客

输入你的电子邮件地址,订阅此博客并通过电子邮件接收新文章通知。
电子邮件地址
订阅
跳转到内容

重构 Postgres,让分析速度提升 300 倍:批处理、操作符融合和 SIMD

上周,发布了 pgrust 0.2 版本,此版本着重提升性能,比之前版本快 10 倍。在 OLTP 基准测试中,pgrust 比 Postgres 快 30%;在 Clickbench 中,pgrust 比 Postgres 快 300 倍,甚至超过了 Clickhouse。

查询引擎的改进是实现大幅性能提升的关键,仅查询引擎的优化就为整体 300 倍的提速贡献了约 10 倍。将从一个简化版的 Postgres 查询引擎入手,逐步添加优化,让 pgrust 查询引擎达到如此高的性能。

要理解为何相比 Postgres 有如此大的提升空间,需了解 Postgres 的诞生背景。最初的 Postgres 项目可追溯到 80 年代,当时数据库性能的主要瓶颈是磁盘 I/O。但如今有三个趋势改变了这一状况:一是许多数据集现在可存于内存,大幅减少了磁盘 I/O;二是对于无法完全存入内存的数据集,工作负载也有所不同,数据分析通常是批量扫描数据,此时性能瓶颈往往不再是磁盘吞吐量,而是 CPU 或内存吞吐量;三是近年来磁盘速度大幅提升,NVMe 比传统硬盘快数百倍。这三个趋势使得 CPU 和内存速度比以往更为重要,许多优化都针对这一点。查询引擎是数据库中 CPU 的主要使用者,优化了 pgrust 查询引擎,使其在处理相同查询时,比 Postgres 消耗更少的 CPU 和内存带宽。

对比 Postgres 查询引擎的性能

为直观感受 Postgres 查询引擎的速度,来看一个简单查询:对前 5 亿个数字求和。在 Postgres 中运行此查询,在 c8g.4xl 实例上禁用并行查询的情况下,大约需要 20 秒。

作为对比,在 Rust 中实现相同功能,这个查询仅需 358 毫秒,速度快了约 55 倍,而且实际上还能更快。当然,这并非完全公平的对比,因为 Postgres 底层有更多操作。但优化数据库的关键就在于尽可能减少这些额外开销,Postgres 中两个主要的开销来源是锁机制和解析存储格式并提取相关元组。

构建简化版 Postgres 查询引擎

为聚焦查询引擎的影响,构建一个简化版的 Postgres 查询引擎。首先,简单介绍一下查询引擎。处理 SQL 查询时,Postgres 会先将查询转换为内部表示,即“查询计划”,它描述了查询的执行方式。以上面的查询为例,Postgres 生成的查询计划实际上表示“从 my_table 中获取行,并对这些行中的值求和”。由于查询本身较简单,此查询计划也相对简单,但涉及连接、排序、子查询等操作时,查询计划会变得复杂得多。Postgres 总共有 40 多种不同类型的计划节点。

生成查询计划后,Postgres 将其传递给查询引擎。查询引擎负责根据查询计划检索行并执行聚合操作。Postgres 使用的是“火山模型”执行器。下面是一个简化版的 Postgres 查询引擎实现。火山模型的关键特性是 `next()` 方法,查询计划中的所有节点都支持该方法。`next()` 的作用是返回一行数据。顺序扫描节点的 `next()` 方法返回下一行,聚合节点的 `next()` 方法计算整个聚合结果并返回单行结果。执行查询计划只需不断调用根节点的 `next()` 方法,直到没有更多行返回。火山模型的优点是简单,为每个计划节点实现一个方法即可。上述代码虽经简化,但与 Postgres 内部实现非常接近。

然而,火山模型虽简单,却带来了大量开销。运行上述代码需要 1.3 秒,比 Postgres 版本快很多,因为去除了许多非查询引擎的部分,但仍比原生的 `for` 循环慢,主要是因为火山模型的开销。上述代码的主要性能瓶颈在于 `next()` 方法每次只处理一行数据,没有批处理。`SeqScan.next()` 函数每行调用一次,这增加了显著的开销,尤其是在运行时调用未知函数时,许多 CPU 优化效果不佳。可以实现的第一个优化是批处理。

批处理本身消除了大部分开销,将查询运行时间从 1.3 秒缩短至约 480 毫秒,虽仍比 `for` 循环慢,但已接近很多。一个重要细节是批处理缓冲区在栈上分配,这意味着聚合节点运行时无需分配内存,而内存分配通常是较慢的操作,因此编写超快速代码时,应尽量减少内存分配次数。

操作符融合与 JIT 编译

对批处理版本进行性能分析后,发现热点在于 `copy_from_slice`。尽管采用了批处理,但仍需将数据复制到缓冲区,可通过“操作符融合”消除此开销。如果某些操作经常一起执行,可以创建一个节点来替代两个节点。在本例中,可以创建一个 `SumAggregateSequentialScan` 节点,将顺序扫描和求和逻辑合并。这与直接使用 `for` 循环的性能相同,因为代码本质上是一样的。这看似有些取巧,确实如此,因为针对特定查询进行了硬编码优化。使用操作符融合时,对一些常见情况进行硬编码优化是合理的,但很快会遇到未提前考虑的情况。

这可以通过 JIT 编译解决。借助 JIT 编译,可以生成理想的代码,对每个查询都进行“取巧”优化。JIT 编译能为任何查询生成完美代码,并始终实现操作符融合。可惜本文篇幅有限,后续再介绍 pgrust 如何利用 JIT 编译。

SIMD 优化

最后一个优化是使用 SIMD(单指令多数据)。SIMD 是一组 CPU 操作,可同时对多个数据执行相同操作。使用 SIMD 同时处理多行数据通常比逐行处理快得多。以下是使用 SIMD 优化后的代码。优化后的代码仅需 135 毫秒,比 `for` 循环快近 3 倍,比最初的火山模型代码快 10 倍。虽然编译器通常会将 `for` 循环替换为 SIMD 等效代码,但此例特意避免了这种情况。编译器在处理浮点数时通常会避免引入 SIMD,因为浮点数运算不满足结合律,改变求和顺序可能会产生略微不同的结果。

优化总结

通过这三个简单的优化,将查询速度提升了 10 倍。以下是不同实现的性能对比:

实现方式时间提速倍数
Postgres~20 s
火山模型1.3 s
+ 批处理480 ms2.7×
+ 操作符融合358 ms3.6×
+ SIMD135 ms9.6×

这些优化以及更多其他优化,使 pgrust 在分析型查询上的性能比 Postgres 快数百倍。

基准测试设置

- AWS c8g.4xlarge(Graviton4,16 vCPU)
- PostgreSQL 18.4,`max_parallel_workers_per_gather = 0`
- 数据预热至共享缓冲区
- 每个测试运行 5 次,取中位数
- Rust 使用 `cargo build -release` 编译,每个实现运行 4 次,所有测试在同一台机器的同一个进程中进行

感谢阅读!如果想支持该项目,最好的方式是在 GitHub 上给项目点个星。如果想持续关注:可关注 GitHub、Discord、邮件列表、pgrust.com。

分享本文

在 X 上分享
在 LinkedIn 上分享
在 Facebook 上分享
通过电子邮件分享给朋友
打印

文章导航

上一篇文章:Postgres in Rust: three dead ends before we passed 100% of the regression suite

发表评论

你的电子邮件地址不会被公开。必填字段已标记 *
评论 *
姓名 *
电子邮件 *
网站
通过电子邮件通知我后续评论。
通过电子邮件通知我新文章。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询