ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

连接条件下推:让过滤尽早发生,优化慢SQL的执行计划

连接条件下推:让过滤尽早发生,优化慢SQL的执行计划 连接条件下推这四个字我在优化慢SQL的时候不知道念叨了多少遍。很多DBA和开发朋友碰到大表连接查询变慢第一反应是加索引、调参、换硬件但往往忽略了一个在查询计划层面最关键的动作——优化器到底把过滤条件压到了哪一步执行。我在实际排障中见过太多这样的场景一条SQL明明可以3秒出结果因为条件下推没生效硬生生跑了3分钟加了索引也没用。这篇文章想跟你好好聊聊连接条件下推的底层逻辑、实操验证方法以及它在大大小小数据库里是怎么工作的。这东西适合谁看一类是要经常跟慢SQL搏斗的一线DBA和运维工程师另一类是写复杂查询的开发同学——尤其是你的业务里经常出现多张大表JOIN、侧写报表、数据迁移同步这类场景。理解了连接条件下推你就不再需要拿着EXPLAIN结果凭空猜而是能一眼看出计划里哪个环节不合理知道改写SQL时该往哪个方向使劲。1. 连接条件下推的本质从一条慢SQL说起先抛一条典型的慢查询SQL我们在业务系统里实际遇到过类似的SELECT o.order_id, c.customer_name FROM orders o JOIN customers c ON o.customer_id c.customer_id WHERE o.order_date 2024-01-01 AND o.order_date 2024-02-01 AND c.level VIP;这条查询在数据量只有几百万行的时候跑得还不错但到了几千万行、甚至是分库分表之后的场景性能突然就崩了。乍一看需求很直观加个复合索引应该就能解决可实际上问题不在索引而在于优化器把c.level VIP这个过滤条件放在了JOIN之后还是JOIN之前执行。1.1 “先过滤后连接”为什么快假设orders表有2000万行customers表有500万行。如果优化器选择先做连接把两张表全量关联出结果集再基于WHERE条件过滤那么中间结果集的大小可能达到几千万甚至上亿行。这种执行方式在OLTP场景下几乎是灾难。反过来讲如果优化器能在扫描customers表的时候就把level VIP这个条件下推下去只取VIP客户的数据参与JOIN那参与连接的数据量可能一下就降到几十万行。连接的代价降了一到两个数量级查询自然就快了。这就是连接条件下推最原始也最核心的动机让过滤尽可能地靠近数据读取源头把无用的数据提早丢弃。1.2 连接条件下推和谓词下推的区别很多文章把“谓词下推”和“连接条件下推”混着讲实际在优化器内部它们是有明确分工的。谓词下推Predicate Pushdown指把WHERE条件推到表扫描或者存储引擎层面让底层尽早过滤行。连接条件下推Join Predicate Pushdown特指把JOIN条件本身ON子句中的等值条件或范围条件以及由外表条件推导出的关联过滤条件下推到驱动表或内表的扫描路径上。举个例子A JOIN B ON A.id B.id WHERE B.status 1。如果优化器能把B.status 1推到B表扫描阶段而不是连接完成后才过滤这是谓词下推。但如果它更进一步发现A表里存在A.type 2这样与B无关的过滤条件是否可以提前压到A表扫描阶段这就涉及连接顺序选择和条件下推的联动优化。我在MySQL、PostgreSQL和ClickHouse里都验证过类似场景这三类数据库的处理策略有明显差异但总原则一致尽早过滤永远比事后过滤划算。2. 下推的层级算子、存储引擎与跨节点连接条件下推不是某一层数据库独有的魔法它在不同架构的数据库里有不同的落点。搞清楚这些落点你才能判断自己的数据库到底支不支持以及支持到什么程度。2.1 算子层的下推方式在传统MPP数据库和现代分布式OLAP数据库中算子层的下推是最常见的形式。优化器生成物理执行计划时会把Filter算子和Join算子做重排。拿PostgreSQL举例子它的执行计划里经常能看到Nested Loop - Seq Scan on a - Index Scan using b_idx on b Index Cond: (b.a_id a.id) Filter: (b.status 1)注意这里的Filter出现在了Index Scan节点内部说明b.status 1被压缩到了扫描阶段。假如这个Filter出现在Nested Loop的外面那意味着所有匹配的行会被先连接完再过滤效率完全不是一个级别。2.2 存储引擎层面的下推真正的“下推”到了存储引擎层就是另一番天地了。在这类数据库里过滤条件不再只是执行计划中的算子而是直接变成了存储引擎扫描时使用的参数。MySQL的InnoDB就是一个典型例子。当优化器选择Index Condition PushdownICP时部分WHERE条件会被推给存储引擎在读取索引记录时直接判断减少回表次数。我实测过一张2000万行的表使用ICP后某些范围查询的耗时能从800ms降到200ms左右收益非常可观。在分析型数据库如ClickHouse中条件下推玩得更彻底。它的MergeTree表引擎在生成查询计划时会尽力把过滤条件下推到每个数据分区、每个 granules颗粒跳过完全不符合条件的颗粒这在扫描几十亿行数据时带来的IO节省是恐怖的。2.3 跨节点下推与Shuffle优化在分布式数据库场景比如Doris、StarRocks、TiDB里连接条件下推还有一个额外维度它决定了数据是否需要跨节点Shuffle。假设一张大表按order_id分片另一张大表按customer_id分片两张表做JOIN时如果连接键是customer_id其中一个分片上的数据必须重新分布到另一个节点。此时如果你能把过滤条件下推到分片内部先行处理实际参与Shuffle的数据量就大大减少。我在一个实际项目里把这个逻辑用得很透一张10亿级别的明细表关联一张维表每次查询需要从HDFS上拉几千万行做Shuffle。后来在ETL阶段和查询阶段同时做了裁剪把维表过滤条件直接下推到数据源读取阶段网络传输量下降了70%以上。3. 实操验证EXPLAIN计划中怎么确认下推生效纸上谈兵没用关键还是得自己能看懂执行计划。我分享一套我干活时必做的验证流程你按照这个思路走一遍基本能确定一条SQL的过滤条件到底有没有被正确下推。3.1 看Extra字段的提示信息在MySQL里EXPLAIN输出的Extra字段藏着大量线索。如果出现Using index condition说明ICP生效了如果出现Using where表示MySQL服务器层还需要对存储引擎返回的记录做过滤——这一步如果数据量大往往是性能瓶颈的根源。用一条实际SQL跑出来的结果是这样的------------------------------------------------------------------------------------------------------------------------------------------- | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | Extra | ------------------------------------------------------------------------------------------------------------------------------------------- | 1 | SIMPLE | c | NULL | ALL | PRIMARY | NULL | NULL | NULL | 100 | Using where | | 1 | SIMPLE | o | NULL | ref | idx_customer_id | idx_customer_id | 8 | test.c.customer_id | 50 | Using index condition | -------------------------------------------------------------------------------------------------------------------------------------------这个计划里驱动表c的Extra是Using where被驱动表o的Extra是Using index condition。后者说明JOIN条件被推到了索引扫描层但前者的过滤发生在Join之前还是之后需要进一步确认。3.2 用EXPLAIN ANALYZE验证实际执行位置只看EXPLAIN静态计划不够因为有时代价估算和实际执行路径会不一致。我在处理疑难慢SQL时一定会用EXPLAIN ANALYZE来做动态验证。以PostgreSQL为例EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM orders o JOIN customers c ON o.customer_id c.customer_id WHERE c.level VIP AND o.order_date 2024-01-01;执行结果中重点看每个节点的actual rows。如果一个Filter节点的actual rows远小于它下方节点的rows removed by filter说明大量数据在Filter处被丢弃——这时候你就要警惕了这些数据本来有机会更早被过滤。3.3 判断下推是否生效的三个信号根据我的经验据此判断一条SQL的过滤逻辑是否真正下推了就看三个信号。第一个信号过滤条件出现在扫描节点内部而不是Join节点的输出层。这是最直接的证据。第二个信号参与Join两侧的输入行数远小于原表行数。比如一张500万行的表驱动侧实际参与连接的行数只有5万行说明下推基本到位了。第三个信号排序、分组、去重等算子的输入行数明显变小。如果GROUP BY节点的输入行数和原表行数差不多说明下推在JOIN层面压根没起作用。这三个信号组合起来基本能覆盖95%的连接条件下推验证场景。剩下的5%要么是优化器统计信息过期导致估算偏差要么是非常规的SQL写法需要靠后面的排障方法来解决。4. 进阶玩法条件推导、分区裁剪与Join顺序优化连接条件下推真正见功底的地方不只是简单地把显式的WHERE条件往下压更多在于优化器能否做“隐性推导”利用已有条件生成本来不存在的过滤条件进一步缩小参与计算的数据量。4.1 跨表条件推导把A表的条件用到B表上看这条SQLSELECT * FROM orders o JOIN customers c ON o.customer_id c.customer_id WHERE c.customer_id 10086;c.customer_id 10086显然能过滤customers表但聪明的优化器还会做一步推导通过o.customer_id c.customer_id这个等值关系把o.customer_id 10086也推导出来并把它作为orders表的过滤条件。这就是连接条件下推的高级形态——条件传递。我在TiDB和Doris的优化器文档里都看到过这类规则它们叫“等价谓词推导”或者“谓词传递”。这背后的收益很大尤其当orders表按customer_id建立索引时多了一个点查条件执行计划直接从全表扫描变成了索引点查。顺便说一句这种推导能否生效非常依赖统计信息。如果customers.customer_id 10086这一行过滤后只有一条记录优化器会倾向选择Index Lookup JOIN。如果统计信息缺失优化器估算返回值是100万行它宁可使用Nested Loop或Hash Join也不会去做索引点查。4.2 分区裁剪与连接条件下推的组合拳分区表和连接条件下推放在一起效果会叠加。拿一个电商数据仓库的场景举例订单表orders按月份做Range分区查询条件里经常带order_date范围。如果这个条件能被下推到分区裁剪层查询只会扫描对应月份的少数几个分区而不是全表。我在一个实际数仓项目里做过统计在带时间分区的明细表上做会员维表关联查询连接条件下推分区裁剪组合生效后单条查询扫描的分区数从12个降到了2个查询耗时从14秒降到2.1秒。这里的关键点在于WHERE条件里必须带有分区键的等值或范围条件而且优化器得能在连接之前先识别出分区裁剪的机会。如果条件里把分区键的函数包住了比如WHERE DATE_FORMAT(order_date, %Y-%m) 2024-01分区裁剪就彻底失效了。这是非常经典的下推失效场景后文单列一节细讲。4.3 Join顺序对下推的影响先连接谁、后连接谁直接影响条件下推的效果。优化器选择Join顺序的核心依据是对各个表过滤后数据量的估算哪张表过滤后小谁就优先做驱动表。我们看一个三表连接的场景SELECT * FROM a JOIN b ON a.b_id b.id JOIN c ON a.c_id c.id WHERE a.status 1 AND b.type x AND c.name y;如果优化器判断a.status 1过滤后只剩10万行b.type x过滤后只剩500行c.name y过滤后只剩3000行合理的执行计划应该是先扫描b和c再和a做连接。这样每一层的中间结果都被早早裁剪连接条件也从一开始就有精确的索引可用。但如果统计信息不准确优化器可能误判为a是选择性最好的表把它作为驱动表那后续两个连接的输入行数都会大很多。这也是为什么我总建议运维同学定期更新统计信息而不是出了问题再去手动ANALYZE。让优化器“看清”数据分布它才能更聪明地把连接条件下推动、落到执行计划里。5. 慢SQL排查看板连接条件下推失效的典型场景与对策优化器不是万能的连接条件下推在实际业务SQL上经常失效。我整理了这几年排障时反复遇到的几个典型场景每个都附上了根因分析和对应的解决手段。5.1 OR条件导致下推失效最常见的失效场景就是OR。优化器在面对WHERE a 1 OR b 1这样的条件时很难把它安全地下推到索引扫描层因为下推不当会导致结果集错误。比如这条SQLSELECT * FROM orders o JOIN customers c ON o.customer_id c.customer_id WHERE o.status COMPLETED OR c.level VIP;在OR场景下我见过不少慢SQL是Nested Loop全表扫描级别的执行计划查询时间直接翻几倍甚至十几倍。处理思路有两个方向一是配合优化器调整利用UNION ALL把OR拆开改为两次查询合并结果二是在业务允许的前提下把OR条件改写成两个确定性的过滤条件再让优化器做下推。5.2 函数包裹列导致索引和下推同时失效这是另一类高频问题尤其是在报表查询里。SELECT * FROM orders o JOIN customers c ON o.customer_id c.customer_id WHERE DATE_FORMAT(o.order_date, %Y-%m-%d) 2024-01-15;DATE_FORMAT包裹了order_date列导致索引失效同时这个条件下推到了分区裁剪层也基本不起作用因为优化器无法从函数表达式直接推导出分区键的边界值。正确处理方式是不对列本身做函数运算而是把条件改写成范围AND o.order_date 2024-01-15 00:00:00 AND o.order_date 2024-01-16 00:00:00改写之后索引能命中条件下推能下推到扫描层分区裁剪也能正常识别时间范围。5.3 子查询折叠不彻底在MySQL 5.7早期版本和部分老版本数据库里IN (SELECT ...)子查询的折叠能力非常弱经常被物化成临时表导致连接条件下推彻底失效。比如SELECT * FROM orders o WHERE o.customer_id IN ( SELECT customer_id FROM customers WHERE level VIP );旧版优化器会把子查询物化成一张临时表再和orders做半连接level VIP无法下推到orders扫描层。升级到8.0之后优化器的子查询折叠能力大幅增强这类SQL会自动改写成semi join条件下推和连接顺序优化都会更好。如果没办法升级业务层可以手动把IN子查询改写为JOIN配合DISTINCT或直接利用JOIN的语义来规避物化问题。5.4 统计信息过期导致错误下推统计信息过期是隐藏最深的坑。表数据量涨了十倍统计信息还是一个月前的优化器估算的过滤行数和实际情况天差地别连接顺序和下推决策全部跟着跑偏。这时候你看到的执行计划可能特别“合理”——过滤条件下推到了扫描节点、驱动表也挑对了但实际跑起来就是慢。我在PostgreSQL里遇到过一张1亿行的表统计信息里显示只有100万行优化器选了Hash Join并且把大表当成了build侧结果内存直接被打爆临时文件写了几百GB。处理方式很简单定期跑ANALYZE或等价的统计信息更新命令。对于MySQL可以在夜间维护窗口执行ANALYZE TABLEPostgreSQL可以设置autovacuum的阈值或使用pg_stat_statements监控统计信息陈旧程度。5.5 常见问题速查表我把上述问题整理成一张速查表平时排查慢SQL时可以直接对照。典型场景症状根本原因处理方式OR多条件执行计划退化为全表扫描优化器无法安全下推OR条件改写为UNION ALL或拆分为多条SQL函数包裹列索引失效、分区裁剪失效函数表达式遮蔽了原始列信息改写为范围条件消除列上的函数IN子查询物化临时表拖慢查询优化器不会折叠子查询升级版本或改写为JOIN统计信息过期连接顺序颠倒优化器基于错误估算决策定期更新统计信息三表以上连接中间结果集膨胀连接顺序选择错误手动改写驱动表或使用Hint引导分页大偏移量LIMIT下推不合理优化器对OFFSET场景优化不足使用游标分页或延迟关联改写这张表是我做慢SQL治理时的保留项目每次遇到问题先对号入座再深入分析。5.6 动手改写SQL的正确心态最后说点掏心窝的话。连接条件下推是优化器层面的能力但优化器不总是最聪明的那个。作为DBA或者开发你需要做的是理解它的决策逻辑然后在SQL写法上顺势而为。我见过不少同行遇到慢查询第一反应是“优化器不行我加个Hint强制它走我想要的计划”。这个思路短期有效但长期来看非常危险数据分布一变强制计划可能就是灾难。更稳妥的做法是先把SQL改写成能让优化器更容易下推的形式——去掉无用的函数包裹、简化子查询、避免OR扩散、提供更准确的统计信息。让优化器自己走到正确的路上而不是拿根绳子牵着它走。根据我个人经验一条SQL的连接条件下推是否生效80%取决于你给优化器的“素材”索引质量、统计信息新鲜度、SQL写法规范。这三样做好优化器自然会把条件下推到正确的位置。如果这三样都到位了还是慢再考虑用Hint和物理设计去兜底也不迟。最后再分享一个小技巧日常排查时养成先看rows估算和actual rows对比的习惯差异超过数量级时优先怀疑统计信息不要急着改SQL差异不大但计划仍然奇怪时再着手调整SQL结构和连接顺序。这套顺序我在多个项目里验证过排查效率比“上来就改SQL”高出一大截。
返回列表