SQL执行计划与优化器:Oracle是怎么决定"该怎么查"的
从一个实际场景说起
你写了一条SQL:
sql
SELECT e.employee_name, d.dept_name
FROM employees e, departments d
WHERE e.dept_id = d.dept_id
AND e.salary > 50000;
Oracle要执行它,但有很多种方式:
- 先扫描
employees表过滤工资,再去departments表匹配? - 先连接两张表,再过滤工资?
- 用索引还是全表扫描?
- 用嵌套循环连接还是哈希连接?
优化器(Optimizer)就是做这个决策的。它会分析所有可能的执行路径,估算成本,选一条它认为最快的。执行计划就是优化器选出来的那条路径。
理解执行计划,是DBA调优的最核心技能------你不看执行计划就调优,等于闭着眼睛开车。
两代优化器:RBO vs CBO
RBO(Rule-Based Optimizer)------ 基于规则
Oracle 10g之前的默认模式。它有一套固定的优先级规则:
1. 单行通过ROWID访问 ← 最快
2. 单行通过唯一索引
3. 组合索引完全匹配
4. 范围扫描
...
15. 全表扫描 ← 最慢
RBO的问题:它不看数据。一张表有100行和1000万行,RBO做出的决策可能完全一样。这在复杂场景下经常选错执行路径。
CBO(Cost-Based Optimizer)------ 基于成本
10g之后的唯一选择。CBO的核心逻辑:
- 收集统计信息:表有多少行?列的值分布如何?索引的选择性怎样?
- 估算每条路径的成本:CPU消耗 + I/O消耗
- 选择成本最低的路径
关键点:CBO的决策质量完全依赖于统计信息的准确性。如果统计信息过期了(表已经从100行增长到100万行,但统计信息还是100行的),CBO就会做出错误决策。
!important\] 实战经验 你在工作中遇到"某条SQL突然变慢",第一件事应该检查统计信息是否过期。很多时候,跑一次`DBMS_STATS.GATHER_TABLE_STATS`就能解决问题。
如何查看执行计划
方法一:EXPLAIN PLAN
sql
EXPLAIN PLAN FOR
SELECT * FROM employees WHERE dept_id = 10;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
这是预估的执行计划,不需要真正执行SQL。
方法二:查看实际执行计划
sql
SELECT /*+ GATHER_PLAN_STATISTICS */ * FROM employees WHERE dept_id = 10;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST'));
这是真实执行后的计划,包含实际的行数、时间等信息。
!tip\] 建议 预估计划和实际计划可能不一样。调优时尽量看实际执行计划,因为它反映了真实情况。
方法三:通过V$SQL查历史SQL的执行计划
sql
-- 先找到SQL_ID
SELECT sql_id, sql_text FROM v$sql WHERE sql_text LIKE '%employees%';
-- 再看执行计划
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('sql_id_here', NULL, 'ALLSTATS'));
这个在排查线上问题时最常用------你不需要重新执行SQL,直接看内存里缓存的执行计划。
读懂执行计划
来看一个实际的执行计划输出:
-------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost | Time |
-------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | | 5 | |
| 1 | NESTED LOOPS | | 10 | 720 | 5 | 00:00:01 |
| 2 | TABLE ACCESS BY INDEX ROWID| EMPLOYEES | 10 | 520 | 3 | 00:00:01 |
|* 3 | INDEX RANGE SCAN | EMP_DEPT_IX | 10 | | 1 | 00:00:01 |
| 4 | TABLE ACCESS BY INDEX ROWID| DEPARTMENTS | 1 | 20 | 1 | 00:00:01 |
|* 5 | INDEX UNIQUE SCAN | DEPT_PK | 1 | | 0 | 00:00:01 |
-------------------------------------------------------------------------------------
Predicate Information:
3 - access("E"."DEPT_ID"=10)
5 - access("D"."DEPT_ID"="E"."DEPT_ID")
怎么读?核心原则
执行顺序不是从上到下,而是"最缩进的先执行,同级从上到下"。
上面这个计划的执行顺序是:
- Id 3 :用
EMP_DEPT_IX索引扫描,找到dept_id=10的行对应的ROWID - Id 2 :通过ROWID回表,从
EMPLOYEES取完整数据 - Id 5 :对每一行结果,用
DEPT_PK索引查DEPARTMENTS表 - Id 4 :通过ROWID回表,从
DEPARTMENTS取数据 - Id 1:嵌套循环连接,组合结果
- Id 0:返回最终结果
关注什么?
| 列 | 含义 | 看什么 |
|---|---|---|
| Operation | 具体操作 | 是全表扫描还是索引扫描? |
| Rows | 预估行数 | 预估和实际差距大吗?差距大说明统计信息有问题 |
| Cost | 优化器估算的成本 | 哪个步骤成本最高? |
| Bytes | 预估数据量 | 是否处理了过多数据? |
!warning\] 重要 **Rows**列是最需要关注的。如果预估是10行,但实际执行了100万行,那优化器的决策一定是错的------它以为数据很少所以选了嵌套循环,但实际数据量巨大,应该用哈希连接。
核心访问路径
1. 全表扫描(TABLE ACCESS FULL)
读取表的所有数据块,从头到尾。
- 不一定是坏事:小表或者需要返回大量数据时,全表扫描可能比走索引更快
- 什么时候是问题:大表但只需要几行数据时,全表扫描就很浪费
2. 索引唯一扫描(INDEX UNIQUE SCAN)
通过唯一索引精确定位一行。最快的索引访问方式。
sql
-- 典型场景:主键查找
SELECT * FROM employees WHERE employee_id = 100;
3. 索引范围扫描(INDEX RANGE SCAN)
通过索引找到一个范围内的多行。
sql
-- 典型场景
SELECT * FROM employees WHERE dept_id = 10;
SELECT * FROM employees WHERE hire_date BETWEEN '2020-01-01' AND '2020-12-31';
4. 索引全扫描(INDEX FULL SCAN)
扫描索引的所有叶子节点,按顺序。比全表扫描快,因为索引比表小。
5. 索引快速全扫描(INDEX FAST FULL SCAN)
类似索引全扫描,但不按顺序读取,可以并行。当查询的列全部在索引中(覆盖索引)时出现。
6. 回表(TABLE ACCESS BY INDEX ROWID)
通过索引找到ROWID后,再去表里取完整行。
!note\] 关键理解 索引扫描 + 回表是一个组合操作。如果索引扫描返回了大量ROWID,每个都要回表一次,I/O反而可能比全表扫描还多。这就是为什么**当查询需要返回大比例数据时,Oracle宁可全表扫描**。通常超过表数据的5%-15%时,优化器就倾向于全表扫描。
核心连接方式
当SQL涉及多表关联时,Oracle有三种主要连接方式:
1. 嵌套循环连接(NESTED LOOPS)
原理像两层for循环:
对于驱动表的每一行:
去被驱动表中查找匹配的行
适合:驱动表结果集很小,被驱动表有高效索引
不适合:两张表都很大,结果集多
2. 哈希连接(HASH JOIN)
原理:
- 把较小的表读入内存,建哈希表
- 扫描大表,对每一行算哈希值去匹配
适合 :两张表都比较大,等值连接(=)
不适合 :内存不够(小表放不进PGA),非等值连接(>、<、LIKE)
!note\] 联系前文 还记得01篇讲的PGA吗?哈希连接的哈希表就是在PGA中构建的。如果PGA不够,哈希表会溢出到临时表空间(磁盘),性能暴跌。所以`PGA_AGGREGATE_TARGET`参数对哈希连接的性能有直接影响。
3. 排序合并连接(SORT MERGE JOIN)
原理:
- 对两张表分别排序
- 像拉链一样合并
适合:非等值连接,或者数据已经有序
不适合:排序成本高时(大数据量、无索引辅助排序)
三种连接对比
| 场景 | 最佳连接 | 原因 |
|---|---|---|
| 小表驱动大表,大表有索引 | NESTED LOOPS | 索引查找效率高 |
| 两个大表等值连接 | HASH JOIN | 建哈希比嵌套循环高效 |
| 非等值连接 | SORT MERGE / NESTED LOOPS | 哈希连接不支持非等值 |
| 数据本身有序 | SORT MERGE | 免去排序成本 |
统计信息:CBO的命脉
前面反复提到统计信息,现在来说清楚它到底是什么。
表统计信息
NUM_ROWS:表有多少行BLOCKS:占用多少个数据块AVG_ROW_LEN:平均行长度
列统计信息
NUM_DISTINCT:去重后有多少个不同的值LOW_VALUE/HIGH_VALUE:最小值和最大值NUM_NULLS:多少个NULL- 直方图(Histogram):值的分布情况
索引统计信息
BLEVEL:索引树的层级LEAF_BLOCKS:叶子节点数CLUSTERING_FACTOR:聚簇因子------衡量索引顺序和表的物理存储顺序有多匹配
!important\] 聚簇因子 这个值经常被忽视,但极其重要。如果聚簇因子接近表的行数,说明索引顺序和数据存储顺序差异很大,每次索引回表都可能读不同的数据块,I/O代价极高。反之,如果接近数据块数,说明排列很紧凑,回表效率高。
收集统计信息
sql
-- 收集表统计信息(包含索引和列统计)
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => 'SCOTT',
tabname => 'EMPLOYEES',
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
method_opt => 'FOR ALL COLUMNS SIZE AUTO',
cascade => TRUE
);
END;
/
-- 收集整个Schema的统计信息
BEGIN
DBMS_STATS.GATHER_SCHEMA_STATS(
ownname => 'SCOTT',
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE
);
END;
/
查看统计信息
sql
-- 查看表统计
SELECT table_name, num_rows, blocks, avg_row_len, last_analyzed
FROM user_tables WHERE table_name = 'EMPLOYEES';
-- 查看列统计
SELECT column_name, num_distinct, num_nulls, histogram
FROM user_tab_col_statistics WHERE table_name = 'EMPLOYEES';
-- 查看索引统计
SELECT index_name, blevel, leaf_blocks, clustering_factor
FROM user_indexes WHERE table_name = 'EMPLOYEES';
Oracle自动收集机制
Oracle有一个自动任务AUTO_TASK会在维护窗口(默认是晚上10点到凌晨2点的工作日,周末全天)自动收集过期的统计信息。
但有些场景需要你手动处理:
- 大批量数据加载后
- 分区表新增分区后
- 统计信息被锁定的表
实战:一个调优案例
场景:某条查询突然从0.1秒变成了30秒。
排查步骤:
第一步:看执行计划变了没
sql
-- 通过AWR找到之前的执行计划
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_AWR('sql_id_here'));
-- 对比现在的
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('sql_id_here'));
发现:之前用的是INDEX RANGE SCAN + NESTED LOOPS,现在变成了TABLE ACCESS FULL + HASH JOIN。
第二步:为什么计划变了?
sql
-- 检查统计信息最后更新时间
SELECT table_name, num_rows, last_analyzed
FROM user_tables WHERE table_name IN ('EMPLOYEES', 'DEPARTMENTS');
发现:EMPLOYEES表的last_analyzed是3个月前,当时只有1万行,现在已经有500万行了。
第三步:更新统计信息
sql
BEGIN
DBMS_STATS.GATHER_TABLE_STATS('SCOTT', 'EMPLOYEES', cascade => TRUE);
END;
/
第四步:验证
重新执行SQL,执行计划恢复正常,查询回到0.1秒。
!note\] 经验总结 SQL性能突变的排查顺序: 1. 执行计划是否变化? 2. 统计信息是否过期? 3. 数据量是否暴增? 4. 是否有锁等待或资源竞争? 5. 系统资源(CPU、I/O、内存)是否有瓶颈?
思考题(你有新的理解吗?oo)
-
你工作中遇到过SQL突然变慢的情况吗?回想一下,当时的排查过程是什么样的?现在看,最可能的原因是什么?
提示:结合执行计划变化和统计信息来分析。
-
假设一张表有1000万行,你要查其中的10行数据。走索引一定比全表扫描快吗?如果索引的聚簇因子很高(接近行数),会怎样?
提示:想想索引扫描 + 回表的I/O模式。
-
为什么哈希连接只支持等值连接(
=),不支持>、<这样的非等值连接?提示:想想哈希表的查找原理。
-
在你的工作环境中,统计信息是自动收集的还是手动收集的?有没有遇到过因为统计信息不准确导致的问题?
下一篇预告:深入Oracle索引------B-Tree索引的内部结构、不同索引类型的选择、索引设计原则,以及那些"以为建了索引就万事大吉"的常见误区。