2008年,一位数据库研究员做了一个简单的实验:他用C++手写了一段处理TPC-H Query 1的代码,然后与当时最先进的数据库系统对比性能。结果令人震惊——手写代码比数据库快了整整一个数量级。这个结果刺痛了数据库社区:为什么精心设计的查询引擎,竟然输给了几行手写的循环?
这个问题的答案,藏在数据库查询执行引擎三十年的演进史中。从1990年代的Volcano模型,到2005年的向量化执行,再到2011年的即时编译,每一次范式转变都在回答同一个问题:如何让数据库引擎榨干现代CPU的每一滴性能。
火山模型的优雅陷阱
1990年,Goetz Graefe发表了经典的Volcano论文,提出了一个优雅到近乎完美的查询执行模型。在这个模型中,每个关系操作符(扫描、选择、连接、聚合)都实现一个统一的接口:
class Operator {
public:
virtual Tuple* next() = 0; // 返回下一个元组,或NULL表示结束
};
这个设计的美妙之处在于其简洁性。查询计划被组织成一棵算子树,数据从叶子节点向上流动。一个简单的 SELECT * FROM users WHERE age > 30 查询,执行流程是这样的:顶层算子调用scan算子的next(),scan算子从磁盘读取一条记录返回,顶层算子再调用filter算子判断条件,如此往复,直到所有数据被处理完毕。
这种"拉取"式的迭代器模式完美契合了面向对象的设计哲学——每个算子只需要关注自己的逻辑,不需要知道上下游的实现细节。Oracle、MySQL、PostgreSQL等主流数据库都采用了这个模型。
然而,当内存足够大、查询不再受I/O限制时,Volcano模型的致命缺陷暴露无遗。
2005年,MonetDB团队的Peter Boncz等人发表了一篇论文,揭示了令人震惊的数据:在MySQL中执行一个简单的查询时,实际的数据计算只占总执行时间的10%,剩余的90%全部消耗在所谓的"解释开销"上——创建和查找哈希表、获取第N个字段、拷贝数据到临时缓冲区。
问题出在tuple-at-a-time的处理方式上。每次调用next(),都会触发一次虚函数调用。虚函数调用需要通过vtable查找函数地址,这个过程中CPU的分支预测器很容易失效,流水线被迫清空。更糟糕的是,每次只处理一条记录,根本无法利用CPU的SIMD指令和缓存预取机制。
现代CPU的设计与Volcano模型的假设完全相反。CPU不再是单纯的计算单元,而是一个复杂的并行执行机器:乱序执行、分支预测、多级缓存、SIMD向量单元。Volcano模型像一个19世纪的工厂,一次只加工一个零件;而现代CPU适合的是流水线生产,批量处理相同类型的任务。
CMU的数据库课程用一个形象的比喻描述了这个问题:Volcano模型就像一个图书馆管理员,每次只从书架上取一本书递给读者。如果读者需要借100本书,管理员就要在书架和柜台之间来回跑100次。而现代CPU希望的是:管理员一次取100本书放在推车上,推到柜台一次性处理。
向量化执行:批量处理的革命
MonetDB团队给出的解决方案被称为"向量化执行"。核心思想简单而有力:既然处理单个tuple的开销太大,那就一次处理一批。
MonetDB/X100引擎将数据组织成向量(vector),每个向量包含约1000个值。查询执行时,不再是"取一条数据,处理一条",而是"取一批数据,处理一批"。比如一个选择操作 WHERE age > 30,在向量化引擎中变成了:
// 传统Volcano:每次处理一个tuple
while ((tuple = scan.next()) != NULL) {
if (tuple.age > 30) {
result.add(tuple);
}
}
// 向量化执行:每次处理一批tuple
Vector ageVector = scan.nextBatch(); // 一次取出1000个age值
Vector result = selectGreaterThan(ageVector, 30); // 批量比较
这种看似简单的改变带来了巨大的性能提升。首先,虚函数调用被分摊到了1000个tuple上,每个tuple的调用开销降低了1000倍。其次,连续的内存访问模式让CPU的缓存预取机制充分发挥作用。最重要的是,批量处理开启了SIMD指令的大门。
SIMD(Single Instruction, Multiple Data)允许一条指令同时处理多个数据。Intel的AVX-512指令集可以一次处理16个32位整数。向量化引擎的选择操作可以被编译成高效的SIMD代码:
// 伪代码:SIMD化的选择操作
__m512i ages = _mm512_loadu_ps(ageArray); // 加载16个年龄值
__m512i threshold = _mm512_set1_ps(30.0); // 广播阈值30
__mmask16 mask = _mm512_cmp_ps_mask(ages, threshold, _MM_CMPINT_GT); // 并行比较
MonetDB/X100的实验数据令人印象深刻:一个简单的乘法操作,传统MySQL需要49个CPU周期处理一个tuple,而X100只需要2.2个周期。内存带宽方面,向量化引擎达到了7.5GB/s,而传统引擎只能达到500MB/s。
ClickHouse是这个范式的集大成者。它的核心数据结构是Column,数据按列存储,天然适合向量化处理。ClickHouse的代码中充满了SIMD优化:字符串比较、哈希计算、聚合操作都有专门的SIMD实现。在ClickHouse的压测中,单节点可以达到每秒处理数十亿行的速度。
但向量化执行并非完美。它面临一个根本性的困境:为了批量处理,必须将中间结果物化到内存中。一个三表连接的查询,每一步连接的结果都需要写入内存,再被下一步读取。这在内存带宽有限的场景下会形成新的瓶颈。
编译执行:把查询变成程序
2011年,TUM(慕尼黑工业大学)的Thomas Neumann提出了一个大胆的想法:既然解释执行的开销这么大,为什么不把查询直接编译成机器码?
这个想法的核心是"数据中心化"的代码生成。不同于Volcano模型的"算子中心化"(每个算子是一个独立的执行单元),编译执行将整个查询流水线融合成一个紧凑的循环。
考虑一个简单的查询 SELECT name FROM users WHERE age > 30。传统Volcano模型会生成三段独立的代码:scan、filter、project,通过虚函数调用连接。而编译执行会生成类似这样的融合代码:
// 编译执行的融合代码
for (auto& row : users_table) {
if (row.age > 30) { // 条件判断
output(row.name); // 直接输出,无需中间物化
}
}
HyPer数据库(后来的Umbra)使用LLVM作为编译后端。查询计划被翻译成LLVM IR(中间表示),经过优化后生成高效的机器码。这个过程中,编译器可以做很多Volcano模型做不到的优化:
寄存器分配:在Volcano模型中,每次调用next(),寄存器中的数据都会被写回内存。而编译执行的融合循环可以将数据长时间保存在寄存器中,减少内存访问。
分支预测优化:编译器可以根据条件的选择性生成不同的分支布局,最大化CPU的分支预测准确率。
循环展开与向量化:编译器自动进行循环展开,甚至在可能的情况下应用SIMD向量化。
HyPer的论文给出了一个震撼的对比:TPC-H Query 1是一个简单的聚合查询,手写的C++代码需要142毫秒,HyPer生成的LLVM代码只需要35毫秒,竟然比手写代码还快。原因是LLVM生成了一些手工代码难以实现的优化,比如特定的指令调度和寄存器分配策略。
编译时间是一个潜在的瓶颈。早期HyPer将查询编译成C++代码再调用系统编译器,一个复杂查询的编译可能需要几秒钟。后来改用LLVM的即时编译,编译时间降到了几十毫秒。Umbra更进一步,设计了一种快速的编译后端,将编译时间压缩到毫秒级。
并行执行的范式之争
单线程性能提升的同时,多核并行成为另一个战场。Volcano模型的并行化方案被称为Exchange操作符:将数据分片,每个线程处理一个分片,最后合并结果。这种方案简单但僵化——并行度在查询优化阶段就固定了,无法动态调整。
HyPer团队在2014年提出了"Morsel-Driven Parallelism"(小块驱动并行)。核心思想是将数据切分成小块(morsel,约10万行),由一个调度器动态分配给工作线程。这种设计带来了三个关键优势:
动态负载均衡:不同morsel的处理时间可能差异很大(因为数据分布不均或CPU核心性能差异)。动态调度可以让快线程多干活,避免慢线程拖后腿。
NUMA感知:现代多插槽服务器是非统一内存访问(NUMA)架构,访问本地内存比远程内存快得多。Morsel驱动的调度器优先将morsel分配给数据所在NUMA节点的线程。
弹性资源分配:当高优先级查询到达时,可以从正在执行的查询中"偷"线程,实现资源动态调配。
TPC-H的测试结果证明了这套方案的有效性:在32核机器上,HyPer实现了平均30倍的加速比。相比之下,使用静态分片的VectorWise只有9倍左右的加速。
三种模型的权衡
2018年,TUM和CMU的研究团队做了一项开创性的研究:在同一个系统中实现了向量化执行和编译执行两种引擎,进行了首次"苹果对苹果"的公平对比。
结果出乎意料:两种模型的性能差距并不大。在TPC-H查询集上,编译执行在计算密集型查询(如大量聚合操作)上有优势,因为它可以将中间结果保持在寄存器中。向量化执行在内存访问密集型查询(如大表连接)上表现更好,因为它的简单循环结构更有利于CPU乱序执行和缓存预取。
这意味着数据库架构师的选择不再仅仅是性能问题,还需要考虑其他因素:
编译时间:向量化引擎的执行原语是预先编译好的,查询执行前无需额外编译。编译执行需要即时生成机器码,对于短查询可能"编译时间>执行时间"。Umbra通过分层编译解决了这个问题:先用解释器执行,后台异步编译,编译完成后再切换。
调试与剖析:向量化引擎可以精确统计每个原语的执行时间,方便性能调优。编译执行的融合代码难以区分各个算子的贡献。
适应性:向量化引擎可以在执行过程中动态调整策略(比如改变选择谓词的评估顺序)。编译执行生成的代码是静态的,难以中途改变。
现代数据库系统往往采用混合策略。ClickHouse以向量化为主,但在特定场景(如复杂的表达式计算)使用JIT编译。Umbra以编译执行为主,但对压缩数据的扫描使用向量化原语。
没有银弹的工程世界
三十年的演进史,本质上是数据库与CPU架构的博弈史。从Volcano到向量化到编译执行,每次范式转变都在回答:如何更好地利用现代CPU的特性——更宽的SIMD单元、更深的流水线、更大的缓存、更多的核心。
但这个领域没有银弹。向量化执行的批量处理与内存带宽之间的张力,编译执行的高效代码与编译时间之间的权衡,并行执行的负载均衡与调度开销之间的博弈,都是无法完全解决的工程难题。
回到文章开头的问题:为什么数据库引擎跑不过手写代码?答案已经清晰——手写代码天然是"融合"的,没有算子边界的开销,没有类型转换的负担,没有虚函数调用的延迟。数据库引擎追求的,正是要在保持通用性的同时,逼近手写代码的性能极限。
今天,当一个SQL查询在ClickHouse中跑出每秒数十亿行的速度,或者在HyPer中与手写C++平分秋色时,我们知道,这场持续三十年的突围战已经取得了阶段性的胜利。但CPU架构仍在演进——更宽的SIMD、更多样的加速器、更深层的异构计算。数据库查询引擎的演进,还远未结束。