土法炼钢兴趣小组的算法知识备份

查询优化器:从 System R 到 Cascades

目录

SQL 是声明式的——你告诉数据库”要什么”,而非”怎么做”。把”要什么”翻译成”怎么做”的那个组件,就是查询优化器。它可能是整个数据库系统中最复杂、最精妙,也最容易被低估的部分。

本文从经典的 System R 优化器出发,逐步深入到现代 Cascades 框架,覆盖基数估算、代价模型、连接顺序选择、子查询去关联、自适应执行,以及近年来兴起的学习型优化器。最后,我们会用大约 200 行 Python 实现一个可运行的 Bottom-Up 优化器。

一、同一条 SQL,千倍性能差异

先看一个直觉性的例子。假设有三张表:

-- orders:    10,000,000 行
-- customers:    500,000 行
-- products:     100,000 行

SELECT c.name, p.title, SUM(o.amount)
FROM   orders o
  JOIN customers c ON o.cust_id = c.id
  JOIN products  p ON o.prod_id = p.id
WHERE  c.country = 'CN'
  AND  p.category = 'electronics'
GROUP BY c.name, p.title;

如果优化器选择了一个糟糕的执行计划——例如先对 ordersproducts 做笛卡尔积,再过滤——运行时间可能是小时级。而一个好的计划——先用索引过滤 customersproducts,再分别与 orders 做 Hash Join——可能只需要几百毫秒。

差异的根源在于:

  1. 连接顺序不同:n 张表的连接有 n!/2 种左深树排列。
  2. 物理算子不同:同一个逻辑 Join 可以用 Nested Loop、Hash Join、Sort-Merge Join 实现。
  3. 访问路径不同:全表扫描、索引扫描、覆盖索引,代价天差地别。

优化器的任务,就是在这个巨大的搜索空间中,找到代价最低(或足够低)的执行计划。

搜索空间的规模

连接表数 n 左深树数量 Bushy 树数量
4 24 120
6 720 665,280
8 40,320 约 1.7 亿
10 3,628,800 约 1.76 万亿

每棵树的每个节点还要选择物理算子和访问路径,因此真实搜索空间远大于上表。这就是为什么查询优化本质上是一个组合优化问题。

二、System R 优化器(1979)

1979 年 Selinger 等人发表的论文”Access Path Selection in a Relational Database Management System”奠定了关系数据库优化器的基石。几乎所有现代数据库的优化器都直接或间接受其影响。

核心思路:动态规划

System R 优化器使用自底向上的动态规划算法。其核心思想是:

  1. 先枚举所有单表的最优访问路径。
  2. 然后枚举所有两表连接的最优计划。
  3. 依次增加表的数量,直到覆盖所有表。

每一步都只保留代价最低的子计划,利用最优子结构性质避免重复计算。

// 伪代码:System R 动态规划
for each single table T:
    bestPlan[{T}] = cheapest access path for T

for i = 2 to n:
    for each subset S of size i:
        for each way to split S into (S1, S2)
            where S1 and S2 are non-empty, S1 ∪ S2 = S:
            plan = join(bestPlan[S1], bestPlan[S2])
            if cost(plan) < cost(bestPlan[S]):
                bestPlan[S] = plan

return bestPlan[{all tables}]

左深树限制

System R 只考虑左深树(left-deep tree),即右侧输入始终是基表。这将搜索空间从 Catalan 数量级压缩到 n! 量级。虽然可能错过某些最优的 Bushy 树计划,但在实践中效果很好,因为左深树更容易实现流水线执行。

左深树:            Bushy 树:
    ⋈                  ⋈
   / \               /   \
  ⋈   D             ⋈     ⋈
 / \               / \   / \
⋈   C             A   B C   D
/ \
A   B

“有趣顺序”(Interesting Orders)

这是 System R 最精妙的洞察之一。考虑以下情况:

SELECT * FROM orders o JOIN customers c ON o.cust_id = c.id
ORDER BY c.name;

假设对 customersname 排序的索引扫描比全表扫描代价高 20%,但它产生的有序输出可以避免最终的排序操作。如果只看局部代价,优化器会选择全表扫描;但从全局来看,索引扫描反而更优。

System R 的解决方案是:对于每个子问题,不仅保留代价最低的计划,还保留所有具有”有趣顺序”的计划。有趣顺序包括:

# 每个子问题维护多个计划(有趣顺序)
best_plans = {}  # key: (table_set, order) -> plan
# ({customers}, order_by_name) -> IndexScan(customers, name_idx)
# ({customers}, no_order)      -> SeqScan(customers)

System R 代价模型

System R 的代价公式非常简洁:

Cost = W * (CPU 代价) + (1 - W) * (I/O 代价)

其中 I/O 代价以页面(page)读取次数衡量。统计信息包括:

这一代价模型虽然简单,但奠定了后来所有代价模型的基本范式。

三、Volcano/Cascades 框架

System R 的自底向上方法有两个局限:

  1. 当搜索空间很大时,会枚举大量永远不会被选中的子计划。
  2. 规则的添加和管理缺乏模块化。

1995 年 Graefe 提出的 Cascades 框架(以及其前身 Volcano Optimizer Generator)解决了这两个问题。

自顶向下的搜索

与 System R 自底向上不同,Cascades 从查询的根节点开始,自顶向下地展开搜索。这允许通过分支限界(branch-and-bound)提前剪枝——如果当前路径的代价已经超过已知的最优代价,就不再继续展开。

