Oracle 慢 SQL 优化:Cost 最高的那行不是瓶颈,真凶藏在 SELECT 列表里

一份执行计划摆在你面前,Cost 最高的那一行,通常不是真正拖慢查询的地方。

这不是玄学。最近在一个 Oracle 库上连着看了三条慢 SQL,三条都是这样。

下面这篇不讲怎么写 SQL,只讲怎么把一条老 SQL 看穿------以及一个很多人从来没数过的数字。


一、先看现场:一条商品资料查询

这是一条很典型的商品资料查询,8 张表,DOC_GOODS(商品档案)打头,连了商品配置表 DOC_GOODSCFG、商品供应商表 DOC_GOODSSUP、三份单位表 DOC_GOODSUNIT、两份供应商表 DOC_SUPPLIER

WHERE 里挂了一个 EXISTS 子查询,SELECT 列表里塞了 3 个标量子查询和 5 个 PL/SQL 函数。

它在服务器上的执行计划长这样(节选关键行):

sql 复制代码
| Id | Operation                    | Name                   | Rows  | Cost  | Time     |
|----|------------------------------|------------------------|-------|-------|----------|
|  0 | SELECT STATEMENT             |                        | 24822 | 20107 | 00:04:02 |
|  8 |   SORT ORDER BY              |                        | 24822 | 20107 | 00:04:02 |
| 11 |    HASH JOIN                 |                        | 24822 | 17440 | 00:03:30 |
| 21 |     HASH JOIN                |                        | 24244 | 17218 | 00:03:27 |
| 22 |      NESTED LOOPS            |                        |       |       |          |
| 24 |       SORT UNIQUE            |                        | 31313 |   267 | 00:00:04 |
| 25 |        INDEX RANGE SCAN      | IDX_DOC_GOODSCFG_ISCFG | 31313 |   267 | 00:00:04 |
| 26 |       INDEX RANGE SCAN       | PK_DOC_GOODSCFG        |     1 |     2 | 00:00:01 |
| 28 |      TABLE ACCESS FULL       | DOC_GOODS              | 12372 |   598 | 00:00:08 |

总 Cost 20107 ,优化器自己估了个 4 分 02 秒 ,预计输出 24822 行

Cost 最高的是 Id 8 的排序(20107),但排序是被下面喂出来的,砍它没用。真正贵的是 Id 22--27 那串嵌套循环(NESTED LOOPS,就是两层循环逐行比对),Cost 16272,占整条计划的八成。


二、第一次被骗:你以为改完 JOIN 就完事了

那串嵌套循环是 EXISTS 子查询被展开的结果。还原一下优化器的动作:

  1. IDX_DOC_GOODSCFG_ISCFG 索引取出 DEPTID='1001'31313 个 GDSEQ(Id 25)
  2. 对这 31313 行,逐行 回表用主键 PK_DOC_GOODSCFGDEPTID='5001' 的那条配置(Id 26--27)
  3. 结果 43832 行,再拿去 HASH JOIN 商品表

表面上看,答案很清楚:驱动集太大了,让优化器改用哈希半连接(HASH SEMI JOIN),一次建哈希表搞定,不用逐行回表,Cost 能砍掉一大块。

但先别急。 我在这段 SQL 里发现了一个比性能更要命的问题。


三、一个 Bug,同时造成慢和错

原 SQL 的 EXISTS 是这样写的:

sql 复制代码
AND EXISTS (SELECT GDSEQ
              FROM DOC_GOODSCFG
             WHERE DEPTID = '1001'
               AND PZ.ISCFG IN ('1', 'Y')
               AND GDSEQ = PZ.GDSEQ)

看出问题了吗?

子查询里的 DOC_GOODSCFG 没有起别名,而 PZ 是外层那个 DEPTID='5001' 的别名。

也就是说,这个 PZ.ISCFG IN ('1','Y') 约束的是外层 那一行,跟 DEPTID='1001' 那一行一点关系都没有。而主 WHERE 里早就写了 PZ.ISCFG IN ('1','Y')------这个条件纯属冗余。

