一条线上报表任务,主查询套了三层子查询,每层都在全量订单表上做聚合,外层再叠过滤条件和 JOIN。执行计划拉出来一看,最内层把全部历史订单扫了一遍,生成上千万行的中间结果,然后才在外层做连接做裁剪。SQL 从 3 秒变成 3 分钟,问题根源就一句话:连接条件没有下推到子查询内部。
这个场景我处理过太多次。复杂嵌套 SQL 慢,通常不是因为表数据量真的到了可怕的程度,而是嵌套结构让优化器选择了最笨的执行路径。早点把过滤条件往下压,让数据在进入连接和聚合之前就被裁掉,性能往往能提升一个数量级。这篇文章就把“连接条件下推”这个优化思路拆开讲透,适合正在被慢 SQL 折磨的开发、DBA,以及所有写复杂报表查询的同学。
1. 先搞清楚:嵌套 SQL 到底慢在哪
要理解连接条件下推为什么能救场,得先看优化器面对嵌套子查询时是怎么干活的。很多人以为写了子查询,数据库就会自动把外层条件带进去,但实际上完全不是这么回事。
1.1 物化与合并:子查询的两种命运
FROM 子句里的子查询和公共表表达式,优化器通常有两种处理策略:物化和合并。
物化的意思是,优化器先把子查询单独执行一遍,把结果存进内存或者临时表,再把这堆临时数据当成一张普通表参与后续连接。比如FROM (SELECT ... GROUP BY customer_id) t,如果优化器选择物化,它会把整个分组聚合的结果全部算出来,再拿去做 JOIN。
合并则是把子查询的 SQL 直接展开,和外层 SQL 揉成一张大表,过滤条件可以一路压到基表扫描那一步。合并是更理想的状态,但并不是所有子查询都能合并——只要子查询里带了聚合函数、DISTINCT、窗口函数、LIMIT、UNION 这类“破坏性”操作,优化器就没办法安全展开。
用生活里的场景打比方:物化就像先让中央厨房把十万人份的食材全部切好备好,再由各门店去挑自己需要的;合并则是一张菜谱直接下发到门店,需要多少就切多少。前者的浪费是显而易见的,但数据库为了保证结果正确,很多时候不得不选前者。
1.2 中间结果膨胀:嵌套变慢的根源
嵌套 SQL 性能问题的高发区,就在“中间结果膨胀”这四个字上。
举一个最典型的例子,一个订单明细查询:外层只要华东区域、消费超过 1 万的客户汇总,但内层子查询是对全量订单按客户分组求和。如果过滤条件没有下推,数据库会先把全公司几千万张订单全部聚合成几百万个客户的汇总结果,再用这个巨大的中间结果去 join 客户表、区域表,最后才过滤区域和金额。
这个过程的代价是三重叠加的:第一,聚合阶段扫描了根本不需要的数据;第二,内存和临时表要装下远超实际需要的中间结果;第三,JOIN 阶段要把无用的中间数据和维度表逐行匹配。数据量一旦上来,慢是必然的。
除了行数膨胀,列膨胀也是常见问题。很多开发在子查询里成习惯地SELECT *,把几十个字段全带出来,连接时内存和网络开销都会明显放大。这也是嵌套 SQL 性能差的一个隐形推手。
1.3 连接条件下推的介入点
既然慢的根源是“后置裁剪”,那优化方向就很清晰了:把条件和连接时机尽量前移。
连接条件下推的本质,是把外层 WHERE 条件、JOIN 的关联条件、常量过滤条件,尽可能下移到子查询内部、基表扫描层,让数据在进入聚合和 JOIN 之前就已经变小。简单说,先洗菜再切菜,而不是把整个菜市场的菜都买回家再挑。
具体介入点有三个:一是外层 WHERE 的常量条件,比如region = '华东';二是连接条件本身,比如o.customer_id = c.customer_id这类等值关联;三是可以在子查询内提前完成的裁剪逻辑。后面实战部分会逐一说明。
2. 连接条件下推的原理与边界
这一节把原理说透,同时也说清楚为什么数据库优化器经常不主动这么干。
2.1 连接条件下推的本质是“尽早过滤”
SQL 是一条声明式语言,你告诉数据库“我要什么”,但没说“怎么取”。怎么取,完全由优化器决定。优化器的核心目标是在结果正确的前提下,选择总代价最小的执行路径。总代价主要取决于三个因素:扫描的数据量、中间结果的大小、连接的次数。
连接条件下推,本质是在执行计划树上把 Filter 节点往下挪。在关系代数里,有一个等价变换规则叫“选择下推”:σ(condition)(A ⋈ B)等价于(σ(condition)(A)) ⋈ B,前提是 condition 只涉及 A 的列。这条规则看起来简单,真正落地时牵扯到聚合、去重、外连接语义,复杂度并不低。
得到的好处是立竿见影的:扫描阶段就过滤掉 95% 的行,聚合阶段和 JOIN 阶段的数据量都跟着降下来。以前端体验类比,一个网页请求如果能在网关层直接拦截掉无效请求,后面的应用压力自然小得多。连接条件下推干的就是这件事。
2.2 优化器为什么不总下推
既然下推收益这么大,为什么很多 SQL 的执行计划里还是看不到下推?原因主要有四个。
第一个原因是语义风险。如果子查询里有GROUP BY,过滤条件放在分组前还是分组后,结果可能完全不同。聚合前过滤掉一部分数据,聚合结果自然不一样。优化器在没有把握确认条件可下推时,宁可保守地选择物化。
第二个原因是外连接语义。LEFT JOIN 中,ON 条件和 WHERE 条件并不是等价替换关系。ON 里的条件在连接时就要判断,不满足的行会用 NULL 补齐;WHERE 里的条件在连接完成之后才过滤,NULL 补齐的行会被淘汰。优化器绝不会冒着改变结果的风险去自动移动这类条件。
第三个原因是物化边界。用户显式写的 CTE、视图、临时表,在某些数据库里天然就是物化边界。比如 PostgreSQL 早期版本对 CTE 一律物化,MySQL 对带聚合的派生表默认也无法合并,这时候外层条件再简单也下推不进去。
第四个原因是成本模型判断“没必要”。如果过滤条件的选择率很低,比如过滤后仍然要扫 99% 的数据,下推带来的收益可能被其他因素抵消。优化器算完账觉得不划算,自然就不推了。
2.3 主流数据库的下推能力对比
不同数据库对下推的支持差别非常大,了解这些差异能帮你快速判断一条 SQL 的问题是不是“数据库干不了”。
| 数据库 | 派生表/子查询策略 | 下推能力特点 |
|---|---|---|
| MySQL 5.7 | 多数派生表物化 | 不支持条件下推到物化派生表,需要手动改写 |
| MySQL 8.0 | 默认开启 derived_merge,聚合类子查询仍物化 | 8.0.22 起支持 derived condition pushdown,条件可下推到物化派生表 |
| PostgreSQL | 子查询可内联,CTE 默认物化(PG12 起可选内联) | 子查询中过滤下推成熟,CTE 需手动指定 NOT MATERIALIZED |
| SQL Server | 普通 CTE/派生表默认内联 | 下推能力强,但多语句 TVF 会阻断下推 |
| Oracle | 视图合并 + 谓词下推机制成熟 | 可手动使用 PUSH_PRED / NO_PUSH_PRED 提示控制 |
| 达梦等国产数据库 | 兼容 Oracle 语法和语义较多 | 多数支持 PUSH_PRED 提示,但优化器成熟度需实测 |
这张表值得收藏。它说明一个现实:实际工作中不能光指望优化器,遇到关键慢 SQL,手动改写是绕不开的。
2.4 写 SQL 时的下推意识
我的建议是:写嵌套查询时,先把“能不能下推”这个问题在脑子里过一遍,而不是写完再让优化器去猜。
具体习惯是:先看子查询里有没有聚合、去重这类破坏合并的操作;再看外层条件是常量过滤还是关联条件;最后判断这个条件如果下推,会不会改变原有语义。如果答案是“可以且语义等价”,就直接把条件写进子查询里,不要等着优化器去推导。手动下推的另一个好处是可读性更好——别人看 SQL 的时候一眼就知道过滤发生在哪个阶段。
当然,手动改写一定要在注释里说明:“此处过滤条件为人工下推,和业务语义等价”,否则后面维护的人看到 SQL 被改成和原版不一样,很容易困惑甚至改回去。
3. 实战改造:四个嵌套场景的下推写法
理论讲完,上实战。下面四个场景基本覆盖了工作中最常见的嵌套 SQL 性能问题,每一个我都给出了前后的 SQL 对比和改写思路。
3.1 聚合子查询:把外层 WHERE 推回内层
先看最典型的慢 SQL:
SELECT c.customer_name, t.total_amount FROM ( SELECT o.customer_id, SUM(oi.amount) AS total_amount FROM orders o JOIN order_items oi ON o.order_id = oi.order_id GROUP BY o.customer_id ) t JOIN customers c ON t.customer_id = c.customer_id WHERE c.region = '华东' AND t.total_amount > 10000;这条 SQL 的逻辑本身没问题,但执行效率很成问题。内层子查询先对全量订单明细做分组聚合,再把所有客户的汇总结果拿出来和客户表连接,最后才过滤华东和金额。订单明细有几百万行,中间结果就有几十万行客户汇总,纯属浪费。
改写思路是把客户区域过滤下推到子查询内部,并把金额过滤从外层 WHERE 改成内层 HAVING:
SELECT c.customer_name, t.total_amount FROM ( SELECT o.customer_id, SUM(oi.amount) AS total_amount FROM orders o JOIN order_items oi ON o.order_id = oi.order_id JOIN customers c ON o.customer_id = c.customer_id WHERE c.region = '华东' GROUP BY o.customer_id HAVING SUM(oi.amount) > 10000 ) t JOIN customers c ON t.customer_id = c.customer_id;这里有两个关键点。
第一,region = '华东'下推后,聚合操作只针对华东区域的订单,数据量可能只有原来的十分之一甚至更少。第二,外层原本对聚合结果的total_amount > 10000过滤,对应的语义正确写法是内层HAVING SUM(oi.amount) > 10000,这不会改变结果,因为这两个过滤都发生在聚合之后。
可能有读者会问:内层 join 了 customers,外层又 join 一次 customers,会不会多余?其实不会。customers 表按 customer_id 关联时每条客户只对应一行,不会放大行数;外层再 join 一次只是为了取 customer_name。如果嫌两次 join 啰嗦,也可以把c.customer_name直接带进子查询并参与 GROUP BY,只要 customer_id 能唯一确定 customer_name,分组粒度就不会变。
3.2 LEFT JOIN:ON 与 WHERE 的语义陷阱
第二个场景是连接条件下推里最容易踩坑的。问题 SQL 长这样:
SELECT c.customer_id, c.customer_name, o.order_id FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE o.status = 'SHIPPED';这条 SQL 运行没问题,但结果大概率不是写的人想要的。LEFT JOIN 的语义是:右表不满足关联条件时,照样返回左表行,右表列填 NULL。可 WHERE 里对o.status的过滤发生在连接完成之后,一旦 status 为 NULL 的行被剔除,LEFT JOIN 就退化成了一条 INNER JOIN。
换句话说,这里想表达“只看已发货订单”,但实际效果是“把没有订单和没有已发货订单的客户全部过滤掉”,只有那些有已发货订单的客户才会出现在结果里。如果业务上确实只想要有已发货订单的客户,直接写 INNER JOIN 更清晰:
SELECT c.customer_id, c.customer_name, o.order_id FROM customers c INNER JOIN orders o ON o.customer_id = c.customer_id WHERE o.status = 'SHIPPED';但更常见的情况是,业务需要“所有客户,以及他们已发货的订单”,没发过货的客户也要保留。这时候就必须把过滤条件从 WHERE 下推到 ON 子句:
SELECT c.customer_id, c.customer_name, o.order_id FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'SHIPPED';这就是 LEFT JOIN 场景下的“连接条件下推”:把 WHERE 条件挪到 ON 里,语义从“连接后过滤”变成“连接时过滤”。理解这两条 SQL 的区别,能帮你避免很多线上数据对不上的事故。我在实际工作中见到过不少因为这条改写错误导致的报表数据缺失问题。
3.3 CTE 物化场景:把连接条件下推进 CTE
CTE 本身不一定是性能优化利器,有些时候反而是性能陷阱。PHPostgreSQL 12 之前的版本中,CTE 一律物化,这就意味着:外层无论怎么过滤,CTE 里的子查询都要先把全量数据算完。
看这个例子:
WITH recent_orders AS ( SELECT o.order_id, o.customer_id, o.order_date, o.total_amount FROM orders o WHERE o.order_date >= '2024-01-01' ) SELECT r.region_name, COUNT(ro.order_id) AS order_cnt FROM recent_orders ro JOIN customers c ON ro.customer_id = c.customer_id JOIN regions r ON c.region_id = r.region_id WHERE r.region_name = '华南' GROUP BY r.region_name;recent_orders 这个 CTE 如果被物化,那么 2024 年以来所有的订单都会被先算一遍,哪怕最后只需要华南区域。正确的做法是把区域过滤条件下推到 CTE 内部:
WITH recent_orders AS ( SELECT o.order_id, o.customer_id, o.order_date, o.total_amount FROM orders o JOIN customers c ON o.customer_id = c.customer_id JOIN regions r ON c.region_id = r.region_id WHERE o.order_date >= '2024-01-01' AND r.region_name = '华南' ) SELECT r.region_name, COUNT(ro.order_id) AS order_cnt FROM recent_orders ro GROUP BY r.region_name;改写后要注意两个点:一是 CTE 里加的 JOIN 不会放大行数,因为 orders 到 customers、regions 都是多对一的关系,每个订单仍然一行;二是 COUNT(ro.order_id) 的语义没有变化,CTE 里没有做分组聚合,外层统计的是订单行数。
PostgreSQL 用户可以显式控制 CTE 行为:
WITH recent_orders AS NOT MATERIALIZED ( ... ) SELECT ...这样相当于告诉优化器“别物化,直接内联展开”,同样能达到下推效果。MySQL 没有这个语法,但可以通过 EXPLAIN 观察 CTE 是否被物化,必要时手动把条件写进 CTE。
3.4 EXISTS 子查询:改 JOIN 消除逐行执行
第四个场景是慢 SQL 重灾区。相关子查询在部分执行计划里会变成一次次的逐行执行:外层有多少行,内层就执行多少次,性能极具灾难性。
SELECT c.customer_id, c.customer_name FROM customers c WHERE c.vip_level = 'GOLD' AND EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id AND o.status = 'COMPLETED' AND o.total_amount > 5000 );如果 customers 表有几十万行,内层 EXISTS 子查询理论上可能执行几十万次。改写为 JOIN 可以把相关子查询的逐行执行变成一次连接,同时把过滤条件下推到订单表扫描阶段:
SELECT DISTINCT c.customer_id, c.customer_name FROM customers c JOIN orders o ON o.customer_id = c.customer_id WHERE c.vip_level = 'GOLD' AND o.status = 'COMPLETED' AND o.total_amount > 5000;这里有两点需要提醒。第一,JOIN 版本需要加 DISTINCT,因为一个客户可能有多条符合条件的订单,不加上会重复。第二,EXISTS 其实有“找到一条就停”的短路优势,如果订单表小、外层表大,优化器可能把 EXISTS 实现为 Semi Join,性能也不差。真正该改写的是确认执行计划变成了 Nested Loop 且内层走了全表扫描的情况。改写之前先 EXPLAIN,不要无脑替换。
另外一个技巧是:如果只要客户列表,也可以用 IN 加子查询的形式,WHERE customer_id IN (SELECT customer_id FROM orders WHERE ...)。但 IN 子查询在遇到 NULL 值处理上要小心,JOIN 改写通常是更稳妥的选择。
4. 用执行计划验证下推是否生效
SQL 改完不是终点,必须用执行计划验证下推确实生效了。这一节分别讲 MySQL 和 PostgreSQL 的查看方法,SQL Server 和 Oracle 的思路也顺带提一下。
4.1 MySQL:EXPLAIN FORMAT=TREE 看什么
MySQL 8.0 之后,推荐用EXPLAIN FORMAT=TREE或EXPLAIN ANALYZE查看执行计划结构。TREE 格式能直观看到节点嵌套关系,ANALYZE 还会附加实际执行时间和行数信息。
假设改写前的 SQL 在 MySQL 8.0 里跑,执行计划关键部分会是这样(简化示意):
-> Nested loop inner join -> Table scan on t (materialized derived table) -> Filter: (t.total_amount > 10000.00) -> Single-row index lookup on c using PRIMARY重点关注两个地方:一是有没有materialized derived table这个节点,如果有,说明子查询被物化了;二是Filter节点是出现在物化表的外层,还是已经出现在表扫描层。
改写之后,执行计划里的 Filter 应该下沉到 orders、order_items 扫描阶段,物化派生表的中间结果行数会明显变小。用EXPLAIN ANALYZE跑一遍,对比执行时间和 rows 值,效果一目了然。
MySQL 8.0.22 之后还引入了 derived condition pushdown 特性,即使派生表被物化,外层条件下推后也会在物化扫描时先过滤一遍,执行计划里能看到类似attached condition的信息。所以如果你的 MySQL 比较老,遇到下推不生效更得靠手动改写。
4.2 PostgreSQL:EXPLAIN ANALYZE 看什么
PostgreSQL 查看执行计划的标准姿势是:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT ...重点看三类节点。第一,看有没有SubPlan。如果外层查询里出现SubPlan 1说明相关子查询没有被消除,每次外层行都会执行一次。第二,看 CTE 节点下有没有Materialize,有的话说明物化发生,外层过滤没有下推。第三,对比改写前后的rows和Buffers数值。
还是以 3.3 的场景为例,改写前执行计划可能长这样:
CTE recent_orders -> Seq Scan on orders o Filter: (order_date >= '2024-01-01') -> HashAggregate -> Hash Join -> Hash Join -> CTE Scan on recent_orders改写后,CTE 内部的 Scan 和 Join 会把区域过滤带进去:
CTE recent_orders -> Nested Loop -> Seq Scan on orders o Filter: (order_date >= '2024-01-01') -> Index Scan using ... Filter: (region_name = '华南')看到 Filter 出现在更底层的位置,基本就能确认下推生效了。
4.3 SQL Server 与 Oracle 的观察要点
SQL Server 直接用 SSMS 的图形化执行计划,重点看 Filter 操作符出现在 Join 之前还是之后。如果 Filter 位于两个表合并之前,说明条件下推了;如果过滤动作发生在 JOIN 合并后,说明是后置过滤。另外 SQL Server 对普通派生表和 CTE 默认内联,遇到下推不生效要先检查是否用了多语句 TVF 或者临时表。
Oracle 看执行计划的 Predicate Information 部分,注意每个操作符的access和filter位置。Oracle 的谓词下推机制比较成熟,但视图和复杂子查询偶尔也会“拒绝下推”。此时可以用 hint 强制干预:
SELECT /*+ PUSH_PRED(t) */ ... FROM (SELECT ... ) t这个是 Oracle 优化器提示,直接告诉优化器把外层谓词推进子查询 t。注意 hint 的关键词拼写要对,我在生产环境踩过好几次拼错导致 hint 被忽略的坑。达梦等国产数据库大多兼容这套语法,也可以先加上试试。
5. 常见问题与排查技巧实录
优化做多了,就会遇到一些反复出现的坑。这一节把常见的“下推不生效”“结果集对不上”“改完反而更慢”三类问题整理成速查手册。
5.1 优化器死活不下推怎么办
遇到执行计划里 Filter 一直在外层、子查询始终物化的情况,按顺序排查。
第一步看版本。MySQL 5.7 的派生表下推能力很弱,8.0.22 之后才有较大改善;PostgreSQL 的老版本对 CTE 一律物化。升级数据库版本有时候比改 SQL 更直接。
第二步看子查询结构。带聚合、DISTINCT、LIMIT、窗口函数的子查询,绝大多数优化器不敢合并。这种只能手动把过滤条件写进子查询,或者拆成临时表加索引。
第三步用对应数据库的提示词强制干预。Oracle 用 PUSH_PRED,MySQL 可以考虑把派生表改成 JOIN 形式,PG 给 CTE 加 NOT MATERIALIZED。Hint 是最后一招,用之前先确认不同数据库版本的兼容性。
第四步也是我最推荐的:如果子查询复杂度高、下推链路长,直接拆解业务逻辑,把中间结果落地成临时表并建立合适的索引。这样等于帮优化器画好了执行路径,虽然多了一步写入开销,但整体往往比一条超级嵌套 SQL 快很多。
5.2 下推后结果集变化:三个语义坑
下推不是无脑往里搬条件,语义对不上才是最大的坑。下面三个场景我都在实际项目中见过翻车。
第一个是 LEFT JOIN 的 ON 和 WHERE 混淆。前面 3.2 已经说过,把 WHERE 里的右表条件直接挪进 ON 会改变查询语义。反过来也一样:如果原 SQL 是LEFT JOIN ... AND o.status='SHIPPED',有人“执行计划优化”把它改成 WHERE,结果会从保留无订单客户变成只留有订单客户。
第二个是聚合前过滤与聚合后过滤混淆。WHERE amount > 100和HAVING SUM(amount) > 100是两回事。把后者误写成前者,会把原本“分组后总和大于 100”变成“只有明细行金额大于 100 才参与分组”,结果完全不同。
第三个是 NULL 值处理。把region = '华东'下推到子查询内部,等价于把 NULL 区域的客户也提前排除。如果外层原本是 LEFT JOIN 且期望保留 NULL 区域客户,结果就会少一行。下推前先确认过滤列是否可空、是否需要保留空值行。
5.3 下推后反而变慢的意外情况
多数情况下下推是正向优化,但也碰到过改完变慢的。
原因一,过滤条件太弱。比如 state 字段只有两种取值,过滤后还剩 80% 数据。这种情况下推并不能带来明显收益,反而可能因为优化器重新选择了连接顺序,把原本不错的 Hash Join 换成了 Nested Loop。
原因二,下推导致索引失效。如果原 SQL 外层连接可以利用二级索引,下推后内层因为过滤条件组合变化,优化器选了全表扫描。遇到这种情况,检查一下内层涉及的过滤列有没有合适的复合索引。
原因三,下推导致中间结果行数放大。这一点最容易忽略:如果下推时把一个大表 JOIN 进子查询,而这个大表和内层表是多对多关系,行数会成倍放大。比如订单明细 JOIN 了物流表,一个订单可能对应多条物流记录,这时候把聚合放在外层和放在内层,数据量完全不同。解决方案是先聚合再连接,或者用 EXISTS 语义改写。
5.4 生产环境落地建议
最后说几条团队实践层面的建议。
建立慢 SQL 巡检机制,定期抓取执行时间超过阈值的 SQL,重点盯嵌套层级超过两层、带聚合子查询、带 EXISTS 相关子查询的语句。新 SQL 评审环节强制要求提交 EXPLAIN ANALYZE 结果,避免上线后才发现问题。对于人工改写过的下推逻辑,一定要在 SQL 注释里写明改写原因和等价性说明,减少后续维护者的误改。
我在实际工作中还有一个习惯:优化完一条 SQL,会把优化前后的执行计划截图一起存档。下次遇到类似的慢 SQL,直接对比这些计划,排查效率高很多。
6. 一点个人心得
做数据库优化时间长了,最大的体会是:别迷信优化器,也别不信优化器。它是工具,不是救世主。写得清清楚楚的 SQL,优化器才能发挥能力;写得一团乱麻的 SQL,任何优化器都救不了。
6.1 先看执行计划再动手
我处理慢 SQL 的标准流程是:先看执行计划,再改 SQL。有同行一上来就凭经验堆索引、改写法,结果跑了一遍发现根本没对症。执行计划是最直接的事实来源,Filter 出现在哪个节点、物化发生在哪里、有没有相关子查询逐行执行,这些信息 5 分钟就能看完。真正定位到瓶颈所在,动手改写才有意义。
之前处理过一个用了三年都没问题的报表 SQL,某天突然从 8 秒变 8 分钟,排查半天发现是数据量翻倍后,优化器选择了物化派生表,外层条件没下推。如果只看 SQL 文本,完全看不出问题,但执行计划一眼就暴露了真相。
6.2 把经验沉淀成规范
优化经验不能只留在个人脑里,要变成团队规范。我所在的团队就定了几条硬性规则:报表类 SQL 禁止三层以上子查询嵌套;CTE 里的过滤条件必须写全,不允许依赖外层条件回推;LEFT JOIN 的过滤条件一律放在 ON 子句,禁止在 WHERE 里直接过滤右表字段;所有人工下推的写法必须加注释说明语义等价。
这几条规则刚推的时候有人说太死板,但后来跑线上问题变少了。好的 SQL 风格不是限制,而是帮你避开绝大多数性能陷阱。复杂嵌套 SQL 本身没有错,错的是写完之后不验证、不维护。连接条件下推只是优化手段之一,真正值钱的是那句老话:永远搞清楚数据库到底是怎么执行你的 SQL 的。