OptimizeGroup(group, requiredProperties, upperBound):
    if group.winner exists for requiredProperties:
        return group.winner

    for each expression e in group:
        // 尝试变换规则
        for each transformation rule r applicable to e:
            newExpr = r.apply(e)
            add newExpr to group  // 去重

        // 尝试实现规则
        for each implementation rule r applicable to e:
            physExpr = r.apply(e)
            cost = physExpr.localCost
            for each child group g_i of physExpr:
                remaining = upperBound - cost
                if remaining <= 0: break  // 剪枝
                cost += OptimizeGroup(g_i, required_i, remaining)
            if cost < upperBound:
                upperBound = cost
                group.winner = physExpr

Memo 表

Cascades 的核心数据结构是 Memo 表(又叫 MEMO 或 Search Space)。它是一个 DAG,节点分为两类:

  1. Group(等价类):一组逻辑等价的表达式,产生相同的输出。
  2. Group Expression(组表达式):一个具体的算子,其子节点指向 Group 而非具体表达式。
Cascades Memo 表结构

例如,对于查询 SELECT * FROM R JOIN S ON R.a = S.b,Memo 表可能包含:

Group 0 (R ⋈ S):
  Logical:  InnerJoin(G1, G2)     -- 原始
  Logical:  InnerJoin(G2, G1)     -- 交换律
  Physical: HashJoin(G1, G2)
  Physical: MergeJoin(G1, G2)
  Physical: NLJoin(G1, G2)

Group 1 (R):
  Logical:  Get(R)
  Physical: SeqScan(R)
  Physical: IdxScan(R, R.a_idx)

Group 2 (S):
  Logical:  Get(S)
  Physical: SeqScan(S)
  Physical: IdxScan(S, S.b_idx)

这种结构的好处是共享子表达式——不同的等价变换可以复用同一个子 Group,避免重复优化。

规则系统

Cascades 将所有优化逻辑封装为规则:

变换规则(逻辑到逻辑,扩展搜索空间)和实现规则(逻辑到物理,生成可执行算子)。

变换规则:
  JoinCommutativity:   A ⋈ B  ->  B ⋈ A
  JoinAssociativity:   (A ⋈ B) ⋈ C  ->  A ⋈ (B ⋈ C)
  PredicatePushdown:   Filter(Join(A,B), p(A))  ->  Join(Filter(A,p(A)), B)

实现规则:
  InnerJoin  ->  HashJoin | SortMergeJoin | NestedLoopJoin
  LogicalGet ->  SeqScan | IndexScan

这种模块化设计使得添加新算子或新优化规则变得简单——只需注册新规则,无需修改优化器的核心代码。

属性强制(Property Enforcement)

Cascades 中 Merge Join 要求输入按连接键排序。若子 Group 最优计划不满足排序要求,自动插入 Sort 算子。若已有有序的 IndexScan,则无需额外排序。

现代应用

数据库 优化器框架 说明
SQL Server Cascades 最早的工业级 Cascades 实现
CockroachDB Cascades Go 实现,开源可读
Apache Calcite Volcano Java 实现,广泛用于大数据系统
Greenplum ORCA (Cascades) 独立优化器进程,支持分布式
TiDB Cascades (实验) 正在从 System R 风格迁移

四、基数估算

查询优化的质量高度依赖于基数估算(Cardinality Estimation)的准确性。代价模型再精确,如果输入的行数估算偏差 100 倍,输出的计划也不可能好。

直方图(Histograms)

直方图是最基本的统计工具。数据库收集每列的值分布,将值域划分为若干桶(bucket)。

等宽直方图:每个桶覆盖相同的值域宽度。

Column: age (0-100)
Bucket [0,20):   count = 1500
Bucket [20,40):  count = 8200
Bucket [40,60):  count = 6300
Bucket [60,80):  count = 3500
Bucket [80,100]: count = 500
Total: 20000 rows

估算 WHERE age BETWEEN 30 AND 50

Bucket [20,40): 覆盖 30-40,占桶宽的 1/2 -> 8200 * 0.5 = 4100
Bucket [40,60): 覆盖 40-50,占桶宽的 1/2 -> 6300 * 0.5 = 3150
估算结果: 4100 + 3150 = 7250 行

等深直方图:每个桶包含大致相同数量的行。这在数据分布不均匀时更有效。PostgreSQL 默认使用等深直方图。

最常见值(MCV,Most Common Values)

对于高频值,直方图的分桶可能丢失精度。因此数据库通常额外维护一个 MCV 列表:

-- PostgreSQL 中查看统计信息
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM   pg_stats
WHERE  tablename = 'orders' AND attname = 'status';

-- 输出示例:
-- most_common_vals:  {pending, shipped, delivered, cancelled}
-- most_common_freqs: {0.35, 0.30, 0.25, 0.10}

估算 WHERE status = 'pending' 的基数:

行数 = 总行数 * 频率 = 10,000,000 * 0.35 = 3,500,000

独立性假设的失败

传统优化器假设各列之间统计独立。对于复合谓词:

WHERE city = 'Beijing' AND country = 'CN'

选择率 = sel(city=‘Beijing’) * sel(country=‘CN’)。

但显然 citycountry 高度相关!如果 city='Beijing' 的选择率是 0.05,country='CN' 的选择率是 0.15,独立性假设会给出 0.05 * 0.15 = 0.0075,而真实选择率其实就是 0.05(北京一定在中国)。这种低估可能导致优化器选择 Nested Loop 而非 Hash Join,造成灾难性的性能问题。

