ARTICLE DETAIL

资讯详情

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

连接条件下推:从执行计划视角根治慢SQL优化难题

连接条件下推:从执行计划视角根治慢SQL优化难题 在数据库运维一线待久了你会发现慢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 (cost13358.59..50218.31 rows6519 width36) Hash Cond: (o.customer_id c.customer_id) - Seq Scan on orders o (cost0.00..10345.29 rows489729 width24) - Hash (cost13316.79..13316.79 rows2679 width16) - Seq Scan on customers c (cost0.00..13316.79 rows2679 width16) 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_sales4千万行和dim_product2万行。原始SQLSELECT 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 (cost3480.21..425881.12 rows196887 width40) Hash Cond: (fs.product_id p.product_id) - Seq Scan on fact_sales fs (cost0.00..355018.15 rows41999351 width36) - Hash (cost2369.21..2369.21 rows41881 width12) - Seq Scan on dim_product p (cost0.00..2009.21 rows41881 width12) 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 JOINSELECT 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的位置看看下推有没有成功。这个动作看似不起眼但在企业级大表场景下带来的收益往往比盲目加索引大得多。
返回列表