Explain 和查询优化
Explain 分析
Explain
关键字可以模拟优化器执行 SQL 语句,分析查询语句或是结构的性能瓶颈。
在 select
语句之前增加 explain
关键字,MySQL 会在查询上设置一个标记,执行查询会返回执行计划的信息,而不是执行这条 SQL。
注意:如果 from
中包含子查询,仍会执行该子查询,将结果放入临时表中。
创建示例表:
DROP TABLE IF EXISTS `actor`;
CREATE TABLE `actor` (
`id` int(11) NOT NULL,
`name` varchar(45) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `actor` (`id`, `name`, `update_time`) VALUES (1,'a','2017-12-22 15:27:18'), (2,'b','2017-12-22 15:27:18'), (3,'c','2017-12-22 15:27:18');
DROP TABLE IF EXISTS `film`;
CREATE TABLE `film` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(10) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `film` (`id`, `name`) VALUES (3,'film0'),(1,'film1'),(2,'film2');
DROP TABLE IF EXISTS `film_actor`;
CREATE TABLE `film_actor` (
`id` int(11) NOT NULL,
`film_id` int(11) NOT NULL,
`actor_id` int(11) NOT NULL,
`remark` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_film_actor_id` (`film_id`,`actor_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `film_actor` (`id`, `film_id`, `actor_id`) VALUES (1,1,1),(2,1,2),(3,2,1);
Explain 列
id
select
查询的序列号,有几个 select
就有几个 id
,并且 id
的顺序是按 select
出现的顺序增长的。id
列越大执行优先级越高,id
相同则从上往下执行,id
为 NULL
最后执行。
select_type
表示对应行是简单还是复杂的查询。
simple
:简单的 select 查询,查询中不包含子查询或者UNION
。
explain select * from film where id = 2;
primary
:查询中若包含任何复杂的子部分,最外层查询则被标记为primary
。subquery
:包含在select
中的子查询(不在from
子句中)derived
:包含在from
子句中的子查询。MySQL会将结果存放在一个临时表中,也称为派生表。
-- 关闭衍生表的合并优化
set session optimizer_switch='derived_merge=off';
explain select (select 1 from actor where id = 1) from (select * from film where id = 1) der;
-- 还原默认配置
set session optimizer_switch='derived_merge=on';
可以看出来:
(select 1 from actor where id = 1)
就是id
为 2, 类型为subquery
的select
。(select * from film where id = 1)
就是id
为 3,类型为derived
的select
。将查询语句的结果集放到一个临时表中。- 最外层的
select
就是id
为 1,类型为primary
。这个语句的table
的值为<dirived3>
,表示这个查询是从衍生表中查询的。<dirived3>
中的3
表示id
为 3 的select
。
union
:在union
中的第二个和随后的select
type
这一列表示关联类型或访问类型,即 MySQL 决定如何查找表中的行,查找数据行记录的大概范围。依次从最优到最差分别为:system
> const
> eq_ref
> ref
> range
> index
> ALL
。一般来说,要保证查询达到 range 级别,最好达到 ref。
NULL
:MySQL 优化器优化查询语句时,判断不需要再扫描表或着索引树。例如:在索引列中选取最小值,select min(id) from film;
,直接从索引树中获取最小值,不需要扫描表。system
,const
:主键索引或唯一二级索引的等值查询。MySQL 能对查询的某部分进行优化并将其转化成一个常量,就是说像查询常量一样快。因为表最多有一个匹配行,读取 1 次,所以速度很快。system
是const
类型的特列,表示要查询的表或者结果集中只有一条数据。
EXPLAIN SELECT * FROM (SELECT * FROM film WHERE id = 1) tmp;
SHOW WARNINGS;
SHOW WARNINGS
的 message 可以看到表示查询被优化成了 select 1 AS 'id','film1' AS 'name' from dual
。
eq_ref
:主键索引或唯一二级索引的所有列都被被连接使用,最多只会返回一条符合条件的记录。这可能是在const
之外最好的联接类型了,简单的select
查询不会出现这种类型。
EXPLAIN SELECT * FROM film_actor LEFT JOIN film ON film_actor.film_id = film.id;
可以看到,两个 select
的 id
都是 1,说明关联的两张表没有明确的前后顺序,会一起去查询,不过真正执行的时候,会先执行上面的 select
。
film
表对应的 type 是eq_ref
,因为关联的条件使用的是film
表的主键id
,所以会使用主键索引来查询数据。使用主键来查询,只会返回一条记录,速度还是很快的。
ref
:相比eq_ref
,不使用唯一索引,而是使用二级索引或者唯一性索引的部分前缀,索引要和某个值相比较,可能会找到多个符合条件的行。
-- name 是普通索引
EXPLAIN SELECT * FROM film WHERE name = 'film1';
range
:利用索引进行范围匹配。范围扫描通常出现在in(), between ,> ,<, >=
等操作中。
EXPLAIN SELECT * FROM actor WHERE id > 1;
index
:全索引扫描,一般是扫描某个二级索引,直接对二级索引的叶子节点遍历和扫描,速度还是比较慢的,这种查询一般为使用覆盖索引,二级索引一般比较小,所以这种通常比ALL
快一些。
EXPLAIN SELECT * FROM film;
这里扫描的是二级索引 idx_name
,没有去扫描聚簇索引。MySQL 在优化时,如果查询的字段在二级索引中全部都有,会优先使用二级索引,而不会去扫描聚簇索引。因为一般情况下,聚簇索引的叶子节点存储的是完整的行数据,所要扫描的数据量会比较大,而二级索引的叶子节点存储的是主键值,所以扫描的行数会比较少。
ALL
:Full Table Scan,即全表扫描,直接扫描聚簇索引的所有叶子节点。通常情况下这需要增加索引来进行优化了。
EXPLAIN SELECT * FROM actor;
possible_keys
查询时,可能使用的索引。如果 possible_keys
有列,而 key
为 NULL
,这种情况是因为表中数据不多,MySQL 认为索引对此查询帮助不大,选择了全表查询。
如果 possible_keys
为 NULL
,则没有相关的索引。在这种情况下,可以考虑创造一个适当的索引来提高查询性能。
key
实际使用的索引。如果为 NULL
,则没有使用索引。如果想强制 MySQL 使用或忽视 possible_keys
列中的索引,可以在查询中使用 force index
、ignore index
。
key_len
显示 MySQL 在索引里使用的字节数,通过这个值可以算出具体使用了索引中的哪些列。例如,film_actor
的联合索引 idx_film_actor_id
由 film_id
和 actor_id
两个 int
列组成,并且每个 int
是 4
字节。通过结果中的 key_len=4
可推断出查询使用了第一个列:film_id
列来执行索引查找。
key_len
计算规则如下:
- 字符串,
char(n)
和varchar(n)
,n
均代表字符数,而不是字节数,如果是utf-8
,一个数字或字母占1
个字节,一个汉字占3
个字节char(n)
:如果存汉字长度就是3n
字节varchar(n)
:如果存汉字则长度是3n + 2
字节,加的2
字节用来存储字符串长度,因为varchar
是变长字符串 - 数值类型
- tinyint:1 字节
- smallint:2 字节
- int:4 字节
- bigint:8 字节
- 时间类型
- date:3 字节
- timestamp:4 字节
- datetime:8 字节
- 如果字段允许为 NULL,需要 1 字节记录是否为 NULL
- 索引最大长度是 768 字节,当字符串过长时,MySQL 会做一个类似左前缀索引的处理,将前半部分的字符提取出来做索引
ref
显示索引的哪些列或常量被使用了。
rows
表示 MySQL 估计要读取并检测的行数,注意这个不是结果集里的行数。
filtered
该列是一个百分比的值,rows*filtered/100
可以估算出将要和 explain 中前一个表进行连接的行数。
extra
展示的是额外信息:
- Using index:使用覆盖索引,避免访问了表的数据行,效率不错。
- Using where:表示使用了
where
过滤,并且查询的列未被索引覆盖。 - Usering index condition:表示使用了索引下推优化。
- Using temporary:要创建一张临时表来处理查询。出现这种情况一般是要进行优化的,首先是想到用索引来优化。例如
EXPLAIN SELECT DISTINCT name FROM actor;
actor.name
没有索引,此时创建了张临时表来distinct
。可以为name
列创建索引,然后再去重,MySQL 在扫描索引树的过程中就可以直接去重。因为索引是有序的,相同的记录是在一起的,相同的记录直接扔掉就可以了。 - Using filesort:将用外部排序而不是索引排序,数据较小时在内存排序,否则需要在磁盘完成排序。这种情况下一般也是要考虑使用索引来优化。索引本身就是排好序的。
- Using join buffer:使用连接缓存。
- Select tables optimized away:使用某些聚合函数(比如
max
、min
)来访问存在索引的某个字段。
table
表示 explain
的一行正在访问哪个表。
partitions
如果查询是基于分区表的话,partitions 字段会显示查询将访问的分区。
Using filesort 文件排序原理详解
文件排序方式:
- 单路排序:是一次性取出(聚簇索引)满足条件行的所有字段,然后在 sort buffer 中进行排序;trace 工具可以看到
sort_mode
信息里显示<sort_key, additional_fields>
或者<sort_key,packed_additional_fields>
,sort_key
就表示排序的key
,additional_fields
表示表中的其他字段。 - 双路排序(又叫回表排序模式):是首先根据相应的条件取出(聚簇索引)相应的排序字段和可以直接定位行数据的主键 ID,然后在 sort buffer 中进行排序,排序完后需要回表去取回其它需要的字段;trace 工具可以看到
sort_mode
信息里显示<sort_key, rowid>
,sort_key
就表示排序的key
,rowid
表示主键 ID。占用内存空间小,但是需要多回表一次。
判断使用哪种排序模式:
- 如果字段的总长度(表中所有的列)小于
max_length_for_sort_data
,那么使用单路排序模式; - 如果字段的总长度大于
max_length_for_sort_data
,那么使用双路排序模式。
如果 MySQL 排序内存 sort_buffer 配置的比较小并且没有条件继续增加了,可以适当把 max_length_for_sort_data
配置小点,让优化器选择使用双路排序算法,可以在 sort_buffer 中一次排序更多的行,只是需要再根据主键回到原表取数据。
如果 MySQL 排序内存有条件可以配置比较大,可以适当增大 max_length_for_sort_data
的值,让优化器优先选择全字段排序(单路排序),把需要的字段放到 sort_buffer 中,这样排序后就会直接从内存里返回查询结果了。
所以,MySQL 通过 max_length_for_sort_data
这个参数来控制排序,在不同场景使用不同的排序模式,从而提升排序效率。
注意,如果全部使用 sort_buffer 内存排序一般情况下效率会高于磁盘文件排序,但不能因为这个就随便增大 sort_buffer(默认 1M),MySQL 很多参数设置都是做过优化的,不要轻易调整。
查询优化
常见的分页场景优化技巧
select * from employees limit 10000,10;
从表 employees
中取出从 10001 行开始的 10 行记录。看似只查询了 10 条记录,实际这条 SQL 是先读取 10010 条记录,然后抛弃前 10000 条记录,然后返回后面 10 条想要的数据。因此要查询一张大表比较靠后的数据,执行效率是非常低的。
limit
的执行过程,Server 层向 InnoDB 要第 1 条记录,InnoDB 得到完整的聚簇索引记录,然后返回给 Server 层。Server 将其发送给客户端之前,发现判断 limit 10000,10
的要求,意味着符合条件的记录中的第 10001 条才可以真正发送给客户端,所以在这里先做个统计。假设 Server 层维护了一个称作 limit_count
的变量用于统计已经跳过了多少条记录,此时就应该将 limit_count
设置为 1。
Server 层再向 InnoDB 要下一条记录,limit_count
变为了 2。重估上面的操作。直到 limit_count
等于 10010 的时候,Server 层才会真正的将 InnoDB 返回的完整聚簇索引记录发送给客户端。
自增且连续的主键排序的分页查询
select * from employees limit 90000,5;
-- 优化为
select * from employees where id > 90000 limit 5;
但是,这条改写的 SQL 在很多场景并不实用,因为表中可能某些记录被删后,主键空缺,导致结果不一致。
根据非主键字段排序的分页查询
-- 联合索引 (name,age,position)
-- 该 sql 没有使用 name 字段的索引,因为查找联合索引的结果集太大,并回表的成本比扫描全表的成本更高,所以优化器放弃使用索引。
select * from employees ORDER BY name limit 90000,5;
-- 关键是让排序时返回的字段尽可能少,所以可以让排序和分页操作先查出主键,然后根据主键查到对应的记录,SQL 改写如下
-- 优化为
select * from employees e inner join (select id from employees order by name limit 90000,5) ed on e.id = ed.id;
优化后的语句全部都走了索引,其中 (select id from employees order by name limit 90000,5)
使用了覆盖索引来优化,查询的字段只有 id 字段,而且排好了序。(select id from employees order by name limit 90000,5) ed
产生的临时表只有 5 条记录,然后再根据主键 id 去 employees
表中查询对应的记录。
JOIN 关联查询优化
测试数据:
-- 示例表:
CREATE TABLE `t1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_a` (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
create table t2 like t1;
-- 插入一些示例数据
-- 往t1表插入1万行记录
DROP PROCEDURE IF EXISTS insert_t1;
DELIMITER ;;
CREATE PROCEDURE insert_t1()
BEGIN
DECLARE i INT;
SET i = 1;
WHILE i <= 10000 DO
INSERT INTO t1(a, b) VALUES(i, i);
SET i = i + 1;
END WHILE;
END;;
DELIMITER ;
CALL insert_t1();
-- 往t2表插入100行记录
DROP PROCEDURE IF EXISTS insert_t2;
DELIMITER ;;
CREATE PROCEDURE insert_t2()
BEGIN
DECLARE i INT;
SET i = 1;
WHILE i <= 100 DO
INSERT INTO t2(a, b) VALUES(i, i);
SET i = i + 1;
END WHILE;
END;;
DELIMITER ;
CALL insert_t2();
MySQL 的表关联常见有两种算法:
- Nested-Loop Join 算法
- Block Nested-Loop Join 算法
1. Nested-Loop Join 算法
一次一行循环地从第一张表(称为驱动表)中读取行,在这行数据中取到关联字段,根据关联字段在另一张表(被驱动表)里取出满足条件的行,然后取出两张表的结果合集。
EXPLAIN select * from t1 inner join t2 on t1.a= t2.a;
从执行计划中可以看到:
- 驱动表是
t2
,被驱动表是t1
。先执行的就是驱动表(执行计划结果的 id 如果一样则按从上到下顺序执行 sql); - 如果执行计划
Extra
中未出现Using join buffer
则表示使用的 join 算法是 NLJ。
SQL 的大致流程如下:
- 从表
t2
中读取一行数据(如果t2
表有查询过滤条件的,会从过滤结果里取出一行数据); - 从第 1 步的数据中,取出关联字段
a
,到表t1
中查找; - 取出表
t1
中满足条件的行,跟t2
中获取到的结果合并,作为结果返回给客户端; - 重复上面 3 步。
整个过程会读取 t2
表的所有数据(扫描 100 行),然后遍历这每行数据中字段 a
的值,根据 t2
表中 a
的值索引扫描 t1
表中的对应行(扫描 100 次 t1
表的索引,1 次扫描可以认为最终只扫描 t1
表一行完整数据,也就是总共 t1
表也扫描了 100 行)。因此整个过程扫描了 200 行。
如果被驱动表的关联字段没索引,那就需要去聚簇索引扫描全表,所以使用 NLJ 算法性能会比较低,MySQL 会选择 Block Nested-Loop Join 算法。
2. Block Nested-Loop Join 算法
把驱动表的数据读入到 join_buffer 中,然后扫描被驱动表,把被驱动表每一行取出来跟 join_buffer 中的所有数据一起做对比。
EXPLAIN select * from t1 inner join t2 on t1.b = t2.b;
Extra
中 的 Using join buffer
(Block Nested Loop) 说明该关联查询使用的是 BNL 算法。
SQL 的大致流程如下:
- 把
t2
的所有数据放入到join_buffer
中 - 把表
t1
中每一行取出来,跟join_buffer
中的所有数据做对比 - 返回满足 join 条件的数据。
整个过程对表 t1
和 t2
都做了一次全表扫描,因此扫描的总行数为 10000 (表 t1 的数据总量) + 100 (表 t2 的数据总量) = 10100
。并且 join_buffer
里的数据是无序的,因此对表 t1
中的每一行,都要做 100 次判断,所以内存中的判断次数是 100 * 10000= 100 万次
。
被驱动表的关联字段没索引为什么要选择使用 BNL?
如果上面第二条 SQL 使用 Nested-Loop Join,由于没有 t1.b
是没有索引的,意味这要进行全表扫描,10000 行扫描 100 次就是 100 * 10000 = 100 万行
。很显然,用 BNL 磁盘扫描次数少很多,相比于磁盘扫描,BNL 的内存计算会快得多。
对于关联 SQL 的优化
- 关联字段加索引,让 MySQL 做 join 操作时尽量选择 NLJ 算法
- 小表驱动大表,写多表连接 SQL 时如果明确知道哪张表是小表可以用
straight_join
写法固定连接驱动方式,省去 mysql 优化器自己判断的时间。
straight_join
功能同 join 类似,但能让左边的表来驱动右边的表,能改表优化器对于联表查询的执行顺序。比如:select * from t2 straight_join t1 on t2.a = t1.a
; 代表指定 MySQL 选择t2
表作为驱动表。straight_join
只适用于 inner join,并不适用于 left join,right join。(因为 left join,right join 已经指定了表的执行顺序)。尽可能让优化器去判断,因为大部分情况下 MySQL 优化器是比人要聪明的。使用straight_join
一定要慎重,因为部分情况下人为指定的执行顺序并不一定会比优化引擎要靠谱。
对于小表定义的明确:
在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
join buffer:
当被驱动表中的数据非常多时,每次访问被驱动表,被驱动表的记录会被加载到内存中,在内存中的每一条记录只会和驱动表结果集的一条记录做匹配,之后就会被从内存中清除掉。然后再从驱动表结果集中拿出另一条记录,再一次把被驱动表的记录加载到内存中一遍,周而复始,驱动表结果集中有多少条记录,就得把被驱动表从磁盘上加载到内存中多少次。所以可以在把被驱动表的记录加载到内存的时候,一次性和多条驱动表中的记录做匹配,这样就可以大大减少重复从磁盘上加载被驱动表的代价了。
join buffer
就是执行连接查询前申请的一块固定大小的内存,先把若干条驱动表结果集中的记录装在这个 join buffer
中,然后开始扫描被驱动表,每一条被驱动表的记录一次性和 join buffer 中的多条驱动表记录做匹配,因为匹配的过程都是在内存中完成的,所以这样可以显著减少被驱动表的 I/O 代价。
join_buffer
的大小是由参数 join_buffer_size
设定的,默认值是 256k
。如果放不下表 t2
的所有数据话,策略很简单,就是分段放。
比如 t2
表有 1000 行记录, join_buffer
一次只能放 800 行数据,那么执行过程就是先往 join_buffer
里放 800 行记录,然后从 t1
表里取数据跟 join_buffer
中数据对比得到部分结果,然后清空 join_buffer
,再放入 t2
表剩余 200 行记录,再次从 t1
表里取数据跟 join_buffer
中数据对比。所以就多扫了一次 t1
表。
join_buffer
中,只有查询列表中的列和过滤条件中的列才会被放到 join_buffer
中,所以,最好不要把 *
作为查询列表,只需要把我们关心的列放到查询列表就好了,这样还可以在 join buffer
中放置更多的记录。Hash Join 原理(仅支持等值连接)
MySQL 中的 Hash Join 是一种高效的等值连接算法,尤其适合没有索引、表不太大或临时表操作的场景。
SELECT * FROM t1 JOIN t2 ON t1.id = t2.id;
Hash Join 分两个阶段进行:
- Build Phase(构建哈希表)
- 优化器选择较小的一张表(如
t2
)作为构建表(build input)。 - 把这张表的连接列(如
t2.id
)作为 key,构建哈希表,存入内存。
- Probe Phase(探测匹配项)
- 遍历另一张较大的表
t1
。 - 以连接键
t1.id
去刚刚构建的哈希表中查找匹配项。
-- 探测并输出结果:
for each row in t1:
if hash_table contains t1.id:
output (t1, hash_table[t1.id])
相对于传统的 Nested Loop Join(嵌套循环),Hash Join 将连接时间复杂度从 O(n*m)
降低到接近 O(n + m)
,适合无索引的中小表等值连接。
限制:
- 只支持等值连接,例如
ON a.id = b.id
,不能用范围条件如>
、<
。 - 大表内存不够会溢出,如果构建的哈希表过大,会使用磁盘上的临时表,性能降低
不在索引列上做任何操作(计算、函数、(自动 or 手动)类型转换),会导致索引失效而转向全表扫描
SELECT * FROM person_info WHERE left(name,3) = 'LiLei';
索引列上做了函数操作,得到的结果在索引树上是无法匹配的,所以索引失效了。left(name,3)
有点类似 LiL%
的效果,但是 MySQL 并没有对这种情况做优化,所以索引失效了。
范围查询优化
select * from employees where id >=1 and id <=20000;
mysql 内部优化器会根据检索比例、表大小等多个因素整体评估是否使用索引。比如这个例子,可能是由于单次数据量查询过大导致优化器最终选择不走索引。
优化方法:可以将大的范围拆分成多个小范围:
select * from employees where id >=1 and id <=5000;
select * from employees where id >=5001 and id <=10000;
select * from employees where id >=10001 and id <=15000;
select * from employees where id >=15001 and id <=20000;
让索引列在比较表达式中单独出现
假设为整数列 my_col
建立索引:
WHERE my_col * 2 < 4
是以 my_col * 2
这样的表达式的形式出现的,存储引擎会依次遍历所有的记录,计算这个表达式的值是不是小于 4。
WHERE my_col < 4/2
my_col
列是以单独列的形式出现的,这样的情况可以直接使用 B+ 树索引。
如果索引列在比较表达式中不是以单独列的形式出现,而是以某个表达式,或者函数调用形式出现的话,是用不到索引的。
in 和 exsits 优化
原则:小表驱动大表。
in
:当 B 表的数据集小于 A 表的数据集时,in
优于exists
。
例如:select * from A where id in (select id from B)
:
# 等价于:
for(select id from B){
select * from A where A.id = B.id
}
exists
:当 B 表的数据集大于 A 表的数据集时,exists
优于in
。
例如:select * from A where exists (select 1 from B where B.id = A.id)
。
EXISTS
(subquery) 只返回 TRUE 或 FALSE,因此子查询中的 SELECT *
也可以用 SELECT 1
替换,官方说法是实际执行时会忽略 SELECT
清单,因此没有区别。
#等价于:
for(select * from A){
select * from B where B.id = A.id
}
count(*) 查询优化
EXPLAIN select count(1) from employees;
EXPLAIN select count(id) from employees;
EXPLAIN select count(name) from employees;
EXPLAIN select count(*) from employees;
四个 SQL 的执行计划是一样的,也就说明这四个 SQL 执行效率应该差不多。
count(*)
MySQL 是专门做了优化,并不会把全部字段取出来,不取值,按行累加,效率很高。
count(1)
跟 count(字段)
执行过程类似,不过 count(1)
是用常量 1 做统计,count(字段)
还需要取出字段,所以理论上 count(1)
比 count(字段)
会快一点。
count(*)
、count(1)
或者任意的count(常数)
:对于count(*)
、count(1)
或者任意的count(常数)
来说,读取哪个索引的记录其实并不重要,因为 Server 层只关心存储引擎是否读到了记录,而并不需要从记录中提取指定的字段来判断是否为NULL
。所以优化器会使用占用存储空间最小的那个索引(主键索引包含完整的记录,占用的存储空间是最大的)来执行查询。count(主键 id)
:对于count(主键 id)
来说,由于id
是主键,不论是聚簇索引记录,还是任意一个二级索引记录中都会包含主键字段,所以其实读取任意一个索引中的记录都可以获取到id
字段,此时优化器也会选择占用存储空间最小的那个索引来执行查询。count(字段)
:如果字段有索引,指定的字段可能并不会包含在每一个索引中。优化器只能选择包含指定字段的索引去执行查询,这就可能导致优化器选择的索引并不是最小的那个。如果字段没有索引,就无法使用索引优化查询了,只能选择全表扫描。
所以查询效率:count(*)≈count(1)>count(主键 id)>=count(字段)
,count(字段)
和 count(主键 id)
还需要取出字段判断是否为 NULL
(虽然主键不会为 NULL
,但优化器仍会检查),所以效率会比 count(*)
和 count(1)
低。如果 count(字段)
有索引,并且是占用存储空间最小的那个索引,那么效率会和 count(主键 id)
差不多。
为什么对于 count(id)
,MySQL 最终选择辅助索引而不是主键聚集索引?
因为二级索引相对主键索引存储数据更少,检索性能应该更高。
不带 WHERE 条件的常见优化方法
- 对于 MyISAM 存储引擎的表做不带
where
条件的count
查询性能是很高的,因为 MyISAM 存储引擎的表的总行数会被 MySQL 存储在磁盘上,查询不需要计算。 show table status
可以看到表的行数,但是这个行数是不准确的。性能很高。例如show table status like 'employees'
。- 将总数维护到 Redis 里,插入或删除表数据行的时候同时维护 Redis 里的表总行数 key 的计数值(用
incr
或decr
命令),但是这种方式可能不准,很难保证表操作和 Redis 操作的事务一致性。 - 增加数据库计数表,插入或删除表数据行的时候同时维护计数表,让他们在同一个事务里操作。
索引选择异常和处理
一种方法是,采用 force index
强行选择一个索引。
set long_query_time=0;
select * from t where a between 10000 and 20000; /*Q1*/
select * from t force index(a) where a between 10000 and 20000;/*Q2*/
第二种方法就是,可以考虑修改语句,引导 MySQL 使用我们期望的索引。 第三种方法是,在有些场景下,可以新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引。