Skip to content

元数据卡

维度
难度(黑魔法)
前置第8章 查询执行与优化器、C 语言虚函数概念
关键词Volcano Model、Code Generation、LLVM JIT、Pipeline Breaking、Push-based Execution
代码语言C++ / Pseudocode(主)

你的进度

军师给出了路线图,但老陈不满意。'每次都要看地图、一步一步执行——太慢了。'他想了一个办法:把路线图直接编译成机械指令,工人不用看地图,直接动手。这就是解释执行 vs 编译执行的差别。


破局 · 溯源

1. 火山模型:解释执行的经典

大多数数据库(PostgreSQL、MySQL、SQLite 的传统执行器)都基于火山模型(Volcano Model,也叫 Iterator Model)。

你设想一下:堡垒的工人们排成一条流水线,每个工人只干一件事。前面的工人问下一个下一个是什么,下一个工人就继续往下问。每个工人只等上一手的回答,干完就传给下一手。这就是火山模型的核心:每个算子实现一个 next() 接口。

cpp
// 算子基类
class ExecutorNode {
public:
 virtual ~ExecutorNode() = default;
 virtual Tuple* next() = 0; // 每次调用返回一行,nullptr 表示结束
};

一个简单的过滤算子实现:

cpp
class FilterNode : public ExecutorNode {
 ExecutorNode* child_; // 从下层算子拿数据
 Predicate pred_;
public:
 Tuple* next() override {
 while (true) {
 Tuple* t = child_->next();
 if (!t) return nullptr; // 下层没数据了
 if (pred_(t)) return t; // 满足条件就返回
 // 否则继续循环,直到找到满足条件的行
 }
 }
};

一个 Join 算子:

cpp
class HashJoinNode : public ExecutorNode {
 ExecutorNode* build_child_; // 构建侧
 ExecutorNode* probe_child_; // 探测侧
 HashTable ht_;
 bool built_ = false;
 Tuple* build_output_; // 等值匹配的行

public:
 Tuple* next() override {
 // 构建阶段(只做一次)
 if (!built_) {
 while (auto* t = build_child_->next()) {
 ht_.insert(key(t) t);
 }
 built_ = true;
 }

 // 探测阶段(逐行匹配)
 while (auto* t = probe_child_->next()) {
 if (auto match = ht_.lookup(key(t))) {
 // 返回拼合的行
 return concat(t match);
 }
 }
 return nullptr;
 }
};

干活时,控制权从最底层一路往上拉:

 [输出]
 ↑ next()
 [HashJoin]
 ↑ next() ↑ next()
 [Scan: adventurers] [Scan: quests]

每次 next() 把控制权往上推一层——这就是“拉取模型”(Pull Model)。

好处: 接口简单统一,算子可以独立组合任意嵌套,任何时候只有一行在算子间流动,内存可控。

坏处: 每一行每经过一个算子,就是一次虚函数调用。一次 5-15 纳秒,看着不大,但乘以行数 × 算子数,帐就出来了。

2. 虚函数调用的真实代价

算一笔账:

假设查询计划深度为 4 层(Scan → Filter → HashJoin → Projection),扫描 1000 万行:

cpp
// 每行要经历:
child_->next() // Scan → Filter 的虚函数
child_->next() // Filter → HashJoin 的虚函数
child_->next() // HashJoin → Projection 的虚函数 (假设 Hash Join 探测侧)
// 加上内部 Filter 判断、HashTable lookup 等普通调用

所以:

1000 万行 × 3 次虚函数调用 × 10ns ≈ 300ms

300ms 的纯虚函数开销。听起来不多?对于一个原本跑 500ms 的查询,这 300ms 是 60% 的额外代价。对于分析型查询(处理数十亿行),这个开销可以膨胀到秒级。

更要命的是分支预测: 每个虚函数调用是一次间接跳转,CPU 的分支预测器根本不知道下一步跳哪。这意味着:

虚函数调用 = 无法预测的间接跳转 ≈ CPU 流水线冲刷 ≈ 10-20 个周期

对于热点循环(一次扫描处理几亿行),这种**解释执行的"每行税"**可以占执行时间的 30%-50%。

PostgreSQL 社区早就看到了这个问题。9.6 之后引入 JIT(LLVM),但没有动火山模型的根——JIT 只编译了表达式求值,算子框架本身还是解释执行的。


3. 从解释到编译:三种路径

绕过虚函数开销的思路很简单:不通过 next() 逐行解释,直接生成一段能处理数据的机器码。

业界走了三条深度不同的路。

路径 1:表达式编译——最浅的改动

最浅的改动。只编译 WHERE 条件中的表达式部分,算子框架还是火山模型。

