慢SQL优化是大多数DBA和开发者的日常必修课,光是“为什么不走索引”“为什么这条SQL跑了十分钟”这类问题,就能让人排查一个下午。而在所有慢SQL的根因里,有一种情况特别隐蔽:明明单表过滤条件能过滤掉大量数据,但优化器偏偏把过滤动作放到了连接之后,导致中间结果集膨胀得厉害,整个查询就像背着一麻袋石头跑步。连接条件下推,正是针对这类问题的经典优化手段。这篇文章我从原理层面拆解它到底解决了什么问题,再结合国产数据库KingbaseES的实际执行计划,讲清楚怎么判断、怎么改写、怎么通过参数和统计信息让优化器自动选择这类计划。适合正在做SQL调优、或者刚接触国产数据库迁移和验证的读者,内容偏实操。
1. 连接条件下推的核心逻辑与适用场景
1.1 为什么说“先过滤再连接”是黄金法则
关系型数据库里,连接操作算是最昂贵的运算之一。两个一万行的表做连接,理论上就是上亿次比较动作,哪怕走索引或者哈希连接,代价依然比单表扫描高一个量级。所以所有优化器的基本盘都是“尽可能减少参与连接的数据量”。条件下推就是干这个的——把WHERE条件里只涉及某一个表的过滤谓词,尽量下推到表扫描或索引扫描阶段执行,让进入连接环节的数据量从一开始就变小。
我用个生活化的例子。你去菜市场买鱼,摊主给你两条网兜,一条网兜里装了一百条鱼,另一条装了八十条,要求你从里面挑出重量大于两斤的鱼做配对。最笨的办法是把所有鱼倒进一个大盆,再从头到尾一条条称重配对;聪明的办法是先在各自网兜里把不够两斤的鱼放回去,只拿着保留下来的十来条鱼去配对。连接条件下推就是这个“先把不够格的扔出去”的过程,单表谓词下推得越彻底,连接算子的输入越少,整体代价自然越低。
很多朋友最初听到“条件下推”,第一反应只知道索引条件下推(Index Condition Pushdown,ICP),以为所有条件下推都是存储引擎层的事。实际上连接条件下推是优化器层面的逻辑重写,它关注的是“谓词应该挂在执行计划的哪个位置”。在Oracle里这叫Predicate Pushdown,在PostgreSQL里叫Constraint Exclusion之类,在KingbaseES里同样有完整的实现。理解了这一点,后面看执行计划时你就能抓到重点:谓词挂在动态索引扫描上,还是挂在连接之上的Filter节点上,结果天差地别。
1.2 哪些场景最需要关注连接条件下推
不是所有SQL都需要关心条件下推,它主要针对以下几类场景发挥价值。
第一类是星型模型里的多表关联查询。典型如订单事实表关联客户维表、商品维表、门店维表,事实表行数几百万上千万,维表几万行。如果过滤条件只落在维表上,例如“只看北京地区的客户订单”,这个过滤条件能下推到客户维表扫描阶段,客户维表先缩成一个很小的子集,再参与连接,效果立竿见影。
第二类是子查询展开后的连接。优化器会把很多IN、EXISTS子查询改写成连接,如果子查询内部本身有过滤条件,能否下推到子查询内部执行,直接决定子查询产生的中间集有多大。很多“明明子查询很快,但整条SQL奇慢”的案例就是这里出了问题。
第三类是视图或CTE(Common Table Expression,公共表表达式)展开后的谓词下推。开发者习惯把复杂逻辑封装成视图,在外层加过滤条件,理想情况下过滤条件应该直接穿透到视图内部的基表上。能不能穿透,就取决于优化器的下推能力。KingbaseES的查询重写机制对这类场景处理得比较成熟。
反过来说,如果你的SQL本身没有连接,或者所有过滤条件都集中在单表单列上,那连接条件下推对你的帮助有限,这时候该关注的是索引设计、统计信息、执行计划是不是走了顺序扫描这类更基础的问题。
1.3 连接条件下推和索引条件下推的区别
顺带说一个容易混淆的点。索引条件下推发生在存储引擎层面,它把WHERE里无法通过索引定位、但跟索引列相关的条件,传给存储引擎,在读取索引记录时一并判断,减少回表次数。MySQL的ICP就是典型例子。
连接条件下推则发生在优化器逻辑优化阶段,它不管存储引擎怎么读数据,只决定谓词算子应该挂在执行计划树上的哪个位置。两者是不同层级的东西KingbaseES作为关系型数据库,其执行计划通过EXPLAIN可视化之后,你能看到谓词在算子上的分布,这是判断条件下推是否生效最直接的证据。
2. 为什么优化器有时“不愿”下推:代价模型与统计信息的博弈
2.1 连接顺序与下推的联动关系
要让条件下推真实落地,优化器得先确定连接顺序。连接顺序不同,谓词下推的位置可能完全不同。比如A、B、C三表连接,过滤条件落在A表,优化器如果选择先连接B和C,再把结果跟A连接,那A表的过滤条件就只能等最后一步才能执行,B、C连接产生的中间结果全得背在身上。反过来,如果优化器先把A过滤完再跟B连接,代价就小得多。
所以连接条件下推不是孤立的技术,它跟连接顺序选择直接挂钩。这也是为什么很多情况下,明明某个单表条件能过滤99%的数据,优化器依然选了糟糕的路径,大概率是把带过滤条件的表放到了连接顺序的后半段。
KingbaseES的优化器在处理这类问题时,会综合每个表的行数估计值、分布特征、索引情况来做代价评估。“把最能把结果集削小的表优先连接”是优化器试图遵循的原则,但能不能识别出来,依赖统计信息是否准确。经常能看到,统计信息过旧导致行数估计偏差十倍百倍,优化器据此选错了连接顺序,条件下推也跟着失效。
2.2 统计信息:优化器判断下推效益的输入
我接触过不少国产数据库迁移的现场,最大的坑就是迁移完忘了收集统计信息。原来数据分布是老样子,统计信息却还是空的或者旧的,优化器两眼一抹黑,全靠默认值猜。这样条件下推就算逻辑上能执行,代价评估也可能认为“下推没有收益”而放弃。
举个实际例子,一张五百万行的订单表,客户编号列上过滤条件customer_id = 10086,实际只有几百行命中。如果统计信息缺失,优化器可能认为这个条件会命中一半数据行,那下推后参与连接的还是两百五十万行,收益被严重高估后反而变成“不值得优化”。所以排查条件下推失效问题时,第一步不是改SQL,是看统计信息新鲜度。KingbaseES里可以查pg_statistic或直接用ANALYZE命令主动收集。
2.3 连接算法对下推效果的影响
连接算法也会影响下推的显性收益。嵌套循环连接(Nested Loop Join)下,驱动表如果能被条件过滤缩小,内外层循环次数同步下降,收益是乘数级的。哈希连接(Hash Join)下,小表先建哈希表,如果小表侧有条件下推,建哈希表代价和哈希探测代价都降低。排序合并连接(Merge Join)类似,两侧数据预先排序开销减少。
理解这个点对分析执行计划很有用。你在EXPLAIN里看到Hash Join,通常希望过滤条件能下推到哈希连接的小表侧也就是内表;看到Nested Loop,则希望下推到外表侧作为驱动。如果条件挂在了连接之上的Filter里,意味着数据先连接完再过滤,这在大数据量下几乎是灾难。
3. KingbaseES 环境下连接条件下推的实战形态
3.1 从执行计划判断下推是否生效
在KingbaseES里做SQL调优,最顺手的工具就是EXPLAIN。需要注意一点,单靠默认的EXPLAIN看不出谓词挂在哪个节点,建议用EXPLAIN (ANALYZE, VERBOSE, COSTS ON, BUFFERS ON),把输出信息打全。VERBOSE选项尤其重要,它会显示出每个算子的输出表达式和过滤条件,这样就能清晰看到单表谓词到底被推到了哪里。
拿到一份执行计划,先关注每个扫描节点下的Filter或者Index Cond。比如这样一个SQL:
SELECT * FROM orders o JOIN customers c ON o.cust_id = c.id WHERE c.city = 'Beijing' AND o.amount > 1000;理想计划中,customers表的扫描节点上应该有Filter: (city = 'Beijing'::text),orders表的扫描节点上有Filter: (amount > 1000)。两个过滤动作都在扫描阶段完成,之后才进入连接。如果执行计划长这样:
Nested Loop -> Seq Scan on customers c Filter: (city = 'Beijing'::text) -> Seq Scan on orders o Filter: (amount > 1000) ...说明下推成功。但如果看到连接节点上方还有一层Filter: (c.city = 'Beijing'::text),那就要警惕了,可能优化器因为某种原因没有完成下推,或者等价改写受到了限制。
3.2 常见可下推谓词类型与改写方法
在KingbaseES中,绝大多数基础比较谓词都能下推,比如等值比较、范围比较、IS NULL、IN列表、LIKE前缀匹配等。这些谓词只要确定只引用单表列,逻辑上一定可以下推,优化器一般不会犯低级错误。真正容易出问题的是这几类:
第一类,函数包裹列的情况。像WHERE upper(c.name) = 'ZHANGSAN',如果函数本身被定义为非不可变(不是IMMUTABLE),优化器无法确定每次调用结果是否一致,下推就会被限制。解决办法是改成WHERE c.name = 'zhangsan'配合函数索引,或者把函数改成不可变版本。
第二类,跨表引用条件的子查询。比如WHERE o.amount > (SELECT AVG(amount) FROM orders WHERE status = 'OK'),这种带子查询的谓词通常不能直接下推到orders表扫描里,因为需要先算出聚合值。优化器一般会先执行子查询,把结果作为参数再传递下去,这属于“参数化下推”的范畴,KingbaseES里也支持,但不如单表谓词那么直观。
第三类,OR条件连接多个表字段。WHERE a.x = 1 OR b.y = 2,这类谓词在连接之前无法下推到任何一个单独的表上,因为结果依赖连接后的行。除非优化器能做等价改写,拆成UNION分支的形式,否则这个Filter只能挂在连接之上。遇到这种情况,建议人工改写SQL,把逻辑拆清楚。
3.3 视图与CTE场景下的下推实战
视图和CTE的下推是实践中最常踩坑的地方之一。看一个CTE例子:
WITH filtered_orders AS ( SELECT cust_id, SUM(amount) AS total FROM orders GROUP BY cust_id ) SELECT c.name, f.total FROM filtered_orders f JOIN customers c ON f.cust_id = c.id WHERE c.city = 'Beijing';理想情况下,c.city = 'Beijing'可以下推到customers表扫描阶段,filtered_orders的内部也可以先跟裁剪后的customers做半连接,避免先聚合全部订单再过滤客户。但实际情况中,优化器如果把这个CTE物化,后面就无法执行谓词穿透,只能先全量聚合,再跟customers连接,最后过滤city。
在KingbaseES中,CTE是否物化受查询特征影响。像上述场景更推荐把CTE改写成子查询或直接展开成普通连接,让优化器有更大改写空间。同时多试几种等价写法,把执行计划拿出来对比。写法和执行计划的对应关系,只有在实践中才能真切感知。
再比如视图嵌套视图的场景,外层筛选条件能不能一路穿透到最底层基表,取决于每层视图里是否有聚合、DISTINCT、窗口函数这类“下推阻断算子”。我一般会建议开发人员尽量把视图写“薄”,不要过度包装业务逻辑,否则优化器再聪明也没法突破语义边界。
3.4 通过连接条件推动连接顺序:两张表时的启发式思路
连接条件下推跟连接顺序强相关,这里分享一个自己常用的调优思路。当查询里有两张表以上时,我会先找出“过滤性最强的表”,也就是单表条件下预估返回行数最少的表,想办法让它成为驱动表。在KingbaseES里可以通过EXPLAIN看估算行数,如果发现驱动表的估算行数远大于实际值,优先收集统计信息;如果统计信息准确但优化器还是选错,再考虑用JOIN_ORDER提示干预。
下面是我在实际项目里用过的一个双表案例。某系统里有个大订单表和商品表,订单表五千万行,商品表二十万行。原始SQL是查特定分类下的大额订单,优化器先生成了一份二十来万的订单中间结果再跟商品表连接,执行时间三秒多。后来我把条件做了等价改写,把商品表的分类过滤作为子查询先物化,再跟订单表做连接,执行计划变成了先过滤商品表再嵌套循环订单表,执行时间降到几百毫秒。当然,KingbaseES的优化器本身已经相当智能,这只是特定统计信息不理想时的兜底方法,不建议一上来就重度改写SQL。
4. 常见问题与排查技巧实录
4.1 谓词未下推时的标准排查路径
当你怀疑一条慢SQL存在谓词未下推问题时,我的排查顺序通常是这样的:
- 先看完整执行计划,用
EXPLAIN (ANALYZE, VERBOSE)确认Filter节点位置。如果Filter挂在连接节点之上,说明确实没有下推。 - 检查统计信息新鲜度,看看相关表的
reltuples、relpages是否与实际相符,必要时执行ANALYZE再跑一次计划。 - 检查谓词里有没有函数包裹列、隐式类型转换,这两种情况最容易导致下推失效。
- 如果SQL中有视图、CTE,尝试将视图定义展开,把过滤条件直接写到基表查询里,验证是否由优化器改写能力不足导致。
- 最后再考虑改写SQL结构或者使用提示干预,这一步必须谨慎,因为人工优化往往失去通用性。
这套流程在KingbaseES和PostgreSQL系数据库里都通用,实际排障时很顺手。
4.2 为什么子查询条件没“沉”下去
子查询未下推的问题也很典型。有个项目反馈,某条SQL用了WHERE EXISTS (SELECT 1 FROM detail d WHERE d.order_id = o.id AND d.shop_id = 100),整体查询非常慢。执行计划显示detail表被全表扫描,而且没有携带shop_id = 100的过滤条件,优化器先扫描整个detail表再跟外层做半连接。
排查下来发现,detail.shop_id列上缺乏统计信息,优化器无法估计选择率,干脆保守处理。补完统计信息后执行计划恢复正常,shop_id条件被下推到索引扫描上。这类问题本质上不是优化器不聪明,而是统计信息缺失导致它“不敢”做激进下推。
碰到子查询里条件列有索引但没被利用的情况,先别急着改SQL,很多通过补统计信息就能解决。如果用EXPLAIN ANALYZE看到某个节点实际行数与估算行数差了一个数量级以上,那基本可以断定统计信息出了问题。
4.3 参数调整是否能强制下推
有些同行会问,KingbaseES有没有类似开关的参数能强行打开条件下推。坦白讲,优化器层面的连接条件下推没有极端的开关来控制,它受代价模型驱动,不是简单的on/off。但有几个参数会间接影响,例如:
enable_hashjoin、enable_nestloop、enable_mergejoin:关闭某类连接算法往往能改变执行计划,间接导致条件下推位置改变。from_collapse_limit、join_collapse_limit:这两个参数影响多个连接表的重写顺序,调大后优化器可能获得更多连接顺序尝试空间。geqo_threshold控制遗传查询优化器何时启用,超过阈值后优化器可能不再做穷举搜索,连接顺序和下推都有可能受影响。
这些参数不建议在生产环境随意调整,除非你已经通过EXPLAIN分析确认问题源于连接顺序枚举不足,改参数立竿见影才能考虑系统性验证后下发。否则治标不治本,下次数据分布一变又出问题。
4.4 一张排查速查表
我把实际遇到的高频问题整理成表,方便大家复制到自己的排障手册里。
| 现象 | 可能原因 | 首选处理办法 |
|---|---|---|
| 连接节点之上还有Filter | 谓词涉及多表;函数包裹列;统计信息缺失 | 查看VERBOSE计划;补统计信息;尝试等价改写 |
| 子查询内部表不携带过滤条件 | 子查询展开受限;统计信息缺失 | 先ANALYZE;尝试拆分子查询 |
| 视图/CTE外层条件未穿透 | 物化CTE或视图内部有聚合阻断 | 改写为子查询;重写视图;手动下推 |
| 驱动表顺序不佳 | 多表连接顺序枚举不足;估行不准 | 检查join_collapse_limit;必要时使用提示干预 |
| 相同SQL时快时慢 | 统计信息过旧;数据分布变化 | 定期自动收集统计信息 |
4.5 KingbaseES迁移优化场景里的一个实战示例
最后分享一个完整的小案例,来自某企业从国外数据库迁移到KingbaseES的项目。迁移后某报表查询跑了二十七秒,用户完全不能接受。原SQL大概长这样:
SELECT ... FROM sales_fact f JOIN product_dim p ON f.product_key = p.product_key JOIN store_dim s ON f.store_key = s.store_key WHERE p.category = 'Electronics' AND s.region = 'East' AND f.trans_date BETWEEN '2024-01-01' AND '2024-03-31';查看执行计划,发现两个严重问题:一是sales_fact表过滤条件只有trans_date被下推到了索引条件,另外两个维表过滤条件挂在了Hash Join之上的Filter节点;二是连接顺序是先连接两个维表,再连接大表,导致中间结果膨胀。
处理步骤:先对所有相关表执行ANALYZE,更新统计信息;然后把两个维表条件通过改写SQL,提前用子查询围住维表数据。修改后执行计划变成先过滤维表,再以过滤后的结果集作为驱动连接事实表,整体执行时间从二十七秒降到一点八秒。
这个案例最妙的点是,原SQL本身没有任何语法问题,也不是传统意义上的“烂SQL”,纯粹是优化器在统计信息偏差下做出的局部最优决策。可见连接条件下推不只是优化器的职责,也是SQL编写者需要具备的意识——怎么写能让优化器更轻松地发现下推机会,本身就是值得修炼的能力。
KingbaseES在这方面跟经典PostgreSQL系优化器一脉相承,但国产数据库的统计信息收集、执行计划展示和参数语义有自己的细节差异。建议新接触的朋友多花点时间读执行计划,尝试用EXPLAIN的VERBOSE选项逐节点核对谓词位置,当你对“谓词应该出现在哪一层”形成直觉之后,很多慢SQL一眼就能看出病根。
我自己这几年做SQL优化的体会是,遇到慢SQL别急着加索引,先花五分钟看执行计划,思考“这批数据能不能更早变少”。连接条件下推的理念就是这句话的落地规则:让每一层算子都只处理它必须处理的数据。把这个原则刻在脑子里,再配合准确的统计信息和合理的SQL写法,大多数连接型慢查询都能找到清晰的优化路径。之后你再看执行计划时,关注点就会从“走了什么连接算法”升级到“每个节点过滤了多少数据”,那才算真正入了SQL优化的门。