KingbaseES电科金仓数据库SQL调优

前言
说实话,SQL调优这个话题,很多朋友一听就头疼。我刚开始接触数据库的时候也是这样,看着那些执行计划里密密麻麻的信息,完全不知道从哪儿下手。但是干了这么多年,我发现其实SQL调优没那么玄乎,掌握几个核心思路,很多问题都能迎刃而解。
在数据库应用系统中,SQL语句的执行效率直接影响着整个系统的性能表现。KingbaseES作为国产主流数据库,提供了丰富的SQL优化手段和工具。今天咱们就聊聊KingbaseES的SQL调优,我会尽量用大白话给大家讲清楚。
一、使用索引优化
索引这个东西,我喜欢把它比作书的目录。你想想,如果让你在一本几百页的书里找某个知识点,有目录和没目录,效率能差多少倍?数据库也是一样的道理。
1.1 创建合适的索引
索引不是越多越好,也不是随便建的。我见过有些项目,开发人员一股脑给所有列都建了索引,结果插入数据的时候慢得要命。记住一个原则:在经常查询的列上建索引,在经常更新的列上要慎重。
sql
-- 创建单列索引
-- 比如你经常按员工姓名查询,那就给name列建个索引
CREATE INDEX idx_employee_name ON employee(name);
-- 创建复合索引
-- 如果你经常按客户ID和订单日期一起查,就建个组合索引
-- 注意:列的顺序很重要!把过滤性强的列放前面
CREATE INDEX idx_order_customer_date ON orders(customer_id, order_date);
-- 创建唯一索引
-- 员工ID肯定不能重复,用唯一索引既能加速查询,又能保证数据唯一性
CREATE UNIQUE INDEX idx_employee_id ON employee(employee_id);
1.2 索引使用的坑
这里我要特别提醒几个常见的坑,我自己都踩过:
第一个坑:在索引列上用函数
sql
-- 这样写索引就废了,数据库会全表扫描
SELECT * FROM employee WHERE UPPER(name) = 'ZHANG SAN';
-- 应该这样写
SELECT * FROM employee WHERE name = 'ZHANG SAN';
第二个坑:索引列的顺序搞反了
sql
-- 如果你建了索引 (customer_id, order_date)
-- 这个查询能用上索引
SELECT * FROM orders WHERE customer_id = 100 AND order_date = '2024-01-01';
-- 这个也能用上(只用了索引的第一列)
SELECT * FROM orders WHERE customer_id = 100;
-- 但这个就用不上了!因为跳过了第一列
SELECT * FROM orders WHERE order_date = '2024-01-01';
第三个坑:索引也需要维护 索引用久了会产生碎片,就像硬盘碎片整理一样,定期重建一下效果会更好:
sql
-- 重建索引,我一般在业务低峰期做
REINDEX INDEX idx_employee_name;
二、更新统计信息
这个问题我遇到过好几次。有一次线上系统突然变慢,查了半天发现是统计信息过期了。数据库优化器就像一个导航系统,如果地图是旧的,它给你规划的路线肯定不是最优的。
2.1 什么时候需要更新统计信息?
- 大批量导入数据之后
- 删除了大量数据之后
- 表结构发生变化之后
- 查询突然变慢,但SQL和索引都没问题的时候
2.2 怎么更新?
sql
-- 更新单表统计信息,这个操作很快,放心用
ANALYZE employee;
-- 如果你想更新所有表,直接这样
ANALYZE;
-- 如果表特别大,可以只更新关键列
ANALYZE employee(name, department_id);
小技巧:我一般会在定时任务里加上ANALYZE,比如每天凌晨跑一次,这样就不用担心统计信息过期了。
2.3 配置自动统计信息收集
sql
-- 让数据库自己维护统计信息,省心省力
ALTER TABLE employee SET (autovacuum_enabled = true);
三、调整work_mem参数
work_mem这个参数,说白了就是给排序和哈希操作分配的内存。默认值一般比较保守,如果你的服务器内存够大,适当调大能明显提升性能。
但是注意!这个参数不是越大越好。我见过有人直接设置成几个G,结果系统内存被吃光了。
3.1 什么时候需要调整?
看执行计划的时候,如果看到"external merge"或者"disk"这样的字眼,说明内存不够用了,数据被写到磁盘临时文件里了,这时候就该考虑增大work_mem。
3.2 怎么调整?
sql
-- 我的建议是先在会话级别试试,看看效果
SET work_mem = '256MB';
-- 跑你的查询
SELECT * FROM large_table ORDER BY column1;
-- 如果效果好,再考虑改全局配置
-- 用完记得恢复,不然可能影响其他查询
RESET work_mem;
3.3 全局配置
在kingbase.conf中配置:
ini
# 我一般设置为总内存的1-2%
# 比如64GB内存的服务器,设置64MB-128MB比较合适
work_mem = 64MB
经验之谈:如果你的系统有很多并发连接,work_mem千万别设太大。假设你设置了256MB,有100个连接同时跑排序操作,那就是25GB内存!服务器直接崩溃。
四、使用分区表
分区表这个功能,对付大表特别管用。我之前有个项目,一张订单表有几亿条数据,查询慢得要死。后来按月份做了分区,查询速度提升了十几倍。
4.1 什么样的表适合分区?
- 数据量特别大的表(比如超过几千万行)
- 有明显时间特征的表(比如订单表、日志表)
- 经常按某个范围查询的表
4.2 创建分区表
sql
-- 创建范围分区表
-- 这里我按日期分区,因为订单表通常都是按时间查询的
CREATE TABLE sales (
id INT,
sale_date DATE,
amount DECIMAL(10,2)
) PARTITION BY RANGE (sale_date);
-- 创建分区,我习惯按季度分
-- 这样既不会分区太多,也能有效缩小查询范围
CREATE TABLE sales_2024_q1 PARTITION OF sales
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE sales_2024_q2 PARTITION OF sales
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
4.3 分区的好处
sql
-- 查询2024年第一季度的数据
-- 数据库只会扫描sales_2024_q1这个分区,其他分区根本不碰
SELECT * FROM sales
WHERE sale_date BETWEEN '2024-01-01' AND '2024-03-31';
注意事项:
- 分区字段最好是不会变的,比如订单日期。如果你按状态分区,状态一变就得跨分区更新,反而更慢
- 分区不要分太细,我见过有人按天分区,结果一年就365个分区,管理起来很麻烦
- 记得给每个分区都建索引
五、使用物化视图
物化视图是个好东西,特别适合那种计算复杂、但数据不需要实时更新的场景。比如各种报表、统计数据。
5.1 什么时候用物化视图?
我一般在这几种情况下用:
- 复杂的多表关联查询
- 大量聚合计算
- 查询频繁但数据更新不频繁
- 报表类查询
5.2 创建物化视图
sql
-- 比如你要做一个销售统计报表
-- 每次查都要关联好几张表,还要做聚合,特别慢
-- 不如把结果存起来
CREATE MATERIALIZED VIEW mv_sales_summary AS
SELECT
department_id,
DATE_TRUNC('month', sale_date) AS month,
SUM(amount) AS total_amount,
COUNT(*) AS sale_count
FROM sales
GROUP BY department_id, DATE_TRUNC('month', sale_date);
-- 别忘了给物化视图也建索引
CREATE INDEX idx_mv_sales_dept ON mv_sales_summary(department_id);
5.3 刷新物化视图
sql
-- 完全刷新,会锁表,适合在业务低峰期做
REFRESH MATERIALIZED VIEW mv_sales_summary;
-- 并发刷新,不会阻塞查询,但需要物化视图有唯一索引
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sales_summary;
实战经验:我一般会在定时任务里刷新物化视图,比如每天凌晨3点刷新一次。白天业务高峰期,用户查询的都是物化视图,速度飞快。
六、缓存执行计划
这个功能很多人不知道,其实挺有用的。如果你的SQL是参数化的,用PREPARE可以让数据库缓存执行计划,不用每次都重新解析。
6.1 什么时候用?
- 同一个SQL要执行很多次,只是参数不同
- 在应用程序里用预编译语句(PreparedStatement)
6.2 怎么用?
sql
-- 准备语句,$1是参数占位符
PREPARE get_employee (INT) AS
SELECT * FROM employee WHERE department_id = $1;
-- 执行,第一次会生成执行计划
EXECUTE get_employee(10);
-- 再执行,直接用缓存的执行计划,快!
EXECUTE get_employee(20);
EXECUTE get_employee(30);
-- 用完记得释放
DEALLOCATE get_employee;
注意:如果参数的数据分布差异很大,缓存的执行计划可能不是最优的。比如某个部门有1万人,另一个部门只有10个人,用同一个执行计划就不合适了。
七、调整性能参数
数据库的性能参数,就像汽车的调校一样,需要根据实际情况调整。默认配置是为了兼容各种环境,往往比较保守。
7.1 关键性能参数
sql
-- shared_buffers:数据库的缓存池
# 我的经验是设置为系统内存的25%左右
# 比如32GB内存的服务器,设置8GB
shared_buffers = 8GB
-- effective_cache_size:告诉优化器系统有多少内存可以用来缓存
# 这个可以设大一点,50-75%都行
# 它不会真的占用内存,只是给优化器一个参考
effective_cache_size = 24GB
-- random_page_cost:随机读取的成本
# 如果用的是SSD,可以设小一点,1.1左右
# 机械硬盘的话,保持默认的4.0
random_page_cost = 1.1
-- maintenance_work_mem:维护操作的内存
# 用于CREATE INDEX、VACUUM等操作
# 可以设大一点,512MB-1GB
maintenance_work_mem = 512MB
7.2 查询优化参数
sql
-- JIT编译,对复杂查询有帮助
# 但也会增加编译开销,简单查询反而可能变慢
jit = on
-- 并行查询的工作进程数
# 根据CPU核心数设置,一般设置为核心数的一半
max_parallel_workers_per_gather = 4
调参心得:
- 一次只改一个参数,改完测试效果
- 改参数前先备份配置文件
- 不要盲目照搬别人的配置,每个系统的情况都不一样
- 改完参数记得重启数据库(有些参数需要重启才能生效)
八、使用并行查询
并行查询就是让数据库用多个CPU核心同时处理一个查询。对于大表扫描和聚合操作,效果特别明显。
8.1 什么时候用并行?
- 表数据量很大(至少几百万行)
- 查询需要扫描大量数据
- 服务器CPU核心数比较多
- 当前系统负载不高
8.2 启用并行查询
sql
-- 设置并行度,不要超过CPU核心数
SET max_parallel_workers_per_gather = 4;
-- 如果数据库不愿意用并行,可以降低并行的成本估算
-- 但这是临时方案,不建议长期使用
SET parallel_setup_cost = 0;
SET parallel_tuple_cost = 0;
8.3 查看是否使用了并行
sql
-- 用EXPLAIN看执行计划
-- 如果看到"Parallel Seq Scan"或"Gather",说明用上并行了
EXPLAIN (ANALYZE, BUFFERS)
SELECT department_id, COUNT(*)
FROM large_employee_table
GROUP BY department_id;
实战经验:
- 并行查询不是银弹,小表用并行反而更慢(因为有并行协调的开销)
- 如果系统并发很高,不要开太多并行,会抢占其他查询的资源
- OLTP系统慎用并行,更适合OLAP场景
九、SQL改写优化
有时候,同样的业务逻辑,换一种写法,性能能差好几倍。这里分享几个我常用的改写技巧。
9.1 避免SELECT *
sql
-- 这是我见过最多的坏习惯
-- 你真的需要所有列吗?很多时候只需要几个字段
SELECT * FROM employee WHERE department_id = 10;
-- 应该这样写,需要什么查什么
-- 这样不仅减少网络传输,还能用上覆盖索引
SELECT employee_id, name, salary FROM employee WHERE department_id = 10;
真实案例:我之前有个项目,一张表有50多个字段,其中有几个是TEXT类型的大字段。开发人员图省事用SELECT *,结果查询特别慢。后来改成只查需要的列,速度提升了5倍。
9.2 使用EXISTS代替IN
sql
-- IN子查询,如果子查询结果集很大,性能会很差
SELECT * FROM employee
WHERE department_id IN (SELECT id FROM department WHERE location = 'Beijing');
-- EXISTS通常更快,因为找到第一条匹配就停止了
SELECT * FROM employee e
WHERE EXISTS (
SELECT 1 FROM department d
WHERE d.id = e.department_id AND d.location = 'Beijing'
);
9.3 避免隐式类型转换
sql
-- 这个坑很隐蔽,如果employee_id是整型
-- 你用字符串去比较,索引就废了
SELECT * FROM employee WHERE employee_id = '1001';
-- 应该用正确的类型
SELECT * FROM employee WHERE employee_id = 1001;
血泪教训:有一次线上查询突然变慢,查了半天发现是前端传过来的参数类型不对。明明是数字,传成了字符串,导致索引失效,全表扫描。
9.4 使用UNION ALL代替UNION
sql
-- UNION会自动去重,需要排序,很慢
SELECT name FROM employee_2023
UNION
SELECT name FROM employee_2024;
-- 如果你确定没有重复,或者不在乎重复,用UNION ALL
-- 性能能提升好几倍
SELECT name FROM employee_2023
UNION ALL
SELECT name FROM employee_2024;
9.5 其他改写技巧
用JOIN代替子查询
sql
-- 子查询写法,可读性好但性能一般
SELECT e.name,
(SELECT d.name FROM department d WHERE d.id = e.department_id) as dept_name
FROM employee e;
-- JOIN写法,性能更好
SELECT e.name, d.name as dept_name
FROM employee e
LEFT JOIN department d ON d.id = e.department_id;
避免在WHERE子句中使用OR
sql
-- OR可能导致索引失效
SELECT * FROM employee WHERE department_id = 10 OR department_id = 20;
-- 改成IN,或者用UNION ALL
SELECT * FROM employee WHERE department_id IN (10, 20);
十、执行计划分析
执行计划是SQL调优的基础,不会看执行计划,调优就是瞎猫碰死耗子。我刚开始的时候也看不懂,后来慢慢摸索,总结了一些经验。
10.1 怎么看执行计划?
sql
-- 最基础的,看预估的执行计划
EXPLAIN SELECT * FROM employee WHERE department_id = 10;
-- 加上ANALYZE,看实际执行情况
-- 这个会真的执行SQL,所以对于UPDATE/DELETE要小心
EXPLAIN ANALYZE SELECT * FROM employee WHERE department_id = 10;
-- 详细版本,包含缓冲区信息
-- 我一般用这个,信息最全
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM employee WHERE department_id = 10;
10.2 执行计划里的关键信息
扫描方式
- Seq Scan(顺序扫描):全表扫描,小表无所谓,大表就要注意了
- Index Scan(索引扫描):用上索引了,通常是好事
- Index Only Scan(仅索引扫描):最优情况,只扫描索引,不回表
- Bitmap Index Scan:位图索引扫描,适合返回大量数据的情况
关联方式
- Nested Loop:嵌套循环,适合小表关联
- Hash Join:哈希关联,适合大表关联
- Merge Join:归并关联,需要数据已排序
成本信息
ini
cost=0.00..35.50 rows=10 width=244
cost:预估成本,前面是启动成本,后面是总成本rows:预估返回行数width:每行的平均字节数
实际执行信息
ini
actual time=0.015..0.023 rows=10 loops=1
actual time:实际执行时间(毫秒)rows:实际返回行数loops:执行次数
10.3 怎么判断有问题?
预估和实际差距很大
ini
-- 预估10行,实际10000行,说明统计信息不准
Seq Scan on employee (cost=0.00..35.50 rows=10 width=244)
(actual time=0.015..15.234 rows=10000 loops=1)
这时候需要ANALYZE更新统计信息。
看到Seq Scan on 大表
ini
-- 如果employee表有几百万行,这就是问题
Seq Scan on employee (cost=0.00..350000.00 rows=10 width=244)
应该考虑加索引。
看到"external merge"或"disk"
sql
Sort Method: external merge Disk: 102400kB
说明内存不够,数据写到磁盘了,需要增大work_mem。
Nested Loop的loops很大
ini
Nested Loop (cost=0.00..1000.00 rows=100 width=244)
(actual time=0.015..500.234 rows=100 loops=10000)
这说明外层循环了10000次,每次都要执行内层查询,性能肯定差。
十一、监控和诊断
调优不是一次性的工作,需要持续监控。我一般会设置一些监控指标,定期检查。
11.1 找出慢查询
sql
-- 启用慢查询日志
-- 记录超过1秒的查询,这个阈值可以根据业务调整
SET log_min_duration_statement = 1000;
-- 查看当前正在执行的查询
-- 这个在排查问题时特别有用
SELECT
pid,
usename,
state,
query,
query_start,
NOW() - query_start AS duration
FROM sys_stat_activity
WHERE state = 'active'
ORDER BY duration DESC;
实战技巧 :如果发现某个查询执行了很久还没结束,可以用这个SQL找出来,然后用SELECT sys_cancel_backend(pid)终止它。
11.2 查看表和索引的统计信息
sql
-- 查看表的统计信息
-- 可以看到表的大小、扫描次数、索引使用情况等
SELECT
schemaname,
relname,
seq_scan, -- 顺序扫描次数
seq_tup_read, -- 顺序扫描读取的行数
idx_scan, -- 索引扫描次数
idx_tup_fetch, -- 索引扫描获取的行数
n_tup_ins, -- 插入的行数
n_tup_upd, -- 更新的行数
n_tup_del -- 删除的行数
FROM sys_stat_user_tables
WHERE relname = 'employee';
-- 查看索引使用情况
-- 如果idx_scan是0,说明这个索引从来没用过,可以考虑删掉
SELECT
schemaname,
relname,
indexrelname,
idx_scan, -- 索引被使用的次数
idx_tup_read, -- 索引扫描返回的行数
idx_tup_fetch -- 索引扫描获取的行数
FROM sys_stat_user_indexes
WHERE relname = 'employee'
ORDER BY idx_scan;
11.3 查看缓存命中率
sql
-- 查看缓存命中率
-- 如果命中率低于95%,说明shared_buffers可能设置得太小了
SELECT
sum(heap_blks_read) as heap_read,
sum(heap_blks_hit) as heap_hit,
sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) as ratio
FROM sys_statio_user_tables;
11.4 查看锁等待
sql
-- 查看锁等待情况
-- 如果经常有锁等待,说明可能有长事务或者死锁
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS blocking_statement
FROM sys_catalog.sys_locks blocked_locks
JOIN sys_catalog.sys_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN sys_catalog.sys_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN sys_catalog.sys_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
总结

说了这么多,其实SQL调优的核心思想就几条:
- 减少数据扫描量:能用索引就用索引,能分区就分区
- 减少数据传输量:只查需要的列,避免SELECT *
- 合理使用内存:该给的内存要给,但也别给太多
- 利用缓存:统计信息要准确,执行计划能缓存就缓存
- 持续监控:定期检查慢查询,及时发现问题
最后说句心里话:SQL调优没有银弹,没有一招鲜吃遍天的方法。每个系统的情况都不一样,需要具体问题具体分析。我给大家的这些经验,是我这些年踩过的坑总结出来的,希望能帮大家少走点弯路。
调优这个事情,三分靠技术,七分靠经验。多看执行计划,多分析慢查询,慢慢就有感觉了。遇到问题别慌,一步一步排查,总能找到原因。
好了,今天就聊到这里。如果大家在实际工作中遇到SQL性能问题,可以按照我说的这些方法试试。记住,调优是个持续的过程,不是一劳永逸的。祝大家的SQL都能跑得飞快!