文章目录
SQL 性能优化是数据库应用开发中至关重要的一环。随着业务数据量的增长和并发访问量的提升,未经优化的 SQL 语句可能会导致严重的性能问题,包括查询响应缓慢、数据库连接池耗尽、服务器资源占用过高等,最终影响用户体验甚至导致系统崩溃。
进行 SQL 性能优化的主要原因包括:
- 提升响应速度:优化后的 SQL 可以显著减少查询执行时间,从秒级优化到毫秒级,提升用户体验。
- 降低服务器负载:高效的 SQL 能够减少 CPU、内存和磁盘 IO 的消耗,降低服务器压力。
- 提高并发能力:优化后的 SQL 执行时间更短,可以释放更多数据库连接资源,支持更高的并发访问。
- 节约成本:通过优化 SQL 而非盲目扩容硬件,可以有效降低服务器成本。
- 保障系统稳定性:避免慢查询导致的连接堆积、锁等待等问题,提高系统的稳定性和可用性。
SQL 性能优化应该是开发过程中的常态化工作,而不是等到出现性能问题时才进行。掌握常见的 SQL 优化技巧,可以在编写代码时就避免大部分性能问题。
优化 insert
1. 使用批量插入方式
批量插入可以减少客户端与数据库之间的连接、关闭等消耗,提升插入效率。
单条插入(效率低):
sql
insert into user(name, age) values('张三', 18);
insert into user(name, age) values('李四', 20);
insert into user(name, age) values('王五', 22);
批量插入(推荐):
sql
insert into user(name, age) values('张三', 18), ('李四', 20), ('王五', 22);
建议每次批量插入的数据量控制在 500-1000 条,避免单次插入数据量过大导致的性能问题。
2. 手动控制事务提交
MySQL 默认每条 INSERT 语句都会自动提交事务,频繁的事务提交会增加系统开销。
sql
-- 开启事务
start transaction;
insert into user(name, age) values('张三', 18);
insert into user(name, age) values('李四', 20);
insert into user(name, age) values('王五', 22);
-- 手动提交事务
commit;
3. 按照主键顺序插入
InnoDB 存储引擎使用聚簇索引,主键顺序插入可以避免页分裂,提高插入效率。
sql
-- 推荐:按主键顺序插入
insert into user(id, name, age) values(1, '张三', 18), (2, '李四', 20), (3, '王五', 22);
-- 不推荐:乱序插入
insert into user(id, name, age) values(3, '王五', 22), (1, '张三', 18), (2, '李四', 20);
4. 使用 LOAD DATA INFILE(大批量数据导入)
对于大批量数据导入,使用 LOAD DATA INFILE 比 INSERT 语句效率高得多。
sql
load data infile '/path/to/data.csv'
into table user
fields terminated by ','
lines terminated by '\n'
(name, age);
5. 关闭唯一性校验(批量导入时)
在批量导入数据时,可以临时关闭唯一性校验以提高性能,导入完成后再开启。
sql
-- 关闭唯一性校验
set unique_checks = 0;
-- 批量插入数据
insert into user(name, age) values(...);
-- 开启唯一性校验
set unique_checks = 1;
6. 关闭自动提交(批量导入时)
sql
-- 关闭自动提交
set autocommit = 0;
-- 批量插入数据
insert into user(name, age) values(...);
-- 手动提交
commit;
-- 恢复自动提交
set autocommit = 1;
优化 order by 排序
MySQL 中支持 Using FileSort 与 Using Index(在执行 explain 命令分析执行计划的 Extra 结果字段中体现)两种排序方式。
Using FileSort排序:一般在内存中进行排序,占用 CPU 较多,待排序结果较大情况下会进行磁盘 IO 降低效率。Using Index排序:索引可以保证数据的有序性,不需要再进行排序,效率更高;
在 order by 子句中使用索引(index),可以避免 FileSort 排序,所以应该尽量使用索引来完成 order by 排序。
需要注意的是,在某些情况下,FileSort 排序并不一定比 Index 慢,原因是所有的排序都是在条件过滤之后执行的。所以如果条件过滤掉大部分数据的话,剩下几百几千条数据进行排序其实也不是很消耗性能,即使索引优化了排序,但实际上提升性能是很有限的。
在创建联合索引对 order by 排序时,应该注意联合索引创建的规则是 asc 还是 desc,应该根据实际情况选择合适的规则。不合理的规则,如创建联合索引字段都为 asc 规则的联合索引,而实际使用过程中却进行 desc 排序,将出现 Using FileSort 的情况,导致索引无法充分利用。
asc 规则与 desc 规则的联合索引是可以同时存在的。可以通过 asc 与 desc 关键字在创建索引时指定 desc 规则的索引:
sql
create index index_name on table_name(column1 [asc|desc],column2 [asc|desc]...);
如果在查询的字段中加入非索引字段与非主键字段,如 select *,将会进行 FileSort 排序,因为需要进行回表查询。
如果不可避免出现 FileSort 排序,并且待排序数据量较大,可以通过适当增大排序缓冲区大小 sort_buffer_size(默认 256 KB)。
查看排序缓冲区大小(默认结果为 262144 B):
sql
show variables like 'sort_buffer_size';
如果待排序数据量较大,超过排序缓冲区大小范围,则会产生临时文件 I/O 到磁盘中进行排序,从而使排序效率较低。
优化分页查询
在实际项目开发中,可能会遇到深度分页的业务场景。如果没有对 SQL 进行优化,当遇到深度分页时,可能会造成比较严重的性能问题。以下举一个案例说明深度分页问题和优化方案。
深度分页是数据库查询中的一个概念,它指的是在获取大量数据集中的较靠后部分数据时所进行的分页查询操作。
深度分页的性能问题
例如,limit 900000, 10 时需要 MySQL 排序前 900010 条记录,仅仅返回 900000 - 900010 的记录,其他记录丢弃,查询越往后,消耗的时间越多:
sql
select * from `表名` order by id limit 900000, 10;
这条 SQL 的执行过程:
- 扫描并排序前 900010 条记录
- 丢弃前 900000 条记录
- 返回最后 10 条记录
优化思路一:覆盖索引 + 子查询(推荐)
官方推荐通过【创建覆盖索引 + 子查询】能够比较好地提高性能:
sql
select * from `表名` a
inner join (select id from `表名` order by id limit 900000, 10) b
on a.id = b.id;
优化原理:
- 子查询只查询 id 字段,使用覆盖索引,避免回表
- 先通过索引快速定位到需要的 id
- 再通过 id 关联查询完整数据
优化思路二:记录上次查询的位置(适用于 id 连续场景)
将 limit 查询转换为基于上次查询位置的查询:
sql
select * from `表名` where id > 900000 limit 10;
优化原理:
- 直接通过主键索引定位,避免扫描前面的数据
- 性能最优,但有使用限制
适用场景与限制:
- 适用于主键连续且不会被删除的场景
- 需要客户端记录上次查询的最大 id
- 不适用于需要跳页查询的场景
优化思路三:使用游标分页(适用于数据导出场景)
对于数据导出等不需要跳页的场景,可以使用游标方式:
sql
-- 第一页
select * from `表名` order by id limit 10;
-- 第二页(假设第一页最后一条记录的 id 为 10)
select * from `表名` where id > 10 order by id limit 10;
-- 第三页(假设第二页最后一条记录的 id 为 20)
select * from `表名` where id > 20 order by id limit 10;
优化思路四:延迟关联
如果必须使用传统的 limit 分页,可以使用延迟关联优化:
sql
select * from `表名`
where id >= (select id from `表名` order by id limit 900000, 1)
limit 10;
性能对比总结
| 优化方案 | 性能 | 适用场景 | 限制 |
|---|---|---|---|
| 覆盖索引 + 子查询 | 较好 | 通用场景 | 需要创建合适的索引 |
| 记录上次位置 | 最优 | id 连续场景 | 不支持跳页 |
| 游标分页 | 最优 | 数据导出场景 | 只能顺序查询 |
| 延迟关联 | 较好 | 传统分页场景 | 需要子查询支持 |
优化 group by
对于 group by 实际上也同样会进行排序操作,而且与 order by 相比,group by 主要只是多了排序之后的分组操作。当然,如果在分组的时候还使用了其他的一些聚合函数,那么还需要一些聚合函数的计算。所以,在 group by 的实现过程中,与 order by 一样也可以利用到索引。
1. 使用索引优化 group by
为 group by 的字段创建索引,可以避免临时表和文件排序。
sql
-- 为分组字段创建索引
create index idx_emp_age_salary on emp(age, salary);
-- 使用索引进行分组
explain select age, count(*) from emp group by age;
2. 使用 order by null 禁止排序
如果不需要对分组结果进行排序,可以使用 order by null 来禁止排序,减少排序开销。
sql
-- 默认情况下,GROUP BY 会对结果进行排序
explain select age, count(*) from emp group by age;
-- 使用 ORDER BY NULL 禁止排序
explain select age, count(*) from emp group by age order by null;
通过 explain 可以看到,使用 order by null 后,Extra 字段中不再出现 Using filesort。
**3. 避免使用 select ***
在使用 group by 时,应该只查询需要的字段,避免使用 select *,这样可以减少数据传输量。
sql
-- 不推荐
select * from emp group by age;
-- 推荐
select age, count(*) from emp group by age;
4. where 条件过滤优于 having
能在 where 中过滤的条件,尽量不要放在 having 中,因为 where 在分组前过滤,having 在分组后过滤。
sql
-- 不推荐:使用 HAVING 过滤
select age, count(*) from emp group by age having age > 20;
-- 推荐:使用 WHERE 过滤
select age, count(*) from emp where age > 20 group by age;
优化 join
对于关联查询的优化,可以区分为外连接(Left join)和内连接(Inner join)。但是不论对于外连接与内连接,关联查询的优化策略都是相似的。
优化关联查询时,需要考虑如下方面:
1. 避免关联条件的隐式类型转换
关联条件存在隐式类型转换会导致索引失效。例如,一个字段是 VARCHAR 类型,另一个是 INT 类型,MySQL 会进行类型转换,导致无法使用索引。
sql
-- 假设 user.id 是 INT 类型,order.user_id 是 VARCHAR 类型
-- 不推荐:存在隐式类型转换
select * from user u join order o on u.id = o.user_id;
-- 推荐:确保关联字段类型一致
-- 方案1:修改表结构,统一字段类型
-- 方案2:显式类型转换(但仍会导致索引失效)
select * from user u join order o on u.id = cast(o.user_id as signed);
2. 给关联条件字段添加索引
不给关联条件加索引的情况下,驱动表与被驱动表都将走全表扫描。关联查询的本质还是嵌套查询,比如表 A 关联表 B,如果 A 与 B 经过 where 条件过滤后分别还剩 M 和 N 条数据,那么遍历次数为 M*N(表 A 一条一条取数据出来,循环表 B 中的数据进行匹配,如果匹配上则取出放入结果集)。
实际上,在上述情况下 MySQL 会对 N 的数据循环遍历动作进行优化,通过 Using join buffer (hash join) 或 Using join buffer (Block Nested Loop)。这是一种通过缓存的方式优化对 N 的数据循环遍历,通过 explain 分析执行记录可以从 Extra 中看到这个描述。
sql
-- 为关联字段创建索引
create index idx_user_id on order(user_id);
create index idx_dept_id on employee(dept_id);
3. 小表驱动大表
本质是减少外层循环的数据量。小表的度量标准是:表行数 × 每行数据大小。
sql
-- 假设 dept 表有 10 条记录,employee 表有 10000 条记录
-- 推荐:小表 dept 驱动大表 employee
select * from dept d left join employee e on d.id = e.dept_id;
-- 不推荐:大表驱动小表
select * from employee e left join dept d on e.dept_id = d.id;
对于内连接(inner join),MySQL 优化器会自动选择小表作为驱动表,但对于外连接(left join、right join),驱动表是固定的,需要手动调整。
4. 为被驱动表的关联条件增加索引
减少内层表的循环匹配次数,这是最重要的优化手段。
sql
-- 为被驱动表的关联字段创建索引
create index idx_dept_id on employee(dept_id);
-- 查询时会使用索引
select * from dept d left join employee e on d.id = e.dept_id;
5. 增大 join_buffer_size 参数值
一次缓存的数据越多,那么内层表的扫描次数就越少。
sql
-- 查看当前 join_buffer_size 大小
show variables like 'join_buffer_size';
-- 设置 join_buffer_size(单位:字节)
set session join_buffer_size = 262144; -- 256KB
6. 减少不必要的字段查询
字段越少,join_buffer 缓存的数据就越多,避免使用 SELECT *。
sql
-- 不推荐:查询所有字段
select * from dept d left join employee e on d.id = e.dept_id;
-- 推荐:只查询需要的字段
select d.name, e.name, e.salary from dept d left join employee e on d.id = e.dept_id;
7. 使用 straight_join 强制指定驱动表(慎用)
在某些情况下,如果确定优化器选择的驱动表不合理,可以使用 straight_join 强制指定驱动表。
sql
-- 强制使用 dept 作为驱动表
select * from dept straight_join employee on dept.id = employee.dept_id;
驱动表的选择规则
对于内连接(inner join)来说,驱动表的定义是由查询优化器来决定的:
- 在表的连接条件中,有索引字段的表,将被查询优化器作为被驱动表
- 如果两个表的关联字段都有索引,查询优化器将选择小表(数据量较小的表)作为驱动表,即"小表驱动大表"
- 可以通过
explain查看执行计划,第一行的表就是驱动表
对于外连接(left join、right join):
- left join:左表是驱动表,右表是被驱动表
- right join:右表是驱动表,左表是被驱动表
性能优化总结
| 优化手段 | 重要程度 | 说明 |
|---|---|---|
| 被驱动表关联字段加索引 | ⭐⭐⭐⭐⭐ | 最重要的优化手段 |
| 小表驱动大表 | ⭐⭐⭐⭐ | 减少外层循环次数 |
| 避免隐式类型转换 | ⭐⭐⭐⭐ | 防止索引失效 |
| 减少查询字段 | ⭐⭐⭐ | 提高 join_buffer 利用率 |
| 增大 join_buffer_size | ⭐⭐ | 适度调整,不宜过大 |
优化子查询
使用子查询可以一次性的完成很多逻辑上需要多个步骤才能完成的 SQL 操作,同时也可以避免事务或者表锁死,并且子查询的编写也很容易。
例如:
sql
select * from user where uid in (select uid from user_role);
但是需要注意的是,子查询执行效率并不高,所以在使用子查询时,可以合理考虑是否需要对子查询进行优化。
子查询效率不高的原因
具体而言,子查询执行效率不高的主要原因有以下三点:
1. 临时表的创建与销毁
执行子查询时,MySQL 需要为内层查询语句的查询结果建立一个临时表,然后外层查询语句从临时表中查询记录。查询完毕后,再撤销这些临时表。这样会过多消耗 CPU 与 IO 资源,产生大量慢查询。
2. 临时表无法使用索引
子查询的结果即存储的临时表,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定影响。
3. 结果集太大时不友好
对于返回结果集越大的子查询,其对查询性能的影响就越大。
优化方案:使用 join 替代子查询
在 MySQL 中,可以通过使用连接(join)查询来替代子查询。因为连接查询不需要建立临时表,其执行效率要比子查询高,如果在连接查询的连接条件上使用索引,那么连接查询的性能将会更好。
将上述子查询的案例通过更高效的连接(join)查询来替代,修改后的 SQL 如下:
sql
-- 使用 INNER JOIN 替代 IN 子查询
select u.* from user u
inner join user_role ur on u.uid = ur.uid;
不同子查询场景的优化方案
场景 1:in 子查询优化
sql
-- 优化前:使用 IN 子查询
select * from user where uid in (select uid from user_role where role_id = 1);
-- 优化后:使用 INNER JOIN
select u.* from user u
inner join user_role ur on u.uid = ur.uid
where ur.role_id = 1;
场景 2:exists 子查询优化
sql
-- 优化前:使用 EXISTS 子查询
select * from user u where exists (
select 1 from user_role ur where ur.uid = u.uid and ur.role_id = 1
);
-- 优化后:使用 INNER JOIN
select distinct u.* from user u
inner join user_role ur on u.uid = ur.uid
where ur.role_id = 1;
场景 3:标量子查询优化
sql
-- 优化前:使用标量子查询
select
u.uid,
u.name,
(select count(*) from orders o where o.user_id = u.uid) as order_count
from user u;
-- 优化后:使用 LEFT JOIN
select
u.uid,
u.name,
count(o.id) as order_count
from user u
left join orders o on u.uid = o.user_id
group by u.uid, u.name;
场景 4:not in 子查询优化
sql
-- 优化前:使用 NOT IN 子查询(性能最差)
select * from user where uid not in (select uid from user_role);
-- 优化后:使用 LEFT JOIN + IS NULL
select u.* from user u
left join user_role ur on u.uid = ur.uid
where ur.uid is null;
子查询与 join 的性能对比
| 对比项 | 子查询 | join |
|---|---|---|
| 临时表 | 需要创建 | 不需要 |
| 索引使用 | 临时表无索引 | 可以使用索引 |
| 执行效率 | 较低 | 较高 |
| 代码可读性 | 较好 | 一般 |
| 适用场景 | 简单查询、逻辑清晰 | 性能要求高 |
何时可以使用子查询
虽然 join 性能更好,但在以下场景中,子查询仍然是合理的选择:
- 子查询结果集很小(几条到几十条)
- 代码逻辑清晰度要求高于性能要求
- 子查询只执行一次(非关联子查询)
- 使用 exists 判断存在性(小表驱动大表时)
优化建议总结
- 优先使用 join 替代 in/not in 子查询
- 使用 exists 替代 in(当子查询表较大时)
- 使用 left join + is null 替代 not in
- 避免在 select 列表中使用标量子查询
- 为关联字段创建索引
- 使用 explain 分析执行计划,确认优化效果