查询执行基础回顾:查询解析、查询重写、查询优化、查询执行
各位同学,咱们今天聊聊查询执行的基础流程。说白了,一条 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。别等出问题再补救。
优化器生成执行计划的过程,可以用下面这张图来理解:
优化器枚举计划时,不是穷举所有可能——那样太慢了。它用剪枝策略,比如只考虑前 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
- 优化器选了错误的索引,因为统计信息过期了
所以,理解这四个阶段,是做好查询优化的第一步。后面我们会深入每个阶段,看看自适应优化怎么在这些环节里动态调整,让查询跑得更快。