一、前言
当我们学会通过慢查询日志、EXPLAIN 或执行耗时去定位慢 SQL 之后,下一步就要分析 SQL 的慢点,并根据执行过程选择合适的优化方向。
二、insert 插入优化
insert 优化主要解决的是大量数据写入时速度慢的问题。如果只是偶尔插入一两条数据,通常不需要特别处理;但如果是批量导入、初始化数据、日志落库,就需要注意写入方式。
1. 批量插入
单条插入的写法通常是这样:
sql
insert into tb_user(name, age, phone) values ('张三', 18, '13800000000');
insert into tb_user(name, age, phone) values ('李四', 20, '13900000000');
如果数据量比较大,每一条 SQL 都要经历解析、优化、执行、提交等过程,整体效率会比较低。
可以改成批量插入:
sql
insert into tb_user(name, age, phone)
values
('张三', 18, '13800000000'),
('李四', 20, '13900000000'),
('王五', 22, '13700000000');
这样可以减少客户端和 MySQL 服务端之间的交互次数,也能减少 SQL 解析次数。
不过一次插入的数据不建议无限堆叠。一般来说,可以控制在 1000 条以内,具体还要看字段数量、单行大小和数据库配置。
2. 手动提交事务
如果开启了自动提交,每执行一条 insert,MySQL 都可能提交一次事务。大量数据插入时,频繁提交事务会带来额外开销。
可以使用手动事务:
sql
start transaction;
insert into tb_user(name, age, phone)
values
('张三', 18, '13800000000'),
('李四', 20, '13900000000');
commit;
这样可以把多次插入放到一个事务中统一提交,减少提交次数。
需要注意的是,事务也不是越大越好。如果一次事务里塞入过多数据,可能会导致锁持有时间变长、回滚成本变高、日志压力增大。所以实际开发中要分批处理。
3. 主键顺序插入
InnoDB 表的数据是按照主键组织的。如果主键是自增的,插入时通常会追加到后面,页分裂较少,性能比较稳定。
如果主键是乱序的,比如使用完全随机的字符串主键,插入时可能需要在 B+Tree 的中间位置插入数据,这时就可能导致页分裂。
简单理解就是:原来一个数据页里已经按顺序存了一批数据,现在突然要往中间插入一条新数据,如果当前页空间不够,就需要拆分数据页,这个过程会带来额外的移动和维护成本。
所以在没有特殊业务要求时,主键尽量选择顺序增长的方式,例如自增主键,或者使用能保证趋势递增的分布式 ID。
4. 大量数据导入使用 load data
如果是特别大的数据导入,可以考虑使用 load data ,它通常比一条条 insert 更适合导入文件数据。
客户端连接服务端时,可以加上 --local-infile 参数:
bash
mysql --local-infile -u root -p
然后查看并开启本地文件导入:
sql
select @@local_infile;
set global local_infile = 1;
再把本地文件导入到表中:
sql
load data local infile '/root/sql1.log'
into table tb_user
fields terminated by ','
lines terminated by '\n';
这里的意思是:
fields terminated by ',':每一列数据使用逗号分隔lines terminated by '\n':每一行数据使用换行分隔into table tb_user:导入到tb_user表中
这个方式适合结构比较规整的大批量数据导入。使用前要确认文件格式、字段顺序、字符集以及测试环境导入结果,避免直接在生产环境误导入错误数据。
三、主键优化
主键优化重点在于理解 InnoDB 的数据组织方式。
InnoDB 中,表数据会按照主键构建聚簇索引。也就是说,主键索引的叶子节点保存的是完整行数据。因此主键的设计会直接影响数据插入、查询和索引维护。
1. 尽量使用顺序主键
顺序主键的优势是插入位置比较稳定,一般都是向后追加。这样 B+Tree 的维护成本更低。
比如:
sql
id: 1, 2, 3, 4, 5
这种顺序插入比较符合 InnoDB 的存储特点。
如果是乱序插入:
text
1, 3, 2
新数据可能插入到已有数据中间。当数据页空间不足时,就可能触发页分裂。
2. 删除数据后的页合并
删除数据时,并不是一定会立刻释放整个数据页。如果页内数据减少到一定比例,比如低于一半,就可能触发页合并。
所以频繁的随机插入和删除,会让索引页不断分裂、合并,增加维护成本。
所以在设计主键时,不能只考虑唯一性,还要考虑它对索引结构的影响。
四、order by 排序优化
order by 是非常常见的性能问题来源。
排序一般有两种情况:
- 直接利用索引顺序返回结果,通常性能较好
- 不能利用索引,需要额外排序,也就是常见的 filesort
1. 使用联合索引优化排序
假设经常按照 age 和 phone 排序,可以创建联合索引:
sql
create index idx_user_age_phone on tb_user(age, phone);
查询时:
sql
select id, age, phone
from tb_user
order by age, phone;
如果执行计划能用上 idx_user_age_phone,MySQL 就可以按照索引本身的顺序读取数据,减少额外排序成本。
2. 注意排序字段顺序
联合索引要遵守最左前缀法则。比如索引是:
sql
(age, phone)
那么下面这种排序更容易利用索引:
sql
order by age, phone
如果只按照 phone 排序,就不一定能直接使用这个联合索引完成排序。
3. filesort 不一定是坏事
看到 filesort 不代表 SQL 一定有问题,它只是说明 MySQL 使用了额外排序。真正需要关注的是:
- 数据量的大小
- 排序结果的耗时
- 索引对排序成本的降低效果
- 对核心业务接口的影响
如果排序字段确实无法建立合适索引,或者排序数据量很小,filesort 也可能是可以接受的。
当它成为性能瓶颈时,再考虑优化索引或者调整 sort_buffer_size 等参数。参数优化应该放在后面,不能一上来就改配置。
五、group by 分组优化
group by 的优化思路和 order by 有相似之处,也可以考虑通过索引降低分组成本。
比如经常按照部门、状态统计数据:
sql
select dept_id, status, count(*)
from tb_user
where dept_id = 1
group by dept_id, status;
可以考虑建立联合索引:
sql
create index idx_user_dept_status on tb_user(dept_id, status);
这样 where 条件 和 group by 字段能够尽量匹配索引顺序,更容易减少扫描和分组成本。
需要注意的是,索引不是越多越好。索引会提升查询性能,但也会增加写入、更新和存储成本。所以创建索引前要结合高频 SQL,而不是看到一个字段就加一个索引。
六、limit 深分页优化
分页查询在后台管理系统中非常常见。普通分页一般这样写:
sql
select s.*
from tb_sku s
limit 0, 10;
前几页通常问题不大,但深分页就可能很慢:
sql
select s.*
from tb_sku s
limit 9000000, 10;
这条 SQL 的含义是跳过前 9000000 条,再返回 10 条。问题在于 MySQL 仍然需要扫描并丢弃前面大量数据,所以越往后翻页越慢。
1. 先查主键,再回表
可以先查询主键,再根据主键回表查询完整数据:
sql
select s.*
from tb_sku s,
(select id from tb_sku order by id limit 9000000, 10) a
where s.id = a.id;
这个写法的核心思想是:子查询只查 id ,如果 id 是主键或索引字段,扫描成本会比直接查询整行数据低。拿到目标页的主键后,再回表查询完整记录。
2. 使用上一页最大 id
如果业务允许,也可以使用"游标分页"的思路:
sql
select *
from tb_sku
where id > 9000000
order by id
limit 10;
这种方式不需要跳过大量数据,性能通常更稳定。
不过它也有边界:它更适合"下一页、上一页"这种连续翻页,不适合直接跳转到第 10000 页的场景。
七、count 统计优化
常见的统计写法有:
count(*)count(1)count(主键)count(字段)
它们看起来都能统计数量,但细节上有区别。
1. count(字段) 不统计 null
比如:
sql
select count(phone)
from tb_user;
如果 phone 字段 有 null,这部分数据不会被统计进去。
所以如果目标是统计整张表的行数,不建议使用可能为 null 的字段。
2. 推荐使用 count(*)
在 InnoDB 中,通常推荐使用:
sql
select count(*)
from tb_user;
count(*) 表示统计结果集行数,并不会真的把所有字段取出来。MySQL 优化器会选择合适的方式执行。
所以不要简单认为 count(*) 就一定比 count(1) 慢。实际开发中,统计总行数一般直接使用 count(*) 即可。
八、update 优化:避免锁范围扩大
update 优化不仅关系到速度,还关系到锁。
在 InnoDB 中,如果更新条件能走索引,通常可以锁定更小范围的数据;如果条件没有走索引,就可能扫描更多记录,锁范围也可能随之扩大。
比如根据主键更新:
sql
update tb_user
set phone = '13800000000'
where id = 1;
这种写法通常比较安全,因为 id 是主键,能够快速定位到一行数据。
但如果根据一个没有索引的字段更新:
sql
update tb_user
set phone = '13800000000'
where name = '张三';
如果 name 没有索引,MySQL 可能需要扫描更多数据。数据量一大,就容易影响并发性能。
所以更新时要注意:
- where 条件尽量使用索引字段
- 避免没有条件的全表更新
- 更新前先用 select 确认影响范围
- 大批量更新时分批执行,避免长事务
尤其是在生产环境,更新和删除都要非常谨慎。可以先执行:
sql
select count(*)
from tb_user
where name = '张三';
确认影响行数符合预期后,再执行 update。
九、SQL 优化的整体思路
前面讲的是具体场景,最后再把 SQL 优化的思路串起来。
1. 先定位,再优化
不要凭感觉优化 SQL。建议先通过这些方式定位问题:
- 慢查询日志
EXPLAIN执行计划- 接口耗时
- 数据量变化
- 索引命中情况
只有知道慢在哪里,优化才有方向。
2. 优先优化 SQL 和索引
很多性能问题,其实是 SQL 写法和索引设计不合理造成的。
比如:
- 排序字段没有合适索引
- 分组字段没有结合查询条件设计联合索引
- 深分页跳过了大量数据
- 更新条件没有走索引
这些问题优先通过 SQL 和索引解决,通常比直接改数据库参数更可靠。
3. 参数优化放在后面
像 sort_buffer_size 这类参数确实可能影响排序性能,但参数优化应该建立在 SQL 和索引已经分析清楚的基础上。
如果 SQL 本身写得不合理,盲目调参数只是暂时掩盖问题。
十、总结
这篇文章主要整理了几个常见 SQL 优化点。
insert 优化 重点是批量插入、手动提交事务、主键顺序插入以及大批量数据导入时使用 load data。
主键优化重点是理解 InnoDB 的聚簇索引结构,尽量减少乱序插入带来的页分裂问题。
order by 和 group by 的优化重点是合理设计联合索引,让排序和分组尽量利用索引顺序。
limit 深分页的核心问题是跳过大量数据,可以通过先查主键再回表,或者使用游标分页来优化。
count 统计时,一般推荐使用 count(*),同时要注意 count(字段) 不统计 null。
update 优化不仅要考虑查询速度,还要考虑锁范围,更新条件尽量使用索引字段,避免全表扫描和长事务。
SQL 优化最终还是要回到一句话:先看执行计划,再结合业务场景优化 SQL、索引和数据访问方式。