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;如果优化器选择了一个糟糕的执行计划——例如先对
orders 和 products
做笛卡尔积,再过滤——运行时间可能是小时级。而一个好的计划——先用索引过滤
customers 和 products,再分别与
orders 做 Hash Join——可能只需要几百毫秒。
差异的根源在于:
- 连接顺序不同:n 张表的连接有 n!/2 种左深树排列。
- 物理算子不同:同一个逻辑 Join 可以用 Nested Loop、Hash Join、Sort-Merge Join 实现。
- 访问路径不同:全表扫描、索引扫描、覆盖索引,代价天差地别。
优化器的任务,就是在这个巨大的搜索空间中,找到代价最低(或足够低)的执行计划。
搜索空间的规模
| 连接表数 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 优化器使用自底向上的动态规划算法。其核心思想是:
- 先枚举所有单表的最优访问路径。
- 然后枚举所有两表连接的最优计划。
- 依次增加表的数量,直到覆盖所有表。
每一步都只保留代价最低的子计划,利用最优子结构性质避免重复计算。
// 伪代码: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;假设对 customers 按 name
排序的索引扫描比全表扫描代价高
20%,但它产生的有序输出可以避免最终的排序操作。如果只看局部代价,优化器会选择全表扫描;但从全局来看,索引扫描反而更优。
System R 的解决方案是:对于每个子问题,不仅保留代价最低的计划,还保留所有具有”有趣顺序”的计划。有趣顺序包括:
ORDER BY子句要求的排序。GROUP BY子句要求的排序。- 上层 Merge Join 需要的排序。
# 每个子问题维护多个计划(有趣顺序)
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)读取次数衡量。统计信息包括:
- 表的行数(cardinality)
- 每页行数
- 列的不同值数量(distinct values)
- 索引的层数(B-Tree 高度)
这一代价模型虽然简单,但奠定了后来所有代价模型的基本范式。
三、Volcano/Cascades 框架
System R 的自底向上方法有两个局限:
- 当搜索空间很大时,会枚举大量永远不会被选中的子计划。
- 规则的添加和管理缺乏模块化。
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,节点分为两类:
- Group(等价类):一组逻辑等价的表达式,产生相同的输出。
- Group Expression(组表达式):一个具体的算子,其子节点指向 Group 而非具体表达式。
例如,对于查询
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’)。
但显然 city 和 country
高度相关!如果 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_cost
和 mysql.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 实现一个完整的自底向上优化器。它支持:
- 多种 Join 算法(Nested Loop、Hash Join、Merge Join)
- 基于统计信息的代价模型
- 动态规划搜索最优连接顺序
- 有趣顺序的处理
"""
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 的优点:
- 安全性:最差情况下退回默认优化器(arm_0),不会比传统方法更差。
- 增量学习:每次执行都能改进模型。
- 可解释性:选择的是具体的优化器提示,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 至今已近五十年,但这个领域仍在快速发展——无论是分布式场景下的优化挑战,还是机器学习带来的新可能性,都在不断拓展着搜索空间的边界。
参考文献
- P. G. Selinger et al., “Access Path Selection in a Relational Database Management System,” SIGMOD 1979.
- G. Graefe, “The Cascades Framework for Query Optimization,” IEEE Data Engineering Bulletin, 1995.
- G. Graefe and W. McKenna, “The Volcano Optimizer Generator: Extensibility and Efficient Search,” ICDE 1993.
- V. Leis et al., “How Good Are Query Optimizers, Really?” PVLDB 2015.
- R. Marcus et al., “Neo: A Learned Query Optimizer,” PVLDB 2019.
- R. Marcus et al., “Bao: Making Learned Query Optimization Practical,” SIGMOD 2021.
- A. Kipf et al., “Learned Cardinalities: Estimating Correlated Joins with Deep Learning,” CIDR 2019.
- B. Hilprecht et al., “DeepDB: Learn from Data, not from Queries!” PVLDB 2020.