【从零开始编写数据库系统:架构设计与实现】第5章:查询执行引擎与火山模型

手写数据库内核!从零实现ToyDB第5章:查询执行引擎与火山模型

导语 :你的 SELECT 语句经历了 SQL 解析后,如何从磁盘上的页面变成一行行结果?本章我们将用火山模型亲手打造查询执行引擎,并且实现全表扫描、条件过滤、列投影、结果截断四大基础算子。读完本文,你将彻底看懂一条查询在数据库内部最核心的执行路径,面试时可以对执行计划娓娓道来。


一、学习目标

  • 理解迭代器模型、物化模型、向量化模型的区别,以及为什么我们选择火山模型
  • 掌握火山模型算子统一接口 open() → next() → close() 的设计哲学
  • 亲手实现 SeqScanFilterProjectLimit 四个算子
  • 构建表达式求值系统,支撑 WHERE 条件和算术运算
  • 将 AST 转换为算子树,并执行带有 WHERELIMIT 的查询
  • 通过实验观察算子执行流程,能分析虚函数调用开销

二、理论基础:数据库如何执行你的查询

在 SQL 解析器生成抽象语法树(AST)之后,数据库需要将逻辑上的"做什么"变为物理上的"怎么做"。这个转换过程就是查询执行引擎的职责。执行模型决定了数据如何在算子间流动,直接影响 CPU、内存和 I/O 的效率。

2.1 迭代器模型(火山模型)

火山模型(Volcano / Iterator Model) 由数据库先驱 Goetz Graefe 在 1994 年提出。其核心思想是:将每一个关系代数操作(扫描、过滤、投影、排序等)封装成一个迭代器(Iterator),所有迭代器都实现三个统一接口:

python 复制代码
open()  → 初始化资源
next()  → 获取下一行数据,若无数据返回 None
close() → 释放资源

上层算子通过调用下层算子的 next() 来"拉取"数据,数据像一个元组一样自底向上流动。这种按行拉取的模式天然形成流水线(pipeline),几乎不占用额外内存。
next
next
返回一行
满足条件则返回
根算子 (Project)
中间算子 (Filter)
叶子算子 (SeqScan)

优势 :接口统一,算子可任意组合;内存开销小;非常适合 OLTP 场景。
劣势:每处理一行都要经历虚函数调用链,CPU 缓存命中率低,难以利用现代 CPU 的 SIMD 批量计算。

2.2 物化模型与向量化模型

  • 物化模型:每个算子一次性处理所有输入,生成完整的结果集再传给上层。缺点是需要大量内存存放中间结果,延迟高。
  • 向量化模型:介于二者之间,每次处理一批(几百到几千行)数据,可利用 CPU 的矢量指令,大幅减少函数调用次数,是现代 OLAP 数据库(ClickHouse、DuckDB)的主流选择。

2.3 选择火山模型的理由

在 ToyDB 中,我们选择火山模型,因为:

  1. 接口清晰,代码量少,完美契合教学目的;
  2. 扩展性强,后续可平滑加入排序、聚合、连接等算子;
  3. 理解火山模型后,再学习向量化执行引擎将事半功倍。

三、架构设计:火山模型在 ToyDB 中的定位

整个执行引擎位于查询处理器的最底层,接收优化器产出的物理执行计划(或直接接收 AST),与事务管理器、缓冲池交互,完成数据读写。
查询处理器
SQL解析器
查询优化器
执行引擎
缓冲池 BufferPool
磁盘管理器
目录管理器

执行引擎内部,我们通过下列类协同工作:

职责
ExecutorContext 持有全局组件(Catalog、BufferPool、LockManager)的引用
Iterator 所有算子的抽象基类
SeqScan 全表扫描
Filter 条件过滤
Project 列投影
Limit 结果截断
ExpressionEvaluator 表达式求值(比较、算术、逻辑运算)
QueryExecutor 将 AST 转换成算子树并驱动执行

四、代码实现:手把手打造每一个算子

4.1 环境准备:ExecutorContext 与 Iterator 基类

为了让算子能够访问缓冲池和目录,我们引入执行上下文,避免全局变量。

