MySQL调优之Explain详解与索引优化实践
本文介绍了MySQL的Explain工具及其在SQL调优中的应用。主要内容包括: Explain工具概述:作为MySQL提供的调试工具,帮助开发者了解SQL执行路径,优化查询性能。 基本用法:在SELECT语句前加EXPLAIN关键字获取执行计划信息,展示id、select_type、table等关键指标。 扩展功能: 使用EXPLAIN EXTENDED可获取filtered列(估算查询连接范围
1、概述
Explain可以理解成MySQL提供给sql编写人员的调试工具,通过这个工具可以感知到sql在存储引擎中具体执行路径是什么,以此为依据来判断sql优化结果是否符合预期。
后文分为2部分:Explain工具介绍
和 sql调优实践示例
。通过这2部分来帮助读者(当然也包括作者自己)快速熟悉使用Explain工具调优整个过程。
说明
1、本文理论知识基于存储引擎InnoDB
+ MySQL版本5.7。
2、Explain工具介绍
一般用法,在select 语句前面加上explain
关键字,这样在查询的时候MySQL就会返回执行计划信息:
MariaDB [wang_test]> explain select * from t_user;
+------+-------------+--------+------+---------------+------+---------+------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+------+---------------+------+---------+------+------+-------+
| 1 | SIMPLE | t_user | ALL | NULL | NULL | NULL | NULL | 1 | |
+------+-------------+--------+------+---------------+------+---------+------+------+-------+
这些字段每个都有自己的含义,具体在后文进一步介绍。
特别说明,当from中包含子查询时,依旧会执行该子查询并放入临时表中,当然这个子查询必须是复杂的(不然会被优化器优化掉),例如包含union、聚合等操作。
本小节后文以如下表结构为示例:
CREATE TABLE IF NOT EXISTS `t_user`(
`id` bigint(50) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` varchar(255) NOT NULL COMMENT '用户姓名',
`email_address` varchar(255) NOT NULL DEFAULT '' COMMENT '邮箱',
`last_modified_time` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp () COMMENT '数据库记录上一次更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '用户信息表';
insert into t_user (id, user_name, email_address) values (1, '王霄一', 'wangmyyau@qq.com');
CREATE TABLE IF NOT EXISTS `t_address`(
`id` bigint(50) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`address` varchar(255) NOT NULL DEFAULT '' COMMENT '收货地址',
`last_modified_time` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp () COMMENT '数据库记录上一次更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '用户收货地址表';
insert into t_address (id, address) values (8, '四川省成都市二仙桥大道');
CREATE TABLE IF NOT EXISTS `t_user2address`(
`id` bigint(50) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint(50) NOT NULL COMMENT '[关联表t_user] 用户ID',
`address_id` bigint(50) NOT NULL COMMENT '[关联表t_address] 用户收货地址',
`last_modified_time` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp () COMMENT '数据库记录上一次更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '用户与收货地址关联表';
insert into t_user2address (user_id, address_id) values (1, 8);
2.1、Explain工具其它关键字
在explain关键字的基础上还可以加上extended
或partitions
关键字使MySQL返回的执行计划更加的具体。
2.1.1、explain extended + select查询语句,可以多返回一列字段filtered
,用于估算查询连接范围集大小:
MariaDB [wang_test]> explain extended select * from t_user;
+------+-------------+--------+------+---------------+------+---------+------+------+----------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+------+-------------+--------+------+---------------+------+---------+------+------+----------+-------+
| 1 | SIMPLE | t_user | ALL | NULL | NULL | NULL | NULL | 1 | 100.00 | |
+------+-------------+--------+------+---------------+------+---------+------+------+----------+-------+
1 row in set, 1 warning (0.000 sec)
MariaDB [wang_test]> select count(*) from t_user;
+----------+
| count(*) |
+----------+
| 1 |
+----------+
1 row in set (0.000 sec)
估算值 = rows * filtered/100
实际上,当前t_user表中确实仅有1条数据。(1 * 100 / 100 = 1), 如果是联表查询,rows的取值是目标表的行数。
除了多返回一列字段filtered
,再紧接着执行show warning
时可以直接看到MySQL优化后的sql长啥样。(这个就很有用了):
MariaDB [wang_test]> explain extended select * from (select * from t_user) as tmp;
+------+-------------+--------+------+---------------+------+---------+------+------+----------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+------+-------------+--------+------+---------------+------+---------+------+------+----------+-------+
| 1 | SIMPLE | t_user | ALL | NULL | NULL | NULL | NULL | 1 | 100.00 | |
+------+-------------+--------+------+---------------+------+---------+------+------+----------+-------+
1 row in set, 1 warning (0.000 sec)
MariaDB [wang_test]> show warnings;
+-------+------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Level | Code | Message |
+-------+------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Note | 1003 | select `wang_test`.`t_user`.`id` AS `id`,`wang_test`.`t_user`.`user_name` AS `user_name`,`wang_test`.`t_user`.`email_address` AS `email_address`,`wang_test`.`t_user`.`last_modified_time` AS `last_modified_time` from `wang_test`.`t_user` |
+-------+------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.000 sec)
这里就可以明显看出,把冗余的子查询给直接优化掉了。这样就直接去掉了内存临时表的额外开销。
2.1.2、explain partitions + select查询语句,可以多返回一列字段partitions
该partitions字段用于表示要访问的分区,MySQL提供自己的分区功能,但是我实际上也没用过。。。。。这里仅做了解吧。
同时生产上面分区方案,也不会直接使用MySQL直接提供的这个, 我觉得最主要的原因还是MySQL自身维护数据存储已经开销很大了,再继续维护分区的话,将直接裂开,导致性能整体下滑:
MySQL分区功能限制多、维护复杂、性能收益有限,加上现在有更好的分库分表中间件和云原生数据库方案,所以在生产环境中使用较少。对于大多数场景,通过合理的索引设计、读写分离、缓存策略等方式,能够更好地解决性能问题。
MariaDB [wang_test]> explain partitions select * from t_user;
+------+-------------+--------+------------+------+---------------+------+---------+------+------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+------------+------+---------------+------+---------+------+------+-------+
| 1 | SIMPLE | t_user | NULL | ALL | NULL | NULL | NULL | NULL | 1 | |
+------+-------------+--------+------------+------+---------------+------+---------+------+------+-------+
1 row in set (0.000 sec)
2.2、Explain结果各列详细说明
explain返回的执行计划中有10列字段,一下将逐一进行说明。
MariaDB [wang_test]> explain select * from t_user;
+------+-------------+--------+------+---------------+------+---------+------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+------+---------------+------+---------+------+------+-------+
| 1 | SIMPLE | t_user | ALL | NULL | NULL | NULL | NULL | 1 | |
+------+-------------+--------+------+---------------+------+---------+------+------+-------+
1 row in set (0.000 sec)
2.2.1、id
1、一个复杂查询sql中多少个select关键字,执行结果的返回值对应的就有多少行(id)。
2、该执行结果集中的id值越大,代表越先被执行。
3、该执行结果集中的id值相同,则从上往下执行。
4、该执行结果集中的id值为NULL,则最后执行。
2.2.2、select_type
顾名思义,查询的类型:
1、simple:简单查询,仅一个查询。
2、primary:主查询,复杂查询中最外层的查询。
3、subquery:子查询,但是不包含在from中。
4、derived:包含在 from 子句中的子查询。MySQL会将结果存放在一个临时表中,也称为派生表(derived的英文含 义)
MariaDB [wang_test]> set session optimizer_switch='derived_merge=off'; #关闭对衍生表的合并优化
Query OK, 0 rows affected (0.000 sec)
MariaDB [wang_test]> explain select (select 1 from t_user where id = 1) from (select * from t_address where id = 8) tmp;
+------+-------------+------------+--------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+------------+--------+---------------+---------+---------+-------+------+-------------+
| 1 | PRIMARY | <derived3> | system | NULL | NULL | NULL | NULL | 1 | |
| 3 | DERIVED | t_address | const | PRIMARY | PRIMARY | 8 | const | 1 | |
| 2 | SUBQUERY | t_user | const | PRIMARY | PRIMARY | 8 | const | 1 | Using index |
+------+-------------+------------+--------+---------------+---------+---------+-------+------+-------------+
3 rows in set (0.001 sec)
MariaDB [wang_test]> set session optimizer_switch='derived_merge=on';
Query OK, 0 rows affected (0.000 sec)
可以看到有三个select_type, 能与上述对应上。
另外select (select 1 from t_user where id = 1) from (select * from t_address where id = 8) tmp;
的作用说明:
1)查询结果集每多一行, (select 1 from t_user where id = 1) 就会被执行一次。
2)这个整条sql的作用是:当存在t_address的记录id为8时,检查是否存在t_user的记录ID为1。 如果存在,则输出1,通常用于存在检查。
5、union:使用union关键字则有
MariaDB [wang_test]> explain select 1 union all select 1;
+------+-------------+-------+------+---------------+------+---------+------+------+----------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-------+------+---------------+------+---------+------+------+----------------+
| 1 | PRIMARY | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used |
| 2 | UNION | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used |
+------+-------------+-------+------+---------------+------+---------+------+------+----------------+
2 rows in set (0.000 sec)
2.2.3、table
1、代表该行的执行计划访问的表(或者说本质上是数据源)。
2、当是deriven${N} 格式,表示当前执行计划行
依赖执行计划结果集中 id=N
的执行结果,于是先执行 id=N 的执行计划查询,从上面的例子能看出来。
3、当有 union 时,select_type为UNION RESULT 的 table 列的值为<union1,2>,1和2表示参与 union 的 执行计划id。
+----+--------------+------------+------+---------------+------+---------+------+------+----------+-----------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+--------------+------------+------+---------------+------+---------+------+------+----------+-----------------+
| 1 | PRIMARY | t_user | ref | idx_status | ... | ... | ... | 100 | 100.00 | Using where |
| 2 | UNION | t_address | ref | idx_status | ... | ... | ... | 50 | 100.00 | Using where |
| NULL| UNION RESULT | <union1,2>| ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary |
+----+--------------+------------+------+---------------+------+---------+------+------+----------+-----------------+
2.2.4、partitions
上面有提到,是MySQL本身的分区。属于explain partitions 关键字才有的执行结果。
2.2.5、type
重要:我们优化是否达到了预期,实际上大多是看这个字段的值。也就是MySQL的执行类型,通过什么方式去查询。
1、按照类型的效率排序,主要分为:system > const > eq_ref > ref > range > index > all
2、另外有一个特殊的情况,就是NULL
,是指在不执行查询表或索引时,仅在sql的优化阶段就可以确定返回值时的情况:
MariaDB [wang_test]> explain select min(id) from t_user;
+------+-------------+-------+------+---------------+------+---------+------+------+------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-------+------+---------------+------+---------+------+------+------------------------------+
| 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | Select tables optimized away |
+------+-------------+-------+------+---------------+------+---------+------+------+------------------------------+
1 row in set (0.000 sec)
说明
1、优化器发现有min、max则直接检查id是否是索引,如果是,则直接访问B+树的最左子叶或者最右子叶,完全不需要经过扫描。
2、当然表自身也维护了表总大小的近似值等元数据,也不需要访问索引和表:
MariaDB [wang_test]> SELECT TABLE_NAME, TABLE_ROWS FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'wang_test' and TABLE_NAME = 't_user';
+------------+------------+
| TABLE_NAME | TABLE_ROWS |
+------------+------------+
| t_user | 1 |
+------------+------------+
1 row in set (0.000 sec)
2.2.5.1、system
查询源中,仅有一个常量元组(说白了就是,直接可以把sql语句中的from作为查询源拿来用):
MariaDB [wang_test]> explain select * from (select 1 as id, '王霄一' as name) as tmp;
+------+-------------+------------+--------+---------------+------+---------+------+------+----------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+------------+--------+---------------+------+---------+------+------+----------------+
| 1 | PRIMARY | <derived2> | system | NULL | NULL | NULL | NULL | 1 | |
| 2 | DERIVED | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used |
+------+-------------+------------+--------+---------------+------+---------+------+------+----------------+
2 rows in set (0.000 sec)
2.2.5.2、const
常量级,也就是查找次数是确定一次就可以找到的,比如通过主键索引、唯一索引找,那么查询次数必定仅有一次:
MariaDB [wang_test]> explain select * from t_user where id = 1;
+------+-------------+--------+-------+---------------+---------+---------+-------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+-------+---------------+---------+---------+-------+------+-------+
| 1 | SIMPLE | t_user | const | PRIMARY | PRIMARY | 8 | const | 1 | |
+------+-------------+--------+-------+---------------+---------+---------+-------+------+-------+
1 row in set (0.000 sec)
2.2.5.3、eq_ref
2表联合查询,仅使用主键索引或唯一索引(如果这2个索引为复合索引,那么条件必须是它的所有字段)。
MariaDB [wang_test]> explain select * from t_user u1 left join t_user u2 on u1.id = u2.id;
+------+-------------+-------+--------+---------------+---------+---------+-----------------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-------+--------+---------------+---------+---------+-----------------+------+-------+
| 1 | SIMPLE | u1 | ALL | NULL | NULL | NULL | NULL | 1 | |
| 1 | SIMPLE | u2 | eq_ref | PRIMARY | PRIMARY | 8 | wang_test.u1.id | 1 | |
+------+-------------+-------+--------+---------------+---------+---------+-----------------+------+-------+
2 rows in set (0.000 sec)
2.2.5.4、ref
1、简单查询中用到了非唯一索引(包括非主键索引)或者唯一索引的最左前缀。-- 说白了就是仅用上了普通索引。(当然有一个前提,where的判断条件是使用等号‘=’)
MariaDB [wang_test]> alter table t_user add index idx_name (user_name) ; # 增加一个user_name普通索引
Query OK, 0 rows affected (0.017 sec)
Records: 0 Duplicates: 0 Warnings: 0
MariaDB [wang_test]> explain select * from t_user where user_name = 'xx';
+------+-------------+--------+------+---------------+----------+---------+-------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+------+---------------+----------+---------+-------+------+-----------------------+
| 1 | SIMPLE | t_user | ref | idx_name | idx_name | 1022 | const | 1 | Using index condition |
+------+-------------+--------+------+---------------+----------+---------+-------+------+-----------------------+
1 row in set (0.001 sec)
2、同理,在联表查询时,也仅使用普通索引(包括左前缀唯一复合索引)作为联结条件,也是一样的ref。
2.2.5.5、range
使用索引进行范围查找(包括主键索引、唯一索引)。范围查找是指(<、>、=、in、between)。 – like不算
MariaDB [wang_test]> explain select * from t_user where id in (1, 2);
+------+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| 1 | SIMPLE | t_user | range | PRIMARY | PRIMARY | 8 | NULL | 1 | Using where |
+------+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
1 row in set (0.000 sec)
MariaDB [wang_test]>
MariaDB [wang_test]> explain select * from t_user where user_name in ('xx', 'aa'); # user_name上面示例中加了普通索引
+------+-------------+--------+-------+---------------+----------+---------+------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+-------+---------------+----------+---------+------+------+-----------------------+
| 1 | SIMPLE | t_user | range | idx_name | idx_name | 1022 | NULL | 1 | Using index condition |
+------+-------------+--------+-------+---------------+----------+---------+------+------+-----------------------+
1 row in set (0.000 sec)
2.2.5.6、index_merge(8/31 补充)
where条件中包含了多个索引的情况,并且为了获取结果集还需要合并。
MariaDB [wang_test]> ALTER TABLE t_user ADD INDEX idx_user_name (user_name); -- 添加索引,模拟多索引情况
Query OK, 0 rows affected, 1 warning (0.024 sec)
Records: 0 Duplicates: 0 Warnings: 1
MariaDB [wang_test]> ALTER TABLE t_user ADD INDEX idx_email_address (email_address); -- 添加索引,模拟多索引情况
Query OK, 0 rows affected (0.014 sec)
Records: 0 Duplicates: 0 Warnings: 0
MariaDB [wang_test]> EXPLAIN SELECT * FROM t_user force INDEX (idx_user_name, idx_email_address) WHERE user_name = 'xx' OR email_address = 'xx'; -- 数据量少,如果不加force index强制走index_merge,会全表扫描,这里只是为了效果演示
+------+-------------+--------+-------------+---------------------------------+---------------------------------+-----------+------+------+-----------------------------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+-------------+---------------------------------+---------------------------------+-----------+------+------+-----------------------------------------------------------+
| 1 | SIMPLE | t_user | index_merge | idx_user_name,idx_email_address | idx_user_name,idx_email_address | 1022,1022 | NULL | 1 | Using union(idx_user_name,idx_email_address); Using where |
+------+-------------+--------+-------------+---------------------------------+---------------------------------+-----------+------+------+-----------------------------------------------------------+
1 row in set (0.000 sec)
MariaDB [wang_test]> ALTER TABLE t_user DROP INDEX idx_user_name; -- 还原索引
Query OK, 0 rows affected (0.011 sec)
Records: 0 Duplicates: 0 Warnings: 0
MariaDB [wang_test]> ALTER TABLE t_user DROP INDEX idx_email_address; -- 还原索引
Query OK, 0 rows affected (0.011 sec)
Records: 0 Duplicates: 0 Warnings: 0
其实本质上属于范围查询,但是最后还涉及到额外的合并开销,因此判定速度比range慢,并且有可能不回表,就也判定比index快。
2.2.5.7、index
名覆盖索引,即不从B+树根节点开始查找,而是直接查找二级索引的叶子节点就可以拿到select所需的全字段(不用回表),比扫描全表快些。
MariaDB [wang_test]> explain select id,user_name from t_user ; # user_name在上面有添加普通索引
+------+-------------+--------+-------+---------------+----------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+-------+---------------+----------+---------+------+------+-------------+
| 1 | SIMPLE | t_user | index | NULL | idx_name | 1022 | NULL | 1 | Using index |
+------+-------------+--------+-------+---------------+----------+---------+------+------+-------------+
1 row in set (0.000 sec)
2.2.5.8、ALL
需要扫描全表(全聚簇索引)才能拿到数据结果。(最low的)
MariaDB [wang_test]> explain select * from t_user;
+------+-------------+--------+------+---------------+------+---------+------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+------+---------------+------+---------+------+------+-------+
| 1 | SIMPLE | t_user | ALL | NULL | NULL | NULL | NULL | 1 | |
+------+-------------+--------+------+---------------+------+---------+------+------+-------+
1 row in set (0.000 sec)
2.2.6、possible_keys
possible_keys
列显示查询可能使用哪些索引来查找,而实际使用了哪个索引由key
列决定。
explain 时可能出现 possible_keys 有值,而 key列 显示 NULL 的情况,这种情况有可能是因为表中数据不多,优化器认为索引对 此查询帮助不大,选择了全表查询。
如果possible_keys
列是NULL,则代表没有where子句没有匹配上相关的索引。
2.2.7、key
1、上面提到过,用于显示当前执行计划实际会用到索引。
2、另外值得一提的是:如果想强制执行计划使用或忽视possible_keys列中的索引,在查询中使用 force index、ignore index。(但是不推荐这么干,因为执行计划运行至今,已经积累了太多自己的经验,可以值得相信)
2.2.8、key_len
顾名思义,就是索引字段的长度。
示例:
有一个索引: idx_address(id, address_id), 其中id字段为bigint占8字节,address_id也占8个字节,加在一起是16字节
,那么这个索引key idx_address的key_len 就是16
。
这里就涉及到一些各个表字段类型占用多少字段的知识:
char(n):如果存汉字长度就是 3n 字节
tinyint:1字节 smallint:2字节 int:4字节 bigint:8字节
时间类型 date:3字节
timestamp:4字节
datetime:8字节
字符串,char(n)和varchar(n),n均代表字符数,而不是字节数,如果是utf-8,一个数字或字母占1个字节,一个汉字 占3个字节
varchar(n):如果存汉字则长度是 3n + 2 字节,加的2字节用来存储字符串长度,因为varchar是变长字符串 数值类型
如果字段允许为 NULL,需要1字节记录是否为 NULL
索引最大长度是768字节,当字符串过长时,mysql会做一个类似左前缀索引的处理,将前半部分的字符提取出来做索引。
2.2.9、ref
具体是执行计划进行查找时所用到的字段,上面的用例中可以观察到。
说明
1、const:用到的是常量
2、t_user.id: 用到了id字段
2.2.10、rows
这一列是mysql估计要读取并检测的行数,注意这个不是结果集里的行数。
2.2.11、filtered
上面提过是估算的,与目标表连接将会扫描的预估行数。(公式 rows * filtered / 100),其中rows就是连接目标表要检测的函数,也就是执行计划结果集中的rows列。
2.2.12、Extra
扩展列,用于显示执行计划的内部细节内容:
1、Using index: 使用了覆盖索引。
2、Using where: 全表扫描(不会借助任何索引) + 条件
MariaDB [wang_test]> explain select * from t_address where address = 'xx';
+------+-------------+-----------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-----------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | t_address | ALL | NULL | NULL | NULL | NULL | 1 | Using where |
+------+-------------+-----------+------+---------------+------+---------+------+------+-------------+
1 row in set (0.000 sec)
3、Using index condition: 使用了索引,但是没用上覆盖索引(也就是走索引 + 回表
):
MariaDB [wang_test]> explain select * from t_user where user_name = 'xx'; # user_name的普通索引中没有除了id 和 user_name的其它字段
+------+-------------+--------+------+---------------+----------+---------+-------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+--------+------+---------------+----------+---------+-------+------+-----------------------+
| 1 | SIMPLE | t_user | ref | idx_name | idx_name | 1022 | const | 1 | Using index condition |
+------+-------------+--------+------+---------------+----------+---------+-------+------+-----------------------+
1 row in set (0.000 sec)
4、Using temporary:为了完成本条,使用了临时表,也是没有加索引的情况下:
MariaDB [wang_test]> explain select distinct address from t_address; # address没有索引,为了去重只能额外借助临时表
+------+-------------+-----------+------+---------------+------+---------+------+------+-----------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-----------+------+---------------+------+---------+------+------+-----------------+
| 1 | SIMPLE | t_address | ALL | NULL | NULL | NULL | NULL | 1 | Using temporary |
+------+-------------+-----------+------+---------------+------+---------+------+------+-----------------+
1 row in set (0.000 sec)
5、Using filesort:使用了order by排序,但是排序依据没有加索引,只能全部load出来,临时硬排序。
MariaDB [wang_test]> explain select address from t_address order by address;
+------+-------------+-----------+------+---------------+------+---------+------+------+----------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-----------+------+---------------+------+---------+------+------+----------------+
| 1 | SIMPLE | t_address | ALL | NULL | NULL | NULL | NULL | 1 | Using filesort |
+------+-------------+-----------+------+---------------+------+---------+------+------+----------------+
1 row in set (0.000 sec)
6、Select tables optimized away:
翻译过来是:选择已优化的表
实际上就是和上文提到的type为null一样的结果,就是sql在优化阶段发现不需要去走索引和表。例如min、max。
MariaDB [wang_test]> explain select min(id) from t_user;
+------+-------------+-------+------+---------------+------+---------+------+------+------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-------+------+---------------+------+---------+------+------+------------------------------+
| 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | Select tables optimized away |
+------+-------------+-------+------+---------------+------+---------+------+------+------------------------------+
1 row in set (0.000 sec)
3、SQL调优实践示例
3.1、一般场景示例
以下都使用如下表演示(其中有一个普通的复合索引idx_name_build_addr
):
CREATE TABLE IF NOT EXISTS `t_house`(
`id` bigint(50) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(18) NULL COMMENT '房屋名称',
`buid_years` int(4) NULL COMMENT '已经修建年数',
`address` varchar(255) NULL COMMENT '房屋地址',
`last_modified_time` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp () COMMENT '数据库记录上一次更新时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_name_build_addr`(`name`, `buid_years`, `address`) USING BTREE COMMENT '普通索引'
) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '房屋表';
3.1.1、左前缀法则
当是复合索引时,只要where的条件中使用的左前缀时,尽管没有包括上复合索引的全字段,也会走该索引:
1、仅包含name:
MariaDB [wang_test]> explain select * from t_house where name = 'xx';
+------+-------------+---------+------+---------------------+---------------------+---------+-------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------------+---------------------+---------+-------+------+-----------------------+
| 1 | SIMPLE | t_house | ref | idx_name_build_addr | idx_name_build_addr | 75 | const | 1 | Using index condition |
+------+-------------+---------+------+---------------------+---------------------+---------+-------+------+-----------------------+
1 row in set (0.001 sec)
2、仅包含name和buid_years
MariaDB [wang_test]> explain select * from t_house where name = 'xx' and buid_years = 8;
+------+-------------+---------+------+---------------------+---------------------+---------+-------------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------------+---------------------+---------+-------------+------+-----------------------+
| 1 | SIMPLE | t_house | ref | idx_name_build_addr | idx_name_build_addr | 80 | const,const | 1 | Using index condition |
+------+-------------+---------+------+---------------------+---------------------+---------+-------------+------+-----------------------+
1 row in set (0.000 sec)
3、全量匹配
MariaDB [wang_test]> explain select * from t_house where name = 'xx' and buid_years = 8 and address = 'xx';
+------+-------------+---------+------+---------------------+---------------------+---------+-------------------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------------+---------------------+---------+-------------------+------+-----------------------+
| 1 | SIMPLE | t_house | ref | idx_name_build_addr | idx_name_build_addr | 1103 | const,const,const | 1 | Using index condition |
+------+-------------+---------+------+---------------------+---------------------+---------+-------------------+------+-----------------------+
1 row in set (0.000 sec)
4、反例,不遵循最左前缀
1)仅用address
MariaDB [wang_test]> explain select * from t_house where address = 'xx';
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | t_house | ALL | NULL | NULL | NULL | NULL | 1 | Using where |
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
1 row in set (0.000 sec)
2)仅使用buid_time 和 address:
MariaDB [wang_test]> explain select * from t_house where buid_years = 8 and address = 'xx';
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | t_house | ALL | NULL | NULL | NULL | NULL | 1 | Using where |
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
1 row in set (0.000 sec)
3)仅使用name和address:
MariaDB [wang_test]> explain select * from t_house where name = 'xx' and address = 'xx';
+------+-------------+---------+------+---------------------+---------------------+---------+-------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------------+---------------------+---------+-------+------+-----------------------+
| 1 | SIMPLE | t_house | ref | idx_name_build_addr | idx_name_build_addr | 75 | const | 1 | Using index condition |
+------+-------------+---------+------+---------------------+---------------------+---------+-------+------+-----------------------+
1 row in set (0.000 sec)
特别注意:这里仅用到了该索引里面的一个字段name,buid_time和address并没有走索引。
3.1.2、不能在索引上面左任何计算(函数)操作
反例
MariaDB [wang_test]> explain select * from t_house where left(name,3) = 'xx';
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | t_house | ALL | NULL | NULL | NULL | NULL | 1 | Using where |
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
1 row in set (0.000 sec)
因为经过函数之后的值,在索引B+树上面是不直接存在的,不方便继续走索引了。
3.1.3、尽量使用覆盖索引
大家应该听过过,阿里巴巴开发手册上面建议尽量不要用select *,其实大致其中一个原因就是建议尽量仅查询索引就有的字段,方便走覆盖索引。(减少回表开销)
3.1.4、不使用“不等于”操作,会导致无法走索引
前面提到,type为range时,代表用索引走范围查询,针对的是in、<、>等范围性的。
但是如果使用“不等于”操作,例如a != 1, 那么就算a加了索引,但是优化器分析下来,有大半可能
走全表扫描更划算(例如表中的数据有10w+)。因此在业务场景中尽量不用“不等于”操作(!=、<>、not in
等)
3.1.5、is null,is not null 一般情况下也无法使用索引
这个跟MySQL的索引结构有关,NULL值的索引一般会集中放在索引的最左端,或者最右端,有些时候,不走索引,直接走全表扫描可能会更快些。
3.1.6、like以通配符开头(‘$abc…’)mysql索引失效会变成全表扫描操作
这个很容易理解,前面如果不确定的话,肯定就没法扫描已经排序好的B+树了,跟前缀匹配一个道理。
反例
MariaDB [wang_test]> explain select * from t_house where name like '%x';
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | t_house | ALL | NULL | NULL | NULL | NULL | 1 | Using where |
+------+-------------+-----
如果非要优化这个场景的话,可以退而求其次,走覆盖索引。
实在没办法的话,只能用其他搜索引擎,例如es。
3.1.7、字符串不加单引号索引失效
类型都不同,跟上面提到的where中包含操作函数一样,会失效。
反例
MariaDB [wang_test]> explain select * from t_house where name = 9;
+------+-------------+---------+------+---------------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------------+------+---------+------+------+-------------+
| 1 | SIMPLE | t_house | ALL | idx_name_build_addr | NULL | NULL | NULL | 1 | Using where |
+------+-------------+---------+------+---------------------+------+---------+------+------+-------------+
1 row in set, 1 warning (0.000 sec)
3.1.8、少用or或in,用它查询时,mysql不一定使用索引,mysql内部优化器会根据检索比例、表大小等多个因素整体评估 是否使用索引,详见范围查询优化
3.2、复杂场景示例
这里主要收集了一些相对更深层次的优化方向、方案和特殊情况。-- 上述“3.1、一般场景”小节只是大多数的情况,这里主要是简述MySQL经过自身优化后的一些特殊情况补充。
同样,以下都使用如下表演示(其中有一个普通的复合索引idx_name_build_addr
,同时存储过程能生成10w条随机数据):
CREATE TABLE IF NOT EXISTS `t_house`(
`id` bigint(50) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(18) NULL COMMENT '房屋名称',
`buid_years` int(4) NULL COMMENT '已经修建年数',
`address` varchar(255) NULL COMMENT '房屋地址',
`last_modified_time` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp () COMMENT '数据库记录上一次更新时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_name_build_addr`(`name`, `buid_years`, `address`) USING BTREE COMMENT '普通索引'
) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '房屋表';
-- 以下是插入10万条随机数据的存储过程,下文示例有用
DELIMITER //
CREATE PROCEDURE InsertRandomHouseData()
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE random_name VARCHAR(18);
DECLARE random_years INT;
DECLARE random_address VARCHAR(255);
-- 插入10万条数据
WHILE i < 100000 DO
-- 生成随机房屋名称 (5-10个字符)
SET random_name = SUBSTRING(MD5(RAND()), 1, 5 + FLOOR(RAND() * 6));
-- 生成随机修建年数 (1-100年)
SET random_years = FLOOR(1 + RAND() * 100);
-- 生成随机地址 (10-30个字符)
SET random_address = SUBSTRING(MD5(RAND()), 1, 10 + FLOOR(RAND() * 21));
-- 插入数据
INSERT INTO t_house (name, buid_years, address)
VALUES (random_name, random_years, random_address);
SET i = i + 1;
END WHILE;
end //
DELIMITER ;
CALL InsertRandomHouseData();
3.2.1、二级索引中,第一个是范围查询有可能
不会走索引
不会走索引示例:
MariaDB [wang_test]> explain select * from t_house where name > '1' and buid_years = 8 and address = 'xx';
+------+-------------+---------+------+---------------------+------+---------+------+-------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------------+------+---------+------+-------+-------------+
| 1 | SIMPLE | t_house | ALL | idx_name_build_addr | NULL | NULL | NULL | 99769 | Using where |
+------+-------------+---------+------+---------------------+------+---------+------+-------+-------------+
1 row in set (0.000 sec)
走索引示例:
MariaDB [wang_test]> explain select * from t_house where name > 'fffda66d' and buid_years = 8 and address = 'xx';
+------+-------------+---------+-------+---------------------+---------------------+---------+------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+-------+---------------------+---------------------+---------+------+------+-----------------------+
| 1 | SIMPLE | t_house | range | idx_name_build_addr | idx_name_build_addr | 75 | NULL | 2 | Using index condition |
+------+-------------+---------+-------+---------------------+---------------------+---------+------+------+-----------------------+
1 row in set (0.000 sec)
结果分析
1> 不走索引的示例中,实际的rows(扫描行数)是9w,说明name>"1"的数据有9w多行。确实分析下来,走全表扫描比走9w次索引更快。
MariaDB [wang_test]> select count(*) from t_house where name > '1';
+----------+
| count(*) |
+----------+
| 93754 |
+----------+
1 row in set (0.029 sec)
2> 走索引的示例中,可以反推出来,说明name>"fffda66d"的结果集不会太大,走几次索引就很快能拿到结果集,自然走索引就更快。实际上,name>"fffda66d"的结果集是2条, rows(扫描行数)也是2。
MariaDB [wang_test]> select * from t_house where name > 'fffda66d';
+-------+------------+------------+---------------------+---------------------+
| id | name | buid_years | address | last_modified_time |
+-------+------------+------------+---------------------+---------------------+
| 98034 | ffff03fcc0 | 98 | ef984a6c0b0 | 2025-08-30 23:40:05 |
| 71079 | ffff13f31 | 12 | fb00759ab739ada9851 | 2025-08-30 23:38:53 |
+-------+------------+------------+---------------------+---------------------+
2 rows in set (0.000 sec)
3> 综上所述,其实MySQL提供强制走索引的关键字force index
的功能,但是我们尽量也不用去用它,因为其优化器做的执行计划比我们想象的更好。
3.2.2、索引下推
走索引示例:
MariaDB [wang_test]> explain select * from t_house where name like 'e%' and buid_years = 8 and address = 'xx';
+------+-------------+---------+-------+---------------------+---------------------+---------+------+-------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+-------+---------------------+---------------------+---------+------+-------+-----------------------+
| 1 | SIMPLE | t_house | range | idx_name_build_addr | idx_name_build_addr | 1103 | NULL | 12066 | Using index condition |
+------+-------------+---------+-------+---------------------+---------------------+---------+------+-------+-----------------------+
1 row in set (0.000 sec)
不走索引示例:
MariaDB [wang_test]> explain select * from t_house where name > 'e' and buid_years = 8 and address = 'xx';
+------+-------------+---------+------+---------------------+------+---------+------+-------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------------+------+---------+------+-------+-------------+
| 1 | SIMPLE | t_house | ALL | idx_name_build_addr | NULL | NULL | NULL | 99769 | Using where |
+------+-------------+---------+------+---------------------+------+---------+------+-------+-------------+
1 row in set (0.000 sec)
结果分析
1> 看似上面2个例子同样都是范围,实则不然。 第一个走索引的示例like 'xxx%'实际上用到了索引下推
(MySQL 5.6+版本引入的优化)。
2> MySQL5.6之前的版本,会先用like 'xxx%'在二级索引中过滤出结果集id列表,然后回表全部记录load出来,再继续过滤buid_years = 8 and address = ‘xx’;
3> 索引下推则是直接在用like 'xxx%'在二级索引中过滤出结果集id列表的时候,同时判断buid_years = 8 and address = ‘xx’ 是否符合,如果符合才把id加入结果集,最后回表通过结果集id列表查询出所有数据,并返回。
4> 在2>和3>的比对之下,3>完全利用了buid_years、address索引字段且回表的结果集id列表更少
,效率更高。
5> 那为什么>就不能像like一样使用索引下推? 分析下来,有可能是>的结果集比like相比之下更大,同时做buid_years = 8 and address = 'xx’的过滤时,次数也就越多,开销就越大,不划算了。
3.2.3、MySQL如何选择走合适的索引
上面3.2.1 3.2.1都到MySQL有一套自己的执行计划策略,那么我们如何进一步知道呢?
例如同样是>条件,但是有的会走索引,有的不会走索引。
走索引示例:
MariaDB [wang_test]> explain select * from t_house where name > 'fffda66d';
+------+-------------+---------+-------+---------------------+---------------------+---------+------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+-------+---------------------+---------------------+---------+------+------+-----------------------+
| 1 | SIMPLE | t_house | range | idx_name_build_addr | idx_name_build_addr | 75 | NULL | 2 | Using index condition |
+------+-------------+---------+-------+---------------------+---------------------+---------+------+------+-----------------------+
1 row in set (0.000 sec)
不走索引示例
MariaDB [wang_test]> explain select * from t_house where name > 'a';
+------+-------------+---------+------+---------------------+------+---------+------+-------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------------+------+---------+------+-------+-------------+
| 1 | SIMPLE | t_house | ALL | idx_name_build_addr | NULL | NULL | NULL | 99769 | Using where |
+------+-------------+---------+------+---------------------+------+---------+------+-------+-------------+
1 row in set (0.000 sec)
结果分析
1> MySQL有一套自己的cost成本计算结果,可以使用MySQL提供的trace
工具查看。
MariaDB [wang_test]> SET SESSION optimizer_trace='enabled=on'; -- 开启trace
Query OK, 0 rows affected (0.000 sec)
MariaDB [wang_test]> select * from t_house where name > 'a'; -- 执行要使用trace查看的sql
MariaDB [wang_test]> SELECT * FROM information_schema.OPTIMIZER_TRACE; -- 查看trace结果,结果内容仅太多,这里仅列关键内容
steps.join_preparation -- 第一阶段格式化sql,示例:select t_house.`id` AS `id`,t_house.`name` AS `name`,t_house.buid_years AS buid_years,t_house.address AS address,t_house.last_modified_time AS last_modified_time from t_house where t_house.`name` > 'a'
steps.join_optimization -- 第二阶段sql优化,
steps.join_optimization.condition_processing -- 条件处理:1> 去掉1=1这种无效条件 2> 分析能不能改变and顺序就可以用上联合索引 等等 通过这里就可以看出来,优化等操作尽量不要交给mysql自己来做
steps.join_optimization.rows_estimation.range_analysis.table_scan -- 预估表的访问成本(注意:这是不走索引的全表扫描成本)
steps.join_optimization.rows_estimation.range_analysis.table_scan.rows -- 扫描的行数
steps.join_optimization.rows_estimation.range_analysis.table_scan.cost -- 查询成本(依据表记录行数、回表情况等)
steps.join_optimization.rows_estimation.range_analysis.potential_range_indexes -- 查询可能使用到的索引
steps.join_optimization.rows_estimation.range_analysis.potential_range_indexes -- 查询可能使用到的索引
steps.join_optimization.rows_estimation.range_analysis.potential_range_indexes.usable -- 最终有没有用到该索引
steps.join_optimization.rows_estimation.range_analysis.potential_range_indexes.cause -- 没用上的原因
steps.join_optimization.rows_estimation.range_analysis.potential_range_indexes.key_parts -- 用到的索引字段部分
steps.join_optimization.rows_estimation.analyzing_range_alternatives.range_scan_alternatives -- 分析每个索引的成本
steps.join_optimization.rows_estimation.analyzing_range_alternatives.range_scan_alternatives.index -- 索引名称
steps.join_optimization.rows_estimation.analyzing_range_alternatives.range_scan_alternatives.rows -- 走该索引的扫描行数
steps.join_optimization.rows_estimation.analyzing_range_alternatives.range_scan_alternatives.cost -- 走该索引的查询开销
steps.join_optimization.rows_estimation.analyzing_range_alternatives.range_scan_alternatives.chosen -- 最终有没有选择这个索引
steps.join_optimization.rows_estimation.analyzing_range_alternatives.range_scan_alternatives.cause -- 最终没有选择这个索引的原因
steps.join_optimization.steps.considered_execution_plans.get_costs_for_tables.best_access_path -- 最优选择路径结果
MariaDB [wang_test]> SET SESSION optimizer_trace='enabled=off'; -- 最后关闭trace,因为打开会耗性能
Query OK, 0 rows affected (0.000 sec)
2> 结合trace工具的结果就可以看出来为何全表扫描更优:
3> 特别说明,cost 与 rows的值只是一个相对的预估值,非真实的物理行数。
3.2.4、order by场景下特殊示例
以下内容以如下索引为依据,进行展开
INDEX idx_name_build_addr
(name
, buid_years
, address
) USING BTREE COMMENT ‘普通索引’
1> order by 下的复合索引字段排序规则不一样时不会走索引
MariaDB [wang_test]> explain select * from t_house where name = 'fffbadc453' order by buid_years desc, address asc;
+------+-------------+---------+------+---------------------+---------------------+---------+-------+------+----------------------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------------+---------------------+---------+-------+------+----------------------------------------------------+
| 1 | SIMPLE | t_house | ref | idx_name_build_addr | idx_name_build_addr | 75 | const | 1 | Using index condition; Using where; Using filesort |
+------+-------------+---------+------+---------------------+---------------------+---------+-------+------+----------------------------------------------------+
1 row in set (0.000 sec)
特别说明:MySQL8.0支持这种倒序索引
2> in 不会走索引
MariaDB [wang_test]> explain select * from t_house where name in ('fffbadc453', 'xx') order by buid_years, address;
+------+-------------+---------+-------+---------------------+---------------------+---------+------+------+---------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+-------+---------------------+---------------------+---------+------+------+---------------------------------------+
| 1 | SIMPLE | t_house | range | idx_name_build_addr | idx_name_build_addr | 75 | NULL | 2 | Using index condition; Using filesort |
+------+-------------+---------+-------+---------------------+---------------------+---------+------+------+---------------------------------------+
1 row in set (0.000 sec)
3.2.5、group by说明
1> group by操作本质上是先order by,然后再收集,因此也和order by类型,遵循索引最左前缀法。
2> where 的优先级比 having高,因此尽量条件都放到where中,提前过滤掉。
3.2.6、Using filesort浅析
本质上是不借助已经排好序的索引,而是自己单独开辟一块内存来自己实时的现排
。是不是感觉听起来就很耗时。
根据需要排序数据量的大小,内部提供了2个方式:
1> 单路排序:数据量小的情况下,一次性取出符合条件的聚簇索引
所有字段,load到内存,一次性排序完。
2> 双路排序(又叫回表
排序):数据量大的情况下,在有限的内存下面,仅现取出需要参与排序的字段
和能回表的id
到内存中进行排序。先得到的结果是排好序之后的id集合,最后还需要一步回表,才能拿到所有排好序的记录并返回。(trace工具中能看到sort_mode: <sortkey, rowid>相关内容,证实)
3> 数据量大小的判断依据:MySQL有一个阈值变量
,默认是1MB
,如果参与排序的所有字段加起来超过这个阈值,就会使用双路排序。
4> 特殊说明:如果参与排序的字段实在太大了sort_buffer(默认1MB)
放不下,会使用磁盘上面创建临时文件排序!
3.2.7、索引设计原则小总结
1> 代码先行,索引后上
因为一开始并不知道一共会建多少个索引,索引建立得越多,MySQL维护起来就越吃力。因此在业务代码起来,大概确定之后,再按照实际出发,设计索引,这样才能尽量的确保每个索引都用在刀刃
上。
2> 联合索引尽量覆盖更多的业务查询条件
原因同第一点一样,索引建立得越少越好。具体实施的话可以参考上文的最左前缀原则
,索引覆盖原则
, 排序原则
、范围查询优化
、结合业务分析,大多数场景优先
、范围查询索引字段靠最后
、考虑业务sql能不能换种写法来满足本次修改后的新覆盖索引
、考虑业务当前的存储形式是否能换,例如日期范围查找,能不能换成是否7天内、14天内、14天外
3> 阿里规约之不要想着通过代码维护唯一性,创建表的唯一索引更可靠。
4> 不要在基数小的字段上面建立索引
例如一个基数小的字段的值,只有2到3个,例如性别。实际上不如全表扫描来得快,有点浪费索引个数了。
5> 长字符串字段不要拿来做索引字段
1) 长字段例如varchar(255),太占空间,且检索比对吃力。
2)如果非要对长字段建立索引,可以考虑优化成前缀20个字符来做前缀索引。(理论上,前20个已经足够来区分大多数的不同了) – 有个弊端,不能在索引上使用order by 与 group by了
3)生产级长字符串解决方案,建议使用专业的文本检索中间件,例如es,不要再依赖MySQL了。
6> 复合索引设计中where 与 order by冲突时,优先选择满足where
因为可以先通过where走索引将要排序的字段筛选出来,范围缩小之后,再针对这个小集合全量排序带来的利弊权衡更好些。
7> 慢sql查询优化技巧
MySQL可以开启慢SQL日志,并指定慢sql的输出日志文件,例如mysql-slow.log。 拿到慢sql之后再针对性的分析,进行优化。(因为第一点,所以很多情况下,慢sql都是后发现的)
另外的话,还有一个参数可以设置多少时间算超时,例如可以设置超过10s的才记录到慢sql日志中。
3.2.8、分页查询优化
如下这条sql是想取从10000行开始的10条记录,但是实际的执行计划是读取了10010条记录,然后把前10000条数据丢弃了。 – 查询性能很差
MariaDB [wang_test]> select * from t_house limit 10000,10;
+-------+------------+------------+------------------------------+---------------------+
| id | name | buid_years | address | last_modified_time |
+-------+------------+------------+------------------------------+---------------------+
| 10001 | 7c050bd | 81 | 185ce507250a952695 | 2025-08-30 23:36:17 |
| 10002 | d93bcca3fc | 13 | f98e6c5b4c7ec89270 | 2025-08-30 23:36:17 |
| 10003 | 9499fcbf | 50 | 9a1b51c41e419339c3021f403564 | 2025-08-30 23:36:17 |
| 10004 | 19cc4d80 | 81 | aedc0d5a8a2 | 2025-08-30 23:36:17 |
| 10005 | bdb8720 | 39 | 696bf00f36fdfa3 | 2025-08-30 23:36:17 |
| 10006 | b15fe815d1 | 24 | efc8a17e215e99c7a2021690 | 2025-08-30 23:36:17 |
| 10007 | dca289c06 | 88 | 4b7914e2912 | 2025-08-30 23:36:17 |
| 10008 | a43bea7c | 33 | 4524a01e5d85c0e7d6 | 2025-08-30 23:36:17 |
| 10009 | 2dc9cf0 | 25 | 64575b8215b7e6ecf889 | 2025-08-30 23:36:17 |
| 10010 | 285ef | 14 | bf81a39732c1b60d414cc1d9171 | 2025-08-30 23:36:17 |
+-------+------------+------------+------------------------------+---------------------+
10 rows in set (0.003 sec)
3.2.8.1、根据连续自增的主键优化
前提
:表的主键ID字段是自增且连续的。
MariaDB [wang_test]> select * from t_house where id > 10000 limit 10;
+-------+------------+------------+------------------------------+---------------------+
| id | name | buid_years | address | last_modified_time |
+-------+------------+------------+------------------------------+---------------------+
| 10001 | 7c050bd | 81 | 185ce507250a952695 | 2025-08-30 23:36:17 |
| 10002 | d93bcca3fc | 13 | f98e6c5b4c7ec89270 | 2025-08-30 23:36:17 |
| 10003 | 9499fcbf | 50 | 9a1b51c41e419339c3021f403564 | 2025-08-30 23:36:17 |
| 10004 | 19cc4d80 | 81 | aedc0d5a8a2 | 2025-08-30 23:36:17 |
| 10005 | bdb8720 | 39 | 696bf00f36fdfa3 | 2025-08-30 23:36:17 |
| 10006 | b15fe815d1 | 24 | efc8a17e215e99c7a2021690 | 2025-08-30 23:36:17 |
| 10007 | dca289c06 | 88 | 4b7914e2912 | 2025-08-30 23:36:17 |
| 10008 | a43bea7c | 33 | 4524a01e5d85c0e7d6 | 2025-08-30 23:36:17 |
| 10009 | 2dc9cf0 | 25 | 64575b8215b7e6ecf889 | 2025-08-30 23:36:17 |
| 10010 | 285ef | 14 | bf81a39732c1b60d414cc1d9171 | 2025-08-30 23:36:17 |
+-------+------------+------------+------------------------------+---------------------+
10 rows in set (0.000 sec)
这个前提很难满足,大多数场景下用不上这个方法优化。
3.2.8.2、覆盖索引优化
前提
: 使用索引(本质上是缩小回表范围),本例以已经建立了如下索引为例:
INDEX idx_name_build_addr
(name
, buid_years
, address
) USING BTREE COMMENT ‘普通索引’
MariaDB [wang_test]> select * from t_house a inner join (select id from t_house order by name limit 10000,10) b on a.id = b.id;
+-------+------------+------------+----------------------------+---------------------+-------+
| id | name | buid_years | address | last_modified_time | id |
+-------+------------+------------+----------------------------+---------------------+-------+
| 60657 | 198933d | 13 | 6ce8217aa9ff1e52 | 2025-08-30 23:38:25 | 60657 |
| 33789 | 198a3d | 19 | c65a8c6e6ac703c56739 | 2025-08-30 23:37:16 | 33789 |
| 92954 | 198c72b | 72 | eaf991a00a48400df2d5645bfc | 2025-08-30 23:39:51 | 92954 |
| 76338 | 198e319fed | 83 | c655b1428e558 | 2025-08-30 23:39:07 | 76338 |
| 59643 | 198e8 | 23 | da233446f1f70065d17a9 | 2025-08-30 23:38:22 | 59643 |
| 91225 | 198f6792e | 67 | 56781dd498505e1 | 2025-08-30 23:39:47 | 91225 |
| 62522 | 198fe | 74 | c14394cddf8da01 | 2025-08-30 23:38:30 | 62522 |
| 62266 | 19906be9 | 6 | a2bfc146a962c0 | 2025-08-30 23:38:30 | 62266 |
| 45994 | 1990d0 | 76 | 510ecd06ff9a223f88852cb4 | 2025-08-30 23:37:47 | 45994 |
| 95719 | 1990d69ecf | 16 | bc7bcaa459 | 2025-08-30 23:39:58 | 95719 |
+-------+------------+------------+----------------------------+---------------------+-------+
10 rows in set (0.003 sec)
这样确实是最优的解法。(tips:如果是分库分表场景,还是有文本中间件比较好,比如es)
3.2.9、join关联优化
数据准备(一个用户users
可以创建多个标签tags
, 用户数据100条,标签数据10w条,且都有一个用于2表连接的普通索引)
-- 创建用户表 users (小表,100条记录)
CREATE TABLE users (
user_id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(8) NOT NULL,
registration_date DATE,
INDEX idx_username (username)
) ENGINE=InnoDB;
-- 创建标签表 tags (大表,10万条记录)
CREATE TABLE tags (
tag_id INT PRIMARY KEY AUTO_INCREMENT,
tag_name VARCHAR(50) NOT NULL,
category VARCHAR(30),
created_by VARCHAR(8) NOT NULL,
INDEX idx_created_by (created_by)
) ENGINE=InnoDB;
-- 生成 users 表的100条随机记录
DELIMITER //
CREATE PROCEDURE GenerateUsersData()
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE random_username VARCHAR(50);
WHILE i < 100 DO
SET random_username = CONCAT('user_', LPAD(i+1, 3, '0'));
INSERT INTO users (username, registration_date)
VALUES (random_username, DATE_ADD('2020-01-01', INTERVAL FLOOR(RAND() * 1000) DAY));
SET i = i + 1;
END WHILE;
end //
DELIMITER ;
-- 生成 tags 表的10万条随机记录
DELIMITER //
CREATE PROCEDURE GenerateTagsData()
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE random_tag_name VARCHAR(50);
DECLARE random_category VARCHAR(30);
DECLARE random_creator VARCHAR(50);
WHILE i < 100000 DO
SET random_tag_name = CONCAT('tag_', LPAD(i+1, 6, '0'));
-- 随机选择分类
CASE FLOOR(RAND() * 5)
WHEN 0 THEN SET random_category = '技术';
WHEN 1 THEN SET random_category = '生活';
WHEN 2 THEN SET random_category = '娱乐';
WHEN 3 THEN SET random_category = '学习';
ELSE SET random_category = '其他';
END CASE;
-- 随机选择创建者(从100个用户中选择)
SET random_creator = CONCAT('user_', LPAD(FLOOR(1 + RAND() * 100), 3, '0'));
INSERT INTO tags (tag_name, category, created_by)
VALUES (random_tag_name, random_category, random_creator);
SET i = i + 1;
END WHILE;
end //
DELIMITER ;
CALL GenerateUsersData(); -- 生成100条用户记录
CALL GenerateTagsData(); -- 生成10万条标签记录
3.2.9.1、嵌套循环连接 Nested-Loop Join(NLJ) 算法
前提
: 被驱动表的连接字段建立了索引
MariaDB [wang_test]> explain select * from users u inner join tags t on u.username = created_by;
+------+-------------+-------+------+----------------+----------------+---------+----------------------+-------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-------+------+----------------+----------------+---------+----------------------+-------+-------+
| 1 | SIMPLE | u | ALL | idx_username | NULL | NULL | NULL | 100 | |
| 1 | SIMPLE | t | ref | idx_created_by | idx_created_by | 34 | wang_test.u.username | 26759 | |
+------+-------------+-------+------+----------------+----------------+---------+----------------------+-------+-------+
2 rows in set (0.000 sec)
从这里就可以看出来,是先全表扫描的小表users,然后去和大表tags进行使用索引联合查询,典型的大表驱动小表
MySQL会优化成这样,跟join的前后顺序无关。
扫描行数分析: 小表走聚簇索引,也就是扫描全表的磁盘地址,就是100行。 大表走的是索引,能根据小表的一行直接定位到磁盘地址,因此扫描行数总共也是100行。 那么扫描的总行数就是200行
。
反之,如果是大表驱动小表,那么就是100000 + 100 = 100100行扫描!
这种连接的方式叫嵌套循环连接(NLJ)算法
3.2.9.2、基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法
前提
: 被驱动表的连接字段没有建立索引
因此该例,就需要把上面的索引去掉
MariaDB [wang_test]> ALTER TABLE users DROP INDEX idx_username; -- 去掉 users 表的 idx_username 索引
Query OK, 0 rows affected (0.013 sec)
Records: 0 Duplicates: 0 Warnings: 0
MariaDB [wang_test]> ALTER TABLE tags DROP INDEX idx_created_by; -- 去掉 tags 表的 idx_created_by 索引
Query OK, 0 rows affected (0.011 sec)
Records: 0 Duplicates: 0 Warnings: 0
这是的执行计划就是这样:
MariaDB [wang_test]> explain select * from users u inner join tags t on u.username = created_by;
+------+-------------+-------+------+---------------+------+---------+------+------+-------------------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-------+------+---------------+------+---------+------+------+-------------------------------------------------+
| 1 | SIMPLE | t | ALL | NULL | NULL | NULL | NULL | 1 | |
| 1 | SIMPLE | u | ALL | NULL | NULL | NULL | NULL | 2 | Using where; Using join buffer (flat, BNL join) |
+------+-------------+-------+------+---------------+------+---------+------+------+-------------------------------------------------+
2 rows in set (0.001 sec)
执行流程:
1)先把tags表的数据放到join_buffer
(默认值256KB)中
2)逐一从users表中取出行和join_buufer
做比对
3)返回结果内容
特别说明
1> join_buffer的存储内容是无序的,比较次数也多。
2> join_buffer的内存是有限的,如果一次性放不完,会分批次放。这就导致批次越多,users表被全表扫描层重复次数就越多。
3> 实际分析下来,扫描总行数假设join_buffer能一次性放完的情况下,是100000 + 100 = 100100扫描行数。同时比对次数也是100000 * 100 = 1000w次!
4> 这样做有什么好处呢?
如果不走join_buffer,那么磁盘扫描行数将是100000 * 100 = 1000w次!join_buffer的最大作用就是减少比较时的磁盘扫描行数!
3.2.9.3、总结
综上所述,连接的优化技巧就是尽量加索引,走NLJ方式 和 尽量小表驱动大表(有关键字强制指定驱动表,但是不需要这么干,MySQL的优化比想象中的更好)。
小表定义:是指过滤后,结果集更小的表,而不是表总行数
3.2.10、in和exists优化
核心思想:小表驱动大表。
原因(重复说明):
1> 被驱动表没加索引的情况下,谁驱动谁其实区别不大。
2> 如果被驱动表加了索引,就能保证被驱动表走索引先知道磁盘地址,最终被驱动表读取一次磁盘就可以拿到数据。这样的话,仅需要驱动表的行数越少,磁盘的扫描次数就会越少,因此强调行数少的表作为驱动表。
3> 这里有一个核心支撑点:innoDB的Buffer pool 是将非叶子节点基本缓存在内存的,因此索引的读取基本在内存,几乎不算磁盘扫描开销。
3.2.10.1、in
select * from A where id in (select id from B)
#等价于:
for(select id from B) {
select * from A where A.id = B.id
}
每次select * from A where A.id = B.id 是需要读取磁盘的,因此需要保证(select id from B)是最小的。
3.2.10.2、exists
select * from A where exists (select 1 from B where B.id = A.id)
#等价于:
for(select * from A){
select * from B where B.id = A.id
}
每次select * from B where B.id = A.id是需要读取磁盘的,因此需要保证select * from A是最小的。
3.2.11、count
一般count有如下几种写法
一下执行计划是在有索引的基础上:INDEX idx_name_build_addr
(name
, buid_years
, address
) USING BTREE COMMENT ‘普通索引’
MariaDB [wang_test]> explain select count(1) from t_house;
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
| 1 | SIMPLE | t_house | index | NULL | idx_name_build_addr | 1103 | NULL | 99769 | Using index |
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
1 row in set (0.000 sec)
MariaDB [wang_test]> explain select count(id) from t_house;
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
| 1 | SIMPLE | t_house | index | NULL | idx_name_build_addr | 1103 | NULL | 99769 | Using index |
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
1 row in set (0.000 sec)
MariaDB [wang_test]> explain select count(name) from t_house;
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
| 1 | SIMPLE | t_house | index | NULL | idx_name_build_addr | 1103 | NULL | 99769 | Using index |
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
1 row in set (0.000 sec)
MariaDB [wang_test]> explain select count(*) from t_house;
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
| 1 | SIMPLE | t_house | index | NULL | idx_name_build_addr | 1103 | NULL | 99769 | Using index |
+------+-------------+---------+-------+---------------+---------------------+---------+------+-------+-------------+
1 row in set (0.000 sec)
MariaDB [wang_test]> explain select count(last_modified_time) from t_house;
+------+-------------+---------+------+---------------+------+---------+------+-------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+---------+------+---------------+------+---------+------+-------+-------+
| 1 | SIMPLE | t_house | ALL | NULL | NULL | NULL | NULL | 99769 | |
+------+-------------+---------+------+---------------+------+---------+------+-------+-------+
1 row in set (0.000 sec)
结论:
1> 除了不走索引的统计效率最低,其余的都走的覆盖索引,效率差不多。
2> 进一步仔细分析:
1) 有索引的情况下count(name) > count(主键) 因为主键索引存储的字段比二级索引存储的字段多。-- 其实上面执行计划也看出来了, 即使count(id),也是走的二级覆盖索引。
2) 没索引的情况下count(name) < count(主键), 这个毫无疑问,没索引的话,就要磁盘开销了。
3) count(1), 是代表直接用字段1作为依据,比上面的都快,因为count(name)和count(id)还需要取出对应字段的值。
4) count(*) ,MySQL做过优化,比1更直接,直接按行累加,连1都没有了。
5) 因此 count(*) ≈ count(1) > count(name) > count(id) 有索引的情况下
6) count(name)不会统计值为NULL的行
。
3> 在数据量大的情况下,count还是会很慢
1) 如果是MyISAM存储引擎,因为其锁的最小粒度是表锁,因此维护了表的总记录数的元数据,能直接count(*)拿到,很快。但是innoDB就不行了,因为支持更小粒度的行数与MVCC。
2)InnoDB存储引擎只能拿到一个表总行数的近似值这个元数据,如果需要精确的总行数的话,只能借助第三方了,例如redis计数(如果需要100%一致的话,就不推荐redis了,一致性代价高)。如果需要100%准确的话,只有单独开个MySQL表来记录次数,且事务和业务表处于同一个事务中。当然,这里指的是无条件的count
4、表字段类型选择注意事项
1、浮点数,尽量选择适用Decimal,而不是float、double。因为后面2个有可能丢失精度。
2、日期类型:datetime和date的上限是9999年,但是timestamp的上限是2038年。
3、字符类型char:固定长度,不足时会补空格占位,但是select会自动去掉空格。
4、文本类型:大文本类型,例如text建议单独拆子表存,因为太大会影响聚簇索引性能。
更多推荐
所有评论(0)