Reed's News
← 返回精选

将Postgres分析查询提速300倍:批处理、算子融合与SIMD

Tech 73 poly2it 2026/8/7 1685 字 原文 ↗

上周我们发布了pgrust 0.2版本,本次更新完全围绕性能优化展开,速度较上一版本提升10倍。在OLTP(联机事务处理)基准测试中,pgrust比Postgres快30%;而在Clickhouse专为分析型数据库打造的Clickbench测试中,pgrust的速度更是Postgres的300倍,甚至超过了Clickhouse本身详情见此

查询引擎是本次性能飞跃的核心改进点,单这一项就贡献了300倍性能提升中的约10倍。接下来,我们将从Postgres查询引擎的简化版本入手,逐一介绍我们为pgrust查询引擎打造的各项优化手段。

先说说为何Postgres有如此大的优化空间:Postgres诞生于另一个时代,其最早的项目可追溯至20世纪80年代,当时数据库性能的主要瓶颈是磁盘I/O。但如今三大趋势彻底改变了这一局面:

  • 如今多数数据集可完全存入内存,大幅减少磁盘I/O操作
  • 对于无法存入内存的数据集,工作负载模式已发生变化:数据分析场景下需批量扫描数据,性能瓶颈不再是磁盘吞吐量,而是CPU或内存吞吐量
  • 近年来磁盘性能大幅提升,NVMe硬盘的速度是传统机械硬盘的数百倍

这三大趋势让CPU与内存速度的重要性远超以往,我们的多数优化也正是围绕这一点展开。查询引擎是数据库中CPU的主要消耗模块,我们优化后的pgrust查询引擎,在处理相同查询时,比Postgres占用更少的CPU资源与内存带宽。

为直观展现Postgres查询引擎的性能短板,我们来看一个简单的求和查询:计算前5亿个数字的总和。

CREATE TABLE my_table AS select col::float8 from generate_series(1.0, 500000000.0) g(col); SELECT SUM(col) FROM my_table;

在禁用并行查询的AWS c8g.4xl实例上,Postgres执行该查询耗时约20秒。

作为对比,我们用Rust实现同等逻辑:

let table: Vec<f64> = (1..=500_000_000usize).map(|i| i as f64).collect();
let mut sum = 0.0;
for &value in &table {
sum += value;
}

仅耗时358毫秒,速度约为Postgres的55倍。而且告诉你,这还不是极限。当然,这个对比并非完全公平——Postgres底层的运行逻辑要复杂得多,但数据库优化的核心,就是尽可能消除这些额外开销。(Postgres的两大主要开销来源:一是锁机制,二是解析存储格式并提取查询相关数据行的过程。)

为聚焦查询引擎本身的影响,我们先构建一个Postgres查询引擎的简化版本。首先简单说明什么是查询引擎:Postgres处理SQL查询时,会先将其转换为名为"查询计划"的内部表示,描述查询的执行方式。以上述求和查询为例,Postgres生成的查询计划大致如下:

这个计划的逻辑很简单:"读取my_table的数据行,对其中的值求和"。当然,涉及连接、排序、子查询等操作时,查询计划会复杂得多。Postgres总计有超过40种不同类型的计划节点。

生成查询计划后,Postgres会将其交给查询引擎执行——查询引擎是负责根据计划获取数据行并完成聚合计算的模块。Postgres采用的执行器架构名为"火山模型"。为展示其工作原理,以下是Postgres查询引擎的简化实现:

use std::hint::black_box;
trait Node {
fn next(&mut self) -> Option<f64>;
}
struct SeqScan<'a> {
table: &'a [f64],
pos: usize,
}
impl Node for SeqScan<'_> {
fn next(&mut self) -> Option<f64> {
if self.pos >= self.table.len() {
return None; // end of table
}
let value = self.table[self.pos];
self.pos += 1;
Some(value)
}
}
struct SumAggregate<'a> {
child: Box<dyn Node + 'a>,
total: f64,
done: bool,
}
impl Node for SumAggregate<'_> {
fn next(&mut self) -> Option<f64> {
if self.done {
return None;
}
while let Some(value) = self.child.next() {
self.total += value;
}
self.done = true;
Some(self.total)
}
}
let table: Vec<f64> = (1..=500_000_000usize).map(|i| i as f64).collect();
let mut plan = SumAggregate {
child: black_box(Box::new(SeqScan { table: &table, pos: 0 })),
total: 0.0,
done: false,
};
let sum = plan.next().unwrap();

black_box用于防止编译器优化干扰基准测试结果)

火山模型的核心是所有计划节点都支持的next()方法,其作用是返回单行数据:顺序扫描节点的next()返回下一行数据,聚合节点的next()则完成全部聚合计算后返回单行结果。执行查询计划只需不断调用根节点的next(),直到无数据返回即可。火山模型的优势在于实现简单:每个计划节点只需实现一个方法。上述代码虽经过简化,但与Postgres的内部逻辑非常接近。

不过,火山模型在简化实现的同时也带来了大量额外开销。运行上述示例耗时1.3秒——虽比Postgres原生版本快很多(因为我们移除了非查询引擎的模块),但仍比原生for循环慢,这正是火山模型的开销所致。

上述代码中最大的性能损耗在于next()方法每次仅处理一行数据,完全没有批量处理机制:SeqScan.next()需被调用5亿次,带来了显著开销。尤其值得注意的是,当调用的函数在运行时才能确定时,CPU流水线等优化手段无法有效发挥作用。我们能实现的第一项优化就是批量处理:

const BATCH: usize = 1024;
trait BatchNode {
fn next_batch(&mut self, out: &mut [f64; BATCH]) -> usize;
}
struct BatchSeqScan<'a> {
table: &'a [f64],
pos: usize,
}
impl BatchNode for BatchSeqScan<'_> {
fn next_batch(&mut self, out: &mut [f64; BATCH]) -> usize {
let n = (self.table.len() - self.pos).min(BATCH);
out[..n].copy_from_slice(&self.table[self.pos..self.pos + n]);
self.pos += n;
n
}
}
struct BatchSumAggregate<'a> {
child: Box<dyn BatchNode + 'a>,
total: f64,
}
impl BatchSumAggregate<'_> {
fn run(&mut self) -> f64 {
let mut buf = [0.0f64; BATCH];
loop {
let n = self.child.next_batch(&mut buf);
if n == 0 {
break;
}
for &value in &buf[..n] {
self.total += value;
}
}
self.total
}
}
let mut plan = BatchSumAggregate {
child: black_box(Box::new(BatchSeqScan { table: &table, pos: 0 })),
total: 0.0,
};
let sum = plan.run();

批量处理几乎消除了火山模型的大部分开销,将查询耗时从1.3秒降至约480毫秒。虽仍慢于原生for循环,但已大幅接近。这里有个关键细节:批量缓冲区分配在栈上,意味着聚合节点运行时无需额外分配内存——内存分配是相对较慢的操作,因此编写极致高性能代码时,应尽量减少内存分配次数。

此时对批量处理版本进行性能分析会发现,copy_from_slice成为新的性能热点:即便采用批量处理,仍需将数据复制到缓冲区。这种开销可通过"算子融合"消除:如果我们知道某些操作会被一起执行,就可以用单个节点替代两个节点。在本例中,我们可以创建一个SumAggregateSequentialScan节点,将顺序扫描与求和逻辑合并:

struct SumAggregateSequentialScan<'a> {
table: &'a [f64],
done: bool,
}
impl Node for SumAggregateSequentialScan<'_> {
fn next(&mut self) -> Option<f64> {
if self.done {
return None;
}
self.done = true;
let mut total = 0.0;
for &value in self.table {
total += value;
}
Some(total)
}
}

这样我们就能获得与原生for循环完全相同的性能,因为这段代码本质上就是原生for循环。你可能觉得这像是"作弊"——确实如此,因为我们针对已知的特定查询硬编码了优化逻辑。对于算子融合而言,为几种最常见的场景硬编码优化是合理的,但很快就会遇到未提前准备的场景。

这个问题可以通过JIT(即时编译)解决。JIT编译能够为任意查询生成理想的代码,实现"通用作弊":无论查询是什么,都能生成最优代码并自动完成算子融合。不过本文篇幅已较长,关于pgrust如何利用JIT编译的内容,我们留到下次再讲。

最后一项优化是SIMD(单指令多数据)技术,指CPU可同时对多份数据执行同一操作的指令集。用SIMD同时处理多行数据,通常比逐行处理快得多。我们将代码改为使用SIMD:

#[cfg(target_arch = "aarch64")]
struct SumAggregateSequentialScanSimd<'a> {
table: &'a [f64],
done: bool,
}
#[cfg(target_arch = "aarch64")]
impl Node for SumAggregateSequentialScanSimd<'_> {
fn next(&mut self) -> Option<f64> {
if self.done {
return None;
}
self.done = true;
use std::arch::aarch64::*;
let mut acc = unsafe { [vdupq_n_f64(0.0); 4] };
let (chunks, rest) = self.table.as_chunks::<8>();
for chunk in chunks {
for lane in 0..4 {
unsafe {
let v = vld1q_f64(chunk.as_ptr().add(2 * lane));
acc[lane] = vaddq_f64(acc[lane], v);
}
}
}
let mut tail = 0.0;
for &value in rest {
tail += value;
}
Some(unsafe {
let s01 = vaddq_f64(acc[0], acc[1]);
let s23 = vaddq_f64(acc[2], acc[3]);
vaddvq_f64(vaddq_f64(s01, s23)) + tail
})
}
}

现在代码仅需135毫秒即可完成,速度约为原生for循环的3倍、初始火山模型版本的10倍。通常编译器会自动将for循环替换为SIMD等效代码,但我特意选择了这个不会触发该优化的示例:编译器一般不会为浮点数操作自动引入SIMD,因为这会导致计算结果略有差异——浮点数运算不满足结合律,求和顺序的改变会产生微小误差。


通过这三项简单优化,我们将查询速度提升了10倍。最终各版本的性能对比如下:

实现方案 耗时 性能提升倍数
Postgres ~20秒
火山模型 1.3秒
+ 批量处理 480毫秒 2.7×
+ 算子融合 358毫秒 3.6×
+ SIMD 135毫秒 9.6×

正是这类优化以及更多其他手段,让pgrust在分析型查询中的性能比Postgres快数百倍。

基准测试环境说明:AWS c8g.4xlarge实例(Graviton4处理器,16核vCPU),PostgreSQL 18.4(max_parallel_workers_per_gather = 0),数据已预热至共享缓冲区,取5次运行的中位数;Rust代码通过cargo build --release编译,每种实现运行4次,所有测试在同一机器的同一进程内完成。

感谢阅读,若想支持pgrust项目,最佳方式是在GitHub上为我们点星。如需持续关注项目动态:

  1. GitHub
  2. Discord
  3. 邮件列表——每周推送pgrust更新,包括后续的JIT编译详解
  4. pgrust.com