python 复制代码
# src/query/execution/context.py
class ExecutorContext:
    def __init__(self, catalog, buffer_pool):
        self.catalog = catalog
        self.buffer_pool = buffer_pool
        self.lock_manager = None  # 为事务章节预留

所有算子的统一接口:

python 复制代码
# src/query/execution/iterator.py
from abc import ABC, abstractmethod

class Iterator(ABC):
    @abstractmethod
    def open(self) -> None: ...
    @abstractmethod
    def next(self):          # 返回 Optional[Dict]
        ...
    @abstractmethod
    def close(self) -> None: ...

4.2 全表扫描算子 SeqScan

SeqScan 从目录获取表的首页 ID,通过缓冲池逐页读取,反序列化记录并按顺序返回。
磁盘 BufferPool SeqScan 上层算子 磁盘 BufferPool SeqScan 上层算子 alt [当前页记录已耗尽] loop [每页] open() 获取首页ID next() get_page(page_id) Page 反序列化记录列表 返回一行 close()

核心代码(已进行简化):

python 复制代码
class SeqScan(Iterator):
    def __init__(self, ctx: ExecutorContext, table_name: str, schema):
        self.ctx = ctx
        self.table_name = table_name
        self.schema = schema
        self.current_page = None
        self.current_page_id = -1
        self.records = []
        self.record_idx = 0

    def open(self):
        meta = self.ctx.catalog.get_table(self.table_name)
        self.current_page_id = meta.first_page_id

    def next(self):
        while self.record_idx >= len(self.records):
            if not self._load_next_page():
                return None
        record = self.records[self.record_idx]
        self.record_idx += 1
        return record

    def _load_next_page(self) -> bool:
        if self.current_page_id == -1:
            return False
        page = self.ctx.buffer_pool.get_page(self.current_page_id)
        self.records = page.get_records_with_schema(self.schema)
        self.current_page_id = page.next_page_id
        self.record_idx = 0
        return len(self.records) > 0

    def close(self):
        self.current_page = None

4.3 投影算子 Project

Project 只返回 SELECT 指定的列,列名列表为 ["*"] 时保留所有列。

python 复制代码
class Project(Iterator):
    def __init__(self, child: Iterator, columns: List[str]):
        self.child = child
        self.columns = columns

    def open(self): self.child.open()
    def close(self): self.child.close()

    def next(self):
        row = self.child.next()
        if row is None: return None
        if self.columns == ["*"]:
            return row
        return {col: row[col] for col in self.columns if col in row}

4.4 条件过滤 Filter 与表达式求值系统

Filter 不断从子算子拉取行,送入表达式求值器判断条件,只返回 True 的行。

表达式求值器支持列引用、字面量、比较运算、算术运算以及 AND/OR 逻辑,并具备基本的类型自动转换。

表达式求值器核心:

python 复制代码
class ExpressionEvaluator:
    @staticmethod
    def evaluate(expr, record: Dict[str, Any]) -> Any:
        if isinstance(expr, str):          # 列引用
            return record.get(expr)
        if isinstance(expr, Literal):      # 常量
            return expr.value
        if isinstance(expr, BinaryExpression):
            left = ExpressionEvaluator.evaluate(expr.left, record)
            right = ExpressionEvaluator.evaluate(expr.right, record)
            left, right = ExpressionEvaluator._coerce_types(left, right)
            return ExpressionEvaluator._apply_op(expr.operator, left, right)
        raise ValueError(f"不支持的表达式类型: {type(expr)}")

    @staticmethod
    def _apply_op(op: str, left, right):
        ops = {
            "=": lambda a,b: a == b, "<>": lambda a,b: a != b,
            "<": lambda a,b: a < b,  ">": lambda a,b: a > b,
            "<=": lambda a,b: a <= b,">=": lambda a,b: a >= b,
            "+": lambda a,b: a + b,  "-": lambda a,b: a - b,
            "*": lambda a,b: a * b,  "/": lambda a,b: a / b,
            "AND": lambda a,b: bool(a) and bool(b),
            "OR":  lambda a,b: bool(a) or bool(b),
        }
        return ops[op](left, right)

    @staticmethod
    def _coerce_types(left, right):
        if type(left) == type(right):
            return left, right
        # 数值统一转为float
        if isinstance(left, (int, float)) and isinstance(right, (int, float)):
            return float(left), float(right)
        # 字符串与数值互转可在此扩展
        return left, right