实际语义退化成了:"只要 1001 科室存在这个商品的记录就行,不管它的配置有没有生效。"

后果有两层:

  • 正确性 :如果原意是"两个科室的配置都要有效",那这条 SQL 一直查不准,而且没人发现,因为它不报错。
  • 性能 :正因为内层缺了这个过滤条件,索引只能按 DEPTID 单条件扫,31313 行全量参与驱动,才有了那串要命的嵌套循环。

漏一个别名,同时贡献了一个正确性 Bug 和一个性能 Bug。 这类写法在经手过多人的老系统里特别常见------它不报错、不报慢,就这么安静地待上好几年。


四、第二次被骗:全表扫描不等于要建索引

再看 Id 28,DOC_GOODS 全表扫描,Cost 598。

过滤条件是这四个:

sql 复制代码
SP.CATID0 IN ('2','5')  AND SP.ISGZ = 'N'
AND SP.FLAG IN ('Y','T') AND SP.ISDELETE = 'N'

四个都是低区分度字段(取值很少,比如是否删除只有 Y/N)。这种字段单独建索引,收益极低,还会拖慢每次写入。

而且算笔账:598 的 Cost,在 20107 的总量里只占 3%。

优化里最贵的一课就是------不是每个"看起来不对"的地方都值得改。全表扫先算占比,占比 5% 以下的,动它不如动别的。


五、第三次:真凶在计划里根本不显示

前面两次都还在计划里打转。真凶不在。

关键事实:Oracle 执行计划里的 Cost,不包含 SELECT 列表的开销。

现在拿这个事实,去数一下那条 SQL 的 SELECT 列表:

标量子查询 3 个(就是写在 SELECT 里、每行各执行一次的小查询):

子查询 作用
UDI_DI 取商品 UDI 码,BASE_AMOUNT=1ROWNUM=1
zdkc 取 1001 科室的最低库存
zgkc 取 1001 科室的最高库存

24822 行 × 3 = 约 7.4 万次索引回表

PL/SQL 函数 5 个

函数 作用
f_getcatname 品类名称
f_getsupname 供应商名称
F_GETDEPTCKSL 科室库存数量
F_GETDHBZGG 大包装规格
F_GETZBJG_NAME 中标价格类型

24822 行 × 5 = 约 12.4 万次函数调用 。其中 F_GETDEPTCKSL(取科室库存)几乎可以肯定函数体里还藏着一条 SQL,等于 2.4 万次递归查询。

两者加起来接近 20 万次额外执行,在 Cost 里是一行都看不到的。

所以这条 SQL 的真实画像是:优化器估的 4 分钟里,瓶颈在嵌套循环;但真正上线跑起来把你拖住的,更可能是这 20 万次计划里看不见的函数和子查询。


六、不是个例:三条 SQL,同一个病根

我把同一套系统里另外两条慢 SQL 也拉出来看了。

第二条:单品消耗排行。 总 Cost 50276,优化器估 10 分 04 秒。计划里最显眼的是 DAT_GOODSJXC 全表扫描,Cost 48770,占 97%------看起来答案毫无悬念。

但 SELECT 列表里有 4 个 F_GETBL 函数,分别算金额环比、金额同比、数量环比、数量同比。行数 4469:

4469 × 4 ≈ 1.8 万次函数调用,每次内部大概率再跑一次聚合查询。

第三条:使用类别排行。 连执行计划都不用看,SQL 本身就够了------SELECT 列表里整整齐齐排着 8 个 F_GET_CAT_PIE,每个类别行各调 8 次。

三条 SQL,三张完全不同的表,同一个病根:性能被吃在 SELECT 列表里,而不是 JOIN 里。


七、可带走:《老系统 SQL 体检 5 步法》

这是我这次用完的顺序,下次你拿到一条慢 SQL 可以直接照着走。

第 1 步:先别看 Cost 最高的那行。先看 SELECT 列表。 数清楚:有几个函数?几个标量子查询?写在 SELECT 里的东西,是按输出行数逐行执行的。

第 2 步:算隐藏调用次数。

