金仓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固化执行计划 → 压测验证效果,形成完整闭环。
关键优化方案:
- 慢SQL定位三板斧:慢查询日志 +
sys_stat_statements+ Druid监控三重定位,精准找出Top10慢SQL - 执行计划深度解读:金仓执行计划7类常见节点的识别与调优方向
- 索引优化四大实战案例:联合索引最左前缀、覆盖索引避免回表、表达式索引解决函数索引问题、部分索引缩小索引范围
- SQL改写五大战术:子查询JOIN化、隐式类型转换修复、大偏移量分页优化、OR条件拆分UNION、避免SELECT *
- Hint与Query Map:用Hint强制执行计划,用Query Map固化最优执行路径
- 长效治理机制: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
执行计划分析(从内到外读):
- 最内层 Seq Scan on order_info → 全表扫描order_info表,扫描了200万行(
rows=2000000),过滤后剩下5万行。问题1:全表扫描,没有用到索引。 - Sort排序 → 对5万行按create_time排序,用了快排,内存2MB。问题2:排序量太大。
- Nested Loop嵌套循环 → 用排序后的结果去关联user_info表,循环了1000次(因为LIMIT 20 OFFSET 980,所以要查1000条)。
- Index Scan on user_info → user_info用主键索引查,每次1行,这部分没问题。
- 总耗时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在测试环境就能被质量检查拦截,避免带着问题上生产。
三、参考资料
-
KingbaseES V9R3C18产品手册 - JDBC驱动使用指南
https://www.kingbase.com.cn/download.html#drive -
KingbaseES MySQL兼容版开发指南
https://www.kingbase.com.cn/download.html#database
-
Druid官方文档 - 常见问题
https://github.com/alibaba/druid/wiki/常见问题 -
HikariCP官方文档 - 配置说明
https://github.com/brettwooldridge/HikariCP -
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、多对比,熟能生巧。





