让 Postgres 的分析速度提高 300 倍:批处理、操作符融合和 SIMD
上周我们发布了 pgrust 的 0.2 版本。这个版本专注于性能提升。与之前的 pgrust 版本相比,它的速度提高了 10 倍。在 OLTP 基准测试中,pgrust 比 Postgres 快 30%。在 Clickbench,即 Clickhouse 针对分析数据库的基准测试中,pgrust 比 Postgres 快 300 倍。它甚至超越了 Clickhouse!查询引擎是我们为了实现更好性能而做出的最大改变之一。查询引擎本身促成了这 300 倍中的约 10 倍速度提升。我们将从一个迷你版的 Postgres 查询引擎开始,然后逐一添加优化,以使 pgrust 查询引擎如此快速。为了让大家理解为什么 Postgres 的改进空间如此之大,Postgres 是在一个不同的时代创建的。原始的 Postgres 项目可以追溯到 80 年代。它创建时数据库性能的主要瓶颈是磁盘 I/O。三种趋势使得这一情况不再存在:许多数据集现在可以装入 RAM,消除了大部分磁盘 I/O。对于那些无法装入 RAM 的数据集,其工作负载是不同的。数据分析通常以批量扫描数据。瓶颈往往不再是磁盘吞吐量,通常是 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; 当我在 Postgres 中运行这个查询时,它大约花费 20 秒。这是在 c8g.4xl 上进行的,同时禁用了并行查询。为比较,当我在 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 毫秒。大约快了 55 倍,信不信由你,我们能做得更快,甚至低于 358 毫秒。现在这个例子并不是一个完全对等的比较。Postgres 背后有很多复杂的内容。同时,优化数据库的关键就是尽量减少这些开销。(如果你感兴趣,Postgres 的两个最大开销来源是 1. 锁定和 2. 解析 Postgres 存储格式并提取与查询相关的元组)。为了将我们的重点聚焦在查询引擎的影响上,我们将构建一个 Postgres 查询引擎的迷你版本。首先,简要说明一下查询引擎是什么。当处理 SQL 查询时,Postgres 首先将查询转换为一种称为“查询计划”的内部表示,该表示描述 Postgres 将如何执行查询。在上面的示例中,Postgres 会生成一个查询计划,可能如下所示:这实际上是说“从 my_table 中获取行并对这些行的值求和”。考虑到该查询的性质,这个查询计划相当简单,但是当你开始使用连接/排序/子查询等时,查询计划会变得复杂得多。总的来说,Postgres 有超过 40 种不同类型的计划节点。生成查询计划后,Postgres 将其传递给查询引擎。Postgres 查询引擎是 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; // 表结束 } 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()` 将汇总结果返回。
本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。
☕请我喝杯咖啡