前一阵子处理了一单现场慢SQL排查,问题出在达梦数据库的一个子查询上。单看SQL并不复杂,两层嵌套、几个索引都在,可就是跑起来五六秒起步,数据量一上来直接拖垮业务接口。后来把子查询单独拎出来分析、改写、再对比执行计划,才真正意识到一个事实:在达梦数据库里,子查询优化往往不是"语法对不对"的问题,而是"优化器到底把它处理成了什么形状"的问题。
这篇文章是达梦数据库SQL优化系列的第四篇,专门聊子查询优化。适合两类人看:一类是刚接触达梦、习惯用MySQL或Oracle思维写SQL的开发同学;另一类是已经在生产环境里被慢SQL折磨过、想系统梳理排查方法的运维或DBA。文章会围绕子查询在DM8中的典型执行形态、IN/EXISTS/JOIN的选型对比、标量子查询的隐藏代价、关联子查询的改写边界,以及一套可复制的排查链路展开,最后用一个500万行订单表的完整案例收尾。
1. 为什么子查询在达梦里经常变成"逐行执行"
1.1 优化器并不总是做子查询展开
很多人写子查询的时候,默认优化器会把子查询"展开"成JOIN来跑,这个理解在大多数场景下成立,但并不是绝对的。
所谓子查询展开,指的是优化器把WHERE a.id IN (SELECT b.id FROM b)这种结构,改写成半连接或者普通连接计划。展开之后,优化器可以把两个表的访问路径、连接顺序放在一起全局评估,有机会选择HASH JOIN、排序合并连接等更高效的算子。
但达梦优化器的处理逻辑里,有一类子查询无法或不会展开,最典型的就是关联子查询和带非等值过滤条件的标量子查询。当子查询需要引用外层表的列时,它必须对外层每一行单独计算,这时候执行计划里就会出现类似FILTER或SLCT的算子,配合嵌套循环连接,也就是业内常说的"逐行执行"。
打个好懂的比方:如果把两张表的连接比作"把两份名单合并核对",那么不展开的子查询就相当于"每核对一个外层人员,就把整个内层名单重新翻一遍"。单看一遍可能只要几毫秒,可外层如果我们有十万行,就是十万次重复扫描,累计时间非常恐怖。
1.2 执行计划里出现这些算子要警惕
在DM8里通过EXPLAIN查看执行计划时,我通常会重点关注两类信号:
- 计划树中出现
NEST LOOP且内层有SLCT或FILTER,并且SLCT有对外的列引用; - 子查询对应的子树上方带有"逐行计算"特征,扫描次数(记录在计划注释里)明显高于预期。
另外还有一种形态是优化器把子查询结果物化。物化相当于先把子查询结果算出来,塞进一个临时结构,再和外层做连接。这个策略本身没问题,但如果子查询本身过滤性很差、结果集很大,物化本身就会成为新的瓶颈。我在达梦上见过不少SQL,慢不是慢在连接,而是慢在物化阶段,尤其当子查询内部还要做聚合时。
1.3 统计信息会直接影响子查询改写决策
同样一条SQL,在开发库跑得飞快,到生产库慢成蜗牛,很多人第一反应是"数据库版本不一样",其实大多数时候是统计信息不一样。
达梦优化器判断要不要展开子查询、展开后选什么连接顺序,严重依赖表的行数、唯一值数量、过滤条件选择率。如果表没有收集统计信息,或者数据发生过大量增删改而统计信息长时间不更新,优化器就会拿默认值去猜,猜错了自然容易选出一个灾难级计划。
所以排查子查询慢SQL时,我的第一个动作往往不是改写SQL,而是先查统计信息。达梦里可以通过DBMS_STATS包收集,也可以用管理工具手动更新。很多看起来需要改写的问题,统计信息一刷新,计划立刻正常。
2. IN、EXISTS和JOIN到底怎么选:一次实测对比
2.1 测试环境和SQL设计
先声明一下,下面的对比是在DM8测试库上跑的,两张表:
- 用户表T_USER,约10万行;
- 订单表T_ORDER,约500万行,其中
USER_ID上有索引。
三个写法分别是:
-- 写法1:IN SELECT * FROM T_ORDER o WHERE o.USER_ID IN (SELECT u.ID FROM T_USER u WHERE u.LEVEL = 1); -- 写法2:EXISTS SELECT * FROM T_ORDER o WHERE EXISTS (SELECT 1 FROM T_USER u WHERE u.ID = o.USER_ID AND u.LEVEL = 1); -- 写法3:JOIN SELECT o.* FROM T_ORDER o JOIN (SELECT u.ID FROM T_USER u WHERE u.LEVEL = 1) t ON t.ID = o.USER_ID;从结果集语义上讲,IN和EXISTS返回的都是订单表原始行,不会重复;而JOIN写法如果T_USER里同一个ID出现多条,结果就会膨胀。因此在真实场景里,JOIN改写需要在子查询里做去重,或者接收者明确知道不会重复。
2.2 执行计划差异:半连接与普通连接
实测下来,三种写法在达梦优化器手里呈现出两种典型路线:
IN和EXISTS在子查询可以展开的情况下,优化器都会倾向使用半连接(SEMI JOIN)语义。计划里会看到哈希半连接或排序半连接,底层只需要判断"是否存在匹配行",一旦命中就停止扫描内层,理论上比普通JOIN少做很多无用功。这两种写法在达梦里拿到的基本是同一类计划,性能差异很小。
JOIN改写后走的是普通连接,计划形态通常是HASH JOIN或NEST LOOP JOIN。如果子查询没有去重(我这里故意没去重),优化器产生的结果行数可能偏大,甚至影响后续算子的评估。很多线上事故就是这么来的——为了"优化"把IN改成JOIN,结果多表连接导致结果集翻倍,业务上数据错了,性能也没救回来。
注意:IN和EXISTS并不是在所有情况下等价。如果子查询结果集中出现NULL,
IN的结果可能和EXISTS不同,这个坑后面单独展开。
2.3 NULL语义与去重的隐藏问题
先看NULL对比较逻辑的影响。
WHERE o.USER_ID IN (SELECT u.ID FROM ...)这条SQL,在关系理论上等价于"o.USER_ID = u.ID这个表达式对至少一行返回TRUE";但如果u.ID里有NULL,NULL参与等值比较时结果是UNKNOWN,它不会让条件变成TRUE,却会让最终结果集变少。尤其当外层存在NULL值时,表现会更反直觉:NULL IN (子查询)既不返回TRUE也不返回FALSE,最后这个行就被过滤掉了。
EXISTS不一样,它只关心子查询是否返回了行,完全不关心那一行里具体值是不是NULL。所以严格来说,把IN改写成EXISTS,必须在确认子查询列不存在NULL,或者业务上根本不关心NULL边界时才安全。
JOIN就没有NULL语义问题吗?也有。内连接会把NULL的匹配行直接丢弃,用LEFT JOIN则可能把NULL行带出来,而且如果子查询结果有重复,JOIN结果行数会成倍增加。三种写法里,JOIN是"最需要人工约束"的一种。
我把选型经验整理成了下面这个表,后续处理子查询时可以直接对照:
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 子查询返回行数少、外层大表 | EXISTS/IN | 优化器可走半连接,用小表驱动 |
| 子查询需要去重后关联 | JOIN + 子查询内DISTINCT或GROUP BY | 避免结果膨胀 |
| 子查询对同一外表有多个关联条件 | JOIN | 比多个EXISTS更清晰易优化 |
| 子查询列存在NULL | 谨慎用IN | 可改EXISTS,但需确认业务语义 |
| 目标是取大表分页后的前N条 | JOIN或预分组 | 减少外层逐行扫描 |
2.4 选型结论
从纯达梦优化器的角度,IN和EXISTS并没有绝对的谁快谁慢,关键看子查询能不能被展开成半连接。如果优化器最终选择把子查询物化后再哈希连接,两种写法也都能通吃。
最怕的是子查询里有非等值关联条件、OR条件、或者自定义函数,这些会让优化器放弃半连接,退回逐行执行。遇到这类场景,不要纠结IN还是EXISTS,优先考虑手动拆成临时表或公共表表达式,把复杂条件提前算好。
3. 标量子查询:耗时不长但量大的隐形杀手
3.1 一个典型使用场景
标量子查询就是出现在SELECT列表里的子查询,形如:
SELECT o.ORDER_NO, o.AMOUNT, (SELECT MAX(p.PRICE) FROM T_PRODUCT_PRICE p WHERE p.PRODUCT_ID = o.PRODUCT_ID) AS LATEST_PRICE FROM T_ORDER o;这类SQL在报表类业务里极其常见,逻辑上确实好写,但它有一个天然隐患:子查询要跟外层每一行做一次关联计算。
假定订单表T_ORDER有50万行,子查询每次需要读取一次价格表。如果价格表上PRODUCT_ID的索引设计得不好,每次子查询都会把整张价格表扫一遍——那就是50万次全表扫描,数据量一大,接口直接超时。
3.2 为什么不能完全依赖优化器
有人会问:标量子查询也是子查询,优化器为什么不把它展开成提前聚合一次?
答案是:优化器可以展开一部分,但展开需要满足严格的等价条件。如果子查询里引用的外层列和内部表的连接关系是一对多,优化器必须保证展开后不会改变最终结果行的数量。一旦它判断不了,最保守的选择就是逐行执行——因为逐行执行永远正确,只是慢。
另外还有一种情况更隐蔽:子查询本身有排序,比如取"每个产品最新一条价格",内部写成ROWNUM=1或者ORDER BY配合FETCH FIRST。这类带排序的标量子查询,展开难度更大,优化器几乎一定会选择逐行执行。
3.3 两种改写思路
针对上面这段示例SQL,我常用的改写方案有两种,实测都能把执行时间从秒级压到毫秒级。
思路一:预聚合LEFT JOIN
先把子查询里的聚合提前算出来,形成一张临时结果集,再跟大表做LEFT JOIN:
WITH PRICE_AGG AS ( SELECT PRODUCT_ID, MAX(PRICE) AS LATEST_PRICE FROM T_PRODUCT_PRICE GROUP BY PRODUCT_ID ) SELECT o.ORDER_NO, o.AMOUNT, p.LATEST_PRICE FROM T_ORDER o LEFT JOIN PRICE_AGG p ON p.PRODUCT_ID = o.PRODUCT_ID;这样做的好处是,聚合只做一次,而不是跟着外层行数重复做。代价是公共表表达式可能会物化,如果PRICE_AGG结果集巨大,需要同步检视物化内存和临时表空间。好在大多数"产品价格"这类维度表聚合后行数可控,收益远远大于代价。
思路二:窗口函数
如果子查询要的是"每个分组里按条件排序后的某一条",窗口函数通常比预聚合更优雅:
SELECT ORDER_NO, AMOUNT, LATEST_PRICE FROM ( SELECT o.ORDER_NO, o.AMOUNT, p.PRICE AS LATEST_PRICE, ROW_NUMBER() OVER(PARTITION BY o.PRODUCT_ID ORDER BY p.SNAPSHOT_TIME DESC) AS RN FROM T_ORDER o LEFT JOIN T_PRODUCT_PRICE p ON p.PRODUCT_ID = o.PRODUCT_ID ) WHERE RN = 1;达梦8的窗口函数已经比较完善,ROW_NUMBER、RANK、SUM OVER这些都可以直接用。窗口函数让优化器一次性读取所有参与计算的行,内部做一次排序或分区计算,避免了子查询反复执行。
实操提醒:无论用哪种改写,改写后用
EXPLAIN比较一下"扫描次数"和"排序次数"这两个指标。如果原先计划里子查询子树出现在一个循环体内,改写后子查询子树消失、变成一次聚合或窗口排序,基本就说明优化对了。
4. 关联子查询改写JOIN的边界:能改、不能改、改错
4.1 改写前的语义契约
关联子查询指子查询的WHERE条件里引用了外层表的列,比如:
SELECT * FROM T_ORDER o WHERE o.AMOUNT > ( SELECT AVG(p.AMOUNT) FROM T_ORDER p WHERE p.USER_ID = o.USER_ID );这条SQL的意思是"找出订单金额高于自己所属用户平均订单金额的那些订单"。很多优化建议会告诉你:把它改成JOIN分组后的结果,性能更好。这句话方向没问题,但有一个前提,改写前后结果集必须严格一致。
改写成JOIN之前,我建议先回答三个问题:
- 子查询是否保证每一行外层数据至多匹配一行内层数据?如果子查询返回多行,原SQL本身在达梦里会报"单行子查询返回多行"的错误,改写时必须用聚合保证唯一。
- 如果外层是LEFT JOIN,子查询结果为NULL时,原SQL的比较结果是什么?NULL比较会让条件不成立,外层行会被过滤掉;改写时要用
COALESCE或WHERE条件显式处理。 - 子查询内部的聚合粒度与外层关联键是否完全一致?差一个字段都会导致结果漂移。
4.2 复杂聚合场景的改写示范
上面那个"订单金额高于用户平均值"的SQL,标准改写是:
SELECT o.* FROM T_ORDER o JOIN ( SELECT USER_ID, AVG(AMOUNT) AS AVG_AMOUNT FROM T_ORDER GROUP BY USER_ID ) a ON a.USER_ID = o.USER_ID WHERE o.AMOUNT > a.AVG_AMOUNT;注意这里我使用内连接,而不是LEFT JOIN。原因是原SQL里如果用户没有任何订单,那么AVG子查询返回NULL,o.AMOUNT > NULL在SQL里是UNKNOWN,这个订单会被过滤掉,所以内连接结果与原SQL一致。如果改用LEFT JOIN,那些没有子查询匹配行、同时又满足AMOUNT > COALESCE(NULL, 0)条件的数据会被错误保留,结果就错了。
这个例子很能说明问题:改写的核心难点不是语法,而是NULL和聚合粒度的语义对齐。
4.3 什么时候硬改会变慢
虽然JOIN改写大多数时候能提速,但我必须诚实地说:有些场景硬改JOIN反而更慢。
第一种是子查询过滤性极强,外层只有极少数行能命中。比如"找出最近10分钟内下单且用户是VIP的前10个订单",子查询会先精确筛选出一个很小的集合。此时优化器如果选择哈希连接,代价是至少扫描一遍完整的外层大表;而原来的关联子查询配合USER_ID索引,可能只驱动少量行就完成计算。这种情况下,保留关联子查询,把精力放在内层索引上,效果反而更好。
第二种是子查询内部有很重的聚合,并且聚合结果行数逼近外层行数。预聚合的成本可能比外层反复走索引还高,尤其当外层本身很小、内层表巨大时。
所以我的原则是:改写前先估算两侧结果集规模,再做决定。关联子查询慢,不代表JOIN就一定快,关键是让优化器拿到准确的规模信息。估算方法很简单,可以用EXPLAIN看计划里估算行数,也可以手动跑一下两个简单COUNT查询。
5. 在DM8里快速定位子查询慢SQL的排查链路
5.1 从全局监控到单条SQL
真正到了生产环境,你不会一开始就盯着一两条SQL,而是要先把"哪些SQL最消耗资源"筛出来。达梦8的动态性能视图可以查看SQL执行历史,结合执行次数、耗时、物理读等字段排序,能快速锁定Top SQL。不同小版本视图命名可能略有差异,可以通过达梦官方文档确认当前实例可用的视图名。
筛出嫌疑SQL后,我习惯先看三个维度:
- 执行次数 × 单次耗时,识别累计耗时最高的SQL;
- 计划是否频繁变更,同一SQL如果有时快有时慢,多半是统计信息波动导致计划不稳定;
- 是否与定时任务或批量导入时段重合,如果慢SQL集中在导入时段,问题可能不是子查询本体,而是表锁竞争。
5.2 EXPLAIN的阅读顺序与关注算子
拿到SQL后,在达梦管理工具里选中SQL,按执行计划按钮(或使用EXPLAIN语句),然后按"从右往左、从下往上"的顺序读计划树。
我的关注点按优先级排列:
- 有没有
NEST LOOP且内层带有SLCT/FILTER并引用外层列; - 有没有
MATERIALIZE或临时表算子,如果有,它物化的是哪一层; - 有没有
HASH JOIN但左右两侧行数估算明显失真(比如估算几千行,实际跑几百万行); - 有没有
SORT算子在子查询内部出现,导致每次循环都要排序。
如果第1条命中,基本可以断定子查询在逐行执行。此时进一步看外层行的规模:外层驱动行数是多少,内层每次扫描走的是索引还是全表。对应优化手段分别是"改写为半连接/JOIN"和"补索引"。
5.3 两个必要的检查点
排查子查询慢SQL,除了计划,我每次都会顺手检查两件事,80%的情况下都能发现额外问题:
第一,相关列的索引是否真的能被使用。子查询里的关联列必须有合适的索引,且索引列顺序要跟过滤条件匹配。比如WHERE USER_ID = ? AND CREATE_TIME > ?,建(USER_ID, CREATE_TIME)这个顺序的联合索引才有用,反过来的(CREATE_TIME, USER_ID)在等值+范围混合条件下效果差很多。
第二,两表统计信息是否陈旧。重新收集统计信息后再看计划,有时候什么都不用改,问题就消失。实操中我会强制刷新相关表的统计信息,然后重新EXPLAIN对比,确认计划原地改善还是保持不变。
注意:达梦的统计信息和部分系统视图在权限控制上比普通开发库更严格,使用前确认当前账号有对应的查询或执行权限,否则排查会卡在第一步。
6. 一个500万行订单表的子查询优化全程复盘
6.1 原始SQL与症状
最后用一个我印象很深的真实案例来收尾。业务方反馈一个订单查询页面越来越慢,简单接口从原本的几百毫秒退化到5秒以上,数据库CPU经常飙高。
定位到的原始SQL大致是这样的:
SELECT * FROM T_ORDER o WHERE o.USER_ID IN ( SELECT u.ID FROM T_USER u WHERE u.LEVEL = 1 ) AND o.CREATE_TIME >= DATE '2024-01-01' ORDER BY o.CREATE_TIME DESC LIMIT 20;当时从执行计划里看到的形态是:先对T_ORDER做全表范围扫描,然后对每一行执行子查询匹配,子查询内部虽然走T_USER主键,但外层驱动行数高达500万,累计时间非常可观。
6.2 逐层拆解
第一个疑点:为什么优化器选择了大表驱动小表?这里的关键在于o.CREATE_TIME >= DATE '2024-01-01'这个条件。当时这个条件下实际约350万行,选择性并不高,优化器会认为订单表过滤后仍有大量数据,加上排序需求,它倾向于以大表为驱动。
第二个疑点:IN子查询返回的VIP用户集合有多少行?一查,LEVEL=1的用户只有几千行。明明子查询结果很小,优化器却没有把"小集合驱动大集合"这个思路贯彻到底。
6.3 改写与索引调整
我做了两件事,效果立竿见影。
第一件事:改写为EXISTS语义,并改用JOIN预聚合。既然子查询结果集很小,就手动让它成为驱动端:
SELECT o.* FROM T_ORDER o JOIN ( SELECT ID FROM T_USER WHERE LEVEL = 1 ) u ON u.ID = o.USER_ID WHERE o.CREATE_TIME >= DATE '2024-01-01' ORDER BY o.CREATE_TIME DESC LIMIT 20;这里子查询只取ID字段,结果集几千行,优化器可以把它作为哈希连接的左侧输入,先算出一个很小的哈希表,再来扫描订单表上350万行时,每条只做一次哈希探测,性能自然好了。
第二件事:索引调整。原有索引是T_ORDER(CREATE_TIME),只覆盖了时间过滤。新的高频访问模式是"按用户ID + 创建时间"联合过滤和排序,我补了一个联合索引T_ORDER(USER_ID, CREATE_TIME DESC)。这样即便是用户维度的快速入口,也能在索引内完成排序,避免排序算子。
6.4 多轮验证
改完之后,我没有直接上线,而是在测试库做了三步验证:
- 结果集核对:改写前后查询结果完全一致,重点核对了
USER_ID为NULL的订单没有被错误过滤; - 执行计划复检:确认计划已经从"大表驱动 + 逐行子查询"变成"小表哈希驱动 + 索引范围扫描";
- 压测对比:用并发10线程跑20轮,平均执行时间从5.1秒降到0.3秒,数据库CPU占有率明显下降。
6.5 复盘心得
这个案例最值得记的不是SQL语法本身,而是三个判断点:第一,子查询结果集只有几千行,但优化器一开始完全没利用这个事实,说明统计信息或代价模型给出的估算不可靠;第二,IN改JOIN不是盲目的,这里子查询本身按ID聚合天然唯一,不需要额外去重,所以JOIN不会引起结果膨胀;第三,光有索引不够,索引列顺序和排序需求要一起设计好,否则ORDER BY还是会触发一次显式排序。
现在回想,子查询优化在达梦里就是一个"优化器意图识别"的过程。你写的SQL只是给优化器一个初始形状,最终跑多快,取决于优化器能不能把这个形状折叠成一个开销更小的计划。我们做优化的人,能做的就是尽可能给出清晰的表达、准确的统计信息、合理的索引,再在执行计划里确认优化器确实走对了路。
如果你在自己的达梦库上遇到类似现象,建议先按第5章那条排查链路走一遍:先看计划里有没有逐行执行的算子,再查统计信息,最后才动手改写。很多时候,问题并不在子查询本身,而在于优化器基于错误信息做出的错误选择。