多列统计

为了对抗独立性假设的失败,现代数据库引入了多列统计:

-- PostgreSQL 多列统计
CREATE STATISTICS city_country_stats (dependencies)
    ON city, country FROM addresses;

ANALYZE addresses;

PostgreSQL 支持三种多列统计类型:

类型 功能 适用场景
dependencies 函数依赖(A -> B) 高度相关列
ndistinct 多列组合的不同值数量 GROUP BY 估算
mcv 多列组合的最常见值 复合等值谓词

基数估算误差的传播

基数估算误差在多层 Join 中指数放大——每层误差倍数为 e 时,k 层后变为 e^k。这就是为什么复杂查询的计划选择仍可能出错。

五、代价模型

代价模型将执行计划映射为一个标量数值,使得优化器可以比较不同计划的优劣。一个好的代价模型需要平衡精度与计算开销。

经典代价因子

总代价 = CPU代价 + I/O代价 + 网络代价(分布式场景)

CPU代价:
  - 每行处理代价(比较、哈希计算、表达式求值)
  - 函数调用代价

I/O代价:
  - 顺序读页面代价(seq_page_cost)
  - 随机读页面代价(random_page_cost)
  - 写页面代价

PostgreSQL 的代价参数

-- PostgreSQL 默认代价参数
SHOW seq_page_cost;       -- 1.0(基准)
SHOW random_page_cost;    -- 4.0(随机I/O是顺序的4倍)
SHOW cpu_tuple_cost;      -- 0.01
SHOW cpu_index_tuple_cost;-- 0.005
SHOW cpu_operator_cost;   -- 0.0025
SHOW parallel_tuple_cost; -- 0.1
SHOW effective_cache_size;-- 4GB(影响索引扫描代价估算)

各算子的代价公式

SeqScan:
  cost = seq_page_cost * pages + cpu_tuple_cost * rows

IndexScan:
  cost = random_page_cost * idx_pages + seq_page_cost * heap_pages
       + cpu_index_tuple_cost * idx_tuples + cpu_tuple_cost * rows

HashJoin:
  cost = cost(outer) + cost(inner)
       + cpu_operator_cost * inner_rows           -- 建哈希表
       + cpu_operator_cost * outer_rows           -- 探测
       + cpu_tuple_cost * output_rows

MergeJoin:
  cost = cost(sort_outer) + cost(sort_inner)
       + cpu_operator_cost * (outer + inner) + cpu_tuple_cost * output

MySQL 8.0 代价模型的变化

MySQL 8.0 引入了两张系统表 mysql.server_costmysql.engine_cost,存储可调的代价常数:

-- 关键代价常数
-- server_cost:  row_evaluate_cost=0.1, key_compare_cost=0.05
-- engine_cost:  io_block_read_cost=1.0, memory_block_read_cost=0.25

相比 MySQL 5.7,核心改进在于区分内存和磁盘 I/O 代价,以及根据 Buffer Pool 命中率动态调整。DBA 可以根据实际硬件(如 SSD)调整参数:

UPDATE mysql.engine_cost SET cost_value = 0.5
WHERE  cost_name = 'io_block_read_cost';
FLUSH OPTIMIZER_COSTS;

六、连接顺序选择

连接顺序选择是查询优化中最核心的子问题。给定 n 张表,如何找到最优的连接顺序?

动态规划:O(2^n)

经典的动态规划算法枚举所有子集及其划分:

def dp_join_order(tables, edges):
    """
    tables: 表的列表
    edges:  连接谓词 (表i, 表j) -> 谓词
    返回:   最优计划及其代价
    """
    n = len(tables)
    dp = {}  # bitmask -> (cost, plan)

    # 基础:单表
    for i in range(n):
        mask = 1 << i
        dp[mask] = (scan_cost(tables[i]), ScanPlan(tables[i]))

    # 枚举所有子集大小从2到n
    for size in range(2, n + 1):
        for subset in combinations(range(n), size):
            mask = sum(1 << i for i in subset)
            best = (float('inf'), None)

            # 枚举所有非空真子集作为分割
            sub = mask
            while sub > 0:
                complement = mask & ~sub
                if complement > 0 and sub in dp and complement in dp:
                    # 检查 sub 和 complement 之间是否有连接谓词
                    if has_join_predicate(sub, complement, edges):
                        cost = dp[sub][0] + dp[complement][0] \
                             + join_cost(dp[sub], dp[complement])
                        if cost < best[0]:
                            best = (cost, JoinPlan(dp[sub][1],
                                                   dp[complement][1]))
                sub = (sub - 1) & mask

            if best[1] is not None:
                dp[mask] = best

    full_mask = (1 << n) - 1
    return dp[full_mask]

时间复杂度为 O(3^n)——枚举所有子集的所有子集。空间复杂度为 O(2^n)。当 n <= 10 时可接受,但 n > 15 时基本不可行。

贪心启发式

对于大量表的连接,贪心算法提供了一个多项式时间的近似解:

def greedy_join_order(tables, edges):
    """贪心:每次选代价最低的两张表连接"""
    remaining = set(range(len(tables)))
    plans = {i: ScanPlan(tables[i]) for i in remaining}

    while len(remaining) > 1:
        best_cost = float('inf')
        best_pair = None

        for i in remaining:
            for j in remaining:
                if i < j and has_join_predicate(i, j, edges):
                    cost = join_cost(plans[i], plans[j])
                    if cost < best_cost:
                        best_cost = cost
                        best_pair = (i, j)

        if best_pair is None:
            break

        i, j = best_pair
        new_id = min(remaining)
        plans[new_id] = JoinPlan(plans[i], plans[j])
        remaining.discard(i)
        remaining.discard(j)
        remaining.add(new_id)

    return plans[min(remaining)]

