文章目录
- 1. 插入数据优化
- 1.1 使用批量插入
- 1.2 批量插入数据时手动提交事务
- 1.3 按主键的顺序插入
- 1.4 大批量插入数据时使用 load 指令
- 2. 主键优化
- 2.1 数据组织方式
- 2.2 页分裂
- 2.3 页合并
- 2.4 主键的设计原则
- 2.4.1 降低主键的长度
- 2.4.2 使用 AUTO_INCREMENT 自增主键
- 2.4.3 尽量不要使用 UUID 或者是其他自然主键(如身份证号)做主键
- 2.4.4 选择不受业务操作影响的字段作为主键
- 3. order by 优化
- 3.1 测试
- 3.2 总结
- 4. group by 优化
- 4.1 测试
- 4.2 总结
- 5. limit 优化
- 6. count 优化
- 7. update 优化(防止行锁升级为表锁)
- 7.1 测试
- 7.2 总结
阅读本文前,建议先阅读另一篇博文:MySQL-进阶篇-索引(索引概述、索引的结构、索引的分类、索引的语法、性能分析工具、索引的使用规则、索引的设计原则)
1. 插入数据优化
1.1 使用批量插入
如果要往表中批量插入数据,不要执行多条 insert 语句,因为每执行一次 insert 语句都需要与数据库建立连接,进行网络传输,性能是比较低的
批量插入数据时建议使用批量插入,MySQL 批量插入的语法如下
INSERT INTO 表名 (列1, 列2, 列3, ..., 列N)
VALUES (值1_1, 值1_2, 值1_3, ..., 值1_N),
(值2_1, 值2_2, 值2_3, ..., 值2_N),
...
(值M_1, 值M_2, 值M_3, ..., 值M_N);
但是一次性插入的数据不建议超过 1000 条,原因主要有以下几点:
- 性能问题:大量数据的插入可能会导致数据库的性能下降。长时间的插入操作可能会锁定表,影响其他数据库操作的性能
- 内存消耗:当插入大量数据时,MySQL可能会消耗大量内存来处理这些数据,这可能会导致服务器内存不足
- 错误处理:如果在插入过程中发生错误(如数据格式不正确、违反约束等),处理大量数据的错误会更加复杂。如果错误导致事务回滚,那么所有已经插入的数据都需要重新插入
- 锁定时间:批量插入可能会导致表锁定较长时间,这会阻塞其他尝试写入该表的操作
那如果要插入几万条数据呢?我们可以将数据分成多个部分,使用多条 insert 语句插入
1.2 批量插入数据时手动提交事务
在 MySQL 中,事务默认是自动提交的,每执行一条 insert 语句,就会提交一次事务,如果有多个 insert 语句,会涉及到频繁的开启事务与提交事务操作,为了提高性能,建议使用手动提交事务
navicat 的数据导入就是手动提交事务的方式
1.3 按主键的顺序插入
按主键顺序插入数据的性能高于不按主键顺序插入数据,具体的原因可参考本文的 主键优化 章节
1.4 大批量插入数据时使用 load 指令
- CSV:Comma-Separated Values,逗号分隔值,是一种简单的文件格式,用于存储表格数据,如电子表格或数据库
- CSV 文件以纯文本形式存储数据,其中每一行代表数据表中的一行,而每行中的数据项通常由逗号分隔
- CSV 格式因其简单性和通用性而被广泛用于数据交换
如果一次性需要插入大批量数据,使用 insert 语句插入性能较低,此时可以使用 MySQL 数据库提供的 load 指令(数据文件需要是 csv 格式)进行插入
具体操作如下:
连接 MySQL 服务端时,加上 --local-infile
参数
mysql --local-infile -u root -p
设置全局参数 local infile
为 1,打开从本地加载文件导入数据的开关
set global local infile = 1;
执行 load 指令将准备好的数据加载到表结构中
FIELDS TERMINATED BY ','
:指定字段之间的分隔符LINES TERMINATED BY '\n'
:指定行结束的标记
load data local infile '/tmp/tb_user.csv' into table 'tb user` fields terminated by ',' lines terminated by '\n';
我们新建一个数据库,用于测试批量插入时使用 insert 语句和使用 load 指令的性能差距
表结构如下
/*
Navicat Premium Data Transfer
Source Server : localhost
Source Server Type : MySQL
Source Server Version : 80034 (8.0.34)
Source Host : localhost:3306
Source Schema : blog
Target Server Type : MySQL
Target Server Version : 80034 (8.0.34)
File Encoding : 65001
Date: 29/08/2024 23:13:57
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for tb_user
-- ----------------------------
DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`password` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`name` varchar(20) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`birthday` date NULL DEFAULT NULL,
`sex` char(1) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `unique_user_username`(`username` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
表中有 100 万条数据,不方便在这里展示,需要的具体数据可以私聊我
使用 insert 语句的耗时(2 分 11 秒)
delete
from tb_user
where id >= 1;
我们删除表中的数据后,测试使用 load 指令的耗时(30 秒 904毫秒)
可以看到,使用 load 指令(更准确的来说,是导入 csv 数据格式的文件)导入数据的性能还是不错的
2. 主键优化
在前面我们提到按主键顺序插入数据的性能高于不按主键顺序插入数据,我们现在来剖析一下具体的原因
学习主键优化之前,我们需要先知道在 InnoDB 引擎中,数据的组织方式是怎么样的
2.1 数据组织方式
在 InnoDB 存储引擎中,表数据都是根据主键顺序组织存放的,这种存储方式的表称为索引组织表(Index Organized Table,简称 IOT)
我们知道,基于 InnoDB 存储引擎的表,索引分为两类——聚集索引和二级索引,聚集索引的叶子结点下面挂的是整一行数据
对于一张表来说,默认主键索引就是聚集索引,所以表中的数据是根据主键的顺序进行存储的,示意图如下
在 B+Tree 数据结构中,所有的数据都会出现在叶子结点,非叶子节点仅仅起到一个索引数据的作用,而非叶子节点的索引和叶子结点中的数据结构最终都会存放在一个逻辑结构(页,Page)当中,示意图中的黄色部分,就是一个又一个的 Page,每一页的大小是固定的
我们来回顾一下 InnoDB 的逻辑存储结构
最外层是表空间(Table Space),表空间中存储的是段(Segment),段中存放的是区(Extent),每个区的大小是固定的(1M),区中存放的是页(Page),页中存放的是行(Row),行当中存放的就是具体的字段值
页是 InnoDB 引擎磁盘管理的最小单元,一个页的大小默认为 16 K,我们来看一下,当我们往表中插入数据的时候,大致的流程是怎样的
2.2 页分裂
页可以为空,也可以填充一半,也可以填充 100 %,每个页包含了 2 - N 行数据,根据主键排列(如果一行数据多大,会行溢出)
为什么每一页至少包含两行数据呢,因为如果一个页中只包含一行数据(更一般地说,一条记录),那么当需要在内存中找到多条时,处理器需要连续地访问多个页。这就像是在处理一个链表,每个节点(在这里是页)只包含一个元素(一行数据),要找到下一个元素,你需要跟随链表指针到下一个节点(页)。这种访问方式效率低下,因为它涉及到多次页的换入换出,导致更多的磁盘 IO 操作
我们先来看一下按主键顺序插入数据的情况(相邻的页之间会维护一个双向指针)
我们再来看一下按主键乱序插入数据的情况
假如现在前两页都存满了数据,这时候需要插入一行 id 为 50 的新数据,但是这个新数据不能存放在新开辟的页中,应该存放在 id 为 47 的数据之后,但第一页已经没有足够的空间来存放 id 为 50 的新数据了
那该怎么办呢,MySQL 会开辟一个新的数据页,但 id 为 50 的新数据不会直接存储在新的页中
MySQL 会先找到第一个数据页 50% 的位置,因为 id 为 23 的数据和 id 为 47 的数据在第一个数据页 50% 的位置之后,MySQL 会将 id 为 23 的数据和 id 为 47 的数据移动到新开辟的页中,再将 50 插入到 id 为 47 的数据后面
此时第一页的下一页不应该是第二页,需要改变指针的指向
以上现象被称为页分裂,主键乱序插入的情况下,就有可能出现页分裂现象,页分裂是比较消耗性能的
介绍了页分裂现象之后,我们再来介绍页合并现象
2.3 页合并
假设数据的存储情况如下
接下来我们要进行删除操作,在 InnoDB 引擎中,如果我们要删除一行数据,例如 id 为 16 的这一行数据,并不会真正地删除,也就是说并不会在磁盘中直接将 id 为 16 的这一行数据干掉,只是对这一行记录做一个标记,标记这一行数据被删除了
一旦 id 为 16 的数据对应的空间被标记为删除状态,就代表这块空间可以被其它数据占用了
当页中删除的记录达到 MERGE_THRESHOLD(默认为页的 50%),InnoDB 引擎会开始寻找最靠近的页(前或后),看看是否可以将两个页合并,优化空间的使用
当有一条 id 为 20 的新数据要插入时,直接插入到第三页中就可以了
以上现象称为页合并
MERGE THRESHOLD:合并页的阈值,可以自己设置,在创建表或者创建索引时指定
2.4 主键的设计原则
2.4.1 降低主键的长度
在满足业务需求的情况下,尽量降低主键的长度,为什么要降低主键的长度呢,我们需要从聚集索引和二级索引的结构说起,聚集索引是基于主键的,聚集索引只有一个,但二级索引有很多个,二级索引的叶子节点下面挂的数据除了二级索引本身之外,还有主键值
如果主键的长度比较长,二级索引比较多,将会消耗大量的磁盘空间,在搜索时也会涉及到大量的磁盘 IO,造成性能的下降,所以我们要尽量降低主键的长度
2.4.2 使用 AUTO_INCREMENT 自增主键
插入数据时,尽量选择顺序插入,选择使用 AUTO_INCREMENT 自增主键
2.4.3 尽量不要使用 UUID 或者是其他自然主键(如身份证号)做主键
因为用 UUID 或者是其他自然主键(如身份证号)做主键,主键的值都是不确定的,当有大量数据插入时,可能会频繁地发生页分裂现象,而且 UUID 和其他自然主键(如身份证号)的长度较长,当二级索引很多时,会占用大量的磁盘空间
2.4.4 选择不受业务操作影响的字段作为主键
在选择主键时,选择不受业务操作影响的字段,因为修改主键时底层的数据结构也需要进行调整,代价是比较大的
3. order by 优化
排序有两种方式:
- Using fileSort:通过表的索引或全表扫描,读取满足条件的数据行,然后在排序缓冲区 sort_bufer 中完成排序操作,所有不是通过索引直接返回排序结果的排序都叫 fileSort 排序
- Using index:通过有序索引顺序扫描直接返回有序数据,这种情况即为using index,不需要额外排序,操作效率高
也就是说,我们在优化 order by 语句的时候,尽量将排序方式优化为 Using index
3.1 测试
接下来我们开始对 order by 语句的测试
我们先查看当前 tb_user 表中有哪些索引
show index from tb_user;
为了测试结果的准确性,我们把无关的索引删除掉
drop index index_phone_name on tb_user;
drop index index_user_name on tb_user;
drop index index_user_phone on tb_user;
我们运行以下 SQL 语句,查看 SQL 语句的执行计划
explain select id, age, phone
from tb_user
order by age;
可以看到,Extra 一栏中的值为 Using filesort,排序效率是比较低的
接下来,我们为 age、phone 字段建立一个联合索引
create index index_user_age_phone on tb_user (age, phone);
再次运行 SQL 语句,查看 SQL 语句的执行计划
可以看到,Extra 一栏中的值为 Using index,排序效率是比较高的
我们运行以下 SQL 语句,查看 SQL 语句的执行计划
explain
select id, age, phone
from tb_user
order by age desc, phone desc;
可以看到,Extra 一栏中的值为 Using index,但是出现了 Backward index scan; 语句
Backward index scan; 语句表明使用了反向索引扫描,因为创建索引时默认都是按照字段值升序排列的,但是现在我们要倒序返回数据,所以需要反向扫描索引
那如果我们先按照 phone 字段升序排序,再按照 age 字段升序排序呢
explain
select id, age, phone
from tb_user
order by phone, age;
可以看到,Extra 一栏中有 Using index,也有 Using filesort
因为 SQL 语句违背了最左前缀法则,所以 Extra 字段中会有 Using filesort
那如果我们先按照 age 字段升序,再按照 phone 字段降序呢
可以看到,Extra 一栏中有 Using index,也有 Using filesort
为什么会出现这种情况呢,因为在创建索引的时候,如果没有指定顺序,默认是按照字段的值升序排列的,也就是先按照 age 值升序,再按照 phone 升序进行排列
我们可以查看索引的结构(Collation 字段中的 A 代表 Asc)
show index from tb_user;
如果按照 age 字段升序,再按照 phone 字段降序,需要进行额外的排序
那我们要怎么解决这个问题呢,我们可以额外针对 age 字段和 phone 字段创建一个按照 age 字段升序,phone 字段降序的索引
create index index_user_age_phone_asc_desc
on tb_user (age asc, phone desc);
再次执行 SQL 语句,查看 SQL 语句的执行计划
可以看到,Extra 一栏中只有 Using index
我们来看一下,刚才创建的 index_user_age_phone
索引和 index_user_age_phone_asc_desc
索引大概是怎么样的
当然,这些排序规则都建立在你使用了覆盖索引的前提下
3.2 总结
- 根据排序字段建立合适的索引,多字段排序时,也遵循最左前缀法则
- 尽量使用覆盖索引
- 多字段排序,一个升序一个降序,此时需要注意联合索引在创建时的规则(ASC / DESC)
- 如果不可避免的出现 Using filesort,大数据量排序时,可以适当增大排序缓冲区大小 sort_buffer_size(默认大小为 256 K)
show variables like 'sort_buffer_size';
如果排序缓冲区满了,MySQL 会在磁盘文件中对数据进行排序,性能比较低
4. group by 优化
在研究 group by 优化时,我们主要研究的是索引对 group by 操作的影响
4.1 测试
在测试前,我们先删除 index_user_profession_age_status 索引
drop index index_user_profession_age_status on tb_user;
drop index index_user_age_phone on tb_user;
drop index index_user_age_phone_asc_desc on tb_user;
执行以下 SQL 语句,查看 SQL 语句的执行计划
explain
select profession, count(*)
from tb_user
group by profession;
type 字段为 ALL ,说明进行了全表扫描,Using temporary 表明使用了临时表,性能是比较低的
我们针对 profession、age、status 字段创建一个联合索引后再次进行测试
create index index_user_profession_age_status
on tb_user (profession, age, status);
再次执行 SQL 语句,查看 SQL 语句的执行计划
可以看到,使用了 index_user_profession_age_status 索引,Extra 字段为 Using index,说明 SQL 语句的执行效率是比较高的
我们根据 profession、age 字段进行分组,查看 SQL 语句的执行计划
explain
select profession, age, count(*)
from tb_user
group by profession, age;
可以看到,Extra 字段为 Using index,说明 SQL 语句的执行效率是比较高的
我们根据 age 字段进行分组,查看 SQL 语句的执行计划
explain
select age, count(*)
from tb_user
group by age;
可以看到,Extra 字段出现了 Using filesort,因为不符合最左前缀法则
那不符合最左前缀法则,Extra 字段为什么又会出现 Using index 呢,可能是因为要查询的所有列都包含在索引中(也就是说,索引“覆盖”了查询所需的所有列),那么MySQL可以使用该索引来避免回表操作
在这个例子中,index_user_profession_age_status
索引可能包含了 age
列,因此 MySQL 可以使用这个索引来直接获取age
和count(*)
我们根据 profession、age 字段进行分组,查看 SQL 语句的执行计划
可以看到,Extra 字段只有 Using index,因为符合最左前缀法则
我们先根据 profession 字段筛选,再根据 age 字段进行分组,查看 SQL 语句的执行计划
explain
select profession, age, count(*)
from tb_user
where profession = '软件工程'
group by profession, age;
可以看到,Extra 字段只有 Using index,因为符合最左前缀法则
4.2 总结
- 在分组操作时,可以通过索引来提高效率
- 分组操作时,索引的使用也是满足最左前缀法则的
5. limit 优化
如果表的数据比较大,而且页偏移量比较大时,直接使用 limit 语句的效率比较低(这个问题被称作深度分页问题)
例如 limit 2000000,10 ,此时 MySQL 需要排序前 2000010 条记录,但是仅仅返回 [2000000, 2000009] 区间的记录,丢弃其他记录,代价非常大,而且一不小心内存就炸了
那我们怎么对 limit 语句进行优化呢,官方给出的方案是覆盖索引 + 子查询
的方式优化
示例如下
explain
select *
from tb_sku,
(select id from tb_sku order by id limit 2000000,10) as temporary
where tb_sku.id = temporary.id;
6. count 优化
- MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行
count(*)
的时候会直接返回这个数,效率很高 - InnoDB 引擎就麻烦了,它执行
count(*)
的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数
优化思路:自己计数,我们可以借助 key-value 类型的、基于内存的数据库(例如 Redis),每插入一条数据,就让某一个变量自增一次,每删除一条数据,就让某一个变量自减一次,当然这个操作比较繁琐
count 语句的几种使用方式之间的区别
- count(主键):InnoDB 引擎会遍历整张表,把每一行的主键 id 值都取出来,返回给服务层,服务层拿到主键后,直接按行进行累加主键(因为主键不可能为 NULL)
- count(字段):
- 没有 NOT NULL 约束:InnoDB 引擎会遍历整张表把每一行的字段值都取出来,返回给服务层,服务层判断是否为 NULL,不为 NULL 则累加计数
- 有 NOT NULL 约束:InnoDB 引擎会遍历整张表把每一行的字段值都取出来,返回给服务层,服务层直接按行进行累加
- count(1):InnoDB 引擎遍历整张表,但不取值,服务层对于返回的每一行,放一个数字 1 (不一定要是 1,可以为任意一个常量)进去,直接按行进行累加
- cout(*):InnoDB 引擎并不会把全部字段取出来,而是专门做了优化,不取值,服务层直接按行进行累加
按效率排序:count(字段) < count(主键) < count(1) < count(*)
所以尽量使用 count(*)
7. update 优化(防止行锁升级为表锁)
我们回想一下,InnoDB 引擎有哪三大特性?(支持事务、支持外键、支持行级锁)
7.1 测试
接下来演示行锁升级为表锁的过程
我们先在一个与 MySQL 的连接中开启事务
begin;
然后更新 course 表中 id 为 1 的数据
update course
set name = 'Java'
where id = 1;
执行了更新语句后,InnoDB 会把 id 为 1 的这条数据锁住
我们在另一个与 MySQL 的连接中开启事务
begin;
然后更新 course 表中 id 为 4 的数据
update course
set name = 'C++'
where id = 4;
更新后提交事务
commit;
这时候的更新操作是成功的
更新成功后我们先提交第一个连接中的事务
当前表的数据如下
我们在第一个连接中再次开启一个事务
begin;
然后将 name 字段为 PHP 的数据改成 Python
update course
set name = 'Python'
where name = 'PHP'
我们在第二个连接中也开启一个事务
begin;
将 id 为 4 的数据的 name 字段改为 RabbitMQ
UPDATE course
SET name = 'RabbitMQ'
WHERE id = 4;
可以发现,更新操作没有执行,而是一直处于阻塞状态
按理说第一个连接锁的不是 id 为 2 对应的数据吗,怎么会影响第二个连接的更新操作?
原因是因为第一个连接在执行 update 语句的时候,name 字段没有建立索引,此时加的是表锁,而不是行锁,所以第二个连接在执行更新操作的时候就阻塞了
第一个连接提交事务后,第二个连接的更新操作就能正常执行了
7.2 总结
执行 update 语句时,InnoDB 的行锁是针对索引加的锁,不是针对记录加的锁,并且该索引不能失效,否则会从行锁升级为表锁
一旦行锁升级为表锁,并发性能就会降低