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_GOODSCFG 找 DEPTID='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=1 且 ROWNUM=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 还长?

相关推荐
这个DBA有点耶5 天前
MVCC深入:Read View、版本链与快照读——InnoDB并发控制的内核
数据库·mysql·架构
DBA_G5 天前
从地面到云霄:GBase数据库在民航三大场景的落地实践
数据库
自由能燃气设备5 天前
商用全预混低氮冷凝锅炉免费方案vs付费方案对比+选型避坑指南
大数据·数据库·人工智能
科创致远5 天前
科创致远 ESOP 系统核心效能与实战价值展示
大数据·数据库·人工智能·精益工程
2601_962218615 天前
万象生鲜系统称重自动多退少补算法解决生鲜非标品痛点
大数据·数据库·人工智能·python·算法
张洛闻Eren5 天前
k8s云原生【第十课】:水平 Pod 自动扩缩容
运维·数据库·云原生·kubernetes·github
于平安5 天前
MySQL-触发器
数据库·mysql
白远山5 天前
上海24小时自助健身房解决方案实战指南与经验分享
java·数据库·架构·需求分析
Omics Pro5 天前
斯坦福Nature+Science|广义虚拟细胞基础大模型
数据库·人工智能·算法·机器学习·自然语言处理
2603_965148115 天前
AI+API选品:下一代智能商务助手雏形已现
java·大数据·数据库·人工智能·数据挖掘