预估输出行数 ×(函数个数 + 标量子查询个数)

这是本次最重要的一条。结果过万就亮红灯,过十万基本可以断定它是主因------哪怕执行计划里一点都看不出来。

第 3 步:看 EXISTS / IN 被展开成了什么。 驱动集行数 × 单次回表代价。如果是嵌套循环且驱动集上万,优先改写成哈希半连接;改之前先确认子查询里的别名有没有写错(见第 5 步)。

第 4 步:全表扫先算占比,别急着建索引。 Cost 占比低于 5% 的不值得动;低区分度字段(是否删除、状态标志这类)硬建索引是负收益。这种情况先想统计信息和直方图:

sql 复制代码
EXEC DBMS_STATS.GATHER_TABLE_STATS(USER,'DAT_GOODSJXC',
     METHOD_OPT=>'FOR COLUMNS KCADD, BILLTYPE, DEPTID SIZE AUTO');

第 5 步:检查子查询里的别名。 内层表没起别名、条件里却写了外层别名------这是老系统里最常见的一类隐蔽 Bug。它不报错,通常同时造成慢和错


八、改的话,从哪儿下手

按收益排序,前两步都不用改表结构:

  1. 把行级函数改成集合化 。像 F_GETBL 这种,把当期、环比期、同比期各用一次 GROUP BY 的 CTE 算出来再 JOIN,1.8 万次调用能压成 3 次扫描。
  2. 砍掉多余的连接 。第二条 SQL 里 B.STR2 = DR.SEQNO 连的 DAT_RK_DOC,既没被 SELECT 也没被过滤,却带来了 89379 次索引探测。先确认业务上是否真的需要。
  3. 建覆盖索引(把过滤列和返回列都放进索引,扫描索引直接出结果,不用回表):
sql 复制代码
CREATE INDEX IDX_JXC_RPT
  ON DAT_GOODSJXC(RQSJ, KCADD, DEPTID, GDSEQ, SL, HSJE, BILLTYPE);
  1. 统计信息 + 直方图,让优化器算对选择率,上面的索引才真会被选中。
  2. 表持续膨胀再考虑按日期范围分区 ;周期性报表直接上日汇总表或物化视图

老系统的 SQL,写得久、改得多、经手的人换了好几轮。它慢,往往不是哪个人写错了,而是每一代人在上面加了一层:加一个字段、加一个函数、加一个 EXISTS。单看每一层都合理,叠起来就成了四分钟。

所以优化的第一步不是改,是先把它数清楚------尤其是那些执行计划里不显示的部分。

你手上的老系统里,有没有一条 SQL 的 SELECT 列表比它的 WHERE 还长?

相关推荐
天衍四九-1 小时前
Agent Skills从入门到工程化(十六):面试中如何讲清楚 Agent Skills?
大数据·数据库·人工智能·python·chatgpt·面试
H_oRIZoN_2 小时前
Linux入门DAY37(IO 多路复用、TCP 并发服务器与数据库 SQLite3 详解)
linux·服务器·数据库
2501_930472442 小时前
05_数据库迁移腾讯云_DTS评估与预检回滚清单
数据库·云计算·腾讯云
三8442 小时前
SSRF 从入门到实战(下篇):gopher 打 Redis 实现 RCE
数据库·redis·缓存
承渊政道2 小时前
从GB到TB:KFS如何实现高吞吐、有序的异构增量同步
数据库·kingbase·延迟优化·kfs·数据量级优化
广州灵眸科技有限公司2 小时前
灵眸科技EAI3572-Core-L核心板即将发布!八核+4TOPS NPU,面向工业与边缘AI
linux·运维·服务器·数据库·yolo
Y38153266210 小时前
MySQL 慢查询排查实战:EXPLAIN 看懂 type 与 Extra,一个字段定位性能问题
数据库·mysql
Logintern0912 小时前
PostgreSQL 的 ORDER BY 多列排序
数据库·postgresql
今天AI了吗12 小时前
DeepSeek Harness 深度解析:从评测架构到实战落地
java·网络·数据库·人工智能·架构·java-ee