在数据库运维一线待久了,你会发现慢SQL优化这事儿,很多人的第一反应是加索引、调参数,真正去抠执行计划细节的反而不多。其实不少企业级慢查询,病根根本不在索引缺失,而是优化器没能把连接条件、过滤条件推到表扫描之前执行,中间结果集被撑大了一两个数量级。今天聊的连接条件下推(Join Predicate Pushdown)就是这么个容易被忽略但收益极高的优化方向。我以人大金仓KingbaseES V8的实际执行计划为例,把原理拆开讲清楚,再附上几个真实改造记录,希望能给被慢SQL折磨的DBA和后端同行的排查思路带来一点参考。
1. 连接条件下推:从执行计划视角重新认识 Join 优化
1.1 先过滤、后连接:为什么它是最高原则
先看一个最简单的业务模型:两张表关联查询,一张订单表,一张客户表,要查某个城市客户的订单量。绝大多数人写出来的SQL长这样:
SELECT o.order_id, o.amount FROM orders o JOIN customers c ON o.customer_id = c.customer_id WHERE c.city = '杭州';这条SQL看起来人畜无害。但执行计划里,优化器到底先把city = '杭州'这个过滤条件放到扫描客户表时就执行,还是等两表Join完成后再统一过滤,会导致成千上万倍的性能差距。
原因很好理解。数据库执行Join的方式,无论Nest Loop、Hash Join还是Merge Join,都和“待连接的数据量”强相关。过滤条件如果能下推到基表扫描层,客户表只需取出杭州那几千行参与连接;如果下推失败,客户表全表几十万行全部进入Join,中间结果集膨胀,排序、哈希、内存占用全部跟着失控。
我习惯用一个筛沙子的类比来解释这件事:你有一堆混着石头的沙子,要挑出细沙做玻璃。高效的做法当然是先把大石头筛掉再运输,而不是把整堆沙子拉回工厂再挑。数据库里的过滤下推,就是这“先筛后运”的工序。连接条件下推的意义远不止“少扫描几行”,它决定了整个Join算子下游的内存、CPU、临时文件开销,是企业级SQL性能的分水岭。
1.2 三类下推场景:关联条件、过滤条件与外连接陷阱
要理解连接条件下推,先得把它拆成两种不同的下推类型。
第一种是关联条件下推。还是上面那个例子,o.customer_id = c.customer_id这个等值条件本身驱动着Join的实现。在Nest Loop Join里,优化器会对外表(驱动表)的每一行,到内表去查找匹配行。如果能确认内表有索引,这个关联条件会下推成内表的索引扫描条件,即Index Scan using idx_customers on c (customer_id = o.customer_id)。这样内表不需要全表扫描,B树索引直接定位,这就是最常见的下推收益。
第二种是过滤条件下推。c.city = '杭州'这种单表过滤条件,如果能下推到基表层级,执行计划里你会看到Seq Scan on customers c Filter: ((city)::text = '杭州'::text)。注意这个Filter出现在扫描节点上,而不是Join节点之上。
第三种情况最考验对SQL语义的理解:外连接(LEFT JOIN / RIGHT JOIN)的下推有严格限制。看这条:
SELECT * FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id WHERE c.city = '杭州';这里的WHERE条件一旦下推到右表扫描层,左连接就失去了“保留左表全部行”的语义。因为如果某条订单对应的客户不在杭州,在扫描客户表阶段就把该右表行过滤掉了,连接后左表那行也会被丢弃,结果相当于INNER JOIN。所以优化器出于语义正确性,会拒绝把右表的过滤条件下推到扫描层,只能在Join完成后做Filter。很多初级开发者在这里踩坑,日志里明明扫描只有几千行,Join之后却过滤掉几万行,性能差还怪数据库。
半连接(SEMI JOIN)和反连接(ANTI JOIN)也有类似的语义约束,优化器得非常谨慎。理解这些约束,你才能明白为什么有些下推做不了,而不是一上来就骂KingbaseES优化器笨。
1.3 下推失败的典型代价:用真实案例说话
我在客户现场处理过一个典型问题,表结构不复杂,sales_detail有两千多万行,region表只有三百行。业务查询要按区域汇总销售金额,SQL长这样:
SELECT r.region_name, sum(s.amount) FROM sales_detail s JOIN region r ON s.region_id = r.region_id WHERE r.region_type = '华东' GROUP BY r.region_name;问题出现在region_type = '华东'这个条件。因为该列没有统计信息支撑,且写成了字符串常量,优化器对选择率评估失准,没有把过滤下推到region表扫描。执行计划里能看到在Hash Join之上多了一个Filter: (r.region_type = '华东'),等于说300行region全量参与Hash,再加上两千多万行sales_detail全部飘过Hash表,最终才过滤出几十行。那条SQL跑了47秒,而整个筛选后涉及的其实就是几百行数据。
我当时的调整方案是重写子查询:先把region表按条件过滤后用CTE包住,再和sales_detail做Join。重写之后执行计划中过滤成功下推,region表扫描直接只剩二十几行,Hash表小得可以塞进CPU缓存,整体耗时降到1.8秒。同样是那句老话:中间结果集的大小决定了SQL的下限,执行计划的形状决定了SQL的上限。
2. KingbaseES 中确认与验证:执行计划与统计信息
2.1 EXPLAIN 输出从哪里看下推是否成功
KingbaseES V8和PostgreSQL同源,执行计划查看完全兼容EXPLAIN语法。我强烈建议在优化阶段用EXPLAIN (ANALYZE, BUFFERS, COSTS),三个选项一个都别省:
ANALYZE:真实执行SQL,打印实际行数(Actual Rows)和真实耗时,这是判断优化器预估是否失真的关键。BUFFERS:显示shared hit、read、dirtied,帮你判断是否大量读取了磁盘而非内存。COSTS:显示优化器估算成本。
拿到计划后,我的检查顺序是固定的:
- 找
Filter关键字出现在哪个节点。如果它出现在Seq Scan或Index Scan节点内,说明过滤下推成功;如果出现在Join节点之上(例如Hash Join后面的Filter),就说明下推失败或不能下推。 - 看
Actual Rows和Rows的差距。如果优化器预估100行,实际扫了100万行,说明统计信息失真或选择率估算崩了,这是后续收集统计信息的信号。 - 看
Buffers: shared read的值。这个数字越大,说明缓存命中率越低,SQL大概率处于磁盘扫描状态。
举一个真实计划片段,这样看直观:
Hash Join (cost=13358.59..50218.31 rows=6519 width=36) Hash Cond: (o.customer_id = c.customer_id) -> Seq Scan on orders o (cost=0.00..10345.29 rows=489729 width=24) -> Hash (cost=13316.79..13316.79 rows=2679 width=16) -> Seq Scan on customers c (cost=0.00..13316.79 rows=2679 width=16) Filter: (city = '杭州')这个计划里,Filter位于Seq Scan on customers节点内部,这就是标准的过滤条件下推。先过滤出2679行,再进Hash表,和orders表做Join。如果你看到的计划里Filter出现在Hash Join之后,或者Filter前还隔着别的Join,就要警惕了。
2.2 统计信息与 ANALYZE:下推决策的数据基础
连接条件下推能不能做成,很大程度依赖优化器对“过滤后行数”的估算。估算靠的是统计信息——表的行数、列的NULL比例、高频值、直方图。KingbaseES的自动分析(autovacuum)默认是开启的,但它往往按触发阈值运行,对于频繁批量写入的表,统计信息滞后非常常见。
一个我在运维中养成的习惯:大表大批量DML操作后,第一时间手动ANALYZE。命令很简单:
ANALYZE [VERBOSE] sales_detail;生产环境里如果发现执行计划对选择率的估算明显失真,比如等值条件明明能筛掉99%的数据,优化器却只按筛掉50%来算,那大概率就是直方图过期。我还会检查pg_stats视图里的null_frac和n_distinct,这两个值如果和实际严重不符,会导致优化器把下推后的行数估大,从而放弃下推,选择更差的Join顺序。
另外,统计信息的采样比例也可以调。KingbaseES里通过ALTER TABLE ... SET STATISTICS target设置列级采样倍数,默认值是100,对超大表可以调到1000甚至更多。代价是ANALYZE时间变长,但换来的通常是一个靠谱得多的执行计划。
2.3 并行场景与下推的配合
很多企业级大查询不完全靠下推赢,还靠并行。KingbaseES V8支持并行扫描和并行Hash Join,通过max_parallel_workers_per_gather控制单个Gather节点下的并行度。下推和并行并不冲突,反而经常配合出现:过滤下推到基表后,基表扫描可以并行扫描多个数据块,再并行Hash Join,收益叠加。
不过有个坑:并行执行计划里,如果过滤条件下推失败,并行度越高,浪费越严重。因为每个并行worker都在扫描大表做无谓的过滤,CPU和IO全部空转。我在一个地理位置类业务系统(涉及地图瓦片数据的ETL)里碰到过,单表1.2亿行,并行度开到8,一条简单关联查询还是跑了三分钟。开了EXPLAIN ANALYZE才发现每个worker都在做全表扫描,Join之上挂了Filter。当时把子查询里的条件改写成派生表形式,过滤成功下推后,数据量降到几万行级别,并行度开到4就足够,整体耗时降到20秒以内。这也提示一点:并行不是万能的,先让下推做对,再谈并行加速,顺序错了只会让资源白白烧掉。
3. 实操:两个典型慢查询的下推改造记录
3.1 案例一:大表与小表连接时过滤条件未下推
这是一个金额汇总类报表场景,两张表:fact_sales(4千万行)和dim_product(2万行)。原始SQL:
SELECT p.category, sum(fs.amount) FROM fact_sales fs JOIN dim_product p ON fs.product_id = p.product_id WHERE p.is_active = 1 GROUP BY p.category;第一版执行计划(简化关键节选):
Finalize HashAggregate -> Hash Join (cost=3480.21..425881.12 rows=196887 width=40) Hash Cond: (fs.product_id = p.product_id) -> Seq Scan on fact_sales fs (cost=0.00..355018.15 rows=41999351 width=36) -> Hash (cost=2369.21..2369.21 rows=41881 width=12) -> Seq Scan on dim_product p (cost=0.00..2009.21 rows=41881 width=12) Filter: (is_active = 1)发现没有?问题不在过滤条件本身,is_active = 1确实下推到了扫描节点。但看行数:2万行的dim_product,筛选后预测有41881行,比全表还多,这一看就是统计信息坏掉了。事实表4千万行全部进入Hash Join的探测阶段,实际只需要几万个活跃商品参与连接。
我先对dim_product表做了一次ANALYZE,然后重查执行计划,筛选后行数从41881降到了1203行。同时我把GROUP BY从HashAggregate改成显式的grouping sets,减少一重聚合。最终改造:
SELECT p.category, sum(fs.amount) FROM fact_sales fs JOIN dim_product p ON fs.product_id = p.product_id WHERE p.is_active = 1 GROUP BY p.category;一步步稳定到6秒以内。这个案例最大的教训是:过滤条件写在扫描节点上,不代表统计信息就是准的。Further检查pg_stats时发现dim_product表的is_active列的直方图压根没更新,采样时机不对,导致优化器以为几乎全是激活状态。
3.2 案例二:外连接中 WHERE 与 ON 的语义陷阱
第二个案例来自一个订单权限过滤场景。业务要求查全部订单以及对应客户信息,如果订单所属客户已被标记为“黑名单”,则客户信息显示为空。第一眼看到业务需求,大家自然会写LEFT JOIN:
SELECT o.order_id, c.customer_name FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id AND c.is_blacklist = 0;看到没,过滤条件is_blacklist = 0放在了ON子句里,这其实是对的——左连接保留所有订单,客户表只匹配非黑名单的客户。但项目里有个同事把条件误写到了WHERE里:
SELECT o.order_id, c.customer_name FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id WHERE c.is_blacklist = 0;这个版本有两个问题。第一,语义完全变了。WHERE c.is_blacklist = 0相当于把LEFT JOIN变成了INNER JOIN,黑名单客户对应的订单行会从结果集里消失。第二,由于WHERE条件引用的是被驱动表(右表),优化器不能把它下推到右表的扫描层,否则语义更崩。执行计划里右表先全量扫描,再在Join之上做过滤,几十万行的right表全部参与Hash,查询时间从0.8秒恶化到9秒多。
正确方案是保持ON子句里的条件,计划变成:
Hash Right Anti Join / Hash Left Join Hash Cond: (o.customer_id = c.customer_id) -> Seq Scan on customers c Filter: (is_blacklist = 0)注意这个计划里右表扫描自带Filter: (is_blacklist = 0),这就是合法的下推。因为过滤条件在ON里,优化器能在保持左连接语义的前提下,把条件下推到被驱动表的扫描阶段,只把非黑名单客户放进Hash表。改造后执行时间回到1秒内。
这类案例在企业级SQL评审里太常见了。提醒所有做代码评审的DBA:遇到LEFT JOIN + WHERE的写法,一定要先问一句:这个过滤条件是不是本来该放在ON里。这不是索引能救回来的问题,是语义导致的执行计划结构性缺陷,改写后收益立竿见影。
3.3 改造前后对比与收效
我把两个案例的改造效果整理成一个对比表,方便后续复盘参考:
| 指标 | 案例一:统计信息失真 | 案例二:外连接语义陷阱 |
|---|---|---|
| 慢查询耗时 | 47秒 | 9.5秒 |
| 优化后耗时 | 6秒 | 0.9秒 |
| 扫描行数(驱动表) | 4千万行 | 几十万行 |
| 扫描行数(被驱动表) | 4.2万行(实际1203行) | 筛选前全表 |
| 核心改动 | ANALYZE + 修正统计信息 | WHERE 改为 ON 子句 |
| 下推后的效果 | 过滤条件下推至 Hash 内侧 | 右表过滤条件合法下推 |
两个案例都不涉及加索引,纯靠执行计划结构调整和统计信息修正就把性能拉了回来。这说明企业级SQL优化里,执行计划形态检查应该排在加索引之前。
4. 常见问题与排查技巧实录
4.1 问题速查表
日常运维里,连接条件下推相关的问题有一些规律可循。我整理了一张速查表,大家完全可以照着这个思路排查:
| 症状 | 可能原因 | 优先检查项 |
|---|---|---|
| 小表Join大表,计划却先扫大表 | Join顺序优化失败,统计信息失真 | ANALYZE两张表,查看pg_stats的NULL比例 |
| 过滤条件出现在Join节点之上 | 过滤条件引用多表列,或外连接语义限制 | 检查ON/WHERE位置,检查是否可改写为子查询 |
| 预估行数和实际行数差一个数量级 | 选择率估算崩溃,直方图缺失 | 列级SET STATISTICS后重新ANALYZE |
| 并行开启后SQL反而变慢 | 每个worker都在扫描大表执行低选择性过滤 | 先关并行,压缩中间结果集后再开并行 |
| 子查询内过滤下推失败 | 子查询无法被提升(Subquery Unnest失败) | 改用WITH CTE重写,配合materialized属性调整 |
| 等值连接条件被隐式转换阻断 | 两侧数据类型不一致,索引条件失效 | 检查连接列字符集/collation/类型,统一后重写 |
4.2 排查慢SQL的标准姿势
慢SQL日志要开。KingbaseES的配置里,log_min_duration_statement设置成1000(单位毫秒),超过1秒的SQL都会落到日志里。但抓到慢SQL只是第一步,养成“每条可疑SQL必看执行计划”的习惯。
排查流程我会跑一遍这个固定动作:
- 拿到慢SQL文本,先格式化,理清各表关系,标注出可能的过滤条件和连接条件。
- 利用KingbaseES的
auto_explain模块设置auto_explain.log_min_duration = 1000,把慢SQL的执行计划自动记录到日志。这条在排查历史慢SQL时最有用,不用等现场复现。 - 对SQL做
EXPLAIN (ANALYZE, BUFFERS)离线复跑,重点关注Base表扫描节点上的Filter和Join节点之上的Filter。 - 如果发现下推失败,依次做三件事:检查统计信息新鲜度、检查布尔表达式是否可折叠、重写子查询结构。
4.3 下推之外的三个高阶优化点
连接条件下推常常不是性能问题的全部。老规矩,本着先给结论再解释的惯例,我把碰到过的另几个高频优化点一并分享。
第一个是连接顺序。Hash Join复杂时,优化器选择的驱动表和被驱动表不一定符合你的直觉。常见手段是设置join_collapse_limit,把它调到1以后,优化器会老老实实按SQL书写顺序执行Join,很多手工调优场合都用得上。不过它是一把双刃剑,关掉重排意味着你要自己对连接顺序负责,生产环境用之前先在测试环境充分验证。
第二个是索引设计与数据类型。连接条件下推成功之后,如果被驱动表的关联字段上没有合适的索引,Nest Loop Join的内表扫描还是全表。最容易被踩的坑是连接列类型不一致:varchar和text之间在KingbaseES里往往能隐式转换,但一旦转换发生在索引字段上,索引就失效了。这和主键索引与唯一索引的差别一样,常被误用:主键索引自带唯一约束且不允许NULL,唯一索引只保证唯一性但允许NULL,二者从数据和索引维护成本上都不是一回事。企业级表设计里别把唯一索引当主键用,也小心在索引列上套函数或隐式转换。
第三个是并行度与资源隔离。生产环境不能无脑把max_parallel_workers_per_gather调到32,并发高的小事务场景下并行反而会拖垮CPU。我的经验是OLTP库并行度开到2~4,分析型场景开到8左右已经足够。并行和下推是配合关系,两者结合时优先保证下推成功。
关于索引失效,再啰嗦几句最典型的高频场景:
- 索引列参与运算:
WHERE amount + 1 > 100永远不会走索引,改写为WHERE amount > 99。 - 前置模糊:
LIKE '%keyword%'导致B树索引失效,可以考虑pg_trgm的GIN索引或全文检索。 - OR条件:
WHERE a = 1 OR b = 2很难用单列索引,必要时拆成UNION ALL。 - 隐式类型转换:
WHERE phone = 13800000000如果phone是字符串,等值条件里数值常量会被转换,索引失效。
结尾
KingbaseES上的这些优化实践给我的最大体会是:SQL性能问题很少是单点问题,连接条件下推、统计信息、索引设计往往环环相扣。我见过太多人一遇到慢SQL就抄起索引工具书翻,结果印证了那句老话——“方向错了,跑得越快错得越远。”今后再遇到慢SQL,先打开执行计划找Filter的位置,看看下推有没有成功。这个动作看似不起眼,但在企业级大表场景下,带来的收益往往比盲目加索引大得多。