如果你的 AI Agent 能生成 SQL,但面对一个跑不动的查询时只会说「加个索引试试」——它差的不是诊断直觉,而是一套读懂执行计划、判断访问路径、修复基数估算的调优方法论。oracle/skills 里的https://github.com/oracle/skills/blob/main/db/performance/sql-tuning.md,可以提供帮助。


一、执行计划:一切调优的起点

通用大模型写 SQL 没问题,但一旦涉及的查询跑不动,AI 最常见的回答是「加个索引」或「看看执行计划」——它说得出工具名,却不知道怎么用。本文基于 sql-tuning.md,把 AI Agent 训练成一个能真正读懂计划、定位根因的调优助手。

1.1 两种看计划的方式,差距有多大?

Oracle 提供两种获取执行计划的手段,但它们在可信度上有本质区别:

-- 方式一:EXPLAIN PLAN(仅估算,不实际执行)
EXPLAIN PLAN FOR
SELECT e.last_name, d.department_name
FROM   employees e
JOIN   departments d ON e.department_id = d.department_id
WHERE  e.salary > 10000;

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY());

EXPLAIN PLAN 只做估算。它不读取实际数据,依赖的是数据字典中的统计信息。如果统计信息过期——比如批量加载了 500 万行之后忘了收集统计信息——EXPLAIN PLAN 会基于错误的基数生成一个看似完美、实则灾难的计划。

生产调优的黄金标准是 DBMS_XPLAN.DISPLAY_CURSOR 配合 ALLSTATS LAST

-- 先执行目标 SQL,然后从游标缓存中取到它的 SQL_ID
SELECT sql_id, sql_text
FROM   v$sql
WHERE  sql_text LIKE '%last_name%employees%'
AND    sql_text NOT LIKE '%v$sql%';

-- 用真实行数 (A-Rows) 和真实执行次数 (Starts) 分析计划
SELECT * FROM TABLE(
  DBMS_XPLAN.DISPLAY_CURSOR('abc12345xyz', 0, 'ALLSTATS LAST')
);

ALLSTATS LAST 给了 EXPLAIN PLAN 永远无法提供的东西——每一行估算值和实际值并排展示。这是判断计划好坏的唯一可靠依据。

1.2 执行计划输出的关键列