PostgreSQL 的 JIT 走的就是这条路:

sql
-- 开启 JIT
SET jit = on;
SET jit_above_cost = 100000;

-- 对复杂表达式,LLVM 编译后会快很多
SELECT * FROM quests
WHERE reward * difficulty_multiplier + bonus > 1000;

PostgreSQL 把 reward * difficulty_multiplier + bonus > 1000 这个表达式编译成 LLVM IR,再执行:

cpp
// 不做 JIT 时:
bool result = eval_expr(tuple expr_tree); // 遍历表达式树,解释执行

// 做 JIT 后(简化):
auto compiled_fn = jit_compile(expr_ir); // 生成函数指针
bool result = compiled_fn(tuple); // 直接调用编译后的二进制代码

效果:表达式复杂的查询能省去解释遍历表达式树的开销。但对算子之间的 next() 调用链——没用。

路径 2:管道编译——推倒算子之间的墙

把整张查询计划编译成一段首尾相连的代码。算子之间的墙消失了——没有 A 调 B 调 C 了,而是生成一个函数,扫描 → 过滤 → JOIN → 输出全挤在同一个循环里。

HyPer(后来的 Umbra)和 DuckDB 走的就是这条路。

cpp
// 对 SELECT a.name FROM adventurers a JOIN quests q ON ...;
// 生成这样的伪机器码(概念):

void compiled_query(Table* adventurers Table* quests Output* out) {
 // 全部在一个函数里,没有算子间的 next() 调用
 for each page in adventurers {
 for each tuple in page {
 if (tuple.level > 30) { // Filter 不单独建对象
 // Hash 构建:直接把 adventurer_id 插入哈希表
 ht.insert(tuple.adventurer_id tuple.name);
 }
 }
 }

 // 探测阶段
 for each page in quests {
 for each tuple in page {
 if (auto match = ht.lookup(tuple.adventurer_id)) {
 out->emit(tuple.reward match->name);
 }
 }
 }
}

看到了吗?没有 child_->next()。所有循环都在一段连贯的代码里。CPU 看到的是连续的内存访问和能猜对的分支。

Pipeline Breaking:

不是所有操作都能"不打断"地串在一起。典型的打断者:

cpp
// 可以串(非打断)
Scan → Filter → Projection // 全部可以挤在一个循环里

// 必须打断(Pipeline Breaker)
Hash Join 的构建阶段需要先 // 需要先从头到尾读一遍构建侧
 // 建好哈希表之后才能开始探测

// 另一个打断者:Sort
Sort // 必须全量数据到达后才能输出第一行

DuckDB 的编译引擎做得精细:遇到 pipeline breaker 时自动切开生成独立代码块,两个代码块之间通过物化(写入中间结果集)连接。

路径 3:全编译——最彻底的革命

Hyper 从诞生起就押注全编译——直到 Umbra 时代,他们把整个 SQL 查询编译成高效的原生代码,而且还引入自适应索引(adaptive indexing)。

关键洞察:编译执行不只是绕过虚函数。它让编译器能做普通的标量优化(常量折叠、死代码消除、循环展开),还能做数据驱动的优化——比如根据列的实际类型、空值率、重复度生成不同的代码路径。

cpp
// 编译时根据列是否可空生成不同代码路径
if (column.has_null_bitmap) {
 // 带有 null 检查的版本
} else {
 // 没有 null 检查的版本(更快)
}

Umbra 的论文报告,全编译执行比 PostgreSQL 的解释执行在 TPC-H 基准测试中快 1-2 个数量级。


4. DuckDB 的编译执行

DuckDB 是看编译执行最直接的窗口。看看它是怎么设计的:

DuckDB 没有传统意义上的"解释器"——它把查询计划编译成 C++ 风格的模板展开(template expansion),不走 LLVM IR。

cpp
// DuckDB 的向量化 + 编译执行
// 它不是火山模型的逐行调用,而是基于 chunk 的向量化
// 但算子的管道还是用了编译方式

// 一个 Filter 算子对应的代码生成(伪码)
void ExecuteFilter(DataChunk& input Expression& expr) {
 SelectionVector sel;
 idx_t result_count = 0;

 // DuckDB 的向量化过滤:一次处理一整个 chunk(默认 2048 行)
 for (idx_t i = 0; i < input.size(); i++) {
 // 这里不是虚函数调用——而是模板展开的具体逻辑
 if (expr.Evaluate(input i)) {
 sel[result_count++] = i;
 }
 }

 // 只保留符合条件的行
 input.Slice(sel result_count);
}

DuckDB 也不是"纯编译"——它的算子内部有循环,但算子间不是在 next() 虚函数上传递数据,而是编译时确定的类型安全接口