Filter 算子实现:

python 复制代码
class Filter(Iterator):
    def __init__(self, child: Iterator, condition):
        self.child = child
        self.condition = condition

    def open(self): self.child.open()
    def close(self): self.child.close()

    def next(self):
        while (row := self.child.next()) is not None:
            if self.condition is None or ExpressionEvaluator.evaluate(self.condition, row):
                return row
        return None

4.5 结果截断 Limit

Limit 在内部维护计数器,一旦达到上限,无论下游是否还有数据,直接返回 None,这一特性能极大提升 TOP N 查询的性能。

python 复制代码
class Limit(Iterator):
    def __init__(self, child: Iterator, limit: int):
        self.child = child
        self.limit = limit
        self.count = 0

    def open(self):
        self.child.open()
        self.count = 0

    def next(self):
        if self.count >= self.limit:
            return None
        row = self.child.next()
        if row is not None:
            self.count += 1
        return row

    def close(self): self.child.close()

五、执行计划树:从 AST 到算子树

QueryExecutor 负责将解析后的 SelectStatement 转换成实际执行的算子树。对于 SELECT id, name FROM employees WHERE dept='tech' LIMIT 10,生成的算子树如下:
Limit(10)
Project([id, name])
Filter(dept='tech')
SeqScan(employees)

QueryExecutor 的关键逻辑:

python 复制代码
class QueryExecutor:
    def __init__(self, ctx: ExecutorContext):
        self.ctx = ctx

    def execute_select(self, stmt: SelectStatement):
        schema = self.ctx.catalog.get_table_schema(stmt.table)
        # 叶子:全表扫描
        plan = SeqScan(self.ctx, stmt.table, schema)
        # 过滤
        if stmt.where:
            plan = Filter(plan, stmt.where)
        # 投影
        plan = Project(plan, stmt.columns)
        # 截断
        if hasattr(stmt, 'limit') and stmt.limit is not None:
            plan = Limit(plan, stmt.limit)

        # 执行并收集结果
        results = []
        try:
            plan.open()
            while (row := plan.next()) is not None:
                results.append(row)
        finally:
            plan.close()
        return results

六、实验环节:亲手跑通你的第一个查询

实验 6-1:添加 LIMIT 算子并集成

  1. src/query/execution/limit.py 中实现 Limit 类(代码如上)。
  2. 修改 QueryExecutor,在构建计划时根据 stmt.limit 添加 Limit 节点。
  3. 编写测试用例 test_limit.py
    • 向表中插入 100 行数据;
    • 执行 SELECT * FROM t LIMIT 20
    • 断言返回结果数量为 20。

实验 6-2:扩展表达式求值,支持算术运算

ExpressionEvaluator._apply_op 中补全 +, -, *, / 的支持后,测试 SELECT salary * 1.1 FROM employees,观察薪水计算是否正确。

实验 6-3:使用 cProfile 观察算子调用链

在你的 Python 脚本中引入 cProfile,对带 WHERELIMIT 的查询进行性能剖析。重点关注:

  • next() 方法的调用次数;
  • 虚函数调用(抽象类方法)的累积时间;
  • Limit 提前终止对下游算子的影响。

示例命令:

python 复制代码
import cProfile
cProfile.run('executor.execute_select(stmt)', sort='cumtime')

七、常见问题与调试

  1. 算子未关闭导致缓冲池泄漏?
    SeqScan 中持有的 Page 引用若不及时释放,可能导致缓冲池页钉死(pinned)。始终使用 try...finally 确保 close() 被调用,后续可封装上下文管理器自动管理。

  2. 类型转换失败

    目前 _coerce_types 只处理了简单的数值互转,遇到 WHERE age > '25'(字符串与整数比较)会直接返回原类型,导致比较失败或意外结果。生产数据库会用更严格的类型系统,在绑定阶段就报错。暂时可手动插入显式转换函数。

  3. NULL 值处理

    在 SQL 标准中,任何与 NULL 的比较(包括 NULL = NULL)都应返回 UNKNOWN 而非 TRUE。目前我们的求值器简单把 None 等同于 Python 的 None,会比较失败。接下来的事务和存储章节会引入 NULL 的位图标记,届时需要扩展表达式求值器支持三值逻辑。

  4. Limit 算子中断下游

    当你看到 Limit 生效后,下游算子(如 Filter)的 next() 调用次数骤减,这是正常的优化效果。但如果后续扩展 ORDER BY,需要注意 Limit 必须在排序之后插入,否则结果不正确。