贪心的时间复杂度为 O(n^3),但可能错过全局最优解。

PostgreSQL 的 GEQO(遗传查询优化)

当连接表数超过 geqo_threshold(默认 12)时,PostgreSQL 切换到遗传算法:

-- PostgreSQL 遗传优化参数
SHOW geqo;                -- on
SHOW geqo_threshold;      -- 12
SHOW geqo_effort;         -- 5 (1-10)
SHOW geqo_pool_size;      -- 0 (自动,通常为 2*effort)
SHOW geqo_generations;    -- 0 (自动)
SHOW geqo_selection_bias; -- 2.0

遗传算法的核心流程:

1. 编码:连接顺序 -> 排列 [3,1,4,2,5](依次连接表3、表1、表4...)
2. 初始种群:随机生成若干排列
3. 适应度:计划代价越低,适应度越高
4. 交叉(Edge Recombination):
   父代1: [3,1,4,2,5]   父代2: [2,5,1,3,4]
   子代:  [3,1,2,5,4]   保留父1前半+父2相对顺序
5. 变异:随机交换两个位置
6. 迭代若干代后返回最优个体

GEQO 不保证全局最优,但对于大量表的连接能在合理时间内找到不错的计划。不过它有一个著名的问题——由于随机性,同一查询每次执行可能生成不同的计划。

七、子查询去关联

子查询是 SQL 的强大特性,但关联子查询(correlated subquery)如果按照字面语义执行,性能可能极差。

问题:关联子查询的朴素执行

-- 找出每个部门工资最高的员工
SELECT e.name, e.salary, e.dept_id
FROM   employees e
WHERE  e.salary = (
    SELECT MAX(e2.salary)
    FROM   employees e2
    WHERE  e2.dept_id = e.dept_id  -- 关联引用
);

朴素执行:对外层 employees 的每一行,都执行一次内层子查询。如果 employees 有 100 万行,就执行 100 万次子查询。

去关联技术

方法一:子查询转换为 Join

-- 去关联后
SELECT e.name, e.salary, e.dept_id
FROM   employees e
  JOIN (
    SELECT dept_id, MAX(salary) AS max_sal
    FROM   employees
    GROUP BY dept_id
  ) m ON e.dept_id = m.dept_id AND e.salary = m.max_sal;

现在内层查询只执行一次,结果与外层做 Join。

方法二:Apply(Lateral Join)转换

Cascades 框架中,去关联通过以下步骤进行:

原始:
  Filter(e, e.salary = Apply[dept_id -> MAX(salary)])

步骤1: 将 Apply 转换为 Lateral Join
  LateralJoin(e, Agg(Filter(e2, e2.dept_id = e.dept_id)))

步骤2: 消除关联(将关联谓词下推为 Join 条件)
  InnerJoin(e, Agg(e2, group_by=dept_id), 
            e.dept_id = agg.dept_id AND e.salary = agg.max_sal)

方法三:窗口函数替代

-- 使用窗口函数(无需子查询)
SELECT name, salary, dept_id
FROM (
    SELECT name, salary, dept_id,
           MAX(salary) OVER (PARTITION BY dept_id) AS max_sal
    FROM   employees
) t
WHERE salary = max_sal;

EXISTS 子查询的去关联

-- 原始
SELECT c.name
FROM   customers c
WHERE  EXISTS (
    SELECT 1 FROM orders o
    WHERE  o.cust_id = c.id AND o.amount > 1000
);

-- 去关联为 Semi Join
SELECT c.name
FROM   customers c
  SEMI JOIN orders o ON o.cust_id = c.id AND o.amount > 1000;

Semi Join 只需要找到第一条匹配就可以停止,不会产生重复行。

NOT EXISTS 与 Anti Join

-- 原始
SELECT c.name
FROM   customers c
WHERE  NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.cust_id = c.id
);

-- 去关联为 Anti Join
SELECT c.name
FROM   customers c
  ANTI JOIN orders o ON o.cust_id = c.id;

去关联的限制

并非所有关联子查询都能去关联。含 LIMIT、非确定性函数(RANDOM())或复杂嵌套关联引用的子查询通常无法处理。此时数据库退回朴素策略,但会通过缓存(memoization)避免对相同参数重复执行。

八、完整实现:Bottom-Up 迷你优化器

下面我们用 Python 实现一个完整的自底向上优化器。它支持:

"""
mini_optimizer.py -- 自底向上查询优化器
支持: SeqScan, IndexScan, NLJoin, HashJoin, MergeJoin
"""
from __future__ import annotations
import itertools
from dataclasses import dataclass, field
from typing import Optional

# ============================
# 数据结构
# ============================

@dataclass
class TableStats:
    """表的统计信息"""
    name: str
    rows: int
    pages: int
    indexes: dict[str, "IndexStats"] = field(default_factory=dict)

@dataclass
class IndexStats:
    """索引的统计信息"""
    column: str
    distinct: int
    pages: int
    ordered: bool = True

@dataclass
class JoinEdge:
    """连接条件"""
    left_table: str
    left_col: str
    right_table: str
    right_col: str
    selectivity: float = 0.1

