
凌晨两点半告警群里的消息一条接一条。业务方说某个核心接口的P99延迟从 8ms 直接飙到 1.2s翻了两个数量级。我登录 GaussDB 数据库一查发现罪魁祸首是一条跑了好几个月的 SQL它的执行计划整个变了——之前一直走索引现在改成全表扫描。这种场景在数据库运维里太经典了执行计划跳变性能说崩就崩。而这一次我用来终结这类问题的手段是 GaussDB 的 SQLPATCH——直接把执行计划“绑死”。这篇文章就完整梳理一遍从 GPLAN 跳变的原理到 SQLPATCH 的实操细节给同样踩过这个坑的朋友一份能直接抄作业的记录。我当时处理的这个问题里GPLAN 是执行计划跳变的直接载体。GaussDB 中一条 SQL 经过优化器解析、生成计划后会把计划缓存下来供后续执行复用这个被缓存、被复用的计划就是 GPLAN。正常情况下这是好事省掉了重复硬解析的开销。但问题在于缓存的计划在某些条件下会被判定失效或者优化器在重新生成时选出了另一条成本更低的路径于是执行计划就“跳”了——SQL 文本一个字没变底层行为完全变了。我打算从四个层面把这件事讲透执行计划为什么跳、GPLAN 在其中扮演什么角色、SQLPATCH 到底是怎么把计划绑住的以及真正在 GaussDB 里做一次计划绑定的完整操作流程和坑点。1. 执行计划跳变的现场现象、影响与根因1.1 你遇到的可能是哪种跳变执行计划跳变表面上看起来都一样SQL 没变性能变了。但实际拆开看至少有三种不完全一样的情况。第一种是“索引路径丢失”。比如优化器原先选择了一个选择性很好的二级索引某个时刻开始突然改走全表扫描。这种情况最常见一般和统计信息更新有关——统计信息刷新后优化器重新估算行数发现走索引的成本比走全表还高于是换路。但统计数据是采样估算的有时候估算误差会引导优化器做出反直觉的选择。第二种是“连接顺序变化”。多表关联的 SQL表连接的先后顺序不同中间结果集大小完全不同。Nested Loop、Hash Join、Merge Join 三种连接方式的选择也属于这一类。这种跳变往往更隐蔽因为执行计划看起来每张表都走了索引只是连接顺序换了但性能差异非常大。第三种是“并行度或资源参数变化”。比如数据库的 work_mem内存排序/哈希区被调整了优化器认为可以用更激进的 Hash Join 方案或者并行度参数变了计划形态完全重构。这种跳变通常出现在数据库参数调整或主机资源变化之后。不管是哪一种一旦发生在高并发、低延迟要求的核心链路上结果就是业务受损、告警轰炸。在 GaussDB 里这些跳变最终都会反映在 GPLAN 的更新上——当旧的缓存计划失效新的计划生成执行就开始按新路径跑了。1.2 为什么执行计划会“自己变”要理解跳变得先理解优化器的“决策依据”。优化器不是靠猜而是靠一套成本模型它会把 SQL 的每个执行路径换算成成本值成本低的优先选。而成本计算依赖三类输入统计信息表的行数、列的 distinct 值数量、直方图分布、索引的元数据。系统参数内存配置、并行度、扫描方式开关等。环境信息当前资源负载、会话参数、优化器版本。这三类输入里任何一类发生变化都有可能导致同一个 SQL 的成本排名发生变化。举个例子一张订单表有 5000 万行status 列上有普通索引。优化器通过直方图知道 statusPAID 的行数大约 5 万行于是走索引成本很低。某天统计信息自动收集任务执行后采样出了偏差估算 statusPAID 的行数变成了 200 万行。优化器一算走索引要回表 200 万次不如直接全表扫。于是计划就变了。这种因为统计信息采样误差导致的计划跳变非常隐蔽——你说统计信息有错吧它确实刚更新过你说没错吧它把好计划带沟里去了。我个人的经验是处理执行计划跳变时先不要急着去调统计信息因为统计信息收集有随机性你今天手工收集一次可能恢复正常明天自动收集一次又变回去。这种“薛定谔的稳定性”在核心业务上是不可接受的。正确做法是先冻结执行计划把性能稳住再回头慢慢排查到底哪条统计信息不靠谱。1.3 GPLAN 缓存在这里面扮演的角色GaussDB 的 GPLAN 机制本质上是一个“计划缓存池”。一条 SQL 第一次硬解析后生成的执行计划会被缓存起来后续相同的 SQL文本完全一致的直接复用缓存计划跳过优化过程。这带来两个好处一是降低 CPU 消耗二是减少解析锁竞争。但代价是缓存的计划可能不是当前最优计划。GPLAN 的失效机制一般有三种触发条件表的统计信息发生变化且变化幅度超过阈值缓存计划被标记为需要重新生成。对象定义变化比如表结构变更、索引被删除旧计划依赖的路径不存在了。内存压力导致缓存淘汰比如大量新 SQL 涌入旧的 GPLAN 被 LRU最近最少使用算法挤出去之后这条 SQL 必须重新做硬解析。这里面最尴尬的是第三种——LRU 淘汰。它和业务波动挂钩白天业务低峰期缓存池里堆积了大量其他 SQL 的计划把核心 SQL 的计划挤掉了晚上业务高峰一来核心 SQL 重新硬解析解析时统计信息恰好和白天不同生成了一个更差的计划于是凌晨的告警就来了。我在处理现场问题时第一步永远是确认“当前执行计划长什么样”。GaussDB 里可以直接通过 EXPLAIN 查看也可以用相关视图查缓存计划的状态。如果发现计划确实变了而且是一个更差的形态那接下来的核心动作就是如何让它不再变。这时候 SQLPATCH 就该上场了。2. SQLPATCH 是什么绑定执行计划的正确姿势2.1 SQLPATCH 的原理从补丁到执行的完整链路SQLPATCH 是 GaussDB 提供的一种轻量级 SQL 执行计划绑定工具。你可以把它理解成给 SQL 打一个“补丁”——在 SQL 文本不变的前提下通过数据库内置的匹配机制识别出这条 SQL然后往它的执行计划上注入预先定义好的 hint 或 outline强制优化器按指定路径生成计划。它的工作流程大致是优化器解析 SQL生成规范化/哈希特征。拿这个特征去匹配已创建的 SQLPATCH。如果命中就把 patch 里定义的 hint 集合并入 SQL 的优化过程。优化器基于 hint 约束重新生成计划并执行。关键点在于第三步。SQLPATCH 并不是把“旧计划”原样保存再回放而是把“计划形态”转译成优化器能理解的一组 hint比如强制走索引、强制走嵌套循环、强制指定连接顺序。优化器收到这些 hint 后只能在限定范围内选计划。这样你就把“计划可能会变”的窗口彻底关死了。打个比方优化器本来是一个自主性很强的司机每天自己选路走GPLAN 是他的导航缓存。有一天他发现一条新路觉得更快结果把你带进了拥堵路段——这就是跳变。SQLPATCH 就像是你在导航里把这条路线“固定收藏”了并且锁死了不走其他路线。司机还是那个司机但他不再有自由选路的权力。2.2 与其他方案对比为什么选 SQLPATCH 而不是改写 SQL遇到执行计划跳变可选的手段其实不止 SQLPATCH。我把常见方案排个队逐个说明优劣改写 SQL通过加 hint、调整 SQL 结构来稳定计划。优点是直观缺点是必须改应用代码涉及发布流程。核心系统的 SQL 要走变更审批等发布完成业务早就被投诉淹没了。而且很多 SQL 是 ORM 框架自动生成的你根本没法在代码里塞 hint。调整统计信息手工收集、固定统计信息。优点是“正常手段”缺点是不稳定——自动收集任务随时可能再次覆盖你的手工调整治标不治本。修改优化器参数把某个参数关掉或调低比如禁用某种 join 方式。优点是全局生效缺点是无差别打击——会影响库内所有 SQL有些本来走得好好的计划也被牵连。使用 SQLPATCH直接对单条 SQL 生效不改应用不影响其他 SQL可以随时创建、启用、禁用、删除。从运维视角看SQLPATCH 是性价比最高的方案特别是针对“出问题时要立刻止血”的场景。它能做到分钟级介入甚至不需要重启数据库不需要应用发版。2.3 SQLPATCH 的适用边界与限制SQLPATCH 也并非万能。我在实际使用中总结出它的几个边界它匹配的是 SQL 文本特征。如果应用每次生成 SQL 时带上了不同的动态值并且没有归一化匹配条件可能无法稳定命中。但 GaussDB 的做法一般是先对 SQL 做归一化或提取特征值再匹配对于普通带参数的 SQL 基本都能覆盖。要注意的是如果 SQL 过长或过于复杂需要确认功能限制内支持的语句长度。它依赖 hint 对优化器的约束能力。对于极度复杂的 SQL比如几十张表连接、多层子查询你可能很难手工写出一整套完整的 hint。这时候更合适的做法是“只锁定最关键的路径”比如只强制走某个索引、只强制某种 join 方式其余部分仍然交给优化器。它需要考虑生命周期。业务逻辑变更后这张 SQL 可能不再被使用patch 却还在库里躺着。长期不清理的 patch 会变成“技术债”需要定期审计。所以我的建议是SQLPATCH 解决的是“当下立刻止血”和“中期稳定运行”这两个诉求。真正长久的解法还是要在根因层面梳理统计信息维护策略、SQL 写法规范、索引设计合理性。3. 实操全流程从定位到绑定再到验证3.1 第一步锁定目标 SQL 并定位跳变前的计划形态这一步是整个流程的地基。你首先要确认三件事哪条 SQL 是罪魁祸首。它当前在执行什么计划劣化后的计划。它理想的计划形态是什么跳变前的计划。前两件事可以从告警平台、慢查询日志或 GaussDB 的性能视图里拿到 SQL 文本和 plan。如果是通过抓取活跃会话拿到的 SQL注意确认它的 SQL_ID 或归一化后的特征串这个后面创建 patch 时要用。第三件事最考验功力。你手上不一定有跳变前的完整执行计划但通常有几个线索历史 AWR/性能报告里的快照记录了过去某时段的 TOP SQL 和它的 plan。你有印象这条 SQL 过去只要几十毫秒而你通过评估表数据分布、索引情况能推算出一个合理的执行路径。你可以在测试环境把统计信息回滚到跳变前的状态重新 EXPLAIN 得到旧计划。我在实际操作中如果数据库开启了相关计划记录能力会先把当前 SQL 的完整 plan 抓出来。即便没有历史 plan也可以基于业务特征手工推断比如订单查询按用户 ID 过滤行数很小合理计划就是“先按用户 ID 走索引再嵌套循环关联其他表”。这里有个经验你最终需要的不是一段“看起来很好”的计划文本而是能翻译成 hint 的“计划骨架”。也就是说你得清楚地知道目标表应该走哪个索引。表连接应该是什么顺序。每一层连接应该用 Nested Loop、Hash Join 还是 Merge Join。是否要禁止全表扫描、是否要限制并行度。带着这几个信息下一步才有的放矢。3.2 第二步生成并验证期望的执行计划我习惯先在测试环境或同一台库上用加 hint 的方式把计划“复现”出来确认我期望的路径确实可行、性能确实比当前计划好。这一步很多人会跳过但跳过的后果是你凭感觉写的 hint 可能生成一个根本不存在的路径创建 patch 后 SQL 直接报错或退回原样。例如我有这么一条 SQLSELECT o.order_id, o.amount, c.customer_name FROM orders o JOIN customers c ON o.customer_id c.customer_id WHERE o.order_status PAID AND o.pay_time 2025-01-01 00:00:00;我希望它走orders_pay_time_idx索引然后按 customer_id 对 customers 做嵌套循环。那我先在测试环境里验证EXPLAIN (ANALYZE, BUFFERS) SELECT /* INDEX(orders orders_pay_time_idx) LEADING(orders customers) USE_NL(customers) */ o.order_id, o.amount, c.customer_name FROM orders o JOIN customers c ON o.customer_id c.customer_id WHERE o.order_status PAID AND o.pay_time 2025-01-01 00:00:00;如果执行计划里出现了Index Scan using orders_pay_time_idx和Nested Loop并且耗时符合预期说明这组 hint 是有效的可以翻译成 patch 里的 outline。如果 hint 没生效可能是别名不对、索引名打错了或者统计信息严重失真导致优化器认为这个路径不可行。排查方式是把 hint 逐步删减定位到哪一条 hint 导致计划报错或者不被采纳。这一步务必有产出一组经过验证的、可复现最优路径的 hint。3.3 第三步在 GaussDB 中创建 SQLPATCH创建 SQLPATCH 的命令逻辑上分为两步先确认 SQL 的特征比如 SQL_ID再执行 CREATE SQLPATCH。以 GaussDB 为例手工创建 patch 的常见写法如下不同版本语法略有差异使用时请以你的版本官方手册为准CREATE SQLPATCH patch_order_20250101 ON SQL_ID 对应的SQL_ID USING OUTLINE ( INDEX(orders orders_pay_time_idx), LEADING(orders customers), USE_NL(customers) );如果你的数据库不支持通过 SQL_ID 直接匹配也可以通过原始 SQL 文本来关联类似CREATE SQLPATCH patch_order_20250101 ON SELECT o.order_id ... USING OUTLINE ( INDEX(orders orders_pay_time_idx), LEADING(orders customers), USE_NL(customers) );创建成功后可以通过系统视图查询 patch 的状态。GaussDB 一般提供类似pg_sql_patch的视图来查看所有已创建的 patch里面包含 patch 名称、创建时间、启停状态、匹配的 SQL 特征、outline 内容等字段。我每次创建完都会立刻查一眼确认它已经进入启用状态。要特别提醒的是SQLPATCH 的名称不要随便起最好包含库名、业务名、日期和用途。我在生产环境见过有人起名叫patch1、patch2三个月后根本分不清哪个 patch 是干嘛的也不敢随便删。命名规范在事后审计时能救你一命。3.4 第四步验证绑定效果并对比性能收益patch 创建完成不代表结束真正的验证才刚刚开始。第一步验证“是否命中”。先在当前会话执行这条 SQL用 EXPLAIN 看它的计划形态。如果计划已经变成你期望的路径说明 patch 生效。如果还是老计划说明匹配没对上需要回到上一步排查 SQL_ID 或 SQL 特征是否匹配正确。第二步验证“性能恢复”。在生产环境执行一次 SQL测量实际耗时和逻辑读。对比跳变后的数据确认性能已经恢复到可接受水平。第三步验证“稳定性”。不要只测一次就收工。我会在 patch 生效后持续观察一段时间比如 30 分钟到 1 小时确认不仅单次执行快高峰并发下也不回退。这个阶段重点看两个指标这条 SQL 的平均耗时有没有重新抬头、有没有出现新的 plan 变化告警。我在一次实际处理中patch 创建后单次执行从 1.2s 降到了 9ms但由于原 SQL 在高并发下本身有锁竞争的问题高峰期还是偶发几十毫秒的抖动。这就是另一个层面的问题需要和业务方沟通 SQL 逻辑层面能否优化但至少执行计划跳变这个风险已经彻底关掉了。3.5 第五步灰度范围控制与回滚预案SQLPATCH 创建后默认是全量会话生效的但实际生产环境里我更推荐先小范围灰度。部分版本或场景下可以结合会话级参数或开关来控制 patch 的启用范围。如果版本不支持灰度那就选择业务低峰期创建创建后立刻验证。同时准备好回滚预案——我通常把创建 patch 的 SQL 和删除 patch 的 SQL 一起写成一对脚本放到同一个变更目录下。一旦发生“绑定后比绑定前更差”的极端情况可以一键回滚DROP SQLPATCH patch_order_20250101;删除后优化器会回到自由选择状态缓存计划也会重新生成。这个回滚动作是秒级的这也是 SQLPATCH 相比修改应用代码最大的优势——改代码要发布回滚也要发布而数据库侧的 patch 随时可以进退。4. 常见问题与排查技巧实录4.1 patch 创建报错或权限不足我见过最多的问题是权限。创建 SQLPATCH 通常需要数据库管理员权限或者被显式授权了相关权限的开发账号。如果业务账号权限不足创建时直接报权限错误。解决办法有两个方向一是用管理员账号执行建 patch二是给业务账号最小必要授权。我强烈建议后者因为 DBA 的账号不应该长时间躺在开发手里否则审计的时候说不清是谁动了线上执行计划。另外如果出现语法报错先确认你的版本支持的 SQLPATCH 语法格式。GaussDB 不同版本之间CREATE SQLPATCH 的参数顺序、USING OUTLINE 和 USING HINT 的支持程度是有差异的。遇到这种情况最好的做法不是翻旧笔记而是直接看当前版本的官方文档找出对应的语法模板。4.2 patch 创建成功但没生效这是最让人头疼的坑。patch 创建成功了视图里状态也是正常的但 SQL 执行计划一点没变。排查思路按顺序走确认 SQL 文本的匹配方式。你用的是 SQL_ID 匹配还是全文匹配应用执行的 SQL 里如果有注释、空格、大小写差异但是归一化后 SQL_ID 是一致的那用 SQL_ID 匹配大概率能命中。如果用的是全文匹配SQL 里一个空格的变化都可能导致匹配失败。确认缓存计划是否被重新生成。如果这条 SQL 在 GPLAN 里的缓存计划还没失效执行时可能直接走了旧缓存压根没触发 patch 匹配逻辑。这种场景下把对应的缓存计划淘汰一下或者等它自然失效即可。确认 patch 里的 hint 没有导致优化器“宁可退回原计划”。优化器的原则是 hint 必须被满足但如果 hint 本身互相冲突或者指定的索引已经不存在优化器在无法满足 hint 的情况下会忽略整组 outline退回常规优化。这种情况常见于索引被删了但是 patch 还引用着、多表 JOIN 时别名的对应关系写错了。我自己踩过最深的一个坑是建 patch 的时候outline 里写了两个 hint逻辑上本身是对的但在那个版本的 GaussDB 中这两个 hint 一起使用时不被优化器接受导致整个 outline 被丢弃。后来我把其中一条 hint 拆出来单独建了一个只有单条 hint 的 patch 做测试发现能生效再把另一条加上才找到问题。排查 patch 未生效时最直接的办法是先建一个只包含一条最小 hint 的 patch验证匹配链路是通的再逐步把完整 hint 加回去。这个方法能快速区分是“匹配问题”还是“hint 冲突问题”。4.3 patch 生效了但执行计划仍不稳有一类情况比较特殊patch 生效了计划也确实走到了指定的索引但性能还是不稳定。这时候不要急着骂 patch 没用而是要想清楚一个事性能瓶颈可能已经不是执行计划形态而是资源竞争或数据分布本身。比如我处理过一个案例SQL 通过 patch 固定走了正确的索引单次执行 5ms但并发 200 个会话时这 5ms 放大到了 300ms。原因是业务 SQL 里对同一个热点行做了频繁更新产生了行锁竞争。这种情况下执行计划再怎么绑都解决不了锁等待本质上是应用逻辑设计问题。还有一种情况是数据分布剧烈变化。比如一张表从 100 万行涨到了 2 亿行索引的高度变了回表成本肉眼可见地上升。patch 绑定的计划形态没变但执行耗时的绝对值就是压不下来。这个时候要回到根因上处理比如做分区裁剪、加覆盖索引、归档历史数据。4.4 影响面控制与定期审计最后说点运维层面的建议。第一建立 patch 台账。每创建一个 SQLPATCH同步记录创建时间、操作人、目标 SQL 的 SQL_ID、跳变原因、patch 的 outline 内容、预计保留周期。这张表是后续审计和治理的底账。第二定期清理失效 patch。业务下线、SQL 不再被调用、索引变更导致 outline 无法满足这些场景里 patch 都变成了“无效资产”。长期堆积的 patch 不仅干扰排查还可能在其他业务改造时产生意想不到的干扰。我一般每季度拉一次全量 patch 列表逐个确认是否还需要保留。第三把 patch 看作“临时措施”而不是“永久方案”。它立竿见影但不解决根因。正确的闭环是先建 patch 止血然后分析统计信息维护策略、评估索引设计、优化 SQL 写法等根因修复且验证稳定后再择机删除 patch让优化器重新接管计划选择。写在最后的实操体会做数据库运维这些年我越来越觉得执行计划跳变这件事本质上不是“数据库坏了”而是优化器在信息不完整的情况下反复试错。SQLPATCH 这个工具最值得称赞的一点是它把“人”的判断力和优化器的自动决策做了一个明确的边界划分——关键时刻人说了算。我现在处理线上 SQL 性能故障时已经形成了肌肉记忆先看执行计划再对比历史计划差异明显的话直接 SQLPATCH 止血绝不熬夜和统计信息较劲。等天亮业务低峰再慢慢查根因。这个流程帮我少熬了好几个通宵也帮业务省下了大量损失。如果你也在 GaussDB 上遇到执行计划跳变的困扰希望这篇记录能让你少走一些弯路。