暂无图片
暂无图片
1
暂无图片
暂无图片
暂无图片

金仓SQL性能调优实战:从慢SQL定位到执行计划优化的全链路复盘

原创 手机用户1978 5天前
74

金仓SQL性能调优实战:从慢SQL定位到执行计划优化的全链路复盘

每日一个金仓数据库小知识

金仓数据库(KingbaseES)MySQL兼容版自带慢查询日志功能,通过设置log_min_duration_statement参数可以捕获超过指定阈值的SQL语句,配合sys_stat_statements扩展能精准统计每条SQL的总耗时、调用次数、平均耗时等核心指标。很多从MySQL迁过来的同学不知道金仓也有类似EXPLAIN ANALYZE的执行计划分析能力,而且金仓的执行计划是基于PostgreSQL风格的,节点名称和MySQL的type/ref/rows体系完全不同——比如Seq Scan对应全表扫描,Index Scan对应索引扫描,Nested Loop对应嵌套循环连接。学会看金仓的执行计划,是SQL调优的第一步。


一、摘要

本文基于某企业级业务系统在金仓数据库上的性能调优全过程,系统性复盘了从慢SQL发现、定位、执行计划分析到索引优化、SQL改写、Hint介入的完整调优链路。

核心调优思路:
慢SQL告警触发 → 开启慢查询日志定位问题SQL → EXPLAIN ANALYZE分析执行计划 → 识别全表扫描/低效连接/排序溢出等问题 → 索引优化(联合索引/覆盖索引/表达式索引)→ SQL改写(子查询转JOIN/避免隐式转换/优化LIMIT分页)→ Hint调优(强制索引/连接方式/并行度)→ Query Map固化执行计划 → 压测验证效果,形成完整闭环。

关键优化方案:

  1. 慢SQL定位三板斧:慢查询日志 + sys_stat_statements + Druid监控三重定位,精准找出Top10慢SQL
  2. 执行计划深度解读:金仓执行计划7类常见节点的识别与调优方向
  3. 索引优化四大实战案例:联合索引最左前缀、覆盖索引避免回表、表达式索引解决函数索引问题、部分索引缩小索引范围
  4. SQL改写五大战术:子查询JOIN化、隐式类型转换修复、大偏移量分页优化、OR条件拆分UNION、避免SELECT *
  5. Hint与Query Map:用Hint强制执行计划,用Query Map固化最优执行路径
  6. 长效治理机制:SQL审核规范 + 慢SQL巡检 + 索引使用情况定期体检

最终优化成效:

  • Top10慢SQL平均耗时从8.2秒降至120毫秒,降幅98.5%
  • 数据库CPU峰值从92%降至38%,下降59%
  • 核心接口响应时间从3.5秒降至280毫秒,提升12.5倍
  • 全表扫描占比从47%降至5%以下
  • 系统整体吞吐量从280 QPS提升至650 QPS,提升132%


开发环境声明

指标分类 参数项 初始配置(调优前) 调优后生产配置
数据库版本 KingbaseES V9R3C18 MySQL兼容版 V9R3C18 MySQL兼容版
部署架构 部署方式 单机部署 主备集群(一主一备)
服务器配置 CPU 16核 16核(未变)
服务器配置 内存 32GB 32GB(未变)
服务器配置 磁盘 SSD 500GB SSD 500GB(未变)
共享缓冲区 shared_buffers 128MB(默认) 8GB(内存的25%)
工作内存 work_mem 4MB(默认) 64MB
维护工作内存 maintenance_work_mem 64MB(默认) 512MB
有效缓存大小 effective_cache_size 4GB(默认) 24GB(内存的75%)
慢查询阈值 log_min_duration_statement -1(关闭) 1000(1秒)
统计信息采集 sys_stat_statements 未开启 已开启
排序内存 sort_mem相关 默认配置 work_mem=64MB
连接数 max_connections 100 300
应用框架 ORM框架 MyBatis-Plus 3.5.3 MyBatis-Plus 3.5.3
应用框架 连接池 Druid 1.2.16 Druid 1.2.16
业务规模 核心表数据量 user_info: 500万行 / order_info: 2000万行 / order_item: 8000万行 同前(数据量不变)
并发量级 峰值QPS 约280 约650(优化后承载)
慢SQL数量 超过1秒的SQL 日均1200+次 日均不足10次
数据库CPU 峰值使用率 92% 38%

目录

  • 二、SQL性能调优全实战
    • 2.1 慢SQL定位:三板斧精准找出问题SQL
    • 2.2 执行计划深度解读:看懂金仓的EXPLAIN
    • 2.3 案例一:联合索引优化 + 最左前缀原则
    • 2.4 案例二:覆盖索引 + 避免回表
    • 2.5 案例三:表达式索引解决函数列查询
    • 2.6 案例四:部分索引缩小范围提升效率
    • 2.7 SQL改写五大战术:从写法上榨干性能
    • 2.8 Hint与Query Map:强制执行计划不走偏
    • 2.9 长效治理:慢SQL巡检与索引体检机制
  • 三、参考资料
  • 个人实战总结

二、SQL性能调优全实战

2.1 慢SQL定位:三板斧精准找出问题SQL

调优的第一步不是瞎猜,而是用数据说话——先找到真正慢的SQL,再对症下药。下面是我在金仓上反复验证的定位三件套。

2.1.1 第一板斧:开启慢查询日志

-- ============================================= -- 金仓数据库慢查询日志配置 -- 适用:KingbaseES V9R3C18 MySQL兼容版 -- ============================================= -- 1. 查看当前慢查询配置 SHOW log_min_duration_statement; SHOW log_statement; SHOW log_destination; -- 2. 临时开启慢查询(当前会话生效,用于调试) SET log_min_duration_statement = 1000; -- 单位:毫秒,超过1秒的SQL记录 SET log_statement = 'none'; -- 只记录慢的,不记录所有 -- 3. 永久开启(修改配置文件,需重启或reload) -- 编辑 kingbase.conf,添加/修改以下参数: -- log_min_duration_statement = 1000 # 超过1秒的SQL记录到日志 -- log_destination = 'csvlog' # CSV格式方便分析 -- logging_collector = on # 开启日志收集器 -- log_directory = 'sys_log' # 日志目录 -- log_filename = 'kingbase-%Y-%m-%d_%H%M%S.log' -- 4. 重新加载配置(不需要重启) -- 命令行执行: -- sys_ctl reload -D /opt/Kingbase/ES/V9R3C18/data -- 5. 验证配置是否生效 SELECT name, setting, unit, pending_restart FROM sys_settings WHERE name LIKE 'log_%' OR name = 'log_min_duration_statement' ORDER BY name;
# ================================================ # 慢查询日志分析常用命令 # ================================================ # 1. 查看今天的慢查询日志中最慢的Top 10(CSV格式日志) awk -F',' '$13 > 1000 {print $13, $14}' sys_log/kingbase-2026-08-01*.csv \ | sort -rn \ | head -20 # 2. 统计各慢SQL出现次数(按SQL语句前100字符聚合) awk -F',' '$13 > 1000 {sql=substr($14,1,100); count[sql]++} END {for (s in count) print count[s], s}' sys_log/kingbase-2026-08-01*.csv \ | sort -rn \ | head -20 # 3. 查看某时间段内的慢SQL grep "2026-08-01 14:00" sys_log/kingbase-2026-08-01*.csv \ | awk -F',' '$13 > 2000 {print $1, $13"ms", $14}' \ | sort -t' ' -k2 -rn