@dataclass
class PhysicalPlan:
    """物理执行计划"""
    op: str
    cost: float
    rows: float
    order: Optional[str] = None   # 输出排序键
    children: list["PhysicalPlan"] = field(default_factory=list)
    info: str = ""

    def __repr__(self):
        return self._fmt(0)

    def _fmt(self, indent: int) -> str:
        prefix = "  " * indent
        line = f"{prefix}{self.op}  (cost={self.cost:.1f}, rows={self.rows:.0f}"
        if self.order:
            line += f", order={self.order}"
        line += f")  {self.info}"
        parts = [line]
        for c in self.children:
            parts.append(c._fmt(indent + 1))
        return "\n".join(parts)

# ============================
# 代价常数
# ============================

SEQ_PAGE_COST      = 1.0
RANDOM_PAGE_COST   = 4.0
CPU_TUPLE_COST     = 0.01
CPU_INDEX_COST     = 0.005
CPU_OPERATOR_COST  = 0.0025
HASH_BUILD_COST    = 0.02
HASH_PROBE_COST    = 0.01
SORT_CPU_FACTOR    = 0.05

# ============================
# 单表访问路径
# ============================

def seq_scan(table: TableStats) -> PhysicalPlan:
    cost = SEQ_PAGE_COST * table.pages + CPU_TUPLE_COST * table.rows
    return PhysicalPlan(
        op="SeqScan", cost=cost, rows=table.rows,
        info=f"on {table.name}"
    )

def index_scan(table: TableStats, idx_name: str,
               idx: IndexStats, selectivity: float = 1.0) -> PhysicalPlan:
    est_rows = table.rows * selectivity
    pages_fetched = min(table.pages, int(est_rows * table.pages / table.rows) + 1)
    cost = (RANDOM_PAGE_COST * min(idx.pages, int(est_rows / idx.distinct * idx.pages) + 1)
            + SEQ_PAGE_COST * pages_fetched
            + CPU_INDEX_COST * est_rows
            + CPU_TUPLE_COST * est_rows)
    return PhysicalPlan(
        op="IndexScan", cost=cost, rows=est_rows,
        order=idx.column if idx.ordered else None,
        info=f"on {table.name} using {idx_name}"
    )

# ============================
# 排序
# ============================

def sort_plan(child: PhysicalPlan, sort_key: str) -> PhysicalPlan:
    if child.order == sort_key:
        return child
    n = max(child.rows, 1)
    import math
    sort_cost = SORT_CPU_FACTOR * n * math.log2(max(n, 2))
    total = child.cost + sort_cost
    return PhysicalPlan(
        op="Sort", cost=total, rows=child.rows,
        order=sort_key, children=[child],
        info=f"key={sort_key}"
    )

# ============================
# Join 算子
# ============================

def nested_loop_join(outer: PhysicalPlan, inner: PhysicalPlan,
                     edge: JoinEdge, out_rows: float) -> PhysicalPlan:
    cost = (outer.cost
            + outer.rows * inner.cost
            + CPU_TUPLE_COST * out_rows)
    return PhysicalPlan(
        op="NLJoin", cost=cost, rows=out_rows,
        children=[outer, inner],
        info=f"{edge.left_table}.{edge.left_col}={edge.right_table}.{edge.right_col}"
    )

def hash_join(build: PhysicalPlan, probe: PhysicalPlan,
              edge: JoinEdge, out_rows: float) -> PhysicalPlan:
    cost = (build.cost + probe.cost
            + HASH_BUILD_COST * build.rows
            + HASH_PROBE_COST * probe.rows
            + CPU_TUPLE_COST * out_rows)
    return PhysicalPlan(
        op="HashJoin", cost=cost, rows=out_rows,
        children=[build, probe],
        info=f"{edge.left_table}.{edge.left_col}={edge.right_table}.{edge.right_col}"
    )

def merge_join(left: PhysicalPlan, right: PhysicalPlan,
               edge: JoinEdge, out_rows: float) -> PhysicalPlan:
    left_sorted = sort_plan(left, f"{edge.left_table}.{edge.left_col}")
    right_sorted = sort_plan(right, f"{edge.right_table}.{edge.right_col}")
    merge_cost = CPU_OPERATOR_COST * (left_sorted.rows + right_sorted.rows)
    cost = left_sorted.cost + right_sorted.cost + merge_cost + CPU_TUPLE_COST * out_rows
    return PhysicalPlan(
        op="MergeJoin", cost=cost, rows=out_rows,
        order=f"{edge.left_table}.{edge.left_col}",
        children=[left_sorted, right_sorted],
        info=f"{edge.left_table}.{edge.left_col}={edge.right_table}.{edge.right_col}"
    )

# ============================
# 动态规划优化器
# ============================

