
最近排查一条慢SQL时我把连接条件从JOIN的上层手动改写到基表过滤层结果执行时间不降反升。这条SQL涉及两个大表关联字段都是主外键直觉上把连接条件下推到更早的执行阶段应该减少中间结果但实际却让优化器选了一个更差的连接顺序。这个反直觉的结果逼着我把“基于代价的连接条件下推”这一整套逻辑从头翻了一遍。今天总算得出了还算完整的答案连接条件下推从来不是一条“越早越好”的铁律而是一次由代价模型驱动的决策。这篇文章把我验证过的方法、EXPLAIN观察到的现象、以及踩过的坑都整理出来希望能给同样被这类问题卡住的同学一条可复用的排查路径。1. 连接条件下推的反直觉现场为什么提前过滤反而更慢1.1 我遇到的那条慢SQL与第一反应先交代一下背景。订单表 orders 有约500万行用户表 customer 有约10万行查询目标是找出最近30天里VIP用户下的订单。SQL大致长这样SELECT o.order_id, o.amount, c.name FROM orders o JOIN customer c ON o.cust_key c.cust_key WHERE c.is_vip 1 AND o.order_date 2025-01-01;第一次优化时我的直觉是把c.is_vip 1下推到 customer 表扫描把o.order_date 2025-01-01下推到 orders 表扫描让两边在连接之前都尽可能瘦身。这几乎是教科书里默认的优化手段我当时还专门改写了SQL把过滤条件塞到子查询里SELECT o.order_id, o.amount, c.name FROM (SELECT * FROM orders WHERE order_date 2025-01-01) o JOIN (SELECT * FROM customer WHERE is_vip 1) c ON o.cust_key c.cust_key;结果出乎意料这条改写后的SQL执行了4.8秒而原来的SQL只用了1.2秒。查看执行计划后发现优化器把子查询里的条件下推到了扫描阶段而这两个子查询过滤后的估算行数改变导致优化器重新选择了连接顺序——它把过滤后只有几千行的 customer 子查询作为驱动表然后对 orders 做索引探测。听起来没毛病但 orders 这个表在cust_key上的索引区分度并不高而且order_date过滤后的真实行数只有约30万行统计信息却显示还有300万行。驱动表选错之后原本合理的Hash Join退化成了一大堆索引回表操作。1.2 下推改变的不是过滤行数而是连接顺序这件事让我意识到一个容易被忽略的点连接条件下推的收益并不只取决于“过滤了多少行”更关键的是它如何改变优化器对连接顺序的判断。传统上我们讨论谓词下推默认下推是局部变换把过滤操作往叶子节点移动中间结果变小整体一定更好。但连接条件下推涉及两个表一边的行数变化会直接影响驱动表的选择而驱动表的选择又决定了连接方式是Hash Join还是Nested Loop。理论上当连接键在某一侧有很好的索引时Nested Loop可能比Hash Join更快但当索引出现大量重复值或统计信息严重偏差时Nested Loop会让驱动表的每一行都触发一次昂贵的索引探测。所以下推不能只看“这个条件下推之后能过滤多少行”还要看“过滤之后优化器下一步会怎么选”。这就像给一个导航软件改了起点坐标它可能给你重新规划一条完全不同的路线新路线不一定更快。1.3 由此引出的核心问题下推应该由谁来决定正因为下推会牵扯到全局连接策略所以真正负责做决定的不能是“人肉规则”而应该是优化器基于代价的完整搜索。所谓“基于代价的连接条件下推”就是优化器在枚举执行计划时把同一个连接谓词放在不同执行层次所得到的不同计划分别计算代价然后选择总代价最低的那个。这个决策过程涉及三件事基数估计是否准确、每种连接方法的代价公式是否可靠、以及下推带来的CPU/IO/网络变化能否被模型正确反映。我花了很长时间才把这个链条理清楚先有代价模型才有下推决策如果代价模型里某个环节失真再合理的下推原则也会失效。这也是为什么同一个查询在数据分布变化前后同一个优化器会给出截然不同的下推结果。理解这一点才算真正进入“基于代价”的门。2. 代价模型如何看待一次下推选择率、算子和连接方法的三方博弈2.1 代价函数里每一分都花在哪一个执行计划的代价通常由三部分组成扫描基表的IO与CPU代价、连接操作的CPU与内存代价、以及最终结果输出的代价。在分布式数据库里还要额外加上数据在网络间传输的代价。不同数据库的代价单位不同但本质都是对“行数 × 每行代价”的估算。对于一条连接条件下推与否直接影响的是参与连接的输入行数。假设原计划是先做Hash Join再对结果过滤那么连接操作的输入是两张完整的基表如果把连接条件中某一侧的单表谓词下推到扫描节点那么这一侧的输入行数会从10万变成5000。基于代价的优化器会重新计算扫描代价、Hash表构建代价和探测代价。如果下推后构建Hash表变小Hash Join本身确实会更快。问题在于这种收益不是孤立的它会影响连接顺序的枚举结果。2.2 连接条件下推如何影响基数估计基数估计Cardinality Estimation是整个代价模型的命门。连接条件下推后优化器需要估算“过滤后的表”有多少行以及它与另一张表连接后会产生多少行。绝大多数优化器依赖统计信息里的直方图、频率、相关性等数据通过选择率公式来估算。这里最容易出问题的是关联列的选择率。比如orders.cust_key与customer.cust_key存在比较强的一对多关系但如果两张表的柱状图都是独立统计的优化器只能假设连接键均匀分布。在均匀分布假设下从customer侧过滤出5000行与orders连接后估算约2500万行而实际因为连接键倾斜可能只有200万行。估算偏差导致优化器选择的下推方案偏离真实最优解就是我在第一节遇到的场景。所以“基于代价”不是精确计算而是在统计模型上做启发式搜索。统计信息越新鲜、数据分布越接近模型假设下推决策越可靠反之只要基数估计偏差一个数量级下推就可能从优化变成劣化。2.3 三种连接方法对下推的敏感度对比不同的连接方法对输入行数的敏感度差异很大。我用一个简化代价公式来说明Nested Loop Join代价大致是外层行数 × 内层每次查找代价。下推只要让外层行数减少收益非常明显——从10万降为5000代价直接降到原来的1/20。Hash Join代价大致是Build表扫描代价 Probe表扫描代价 内存哈希表操作代价。下推减少Build表大小能降低哈希表构建和内存占用但Probe表如果没被过滤整体收益相对温和。Sort Merge Join代价还包含排序开销下推带来的行数减少能正比降低排序代价但如果数据本身有序下推收益会变小。因此下推决策不能脱离连接方法。如果优化器预计采用Nested Loop那么下推一个高选择率的条件很值得如果原本是Hash Join只有当过滤后行数能显著降低Build侧成本时下推才有意义。2.4 分布式场景中网络代价把天平压向哪一边上述讨论是单机数据库的视角。在分布式数据库比如TiDB、Greenplum、PolarDB等里连接条件下推多了一层意义减少跨节点数据交换。两个表的数据可能分布在不同存储节点上连接之前通常需要Shuffle也就是把相同连接键的数据发送到同一个计算节点。如果把连接条件中能过滤数据的谓词下推到存储节点让每个节点先本地过滤再参与Shuffle传输的数据量会大幅下降。网络传输代价通常比本地CPU代价贵得多。所以分布式优化器会更激进地下推连接条件甚至会把一些在单机数据库看来收益不大的条件下推。但反过来如果下推的表达式本身非常复杂比如在存储节点执行大量的字符串解析或用户自定义函数存储节点的CPU会被拖累而网络节省未必能抵消这部分开销。这也是为什么很多分布式数据库对下推的表达式类型做了限制不是所有函数都允许下推。3. 在真实执行计划里读“下推决策”EXPLAIN观察与复现实验3.1 如何在执行计划里定位下推位置要判断一个连接条件是否被下推最关键的是看执行计划中过滤条件出现在哪个节点。以PostgreSQL为例EXPLAIN (ANALYZE, BUFFERS)输出中Filter出现在Seq Scan或Index Scan节点表示条件在扫描阶段执行如果条件出现在Hash Join的Hash Cond或Nested Loop的Join Filter表示条件在连接阶段执行。举个例子EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders o JOIN customer c ON o.cust_key c.cust_key WHERE c.is_vip 1 AND o.amount 1000;如果执行计划里customer表扫描节点上显示Filter: (is_vip 1)说明这个单表条件下推成功了如果Hash Join节点上显示Hash Cond: (o.cust_key c.cust_key) AND (c.is_vip 1)说明优化器认为连接后过滤更划算没有下推。对于MySQL可以看EXPLAIN FORMATJSON里的attached_condition和used_key_parts对于TiDB重点看算子属于root还是cop任务出现在cop里的Filter就是真实下推到了存储节点。3.2 复现实验从Hash Join到Nested Loop的摇摆为了验证“基于代价”如何改变下推结果我做了一个可控实验。建两张表t1有10万行t2有200行t2.id是主键索引。查询是简单的等值连接SELECT * FROM t1 JOIN t2 ON t1.id t2.id;统计信息更新后默认执行计划选择了Hash Joint2作为Build表t1作为Probe表连接条件只作为Hash Cond。此时连接条件下推没有变成索引扫描因为Hash Join足够快。接着我故意制造统计偏差在t2中插入大量重复id但不更新统计信息。优化器仍以为t2只有200行实际上t2有10万行。这时基于代价的搜索认为既然t2很小驱动表用t1、内层用t2的Nested Loop可能更优于是把连接条件当作内层表索引条件相当于把连接条件下推到Index Scan的Index Cond。实际执行时由于t2变大了10万倍每一行t1都要去扫描一个巨大的“小表”速度惨不忍睹。这个实验精确还原了我开头的教训下推决策完全依赖基数估计统计信息一旦失真连接条件下推就会被“代价模型”带进沟里。3.3 外连接与复杂表达式下推的语义红线就算代价模型算得再准有些连接条件下推也不能做这是语义约束。比如LEFT JOIN的ON条件它只过滤右表但不能把该条件下推到右表扫描阶段就简单滤掉满足条件的右表行——那样会把左表对应行也过滤掉或者改变外连接保留NULL的行为。优化器必须把这种条件作为Join Filter保留在连接节点不能随便移动。同样的涉及两表列的表达式比如o.amount c.credit_limit * 0.5无法下推到任何一个单表扫描节点因为需要另一张表的列值才能判断。这种条件只能在连接时计算。所以从执行计划里看到某个表达式卡在Join Filter层不一定是优化器“不想下推”而是它根本无法下推。4. 哪些场景需要手动干预下推统计信息、表达式与SQL改写4.1 统计信息过期是下推误判的头号来源遇到连接条件下推导致性能劣化时第一个要查的不是SQL而是统计信息新鲜度。我见过太多线上事故本质上都是表数据量变化了几十倍统计信息却还停留在几个月前。数据库内部有自动ANALYZE但很多OLTP表的高频更新场景下自动采样频率跟不上数据变化。解决方案很简单对参与连接的关键表执行ANALYZE table_name然后重新查看执行计划。我的经验是先做这个动作再看是否需要深入优化SQL。很多时候“下推决策错误”的根因是基数估计偏差而不是下推原则本身有问题。注意如果统计信息正确但下推后仍然变慢那才需要怀疑优化器的代价模型在当前数据分布下是否适用。4.2 下推中的隐藏成本列裁剪与表达式计算还有一个容易被忽略的成本连接条件下推后扫描节点需要读取更多列来执行过滤。比如连接条件里涉及customer.email LIKE %xxx.com下推后存储引擎必须把email列从数据页或列存文件中读出来如果email列很大扫描的IO开销会明显增加。而如果不推虽然过滤在连接后执行但可能只需要读取连接键列IO反而更少。所以“过滤率高”与“值得下推”并不是一回事。过滤率很高但过滤所需的列很宽下推的净收益可能是负数。尤其是列存数据库比如ClickHouse、Parquet扫描场景过滤条件下推通常利大于弊但仍需注意计算复杂度高的表达式而OLTP的行存数据库宽列扫描成本更敏感。4.3 用Hint与等价改写拿回控制权当优化器的下推决策不符合预期时可以手动干预。不同数据库方法不一样但思路相通强制连接顺序、关闭某种连接方法、或者用优化器提示把谓词固定在某个位置。以PostgreSQL为例可以临时关闭Hash Join看Nested Loop计划或者使用SET enable_hashjoin off;后再执行EXPLAIN。如果发现关闭某种方法后反而更好再考虑是否需要通过统计信息或SQL改写让优化器回到正轨。MySQL 8.0可以使用optimizer_switchTiDB则支持STRAIGHT_JOIN和各类hint。改写SQL是最通用的办法。把连接条件改写成子查询过滤通常会迫使优化器先执行子查询里的过滤再参与连接。但正如我开头踩的坑这种改写可能改变连接顺序因此必须通过EXPLAIN ANALYZE验证实际效果。不要相信“改写后的SQL一定更快”这种直觉。5. 我最终得出的答案基于代价的连接条件下推判断框架5.1 一句话结论与三层判断折腾了一整周我最终得出的答案可以浓缩成一句话连接条件下推是否值得取决于下推之后整个执行计划的总代价是否变低而不是取决于能否减少局部行数。实际操作中我按三层来判断第一层看语义这个条件能否安全下推。涉及两表列、外连接ON条件、非确定性函数先排除。第二层看基数下推后的估算行数变化是否可信。如果统计信息不新鲜先更新统计信息或者用EXPLAIN里的实际行数与估算行数对比。第三层看连接策略下推行为是否会改变连接顺序或连接方法。如果会必须把新计划的代价与旧计划对比重点看驱动表选择是否合理、索引访问路径是否退化。5.2 我总结的验证清单我会在每次评估下推方案时走一遍下面的清单这里直接分享给你[ ] 用EXPLAIN ANALYZE查看计划记录下推条件所在节点。[ ] 对比实际行数和估算行数偏差超过10倍立即标记。[ ] 确认驱动表的选择是否与真实数据量匹配。[ ] 检查下推后是否引入了宽列扫描或高成本表达式。[ ] 在测试环境执行原SQL与手动干预后的SQL各三次取稳定耗时对比。[ ] 如果分布式环境同时观察网络传输量统计而不是只看本地执行时间。这套清单帮我避免了很多“拍脑袋下推”的优化。对你来说最关键的可能是养成“先看计划、再问代价”的习惯。连接条件下推这个技术点说到底不是一条孤立规则而是优化器做全局代价优化的一个缩影。理解它就能理解为什么数据库执行计划经常“不讲直觉”为什么统计信息如此重要以及为什么优化SQL最终要比拼的是对代价模型的理解程度。