GaussDB执行计划跳变排查与SQLPATCH固定实践
执行计划突然变了SQL性能断崖式下跌这种事在GaussDB上排查过的人应该都懂。尤其是那些跑了很久、原本秒回的SQL某天早上突然从几十毫秒变成几十秒业务侧直接报警。翻开慢日志一看连接收方都没变就是执行计划从GPLAN走了一条完全没走过的路径。今天就把GaussDB里执行计划跳变的前因后果、怎么定位、以及用SQLPATCH把计划“焊死”的操作方法完整梳理一遍。先解释一下GPLAN到底是什么。GaussDB作为自研的分布式/集中式数据库优化器基于代价模型生成执行计划这套计划在GaussDB内部被称为GPLAN。它本质上是一棵物理算子树决定了大表怎么扫描、Join用什么算法、数据在节点之间怎么流转。跟传统数据库执行计划相比GPLAN的差异主要在于分布式场景下额外包含数据重分布Redistribute、广播Broadcast等算子集中式场景则更接近传统优化器输出。但无论是哪种形态核心逻辑都一样优化器根据统计信息估算每条路径的代价选出理论上最便宜的一条。问题就出在这个“理论上”——当统计信息失真、参数变化、数据分布偏移时代价估算就会失真选出来的“最优计划”可能根本不优甚至是最差的计划。SQLPATCH则是GaussDB提供的一种轻量级执行计划干预手段通俗讲就是给某一条SQL“绑计划”。你可以针对特定的SQL文本或SQL ID通过PATCH下发的hint来改写优化器的决策指定Join顺序、扫描方式、使用的索引等让这条SQL无论统计信息怎么变、环境参数怎么动都稳定走你指定的路径。它和直接改SQL文本不同不需要动业务代码对应用透明非常适合解决“计划跳变”这类历史SQL性能劣化问题。接下来从问题现象、成因拆解、绑定实操到长期治理按实际操作顺序展开。1. GPLAN执行计划跳变的成因拆解1.1 先搞懂GPLAN的选择依赖要理解计划为什么会“跳变”先得看优化器凭什么做选择。GaussDB优化器工作时手上有几类关键输入统计信息表的行数、列的唯一值数量NDV、列的直方图、空值率、平均长度等。统计信息越准确代价估算就越接近真实情况。系统参数GUC比如WorkMem大小影响Hash Join、Sort算子是走内存还是落盘、并发线程数影响并行度选择、enable_xxx系列开关比如enable_hashjoin、enable_nestloop等。SQL语句本身的形态Join条件、过滤条件、子查询写法、谓词下推情况等。环境数据缓存命中率、分区裁剪结果、当前资源负载等。GPLAN的产生就是基于上述输入做一次“静态动态混合”的代价寻优。所谓静态指的是SQL文本不同计划就可能不同所谓动态指的是同一句话的SQL在不同时间点拿到的统计信息不同计划也可能不同。计划跳变本质就是输入变了输出就跟着变了而这类变化并不总是往好的方向走。1.2 最常见的几类跳变诱因我实际操作中遇到的计划跳变基本可以归为下面几类统计信息更新导致的选择偏移。最典型的是批量更新统计信息比如执行了ANALYZE或者统计信息自动收集任务之前的计划可能依赖旧的NDV估算新统计信息下Join顺序被调整原本的Hash Join变成了NestLoop Join驱动表也换了整体代价瞬间爆炸。这类跳变最隐蔽因为统计信息更新本身是正常维护动作新计划出来后又没有足够多的样本验证。数据分布突变。比如表里某一天突然写入了一大批热点数据某个索引列的值域分布发生了倾斜。优化器根据直方图估算过滤条件的选择性时误认为某个过滤条件能过滤掉大量数据实际却只过滤掉很小一部分导致选错了访问路径。GUC参数调整引发全局影响。有时候DBA为了优化某条SQL改动了一个全局参数比如关闭了全表扫描enable_seqscanoff或者改大了并行度query_dop结果这条SQL确实变好了但其他SQL却遭了殃计划大面积跳变。全局参数的影响范围广一旦走偏排查成本非常高。数据库升级或Patch导致优化器行为变化。GaussDB版本迭代时优化器代价模型、算子估算公式都可能微调。升级后同一句话SQL的代价对比关系变了原本占优的计划可能变成次优。这是最麻烦的情况因为不光是数据层面的输入变了连优化器本身的“解题思路”都变了。1.3 跳变后的典型表现与确认方法业务侧的表现通常很直接某个SQL耗时突然上升CPU和IO升高数据库连接可能出现堆积。但从DBA视角不能仅凭“慢”就断言是计划跳变需要先确认慢日志里记录的新执行计划文本是否和历史基线不同。使用EXPLAIN或EXPLAIN VERBOSE脱离业务环境复现观察算子树层级、Join顺序、扫描方式的变化。对比前后计划的代价估算值看是否有明显异常比如估算返回行数比实际行数差了几个数量级。如果确认了计划确实变了且新计划明显劣化才进入下一步处理。需要注意的是这里一定不要立刻去动SQL文本优先考虑固定计划的手段因为SQL文本可能是业务代码生成的改动成本高还可能引入新问题。2. 定位问题SQL与抓取新旧计划的实操方法2.1 从慢日志和动态视图抓“问题SQL”GaussDB提供了多种方式定位慢SQL。最常见的是借助系统视图或管理工具查询历史执行信息找到耗时异常增大的SQL语句。实操中我一般会这样查通过PG_STAT_ACTIVITY或DV_SESSIONS看当前正在执行的、长时间不结束的会话拿到对应的query字段。通过历史会话视图查询过去一段时间内的异常SQL例如查看某时间段内执行时间排名靠前的SQL。如果开启了慢SQL日志或相关监控采集按时间维度拉出一段时间内execute_time显著上升的SQL片段。找到一个目标SQL后先不要急着分析计划先确认它是不是“之前快、现在慢”。如果之前慢现在快说明计划跳变反而改善了性能不需要处理只有性能劣化才需要干预。判断“之前快”可以结合监控历史或者直接用相同参数和条件下重新执行对比。2.2 复现新旧计划并对比差异拿到问题SQL后下一步是复现新旧计划并做差异化对比。操作方法如下先获取当前实际执行的计划。如果SQL已经在跑可以用EXPLAIN或EXPLAIN VERBOSE在测试环境里模拟执行计划注意加上实际的绑定参数值尤其是分页、过滤条件的边界值。找到历史计划的来源。如果之前保存过执行计划记录直接从历史记录中截取如果没有就把问题SQL放到灰度环境通过修改GUC参数如关闭某个Join算子开关来猜测可能的历史路径或者直接用旧统计信息快照做计划验证。将新旧计划输出放到一起对比关注三要素Join顺序哪张表是驱动表哪张表是被驱动表。Join算法NestLoop、Hash Join还是Merge Join。扫描方式全表扫描、索引扫描Index Scan、位图扫描Bitmap Scan等。如果新旧计划的Join顺序完全倒过来了且被驱动表的数据量很大性能劣化几乎是必然的。此时就明确了需要想办法让优化器回到旧计划路径上。2.3 判断新计划“到底坏在哪里”这一步很多人会跳过直接去绑计划结果绑出来的旧计划在当前环境下也不一定最优。正确做法是先搞清楚“新计划差在哪”是驱动表选择错误比如驱动表过小却被当成了大表导致外层循环次数膨胀。是索引选择错误比如过滤条件选择性很高但优化器没走索引反而全表扫描。是分布式场景下数据重分布代价被低估比如Join key的数据分布倾斜重分布后单节点数据量过大。我一般会用真实数据量做一次“干跑”验证把EXPLAIN里的估算行数rows和实际执行后反馈的行数actual rows对比看误差产生的位置。找到误差最大的节点就找到了代价估算失真的根源。这一步对后续构造PATCH的hint非常关键因为你不是盲目“让旧计划回来”而是“让优化器走正确路径”。3. SQLPATCH 绑定执行计划的完整实操3.1 SQLPATCH的适用场景与不适用场景先泼一盆冷水SQLPATCH不是万能的它适合的是“单条SQL的计划固定”需求不适合全局性优化。具体判断标准适合用SQLPATCH的场景SQL文本相对固定不确定参数通过绑定变量传入。确认计划跳变是优化器估算失误导致的手动指定更优路径后性能显著提升。业务代码无法快速修改需要短时间内止血。SQL属于非高频但影响大的关键路径比如定时任务里的核心报表查询。不适合用SQLPATCH的场景业务SQL本身写法极烂比如缺少Join条件、过度使用OR条件、大量子查询嵌套等。这种情况绑计划只能缓解根治必须改SQL。计划跳变是数据大幅增长导致的比如表行数从100万涨到1亿任何计划都可能劣化此时需要的是分区、归档或SQL重写。SQL使用了动态SQL文本每次都不一样PATCH无法匹配。另外要注意同一条SQL绑定了PATCH后如果未来又做了大版本升级、统计信息又发生巨大变化PATCH仍会生效但这不代表它永远是最优的。固定计划是一把双刃剑它能阻断“跳变”也可能阻断“优化”。3.2 查看已有SQLPATCH与系统表在动手创建之前先确认一下库里有没有类似操作痕迹避免重复绑定。GaussDB中SQLPATCH相关的信息存储在系统表中可以用如下方式查看SELECT * FROM gs_sql_patch;该表记录了已经创建的SQLPATCH名称、对应SQL ID或正则表达式、绑定的hint、当前状态enable/disable、创建时间等信息。如果库里已经有同名PATCH需要先清理或改名避免冲突。3.3 构造绑定计划用的hintSQLPATCH绑定计划的核心就是写hint。GaussDB的hint能力覆盖了传统优化器常用项包括表关联顺序、扫描方式、Join算法等。先整理几个最常用的hint形式指定表关联顺序Leading((table1 table2))括号内从左到右表示驱动顺序。指定扫描方式IndexScan(table index_name)或NoSeqScan(table)强制走/不走索引。指定Join算法HashJoin(table1 table2)、NestLoop(table1 table2)、MergeJoin(table1 table2)。构造hint之前先用EXPLAIN去试验。方法是在原SQL前面加上hint语句例如EXPLAIN SELECT /* Leading((t2 t1)) HashJoin(t1 t2) */ ... FROM t1 JOIN t2 ON t1.id t2.id WHERE t1.status A;这里Leading((t2 t1))的意思是用t2作驱动表t1被驱动HashJoin(t1 t2)强制两者走Hash Join。要注意的是hint中表的名称需要和SQL中使用的表名或别名保持一致如果SQL里用了别名但hint里写表原名会导致hint失效。如果SQL特别复杂有子查询、视图嵌套的情况构造hint就要更谨慎可能需要给子查询单独指定alias。另外GaussDB中还支持参数形式来影响计划选择范围。比如通过SET修改enable_xxx系列参数来临时模拟某种计划形态但SQLPATCH里不能直接塞SET只能通过hint做局部干预。如果确实想全局关闭某个算子更适合通过GUC参数去调但那就不属于SQLPATCH的范畴了。3.4 创建SQLPATCH并绑定执行计划确认hint能生成目标计划后就可以正式创建SQLPATCH了。语法大致如下CREATE SQLPATCH patch_name ON sql_id USING hint_name;不过实际使用中不同版本的接口可能存在差异有些版本支持直接基于SQL文本正则匹配有些支持基于SQL ID。我推荐优先用SQL ID方式因为SQL ID是优化器生成的规范化表达匹配更稳定不受空格、大小写、注释干扰。所谓SQL ID是GaussDB内部对SQL模板化后计算得到的一个唯一标识。你可以通过动态视图或工具获取目标SQL的SQL ID。拿到SQL ID后再指定SQLPATCH要绑定的hint文本。完整操作脚本大致长这样-- 创建SQLPATCH CREATE SQLPATCH my_patch_001 ON sql_id xxx USING Leading((t2 t1)) HashJoin(t1 t2);创建成功后建议立即验证效果。回业务环境或测试环境重新执行原SQL看执行计划是否已经按hint生成并对比耗时和资源消耗。这里特别提醒创建SQLPATCH后原SQL的“原始执行计划”不再作为参考优化器会优先采纳SQLPATCH指定的方案。一定要先在灰度或测试环境验证再上生产切忌直接在核心系统上对一个正在跑的SQL做绑定万一hint构造不当可能导致计划比原来更差。3.5 SQLPATCH的管理启用、禁用、删除SQLPATCH创建后不是一成不变的它有自己的生命周期管理命令。常用操作如下查看当前所有PATCHSELECT * FROM gs_sql_patch;禁用某个PATCHALTER SQLPATCH patch_name DISABLE;启用某个PATCHALTER SQLPATCH patch_name ENABLE;删除某个PATCHDROP SQLPATCH patch_name;禁用和删除的区别在于禁用只是让PATCH暂时不生效但保留定义后续可以快速重新启用删除则是彻底移除。实际运维中我一般先禁用而不是删除因为你不确定下个统计周期会不会又跳变。观察几个业务周期后如果确定不需要了再删除清理保持系统整洁。另外还要注意PATCH和SQL的关系是一对多还是多对一。GaussDB允许一个PATCH绑定多个SQL如果基于共同特征匹配也允许多个PATCH绑定同一SQL命名不同但同名会覆盖或报错。为了避免混乱命名规范建议包含业务标识、创建日期、问题类型比如sqlpatch_order_stat_20250610这种可读性强的名字。3.6 用实际案例串一遍完整流程下面用一个简化但完整的案例演示全过程。场景某订单查询SQL每天跑一次汇总报表。SQL文本类似SELECT o.order_id, c.customer_name, o.order_amount FROM orders o JOIN customers c ON o.customer_id c.customer_id WHERE o.order_date 2025-06-01 AND o.order_date 2025-06-02 AND c.customer_level VIP;某天早高峰这条SQL从300ms变成15秒。定位过程从慢日志拿到SQL文本确认是新计划导致。EXPLAIN VERBOSE在新统计信息下执行发现orders表300万行customers表只有5000行但优化器把orders当驱动表先扫全表再和customers做NestedLoop导致orders每一条都要去customers里找匹配循环次数巨大。手工用hint试验发现Leading((customers orders)) HashJoin(orders customers)能稳定走Hash Join且用customers做驱动表估算代价只有原来的1/10。查看SQL ID后创建SQLPATCH绑定该hint。回生产执行原SQL耗时恢复到350ms左右验证通过。这个案例非常典型不是计划本身错了是统计信息更新导致估算关系颠倒。SQLPATCH在这里起到的就是“拨正优化器视线”的作用。4. 常见问题与排查技巧实录4.1 SQLPATCH创建成功了却不生效这是最常遇到的问题。创建SQLPATCH后执行原SQLEXPLAIN显示的计划还是老样子完全没有变化。原因通常有以下几种SQL ID匹配偏差。业务框架拼接SQL时的空格、换行、注释处理方式和创建PATCH时使用的一致吗如果不一致SQL ID可能不同PATCH对不上目标SQL。hint拼写错误或对象名不匹配。比如SQL里用的别名是t1hint里写了table1优化器认为hint无效直接忽略。PATCH处于DISABLED状态。创建后不小心被禁用或者系统表里状态是disabled。排查方法先用SELECT * FROM gs_sql_patch;确认PATCH存在且是enable状态再核对SQL ID是否一致最后用EXPLAIN查看计划中的hint相关提示确认hint是否被采纳。实际上很多优化器会在计划输出中标注hint是否被忽略可以重点查看这类信息。4.2 绑定后计划反而更差了有可能你精心构造的hint在新计划里还不如老计划。常见原因过度指定。同时指定了Leading和HashJoin但实际最优路径可能是NestLoop加索引扫描。hint把优化器完全锁死失去了自适应能力。数据分布变化后hint不再适用。例如绑定的驱动表在大数据量下本身就成了瓶颈Hash Join由于内存限制大量落盘反而比NestLoop更慢。遇到这种情况先禁用PATCHALTER SQLPATCH ... DISABLE;回到原计划然后重新用EXPLAIN验证多种hint组合找到更优的方案再重新绑定。记住SQLPATCH的黄金法则是“只锁关键环节非关键环节放权给优化器”不要一股脑把能指定的都指定了。4.3 PATCH影响了其他SQL如果创建PATCH时基于SQL文本正则匹配可能误伤其他同构SQL。例如用正则匹配SELECT * FROM orders的片段结果所有包含该片段的SQL都被套用了hint。尤其是同一张表参与多个业务场景时这种影响很难发现。避免方法优先用SQL ID精确匹配如果必须用文本匹配正则尽量收窄范围加上唯一的业务标识。另外建议每次PATCH创建后关注一下执行计划数量是否出现异常集中以及有没有非目标SQL的耗时上升。4.4 SQL文本规范化问题GaussDB在生成SQL ID时会对文本做规范化处理比如把具体的参数值替换为占位符。好处是你用绑定变量的SQL无论传入什么值SQL ID都不变PATCH可以稳定匹配。但如果你使用的是文本拼接的SQL没有绑定变量每次参数不同SQL ID都可能不同PATCH就很难覆盖。这种情况下建议推动业务侧改成绑定变量写法既有利于PATCH匹配也有利于缓存复用。即便暂时无法改代码也可以考虑基于文本正则匹配的方式创建PATCH但需要承担误匹配风险。4.5 什么时候不该用SQLPATCH最后聊聊边界。SQLPATCH是“止血”工具不是“根治”手段。以下情况我尽量不用SQL本身质量极低。比如在WHERE条件中对索引列做了函数运算导致索引失效。这种情况绑计划只能指定走哪一种全表扫描性能天花板摆在那不如改写SQL来根治。表结构频繁变化。如果表经常加索引、删除索引PATCH指定的索引可能突然不存在执行直接报错。数据量正在快速膨胀。今天绑定的计划适合100万行下周数据涨到1000万行这个计划大概率会劣化。这种情况下更建议优先做数据生命周期管理或分区设计。SQLPATCH真正适合的是“优化器因为统计信息波动选错路径但SQL本身和数据量级没有大问题”的场景。用它锁定正确计划同时并行推动统计信息维护、SQL优化才能长期稳定。5. 顺着SQLPATCH扩展的运维思考5.1 SQLPATCH与统计信息维护的配合固定计划是临时手段维护统计信息才是长期解决方案。如果统计信息新鲜准确优化器自己就能选对计划根本不需要手动绑定。因此我在实践中通常把SQLPATCH当作“过渡方案”先绑计划恢复业务再排查统计信息为何失真比如是不是自动收集频率太低、采样率太小、或者直方图桶数不足。针对性处理后如果统计信息连续多个周期保持稳定再评估是否可以解除PATCH。一个常见误区是绑了SQLPATCH之后就不管统计信息了结果所有SQL都靠PATCH维护数据库变成了“手工优化器”。这种状态非常脆弱一旦PATCH被误删或版本升级导致hint失效问题会集中爆发。5.2 版本升级和PATCH的兼容性GaussDB版本升级通常会带来优化器能力变化也可能影响SQLPATCH语义。升级之前建议先导出当前所有SQLPATCH定义在测试环境做一轮计划复现验证确认升级后PATCH依然能生成目标计划。如果升级后优化器本身已经能选对正确路径可以考虑清掉旧PATCH让系统自动决策。另外要注意系统表结构在升级后可能变化查看PATCH的SQL语句需要适配新版本字段。升级前先读一遍新版本的发布说明重点看优化器和SQLPATCH相关内容的变更说明能少踩不少坑。5.3 案例外的经验总结从我这几年跟执行计划跳变的“战斗”经验看有几个习惯很值得养成保存SQL执行计划基线。定期把核心SQL的计划导出存档出问题时直接拿到历史基线对比节省大量“找老计划”的时间。变更台账不能丢。任何统计信息手动更新、GUC参数调整、版本变更都要记录时间点和影响范围这是排查计划跳变的第一手线索。构造hint时做减法。能从单一hint入手就不一次塞三个以上每加一个hint都单独验证一次性能避免多个hint互相干扰导致劣化。优先处理高频慢SQL。绑定PATCH不是越多越好库里有两三个必要PATCH就够了超过十个就要反思SQL质量和统计信息维护是否出了问题。我在实际使用中的体会是SQLPATCH是用“人工经验”兜底“优化器盲区”的利器但它非常考验DBA对计划、数据形态和业务特征的综合理解。平时多看执行计划差异、多积累各类场景下的最优hint组合到了真正需要绑计划的时候才能做到又快又准。最后再分享一个小技巧测试hint的时候不要在生产环境反复执行大查询尽量先在测试环境用同一份统计信息快照模拟。如果条件不允许就用EXPLAIN时加上真实执行关键节点的统计输出观察估算行数和实际行数的偏差从偏差最大的算子开始设计hint通常能快速找到优化空间。希望这套方法论能帮你在GaussDB计划跳变的坑里少走点弯路。