class BottomUpOptimizer:
    def __init__(self, tables: list[TableStats], edges: list[JoinEdge],
                 interesting_orders: list[str] | None = None):
        self.tables = {t.name: t for t in tables}
        self.table_list = tables
        self.edges = edges
        self.interesting_orders = interesting_orders or []
        # dp[frozenset(table_names)] -> dict[order_key, PhysicalPlan]
        # order_key: None 表示不关心排序,字符串表示输出排序
        self.dp: dict[frozenset[str], dict[Optional[str], PhysicalPlan]] = {}

    def optimize(self) -> PhysicalPlan:
        self._init_single_tables()
        n = len(self.table_list)
        names = [t.name for t in self.table_list]

        for size in range(2, n + 1):
            for combo in itertools.combinations(names, size):
                table_set = frozenset(combo)
                self._optimize_join(table_set)

        full_set = frozenset(names)
        plans = self.dp.get(full_set, {})
        if not plans:
            raise ValueError("无法找到可行的连接计划")
        return min(plans.values(), key=lambda p: p.cost)

    def _init_single_tables(self):
        for t in self.table_list:
            plans: dict[Optional[str], PhysicalPlan] = {}
            ss = seq_scan(t)
            plans[None] = ss
            for idx_name, idx in t.indexes.items():
                isp = index_scan(t, idx_name, idx)
                order_key = f"{t.name}.{idx.column}"
                if order_key in self.interesting_orders:
                    if order_key not in plans or isp.cost < plans[order_key].cost:
                        plans[order_key] = isp
                if isp.cost < plans[None].cost:
                    plans[None] = isp
            self.dp[frozenset([t.name])] = plans

    def _find_edges(self, left_set: frozenset[str],
                    right_set: frozenset[str]) -> list[JoinEdge]:
        result = []
        for e in self.edges:
            if ((e.left_table in left_set and e.right_table in right_set) or
                (e.left_table in right_set and e.right_table in left_set)):
                result.append(e)
        return result

    def _optimize_join(self, table_set: frozenset[str]):
        best: dict[Optional[str], PhysicalPlan] = {}
        tables_list = sorted(table_set)

        for i in range(1, len(tables_list)):
            for left_combo in itertools.combinations(tables_list, i):
                left_set = frozenset(left_combo)
                right_set = table_set - left_set
                if not right_set:
                    continue

                if left_set not in self.dp or right_set not in self.dp:
                    continue

                join_edges = self._find_edges(left_set, right_set)
                if not join_edges:
                    continue

                edge = join_edges[0]
                sel = edge.selectivity
                for e in join_edges[1:]:
                    sel *= e.selectivity

                for l_order, l_plan in self.dp[left_set].items():
                    for r_order, r_plan in self.dp[right_set].items():
                        out_rows = l_plan.rows * r_plan.rows * sel

                        candidates = [
                            hash_join(l_plan, r_plan, edge, out_rows),
                            hash_join(r_plan, l_plan, edge, out_rows),
                            nested_loop_join(l_plan, r_plan, edge, out_rows),
                            merge_join(l_plan, r_plan, edge, out_rows),
                        ]

                        for plan in candidates:
                            order_key = plan.order
                            if order_key and order_key not in self.interesting_orders:
                                order_key = None

                            if None not in best or plan.cost < best[None].cost:
                                best[None] = plan
                            if order_key:
                                if (order_key not in best
                                        or plan.cost < best[order_key].cost):
                                    best[order_key] = plan

        if best:
            self.dp[table_set] = best

# ============================
# 示例运行
# ============================

def demo():
    orders = TableStats("orders", rows=10_000_000, pages=200_000,
        indexes={"ord_cust_idx": IndexStats("cust_id", 500_000, 30_000),
                 "ord_prod_idx": IndexStats("prod_id", 100_000, 20_000)})

    customers = TableStats("customers", rows=500_000, pages=10_000,
        indexes={"cust_pk": IndexStats("id", 500_000, 1500)})

    products = TableStats("products", rows=100_000, pages=2_000,
        indexes={"prod_pk": IndexStats("id", 100_000, 300)})

    edges = [
        JoinEdge("orders", "cust_id", "customers", "id", selectivity=1/500_000),
        JoinEdge("orders", "prod_id", "products",  "id", selectivity=1/100_000),
    ]

    optimizer = BottomUpOptimizer(
        tables=[orders, customers, products],
        edges=edges,
        interesting_orders=["customers.id", "products.id"]
    )

    best = optimizer.optimize()
    print("===== 最优执行计划 =====")
    print(best)
    print(f"\n总代价: {best.cost:.1f}")

if __name__ == "__main__":
    demo()

运行结果示例:

===== 最优执行计划 =====
HashJoin  (cost=244851.0, rows=1000000)  orders.cust_id=customers.id
  HashJoin  (cost=214600.5, rows=10000000)  orders.prod_id=products.id
    SeqScan  (cost=300000.0, rows=10000000)  on orders
    SeqScan  (cost=3000.0, rows=100000)  on products
  SeqScan  (cost=15000.0, rows=500000)  on customers

总代价: 244851.0

这个迷你优化器简化了很多细节,但完整展示了核心算法:动态规划枚举连接顺序 + 代价模型选择物理算子。

九、实战:PostgreSQL 优化器分析

EXPLAIN 的输出解读

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT c.name, COUNT(o.id), SUM(o.amount)
FROM   customers c
  JOIN orders o ON o.cust_id = c.id
WHERE  c.country = 'CN'
GROUP BY c.name;
HashAggregate  (cost=45231.12..45233.12 rows=200 width=48)
               (actual time=892.451..892.573 rows=187 loops=1)
  Group Key: c.name
  Buffers: shared hit=12045 read=3201
  ->  Hash Join  (cost=1234.56..44891.23 rows=45234 width=21)
                 (actual time=12.345..876.543 rows=43892 loops=1)
        Hash Cond: (o.cust_id = c.id)
        Buffers: shared hit=12045 read=3201
        ->  Seq Scan on orders o  (cost=0.00..38921.00 rows=10000000 width=16)
                                  (actual time=0.012..534.210 rows=10000000 loops=1)
              Buffers: shared hit=8923 read=3201
        ->  Hash  (cost=1122.33..1122.33 rows=8978 width=21)
                  (actual time=11.234..11.234 rows=8856 loops=1)
              Buckets: 16384  Batches: 1  Memory Usage: 512kB
              Buffers: shared hit=3122
              ->  Seq Scan on customers c  (cost=0.00..1122.33 rows=8978 width=21)
                                           (actual time=0.008..9.456 rows=8856 loops=1)
                    Filter: (country = 'CN'::text)
                    Rows Removed by Filter: 491144
                    Buffers: shared hit=3122