踩坑实录第1坑:慢查询日志默认是关闭的

刚迁金仓时出了性能问题,想查慢SQL,结果发现log_min_duration_statement默认是-1(关闭状态),等于没有慢查询日志。后来花了半天时间手动在应用层埋点才定位到问题SQL。

建议: 上线第一天就把慢查询打开,阈值设为1000毫秒(1秒)。这东西开着不怎么耗性能,但关键时刻能救命。

2.1.2 第二板斧:sys_stat_statements 扩展

慢查询日志只能看到慢的SQL,但看不到调用次数、总耗时、平均耗时这些统计维度。sys_stat_statements是金仓的SQL统计神器,相当于MySQL的performance_schema

-- ============================================= -- 开启 sys_stat_statements 扩展 -- ============================================= -- 1. 首先确认 shared_preload_libraries 中包含 sys_stat_statements -- 编辑 kingbase.conf: -- shared_preload_libraries = 'sys_stat_statements' -- sys_stat_statements.track = all -- sys_stat_statements.max = 10000 -- 修改后需要重启数据库! -- sys_ctl restart -D /opt/Kingbase/ES/V9R3C18/data -- 2. 在目标数据库中创建扩展 CREATE EXTENSION IF NOT EXISTS sys_stat_statements; -- 3. 验证扩展是否创建成功 SELECT * FROM sys_extension WHERE extname = 'sys_stat_statements';
-- ============================================= -- Top 10 耗时SQL(总耗时排序) -- 最常用的SQL,调优优先看这个 -- ============================================= SELECT row_number() OVER (ORDER BY total_exec_time DESC) AS 排名, queryid, round(total_exec_time::numeric, 2) AS 总耗时_ms, calls AS 调用次数, round(mean_exec_time::numeric, 2) AS 平均耗时_ms, round(total_exec_time::numeric / calls::numeric, 2) AS 单次平均_ms, round(shared_blks_hit * 100.0 / NULLIF(shared_blks_hit + shared_blks_read, 0), 2) AS 缓存命中率_pct, -- 截取SQL前120个字符方便查看 left(query, 120) AS SQL预览 FROM sys_stat_statements WHERE query NOT LIKE '%sys_stat_statements%' -- 排除查询自身 AND query NOT LIKE 'SHOW%' AND query NOT LIKE 'SET %' ORDER BY total_exec_time DESC LIMIT 10; -- ============================================= -- Top 10 平均耗时SQL(单次最慢排序) -- 适合找那种单次特别慢但调用不多的SQL -- ============================================= SELECT calls AS 调用次数, round(mean_exec_time::numeric, 2) AS 平均耗时_ms, round(max_exec_time::numeric, 2) AS 最大耗时_ms, round(total_exec_time::numeric, 2) AS 总耗时_ms, left(query, 150) AS SQL预览 FROM sys_stat_statements WHERE calls > 5 -- 至少调用5次以上,排除偶发 AND mean_exec_time > 500 -- 平均耗时超过500ms ORDER BY mean_exec_time DESC LIMIT 10; -- ============================================= -- 高频率SQL Top 10(调用次数最多) -- 这些SQL哪怕只快1毫秒,乘以调用次数收益也很大 -- ============================================= SELECT calls AS 调用次数, round(mean_exec_time::numeric, 2) AS 平均耗时_ms, round(total_exec_time::numeric, 2) AS 总耗时_ms, left(query, 120) AS SQL预览 FROM sys_stat_statements WHERE query NOT LIKE '%sys_stat_statements%' ORDER BY calls DESC LIMIT 10; -- ============================================= -- 临时表/排序溢出最多的SQL(work_mem不足的信号) -- ============================================= SELECT calls, temp_blks_written AS 临时块写入数, round(temp_blks_written * 8.0 / 1024, 2) AS 临时写入_MB, round(mean_exec_time::numeric, 2) AS 平均耗时_ms, left(query, 120) AS SQL预览 FROM sys_stat_statements WHERE temp_blks_written > 0 ORDER BY temp_blks_written DESC LIMIT 10; -- ============================================= -- 重置统计(调优前后对比用) -- ============================================= SELECT sys_stat_statements_reset();

踩坑实录第2坑:忘了开启sys_stat_statements,调优全靠猜

第一次做金仓性能调优时,习惯性打开Navicat就想找慢SQL,翻了半天没找到类似MySQL慢查询日志的东西。后来才知道金仓要手动开sys_stat_statements扩展,而且修改shared_preload_libraries需要重启数据库——大半夜找DBA审批重启,折腾半宿。

经验: 生产环境上线前就把sys_stat_statements配好,省得后面被动。

2.1.3 第三板斧:Druid慢SQL监控

如果你的应用用了Druid连接池,那它自带的慢SQL监控就是第三只眼睛——能看到哪条SQL慢、被哪个接口调用、执行参数是什么

