金仓数据库SQL性能调优实战复盘:从问题发现到解决
去年秋天接手一个政务系统的KingbaseES数据库运维时,我没想到后面会有这么多故事可讲。系统上线跑了大半年,数据量从几十万涨到几千万,页面开始转圈,报表超时,用户电话一个接一个打到信息中心。领导找到我,说"想想办法"。这就开始了持续两个多月的SQL调优之旅。
回头看,整个过程谈不上多高深,就是一遍遍定位问题、分析计划、试方案、验证效果,循环往复。但有些坑踩过了值得记一笔,也算是给自己做个沉淀。
一、问题浮出水面
最先报警的是运维监控。那天下午我习惯性扫一眼Zabbix,发现几个节点的磁盘IO等待时间飙到了30%以上,CPU使用率曲线也不再是平的了,每隔几分钟就有一个尖峰。登录数据库一看,系统视图
sys_stat_statements 里,有一条查询的总执行时间占了整个数据库时间的40%以上,平均每次执行接近12秒。
SELECT queryid, query, calls, total_exec_time, mean_exec_time, rowsFROM sys_stat_statements ORDER BY total_exec_time DESC LIMIT 5;
排在第一位的是一个带四个表关联和两层子查询的统计汇总语句,调用频率还不低——每分钟跑几十次。每次12秒乘上调用次数,数据库时间大头全耗在这了。
我去找开发要了这条SQL的完整文本,复制出来看了几遍。凭直觉,这种写法能跑快才怪。但先不急着下结论,得看执行计划说话。
二、第一个案例:索引没到位,全表扫描扛下所有
先拿那个TOP SQL开刀。执行计划打出来:
EXPLAIN (ANALYZE, BUFFERS) -- 完整的SQL文本,业务表名替换为A/B/C/DSELECT ... FROM A LEFT JOIN B ON A.key = B.key WHERE A.status = '1' AND A.create_time >= '2024-01-01';
计划输出很长,但一眼就看到了问题——表A上走的是 Seq Scan,filter条件过滤后返回8300行,filter掉了30万行。也就是说为了这8300行,全表扫了30多万行。过滤完的数据占比不到3%,这种场景全表扫描完全不应该出现。
Seq Scan on A (cost=0.00..12345.67 rows=8300 width=XX) (actual time=0.12..118.5 rows=8300 loops=1) Filter: ((status = '1'::text) AND (create_time >= '2024-01-01 00:00:00'::timestamp without time zone)) Rows Removed by Filter: 322000
再看看表结构,
status 和
create_time 上各自有一个独立索引,但没有联合索引。问题就在这里——两列各自有索引,优化器评估下来觉得分别用索引再组合不如直接扫全表,尤其是在统计信息不新的时候。
解决方案很简单:建一个联合索引。
CREATE INDEX idx_a_status_time ON A (status, create_time);
建完索引再跑一遍EXPLAIN ANALYZE,执行计划变了:
Index Scan using idx_a_status_time on A (cost=0.42..245.6 rows=8300 width=XX) (actual time=0.03..3.8 rows=8300 loops=1) Index Cond: ((status = '1'::text) AND (create_time >= '2024-01-01 00:00:00'::timestamp without time zone))
执行时间从118毫秒降到了3.8毫秒。整条TOP SQL因为有这个联合索引,从12秒降到了不到0.5秒。前后对比,磁盘IO曲线的尖峰直接消失了。
但这只是开胃菜。系统里慢的不止这一条。
事后想一下:联合索引的列顺序很重要。
status 是枚举值,只有"0"和"1"两种,选择性很差,按理说应该放后面。但这个查询场景特殊——
status='1' 是刚性条件,且
create_time 是范围过滤。实践中我把区分度高的
create_time 放前面也试过,优化器自己评估后选了同一个计划。有时候理论归理论,跑起来才知道。
三、统计信息引发的"幽灵慢查询"
第二个案例更折腾人。
有段时间业务反馈说某个查询"时快时慢"。快的时候几十毫秒,慢的时候五六秒,毫无规律。这种间歇性故障最让人头疼——重启一下也许就好了,但明天又冒出来。
我先去查是不是系统负载的问题。CPU正常,IO正常,连接数正常。那问题大概率在SQL本身。把这条查询拿出来单独跑了几遍,果然,同一句SQL,有时候秒出,有时候卡住。
SELECT C.cust_id, C.cust_name, O.order_amount, O.order_dateFROM customers CINNER JOIN orders O ON C.cust_id = O.cust_idWHERE C.city = '深圳' AND O.order_status = '已完成'ORDER BY O.order_date DESCLIMIT 50;
用EXPLAIN ANALYZE抓执行计划。第一次跑出来用的是Hash Join,customers表走Seq Scan(city过滤后约1.2万行),orders表也走Seq Scan(全表200万行),建hash然后探测,总耗时5.2秒。
第二次再跑,计划变成了NestLoop,customers表走Index Scan(
idx_city),orders表走Index Scan(
idx_cust_id),总耗时82毫秒。
同一个SQL,执行计划不一样,效果天上地下。
问题出在哪?我查看了一下统计信息对上。发现
customers 表的统计信息是三个月前收集的,当时这张表只有5万行,现在是28万行。更关键的是,
city
列上的高频值(MCV)列表里,"深圳"的占比还是老的20%,而实际上因为业务重心转移,深圳的客户已经占到了60%以上。优化器按照老的统计估算,以为过滤完只有几千行,选择了Hash
Join;但实际返回1.2万行,hash表太大,部分数据被写到了临时磁盘文件上,性能陡降。
ANALYZE customers; ANALYZE orders;
更新完统计信息后,再跑同样的SQL,执行计划稳定在了NestLoop路线,响应时间稳定在80-100毫秒之间,再也没有忽快忽慢的情况。
这个事给我的教训:统计信息就是优化器的眼睛。眼神不好,路就走歪了。从那以后,我在项目里加了个定时任务——每天凌晨对变更量大的表执行
ANALYZE。对于经常出现"时快时慢"的SQL,第一件事先查统计信息的收集时间,这比看执行计划来得还快。
还有一个细节:金仓的
autovacuum 虽然默认开启,但它的触发阈值对于一些高频更新的表来说过于保守。我遇到过某张表一天插入100多万行,autovacuum的analyze阈值要到200万行才触发——等于一周内统计信息都处于"失明"状态。这种情况要么手动调小
autovacuum_analyze_scale_factor,要么写个crontab定时analyze。
四、当优化器选错路: Hint 强制干预
第三个案例来自一个报表查询。四个大表做关联,数据量都在百万级以上。金仓的优化器选了一个MergeJoin方案,两个大表先分别排序,再合并。排序这一步用了大量的临时磁盘空间。
Merge Join (cost=23456..56789 rows=120000 width=XX) Merge Cond: (T1.id = T2.ref_id) -> Sort (cost=12345..23456 rows=800000 width=XX) Sort Key: T1.id -> Seq Scan on T1 (cost=0..12345 rows=800000 width=XX) -> Sort (cost=11111..22222 rows=600000 width=XX) Sort Key: T2.ref_id -> Seq Scan on T2 (cost=0..11111 rows=600000 width=XX)
两条Sort的cost都不低,加起来占了总时间的大头。实际看执行时间,排序花了将近8秒,两个Sort加起来占了查询总时间的70%。
我分析了一下数据分布:T1和T2在关联键上的数据分布有一定顺序性,但没到完全有序的程度。Hash Join应该更适合——不需要排序,建个hash桶直接探测。但优化器可能高估了hash表的内存开销,选择了MergeJoin。
改不了表结构,也改不了SQL(报表工具自动生成的),那就用HINT。
先在
kingbase.conf 里开了HINT支持:
enable_hint = on
重启后,在SQL上加了一个HINT:
SELECT /*+ HashJoin(T1 T2) */...FROM T1INNER JOIN T2 ON T1.id = T2.ref_id ...
HINT语法很直观:
/*+ ... */ 放在SELECT关键字后面,
HashJoin(表1 表2) 表示指定这两个表使用哈希连接。
打了HINT之后再跑EXPLAIN ANALYZE,计划变成了:
Hash Join (cost=15000..42000 rows=120000 width=XX) Hash Cond: (T1.id = T2.ref_id) -> Seq Scan on T1 (cost=0..12345 rows=800000 width=XX) -> Hash (cost=11111..11111 rows=600000 width=XX) -> Seq Scan on T2 (cost=0..11111 rows=600000 width=XX)
没有Sort了,总耗时从原来的12秒降到了4.3秒。效果很明显。
但HINT这把双刃剑我也见识过它的副作用。之前有个同事图省事,给一批SQL都加了
/*+ SeqScan */ 禁止全表扫描。后来表数据量变化了,用户反馈某些查询反而变慢了——因为数据量小的表走索引有两次IO(索引页+数据页),比顺序读还慢。所以用HINT治标不治本,它应该是统计信息、索引、SQL改写都试过之后的最后手段。而且加了HINT的SQL要有监控,数据量级变化后要重新评估。
金仓支持的HINT类型还挺丰富的,从单表扫描方式(SeqScan/IndexScan/BitmapScan)、连接方式(HashJoin/NestLoop/MergeJoin)、连接顺序(Leading)到并行度(Parallel)都能控制。我后来还用过
rows 提示来修正优化器对中间结果集的估算:
SELECT /*+ rows(T1 T2 #10000) */
这个提示告诉优化器:T1和T2连接后的结果大约10000行。如果优化器严重高估或低估了连接结果的行数,用
rows 提示能帮它做出更好的后续决策。
五、Query Mapping:不改代码也能改SQL
说到用HINT,我还碰到过一个场景——HINT解决不了的问题。
有个第三方厂商的业务系统,SQL是写死在代码里的,只提供了编译后的程序,没法改一行代码。但其中一条SQL写得太烂了:在一个子查询里用了
DISTINCT 结合
ORDER BY 非排序列,导致走了临时文件排序,跑一次要20多秒。系统每天定时任务跑这个SQL,把ETL调度整个拖慢。
SELECT DISTINCT a.column1, a.column2FROM table_a aJOIN table_b b ON a.id = b.ref_idWHERE b.type = 'X'ORDER BY a.create_time DESC;
这条SQL的问题是:
DISTINCT 要对大量数据做去重,然后
ORDER BY create_time 还需要一次排序,而
create_time 又不是
DISTINCT 里的列,排序无法利用索引,只能额外生成一个临时排序。把这个SQL分开看,其实
DISTINCT 在这个场景下是多余的——业务上
(column1, column2) 本身就是唯一的。但代码改不了。
金仓的Query Mapping功能这时候派上了用场。
先在
kingbase.conf 里开启:
enable_query_rule = on
然后创建一个映射规则:
SELECT create_query_rule( 'rm_distinct_opt', 'SELECT DISTINCT a.column1, a.column2 FROM table_a a JOIN table_b b ON a.id = b.ref_id WHERE b.type = ''X'' ORDER BY a.create_time DESC;', 'SELECT a.column1, a.column2 FROM table_a a JOIN table_b b ON a.id = b.ref_id WHERE b.type = ''X'' ORDER BY a.create_time DESC;', true, 'text');
创建成功后,应用程序发来的原SQL会被数据库自动替换为新的SQL,完全不需要改应用代码。新SQL去掉了
DISTINCT(因为冗余),直接从排序阶段省掉一步,执行时间从22秒降到了2.1秒。
这里有个坑要注意——
create_query_rule 的第四个参数是
true 表示启用,第五个参数
'text' 表示按文本匹配。还有一个选项是
'semantics'(语义匹配)。TEXT模式的好处是可以保留注释(可以和HINT配合),坏处是大小写、空格都必须完全一致。SEMANTICS模式会做语义解析,容错性更好,但无法保留注释,且不支持带
rownum 的场景。
我当时遇到的问题是:第三方程序生成的SQL里缩进和换行格式不固定,用TEXT模式怎么都匹配不上。最后折中的办法是用SEMANTICS模式,但SEMANTICS又不能保留注释,所以HINT也用不了。好在去掉DISTINCT这个改写不需要HINT。
后来还碰到过另一个适合Query Mapping的场景:一个旧系统迁移上来的SQL,里面写了Oracle风格的
(+) 外连接。虽然金仓兼容Oracle模式下能识别,但某些复杂嵌套场景会走错执行计划。我建了一个Query Mapping把
(+) 写法映射成标准的
LEFT JOIN 写法,执行计划就对劲了。
SELECT create_query_rule( 'mig_oracle_to_ansi', 'SELECT * FROM t1, t2 WHERE t1.id = t2.id(+)', 'SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id', true, 'semantics');
这种无侵入的整改方式,在国产化替代和系统迁移的场景下特别实用。
六、子查询和连接改写的学问
前面几个案例讲的都是"怎么发现问题、怎么用工具解决",但有些时候问题没那么复杂,就是SQL本身写得绕。
讲一个比较典型的例子。原始SQL是这样的:
SELECT e.emp_id, e.emp_name, e.dept_idFROM employees eWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.emp_id = e.emp_id AND o.order_date >= '2024-06-01' AND o.order_date < '2024-07-01')AND NOT EXISTS ( SELECT 1 FROM orders o WHERE o.emp_id = e.emp_id AND o.order_date < '2024-06-01');
翻译成业务语言:找出6月份有订单、但6月之前没有订单记录的新员工。用了两个
EXISTS 子查询,每次子查询都要扫一次orders表。orders表当时已经800万行了,这个SQL跑一次要14秒。
金仓的优化器在逻辑优化阶段会对子查询做上拉(pull up),把EXISTS转换成半连接(semi join)。但对于NOT EXISTS这块,优化器的处理有时不够彻底,尤其在表上有复杂索引的情况下。我们看执行计划,发现orders表被扫描了两次。
改写成JOIN形式:
SELECT e.emp_id, e.emp_name, e.dept_idFROM employees eINNER JOIN ( SELECT emp_id FROM orders WHERE order_date >= '2024-06-01' AND order_date < '2024-07-01' GROUP BY emp_id ) o1 ON e.emp_id = o1.emp_idLEFT JOIN ( SELECT emp_id FROM orders WHERE order_date < '2024-06-01' GROUP BY emp_id ) o2 ON e.emp_id = o2.emp_idWHERE o2.emp_id IS NULL;
改写思路是把两次子查询分别聚合成派生表,然后做连接过滤,让优化器有更多选择空间。改写后orders表改为两次索引扫描(
idx_orders_emp_date 覆盖索引),执行时间降到了0.8秒。
同样的思路,还有一个更经典的
UNION 改
UNION ALL 案例。某报表SQL用
UNION 合并两个查询,两个查询的结果集完全没有重复(一个是本月数据,一个是历史数据)。但
UNION 自带去重,多了一次排序和唯一性检查的开销。
-- 改写前SELECT * FROM current_month_salesUNIONSELECT * FROM history_sales;-- 改写后(两个查询结果集不重叠)SELECT * FROM current_month_salesUNION ALLSELECT * FROM history_sales;
就这一处改动,查询时间从6.5秒降到了2.8秒。虽然没有前面那些案例惊艳,但这种零成本的优化多了,累积效果就出来了。
金仓逻辑优化器本身也有一些改写能力,比如
kdb_rbo 插件提供的等价转换规则。我试过
kdb_rbo.enable_merge_comm_expr 来合并公共子表达式,在某些场景下有效果。但自动改写不如人手动改写精准,因为优化器要考虑的是"任何数据分布下的平均最优",而人工改写可以针对具体场景做极端优化。
七、监控和调优报告让我少走了弯路
前面说的都是具体案例,最后聊一下工具层面的东西。金仓提供的几个插件在日常运维中帮了不少忙。
sys_stat_statements 是最基础的一个,用来定位TOP SQL。我会定期把它的数据归档到一张历史表里,然后按周看趋势。一条SQL上周平均执行时间5ms,这周变成50ms,就是一个明确的预警信号。
sys_qualstats 这个插件我比较喜欢——它记录的是WHERE条件和JOIN条件里的谓词统计。跑一段时间后查一下,就能发现哪些列经常被过滤但没有索引,系统会给出索引建议。
SELECT * FROM sys_qualstats WHERE queryid IN (SELECT queryid FROM sys_stat_statements ORDER BY total_exec_time DESC LIMIT 20);
有一次我就是通过这个插件发现,某张表的
create_date 列在TOP 20 SQL里被引用了16次,但竟然没有索引。建上去之后,相关查询的总体响应时间下降了40%。
sys_sqltune 的调优报告功能也值得一说。有一次遇到一条极其复杂的多层嵌套SQL,我自己看了半天执行计划也没理清。直接调用了调优接口:
SELECT PERF.QUICK_TUNE_BY_SQL('SELECT ... 完整的复杂SQL ...');
系统返回了一份报告,包含了执行计划对比、优化建议和预期收益。报告指出有一个子查询可以改写成CTE形式,避免重复计算。照着改完,效果确实不错。
不过这些工具终究是辅助。再智能的调优报告也不能替你理解业务逻辑——比如有时候索引建在A列上效率更高,但业务上B列的过滤条件才是刚需。工具指方向,人做决策。
八、踩坑记录和几点体会
两个多月调下来,SQL改了二十几条,索引建了十几个,Query Mapping用了四五处。系统的整体吞吐量提升了大概3倍,TOP SQL的平均响应时间从6.8秒降到了0.3秒。过程不复杂,但有些坑值得拿出来说。
第一个坑:改完不验证。 有一次我改了一条SQL,加上HINT强制走索引,测试环境跑得飞快。上生产后业务反馈某个报表数据对不上了。一查才发现HINT里引用的索引名和生产环境的索引名不一样(两套环境的建索引脚本差了一个版本)。从那以后,我每次改完都会在目标环境上用真实数据跑一次,对比结果集行数和关键字段值。
第二个坑:统计信息是基础,但别走极端。 一开始遇到几个统计信息不准导致的问题后,我让crontab每半小时对所有表跑一次
ANALYZE。结果系统负载反而上去了——大表
ANALYZE 本身也要消耗资源。后来改成只对变更量大的核心表做高频analyze,其他表一天一次。
第三个坑:索引不是越多越好。 有一次我看到一条查询走不了索引,一查这张表上有8个索引,但查询条件列一个都没覆盖到。更糟的是,DELETE操作因为要维护这8个索引,慢得离谱。后来删了4个无用的索引(通过
sys_stat_user_indexes 查使用率),写入性能提升了30%。索引是要花钱的——花的是写入和存储的钱。
第四个坑:Query Mapping是双刃剑。 TEXT模式匹配太严格,大小写多一个空格就不生效。SEMANTICS模式虽然容错好,但调试困难——匹配成功了没有明显的日志提示,只有通过
explain (usingquerymapping) 才能看到映射是否生效。有一次我配置了一个映射,以为上线了,两个月后查日志才发现根本没匹配上,中间所有的优化效果都是0。
第五个坑:HINT依赖要敢于放弃。 有一条SQL加了
/*+ HashJoin */
之后跑了半年都很好。后来业务数据量翻了10倍,Hash
Join因为hash桶太大开始频繁写磁盘,反而比原来的MergeJoin还慢。去掉HINT后优化器自己选择了新的计划。所以,每次大版本数据变更或业务高峰期过后,我都会把有HINT的SQL清单拿出来复查一遍。
九、说说方法论
上面写了这么多案例,最后整理一下我在这个项目里形成的工作方法。
接手的第一个星期不要急着改任何东西。先搭监控,把
sys_stat_statements、
sys_qualstats、
sys_sqltune 这些插件装好,跑几天数据,看清楚系统里的"真凶"是谁。很多时候用户抱怨慢,慢的SQL可能就那一两条,其他都是连锁反应。
定位到TOP SQL之后,每一步都有固定流程:
拿到SQL → EXPLAIN看计划 → 检查统计信息时效 → 分析耗时节点(扫描方式/连接方法/基数估算)→ 确定优化路径(索引/SQL改写/HINT/Query Mapping)→ 实施 → EXPLAIN ANALYZE验证 → 对比效果
这个循环走下来,大多数性能问题都能在3轮以内解决。
优化路径的选择有一个优先级顺序(经验之谈,不是金仓官方说的):
- 统计信息——先把"眼睛"擦亮,让优化器自己做对选择
- 索引——缺索引补索引,冗余索引删索引
- SQL逻辑改写——子查询改连接、UNION改UNION ALL、删除冗余DISTINCT
- HINT——前三个都试过了还不行,再用手动干预
- Query Mapping——适合于改不了代码的场景,作为"最后手段"
最后一条:文档要跟上。我每改一条SQL,就在一个共享文档里记录:问题现象、根因分析、改动内容、验证方法、预期收益。两个月下来,这个文档差不多一万五千字。后来新同事接手这个系统,看一遍文档就能直接上手。数据库调优不只是技术活,也是知识管理。
十、写在最后
金仓数据库的性能调优,说到底是三件事:理解数据、理解查询、理解优化器。理解数据靠统计信息和业务知识,理解查询靠阅读执行计划,理解优化器靠长期和它"打交道"积累的经验。
这次经历让我印象最深的是,很多性能问题其实不是数据库本身的问题。一个适合的索引、一句更清晰的SQL、一次及时的
ANALYZE,几十倍的性能提升就在那里等着你。数据库不会说谎,你给它的信息够多够准,它自然会给你最好的答案。
回过头看那段时间,每天面对的都是慢查询、高IO、锁等待这些烦心事。但每解决一个问题,系统响应快那么一些,用户的抱怨少那么一些,这种正向反馈挺有成就感的。希望这篇复盘对其他在金仓上做调优的人有点帮助。