- 引言 —— 花 30 秒确认标题里的主语
- 300 倍和 10 倍是两个不同的实验
- 那条要跑 20 秒的 SUM 查询是什么
- 火山模型 —— 一行一次函数调用
- 批处理 —— 把调用次数除以 1024
- 算子融合 —— 以及作者自称「作弊」的那个地方
- SIMD —— 编译器为什么不替你做
- 这张表在说什么,以及今天能做什么
- 参考资料
引言 —— 花 30 秒确认标题里的主语
当「把 PostgreSQL 的分析查询做快了 300 倍」这样的文章开始在时间线上转起来时,最先要确认的不是 300 这个数字,而是这句话的主语。
原文是 2026 年 8 月 3 日发在 malisper.me 上的 Rebuilding Postgres for 300x faster analytics,第一段是这么开头的:「上周我们发布了 pgrust 0.2。」
pgrust 是用 Rust 重新实现 PostgreSQL 的另一个数据库。它的线路协议和 SQL 方言兼容,并且通过了全部 46,066 个回归测试,但仓库 README 的第一行就写着:还没有准备好上生产,别把你珍贵的数据放进去。也就是说,300 倍不是你改改今天正在运行的那套 PostgreSQL 的配置文件就能拿到的数字。
但这绝不意味着这篇文章不值得读。恰恰相反。作者把 300 倍里查询引擎贡献的大约 10 倍,拆解成了任何人都能复现的 60 行 Rust 代码。那个拆解才是这篇文章真正的内容,也是我们该学的东西。
300 倍和 10 倍是两个不同的实验
为了避免混淆,先把两个数字分开。
300 倍是 ClickBench 的分数。按 README 的说法,是在 c8g.4xlarge(AWS Graviton4)上对着 PostgreSQL 18.3 测的,用的是 pgrust 自家的列式存储格式 pgrcolumnar。文中还写着,在同一个基准上比 ClickHouse 快 18.5 个百分点。
9.6 倍是博客正文里的 SUM 查询实验。这是和 pgrust 毫无关系的、四个版本纯 Rust 代码之间的比较结果。
作者自己写进 README 的附注也要一起读。基准测试用的构建针对 Graviton4 做了 -Ctarget-cpu=neoverse-v2 的调优,而分发的二进制并没有,所以他明确写着「下载下来是复现不出来的」。JIT 编译器也只针对 Graviton4。他还说明,在 Kubernetes 上 OLTP 的差距反而更大(50~60 个百分点),但因为没能查清原因,所以引用的是偏低的那个数字 30 个百分点。
读基准测试类文章时,能把自己的条件写到这个程度的情况很少见。把这份诚实原样接过来再引用,才算是有礼貌。
那条要跑 20 秒的 SUM 查询是什么
作者作为出发点的查询是这个。
CREATE TABLE my_table AS
SELECT col::float8 FROM generate_series(1.0, 500000000.0) g(col);
SELECT SUM(col) FROM my_table;
测量条件是 c8g.4xlarge、PostgreSQL 18.4、max_parallel_workers_per_gather = 0、数据已经在共享缓冲区里、取五次的中位数。在这个条件下大约花了 20 秒。
同样这 5 亿个 f64,用 Rust 的简单 for 循环加起来是 358 毫秒。相差 55 倍,但作者立刻把话钉死:这不是同等条件的比较。因为 PostgreSQL 那一侧还多做了加锁处理、存储格式解析、元组抽取之类的事。
这里把并行查询关掉这件事同样重要。真实运行中的 PostgreSQL,max_parallel_workers_per_gather 默认是开着的,这条查询会被拆给多个 worker。作者关掉它是为了看查询引擎本身的单核效率,而不是为了让 PostgreSQL 吃亏。不过引用这个数字的时候,条件也得一起引用。
火山模型 —— 一行一次函数调用
PostgreSQL 的执行器用的是火山模型(Volcano model)。每个计划节点都有一个 next(),调用一次就出一行。在根节点上不停地调 next(),查询就跑完了。作者做的缩小版是这样的。
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 }
let value = self.table[self.pos];
self.pos += 1;
Some(value)
}
}
这个结构的优点很清楚。每个节点只要实现一个方法,而且任何节点都能叠在任何节点之上。这正是 PostgreSQL 管着四十多种计划节点却不会遭遇组合爆炸的原因。
问题在成本。5 亿行就意味着 next() 被调用 5 亿次。而且藏在 Box<dyn Node> 背后的调用是要到运行时才确定目标的间接调用,分支预测和内联都不太起作用。这个缩小版是 1.3 秒,for 循环是 358 毫秒。差距的大部分就是这份调用开销。
批处理 —— 把调用次数除以 1024
第一项优化是批处理。把 next() 从返回一行,改成填满并返回一个 1024 个元素的数组。
const BATCH: usize = 1024;
trait BatchNode {
fn next_batch(&mut self, out: &mut [f64; BATCH]) -> usize;
}
调用次数从 5 亿降到约 49 万,时间从 1.3 秒变成约 480 毫秒。这是 2.7 倍。
作者点出的一个细节,在实务上更重要:批处理缓冲区是开在栈上的。[0.0f64; BATCH] 不是堆分配,所以聚合节点在执行期间完全不分配内存。如果一边改成按批执行、一边每批都新建一个 Vec,那省下来的函数调用成本就会原封不动地以分配成本的形式还回去。
算子融合 —— 以及作者自称「作弊」的那个地方
给批处理版本做剖析,这时热点变成了 copy_from_slice。因为结构是扫描往缓冲区里拷贝、聚合再读那个缓冲区,中间的拷贝还留着。
算子融合把扫描和聚合合成一个节点,消掉这次拷贝。结果是 358 毫秒,和 for 循环一模一样。这是当然的,合并之后字面意义上就是同一段代码。
作者在这里自己踩了刹车。用原文的说法,他写道:这看上去可能像是作弊,而且它确实就是作弊。因为他事先知道会来什么查询,只把那一种组合硬编码了。预先准备好几种常见组合是有意义的,但没准备到的组合很快就会冒出来。
它的通解是 JIT 编译。收到查询之后,生成一段严丝合缝匹配这条查询的机器码,就能在所有查询上「作弊」。这正是 pgrust 实际采用的方式,正文没有展开,留到了下一篇。
SIMD —— 编译器为什么不替你做
最后是 SIMD。用 aarch64 NEON 开四个累加器,每个 chunk 加八个。
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);
}
}
}
135 毫秒。比 for 循环还快 2.7 倍,相对最初的火山模型是 9.6 倍。
编译器为什么没有自动做这个变换呢?作者明确给出了答案:因为浮点加法不满足结合律。 把累加器拆成四个,相加的顺序就变了,结果可能在最后一位上不同。编译器默认不允许这种变换。作者之所以挑这个例子,也正是为了不让编译器偷偷向量化、把实验搞砸。
如果换成整数求和,编译器早就自己向量化了,这一步的收益看起来会小得多。这是「基准测试的设计造就了结果」的一个好例子。
这张表在说什么,以及今天能做什么
| 实现 | 时间 | 倍数 |
|---|---|---|
| PostgreSQL 18.4 | 约 20 秒 | — |
| 火山模型 | 1.3 秒 | 1 倍 |
| 加上批处理 | 480 毫秒 | 2.7 倍 |
| 加上算子融合 | 358 毫秒 | 3.6 倍 |
| 加上 SIMD | 135 毫秒 | 9.6 倍 |
这张表的左端和右端性质不同。从 1.3 秒到 135 毫秒,是在同一种语言、同一个进程、同一套数据结构上只改执行策略的结果。从 20 秒到 1.3 秒,是存储格式、加锁、MVCC 可见性判定、元组反序列化这些东西被整块拿掉的结果。前者讲的是执行引擎的设计,后者讲的是「数据库因为身为数据库而付出的成本」。
所以把这篇文章总结成「PostgreSQL 慢」,一半是错的。准确地说,这是在讲:一个在 1980 年代按照「磁盘 I/O 是瓶颈」这个假设设计出来的执行器,在数据全都装得进内存的 2026 年分析型负载下,暴露出了 CPU 瓶颈。 作者在开头写的也正是这个意思。
那么在还用不上 pgrust 的当下,同样的原理能用到什么程度呢?下面这些条目不在原文之中,属于另一回事,请各自到自己版本的文档里确认默认值与支持情况后再用。
第一,别把并行查询关掉。上面的实验是故意关的,但在真实的聚合扫描里,加 worker 依然是最省事的倍数来源。先从计划里能不能看到 Gather、挂上了几个 worker 查起。
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT SUM(col) FROM my_table;
-- 看看 Workers Planned / Workers Launched 实际是几个
第二,减少行数几乎总是比减少每行成本收益更大。火山模型的开销与行数成正比,所以用预聚合表或者部分索引把要扫的行本身减下来,就等于一次性跳过了上表的所有阶段。
第三,如果确实需要列式存储,那就用专门做这件事的扩展。不过对扩展的性能主张,也要像这篇文章做的那样,确认它「测的是什么、在什么硬件上、用了什么配置」,然后再引用。这才是从这篇文章里能带走的最实用的习惯。
参考资料
현재 단락 (1/77)
当「把 PostgreSQL 的分析查询做快了 300 倍」这样的文章开始在时间线上转起来时,最先要确认的不是 300 这个数字,而是**这句话的主语**。