含义 诊断价值
Id 操作步骤编号,子步骤缩进 理解执行顺序
Operation Oracle 在做什么(TABLE ACCESS、INDEX RANGE SCAN 等) 最核心的诊断信息
Name 被访问的对象名 索引名 vs 表名,一眼看出是否走了索引
E-Rows 估算行数——优化器的预测 偏小→可能选了 NL 而非 HJ;偏大→可能选了 FULL 而非索引
A-Rows 实际行数(需要 ALLSTATS 与 E-Rows 的差距直接反映统计信息质量
Starts 本步骤被执行的次数 NL 内表被重复执行 Starts = 驱动行数——过大意味着 NL 选错了
Cost (%CPU) 优化器成本估算和 CPU 占比 辅助判断

1.3 E-Rows vs A-Rows:一切坏计划的根源

一个铁律:E-Rows 与 A-Rows 的差距超过 10 倍,几乎必然产生坏计划。

假设优化器估算某一步返回 100 行(E-Rows=100),实际返回 10 万行(A-Rows=100K)。这 1000 倍的偏差会导致:

  • 如果这步是 NL 的驱动表,内表会被访问 10 万次而非 100 次——执行时间从毫秒级膨胀到分钟级
  • 如果这步本该用 Hash Join,但优化器因为「觉得」只有 100 行而选了 Nested Loops——灾难

基数估算错误是 SQL 性能问题的 #1 根本原因。 它的常见导火索包括:批量 DML 后未收集统计信息、多列关联查询缺少扩展统计信息、函数包裹 WHERE 条件使优化器无法推断选择性、以及倾斜数据缺少直方图。


二、访问路径:Oracle 怎么找到你的数据

Oracle 有五种核心访问路径,从最高效到最昂贵排列如下:

访问路径 机制 适用场景
INDEX UNIQUE SCAN B-tree 唯一索引单次探测 WHERE pk_col = 123——最高效的单行访问
INDEX RANGE SCAN B-tree 范围扫描 WHERE col BETWEEN 100 AND 200——选择性好的范围查询
INDEX SKIP SCAN 复合索引跳过前导列扫描 前导列基数很低时(如 (status, id)id=5
INDEX FAST FULL SCAN 多块 I/O 读取整个索引段 索引覆盖查询(所有需要列都在索引中)
TABLE ACCESS FULL 多块 I/O 读取整个表段 大比例数据检索(>15~20%)——不一定是坏事

2.1 函数包裹索引列:一条 WHERE 条件杀死一个索引

-- 这条 SQL 永远不会走 name 列上的索引
SELECT * FROM employees WHERE UPPER(name) = 'SMITH';

因为 UPPER(name) 是一个表达式,优化器看到的是「未知函数的结果」,无法与索引 name 关联。解决方式是建一个函数索引:

CREATE INDEX emp_upper_name_idx ON employees(UPPER(name));

2.2 隐式类型转换:最隐蔽的索引杀手

-- emp_id 是 NUMBER 类型,但传入的却是字符串 '100'
SELECT * FROM employees WHERE emp_id = '100';

Oracle 不会报错——它会自动把 emp_id 隐式转为 TO_NUMBER(emp_id)。结果是:索引失效,变成全表扫描。WHERE 条件中的字面值类型必须与列类型严格匹配。


三、连接方法:三张牌各打什么场景

Oracle CBO 在三种连接算法中选择,选择依据是基数估算、可用索引和 PGA 内存。

3.1 Nested Loops——小驱动集 + 内表索引

NESTED LOOPS
  TABLE ACCESS FULL   EMPLOYEES      (驱动表,返回少量行)
  INDEX RANGE SCAN    DEPT_ID_IDX    (内表,每条驱动行探测一次)
  • 内存消耗几乎为零
  • 驱动行数必须小(通常 < 1000),否则内表被重复访问的次数会让执行时间线性膨胀
  • Starts 列如果等于驱动表的 A-Rows 且数值很大——NL 选错了

3.2 Hash Join——两大表无索引时的最优解

HASH JOIN
  TABLE ACCESS FULL   DEPARTMENTS    (Build 端——较小表,被哈希到内存)
  TABLE ACCESS FULL   EMPLOYEES      (Probe 端——较大表,逐行探测)
  • 仅支持等值连接
  • 如果 Build 端超过 PGA 内存,会溢出到 TEMP 表空间——性能急剧下降
  • 哈希表在 PGA 中构建,不需要索引

3.3 Sort Merge Join——预排序数据的加速器

两表各自按连接键排序后再做归并。当输入已经有序(比如索引已经提供了排序)时,Sort Merge 可以跳过排序步骤,比 Hash Join 更快。但它的内存需求通常高于 Hash Join。


四、Optimizer Hints:双刃剑,而非万金油

Hint 是嵌入 SQL 注释中的优化器指令。它能强制指定访问路径、连接方法和连接顺序——但代价是绕过了 CBO 的自主决策能力。

4.1 常用 Hint 速查

-- 强制使用指定索引
SELECT /*+ INDEX(e EMP_DEPT_IDX) */ *
FROM   employees e
WHERE  department_id = 30;

-- 强制全表扫描(绕过索引)
SELECT /*+ FULL(e) */ *
FROM   employees e
WHERE  status = 'A';

-- 控制连接方法
SELECT /*+ USE_NL(e d) */ e.last_name, d.department_name
FROM   employees e
JOIN   departments d ON e.department_id = d.department_id;

SELECT /*+ USE_HASH(e d) */ e.last_name, d.department_name
FROM   employees e
JOIN   departments d ON e.department_id = d.department_id;

-- 控制连接顺序(e 做驱动表,d 做内表)
SELECT /*+ LEADING(e d) USE_NL(d) */ e.last_name, d.department_name
FROM   employees e
JOIN   departments d ON e.department_id = d.department_id;

-- 并行执行(4 个并行线程)
SELECT /*+ PARALLEL(e, 4) */ COUNT(*) FROM employees e;

-- 基数提示(当统计信息不准确时的临时补救)
SELECT /*+ CARDINALITY(e 100) */ *
FROM   employees e
WHERE  complex_function(salary) > 5000;

4.2 什么时候绝对不要用 Hint

Hint 有三个结构性缺陷,使其不适合作为长期调优方案:

  1. 脆弱的依赖——表改名、索引重建、统计信息变化后,Hint 指定的路径可能反而变成最差路径
  2. 绕过自适应优化——12c+ 默认开启的 OPTIMIZER_ADAPTIVE_PLANS 可以在执行中途根据实际行数切换连接方法,Hint 直接禁止了这个能力
  3. 维护负担——应用代码里的 Hint 随版本迭代很容易被遗忘,变成「没人敢删的魔法注释」

前置原则:先用统计信息、索引、SQL 改写解决性能问题。只有调无可调时,才用 Hint 作为临时方案,并在下一个维护窗口换成 SQL Profile 或 Plan Baseline。


五、统计信息:CBO 的「眼睛」必须保持清晰

CBO 靠统计信息判断每张表有多少行、每列有多少不同值、数据如何分布。统计信息缺失或过期,是坏计划最常见(也最容易被忽视)的原因。

5.1 收集统计信息的标准姿势

-- 单表收集(推荐使用 AUTO_SAMPLE_SIZE)
EXEC DBMS_STATS.GATHER_TABLE_STATS(
  ownname          => 'HR',
  tabname          => 'EMPLOYEES',
  estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
  method_opt       => 'FOR ALL COLUMNS SIZE AUTO',
  cascade          => TRUE   -- 同时收集该表所有索引的统计信息
);

-- 整个 Schema 收集
EXEC DBMS_STATS.GATHER_SCHEMA_STATS(
  ownname          => 'HR',
  estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
  method_opt       => 'FOR ALL COLUMNS SIZE AUTO',
  cascade          => TRUE
);

AUTO_SAMPLE_SIZE 使用 Oracle 的增量统计算法,以接近 100% 的精度、极小的采样成本完成统计——几乎没有理由手动指定采样百分比。

5.2 过期统计信息的探测

-- 超过 10% 数据变更的表会被标记为 STALE
SELECT owner, table_name, stale_stats
FROM   dba_tab_statistics
WHERE  owner = 'HR'
AND    stale_stats = 'YES';

批量加载、大事务提交后务必检查 stale_stats。如果表被标记为 YES,CBO 看到的行数可能只是实际行数的几分之一——这正是 E-Rows 远远小于 A-Rows 的最常见成因。

5.3 直方图:倾斜数据的必修课

-- 查看直方图的桶分布
SELECT endpoint_number, endpoint_value
FROM   dba_histograms
WHERE  owner = 'HR'
AND    table_name = 'EMPLOYEES'
AND    column_name = 'DEPARTMENT_ID'
ORDER BY endpoint_number;

SIZE AUTO 生效时,Oracle 自动为低基数列创建频率直方图(≤254 个不同值)或等高直方图(>254 个不同值)。手动干预通常不需要——除非某些列确实存在严重倾斜且自动直方图未触发。

5.4 扩展统计信息:修复关联列的盲区

当两个列存在关联(如 citystate),优化器默认假设它们独立,从而严重低估组合条件的选择性:

-- 创建列组扩展统计信息
SELECT DBMS_STATS.CREATE_EXTENDED_STATS(
  ownname => 'HR',
  tabname => 'EMPLOYEES',
  extension => '(DEPARTMENT_ID, JOB_ID)'
) FROM dual;

-- 重新收集以填充扩展统计信息
EXEC DBMS_STATS.GATHER_TABLE_STATS('HR', 'EMPLOYEES',
  method_opt => 'FOR ALL COLUMNS SIZE AUTO');

六、索引策略:从发现缺失到安全上线

6.1 从 v$sql_plan 发现缺失索引

全表扫描不一定是坏事——但如果大表反复被全扫,这几乎一定是索引缺失的信号:

-- 从游标缓存中找出大表上的全表扫描
SELECT s.sql_id, s.executions, p.object_name, p.operation, p.options,
       s.disk_reads / NULLIF(s.executions, 0) AS reads_per_exec
FROM   v$sql s
JOIN   v$sql_plan p ON s.sql_id = p.sql_id AND s.child_number = p.child_number
WHERE  p.operation = 'TABLE ACCESS'
AND    p.options   = 'FULL'
AND    p.object_name NOT IN ('DUAL')
ORDER BY reads_per_exec DESC NULLS LAST
FETCH FIRST 20 ROWS ONLY;

按每次执行平均物理读排序,排在最前面的就是最急需索引的查询。

6.2 不可见索引:生产环境的「隐形测试」

新增索引有风险:它可能改变其他 SQL 的执行计划,甚至在极端情况下拖慢批量 DML。不可见索引完美解决了这个两难:

-- 创建不可见索引——不影响任何现有执行计划
CREATE INDEX emp_salary_idx ON employees(salary) INVISIBLE;

-- 只在当前会话中启用不可见索引,进行效果验证
ALTER SESSION SET optimizer_use_invisible_indexes = TRUE;
EXPLAIN PLAN FOR SELECT * FROM employees WHERE salary BETWEEN 5000 AND 8000;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

-- 验证通过后设为可见
ALTER INDEX emp_salary_idx VISIBLE;

6.3 索引使用监控

-- 12.2+ 查看索引使用统计
SELECT index_name, table_name, monitoring, used, start_monitoring, end_monitoring
FROM   v$object_usage
WHERE  table_name = 'EMPLOYEES';

-- 对可疑索引开启监控
ALTER INDEX hr.emp_name_idx MONITORING USAGE;

发现从未使用过的索引后,在下一个维护窗口可以安全地将其删除——减少 DML 开销。

6.4 19c+ 自动索引

Oracle 19c 及以上版本支持自动索引:系统分析 SQL 工作负载,自动识别候选索引、创建、验证并在效果不佳时自动删除。在 DBA 资源有限的环境中,自动索引是索引优化的零成本起点。


七、SQL Profile 与 SQL Plan Baseline:生产环境的持久化护栏

Hint 写完就忘、统计信息可能在下一次自动收集任务中变化——生产环境需要更可靠的调优机制。

7.1 SQL Profile:修正估算,不锁定计划

SQL Profile 为特定 SQL 存储额外的统计修正因子,让 CBO 做出更准确的估算。它不锁定执行计划——如果数据分布变化,CBO 仍然可以自由选择新计划,但基于更准确的统计信息。

-- 使用 SQL Tuning Advisor 生成 Profile
DECLARE
  l_task_name VARCHAR2(30);
  l_sql_id    VARCHAR2(13) := 'abc12345xyz78';
BEGIN
  l_task_name := DBMS_SQLTUNE.CREATE_TUNING_TASK(
    sql_id      => l_sql_id,
    scope       => DBMS_SQLTUNE.SCOPE_COMPREHENSIVE,
    time_limit  => 60,
    task_name   => 'tune_task_1'
  );
  DBMS_SQLTUNE.EXECUTE_TUNING_TASK(task_name => l_task_name);
END;
/

-- 查看调优建议
SELECT DBMS_SQLTUNE.REPORT_TUNING_TASK('tune_task_1') FROM dual;

-- 接受 SQL Profile 建议
EXEC DBMS_SQLTUNE.ACCEPT_SQL_PROFILE(
  task_name    => 'tune_task_1',
  replace      => TRUE
);

7.2 SQL Plan Baseline:锁定已知良好的计划

SPM(SQL Plan Management)捕获已知良好的执行计划,并在优化器产生新计划时进行性能验证——只有验证通过的新计划才会被启用。这是防止计划回退的最后一道防线。

-- 从游标缓存加载已知良好计划作为基线
DECLARE
  l_count PLS_INTEGER;
BEGIN
  l_count := DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE(
    sql_id => 'abc12345xyz78'
  );
  DBMS_OUTPUT.PUT_LINE('Baselines loaded: ' || l_count);
END;
/

-- 查看现有基线
SELECT sql_handle, plan_name, enabled, accepted, fixed, origin
FROM   dba_sql_plan_baselines
ORDER BY created DESC;

-- 演化未接受的计划(测试并择优升级)
DECLARE
  l_report CLOB;
BEGIN
  l_report := DBMS_SPM.EVOLVE_SQL_PLAN_BASELINE(
    sql_handle => 'SQL_abc123',
    time_limit => 30,
    verify     => 'YES',
    commit     => 'YES'
  );
  DBMS_OUTPUT.PUT_LINE(l_report);
END;
/

两者的分工明确:

特性 SQL Profile SQL Plan Baseline
核心作用 修正统计估算 防止计划回退
是否锁定计划 否——CBO 仍可自由选择 是——仅允许已验证的计划
适用阶段 调优阶段 上线后的稳定化阶段

八、常见误区与纠正

错误做法 后果 正确做法
WHERE UPPER(name) = 'SMITH' 使用普通索引 索引失效→全表扫描 创建函数索引 ON employees(UPPER(name))
WHERE emp_id = '100'(NUMBER 列传入字符串) 隐式 TO_NUMBER → 索引失效 类型严格匹配
批量加载后不收集统计信息 CBO 基于旧行数生成计划 大 DML 后立即 GATHER_TABLE_STATS
在应用代码中硬编码 Hint 表改名/重建后 Hint 指向不存在对象 用 SQL Profile/Baseline 替代
WHERE ROWNUM < 10 ORDER BY col ROWNUM 在排序前过滤——返回随机 10 行 用子查询或 FETCH FIRST 10 ROWS ONLY
过度索引 DML 变慢、空间浪费 监控索引使用,删除未使用索引
忽略 TEMP 表空间溢出 Hash/Sort 溢出导致性能断崖式下降 增大 PGA 或减少中间结果集

九、总结

这张 SQL 调优知识体系的核心只有一个循环:看到坏计划 → 检查 E-Rows vs A-Rows → 修复统计信息或索引 → 用 Profile/Baseline 固化成果。

把这份 15KB 的指南配进 AI Agent,意味着它不再只会说「加个索引」——它能看到执行计划中 E-Rows 与 A-Rows 的鸿沟、能判断 NL/HJ/SMJ 的选择是否合理、能在批量 DML 后记得检查 stale_stats、能在调优完成后用 SQL Plan Baseline 锁定成果。这就是从「会生成 SQL 的 AI」到「会诊断 SQL 的 AI」的质变。

它提供什么 它不提供什么
执行计划的读取方法和诊断技巧 SQL 语法教程(那是 sql-patterns.md 的领域)
五种访问路径的选择逻辑和陷阱 具体的业务逻辑建议
三种连接算法的适用场景对比 操作系统级性能分析
Hint 的正确使用姿势和边界 非 Oracle 数据库的调优方法
统计信息、索引、Profile、Baseline 的完整操作链 应用层缓存策略

如果只记住一件事,记住这个:E-Rows 和 A-Rows 差距超过 10 倍的地方,就是你的维修点。 先修统计信息,再看索引,不到万不得已不用 Hint。

最后一条操作纪律:每次上线前,用不可见索引测试新增索引的效果;每次调优后,用 SQL Plan Baseline 锁定已验证的计划。这两步之间的空白,就是生产事故的温床。


源文件:https://github.com/oracle/skills/blob/main/db/performance/sql-tuning.md

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