索引是为了高效查询排好序的数据结构,当表数据量到达一个量级没有对应索引帮助查询耗时会很长,MySQL资源开销也会非常大,当然索引也不能随意创建,要做到尽量少的索引解决尽量多的问题,这里会对一些业务场景做索引优化演示,这篇文中只介绍单表索引优化,如果单表问题能解决多表关联查询优化就简单多了。
如果对MySQL索引原理还有explain SQL分析工具不是很熟悉的可以看看几篇文章:
这里要准备100w数据左右的表,表数据尽量多一些或者列多一些,如果数据太少,测试的时候可能看不到效果。
创建订单信息表
DROP TABLE IF EXISTS `order_info`;
CREATE TABLE `order_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '订单ID',
`order_no` varchar(100) NOT NULL COMMENT '订单编号',
`customer_id` bigint(20) NOT NULL COMMENT '客户编号',
`goods_id` bigint(20) NOT NULL COMMENT '商品ID',
`goods_title` varchar(100) DEFAULT NULL COMMENT '商品标题',
`order_status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '订单状态 1:待支付 2:已支付 3:已发货 4、已收货',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='订单信息表';
使用存储过程插入100w条数据
## 创建一个插入数据的存储过程
DROP PROCEDURE IF EXISTS insert_procedure;
delimiter;;
CREATE PROCEDURE insert_procedure ()
BEGIN
DECLARE i INT DEFAULT 1;
DECLARE goods_id BIGINT DEFAULT CEIL(RAND() * 100);
DECLARE t_error INTEGER DEFAULT 0;
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET t_error=1;
START TRANSACTION;
WHILE ( i <= 1000000 ) DO
INSERT INTO `order_info`(`order_no`,`customer_id`, `goods_id`, `goods_title`, `order_status`, `create_time`) VALUES (CONCAT('ON00000',i), CEIL(RAND() * 100000), goods_id, CONCAT('笔记本电脑',goods_id), MOD(i, 4)+1, NOW());
SET i = i + 1;
SET goods_id = CEIL(RAND() * 100);
END WHILE;
IF t_error=1 THEN
ROLLBACK;
ELSE
COMMIT;
END IF;
END;;
delimiter;
# 调用存储过程插入数据 我本地插入100w条数据耗时200s
CALL insert_procedure ();
我电脑的配置32G内存500G固态,MySQL配置全用默认,自己测试先看看自己MySQL配置的innodb_buffer_pool_size设置的是多少,默认是128MB,查看命令SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
配置里的单位是字节,InnoDB使用一个缓冲池来保存索引和原始数据,innodb_buffer_pool_size就是控制这个缓冲池的大小,这个缓冲池在一些情况下对查询性能影响非常大,线上建议设置成MySQL能使用内存的80%,这里不深入。
SELECT * FROM order_info WHERE id = 955;
SELECT * FROM order_info WHERE order_no = 'ON000009999';
这里已经可以看到查询耗时明显的差距,我们这里的ID是主键,MySQL InnoDB存储引擎会自动将表中的主键设置为主键索引,同时也是一个聚簇索引叶子节点携带数据,而订单编号是没有索引的会进行全表扫描,会将ON000009999和这个表中每行数据的订单编号都进行比对然后取出满足条件的数据行,100w数据查询一个订单信息耗时已经到了0.6秒左右。
查询订单列表一般查询条件比较多,如订单编号、客户编号、订单状态、创建时间、创建时间倒序、是否分页做几个演示,查询条件的内容自己看看存储过程插入的数据长什么样。
SELECT * FROM order_info WHERE customer_id = 111 AND create_time >= '2023-10-02 09:57:24' AND create_time <= '2023-10-02 10:00:46';
SELECT * FROM order_info WHERE customer_id = 111 AND create_time >= '2023-10-02 09:57:24' AND create_time <= '2023-10-02 10:00:46' LIMIT 3;
SELECT * FROM order_info WHERE customer_id = 111 AND create_time >= '2023-10-02 09:57:24' AND create_time <= '2023-10-02 10:00:46' ORDER BY create_time DESC LIMIT 3;
这里三个查询只有第2个查询相对较快一点,耗时110毫秒,其它两个查询耗时基本上都接近500毫秒,从我们给定的条件来看区别就在分页或者不分页,排序或者不排序,一般认为分页肯定比不分页查询要快,但是我们看1和2查询分页比不分页耗时相差接近5倍相差可以说是巨大,然后在查询3中分页但是根据创建时间倒序,这里耗时和查询1相近和查询2耗时相差接近5倍,这其中原理挺有趣的会在下面索引优化实践中举例说明这一些问题。
这里会对一些业务场景举例说明,也会对索引的一些特性做讲解。
类似通过订单编号查询订单信息的业务有很多,都是通过一个编号信息如客户编号配送员编号查询一个一对一的详情数据,这一类查询都有一个特性编号唯一,并且编号类的数据大多都是字符串类型,这里优化可以考虑唯一索引和普通索引,一般我们会给这一类编号数据设置一个唯一索引,既保证了数据的唯一性也保证了通过编号查询的性能问题。
ALTER TABLE `order_info` ADD UNIQUE INDEX `idx_orderNo`(`order_no`);
SELECT * FROM order_info WHERE order_no = 'ON000009999';
无索引测试的时候耗时0.538s,添加索引后查询性能提升十几倍,数据量越大提升比例越高。
在上面无索引查询我们列举了查询订单列表的三个例子,查询耗时除了第2个都差不多耗时0.5s,不算太慢但是对MySQL性能开销其实是很大的,如果数据量在大一些到500w 1000w,查询时间也会增加到接受不了数值,所以必须要优化。
优化前第一要考虑的就是需要一些什么字段,如我们例子中会使用订单编号、客户编号、订单状态、创建时间。
分析通过这四个字段查询的场景:
这里我给这四个字段分了四个查询场景,为什么这么分我在下面会详细说明。
对于这种列表查询使用索引一定要知道索引的一个特性就是最左前缀原则,索引的匹配一定是从最左边第一个字段开始匹配的,不能跳过中间字段匹配,在索引优化实践中会详细说明。
在一个订单列表如果需要根据订单编号查询,那么一定是要查询一个唯一的订单,如果我们有索引那么我们可以通过一个订单编号快速定位到一条数据不用进行全表扫描,竟然能快速定位到一条数据那么就算还携带别的条件那直接回表取出行数据再去判断即可。
所以这里只需要创建一个订单编号的索引来适配所有带订单编号的查询,我们在本文的4.1的优化通过订单编号查询订单信息中创建过一个订单编号的唯一索引,我们这里就用这个唯一索引就行。
SELECT * FROM order_info WHERE order_no = 'ON000009999' AND order_status=4;
这里可以看到查询耗时为0.037s,执行计划中使用到了订单编号的索引,扫描估计行数为1。
在需要根据客户编号查询的业务中一定是以客户编号为主要条件的,还有可能会携带订单状态,创建时间等,一个客户可能会下很多单,想想自己这些年网购和点外面应该也有个100单以上了把,那么这里就不能像订单编号只用一个单字段索引了,我们需要把订单状态和创建时间也加上,其实就算不加性能也不会差太多因为一个客户订单本来也不会太多,单表几十条数据和几百条数据查询差距不会很大。
ALTER TABLE `order_info` ADD INDEX `idx_customerId_orderStatus_createTime`(`customer_id`, `order_status`, `create_time`);
我这里会把订单状态和创建时间也带上,某购物APP查询自己订单是不是都有状态选择,时间字段可以用作排序和检索。
SELECT * FROM order_info WHERE customer_id = 111 AND order_status=4 AND create_time >= '2023-10-02 09:57:24' AND create_time <= '2023-10-02 10:00:46';
执行计划 EXPLAIN
这里可以看到使用到了我们创建的联合索引,并且三个字段全用到了,客户编号bigint类型不能为空占用8字节,订单状态tinyint不能为空占用1字节,创建时间datetime类型占用5个字节因为创建时间可为空所以多加一个字节,创建时间占用6个字节,合集15个字节和key_len相等。
在查询订单列表时经常会查询某个状态某个时间有多少订单,状态值只有4个如果要通过状态值建立索引的话显然是不可行的,通过状态索引查找某个类型数据可能得到的是几十万行数据,然后还需要回表获取聚簇索引数据,所以对于这种状态值创建单独索引时还需要带上时间字段,在单独查询某个时间内全部订单时也可以使用这个索引,通过in查询将状态值全部包含满足最左前缀原则就能使用该索引查询指定时间段的全部订单。
SELECT * FROM order_info WHERE order_status IN (1,2,3,4) AND create_time >= '2023-10-02 09:57:30' AND create_time <= '2023-10-02 09:57:31';
因为我们是批量插入的时间间隔比较相近1秒有好几千条数据,自己查询测试的时候最好控制区间在2s的样子。
ALTER TABLE `order_info` ADD INDEX `idx_orderStatus_createTime`(`order_status`,`create_time`);
SELECT * FROM order_info WHERE order_status IN (1,2,3,4) AND create_time >= '2023-10-02 09:57:30' AND create_time <= '2023-10-02 09:57:40' ;
执行计划 EXPLAIN
我们这里数据是批量插入的,每秒会插入几千条数据,查询时间间隔不能太大了最好在2s的样子,不然索引可能是会失效的,要查询某个状态的订单只有把订单状态的的IN查询换成=查询效果是一样的,满足最左前缀原则即可。
在订单常见业务索引优化实践中简单的介绍了一下在一些场景下创建一些什么索引能提升查询效率,但是还有很多可变因素会影响到索引的使用,也有很多场景可以使用更好的索引,以及索引中很重要的左前缀原则,这里会对一些场景做举例说明。
删除之前创建的索引,创建新的测试联合索引
# 删除上面创建的三个索引,避免测试时被其它索引干扰,要是没有创建则不用删除
ALTER TABLE `order_info` DROP INDEX `idx_orderNo`;
ALTER TABLE `order_info` DROP INDEX `idx_customerId_orderStatus_createTime`;
ALTER TABLE `order_info` DROP INDEX `idx_orderStatus_createTime`;
# 创建新的测试联合索引
ALTER TABLE `order_info` ADD INDEX `idx_goodsTitle_customerId_orderStatus`(`goods_title`, `customer_id`, `order_status`);
EXPLAIN SELECT * FROM order_info WHERE goods_title > '笔记本电脑9' AND customer_id = 9 AND order_status = 4;
EXPLAIN SELECT * FROM order_info WHERE goods_title > '笔记本电脑9' AND goods_title < '笔记本电脑99' AND customer_id = 9 AND order_status = 4;
联合索引第一个字段就用范围查找可能不会走索引,对于MySQL5.7来说只要第一个字段用范围查找不会走索引,但是对于MySQL8.0来说给个查询区间还是可能会走索引的,前提是区间也不能太大不然也不会走索引,MySQL内部可能觉得第一个字段用范围,结果集应该很大,回表效率不高,还不如就全表扫描,创建联合索引时千万别把时间这类型字段放第一个了。
EXPLAIN SELECT * FROM order_info FORCE INDEX(idx_goodsTitle_customerId_orderStatus) WHERE goods_title > '笔记本电脑9' AND customer_id = 9 AND order_status = 4;
经过对比发现强制走索引查询时间能缩短近10倍,所以有时候MySQL自身并不一定能选择到性能最高的索引使用方式,需要自己不断的尝试对比出最好的方式。
EXPLAIN SELECT goods_title,customer_id,order_status FROM order_info WHERE goods_title > '笔记本电脑9' AND customer_id = 9 AND order_status = 4;
查询结果字段和条件字段都在同一个索引中,查询可以完全使用索引中字段不用回表则称为覆盖索引,覆盖索引因为能避免回表就算联合索引第一个字段范围查询也能走索引。
EXPLAIN SELECT * FROM order_info WHERE goods_title IN('笔记本电脑8','笔记本电脑9') AND customer_id = 9 AND order_status = 4;
EXPLAIN SELECT * FROM order_info WHERE (goods_title = '笔记本电脑8' OR goods_title = '笔记本电脑9') AND customer_id = 9 AND order_status = 4;
这里可以看到MySQL5.7中会进行全表扫描,但是MySQL8.0还是会走索引,现在MySQL8.0市场上用的已经比较多了,它执意要走索引肯定有它的道理,而且要通过索引优化查询肯定是要测试比较查询效率的,在实际业务中多测试再看看执行计划,只要实践才知道加的索引是否好用。
EXPLAIN SELECT * FROM order_info WHERE goods_title LIKE '笔记本电脑999%' AND customer_id = 9 AND order_status = 4;
EXPLAIN SELECT * FROM order_info WHERE goods_title LIKE '笔记本电脑9%' AND customer_id = 9 AND order_status = 4;
使用like前缀查询在结果集较少的时候是会走索引的,如果MySQL认为结果集较大还是不会走索引,结合以上几个例子可以看出,如果通过索引查询响应结果集过大并且没有满足覆盖索引也很有可能不会走索引,这点MySQL5.0 8.0都是一样的。
EXPLAIN SELECT * FROM order_info WHERE customer_id = 9 ORDER BY goods_title;
SELECT * FROM order_info WHERE customer_id = 9 LIMIT 0,3;
SELECT * FROM order_info WHERE customer_id = 9 LIMIT 80,3;
通过这两个分页查询可以看到查询从第0条开始往后查3条数据执行耗时很短,查询从第80条开始往后查3条数据耗时和不分页差不多,因为MySQL分页查询的时候会根据我们的分页条件找出对应结果集的数据,比如我们分页条件时customer_id = 9 LIMIT 0,3,MySQL会先用customer_id = 9去一条条对比数据,因为我们的分页参数时0,3也就是说找出3条数据即可,只要找到了3条数据就不会往后找了,同理LIMIT 80,3的时候需要找到83条数据就不会往后找了,所以这里我们LIMIT 80,3的时候和不分页耗时差不了多少,不加分页会扫描全表拿出全表的数据。
SELECT * FROM order_info WHERE customer_id = 9 ORDER BY create_time LIMIT 0,3;
这里加了一个排序条件分页查询后耗时也和不分页差不多,因为如果加了排序字段是需要先扫全表获取全部符合结果的数据才能进行分页。
# 创建索引
ALTER TABLE `order_info` ADD INDEX `idx_customerId_createTime`(`customer_id`, `create_time`);
# 删除索引
ALTER TABLE `order_info` DROP INDEX `idx_customerId_createTime`;
SELECT * FROM order_info WHERE customer_id = 9 ORDER BY create_time LIMIT 0,3;
执行计划 EXPLAIN
创建索引后查询快了很多,因为这里是正序的索引默认也是正序的根本不用在进行排序,下面再试试倒序。
SELECT * FROM order_info WHERE customer_id = 9 ORDER BY create_time DESC LIMIT 0,3;
MySQL 5.7
MySQL8.0
查询时间和正序差不多,但是这里MySQL5.7和MySQL8.0的Extra有点区别,MySQL8.0可以反向索引扫描,MySQL5.7应该是将获取到的数据放入内存中排序,也可以创建倒序索引这里不深入。
EXPLAIN SELECT * FROM order_info WHERE goods_title = '笔记本电脑9' AND order_status = 4 ORDER BY customer_id;
利用最左前缀原则:中间字段不能断,因此查询用到了goods_title索引,从key_len=403也能看出,customer_id索引列用在排序过程中,因为Extra字段里没有using filesort,而且索引本来就是正序的无需在排。
EXPLAIN SELECT * FROM order_info WHERE goods_title = '笔记本电脑9' ORDER BY order_status;
从explain的执行结果来看:key_len=403,查询使用了goods_title 索引,由于用了order_status进行排序,跳过了
customer_id,出现了Using filesort。
EXPLAIN SELECT * FROM order_info WHERE goods_title = '笔记本电脑9' ORDER BY customer_id,order_status;
查找只用到索引goods_title 字段,customer_id和order_status用于排序,无Using filesort。
EXPLAIN SELECT * FROM order_info WHERE goods_title = '笔记本电脑9' ORDER BY order_status,customer_id;
和例3中explain的执行结果一样,但是出现了Using filesort,因为索引的创建顺序为goods_title,customer_id,order_status,但是排序的时候customer_id和order_status颠倒位置了。
EXPLAIN SELECT * FROM order_info WHERE goods_title = '笔记本电脑9' AND customer_id = 9 ORDER BY order_status,customer_id;
与例4对比,在Extra中并未出现Using filesort,因为customer_id 为常量,在排序中被优化,所以索引未颠倒,不会出现Using filesort。
EXPLAIN SELECT * FROM order_info WHERE goods_title = '笔记本电脑9' ORDER BY customer_id ASC,order_status DESC;
虽然排序的字段列与索引顺序一样,且order by默认升序,这里position desc变成了降序,导致与索引的排序方式不同,从而产生Using filesort。
EXPLAIN SELECT * FROM order_info WHERE goods_title IN ('笔记本电脑9','笔记本电脑10') ORDER BY customer_id,order_status;
对于排序来说,IN查询也是范围查询,如果排序查询中使用范围查询则索引中只能用到goods_title,后面的字段无法使用。
EXPLAIN SELECT * FROM order_info WHERE goods_title > '笔记本电脑9' ORDER BY goods_title;
MySQL8.0
MySQL5.7只要是联合索引第一个字段要走范围查询,那么索引就没法使用,除非覆盖索引,但是在MySQL8.0时却是会去使用索引的,还可以对比一下查询性能,我这里MySQL5.7全表扫描耗时1.3s,MySQL8.0走索引耗时0.8s,两个版本MySQL第一次执行可能时间都需要2s的样子,会将磁盘中的聚簇索引叶子节点数据加载到内存中,多次执行后回表可以直接查内存不用再次读取磁盘。
Using filesort文件排序原理详解