Planning Time: 0.342 ms
Execution Time: 892.891 ms

关键信息解读:

字段 含义
cost=1234.56..44891.23 启动代价..总代价
rows=45234 估算行数
actual time=12.345..876.543 实际启动时间..总时间(毫秒)
rows=43892 实际行数
loops=1 执行次数
shared hit=12045 缓冲池命中页数
shared read=3201 从磁盘读取页数
Rows Removed by Filter 被过滤掉的行数

估算偏差诊断

上例中 customers 估算 8978 行,实际 8856 行,偏差约 1.4%。但若偏差超过 10 倍,需要更新统计:

ANALYZE customers;
-- 对倾斜列提高精度
ALTER TABLE customers ALTER COLUMN country SET STATISTICS 1000;
ANALYZE customers;

控制优化器行为

SET enable_hashjoin = off;              -- 禁用特定算法测试
SET random_page_cost = 1.1;            -- SSD 场景
SET effective_cache_size = '32GB';
SET join_collapse_limit = 1;           -- 保持 FROM 子句中的连接顺序

十、自适应查询处理

传统优化器在执行前做出所有决策(compile-time optimization)。但是当基数估算严重失误时,执行时的实际情况可能与估算大相径庭。自适应查询处理(Adaptive Query Processing)允许在执行过程中根据实际观测数据调整计划。

自适应 Join

Oracle 12c 引入了 Adaptive Join:优化器同时准备 Nested Loop 和 Hash Join 两个计划,在运行时根据实际行数决定使用哪个。

           Adaptive Join
          /             \
    (rows <= T)      (rows > T)
        |                |
   Nested Loop       Hash Join
   Index Scan        Full Scan

当内层表返回的行数少于阈值 T 时使用 Nested Loop(利用索引);超过 T 时切换到 Hash Join。

TiDB 的运行时重优化

TiDB 在执行过程中收集实际基数,偏差超过阈值时触发重新优化剩余部分:

1. 生成初始计划 P1,执行第一阶段
2. 收集 actual_card,与 estimated_card 比较
3. 偏差过大 -> 将 actual_card 反馈给优化器 -> 重新优化剩余部分

CockroachDB 的统计信息自动收集

CockroachDB 后台持续收集统计信息,过时时自动重新收集:

SHOW CLUSTER SETTING sql.stats.automatic_collection.enabled;
SHOW STATISTICS FOR TABLE orders;
CREATE STATISTICS orders_stats FROM orders;  -- 手动触发

Materialized Plan 重验证

另一种思路是执行前快速采样验证计划的关键假设。若采样发现基数估算偏差超过阈值,则触发重新优化。这种方法开销较低,因为采样通常比完整扫描快几个数量级。

十一、学习型优化器

近年来,机器学习技术被引入查询优化领域,试图解决传统方法难以处理的问题。

Neo(2019,MIT)

Neo 使用深度强化学习来学习连接顺序选择:

输入:   查询的连接图(表、连接条件、谓词)
模型:   Tree-LSTM 编码查询计划 -> 值网络预测执行时间
训练:   与真实执行交互,通过策略梯度优化
输出:   接近最优的连接顺序

训练流程:
1. 执行大量查询,收集 (计划, 实际执行时间) 对
2. 训练值网络 V(plan) -> predicted_time
3. 使用 V(plan) 指导搜索,替代传统代价模型

Neo 的优势在于:学到的代价模型考虑了数据分布、硬件特性、缓存效果等传统代价模型难以建模的因素。但需要大量训练样本,且模型不可解释。

Bao(2021,MIT)

Bao(Bandit optimizer for query optimization)采用了更实用的路线——它不替代传统优化器,而是学习如何选择优化器的提示(hints):

传统优化器的问题:
  - 有时 Hash Join 更好,有时 Nested Loop 更好
  - 有时 Index Scan 更好,有时 Seq Scan 更好
  - 传统代价模型无法始终做出正确选择

Bao 的方法:
  1. 定义一组"提示集" (arm):
     arm_0: 默认(不加提示)
     arm_1: 禁用 Hash Join
     arm_2: 禁用 Index Scan
     arm_3: 禁用 Merge Join
     arm_4: 禁用 Hash Join + Merge Join
     ...

  2. 对每个查询:
     a. 用 Tree-CNN 将查询计划编码为向量
     b. 对每个 arm,预测执行时间
     c. 选择预测时间最短的 arm
     d. 用对应提示执行查询
     e. 观察实际执行时间,更新模型(Thompson Sampling)
# Bao 的简化伪代码
class BaoOptimizer:
    def __init__(self, arms):
        self.arms = arms
        self.model = TreeCNN()
        self.experience = []

    def choose_arm(self, query):
        plans = [(arm, self.model(traditional_optimizer(query, hints=arm)))
                 for arm in self.arms]
        return min(plans, key=lambda x: x[1])[0]  # Thompson Sampling

    def feedback(self, query, arm, actual_time):
        plan = traditional_optimizer(query, hints=arm)
        self.experience.append((plan, actual_time))
        if len(self.experience) % RETRAIN_INTERVAL == 0:
            self.model.train(self.experience)