DuckDB 的架构师的描述:"我们在编译时把查询计划展开成一组高度特化的函数调用链,而不在运行时通过虚函数动态分发。"

作为用户,你不必关心这些内部——但跑下面的查询就能感受到区别:

sql
.timer on
SELECT item_type
 sum(value) as total_value
FROM vault_items
WHERE value > 500
GROUP BY item_type;

DuckDB 会把 WHEREGROUP BYSUM 编译成一个紧密耦合的执行路径,而不是三个独立算子来回调用。


5. PostgreSQL LLVM JIT 实践

PostgreSQL 15+ 的 JIT 用 LLVM 编译关键路径——不是整张计划,只是关键段落。

哪些部分被编译了?

JIT 覆盖的范围:

 Volcano Model Framework ← 不编译,保留 next() 调用

 Expression Evaluation ← 编译 (JIT_Expression)
 WHERE 条件、函数调用等

 Tuple Deforming ← 编译 (JIT_TupleDeform)
 把磁盘格式拆成列值

 聚合函数的计算 ← 编译 (JIT_Agg)

开启 JIT 的影响:

sql
-- 准备:复杂表达式查询
SET jit = on;
SET jit_above_cost = 10000; -- 查询代价超过这个值才启用 JIT
SET jit_optimize_above_cost = 100000; -- 超过此值启用 LLVM 优化

EXPLAIN (ANALYZE TIMING)
SELECT * reward * (1 + difficulty_multiplier / 100) AS adjusted_reward
FROM quests
WHERE reward * difficulty_multiplier + bonus > 1000
 AND (status = 'completed' OR status = 'in_progress');

输出中会出现:

JIT:
 Functions: 6
 Options: Inlining false Optimization false Expressions true Deforming true
 Timing: 2.132 ms (generation) + 0.283 ms (inlining) + 0.421 ms (optimization)
 + 0.123 ms (emission) = 2.959 ms

两笔帐:JIT 编译花了 2.959ms,执行省了一些表达式树解释的时间。如果查询本身就不到 10ms,JIT 那 3ms 编译开销就白花了。

什么时候 JIT 是负资产?


 查询执行时间

 10s
 JIT 收益区
 1s

 100ms JIT 灰色区

 10ms
 JIT 亏损区(编译 > 执行)


 JIT 编译时间 ~3ms

几条经验:

  • 单次查询 > 1 秒:JIT 值得开
  • 单次查询 < 50ms:JIT 不开白不开——不,不开更好
  • OLTP 短查询(微秒级):千万别开

这也是 PostgreSQL 默认 jit_above_cost = 100000 的原因——别让编译时间吃掉查询时间。


6. Hyper / Umbra 的全编译哲学

Thomas Neumann(慕尼黑工大教授,HyPer/Umbra 的创造者)有句名言:

"The best way to avoid interpretation overhead is to not interpret."

他的意思很明确:不要想着优化解释执行,要从根本上不解释。

HyPer(Hyper 是 "Hybrid OLTP & OLAP Engine" 的缩写,不是虚拟化 Hypervisor)的全编译核心:

cpp
// HyPer 的代码生成(概念):一次生成一个完整查询函数的 LLVM IR
// 不再有 iterator / next() 调用

// 输入查询:
// SELECT name FROM adventurers WHERE level > 50 AND class = 'Paladin';

// 近似生成的 LLVM IR(概念级,不是真 IR):
void query_kernel() {
 // 1. 直接读取页面
 for (page in table_pages("adventurers")) {
 for (row in page) {
 // 2. 直接解压 tuple → 列值(不需虚函数)
 int level = deformat_int(row LEVEL_OFFSET);
 // 3. 直接计算条件
 if (level > 50) {
 char* class_name = deformat_str(row CLASS_OFFSET);
 if (str_eq(class_name "Paladin")) {
 // 4. 直接输出
 char* name = deformat_str(row NAME_OFFSET);
 output->append(name);
 }
 }
 }
 }
}

没有 FilterNode 对象,没有 ProjectionNode 对象,没有 child_->next()——只有连续的循环、连续的比较、连续的内存写入。

Umbra 作为 HyPer 的后继,更进一步引入了自适应编译(adaptive compilation)。它观察查询实际执行的情况,在运行时动态调整生成的代码。例如,如果一个哈希表的分区数太大,Umbra 会重新生成一个带有更少分区的代码版本。


深入冒险

Pipeline Breaking 的详细分析

但不是所有查询都能编成一段连续循环。看这个查询:

sql
SELECT a.name MAX(q.reward)
FROM adventurers a JOIN quests q ON a.adventurer_id = q.adventurer_id
WHERE a.class = 'Paladin'
GROUP BY a.name
ORDER BY MAX(q.reward) DESC;

查询管道中的打断点:

Pipeline 1: Scan(adventurers) → Filter(class='Paladin') → Hash Build
 端点:Hash Table 构建完成才能输出
 ↕(Pipeline Break:物化到哈希表)
Pipeline 2: Scan(quests) → Hash Probe → Hash Group By
 端点:GROUP BY 需要所有数据到齐
 ↕(Pipeline Break:物化到聚合哈希表)
Pipeline 3: Sort(reward DESC) → Limit

每个 Pipeline Breaker 处,数据库都得物化中间结果——写入内存或磁盘。物化点越多,编译执行的收益被物化成本吃掉得越多。

编译执行的内存开销

编译执行也需要更多内存:代码段占缓存、物化中间结果占内存、LLVM 优化过程本身也吃内存。在内存紧巴巴的环境里(嵌入式数据库、lambda 函数),编译执行不一定划算。


常见陷阱

陷阱 1:JIT 编译时间 > 查询收益

一条 20ms 的查询,JIT 编译花了 5ms,执行省了 2ms——净亏 3ms,还多占了 CPU。

sql
-- 这类查询不要开 JIT:
SELECT count(*) FROM small_table WHERE id = 42;

陷阱 2:认为 DuckDB 是"纯编译"

DuckDB 经常和编译执行画等号——但它实际上是向量化执行 + 编译友好的模板结构。它的算子内部不逐行跑,而是处理 Data Chunk。它不是纯编译(像 HyPer),也不是纯解释(像 PostgreSQL 火山模型)。它是向量化解释,用 batch 放大计算密度。

陷阱 3:认为 "编译执行 = LLVM"

Llvm 只是编译执行的落地方式之一。DuckDB 的模板元编程、HyPer 的 LLVM IR 生成、甚至是 SQLite 的字节码虚拟机(VDBE)都是"编译"的不同程度。编译执行的核心是消除运行时动态分发,不一定是生成原生机器码。

陷阱 4:PostgreSQL JIT 开与关的影响

sql
SET jit = on;
-- 如果查询代价小于 jit_above_cost,JIT 不会被触发
-- 但每个查询仍然会走一次 JIT 代价检查
-- 在 PG 16 中这个检查微乎其微

实际影响可以忽略,但如果你在微秒级 OLTP 查询中强行关掉 JIT,性能差异可以观测到。


通关挑战

  • 热身:解释为什么 SELECT * FROM t WHERE id = 1 用解释执行比编译执行更好
  • 挑战:在 PostgreSQL 中执行一个复杂表达式查询(至少 5 个算数运算 + 2 个函数调用),对比 JIT 开和关的执行时间:
sql
SET jit = off;
-- 执行查询并记录时间

SET jit = on;
-- 同样查询,查看 JIT 的编译时间占比
  • 观察:对 DuckDB 的 TPC-H SF1 跑 Q1(最复杂的单表聚合),用 .timer on 观察执行时间,理解为什么这类查询在编译/向量化引擎中表现极好

验收标准

  • 能解释火山模型中每次 next() 调用的虚函数代价及其对 CPU 流水线的影响
  • 能区分表达式编译、管道编译、全编译三种不同程度的编译执行
  • 知道 DuckDB 和 HyPer/Umbra 的编译执行方式的核心差异
  • 能够判断什么场景下 JIT 不值得(短查询、OLTP)
  • 理解 Pipeline Breaker 的概念及其对编译执行的影响

常见卡点

卡点原因
觉得 JIT 对所有查询都有益编译本身有开销;短查询净亏
认为火山模型只存于关系数据库很多分布式查询引擎也用类似的拉取模型
混淆向量化执行和编译执行向量化(批量处理数据)可以搭配解释或编译;DuckDB 两者都用
以为 PostgreSQL JIT 编译整个查询实际上只编译了表达式和 tuple 解压部分

现在不需要理解

  • LLVM IR 的具体指令格式及优化 Pass
  • Adaptive Query Execution(运行时调整计划)
  • JIT 在多线程环境下的代码缓存策略
  • Umbra 的 adaptive indexing 机制

旅人笔记

火山模型的每次 next() 都是一副枷锁——CPU 把时间花在间接跳转上,而不是真正干活上。编译执行丢掉枷锁,把整段查询变成连贯的机器码。但枷锁也有存在理由:灵活、省内存、短查询不需要编译成本。选哪种执行方式,看你的查询是"翻整座仓库"还是"找一把钥匙"。

下一站预告

Built with VitePress | Software Systems Atlas