GaussDB慢SQL优化实战:从执行计划到SQL改写

📅 发布时间:2026/10/17 5:47:13
GaussDB慢SQL优化实战:从执行计划到SQL改写
这些年做数据库调优越来越多的场景开始跑在GaussDB上。尤其是金融、政企类的核心系统上线前基本都躲不开一轮SQL性能压测慢SQL、锁等待、资源飙高都是家常便饭。我自己就处理过不少GaussDB的SQL问题有的是索引没走对有的是统计信息过期有的是写法本身就有问题。今天拿一个比较典型的案例出来拆解一下从问题发现、执行计划分析到改写落地把完整链路捋一遍希望能给正在折腾GaussDB的同学一点参考。这个案例来自某业务系统的对账模块核心是一张流水表关联多张维度表做汇总查询。表面上看只是报表统计但跑起来就是慢单次查询能到几十秒直接影响下游批处理时效。适合正在做GaussDB性能调优、或者刚接触GaussDB想了解优化思路的开发和DBA看一看。下面直接进入正题。1. 案例背景与初步诊断1.1 业务场景与慢SQL现象这套系统里的核心表叫交易流水表以下简称为流水表记录每日的交易明细数据量大概在千万级。查询需求是从流水表里按用户、产品、渠道维度汇总交易金额和笔数关联用户信息表和产品分类表再做分组排序。SQL本身不复杂就是典型的星型模型聚合查询。最初的问题表现很直接每日凌晨的批处理任务里这条SQL执行时间从原来的几秒恶化到了40多秒。批处理任务整体链路有超时阈值单条SQL超过30秒就会告警所以这个问题必须尽快处理。我先做了最基础的排查看慢SQL日志、确认执行计划、查系统资源。慢SQL日志定位到这条SQL后我发现它的执行计划里出现了大表全表扫描而且关联顺序明显不合理。更奇怪的是过滤条件里有个时间字段但执行计划显示索引没有被使用。提示遇到SQL变慢第一件事不是去看代码逻辑而是先确认执行计划是否发生了非预期变化。很多时候不是SQL本身写错了而是统计信息或索引选择出了问题。1.2 环境信息与初始执行计划当时的数据库环境是GaussDB集群两节点部署并行度默认开启。流水表按时间做了范围分区每天一个分区。用户信息表一百万行左右产品分类表只有几十行。按说这种量级的关联查询不该这么慢问题大概率出在优化器选错执行路径上。我抓取了实际执行计划关键信息如下流水表过滤后的数据量约800万行走的是分区全扫描与用户信息表关联时选择了哈希连接但用户表被作为驱动表产品分类表虽然很小却在关联过程中被反复扫描最终排序操作消耗了近一半的执行时间。这个计划的问题很明显驱动表选择错误。优化器选了只有一百万行的用户表作为驱动表而流水表过滤后的800万行成了被驱动表。哈希连接时应该用小表驱动大表把大表作为被驱动表建哈希表才能减少内存和计算开销。现在的顺序完全反了导致中间结果集膨胀内存溢出后又落盘IO开销陡增。另外流水表的时间过滤条件明明可以走分区裁剪但执行计划显示扫描的分区数量比预期多。这说明统计信息可能没有及时更新或者分区剪枝的条件识别被某些隐式转换破坏了。2. 执行计划深度拆解与根因定位2.1 看懂GaussDB执行计划的关键指标GaussDB的执行计划和其他主流数据库的思路基本一致但也有自己的特点。拿到执行计划后我习惯先看几个关键指标rows优化器估算的返回行数这个值和实际行数的偏差直接反映统计信息准不准width每行数据的平均字节数可以用来估算中间结果集的内存消耗cost成本估算值用于对比不同执行路径算子类型Seq Scan、Index Scan、Hash Join、Nest Loop、Sort等并行算子GaussDB的并行计划会有Partial和Leader相关节点要关注并行度是否生效。回到这个案例我从执行计划里看到了几个明显信号。信号一是流水表过滤后的rows估算只有300万但实际数据量是800万偏差超过2倍。统计信息里的行数严重滞后这会让优化器低估大表参与join的代价从而倾向于先扫描大表。信号二是用户信息表在计划中作为驱动表但它的rows估算被严重放大到了2000万。我检查后发现查询里关联用户表时用了一个“用户名模糊查询”的条件这个条件本身选择性很差优化器无法用索引导致估算失真。信号三是排序在内存中完成的代价被低估。实际执行时由于中间结果集过大排序操作触发了临时表空间落盘IO等待时间直接拉满。2.2 为什么优化器会选错驱动表很多开发对“小表驱动大表”有误解以为只要物理行数少就行。实际上优化器判断的是“参与关联的结果集大小”而不是表本身大小。在这个案例里用户信息表确实只有一百万行但SQL里加了用户名的模糊条件优化器估算该条件会返回2000万行——这比实际全表还多明显是统计信息不准导致估算异常。流水表虽然有800万行但因为时间条件被正确裁剪后实际参与关联的可能只有800万行。两个结果集对比用户表估算2000万流水表估算300万优化器就认为用户表是“大表”所以把流水表当驱动表、用户表当被驱动表来建哈希表。方向整个反了。另外产品分类表只有几十行但它在join之后还有一次group by操作。优化器选择先关联再聚合而实际上可以先对流水表按维度字段聚合再关联维度表这样能大幅减少join的数据量。因为当时流水表时间过滤后还有大量重复的维度组合提前聚合可以把行数从800万降到几十万。所以这个SQL的核心问题不止一个统计信息过期、驱动表顺序错误、聚合时机不优、排序代价预估失真。要解决必须从统计信息刷新和SQL结构两个方向同时下手。注意不要光看执行计划里的cost数值还要结合rows的估算值是否接近真实值来判断优化器是否“被骗”了。统计信息过期是执行计划变差的头号原因。3. 优化方案设计与具体落地3.1 统计信息刷新与参数检查既然根因之一是统计信息不准第一步自然是做全库统计信息收集。GaussDB里有ANALYZE命令也可以调用系统函数进行采样。这个案例里我直接对涉及的几张表执行了全量统计信息收集耗时可以接受。执行完毕后重新查看执行计划驱动表顺序已经自动纠正了。流水表的时间过滤也正确裁剪到了目标分区。这说明优化器在拿到准确统计信息后自己就能选出合理路径。但也别指望统计信息一刷就万事大吉接下来还是要对SQL本身做优化进一步提升执行效率。统计信息刷完后我还检查了几个相关参数enable_hashjoin默认开启没问题enable_sort默认开启但排序代价高时可以尝试通过改写来规避query_dop并行度设置这个案例里默认值尚可但过高的并行度在小查询上反而会导致调度开销。这一步先不动参数只做统计信息刷新确认基线效果。实测单次执行时间从40多秒降到了18秒左右提升明显但仍有很大优化空间。3.2 SQL改写提前聚合与消除无效关联接下来是关键的SQL改写。原始SQL大致结构是SELECT u.user_id, p.product_name, c.channel_name, SUM(t.amount) AS total_amount, COUNT(*) AS cnt FROM transaction_flow t LEFT JOIN user_info u ON t.user_id u.user_id LEFT JOIN product_info p ON t.product_id p.product_id LEFT JOIN channel_info c ON t.channel_id c.channel_id WHERE t.trans_time 2024-06-01 00:00:00 AND t.trans_time 2024-07-01 00:00:00 AND u.user_name LIKE 张% GROUP BY u.user_id, p.product_name, c.channel_name ORDER BY total_amount DESC;这个写法存在几个问题在关联之后再聚合导致关联过程中处理了800万行明细数据LEFT JOIN后还带u.user_name LIKE 张%过滤这在语义上已经把LEFT JOIN变成了INNER JOIN但优化器可能需要额外处理分组字段里有u.user_id但分组结果其实并不需要用户ID只需要用户名称这里可以把分组粒度缩小到维度表的主键聚合后再排序排序的数据量就是分组后的行数而不是明细行数。改写思路是先对流水表按关联键做聚合再和维度表关联。同时把LIKE 张%条件下推到聚合前的子查询里或者改写成等值条件范围。改写后的SQL如下SELECT u.user_name, p.product_name, c.channel_name, t.total_amount, t.cnt FROM ( SELECT t.user_id, t.product_id, t.channel_id, SUM(t.amount) AS total_amount, COUNT(*) AS cnt FROM transaction_flow t WHERE t.trans_time 2024-06-01 00:00:00 AND t.trans_time 2024-07-01 00:00:00 GROUP BY t.user_id, t.product_id, t.channel_id ) t LEFT JOIN user_info u ON t.user_id u.user_id AND u.user_name LIKE 张% LEFT JOIN product_info p ON t.product_id p.product_id LEFT JOIN channel_info c ON t.channel_id c.channel_id ORDER BY t.total_amount DESC;改完后聚合操作直接在流水表分区扫描后进行只输出聚合后的行数这个案例里用户维度加产品渠道维度大概几千行然后再去关联三张维度表。关联的数据量少了三个数量级后续排序的开销也几乎可以忽略不计。3.3 索引优化与分区裁剪验证虽然SQL改写后大表扫描仍然是全分区扫描但GaussDB的分区裁剪已经生效扫描的分区数从原来的全部72个分区降到目标月份的约30个分区。不过流水表上的trans_time字段如果配合user_id做联合索引是否还有必要我评估了一下由于查询是批处理任务需要扫描大范围时间分区并聚合这种情况下走索引扫描不一定比分区全扫更快。因为聚合需要读取大量行顺序扫描配合分区并行反而更适合。所以我没有对流水表加冗余索引。真正需要加索引的是维度表的关联字段。user_info表的user_id是主键本身有索引product_info、channel_info也都有主键索引但这些主键索引在LEFT JOIN场景下能否被高效利用取决于优化器是否选择索引连接。查阅计划后我发现对于产品表和渠道表由于表很小优化器选择了哈希连接这没问题。对于user_info.user_name上的过滤条件原本的LIKE 张%可以用前缀索引或普通索引。GaussDB默认的B-tree索引对前匹配的LIKE条件有加速效果但前提是索引列的统计信息准确、成本估算较低。这个查询里u.user_name LIKE 张%选择性不高约20%的匹配率而且在提前聚合后这个过滤只是在聚合结果集上过滤本身数据量已经不大所以加不加索引影响有限。实际测试中不加索引的执行时间差异很小。实操心得不是所有查询都需要覆盖索引或联合索引。批处理大范围聚合查询分区裁剪并行扫描往往比索引扫描更合适。索引是为了减少随机IO顺序扫描在大批量读取时反而效率更高。4. 优化效果对比与稳定性验证4.1 执行时间与资源消耗变化优化完成后我对同一份数据、同一个时间段做了多轮对比测试结果如下测试项优化前统计信息刷新后SQL改写刷新后执行时间42.6秒18.3秒2.8秒CPU平均使用率68%55%41%最大内存使用8.2GB5.1GB1.2GB扫描行数约870万行约800万行约800万行聚合后仅数千行参与关联临时落盘有有无可以看到仅统计信息刷新就带来了50%以上的提升但真正的量变到质变还是SQL改写——执行时间从18秒直接降到2.8秒内存消耗也大幅下降。这体现了提前聚合的巨大威力。4.2 并发场景下的稳定性测试单条查询跑得快还不够批处理往往是多个任务并行跑。我模拟了5个并发任务同时执行这条SQL观察是否会出现锁等待或资源争抢。优化前并发执行时会出现明显的临时表空间竞争执行时间波动很大最慢的一个能超过1分钟。优化后由于排序和哈希的内存消耗大减并发执行时各个任务基本稳定在3秒左右波动范围很小。这里还发现一个意外的收益因为聚合后的结果集非常小后续对维度表的关联不需要再访问大表所以锁粒度也更小了。流水表在扫描期间没有长时间持锁下游的写入任务不再因为这条查询而阻塞。注意GaussDB的并行度设置不一定越大越好。这个SQL优化后我尝试把query_dop从默认值调到4执行时间反而略微上升。原因是聚合后的数据量不大并行调度开销超过了并行收益。最终还是用默认并行度跑批处理稳定性更好。5. 优化过程中的常见坑与避坑指南5.1 统计信息收集的时机与方式统计信息不是一次性工作了事。像流水表这种持续增长的表如果分区新增数据量巨大建议在每天批处理前做一次增量统计信息收集。GaussDB支持分区级统计信息可以对目标分区单独收集避免全表重扫。另外不要盲目使用全量ANALYZE。在表数据量上亿的情况下全量采样耗时太长。更好的做法是使用ANALYZE ... WITH TIMESTAMP或者指定采样比例甚至只收集统计信息缺失的分区。我在这套系统里就把统计信息收集任务合并到了调度脚本里放在业务低峰期执行效果远好于每周手动处理一次。5.2 过滤条件下推与隐式类型转换这个案例里还隐藏着一个坑条件t.trans_time 2024-06-01 00:00:00如果trans_time字段是时间戳类型而等号右边是字符串常量优化器会做隐式转换。大多数情况下没问题但如果转换导致索引列无法匹配就会放弃索引。GaussDB对时间类型和字符串的比较处理比较稳健但这种隐式转换依然可能导致分区剪枝失败。排查方法很简单——查看执行计划中的Filter条件旁边是否有cast函数标记。如果有cast就说明发生了隐式类型转换。解决方式是主动使用TIMESTAMP 2024-06-01 00:00:00或者TO_TIMESTAMP()把常量转成目标类型确保类型一致。5.3 LEFT JOIN与过滤条件混用引发的语义陷阱原始SQL里LEFT JOIN user_info u ON t.user_id u.user_id但WHERE里却用了u.user_name LIKE 张%这在语义上会把LEFT JOIN降级为INNER JOIN。有些开发会觉得“反正我是以流水表为主用户表匹配不上就过滤掉”但数据库要处理这种语义转换反而会限制优化器的关联顺序。更干净的做法是把过滤条件直接写在子查询里避免在JOIN之后过滤。改写后的SQL里u.user_name LIKE 张%被挪到了LEFT JOIN的ON条件上这样语义上依然是LEFT JOIN同时降低了右侧维度的输出行数。虽然在这个案例里影响不大但一旦维度表变大这种写法差别就很明显了。5.4 关于hash join和嵌套循环的选择GaussDB优化器会根据表大小自动选择连接算法但不代表一定最优。我在别的案例里遇到过一张大表关联一张小表但小表过滤条件复杂实际结果集远小于估算值优化器却选择了Hash Join而不是嵌套循环导致性能不佳。在这种情况下可以通过/* nestloop(t1 t2) */提示干预。但提示有风险表数据变化后可能不再适用不如把精力花在让统计信息更准确上。GaussDB的优化器相对可靠绝大多数场景下统计信息准确后选出来的计划就是可用的。5.5 排序操作优化技巧原SQL里有ORDER BY total_amount DESC如果聚合后的结果集仍然很大排序可能成为瓶颈。除了提前聚合还有一个技巧如果排序字段来自聚合函数可以尝试把排序下推到子查询里不过这在标准SQL里不一定能生效。更好用的办法是对分组结果再造一个临时表或者把结果集导出后由应用层排序。不过在这个案例里聚合后只有几千行排序完全不是问题。真正要警惕的是对明细行排序那绝对是个深坑。6. 后续扩展与长期监控建议SQL改写完成后我还做了一层加固——把这条查询封装成了预处理视图方便后续定期跑批直接复用。视图本身不会提升性能但能统一逻辑防止有人临时修改WHERE条件导致执行计划恶化。同时我在监控系统里加了对这条SQL的响应时间、扫描行数、临时落盘大小的指标采集。任何一项出现异常变化都会触发告警方便第一时间介入。对于GaussDB环境的长期维护我个人的建议是建立一个慢SQL基线库每一条核心SQL的优化前、优化后、统计信息刷新后的执行计划都保留一份。等到业务数据量翻倍的时候拿出来对比一下很快就能定位是不是优化器的选择又跑偏了。这次优化的核心收获其实可以总结成一句话大部分SQL慢不是数据库不够强而是我们给了优化器错误的信息或者写了一个低效的关联结构。统计信息是优化器的“眼睛”SQL结构是优化器的“约束”把这两样理顺了GaussDB的执行计划通常就会回到正轨。希望这个案例能给你以后调优提供一些可复用的思路。