一、数据准备
创建 employee 表
CREATE TABLE `employee` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键 id',
`name` varchar(32) NOT NULL DEFAULT '' COMMENT '姓名',
`age` int NOT NULL DEFAULT '0' COMMENT '年龄',
`position` varchar(20) NOT NULL DEFAULT '' COMMENT '职位',
`hire_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入职时间',
PRIMARY KEY (`id`),
KEY `idx_name` (`name`),
KEY `idx_age` (`age`),
KEY `idx_hire_time` (`hire_time`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8;
employee 表中 name 字段为 varchar 类型, age 字段为 int 类型,hire_time 为 timestamp 类型,同时针对字段 name、age、hire_time 分别创建了 3 个单列索引 idx_name、idx_age、idx_hire_time ,现在我们为 employee 表插入几条数据
二、SQL 执行计划分析
数据准备好了之后我们就可以开始分析了
下面先看两条 Sql,分析一下这些 Sql 会不会走索引
select * from employee where name = '1';
select * from employee where age = 2;
我们很自然的可以想到,name 是字符串类型,传递的参数也是字符串,age 是数字类型,传递的也是数字,传递的参数类型与索引字段类型完全匹配,所以它们一定是会走索引的
通过 explain 关键字看一下执行计划
果然上面两个 Sql 是能使用到索引的,那么继续看一下这条 Sql
select * from employee where hire_time = '20220513193801';
hire_time 字段的类型是 timestamp,而我们传递的参数是字符串 '20220513193801',参数类型和索引字段类型并不匹配,那么这样的情况会使用到索引吗?
在 Mysql 中如果字段类型和参数类型不一致,并且字段类型和参数类型中存在字符串,那么 Mysql 都会把字符串转换成其它类型,从而保证字段和参数的类型一致,例如上面的 Sql 就会把字符串 '20220513193801' 转换成时间类型,然后再根据条件进行筛选过滤
通过查询可以推断出字符串 '20220513193801' 会被转成时间格式 2022-05-13 19:38:01,然后再根据条件去数据库中查找符合条件的数据记录
转换只发生在参数上,并没有在索引字段上进行 显示/隐式 的转换,所以索引不会失效
继续分析一下下面的两条 Sql,它们会用到索引吗?
select * from employee where age = '1';
select * from employee where name = 0;
我们大多数情况下都会认为 age 是数值类型,传递的参数是字符串 '1', name 是字符串类型,传递的参数却是数值类型的 123,索引字段类型和参数类型不匹配,所以它们不会走索引,真的是这样吗,我们可以使用 explain 验证一下
第一条 Sql 走了索引,而第二条 Sql 却没有走索引,为什么呢,下面我们就来分析一下
先看第一条 Sql
select * from employee where age = '1';
前面我们提到过,当有数据库字段类型和传递的参数类型不一致的时候,如果包含字符串,Mysql 默认会将字符串转成可以让 = 号两边匹配的上的类型
age 是数值类型, '1' 是字符串类型,那么字符串 '1' 会转成数字类型,这样 = 号两边的类型就相符了,如果将字符串 '1' 转成数值类型,转换之后的数值类型的值是多少呢?
- 如果是数值类型的字符串,那么转成数值类型之后的值就是对应的数值本身
例如 '6' 转换成 6、'12' 转换成 12
- 如果字符串不是以数字开头,转成数值类型之后,数值类型的值为 0
例如 'x13x' 转换成 0、'abc' 转换成 0
- 如果字符串是以数字开头,转成数值类型的时候会将开头的数值截取下来,直到截取到非字符串为止,截取下来的数字就是转换后的结果
例如 '1a4' 转换成 1、'16xx14' 转换成 16
age 字段是数值类型,虽然传递的参数是字符串类型,但是转换只发生在参数上,基于 age 字段构建的索引 B+ 树并没有发生任何变化,只需要将参数对应的字符串转化成数字类型的值,然后使用数字类型的值去 age 索引树上查找即可,索引并不会失效
继续看第二条 Sql
select * from employee where name = 123;
前面已经分析了,如果包含字符串的参数类型和字段类型不匹配,Mysql 会将字符串转换成其它类型,以保证字段类型和参数类型相一致
name 字段是字符串, 123 是数值类型,所以这一条 Sql 会将 employee 表中 name 列的所有值先转成数值类型,然后再进行匹配过滤
可以验证一下
上面的 Sql 为什么能筛选到 5 条记录呢,那是因为 Mysql 会将 name 列的所有值先转成数值类型
'0'、'alis'、'dani'、'jean'、'owen' 这些字符串都会转成 int 类型的 0
'1' 转成 1
'2' 转成 2
'3' 转成 3
所以上面的 Sql 能匹配到 5 条记录
接下来分析一下,为什么 name 列不能用到索引 idx_name
先看一下基于 name 字段建立的正常索引树是什么样子的 (黄色的是主键 id)
条件是数字类型的 123 ,而基于 name 字段的索引树是字符串类型的,假如你想要利用索引去搜寻,必然要先将索引树上的字符串转成数字类型,然后再根据数字类型的参数 123 去整颗索引树上进行查找比对,找出符合条件的数据,否则数字类型的 123 怎么可能去字符串类型的索引树上去搜寻结果呢
基于上面的要求,我们就来转换一下索引树
'0'、'alis'、'dani'、'jean'、'owen' 这些字符串都会转成 int 类型的 0
'1' 转成 1
'2' 转成 2
'3' 转成 3
那么原先的索引树就会变成如下这个样子(黄色的是主键 id)
转换的过程会存在以下的问题
1、完全转换一颗这样的 B+ 树是十分消耗性能的,因为如果表数据过大的情况下,索引树也是很庞大的
2、转换后的 B+ 树有可能不是一颗符合规范的 B+ 树,例如上图转换过后的 B+ 树,叶子节点的 key 并不是有序的(key 0<-->1<-->2<-->3<-->0<-->0<-->0<-->0 并不是有序的),既然不是一颗合格的 B+ 树,那么你就不能利用 B+ 树的特性去快速查找了,如果你想把它变成一颗合格的 B+ 树,你又要做很多额外的操作,那么这个成本开销是很大的,显然不现实
3、如果其它的 Sql 也用到了 idx_name 这个索引,转换后的 B+ 树就会影响到其它使用了该索引的 Sql
所以 Mysql 底层认为,与其这样还不如全表扫描呢,这样效率反而更高一些,Mysql 根据自身维护的一套成本计算规则认定全表扫描的成本更低,所以输出执行计划的时候 type 列的值就是 ALL
那么既然 Mysql 认为全表扫描成本更低,而你又认为使用索引的成本更低,如果你强制使用索引(force index),会怎么样呢?
即使你想强制使用 idx_name 索引,Mysql 也不会这么做
同理,在索引字段上显示的使用函数,或者对索引字段进行运算也会使索引失效
三、总结
1、在索引字段对应的参数上 显式、隐式转换不会影响到索引树的结构,索引不会失效
2、对索引字段进行显示、隐式转换都会让索引失效,因为你想利用索引的话必然要先改变索引树的结构,这是一个很高的成本,与其是这样,还不如全表扫描
3、为了避免出现各种意想不到的问题,一定要保证索引字段的类型和条件中参数的类型一致
四、题外话
分享一个生产环境发生的慢 SQL 问题
我们生产环境有一张集团总商户表,里面有一个索引字段 first_agent_id,类型是 int 类型
前端在传递参数的时候没有对输入框做任何限制,既可以输入数字也可以输入字符串
我们后台在实体类中定义了一个字段 private String firstAgentId,用来接收前台传递的参数
当前端输入数字 123456 的时候,可以通过实体类进行转换,变成字符串类型,Sql 中的条件变成了 first_agent_id = '123456' ,由于 fisrt_agent_id 是 int 类型,参数是字符串 '123456',所以字符串会先转换成 int 类型,也就等价于 first_agent_id = 123456,索引不会失效,查找性能也是比较高的
问题就出在,当前端输入的是一些杂乱的字符串,例如 'afsdfsd ' 的时候,Sql 中的条件变成了 first_agent_id = 'afsdfsd ' ,字符串 'afsdfsd ' 转换成 int 类型的 0, Sql 的条件就变成了 first_agent_id = 0,而我们这张集团商户表中存在 130 万条 first_agent_id = 0 的过期商户,这 130 万过期商户就很无辜的被查出来了,后面使用 in(商户号1,商户号2........) 操作的时候,整个数据库都炸了