查询执行基础回顾:查询解析、查询重写、查询优化、查询执行

各位同学,咱们今天聊聊查询执行的基础流程。说白了,一条 SQL 从客户端发出来,到数据库真正返回结果,中间经历了四个关键阶段:解析、重写、优化、执行。这四个阶段环环相扣,任何一个环节出问题,性能都可能崩盘。

我刚开始做 DBA 那会儿,总觉得优化就是加索引、改 SQL。后来被一个慢查询折磨了三天,才发现问题出在查询重写阶段——优化器把子查询展开成了笛卡尔积,执行计划直接炸了。嗯,从那以后我再也不敢小看任何一个阶段了。

1. 查询解析:从字符串到语法树

查询解析,说白了就是把 SQL 字符串变成数据库能理解的结构。你写一句 SELECT * FROM users WHERE age > 18,数据库得先知道:哦,你要查 users 表,条件是 age 大于 18。

解析器干两件事:

  • 词法分析:把 SQL 拆成一个个 ,比如 SELECT、*、FROM、users、WHERE、age、>、18。
  • 语法分析:检查 组合是否符合 SQL 语法规则,生成一棵抽象语法树(AST)。

举个例子,下面这条 SQL:

SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC;

解析后生成的 AST 大概长这样:

AST 结构示意:

  • 根节点:SELECT 语句
  • 子节点:目标列(u.name, o.amount)
  • 子节点:FROM 子句(users u, orders o)
  • 子节点:JOIN 条件(u.id = o.user_id)
  • 子节点:WHERE 条件(o.status = 'PAID')
  • 子节点:ORDER BY(o.created_at DESC)

解析阶段只做语法检查,不关心表存不存在、字段对不对。这些是后面语义检查的事。我记得有一次,同事写了个 SELECT * FROM nonexistent_table,解析器通过了,到语义检查才报错。你想想看,解析器就是个语法洁癖,只管格式对不对,不管内容真不真。

2. 查询重写:优化前的预处理

解析完得到 AST,接下来是查询重写。这个阶段很多人忽略,其实它决定了优化器能不能拿到好牌。

重写器干的事包括:

  • 视图展开:把视图引用替换成底层表查询
  • 子查询展开:把子查询改写成 JOIN 或半连接
  • 常量折叠:比如 WHERE age > 10 + 5 变成 WHERE age > 15
  • 谓词下推:把 WHERE 条件尽量推到 JOIN 之前执行
  • 等价谓词重写:比如 IN 转 EXISTS,OR 转 UNION

举个实际例子。假设你有这么个查询:

SELECT *
FROM orders
WHERE order_id IN (SELECT order_id FROM refunds WHERE refund_status = 'APPROVED');

重写器可能会把它变成:

SELECT o.*
FROM orders o
WHERE EXISTS (
  SELECT 1 FROM refunds r
  WHERE r.order_id = o.order_id AND r.refund_status = 'APPROVED'
);

为什么要这么干?因为 EXISTS 在某些数据库里能走半连接优化,比 IN 快得多。我曾经在一个电商项目里,把一个大查询的 IN 改成 EXISTS,执行时间从 12 秒降到了 0.8 秒。当然,不是所有场景都适用,得看数据分布和索引情况。

个人经验:重写阶段是人工干预的好时机。如果你发现优化器生成的计划不对劲,可以手动改写 SQL 来引导重写器。比如用 WITH 子句强制物化中间结果,或者用 /*+ QB_NAME() */ 提示控制查询块的处理顺序。

3. 查询优化:选择执行计划

重写完,轮到优化器上场了。这是整个查询执行的核心,也是最复杂的一环。

优化器要回答一个问题:怎么执行这条 SQL 最快?它需要考虑:

  • 走哪个索引?还是全表扫描?
  • JOIN 顺序怎么排?
  • 用哪种 JOIN 算法?Nested Loop、Hash Join 还是 Sort Merge Join?
  • 聚合操作怎么分组?
  • 排序能不能利用索引?

