Go语言从零构建SQL数据库(8):执行计划的奥秘

142 阅读5分钟

从SQL语句到高效查询:执行计划的奥秘

想象你是一位旅行者,想从北京到上海。你告诉导航软件你的目的地(类似SQL查询),但导航软件需要为你规划具体路线——是走高速公路还是国道?是选择最短距离还是最省时间的路线?这个"路线规划",就像数据库中的执行计划。

SQL查询的挑战

当用户输入一个SQL查询时,他们只是表达了"我想要什么数据",而没有指定"如何获取这些数据"。例如:

SELECT name, age FROM users WHERE salary > 5000

这告诉数据库:我需要薪资超过5000的用户的姓名和年龄。但数据库如何高效地找到这些数据呢?

graph LR
    A[查询方案1] --> B[扫描全表] --> C[过滤薪资>5000] --> D[返回name,age]
    E[查询方案2] --> F[使用薪资索引] --> G[获取匹配记录] --> H[返回name,age]
    
    style A fill:#f9f,stroke:#333
    style E fill:#bbf,stroke:#333
  • 是先读取所有用户记录,再筛选出薪资高的?
  • 还是先通过索引找到高薪用户,再获取他们的姓名和年龄?
  • 如果有几百万用户,两种方法的性能可能相差上千倍!

数据库的"导航系统"

执行计划正是解决这一难题的关键——它将"我要什么数据"转换为"如何高效获取这些数据"的具体步骤。

好的执行计划能带来惊人的性能提升:同样的查询,可能从几分钟缩短到几毫秒。这就是为什么执行计划在数据库引擎中如此重要。

graph TD
    A[SQL语句] --> B[语法分析]
    B --> C[语法树AST]
    C --> D[逻辑计划生成]
    D --> E[逻辑计划]
    E --> F[优化器]
    F --> G[优化后的逻辑计划]
    G --> H[物理计划生成器]
    H --> I[物理执行计划]
    I --> J[执行引擎]
    J --> K[查询结果]
    
    style A fill:#f9f,stroke:#333
    style K fill:#bbf,stroke:#333

两层设计的智慧

执行计划通常分为两个层次:

  1. 逻辑计划:描述查询的逻辑步骤,好比"从北京到上海"
  2. 物理计划:确定具体执行算法,相当于"走G2高速,经过济南,预计用时5小时"
graph TD
    A[逻辑计划] --> B[物理计划A: 顺序扫描]
    A --> C[物理计划B: 索引扫描]
    A --> D[物理计划C: 哈希连接]
    
    style A fill:#f9f,stroke:#333
    style B fill:#bbf,stroke:#333
    style C fill:#bbf,stroke:#333
    style D fill:#bbf,stroke:#333

这种分层设计非常聪明,原因有三:

  • 关注点分离:先确定要做什么,再决定怎么做
  • 优化灵活性:同一逻辑操作可以有多种物理实现方式
  • 技术升级简化:可以改进物理实现(比如新算法)而不影响上层逻辑

就像你的旅行,先确定要去上海(逻辑目标),再决定是坐飞机、高铁还是自驾(物理实现)。

构建执行计划的核心组件

计划节点:组织成树状结构

执行计划通常构建为树状结构,这种设计很自然地反映了数据处理的流程:

graph TD
    A[投影: SELECT name, age] --> B[过滤: WHERE salary > 5000]
    B --> C[表扫描: users表]
    
    style A fill:#bbf,stroke:#333
    style B fill:#bfb,stroke:#333
    style C fill:#fbb,stroke:#333
  • 叶子节点:获取数据的来源(如表扫描)
  • 中间节点:处理数据的操作(如过滤、排序)
  • 数据自下而上流动:从数据源到最终结果

这种树状结构非常清晰,每个节点只关心接收数据、处理数据和输出数据,不需要了解整个查询的复杂性。

接口设计:灵活性的基石

我们为执行计划设计了接口而非具体类型,这带来了巨大的灵活性:

type LogicalPlan interface {
    PlanType() PlanType
    Children() []LogicalPlan
    Schema() *Schema
    String() string
}
classDiagram
    class LogicalPlan {
        <<interface>>
        +PlanType() PlanType
        +Children() []LogicalPlan
        +Schema() *Schema
        +String() string
    }
    
    class LogicalTableScan {
        +TableName string
        +Schema *Schema
    }
    
    class LogicalFilter {
        +Input LogicalPlan
        +Condition Expression
    }
    
    class LogicalProjection {
        +Input LogicalPlan
        +Expressions []Expression
        +OutputSchema *Schema
    }
    
    LogicalPlan <|.. LogicalTableScan
    LogicalPlan <|.. LogicalFilter
    LogicalPlan <|.. LogicalProjection

这种接口设计有几个关键好处:

  1. 扩展性:可以轻松添加新的计划节点类型
  2. 低耦合:不同模块通过接口交互,而非具体实现
  3. 测试便利:可以创建模拟实现进行单元测试

想象一下,如果没有这种接口设计,每添加一种新的操作类型(如窗口函数),就需要修改整个系统的代码。

Schema系统:类型的守护者

执行计划需要知道每个操作产生的数据结构,这就是Schema的作用:

type Schema struct {
    Columns []*Column
}

type Column struct {
    Name     string
    Table    string
    DataType DataType
}
classDiagram
    class Schema {
        +Columns []*Column
    }
    
    class Column {
        +Name string
        +Table string
        +DataType DataType
    }
    
    Schema "1" --> "*" Column

Schema系统看似简单,但功能强大:

  1. 类型检查:确保操作应用于兼容类型(不能对字符串求平均值)
  2. 列引用解析:当有多表连接时,确定列来自哪个表
  3. 结果描述:告诉客户端返回数据的结构

没有Schema,数据库就像是没有类型检查的语言,可能在运行时才发现致命错误。

表达式系统:计算的引擎

表达式是查询的核心,表示过滤条件、计算列等。设计独立的表达式系统有多重好处:

  1. 统一表示:无论是简单比较还是复杂函数,都用同一套系统表示
  2. 类型安全:在执行前检查表达式类型是否正确
  3. 优化机会:可以对表达式进行变换和优化

例如,系统可以自动将 price * 0 优化为常量 0,避免不必要的计算。

完整的计划转换流程

当我们把所有组件结合起来,就能实现从SQL到执行计划的完整转换:

flowchart LR
    A[SQL文本] --> B[词法分析器]
    B --> C[语法分析器]
    C --> D[语法树AST]
    D --> E[计划生成器]
    E --> F[逻辑计划]
    F --> G[优化器]
    G --> H[优化后的逻辑计划]
    H --> I[物理计划生成器]
    I --> J[物理执行计划]
    J --> K[执行引擎]
    K --> L[查询结果]
    
    style A fill:#f9f,stroke:#333
    style L fill:#bbf,stroke:#333

这整个流程看似复杂,但每一步都有其不可替代的作用,共同确保数据库能够高效地执行查询。

总结与未来方向

通过这种设计,我们的数据库引擎能够:

  1. 将用户的声明式查询转换为高效的执行步骤
  2. 灵活应对各种查询模式和数据规模
  3. 为后续优化提供坚实基础
graph TD
    A[当前系统] --> B[增加聚合操作]
    A --> C[实现基于成本的优化]
    A --> D[丰富表达式函数]
    A --> E[优化物理执行策略]
    
    style A fill:#f9f,stroke:#333
    style B fill:#bbf,stroke:#333
    style C fill:#bbf,stroke:#333
    style D fill:#bbf,stroke:#333
    style E fill:#bbf,stroke:#333

未来,我们还可以增强系统的多个方面:

  • 支持更多的操作类型(如聚合、排序)
  • 实现基于成本的查询优化
  • 添加更丰富的表达式函数
  • 优化物理执行策略

理解了执行计划的设计理念,我们就能更好地构建高性能、可扩展的数据库系统。下一篇文章,我们将探讨如何实现对SELECT * 查询的支持,进一步完善我们的执行计划生成器。