一份执行计划摆在你面前,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 子查询被展开的结果。还原一下优化器的动作:
- 用
IDX_DOC_GOODSCFG_ISCFG索引取出DEPTID='1001'的 31313 个 GDSEQ(Id 25) - 对这 31313 行,逐行 回表用主键
PK_DOC_GOODSCFG找DEPTID='5001'的那条配置(Id 26--27) - 结果 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。它不报错,通常同时造成慢和错。
八、改的话,从哪儿下手
按收益排序,前两步都不用改表结构:
- 把行级函数改成集合化 。像
F_GETBL这种,把当期、环比期、同比期各用一次GROUP BY的 CTE 算出来再JOIN,1.8 万次调用能压成 3 次扫描。 - 砍掉多余的连接 。第二条 SQL 里
B.STR2 = DR.SEQNO连的DAT_RK_DOC,既没被 SELECT 也没被过滤,却带来了 89379 次索引探测。先确认业务上是否真的需要。 - 建覆盖索引(把过滤列和返回列都放进索引,扫描索引直接出结果,不用回表):
sql
CREATE INDEX IDX_JXC_RPT
ON DAT_GOODSJXC(RQSJ, KCADD, DEPTID, GDSEQ, SL, HSJE, BILLTYPE);
- 统计信息 + 直方图,让优化器算对选择率,上面的索引才真会被选中。
- 表持续膨胀再考虑按日期范围分区 ;周期性报表直接上日汇总表或物化视图。
老系统的 SQL,写得久、改得多、经手的人换了好几轮。它慢,往往不是哪个人写错了,而是每一代人在上面加了一层:加一个字段、加一个函数、加一个 EXISTS。单看每一层都合理,叠起来就成了四分钟。
所以优化的第一步不是改,是先把它数清楚------尤其是那些执行计划里不显示的部分。
你手上的老系统里,有没有一条 SQL 的 SELECT 列表比它的 WHERE 还长?