函数不走索引

在 MySQL 中,有很多看上去逻辑相同,但性能却差异巨大的 SQL 语句。对这些语句使用 不当的话,就会不经意间导致整个数据库的压力变大。

案例一 条件字段函数操作

假设现在维护了一个交易系统,其中交易记录表 tradelog 包含交易流水号 (tradeid)、交易员 id(operator)、交易时间(t_modified)等字段。为了便于描述, 先忽略其他字段。这个表的建表语句如下:

1
2
3
4
5
6
7
8
9
mysql> CREATE TABLE `tradelog` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`operator` int(11) DEFAULT NULL,
`t_modified` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`),
KEY `t_modified` (`t_modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

假设,现在已经记录了从 2016 年初到 2018 年底的所有数据,运营部门有一个需求是,要统计发生在所有年份中 7 月份的交易记录总数。这个逻辑看上去并不复杂,你的 SQL 语句 可能会这么写:

1
select count(*) from tradelog where month(t_modified)=7;

虽然t_modified字段上有索引,但是返回的特别慢。如果对字段做了函数计算,就用不上索引了,这是 MySQL 的规定。

下面是t_modified索引的示意图,方框上面的数字就是month()函数对应的值。

如果你的 SQL 语句条件用的是 where t_modified=’2018-7-1’的话,引擎就会按照上面 绿色箭头的路线,快速定位到 t_modified=’2018-7-1’需要的结果。实际上,B+ 树提供的这个快速定位能力,来源于同一层兄弟节点的有序性。

但是,如果计算 month() 函数的话,你会看到传入 7 的时候,在树的第一层就不知道该怎么办了。也就是说,对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。

需要注意的是,优化器并不是要放弃使用这个索引。

在这个例子里,放弃了树搜索功能,优化器可以选择遍历主键索引,也可以选择遍历索引 t_modified,优化器对比索引大小后发现,索引 t_modified 更小,遍历这个索引比遍历主 键索引来得更快。因此最终还是会选择索引 t_modified。也就是说仍然会使用索引,但是会扫描索引中的所有值,并没有使用索引快速定位的能力。

案例二 隐式类型转换

看下面的一句sql

1
select * from tradelog where tradeid = 110717;

交易编号 tradeid 这个字段上,本来就有索引,但是 explain 的结果却显示,这条语句需 要走全表扫描。可以发现,tradeid 的字段类型是 varchar(32),而输入的参数却是 整型,所以需要做类型转换。有两个问题需要解决:

  • 1数据类型转换的规则是什么? 2为什么有数据类型转换,就要走全索引扫描

在MySQL里,字符串和数字做比较的话,是将字符串转换成数字。

因此,上面语句对优化器来说,相当于

1
select * from tradelog where CAST(tradid AS signed int) = 110717;

触发了案例一中的规则,所以就会走全索引扫描。

案例三 隐式字符编码转换

假设系统里还有一个表trade_detail ,用以记录交易的操作细节。

1
2
3
4
5
6
7
8
CREATE TABLE `trade_detail` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`trade_step` int(11) DEFAULT NULL, /* 操作步骤 */
`step_info` varchar(32) DEFAULT NULL, /* 步骤信息 */
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

这时,如果要查询id=2的交易的所有操作步骤信息,SQL语句可以这么写

1
select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2;

一共分为三步执行:

第 1 步,是根据 id 在 tradelog 表里找到 L2 这一行;

第 2 步,是从 L2 中取出 tradeid 字段的值;

第 3 步,是根据 tradeid 值到 trade_detail 表中查找条件匹配的行。explain 的结果里 面第二行的 key=NULL 表示的就是,这个过程是通过遍历主键索引的方式,一个一个地 判断 tradeid 的值是否匹配。

通过explain可以发现,第三步中根据 tradeid 值到 trade_detail 表中查找条件匹配的行,这个过程是通过遍历主键索引的方式,一个一个地 判断 tradeid 的值是否匹配。这不符合我们的预期。因为表 trade_detail 里 tradeid 字段上 是有索引的,本来是希望通过使用 tradeid 索引能够快速定位到等值的行。但这里 并没有。

这里的原因是因为这两个表的字符集不同,一个是 utf8,一 个是 utf8mb4,所以做表连接查询的时候用不上关联字段的索引。字符集 utf8mb4 是 utf8 的超集,所以当这两个 类型的字符串在做比较的时候,MySQL 内部的操作是,先把 utf8 字符串转成 utf8mb4 字 符集,再做比较。

单独将第三步写成sql,就是

1
2
select * from trade_detail where tradeid=$L2.tradeid.value;
其中,$L2.tradeid.value 的字符集是 utf8mb4。

实际执行的时候,执行的是

1
2
3
select * from trade_detail where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value;
// 如果反过来,加到参数上,就可以使用索引,就像
select operator from tradelog where traideid =CONVERT($R4.tradeid.value USING utf8mb4)

这再次触发了上面的原则,因此做了全表扫描。


上面的例子,都说明了一件事,对索引字段做函数操作,可能会被破坏索引值的有序性,因此优化器就决定放弃走树搜索功能