优化器一般分两种:

优化器类型 特点 代表数据库
基于规则的优化器(RBO) 按固定规则选择计划,不考虑数据分布 早期 Oracle、MySQL 5.6 之前
基于成本的优化器(CBO) 根据统计信息估算成本,选成本最低的计划 现代 Oracle、PostgreSQL、MySQL 5.7+

现在主流数据库都用 CBO。CBO 依赖统计信息——表行数、列唯一值数、数据分布直方图等。统计信息不准,优化器就会选错计划。我踩过这个坑:一张表 1000 万行,统计信息显示只有 10 万行,优化器选了全表扫描,结果跑了 40 分钟。

避坑指南:我曾经因为没及时更新统计信息,导致一个报表查询从 2 秒变成 2 小时。后来养成了习惯:大表 DDL 或 DML 超过 10% 行数变化后,立刻 ANALYZE 或 UPDATE STATISTICS。别等出问题再补救。

优化器生成执行计划的过程,可以用下面这张图来理解:

查询优化器工作流程 重写后的查询树 统计信息 成本模型 系统配置参数 枚举候选执行计划 (访问路径 + JOIN 顺序 + JOIN 算法) 估算每个计划的成本 (CPU + IO + 网络 + 内存) 选择成本最低的计划 最终执行计划 优化器可能枚举数十到数万种计划 自适应优化可动态调整计划

优化器枚举计划时,不是穷举所有可能——那样太慢了。它用剪枝策略,比如只考虑前 N 种 JOIN 顺序,或者用动态规划减少重复计算。PostgreSQL 的优化器默认只枚举 geqo_threshold 以内的 JOIN 组合,超过就用遗传算法近似求解。

4. 查询执行:把计划变成结果

优化器选好计划,交给执行器。执行器是个流水线工人,按计划一步步干活。

执行器的工作模式:

  • 火山模型(Volcano Model):每个操作符是一个迭代器,父节点调用子节点的 next() 方法获取一行数据。这是最经典的模型,MySQL、PostgreSQL 都用它。
  • 向量化执行:一次处理一批数据(比如 1024 行),减少函数调用开销。ClickHouse、DuckDB 用这个,OLAP 场景下性能提升明显。
  • 编译执行:把查询计划编译成机器码,比如 Hyper、SingleStore。适合复杂查询,但编译本身有开销。

举个例子,执行一个简单的 JOIN:

-- 执行器按计划执行
1. 扫描 users 表(走主键索引或全表扫描)
2. 对每一行,去 orders 表查找匹配的 user_id(走索引或哈希表)
3. 拼接结果行,返回给客户端

执行过程中,执行器还会做:

  • 物化:把中间结果暂存到磁盘或内存
  • 排序:用内存排序或外部排序
  • 聚合:用哈希表或排序分组
  • 限流:如果 SQL 带 LIMIT,找到足够行数就提前停止

个人习惯:我排查慢查询时,第一步不是看 SQL,而是看执行计划。用 EXPLAIN (ANALYZE, BUFFERS) 看实际执行时间和行数,对比优化器的估算值。如果偏差超过 10 倍,基本就是统计信息不准或者参数设置有问题。

小结:四个阶段的关系

这四个阶段不是孤立的。解析和重写是基础,优化是核心,执行是落地。任何一个阶段出问题,最终性能都会受影响。

我见过太多人只盯着优化阶段,忽略了重写和执行。比如:

  • 重写阶段没做谓词下推,导致 JOIN 前没过滤数据
  • 执行阶段用了低效的 JOIN 算法,明明可以走 Hash Join 却走了 Nested Loop
  • 优化器选了错误的索引,因为统计信息过期了

所以,理解这四个阶段,是做好查询优化的第一步。后面我们会深入每个阶段,看看自适应优化怎么在这些环节里动态调整,让查询跑得更快。