八、本章小结与思考题

核心要点回顾

  1. 火山模型通过 open/next/close 统一接口组织算子树,数据按行拉取、流水线执行。
  2. 我们实现了四个基础算子:SeqScanFilterProjectLimit,它们可以像乐高积木一样自由组合。
  3. 表达式求值器负责计算 WHERE 条件和算术表达式,是执行引擎的"大脑"。
  4. 执行引擎将 AST 转换为物理算子树并驱动执行,至此 ToyDB 已能完成带过滤和截断的单表查询。

思考题

  1. 如果要支持 SELECT DISTINCT 消除重复行,你会设计一个怎样的算子?
  2. LIMIT 为什么能提高查询性能?如果查询包含 ORDER BYLIMIT 应该放在算子树中的什么位置?
  3. 假设你需要实现一个支持 OFFSET 的算子,它和 Limit 有什么不同?
  4. 火山模型最大的性能瓶颈在哪里?有哪些可能的改进方向?
  5. 尝试画出你曾用过的某个数据库(如 MySQL)中 EXPLAIN 输出的执行计划,与我们现在的算子树做对比。

💬 欢迎在评论区留下你的答案和实验心得,每一条我都会认真阅读并回复!


九、下集预告与资源获取

下一章 :我们将进入查询分析与逻辑优化 ,学习谓词下推、列裁剪、常量折叠等基于规则的优化,让查询计划在物理执行前就"瘦身"成功,性能再上一个大台阶。

本章完整源码已更新到 GitHub ,包含 ExecutorContext、所有算子和表达式求值器的实现及配套测试。

🔗 GitHub仓库ToyDB - 从零实现的数据库系统

🏷️ 本章Tagchapter-05-query-execution (可对比增量代码)

📦 包含完整实验代码与测试用例

提示:如未及时更新代码库,请后续再查看,地址不变,代码一直再更新中,谢谢理解......

如果本文帮你打通了查询执行引擎的任督二脉,请点赞、收藏、转发,这是对我最大的鼓励!
关注我,第一时间获取后续章节------让我们一起,从零造出现代数据库的每个轮子。


本文为《从零开始编写数据库系统:架构设计与实现》系列第5章,作者:安楠的数智笔记。未经授权,禁止转载。

相关推荐
逻辑诗篇1 小时前
破核拆解:PCIE719——基于Xilinx Zynq UltraScale+的高性能SAS扩展卡设计
fpga开发·架构
天空属于哈夫克32 小时前
企业微信API常见的错误和解决方案
java·数据库·企业微信
wenzhangli72 小时前
Ooder A2UI 核心架构深度解析:WEB 拦截层的设计与实现
前端·架构
东风破1372 小时前
DM8达梦数据库备份、恢复原理介绍
数据库·oracle·dm达梦数据库
福大大架构师每日一题2 小时前
openclaw v2026.4.24 发布:Google Meet 深度集成、DeepSeek V4 上线、浏览器自动化与插件架构全面升级
运维·架构·自动化·openclaw
鹏子训2 小时前
AI记忆新思路:用SQLite替代向量数据库,去EMBEDDINGS化,谷歌开源Google Always On Memory Agent
数据库·人工智能·sqlite·embedding
身如柳絮随风扬2 小时前
深度解析 Elasticsearch 搜索服务:核心原理、架构与优化实践
大数据·elasticsearch·架构
Frank_refuel2 小时前
终端环境下:Ubuntu 22.04.1 安装 MySQL 数据库
数据库·mysql·ubuntu
面汤放盐3 小时前
从单体架构到微服务架构:模式与最佳实践
微服务·云原生·架构