Bao 的优点:

  1. 安全性:最差情况下退回默认优化器(arm_0),不会比传统方法更差。
  2. 增量学习:每次执行都能改进模型。
  3. 可解释性:选择的是具体的优化器提示,DBA 可以理解。

学习型基数估算

比起替代整个优化器,用机器学习改进基数估算是更成熟的方向:

方法 原理 代表工作
有监督学习 训练模型预测谓词选择率 MSCN (2019)
无监督/密度估计 学习数据的联合分布 DeepDB (2020)
自回归模型 自回归分解联合概率 Naru (2019)
采样+学习 小样本上学习纠正因子 NeuroCard (2021)

这些方法在学术基准上表现优异,但工业落地面临挑战:训练开销、模型更新频率、分布漂移等问题尚未完全解决。

十二、工程经验与踩坑总结

常见陷阱一览

陷阱 现象 解决方案
统计信息过时 表数据量翻倍后执行计划突然变差 定期 ANALYZE;监控统计信息时间戳
参数嗅探(Parameter Sniffing) 同一 SQL 不同参数性能差异巨大 使用 PREPARE + GENERIC 计划;或强制重编译
隐式类型转换 WHERE int_col = ‘123’ 导致全表扫描 确保类型匹配;review SQL 规范
相关列导致低估 多列联合过滤基数被严重低估 创建多列统计;考虑物化视图
大 IN 列表 IN (1,2,…,10000) 导致规划时间过长 改用临时表 JOIN;或使用 ANY(array)
CTE 优化屏障 PostgreSQL 12 之前 CTE 是优化屏障 升级到 PG12+;或改写为子查询
外连接阻止重排 LEFT JOIN 限制了连接顺序优化 尽量使用 INNER JOIN;利用外连接消除
函数在索引列上 WHERE UPPER(name) = ‘JOHN’ 无法用索引 创建函数索引;或在应用层标准化
OFFSET 深分页 OFFSET 100000 LIMIT 10 扫描十万行 改用 Keyset Pagination
OR 条件不走索引 WHERE a=1 OR b=2 无法使用单列索引 改用 UNION;或创建复合索引

优化器相关配置建议

-- PostgreSQL 关键配置
-- 1. 让优化器了解你的硬件
SET random_page_cost = 1.1;           -- SSD
SET effective_cache_size = '24GB';    -- 可用内存的 75%
SET work_mem = '256MB';               -- 排序/哈希可用内存

-- 2. 统计信息精度
ALTER TABLE large_table ALTER COLUMN skewed_col SET STATISTICS 5000;
-- default_statistics_target = 100,对倾斜列可提高到 1000-10000

-- 3. 连接优化
SET join_collapse_limit = 12;   -- 超过 12 张表时不重排 explicit JOIN
SET from_collapse_limit = 12;   -- 超过 12 张表时不展平子查询
SET geqo_threshold = 14;        -- 超过 14 张表时启用遗传算法

个人观点

经过多年与查询优化器打交道,我有以下几点感悟:

关于代价模型:代价模型永远是”错”的,但有用的。 正如 George Box 所言”所有模型都是错的,但有些是有用的”。代价模型只需在大多数情况下正确排序不同计划的优劣。当优化器选错计划时,80% 是基数估算失误,只有 20% 是代价模型本身的问题。

关于学习型优化器:短期内不会替代传统方法。 数据库需要确定性、可解释性和稳定性,而 ML 模型天然缺乏这些。更可能的路线是 Bao 式——用 ML 辅助传统优化器,而非替代。

关于调优策略:先看 EXPLAIN ANALYZE,再改 SQL,最后才动配置。 正确的排查顺序:EXPLAIN ANALYZE 定位瓶颈 -> 检查基数估算 -> 检查索引 -> 改写 SQL -> 最后调整优化器参数。

关于查询优化器的学习路径: 建议按以下顺序:通读 CockroachDB 优化器源码 -> Selinger 1979 论文 -> Graefe Cascades 论文 -> 跟踪 PostgreSQL set_rel_pathlist 调用 -> 实现迷你优化器。

查询优化是数据库最迷人的领域之一。它融合了算法、统计学、系统工程和人工智能,是计算机科学理论与工程实践的完美交汇点。从 1979 年 System R 至今已近五十年,但这个领域仍在快速发展——无论是分布式场景下的优化挑战,还是机器学习带来的新可能性,都在不断拓展着搜索空间的边界。

参考文献

  1. P. G. Selinger et al., “Access Path Selection in a Relational Database Management System,” SIGMOD 1979.
  2. G. Graefe, “The Cascades Framework for Query Optimization,” IEEE Data Engineering Bulletin, 1995.
  3. G. Graefe and W. McKenna, “The Volcano Optimizer Generator: Extensibility and Efficient Search,” ICDE 1993.
  4. V. Leis et al., “How Good Are Query Optimizers, Really?” PVLDB 2015.
  5. R. Marcus et al., “Neo: A Learned Query Optimizer,” PVLDB 2019.
  6. R. Marcus et al., “Bao: Making Learned Query Optimization Practical,” SIGMOD 2021.
  7. A. Kipf et al., “Learned Cardinalities: Estimating Correlated Joins with Deep Learning,” CIDR 2019.
  8. B. Hilprecht et al., “DeepDB: Learn from Data, not from Queries!” PVLDB 2020.

上一篇: Join 算法全解 下一篇: 缓冲池管理算法

相关阅读: - Join 算法全解 - 学习索引


By .