1. 一次深夜告警引出的性能血案
事情发生在某天晚上十一点,客户打电话过来,说他们的计费系统跑批任务从平时的三分钟变成了四十分钟,整个营业厅的日终结算全部卡死。我远程连上去一看,CPU利用率倒是没爆,但数据库的DB CPU时间非常低,绝大多数时间都堆在db file sequential read和enq: TX - row lock contention这两个等待事件上。
说实话,做Oracle优化这几年,我越来越觉得性能问题九成以上不是数据库“不行”,而是SQL写得“太随意”。那次排查到最后,根因就是一张两千万行的流水表上,某个查询条件列没建索引,导致关联时驱动表每一次都要全表扫描,继而引发锁等待、回滚段膨胀,最后整个库像被掐住脖子一样。
这篇我就把这几年在Oracle优化上踩过的坑、总结过的经验按实战路径重新捋一遍。不是教科书式的概念堆砌,而是从“当时我是怎么一步步定位问题”的角度出发,把那些真正管用的方法、工具、思路和取舍讲明白。如果你正在处理线上Oracle慢查询,或者刚接触数据库优化、不知道从哪里下手,这篇文章应该能帮你少走很多弯路。
2. 慢SQL定位:不是先看索引,而是先看它“等”在哪里
很多人一接到慢SQL,第一反应就是“加索引”,这其实是个大误区。索引确实是优化的重要手段,但你得先搞清楚这条SQL到底慢在哪里——是跑了太多逻辑读?还是在等锁?还是等待I/O?还是执行计划本身选错了访问路径?方向不对,加再多索引都是白搭。
2.1 从AWR和ASH报告里读症状
第一次接到这个案例时,我第一件事是抓AWR报告。看AWR主要看三个地方:
Top 5 Timed Events:确认时间花在什么等待上;SQL Statistics——Elapsed Time排序:找到最耗时的SQL;Segment Statistics:看哪些段对象的逻辑读或物理读异常高。
那次Top 5里db file sequential read排第一,占比超过64%。这个等待事件的意思是:会话在等单块读取,也就是大量走索引回表,或者手动走了索引但每次只读一两个数据块。按理说走索引是好事,但等这个事件的比例过高,说明索引选择可能有问题——要么选择性太差,要么回表次数太夸张。
ASH(Active Session History)更适合定位瞬间的阻塞问题。那次模拟排障中,我靠ASH里的blocking_session字段顺藤摸瓜,找到了一个会话正拿着一张表的行锁不释放,其他十几个会话全部卡在enq: TX - row lock contention上。
这里想强调一个经验:看AWR/ASH别只看平均值,重点看“时间模型”里的趋势和等待分布。如果DB CPU占比高,那多半是SQL本身逻辑读太大、消耗CPU;如果User I/O类等待占比高,才有必要去检查I/O能力和索引效率。
2.2 执行计划是你最好的“CT机”
跑出慢SQL的SQL_ID后,我用这条语句从V$SQL里拿出来:
SELECT sql_text, sql_id, elapsed_time, buffer_gets, disk_reads FROM v$sql WHERE sql_id = 'xxxxxxxxxxxxxxxx' AND executions_total > 0;拿到SQL文本后,执行计划的查看方式很简单:
EXPLAIN PLAN FOR SELECT ... --- 原SQL SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);对于已经在共享池里的SQL,直接看真实执行计划更准:
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('sql_id', 0, 'ALLSTATS LAST'));用ALLSTATS LAST能看到每一步实际返回的行数(A-Rows)、实际消耗的物理读和逻辑读。很多所谓“优化方案”之所以不落地,就是因为只看预估基数(Cardinality),不看实际行数。预估偏差能差到百倍以上,必须用真实执行数据来判断瓶颈点。
我个人看执行计划只关心三件事:
- 访问路径:是全表扫(TABLE ACCESS FULL),还是索引范围扫(INDEX RANGE SCAN),还是索引快速全扫(INDEX FAST FULL SCAN)?
- 连接顺序:哪张表是驱动表?能不能用小结果集驱动大结果集?
- 过滤时机:
WHERE条件到底在哪一步生效的?是在索引里就过滤掉了,还是回表后才过滤?
这三件事看明白了,SQL慢在哪里基本有数了。
2.3 两个常用的定位脚本
为了快速排查,我把几个常用查询固化成了脚本,这里也分享出来:
-- 当前正在执行的慢SQL SELECT s.sid, s.sql_id, s.status, s.blocking_session, q.sql_text FROM v$session s LEFT JOIN v$sql q ON s.sql_id = q.sql_id WHERE s.type = 'USER' AND s.status = 'ACTIVE' ORDER BY s.last_call_et DESC;-- 按逻辑读排序的Top SQL SELECT sql_id, executions, buffer_gets / DECODE(executions, 0, 1, executions) AS avg_buffer_gets, elapsed_time / DECODE(executions, 0, 1, executions) AS avg_elapsed_us, sql_text FROM v$sql WHERE buffer_gets > 100000 ORDER BY avg_buffer_gets DESC FETCH FIRST 20 ROWS ONLY;这里有个小细节:buffer_gets是逻辑读,它比物理读更能反映SQL是否高效。因为物理读可以被缓存掩盖,而逻辑读是实打实访问了多少个内存块,是CPU消耗的主要来源。一条SQL逻辑读几千次没关系,但如果单次执行逻辑读上百万,无论物理读多少,都说明这个SQL有问题。
3. 索引设计的底层逻辑:B树结构决定了你的优化边界
定位出慢SQL之后,大部分情况下优化手段确实会落在索引上。但“加索引”不是随便加,你得理解B树索引是怎么工作的,才能解释“为什么这个索引没用”以及“为什么看起来该走索引却不走”。
3.1 理解B树索引的三层成本
B树索引的查询成本大致由三部分组成:
- 遍历索引树的深度(BLEVEL):一般2到3层,代价极低;
- 读取索引叶子块的数量:由选择性决定,比如等值条件能命中500个键值,那可能只读几个叶子块;
- 回表(TABLE ACCESS BY INDEX ROWID):每命中一行就顺着ROWID去读表块。注意,如果返回的行在物理上分散,回表就是一次单块读,一次一行,非常慢。
这就是为什么“选择性差”的列不适合建索引。举个例子,一张表有1000万行,某列只有两个值(比如状态字段“有效”和“无效”,比例8:2),你在这种列上建索引,走索引范围扫可能命中800万行,然后回表800万次。这种情况下优化器算一下成本,大概率选择全表扫描,因为全表扫描是多块读,效率反而高很多。
3.2 复合索引的列顺序:等值条件优先,排序字段次之
我给某系统设计过一张交易表的复合索引,当时的需求是查某个客户在某个时间段的订单。一开始索引建的是(trade_date, customer_id),走range scan,效果不理想。后来改成(customer_id, trade_date),效果立竿见影。
原因很简单:customer_id是等值条件,trade_date是范围条件。等值条件的列放前面,可以先通过索引精确定位到某个客户的所有数据,再在索引内部用范围扫描获取时间范围;反过来先放时间列,索引要扫描的时间段内所有客户,数据量完全不是一个量级。
如果SQL里还有排序字段(ORDER BY),复合索引也能帮助消除排序。比如WHERE customer_id = ? ORDER BY trade_date DESC,如果索引是(customer_id, trade_date DESC),数据按索引顺序取出即可,连SORT ORDER BY操作都省了。执行计划里少一个SORT,往往意味着省掉几十MB的临时空间消耗。
这里给一个索引列顺序的经验法则:
| 场景 | 列顺序建议 |
|---|---|
| 等值 + 范围 | 等值列在前,范围列在后 |
| 等值 + 等值 | 选择性高的在前 |
| 等值 + ORDER BY | 等值列在前,排序列紧跟其后 |
| 范围 + ORDER BY | 很难两全,优先范围列作为索引前缀 |
3.3 回表代价的估算方法
回到那晚的案例。流水表上有account_id的索引,查询条件是account_id = :1 AND trans_time BETWEEN :2 AND :3。执行计划显示INDEX RANGE SCAN后跟着84万次回表——索引命中了84万行,但每一行都要回到表里取其他列数据。
这里有个关键知识点:回表取数只在SELECT的列不完全包含在索引里时才发生。如果查询列都被索引覆盖,那就不会回表。优化手段是两部分:
- 把查询中用到的关键列加入索引,形成覆盖索引;
- 或者把回表行数降下来——回表一次一块多,84万次回表简直灾难。
那次我做了个实验,把回表行数从84万降到1.2万之后,SQL耗时从40秒降到了0.8秒。不是索引失效,而是选择性选对了。这个过程也让我体会到:回表的数量级比回表动作本身更值得关注,大结果集回表一定慢。
3.4 函数索引与虚拟列:什么时候值得用
Oracle不支持直接在WHERE TRUNC(create_time) = DATE '2024-01-01'这样对列做函数运算后还用普通索引。这时有两个方案:
- 建
TRUNC(create_time)的函数索引; - 建虚拟列+普通索引。
我个人倾向于虚拟列方案,因为虚拟列可以统计、可以建普通索引,优化器对它的依赖更少,而且后续如果要加分区还能直接挂虚拟列。建法示例:
ALTER TABLE trans_log ADD (trunc_create_time DATE GENERATED ALWAYS AS (TRUNC(create_time)) VIRTUAL); CREATE INDEX idx_translog_trunc_time ON trans_log(trunc_create_time);函数索引不算银弹——它要求SQL写法完全匹配函数表达式,稍微一变就不走了。而且函数索引的统计信息收集和维护比普通索引更费劲,用之前要三思。只在那些确实躲不开函数包裹的场景用。
4. SQL写法决定生死:同一句话换个写法,性能差百倍
执行计划是表象,SQL写法才是根源。有些SQL只要改个写法,效果比加索引还明显。这节讲几个我遇到最多、改写价值最大的SQL反模式。
4.1 隐式转换:字符列与数字比较的坑
这是我见过最多的一类问题。某个系统里表字段是VARCHAR2,查询条件写成WHERE cert_no = 123456789,Oracle会默认把列转换成数字,结果就是该列上的索引完全失效,每次都全表扫。
看执行计划时如果发现predicate里有TO_NUMBER("CERT_NO"),基本就是隐式转换实锤了。解决办法只有一个:把SQL里的常量改成与列类型一致的类型。
-- 错误写法 WHERE cert_no = 123456789; -- 正确写法 WHERE cert_no = '123456789';隐式转换的坑之所以隐蔽,是因为它不报错,只是性能缓慢下降,等发现时往往已经跑了很久。排查时可以查V$SQL里EXECUTIONS高、BUFFER_GETS高的SQL,检查它们的谓词信息,这类问题一抓一个准。
4.2 函数包裹列与范围改写
另一个常见反模式是WHERE TRUNC(create_time) = TRUNC(SYSDATE)。列被函数包了一层后,普通索引失效。正确做法是改写成范围条件:
-- 反模式 WHERE TRUNC(create_time) = TRUNC(SYSDATE); -- 优化 WHERE create_time >= TRUNC(SYSDATE) AND create_time < TRUNC(SYSDATE) + 1;这样既是等值/范围扫描,又能直接用create_time上的索引。这里再补充一个细节:TRUNC(SYSDATE) + 1的写法比TRUNC(SYSDATE + 1)更稳,因为后者在极端情况下会把时分秒也算进去。改成范围条件后,执行计划里能从FULL变成RANGE SCAN,大表上往往就是几百倍的差距。
4.3 OR与IN改写:UNION ALL的真正优势
WHERE a = 1 OR b = 2这种写法,优化器往往选择全表扫,因为OR很难走两个索引再合并。改写成UNION ALL往往能强制走索引:
-- 反模式 SELECT * FROM t WHERE a = 1 OR b = 2; -- 优化(前提:两个分支不会产生重复行) SELECT * FROM t WHERE a = 1 UNION ALL SELECT * FROM t WHERE b = 2;这里有个很关键的注意点:UNION会自动去重,但会引入排序和临时表开销,所以如果两个分支结果集确定不重复,一定用UNION ALL;如果可能有重复,宁可加DISTINCT也别用UNION,因为UNION的排序代价在数据量大时是灾难性的。
另外,如果a和b是同一列上的多个值,直接IN即可,不用拆。IN在Oracle里会被改写为OR,但优化器对IN的优化比对普通OR好得多,执行计划会更干净。
4.4 深分页:OFFSET不一定是好选择
新版本的Oracle 12c以后支持OFFSET ? ROWS FETCH NEXT ? ROWS ONLY,语法很友好,但性能其实很一般——它本质上还是要把前N行全部读出来再丢掉。查询第10000页时,数据库仍要扫完前面五十万行,这个逻辑读逃不掉。
更深入的做法有两个:延迟关联(先只查主键,再回表)或者基于排序键的游标分页:
-- 延迟关联示例 SELECT t.* FROM ( SELECT id FROM trans_log WHERE create_time >= :start AND create_time < :end ORDER BY create_time OFFSET 500000 ROWS FETCH NEXT 100 ROWS ONLY ) x JOIN trans_log t ON t.id = x.id;或者基于上一页最后一条记录的游标分页:
SELECT * FROM trans_log WHERE create_time > :last_create_time ORDER BY create_time FETCH NEXT 100 ROWS ONLY;游标分页是本质解法,它每次只读取100行索引数据,完全不受页数影响。如果前端交互不允许这种“下一页”模式,延迟关联至少能把回表次数限制在100次内,而不是把50万行全部取出再丢掉。
4.5 标量子查询和LEFT JOIN的取舍
还有一个高发问题:在大表关联时用LEFT JOIN+COUNT统计子表行数。比如统计每个客户的订单数,写成:
SELECT c.customer_id, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) AS order_cnt FROM customer c;这条SQL在小数据量时没啥问题,但客户表几十万行、订单表几百万行时,标量子查询会被反复执行,逻辑读爆炸。改写思路一般是先聚合再关联:
SELECT c.customer_id, NVL(agg.cnt, 0) AS order_cnt FROM customer c LEFT JOIN ( SELECT customer_id, COUNT(*) cnt FROM orders GROUP BY customer_id ) agg ON c.customer_id = agg.customer_id;这里聚合子查询只跑一次,然后整体做一次关联,效率天壤之别。执行计划里少了一个COUNT STOPKEY或SORT AGGREGATE的反复调用,总体耗时能降一个数量级。
5. 统计信息与执行计划突变:为什么昨天还很快,今天就变慢
优化完SQL后还有一个绕不开的坑:执行计划不稳定。你可能遇到过这种情况——同一句SQL,昨天走索引,今天全表扫,然后整个系统就崩了。这通常不是SQL的问题,而是统计信息和优化器行为的问题。
5.1 统计信息不新鲜与动态采样的局限
Oracle优化器默认依赖表和索引的统计信息来选择执行计划。如果你没开自动统计信息收集任务,或者刚导入了大量数据但没立刻收集,优化器就会用“过期的”行数来做估算,结果必然偏。
排查方法很简单,对比DBA_TAB_STATISTICS里的LAST_ANALYZED和NUM_ROWS,看是否和实际数据量匹配:
SELECT table_name, num_rows, last_analyzed FROM dba_tab_statistics WHERE owner = 'APP' AND table_name IN ('TRANS_LOG', 'ORDERS');如果发现统计信息过期,直接:
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP', 'TRANS_LOG', cascade => TRUE);这个活儿不能只在出了问题才做,更应该纳入日常巡检。另外我一般不怎么依赖动态采样,它适合在没有统计信息时做个兜底,但动态采样的估算误差在大表上特别夸张,别把它当长期方案。
5.2 绑定变量窥视与自适应游标共享
Oracle 11g之前的绑定变量窥视(Bind Peeking)有个著名问题:SQL第一次执行时用的绑定变量值会决定执行计划,之后即使换成极不符合这个计划的变量值,计划也不变。比如第一次绑定的是'2024-01-01',结果集很小,走了索引;之后绑定'2024-01-01'到'2024-12-31'全年数据,依旧走索引,此时回表几百万行,查询慢到飞起。
Oracle 11g以后的自适应游标共享(Adaptive Cursor Sharing)缓解了一部分问题,优化器会根据不同绑定值重新评估执行计划。但这不是绝对的。更稳妥的方案是用SQL Plan Management(SPM)把执行计划固定住。
5.3 用SQL Plan Management固定计划
SPM的玩法是:捕获SQL的执行计划基线,锁住它,以后优化器只在基线里挑。手动加载当前计划作为基线:
DECLARE v_plan_list DBMS_SQLTUNE.PLAN_LIST; BEGIN v_plan_list(1) := NULL; DBMS_SQLTUNE.LOAD_SQL_PLAN_BASELINE( sql_handle => 'SQL_HANDLE_HERE', plan_name => 'PROD_STABLE_PLAN', replace => 'NO' ); END; /然后验证基线是否启用:
SELECT sql_handle, plan_name, enabled, accepted FROM dba_sql_plan_baselines WHERE sql_text LIKE '%关键字%';SPM不是银弹,它只保证计划不突变,不保证你选的那个计划就是最优的。所以加载基线前一定要确认这个计划在各种业务场景下都稳定可用。在我接手过的系统里,SPM是执行计划突变的“最后一道保险”,强烈推荐给核心生产系统。
5.4 为什么Hint不是长期方案
很多DBA喜欢在SQL里直接塞/*+ FULL(t) INDEX(t idx1) */来强制计划。我的观点是:Hint可以用,但只能作为临时止血手段。原因有三点:
- 数据量变化后,强制走的路径可能不再是正确选择;
- Hint让后续维护的人完全看不懂原本的意图;
- 一旦升级或打补丁,优化器的行为变化可能导致Hint语义异常。
正确的做法是:先用Hint验证优化方向是否有效,稳定后再考虑SPM或改SQL本身来固化,最后把临时Hint撤掉。
6. 一次完整优化实录:从全表扫到稳定秒回
讲了这么多理论和经验,我拿一个具体的模拟优化过程完整走一遍。这个场景参考了某模拟订单系统的日终汇总查询,SQL不算极端复杂,但很能代表生产环境里的典型问题。
6.1 原始SQL与执行计划
SELECT c.customer_name, SUM(o.order_amount) AS total_amount, COUNT(DISTINCT o.order_no) AS order_cnt FROM customer c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.create_time >= TRUNC(SYSDATE) - 30 AND c.customer_level IN ('A', 'B') GROUP BY c.customer_name;执行计划显示:orders表走全表扫,customer表先全表扫再与orders做HASH JOIN RIGHT OUTER,COUNT(DISTINCT)触发SORT GROUP BY,临时表空间消耗巨大。全表执行耗时约183秒,逻辑读接近9000万。
6.2 当时的分析思路
第一眼看到的几个问题,按影响排序:
orders.create_time上虽然有索引,但因为TRUNC(SYSDATE) - 30对常量做函数不会破坏索引,真正的问题在于o.customer_id与c.customer_id的关联没有能充分利用索引;COUNT(DISTINCT order_no)在几百万行级数据上做排序去重,代价极高;customer_level IN ('A', 'B')选择性不好,单独建索引意义不大,但它限制了驱动表范围,可以作为关联的切入点。
我先验证中间结果集规模:30天内订单大约320万行,A/B级客户大概8.7万。驱动表肯定选客户表,然后通过嵌套循环去订单表找数据更合理。
6.3 改写与索引调整
第一步改写:将COUNT(DISTINCT order_no)拆到子查询里,先按客户ID聚合,再关联。
第二步改写:把TRUNC(SYSDATE)-30改成>= SYSDATE-30的范围条件,保证范围扫描时能用create_time索引。
最终SQL:
SELECT c.customer_name, agg.total_amount, agg.order_cnt FROM customer c LEFT JOIN ( SELECT o.customer_id, SUM(o.order_amount) AS total_amount, COUNT(DISTINCT o.order_no) AS order_cnt FROM orders o WHERE o.create_time >= SYSDATE - 30 GROUP BY o.customer_id ) agg ON c.customer_id = agg.customer_id WHERE c.customer_level IN ('A', 'B');索引调整:
CREATE INDEX idx_orders_cust_time ON orders(customer_id, create_time, order_no, order_amount);这个索引设计思路是:customer_id等值关联,create_time做范围过滤,order_no和order_amount让聚合操作直接从索引中取数,避免回表。
6.4 优化后的结果对比
改完后重新跑执行计划,orders的访问从118万的TABLE ACCESS FULL变成了11万的INDEX RANGE SCAN,聚合操作也从SORT GROUP BY变成了SORT GROUP BY NOSORT——因为索引已经排序好了,Oracle直接从索引顺序取数就好。
实测数据:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 执行耗时 | 183秒 | 3.2秒 |
| 逻辑读 | 89,520,341 | 2,310,845 |
| 物理读 | 12,480,532 | 168,902 |
| 临时表空间 | 4.2GB | 0KB |
这个案例里没有用到任何高深的特性,全是基础手段:改掉函数包裹的范围条件、拆分聚合子查询、建立覆盖索引。但效果就是这么明显。
6.5 优化后的回归验证
拿到一个“看起来很快”的执行计划后,不能直接上线。我习惯做三件事:
- 用不同的时间参数(比如1天、60天)跑同一句SQL,确认执行计划没有因为选择率变化而突变;
- 用
DBMS_STATS重新收集相关表统计信息,避免上线后统计信息过期导致计划漂移; - 连续跑三天监控
V$SQL里的elapsed_time和buffer_gets,确认趋势稳定。
这次优化里,我用1天数据测试时执行计划保持INDEX RANGE SCAN,用60天数据测试时优化器改走了INDEX FAST FULL SCAN,但总耗时都控制在5秒内,说明方案是稳的。
7. 写在最后:优化是一场取舍博弈
做了这么多Oracle优化,我最大的体会是:优化本质是取舍,不是闷头加索引、改SQL就行。你加的每个索引都会拖慢INSERT/UPDATE/DELETE,因为写数据时索引也要同步维护;你拆的每个SQL都在增加应用代码的复杂度。所以在给出优化方案前,一定要先搞清楚这个系统的业务特征是读多写少还是写多读少。
如果核心链路是OLTP(在线交易),优先保证小事务的快速提交,索引不能多而杂,复合索引慎之又慎;如果核心链路是OLAP(分析报表),反规范化设计和覆盖索引可以大胆一点,因为写入量相对小,读取性能收益远大于写放大成本。
最后再分享一个小技巧。每次优化完SQL,我都会把优化前的执行计划、逻辑读、耗时记录留档,和优化后的结果一起归档。不要嫌麻烦——上线一个月后如果有人问“当时为什么这么改”,你只需要拿出来几分钟就能讲清楚来龙去脉,远比靠回忆靠谱得多。这套“证据链”习惯,帮我省了无数复查和扯皮的功夫。Oracle优化没有一劳永逸的银弹,但如果你每一步都能说清楚“为什么”,大部分性能问题其实在动手之前就已经解决了一半。