02-SQL执行计划与优化器:Oracle是怎么决定“该怎么查“的

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的核心逻辑:

  1. 收集统计信息:表有多少行?列的值分布如何?索引的选择性怎样?
  2. 估算每条路径的成本:CPU消耗 + I/O消耗
  3. 选择成本最低的路径

关键点: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")

怎么读?核心原则

执行顺序不是从上到下,而是"最缩进的先执行,同级从上到下"。

上面这个计划的执行顺序是:

  1. Id 3 :用EMP_DEPT_IX索引扫描,找到dept_id=10的行对应的ROWID
  2. Id 2 :通过ROWID回表,从EMPLOYEES取完整数据
  3. Id 5 :对每一行结果,用DEPT_PK索引查DEPARTMENTS
  4. Id 4 :通过ROWID回表,从DEPARTMENTS取数据
  5. Id 1:嵌套循环连接,组合结果
  6. 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)

原理:

  1. 把较小的表读入内存,建哈希表
  2. 扫描大表,对每一行算哈希值去匹配

适合 :两张表都比较大,等值连接(=

不适合 :内存不够(小表放不进PGA),非等值连接(><LIKE

!note\] 联系前文 还记得01篇讲的PGA吗?哈希连接的哈希表就是在PGA中构建的。如果PGA不够,哈希表会溢出到临时表空间(磁盘),性能暴跌。所以`PGA_AGGREGATE_TARGET`参数对哈希连接的性能有直接影响。

3. 排序合并连接(SORT MERGE JOIN)

原理:

  1. 对两张表分别排序
  2. 像拉链一样合并

适合:非等值连接,或者数据已经有序

不适合:排序成本高时(大数据量、无索引辅助排序)

三种连接对比

场景 最佳连接 原因
小表驱动大表,大表有索引 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)

  1. 你工作中遇到过SQL突然变慢的情况吗?回想一下,当时的排查过程是什么样的?现在看,最可能的原因是什么?

    提示:结合执行计划变化和统计信息来分析。

  2. 假设一张表有1000万行,你要查其中的10行数据。走索引一定比全表扫描快吗?如果索引的聚簇因子很高(接近行数),会怎样?

    提示:想想索引扫描 + 回表的I/O模式。

  3. 为什么哈希连接只支持等值连接(=),不支持><这样的非等值连接?

    提示:想想哈希表的查找原理。

  4. 在你的工作环境中,统计信息是自动收集的还是手动收集的?有没有遇到过因为统计信息不准确导致的问题?


下一篇预告:深入Oracle索引------B-Tree索引的内部结构、不同索引类型的选择、索引设计原则,以及那些"以为建了索引就万事大吉"的常见误区。

相关推荐
大傻^2 小时前
SpringAI2.0 向量存储生态:Redis、Amazon S3 与 Bedrock Knowledge Base 集成
数据库·人工智能·向量存储·springai
轩情吖2 小时前
MySQL之索引
android·数据库·mysql·b+树·索引·page·
知识分享小能手2 小时前
edis入门学习教程,从入门到精通,Redis编程开发知识点详解(4)
数据库·redis·学习
qq_334903152 小时前
使用Flask快速搭建轻量级Web应用
jvm·数据库·python
wutang0ka2 小时前
高频 SQL 50题 197.上升的温度
数据库·sql
尤山海2 小时前
深度防御:内容类网站如何有效抵御 SQL 注入与脚本攻击(XSS)
前端·sql·安全·web安全·性能优化·状态模式·xss
薛定谔的悦2 小时前
嵌入式 OTA(远程固件升级)(二)
服务器·数据库·能源·储能·ota
V1ncent Chen2 小时前
SQL大师之路 14 子查询
数据库·sql·mysql·数据分析