凌晨两点半,告警群里的消息一条接一条。业务方说某个核心接口的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 列上有普通索引。优化器通过直方图知道 status='PAID' 的行数大约 5 万行,于是走索引成本很低。某天统计信息自动收集任务执行后,采样出了偏差,估算 status='PAID' 的行数变成了 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 直接报错或退回原样。
例如我有这么一条 SQL:
SELECT 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 上遇到执行计划跳变的困扰,希望这篇记录能让你少走一些弯路。