spring: datasource: druid: filter: stat: # 开启慢SQL日志 log-slow-sql: true # 慢SQL阈值:1000毫秒(1秒) slow-sql-millis: 1000 # 合并SQL统计(相同SQL不同参数合并为一条,方便看整体) merge-sql: true # 监控页面开启后,访问 /druid/sql.html 可以看到SQL统计 stat-view-servlet: enabled: true url-pattern: /druid/*
/** * Druid慢SQL告警监听器 * 捕获慢SQL并发送告警,生产环境必备 */ @Slf4j @Component public class SlowSqlListener implements ApplicationListener<ApplicationReadyEvent> { @Autowired private DataSource dataSource; @Override public void onApplicationEvent(ApplicationReadyEvent event) { if (dataSource instanceof DruidDataSource) { DruidDataSource ds = (DruidDataSource) dataSource; log.info("Druid慢SQL监控已启动,阈值:{}ms", ds.getDataSourceStat().getSlowSqlMillis()); } } /** * 定时检查慢SQL(每5分钟执行一次) * 发现新增慢SQL就告警 */ @Scheduled(fixedRate = 300000) public void checkSlowSql() { if (!(dataSource instanceof DruidDataSource)) { return; } DruidDataSource ds = (DruidDataSource) dataSource; JdbcDataSourceStat stat = ds.getDataSourceStat(); // 获取SQL统计列表 Map<String, JdbcSqlStat> sqlStatMap = stat.getSqlStatMap(); List<SlowSqlInfo> slowList = new ArrayList<>(); for (Map.Entry<String, JdbcSqlStat> entry : sqlStatMap.entrySet()) { JdbcSqlStat sqlStat = entry.getValue(); if (sqlStat.getExecuteAndResultHoldTimeMillis() > 1000) { SlowSqlInfo info = new SlowSqlInfo(); info.setSql(left(entry.getKey(), 200)); info.setExecuteCount(sqlStat.getExecuteCount()); info.setTotalTime(sqlStat.getExecuteAndResultHoldTimeMillis()); info.setMaxTime(sqlStat.getMaxTimespan()); slowList.add(info); } } // 按总耗时倒序,取Top 5 slowList.sort((a, b) -> Long.compare(b.getTotalTime(), a.getTotalTime())); List<SlowSqlInfo> top5 = slowList.stream().limit(5).collect(Collectors.toList()); if (!top5.isEmpty()) { log.warn("【慢SQL告警】发现{}条慢SQL,Top5总耗时:", top5.size()); for (int i = 0; i < top5.size(); i++) { SlowSqlInfo sql = top5.get(i); log.warn(" Top{}: 执行{}次,总耗时{}ms,最大单次{}ms - {}", i + 1, sql.getExecuteCount(), sql.getTotalTime(), sql.getMaxTime(), sql.getSql()); } } } @Data static class SlowSqlInfo { private String sql; private long executeCount; private long totalTime; private long maxTime; } private static String left(String str, int len) { if (str == null) return null; return str.length() <= len ? str : str.substring(0, len) + "..."; } }

实战经验:三重定位互相印证

慢查询日志给你数据库视角的慢SQL,sys_stat_statements给你统计视角的全量SQL排名,Druid给你应用视角的调用关联。三个工具交叉验证,既能确认哪条SQL确实慢,又能知道它被哪个业务接口调用,排查效率直接翻倍。

2.2 执行计划深度解读:看懂金仓的EXPLAIN

找到慢SQL之后,第一步不是瞎加索引,而是看执行计划——搞清楚数据库到底是怎么执行这条SQL的,瓶颈在哪里。

2.2.1 EXPLAIN基础用法

-- ============================================= -- EXPLAIN 五种常用用法 -- ============================================= -- 1. 基本执行计划(只看规划,不实际执行) EXPLAIN SELECT * FROM order_info WHERE user_id = 12345 AND status = 'PAID'; -- 2. ANALYZE模式:实际执行SQL,显示真实行数和耗时(最常用!) EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT * FROM order_info WHERE user_id = 12345 AND status = 'PAID'; -- 3. 更详细的输出格式 EXPLAIN (ANALYZE, BUFFERS, VERBOSE, COSTS, TIMING, SUMMARY) SELECT * FROM order_info WHERE user_id = 12345 AND status = 'PAID'; -- 4. JSON格式输出(方便程序解析或对比) EXPLAIN (ANALYZE, FORMAT JSON) SELECT * FROM order_info WHERE user_id = 12345 AND status = 'PAID'; -- 5. BUFFERS参数看缓存命中情况(关键!判断I/O瓶颈) EXPLAIN (ANALYZE, BUFFERS) SELECT oi.order_no, oi.amount, ui.user_name FROM order_info oi JOIN user_info ui ON oi.user_id = ui.id WHERE oi.create_time >= '2026-01-01' ORDER BY oi.create_time DESC LIMIT 20;

2.2.2 执行计划常见节点解读

节点名称 含义 MySQL对应 调优方向
Seq Scan 全表扫描,逐行读取整个表 ALL / type=ALL 加索引、加WHERE条件过滤
Index Scan 索引扫描,通过索引找到行再回表 ref / range 正常,可考虑覆盖索引避免回表
Index Only Scan 覆盖索引扫描,只扫索引不回表 Using index 最优,好上加好
Bitmap Index Scan 位图索引扫描 index_merge 多个索引组合查询时出现
Nested Loop 嵌套循环连接 NL join 小表驱动大表时高效,大表间低效
Hash Join 哈希连接 Hash join 大数据量连接比Nested Loop好
Merge Join 归并连接 - 两边都有序时高效,常出现在ORDER BY + JOIN
Sort 排序操作 Using filesort 加索引消除排序、增大work_mem
Aggregate 聚合操作(COUNT/SUM/AVG等) Using temporary / Using filesort 加索引、物化视图
Limit 限制返回行数 limit 配合索引偏移量小才快,大偏移需优化
CTE Scan 公用表表达式扫描 - CTE是优化栅栏,考虑改子查询

2.2.3 执行计划阅读顺序与分析方法

-- ============================================= -- 实战执行计划解读示例 -- 一条慢SQL的执行计划长这样,我们来逐行分析 -- ============================================= -- 原始SQL(用户订单查询,带分页) EXPLAIN (ANALYZE, BUFFERS) SELECT oi.id, oi.order_no, oi.amount, ui.user_name, ui.mobile FROM order_info oi JOIN user_info ui ON oi.user_id = ui.id WHERE oi.status = 'PAID' AND oi.create_time >= '2026-07-01' AND ui.status = 'ACTIVE' ORDER BY oi.create_time DESC LIMIT 20 OFFSET 980;

执行计划输出分析:

 Limit  (cost=... rows=20 width=120) (actual time=4280.123..4280.215 rows=20 loops=1)
   Buffers: shared hit=128 read=5423
   ->  Nested Loop  (cost=... rows=9800 width=120) (actual time=12.534..4278.500 rows=1000 loops=1)
         Buffers: shared hit=128 read=5423
         ->  Sort  (cost=... rows=9800 width=84) (actual time=11.890..13.200 rows=1000 loops=1)
               Sort Key: oi.create_time DESC
               Sort Method: quicksort  Memory: 2048kB
               Buffers: shared hit=60 read=1200
               ->  Seq Scan on order_info oi  (cost=0.00..5200 rows=9800 width=84)
                                            (actual time=0.015..8.500 rows=50000 loops=1)
                     Filter: ((status = 'PAID'::bpchar) AND (create_time >= '2026-07-01'::date))
                     Rows Removed by Filter: 1950000
                     Buffers: shared hit=60 read=1200
         ->  Index Scan using user_info_pkey on user_info ui  (cost=0.29..2.50 rows=1 width=36)
                                                            (actual time=0.010..0.012 rows=1 loops=1000)
               Index Cond: (id = oi.user_id)
               Filter: (status = 'ACTIVE'::bpchar)
               Rows Removed by Filter: 0
               Buffers: shared hit=68 read=4223
 Planning Time: 0.350 ms
 Execution Time: 4280.500 ms

执行计划分析(从内到外读):

  1. 最内层 Seq Scan on order_info → 全表扫描order_info表,扫描了200万行(rows=2000000),过滤后剩下5万行。问题1:全表扫描,没有用到索引。
  2. Sort排序 → 对5万行按create_time排序,用了快排,内存2MB。问题2:排序量太大。
  3. Nested Loop嵌套循环 → 用排序后的结果去关联user_info表,循环了1000次(因为LIMIT 20 OFFSET 980,所以要查1000条)。
  4. Index Scan on user_info → user_info用主键索引查,每次1行,这部分没问题。
  5. 总耗时4.28秒 → 主要时间花在全表扫描和OFFSET大偏移量上。

优化思路:

  • 给order_info的(status, create_time)加联合索引,消除全表扫描
  • 优化OFFSET分页方式(用游标分页替代OFFSET)
  • 考虑加覆盖索引减少回表

阅读技巧:从最缩进的行往回读

金仓执行计划是树状结构,缩进最多的最先执行(最底层),越往上越后执行。读的时候从最深层开始,一层一层往外看,就能理清执行顺序和数据流向。

2.3 案例一:联合索引优化 + 最左前缀原则

2.3.1 问题现场

-- 问题SQL:用户订单列表查询,按状态+时间范围筛选,按创建时间倒序 -- 耗时:4.28秒,返回20条数据 SELECT oi.id, oi.order_no, oi.amount, oi.create_time FROM order_info oi WHERE oi.status = 'PAID' AND oi.create_time >= '2026-07-01' AND oi.create_time < '2026-08-01' ORDER BY oi.create_time DESC LIMIT 20; -- 执行计划:Seq Scan全表扫描,扫描2000万行,过滤后剩下8万行,再排序,再取20条 -- 总耗时:4280ms

2.3.2 排查过程

-- 1. 先看现有的索引 SELECT indexname AS 索引名, indexdef AS 索引定义 FROM sys_indexes WHERE tablename = 'order_info'; -- 结果:只有主键索引,没有status和create_time的索引

2.3.3 优化方案

-- ============================================= -- 方案:创建联合索引 idx_order_status_time -- 原则:等值查询列在前,范围查询列在后 -- ============================================= -- 创建联合索引(status等值查询在前,create_time范围查询在后) CREATE INDEX idx_order_status_time ON order_info (status, create_time DESC); -- 查看索引是否创建成功,以及索引大小 SELECT indexname AS 索引名, pg_size_pretty(pg_relation_size(indexrelid)) AS 索引大小 FROM sys_stat_user_indexes WHERE relname = 'order_info'; -- 重新执行SQL看效果 EXPLAIN (ANALYZE, BUFFERS) SELECT oi.id, oi.order_no, oi.amount, oi.create_time FROM order_info oi WHERE oi.status = 'PAID' AND oi.create_time >= '2026-07-01' AND oi.create_time < '2026-08-01' ORDER BY oi.create_time DESC LIMIT 20; -- 优化后执行计划: -- Index Scan using idx_order_status_time on order_info -- Index Cond: ((status = 'PAID') AND (create_time >= '2026-07-01') AND ...) -- rows=20 -> 直接走索引,扫描20行就停了 -- 总耗时:1.8ms

2.3.4 最左前缀原则验证

-- 验证最左前缀原则:只查status能不能走索引? EXPLAIN ANALYZE SELECT * FROM order_info WHERE status = 'PAID' LIMIT 100; -- 结果:能走索引,status是联合索引的第一列 -- 验证:只查create_time能不能走索引? EXPLAIN ANALYZE SELECT * FROM order_info WHERE create_time >= '2026-07-01' LIMIT 100; -- 结果:不能走联合索引(或者走了但效率低),因为跳过了第一列status -- 解决:如果经常需要单独按时间查询,考虑单独建一个create_time的索引 -- CREATE INDEX idx_order_create_time ON order_info (create_time DESC); -- 验证:颠倒顺序能不能走索引? EXPLAIN ANALYZE SELECT * FROM order_info WHERE create_time >= '2026-07-01' AND status = 'PAID' LIMIT 100; -- 结果:能走!查询优化器会自动调整条件顺序,不用纠结SQL里的写法顺序

落地效果: SQL耗时从4280ms降到1.8ms,提升2377倍。索引大小约280MB,存储空间增加约3%,但性能收益巨大。

踩坑实录第3坑:索引列顺序写反了

第一次建索引时想当然把create_time放前面了,因为"时间范围查询用得多"。结果发现走索引的效果很差——因为先按时间范围扫描,再过滤status,等于扫了很大一片索引。后来把status(等值)放前面,create_time(范围)放后面,一下就快了。

原则记住:等值列在前,范围列在后。

2.4 案例二:覆盖索引 + 避免回表

2.4.1 问题现场

-- 问题SQL:订单列表查询接口,只需要订单号、金额、创建时间三个字段 -- 虽然走了索引,但每次都要回表查数据 SELECT order_no, amount, create_time FROM order_info WHERE status = 'PAID' AND create_time >= '2026-07-01' ORDER BY create_time DESC LIMIT 20; -- 执行计划: -- Index Scan using idx_order_status_time on order_info -- Index Cond: ... -- Buffers: shared hit=60 read=20 -- 耗时:28ms -- 分析:走了索引,但每次命中都要回表(Heap Fetch)去取order_no和amount字段

2.4.2 优化方案:创建覆盖索引

-- ============================================= -- 方案:把查询需要的所有列都放进索引,实现Index Only Scan -- ============================================= -- 方式1:用INCLUDE语法把需要的列加进索引叶子节点 -- (推荐!不影响索引键顺序,语义清晰) CREATE INDEX idx_order_status_cover ON order_info (status, create_time DESC) INCLUDE (order_no, amount); -- 方式2:直接建复合索引 -- CREATE INDEX idx_order_status_cover2 -- ON order_info (status, create_time DESC, order_no, amount); -- 区别:方式1的order_no和amount不在索引树的键中,不能用于排序和过滤, -- 但占用更小,维护成本更低。看业务需要选择。 -- 验证效果 EXPLAIN (ANALYZE, BUFFERS) SELECT order_no, amount, create_time FROM order_info WHERE status = 'PAID' AND create_time >= '2026-07-01' ORDER BY create_time DESC LIMIT 20; -- 优化后执行计划: -- Index Only Scan using idx_order_status_cover on order_info -- Index Cond: ... -- Heap Fetches: 0 ← 关键!0次回表 -- Buffers: shared hit=5 read=0 -- 总耗时:0.12ms

2.4.3 覆盖索引的适用场景判断

-- ============================================= -- 判断一条SQL是否适合建覆盖索引 -- ============================================= -- 1. SQL是否只返回少数几个列? -- 是 → 适合 -- 否(SELECT *)→ 不适合,列太多索引会膨胀 -- 2. 这条SQL的调用频率高不高? -- 高(每秒几十上百次)→ 值得建 -- 低(一天几次)→ 没必要,省那点时间不值得 -- 3. 索引维护成本能不能接受? -- 表读多写少 → 放心建 -- 表写入极其频繁 → 慎重,每个写入都要多维护一个索引 -- 4. 索引大小和表大小的比例? -- 索引大小 < 表大小的20% → 可以接受 -- 索引大小 > 表大小的50% → 考虑值不值 -- 查看各索引大小 SELECT schemaname, tablename, indexname, pg_size_pretty(pg_relation_size(indexrelid)) AS idx_size, pg_size_pretty(pg_relation_size(relid)) AS table_size, round(pg_relation_size(indexrelid) * 100.0 / NULLIF(pg_relation_size(relid), 0), 2) AS idx_table_ratio_pct FROM sys_stat_user_indexes WHERE tablename = 'order_info' ORDER BY pg_relation_size(indexrelid) DESC;

落地效果: 该SQL耗时从28ms降到0.12ms,提升233倍。Heap Fetches从每次20次降到0,完全消除回表开销。该接口每天调用约5万次,累计每天节省约23分钟的数据库时间。

2.5 案例三:表达式索引解决函数列查询

2.5.1 问题现场

-- 问题SQL:按日期查询订单,用DATE函数截断时间 -- 虽然create_time有索引,但函数包了一层,索引失效了 SELECT COUNT(*) FROM order_info WHERE DATE(create_time) = '2026-07-15' AND status = 'PAID'; -- 执行计划: -- Seq Scan on order_info -- Filter: ((date(create_time) = '2026-07-15'::date) AND (status = 'PAID')) -- 全表扫描,耗时:3.8秒

踩坑实录第4坑:在索引列上用函数,索引直接失效

这是新手最常犯的错误——明明加了索引,但SQL里写了个DATE(create_time)或者UPPER(user_name),结果索引完全没用上。MySQL有函数索引(Generated Column + Index),金仓也有对应的方案——表达式索引。

2.5.2 优化方案:表达式索引

-- ============================================= -- 方案1:创建表达式索引(基于函数计算结果建索引) -- ============================================= CREATE INDEX idx_order_date_status ON order_info (DATE(create_time), status); -- 验证效果 EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM order_info WHERE DATE(create_time) = '2026-07-15' AND status = 'PAID'; -- 执行计划: -- Aggregate -- -> Bitmap Heap Scan on order_info -- Recheck Cond: ((date(create_time) = '2026-07-15'::date) AND (status = 'PAID')) -- -> Bitmap Index Scan on idx_order_date_status -- Index Cond: ... -- 总耗时:15ms ← 从3.8秒降到15毫秒 -- ============================================= -- 方案2:改写SQL,避免对列使用函数 -- (效果一样,但能用到普通索引,适用面更广) -- ============================================= EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM order_info WHERE create_time >= '2026-07-15 00:00:00' AND create_time < '2026-07-16 00:00:00' AND status = 'PAID'; -- 这个写法能走之前建的 idx_order_status_time 联合索引 -- 总耗时:12ms

2.5.3 常见的表达式索引场景

-- 场景1:大小写不敏感查询 CREATE INDEX idx_user_name_lower ON user_info (LOWER(user_name)); -- 使用方式:SELECT * FROM user_info WHERE LOWER(user_name) = 'zhangsan'; -- 场景2:手机号脱敏后查询(比如只查后四位) CREATE INDEX idx_user_mobile_last4 ON user_info (RIGHT(mobile, 4)); -- 使用方式:SELECT * FROM user_info WHERE RIGHT(mobile, 4) = '1234'; -- 场景3:JSON字段某个属性查询 CREATE INDEX idx_order_extra_payway ON order_info ((extra->>'payWay')); -- 使用方式:SELECT * FROM order_info WHERE extra->>'payWay' = 'ALIPAY'; -- 场景4:年月分组统计 CREATE INDEX idx_order_year_month ON order_info (TO_CHAR(create_time, 'YYYY-MM')); -- 使用方式:SELECT TO_CHAR(create_time, 'YYYY-MM'), COUNT(*) -- FROM order_info GROUP BY TO_CHAR(create_time, 'YYYY-MM');

落地效果: 日期统计查询从3800ms降到15ms,提升253倍。表达式索引大小约180MB,维护成本与普通索引相当。

2.6 案例四:部分索引缩小范围提升效率

2.6.1 问题现场

-- 问题SQL:查询"待支付"状态的订单,但待支付的订单只占总量的2% -- 索引建了但里面大部分是已完成的订单数据,索引体积大,扫描效率不高 SELECT * FROM order_info WHERE status = 'PENDING' AND create_time >= '2026-08-01' ORDER BY create_time DESC LIMIT 10; -- 普通索引 idx_order_status_time 包含了所有状态的数据 -- 其中98%是PAID/CANCELLED等终态数据,只有2%是PENDING -- 索引体积大,扫描路径长

2.6.2 优化方案:部分索引

-- ============================================= -- 方案:只针对PENDING状态建索引,大大缩小索引范围 -- 适合:状态分布极不均匀,某几个值占绝大多数 -- ============================================= -- 创建部分索引:只为待支付的订单建索引 CREATE INDEX idx_order_pending_time ON order_info (create_time DESC) WHERE status = 'PENDING'; -- 只包含status='PENDING'的行 -- 验证索引大小对比 SELECT indexname, pg_size_pretty(pg_relation_size(indexrelid)) AS 索引大小 FROM sys_stat_user_indexes WHERE tablename = 'order_info' AND indexname LIKE '%status%' OR indexname LIKE '%pending%' ORDER BY pg_relation_size(indexrelid); -- 结果对比: -- idx_order_status_time (联合索引) → 280 MB -- idx_order_pending_time (部分索引) → 12 MB ← 只有联合索引的1/23! -- 验证执行计划 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM order_info WHERE status = 'PENDING' AND create_time >= '2026-08-01' ORDER BY create_time DESC LIMIT 10; -- 执行计划: -- Index Scan using idx_order_pending_time on order_info -- Index Cond: (create_time >= '2026-08-01') -- Filter: (status = 'PENDING') -- Rows=10 ← 直接从部分索引里取 -- 总耗时:0.35ms

2.6.3 部分索引的适用场景

-- 适合建部分索引的场景: -- 场景1:状态分布极不均匀(20-80法则) -- 例:订单表中95%的订单是"已完成"状态,只有5%是其他状态 -- 只为那5%的活跃数据建索引 CREATE INDEX idx_order_active ON order_info (create_time) WHERE status IN ('PENDING', 'PROCESSING', 'SHIPPED'); -- 场景2:软删除表,只查未删除的数据 -- 绝大多数查询都带 is_deleted = false CREATE INDEX idx_user_active ON user_info (user_name) WHERE is_deleted = false; -- 场景3:特定类型的高频查询 -- 例:VIP用户查询远多于普通用户 CREATE INDEX idx_vip_users ON user_info (vip_level, last_login_time) WHERE is_vip = true; -- 场景4:NULL值特别多的列 -- 大部分行都是NULL,只有少量行有值 CREATE INDEX idx_order_remark ON order_info (remark) WHERE remark IS NOT NULL;

落地效果: 待支付订单查询从28ms降到0.35ms,提升80倍。索引体积从280MB降到12MB,减少95%的索引存储空间。

2.7 SQL改写五大战术:从写法上榨干性能

有些SQL不是加索引就能解决的,SQL写法本身有问题,加了索引也白搭。下面是五个最常见的SQL写法坑和改写方案。

战术一:隐式类型转换——索引失效第一杀手

-- ❌ 错误写法:字符串和数字比较,发生隐式转换 SELECT * FROM user_info WHERE mobile = 13800138000; -- mobile字段是VARCHAR,但传了数字,会导致每行都转成数字再比较 -- 执行计划:Seq Scan全表扫描,索引失效 -- ✅ 正确写法:类型保持一致 SELECT * FROM user_info WHERE mobile = '13800138000'; -- 执行计划:Index Scan,正常走索引 -- 验证方法:看执行计划里有没有Filter条件出现类型转换函数 -- 类似:Filter: ((mobile)::numeric = 13800138000) -- 出现::就是发生了隐式类型转换,索引肯定用不上

战术二:大偏移量分页优化——OFFSET越翻越慢

-- ❌ 常见写法:OFFSET越大越慢 SELECT * FROM order_info WHERE status = 'PAID' ORDER BY create_time DESC LIMIT 20 OFFSET 9800; -- 翻到第491页 -- 需要先扫过9820行再取20行,越往后越慢 -- 100页左右:100ms -- 500页左右:2.5秒 -- 1000页左右:8秒+ -- ✅ 方案1:游标分页(推荐!前提是有唯一排序键) SELECT * FROM order_info WHERE status = 'PAID' AND (create_time, id) < ('上次最后一条的时间', 上次最后一条的ID) ORDER BY create_time DESC, id DESC LIMIT 20; -- 每次只基于上一页的最后一条记录往下查 -- 不管翻到第几页,速度都一样快(2~3ms) -- ✅ 方案2:延迟关联——先分页查ID,再JOIN回表 SELECT oi.* FROM ( SELECT id FROM order_info WHERE status = 'PAID' ORDER BY create_time DESC LIMIT 20 OFFSET 9800 ) t JOIN order_info oi ON t.id = oi.id ORDER BY oi.create_time DESC; -- 原理:子查询用覆盖索引快速定位ID(Index Only Scan), -- 再用ID关联回表取数据,减少回表次数

战术三:OR条件拆UNION——索引各用各的

-- ❌ 写法:OR条件可能导致两个列的索引都用不上 SELECT * FROM order_info WHERE user_id = 12345 OR merchant_id = 67890 ORDER BY create_time DESC LIMIT 20; -- 执行计划:可能变成全表扫描,因为单个索引覆盖不了OR两边 -- ✅ 改写:拆成两条SQL用UNION ALL合并 -- (如果可能有重复,用UNION,去重但慢一点) SELECT * FROM ( SELECT * FROM order_info WHERE user_id = 12345 UNION ALL SELECT * FROM order_info WHERE merchant_id = 67890 ) t ORDER BY create_time DESC LIMIT 20; -- 改写后每条子查询都能走各自的索引,速度翻倍 -- 金仓也支持BitmapOr(位图或运算),但效果不如UNION稳定 -- 可以通过enable_bitmapscan参数控制

战术四:避免SELECT *——能少取就少取

-- ❌ 写法:SELECT * 取出所有列 SELECT * FROM order_info WHERE id = 10001; -- 问题1:不需要的列也取出来了,增加I/O和网络传输 -- 问题2:无法使用覆盖索引(Index Only Scan) -- 问题3:表结构变化时可能有意外问题 -- ✅ 写法:明确指定需要的列 SELECT id, order_no, amount, status, create_time FROM order_info WHERE id = 10001; -- 加上对应的覆盖索引,直接Index Only Scan,不回表

战术五:子查询JOIN化——给优化器更多选择

-- ❌ 写法:IN子查询(有时候优化器处理不好) SELECT * FROM order_info WHERE user_id IN ( SELECT id FROM user_info WHERE vip_level = 'GOLD' ); -- ✅ 写法:改成JOIN SELECT DISTINCT oi.* FROM order_info oi JOIN user_info ui ON oi.user_id = ui.id WHERE ui.vip_level = 'GOLD'; -- 好处:JOIN的执行路径选择更多(Nested Loop/Hash Join/Merge Join), -- 优化器更容易选出最优方案 -- 不过注意:金仓的优化器已经很智能了,大部分情况下IN和JOIN效果一样 -- 只有在复杂SQL(多层嵌套)时,JOIN写法的稳定性更好 -- 建议以EXPLAIN ANALYZE的实际结果为准,不要想当然

2.8 Hint与Query Map:强制执行计划不走偏

2.8.1 Hint语法与常用场景

有时候优化器选错了执行计划(比如统计信息不准、数据倾斜),这时候就需要用Hint强行指定执行方式。

-- ============================================= -- 金仓Hint语法:/*+ hint内容 */ -- 注意:Hint必须紧跟在SELECT/INSERT/UPDATE/DELETE后面 -- ============================================= -- Hint 1:强制走某个索引(Index Scan) SELECT /*+ IndexScan(oi idx_order_status_time) */ oi.order_no, oi.amount, oi.create_time FROM order_info oi WHERE oi.status = 'PAID' AND oi.create_time >= '2026-07-01' ORDER BY oi.create_time DESC LIMIT 20; -- 类似MySQL的FORCE INDEX -- Hint 2:强制全表扫描(明确不让走索引) SELECT /*+ SeqScan(oi) */ COUNT(*) FROM order_info oi WHERE oi.amount > 100; -- 当返回的数据量很大(超过表的20~30%)时,全表扫描可能比索引扫描还快 -- Hint 3:指定连接方式 SELECT /*+ HashJoin(oi ui) */ oi.order_no, ui.user_name FROM order_info oi JOIN user_info ui ON oi.user_id = ui.id WHERE oi.create_time >= '2026-07-01'; -- NestLoop(表1 表2) :强制嵌套循环 -- HashJoin(表1 表2) :强制哈希连接 -- MergeJoin(表1 表2) :强制归并连接 -- Hint 4:指定连接顺序 SELECT /*+ Leading(oi ui) */ oi.order_no, ui.user_name FROM order_info oi JOIN user_info ui ON oi.user_id = ui.id WHERE oi.status = 'PAID'; -- Leading(表1 表2) 表示先扫描表1,再用表1的结果去连表2 -- 类似MySQL的STRAIGHT_JOIN -- Hint 5:并行查询(数据量大时加速) SELECT /*+ Parallel(8) */ status, COUNT(*) FROM order_info GROUP BY status; -- Parallel(N):设置并行度为N -- 注意:并行查询只有在数据量足够大时才有收益,小数据量反而因为启动开销更慢

2.8.2 Query Map:固化执行计划

如果某条SQL的执行计划总是选错,改代码又不方便,可以用Query Map功能把优化后的执行计划固化下来。

-- ============================================= -- Query Map使用步骤 -- ============================================= -- 1. 查看Query Map是否开启 SHOW enable_query_rule; SHOW query_rule_enabled; -- 2. 创建Query Map规则 -- 把原始SQL的执行计划替换为带Hint的优化版本 SELECT create_query_rule( 'rule_001_order_list', -- 规则名称 'SELECT * FROM order_info WHERE status = $1 AND create_time >= $2 ORDER BY create_time DESC LIMIT $3', 'SELECT /*+ IndexScan(order_info idx_order_status_time) */ * FROM order_info WHERE status = $1 AND create_time >= $2 ORDER BY create_time DESC LIMIT $3', 'text', -- 匹配模式:text精确匹配 true -- 是否启用 ); -- 3. 查看已创建的Query Map规则 SELECT * FROM query_rule; -- 4. 验证规则是否生效 -- 执行原始SQL(不带Hint),看执行计划是不是用了指定的索引 EXPLAIN ANALYZE SELECT * FROM order_info WHERE status = 'PAID' AND create_time >= '2026-07-01' ORDER BY create_time DESC LIMIT 20; -- 如果执行计划里出现了指定的索引扫描,说明Query Map生效了 -- 5. 禁用/启用规则 SELECT alter_query_rule('rule_001_order_list', false); -- 禁用 SELECT alter_query_rule('rule_001_order_list', true); -- 启用 -- 6. 删除规则 SELECT drop_query_rule('rule_001_order_list');

踩坑实录第5坑:Hint写错位置不生效

最开始用Hint时,我随手写在SELECT前面的注释里,结果怎么都不生效。查了半天才发现,Hint必须紧跟在SELECT关键字后面,而且必须是/*+格式(加号紧跟在星号后面),中间不能有空格。

✅ 对:SELECT /*+ IndexScan(t idx_xxx) */ * FROM ...
❌ 错:SELECT /* IndexScan(t idx_xxx) */ * FROM ...(没有+号)
❌ 错:/*+ IndexScan(t idx_xxx) */ SELECT * FROM ...(Hint在SELECT前面)

2.9 长效治理:慢SQL巡检与索引体检机制

调优不是一劳永逸的,数据量在涨,业务在变,SQL也会越来越慢。建立一套长效的巡检机制,比一次性调优更有价值。

2.9.1 每日慢SQL巡检脚本

-- ============================================= -- 每日慢SQL巡检报告SQL -- 每天早上跑一次,生成巡检报告 -- ============================================= -- 1. Top 10 总耗时SQL SELECT 'Top总耗时' AS 类别, row_number() OVER (ORDER BY total_exec_time DESC) AS 排名, round(total_exec_time::numeric / 1000, 2) AS 总耗时_秒, calls AS 调用次数, round(mean_exec_time::numeric, 2) AS 平均耗时_ms, round(shared_blks_hit * 100.0 / NULLIF(shared_blks_hit + shared_blks_read, 0), 2) AS 缓存命中率_pct, left(query, 80) AS SQL预览 FROM sys_stat_statements WHERE query NOT LIKE '%sys_stat_statements%' AND calls > 10 ORDER BY total_exec_time DESC LIMIT 10; -- 2. Top 10 平均耗时SQL SELECT 'Top平均耗时' AS 类别, row_number() OVER (ORDER BY mean_exec_time DESC) AS 排名, calls AS 调用次数, round(mean_exec_time::numeric, 2) AS 平均耗时_ms, round(max_exec_time::numeric, 2) AS 最大耗时_ms, left(query, 80) AS SQL预览 FROM sys_stat_statements WHERE calls > 5 AND mean_exec_time > 500 ORDER BY mean_exec_time DESC LIMIT 10; -- 3. 索引使用情况体检 SELECT schemaname, relname AS 表名, indexrelname AS 索引名, idx_scan AS 索引扫描次数, idx_tup_read AS 索引读取行数, idx_tup_fetch AS 回表行数, CASE WHEN idx_scan = 0 THEN '【未使用】' WHEN idx_scan < 10 AND age(CURRENT_DATE, stat_since) > interval '7 days' THEN '【极少使用】' ELSE '正常' END AS 使用状态, pg_size_pretty(pg_relation_size(indexrelid)) AS 索引大小 FROM sys_stat_user_indexes ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC; -- 4. 全表扫描最多的表(重点关注) SELECT relname AS 表名, seq_scan AS 全表扫描次数, seq_tup_read AS 全表扫描行数, idx_scan AS 索引扫描次数, CASE WHEN seq_scan > idx_scan * 10 AND seq_scan > 100 THEN '【异常 - 全表扫描过多】' WHEN seq_scan = 0 THEN '无全表扫描' ELSE '正常' END AS 状态 FROM sys_stat_user_tables ORDER BY seq_tup_read DESC LIMIT 20; -- 5. 膨胀的表和索引(需要VACUUM或重建索引) SELECT schemaname, relname AS 表名, n_live_tup AS 存活行数, n_dead_tup AS 死亡行数, round(n_dead_tup * 100.0 / NULLIF(n_live_tup, 0), 2) AS 死元组比例_pct, last_vacuum AS 上次手动VACUUM时间, last_autovacuum AS 上次自动VACUUM时间, CASE WHEN n_dead_tup * 100.0 / NULLIF(n_live_tup, 0) > 20 THEN '【膨胀严重 - 需要VACUUM】' ELSE '正常' END AS 状态 FROM sys_stat_user_tables WHERE n_live_tup > 10000 ORDER BY n_dead_tup DESC LIMIT 20;

2.9.2 Java端SQL规范校验

/** * SQL代码审查工具 - MyBatis拦截器实现 * 在开发/测试环境开启,自动检测低质量SQL */ @Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) @Component @ConditionalOnProperty(name = "app.sql-review.enabled", havingValue = "true") public class SqlReviewInterceptor implements Interceptor { private static final Logger log = LoggerFactory.getLogger(SqlReviewInterceptor.class); private static final long SLOW_THRESHOLD = 500; // 500ms @Override public Object intercept(Invocation invocation) throws Throwable { long start = System.currentTimeMillis(); Object result = invocation.proceed(); long cost = System.currentTimeMillis() - start; if (cost > SLOW_THRESHOLD) { StatementHandler handler = (StatementHandler) invocation.getTarget(); String sql = handler.getBoundSql().getSql(); // 慢SQL告警 log.warn("[SQL慢查询警告] 耗时: {}ms, SQL: {}", cost, truncateSql(sql)); // 质量检查 checkSqlQuality(sql); } return result; } /** * SQL质量检查 */ private void checkSqlQuality(String sql) { String lowerSql = sql.toLowerCase(); // 检查1:SELECT * if (lowerSql.matches("select\\s+\\*\\s+from")) { log.warn("[SQL质量告警] 发现SELECT *写法,请明确指定列名"); } // 检查2:没有WHERE条件 if (!lowerSql.contains("where") && lowerSql.contains("select")) { // 排除COUNT(*)等聚合查询 if (!lowerSql.contains("count(")) { log.warn("[SQL质量告警] 查询语句没有WHERE条件,可能是全表扫描"); } } // 检查3:可能导致索引失效的函数调用 if (lowerSql.contains("date(") || lowerSql.contains("upper(") || lowerSql.contains("lower(") || lowerSql.contains("substring(")) { log.warn("[SQL质量告警] 在WHERE条件中使用了函数,可能导致索引失效"); } // 检查4:大OFFSET分页 if (lowerSql.contains("offset")) { log.warn("[SQL质量告警] 使用了OFFSET分页,大偏移量下性能差,建议改用游标分页"); } // 检查5:隐式类型转换风险(数字列和字符串比较) // (简化处理,实际需要结合元数据判断) } private String truncateSql(String sql) { return sql.length() > 200 ? sql.substring(0, 200) + "..." : sql; } }

落地效果: 建立每日巡检机制后,慢SQL数量从日均1200+次降到日均不足10次。新上线的SQL在测试环境就能被质量检查拦截,避免带着问题上生产。


三、参考资料

  1. KingbaseES V9R3C18产品手册 - JDBC驱动使用指南
    https://www.kingbase.com.cn/download.html#drive

  2. KingbaseES MySQL兼容版开发指南

    https://www.kingbase.com.cn/download.html#database

  3. Druid官方文档 - 常见问题
    https://github.com/alibaba/druid/wiki/常见问题

  4. HikariCP官方文档 - 配置说明
    https://github.com/brettwooldridge/HikariCP

  5. KingbaseES V9R3C18 知识图谱
    https://bbs.kingbase.com.cn/knowledge/kes/KingbaseES%E7%9F%A5%E8%AF%86%E5%BA%93/KingbaseES%E6%95%B0%E6%8D%AE%E5%BA%93%E7%9F%A5%E8%AF%86%E5%9B%BE%E8%B0%B1


个人实战总结

做了这么多年DBA,SQL调优这件事,说难也难,说简单也简单。难在每一条慢SQL背后的业务场景和数据分布都不一样,没有万能药方;简单在套路就那么几条——找慢SQL、看执行计划、加索引、改写法、上Hint,循环往复。

下面是我从MySQL迁金仓过程中沉淀的几条最核心的调优经验:

第一,调优的本质是减少数据访问量,而不是炫技。 很多人一上来就想搞复杂的优化方案——分区表、物化视图、并行查询——但90%的慢SQL,加个合适的索引就能解决。先把基础的事情做好,再考虑高级方案。

第二,金仓的执行计划和MySQL不一样,要重新学习。 MySQL的执行计划是横向的(id, select_type, type, rows…),金仓的是纵向树状的(Seq Scan → Sort → Nested Loop…)。一开始可能不太适应,但只要记住"从最内层往外读"这个口诀,看懂不难。而且金仓有EXPLAIN (ANALYZE, BUFFERS),能看到实际执行时间和I/O情况,比MySQL的EXPLAIN信息丰富多了。

第三,索引不是越多越好。 每加一个索引,写入的时候就要多维护一份,INSERT/UPDATE/DELETE都会变慢。我见过有人给表建了十几个索引,结果写入性能掉了一半。建议:联合索引优先(一个顶多个)、部分索引省空间(数据不均匀时)、覆盖索引提性能(高频查询用)。定期检查索引使用情况,没用的索引果断删掉。

第四,统计信息不准是优化器"变傻"的最常见原因。 有时候一条SQL本来跑得好好的,突然变慢了,十有八九是统计信息过期了。金仓的自动分析(autoanalyze)默认是开的,但如果表数据变化很快(比如批量导入后),建议手动跑一下ANALYZE 表名;更新统计信息。统计信息准了,优化器才能选出正确的执行计划。

第五,监控比调优更重要。 花一周时间把慢查询日志、sys_stat_statements、Druid监控都配好,设好告警阈值。这样慢SQL一出现你就能收到通知,而不是等用户投诉了才知道。被动救火和主动治理,完全是两个层次。

第六,SQL规范要从开发阶段抓起。 你在生产环境遇到的很多慢SQL,其实在开发阶段就能避免。SELECT *、索引列用函数、大OFFSET分页、隐式类型转换……这些都是教科书级别的反模式,代码评审时拦下来,比后面调优省事一百倍。

国产数据库的性能调优这条路,和MySQL既有相通之处,也有金仓自己的特色。核心原理是一样的——减少I/O、减少计算、用好缓存,但具体到工具、语法、参数上又有差异。多动手、多EXPLAIN、多对比,熟能生巧。

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论