MySQL实战45讲学习笔记----索引

 

目录

索引模型

InnoDB 索引模型

索引查询过程

覆盖索引

最左前缀原则

索引下推

索引选择

查询过程

更新过程

change buffer的使用场景

change buffer 和 redo log

MYSQL索引优化选择

优化器的逻辑

索引选择异常和处理

字符串字段索引

前缀索引对覆盖索引的影响

其他方式


索引的出现其实就是为了提高数据查询的效率,就像书的目录一样。

索引模型

哈希表、有序数组、搜索树、跳表、LSM

哈希表是一种以键-值(key-value)存储数据的结构,我们只要输入待查找的值即key,就可以找到其对应的值即Value。哈希的思路很简单,把值放在数组里,用一个哈希函数把key换算成一个确定的位置,然后把value放在数组的这个位置。不可避免地,多个key值经过哈希函数的换算,会出现同一个值的情况。处理这种情况的一种方法是,拉出一个链表。

                                                            

哈希表这种结构适用于只有等值查询的场景,比如Memcached及其他一些NoSQL引擎。区间查询较慢

有序数组在等值查询和范围查询场景中的性能就都非常优秀。

                                            

需要更新数据的时候麻烦,你往中间插入一个记录就必须得挪动后面所有的记录,成本太高。有序数组索引只适用于静态存储引擎。

二叉搜索树的特点是:每个节点的左儿子小于父节点,父节点又小于右儿子。查询和更新时间复杂度logN

                                                

为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少的数据块。那么,我们就不应该使用二叉树,而是要使用“N叉”树。这里,“N叉”树中的“N”取决于数据块的大小。

以InnoDB的一个整数字段索引为例,这个N差不多是1200。这棵树高是4的时候,就可以存1200的3次方个值,这已经17亿了。考虑到树根的数据块总是在内存中的,一个10亿行的表上一个整数字段的索引,查找一个值最多只需要访问3次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。

N叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。

InnoDB 索引模型

在InnoDB中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。InnoDB使用B+树索引模型,数据都是存储在B+树中的。每一个索引在InnoDB里面对应一棵B+树。B+树能够很好地配合磁盘的读写特性,减少单次查询的磁盘访问次数。

假设,我们有一个主键列为ID的表,表中有字段k,并且在k上有索引。

这个表的建表语句是:

mysql> create table T(
id int primary key, 
k int not null, 
name varchar(16),
index (k))engine=InnoDB;

表中R1~R5的(ID,k)值分别为(100,1)、(200,2)、(300,3)、(500,5)和(600,6),两棵树的示例示意图如下。

                                                  

根据叶子节点的内容,索引类型分为主键索引和非主键索引

主键索引的叶子节点存的是整行数据。在InnoDB里,主键索引也被称为聚簇索引(clustered index)。

非主键索引的叶子节点内容是主键的值。在InnoDB里,非主键索引也被称为二级索引(secondary index)。

基于主键索引和普通索引的查询有什么区别?

  • 如果语句是select * from T where ID=500,即主键查询方式,则只需要搜索ID这棵B+树;
  • 如果语句是select * from T where k=5,即普通索引查询方式,则需要先搜索k索引树,得到ID的值为500,再到ID索引树搜索一次。这个过程称为回表。

也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。

主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。从性能和存储空间方面考量,自增主键往往是更合理的选择。

索引查询过程

初始化语句

mysql> create table T (
ID int primary key,
k int NOT NULL DEFAULT 0, 
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB;

insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');

执行 select * from T where k between 3 and 5,需要执行几次树的搜索操作,会扫描多少行

                                                           

这条SQL查询语句的执行流程:

  1. 在k索引树上找到k=3的记录,取得 ID = 300;

  2. 再到ID索引树查到ID=300对应的R3;

  3. 在k索引树取下一个值k=5,取得ID=500;

  4. 再回到ID索引树查到ID=500对应的R4;

  5. 在k索引树取下一个值k=6,不满足条件,循环结束。

在这个过程中,回到主键索引树搜索的过程,我们称为回表。可以看到,这个查询过程读了k索引树的3条记录(步骤1、3和5),回表了两次(步骤2和4)。

覆盖索引

如果执行的语句是select ID from T where k between 3 and 5,这时只需要查ID的值,而ID的值已经在k索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引k已经“覆盖了”我们的查询需求,我们称为覆盖索引。

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

覆盖索引的实现一般是通过建立冗余索引例如联合索引来实现的。

假设这个市民表的定义是这样的:

CREATE TABLE `tuser` (
  `id` int(11) NOT NULL,
  `id_card` varchar(32) DEFAULT NULL,
  `name` varchar(32) DEFAULT NULL,
  `age` int(11) DEFAULT NULL,
  `ismale` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `id_card` (`id_card`),
  KEY `name_age` (`name`,`age`)
) ENGINE=InnoDB

最左前缀原则

B+树这种索引结构,可以利用索引的“最左前缀”,来定位记录。

用(name,age)这个联合索引来分析。

                                             

索引项是按照索引定义里面出现的字段顺序排序的。不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符。

在建立联合索引的时候,如何安排索引内的字段顺序。

第一原则是,如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。

考虑的原则就是空间。

索引下推

以市民表的联合索引(name, age)为例。如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是10岁的所有男孩”。那么,SQL语句是这么写的:

mysql> select * from tuser where name like '张%' and age=10 and ismale=1;

                   

这个语句在搜索索引树的时候,只能用 “张”,找到第一个满足条件的记录ID3。

在MySQL 5.6之前,只能从ID3开始一个个回表。到主键索引上找出数据行,再对比字段值。

而MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

索引选择

从性能的角度考虑,你选择唯一索引还是普通索引呢?选择的依据是什么呢?假设字段 k 上的值都不重复。

                                               

从这两种索引对查询语句和更新语句的性能影响来进行分析。

查询过程

假设,执行查询的语句是 select id from T where k=5。这个查询语句在索引树上查找的过程,先是通过B+树从树根开始,按层搜索到叶子节点,也就是图中右下角的这个数据页,然后可以认为数据页内部通过二分法来定位记录。

  • 对于普通索引来说,查找到满足条件的第一个记录(5,500)后,需要查找下一个记录,直到碰到第一个不满足k=5条件的记录。
  • 对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。

InnoDB的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在InnoDB中,每个数据页的大小默认是16KB。

因为引擎是按页读写的,所以说,当找到k=5的记录的时候,它所在的数据页就都在内存里了。那么,对于普通索引来说,要多做的那一次“查找和判断下一条记录”的操作,就只需要一次指针寻找和一次计算。

当然,如果k=5这个记录刚好是这个数据页的最后一个记录,那么要取下一个记录,必须读取下一个数据页,这个操作会稍微复杂一些。

但是,我们之前计算过,对于整型字段,一个数据页可以放近千个key,因此出现这种情况的概率会很低。所以,我们计算平均性能差异时,仍可以认为这个操作成本对于现在的CPU来说可以忽略不计。

更新过程

先跟你介绍一下change buffer。

当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB会将这些更新操作缓存在change buffer中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行change buffer中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。

change buffer在内存中有拷贝,也会被写入到磁盘上。

将change buffer中的操作应用到原数据页,得到最新结果的过程称为merge。除了访问这个数据页会触发merge外,系统有后台线程会定期merge。在数据库正常关闭(shutdown)的过程中,也会执行merge操作。

显然,如果能够将更新操作先记录在change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用buffer pool的,所以这种方式还能够避免占用内存,提高内存利用率。

什么条件下可以使用change buffer呢?

对于唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。比如,要插入(4,400)这个记录,就要先判断现在表中是否已经存在k=4的记录,而这必须要将数据页读入内存才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用change buffer了。

因此,唯一索引的更新就不能使用change buffer,实际上也只有普通索引可以使用。

change buffer用的是buffer pool里的内存,因此不能无限增大。change buffer的大小,可以通过参数innodb_change_buffer_max_size来动态设置。这个参数设置为50的时候,表示change buffer的大小最多只能占用buffer pool的50%。

如果要在这张表中插入一个新记录(4,400)的话,InnoDB的处理流程是怎样的。

第一种情况是,这个记录要更新的目标页在内存中。这时,InnoDB的处理流程如下:

  • 对于唯一索引来说,找到3和5之间的位置,判断到没有冲突,插入这个值,语句执行结束;
  • 对于普通索引来说,找到3和5之间的位置,插入这个值,语句执行结束。

这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小的CPU时间。

第二种情况是,这个记录要更新的目标页不在内存中。这时,InnoDB的处理流程如下:

  • 对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束;
  • 对于普通索引来说,则是将更新记录在change buffer,语句执行就结束了。

将数据从磁盘读入内存涉及随机IO的访问,是数据库里面成本最高的操作之一。change buffer因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。

change buffer的使用场景

change buffer只限于用在普通索引的场景下,而不适用于唯一索引。对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时change buffer的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在change buffer,但之后由于马上要访问这个数据页,会立即触发merge过程。这样随机访问IO的次数不会减少,反而增加了change buffer的维护代价。所以,对于这种业务模式来说,change buffer反而起到了副作用。

普通索引和唯一索引应该怎么选择。其实,这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。所以,我建议你尽量选择普通索引。

如果所有的更新后面,都马上伴随着对这个记录的查询,那么你应该关闭change buffer。而在其他情况下,change buffer都能提升更新性能。

change buffer 和 redo log

现在,我们要在表上执行这个插入语句:

mysql> insert into t(id,k) values(id1,k1),(id2,k2);

这里,我们假设当前k索引树的状态,查找到位置后,k1所在的数据页在内存(InnoDB buffer pool)中,k2所在的数据页不在内存中。如图2所示是带change buffer的更新状态图。

                                   

分析这条更新语句,你会发现它涉及了四个部分:内存、redo log(ib_log_fileX)、 数据表空间(t.ibd)、系统表空间(ibdata1)。

这条更新语句做了如下的操作(按照图中的数字顺序):

  1. Page 1在内存中,直接更新内存;

  2. Page 2没有在内存中,就在内存的change buffer区域,记录下“我要往Page 2插入一行”这个信息

  3. 将上述两个动作记入redo log中(图中3和4)。

做完上面这些,事务就可以完成了。所以,你会看到,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。同时,图中的两个虚线箭头,是后台操作,不影响更新的响应时间。

现在要执行 select * from t where k in (k1, k2)

如果读语句发生在更新语句后不久,内存中的数据都还在,那么此时的这两个读操作就与系统表空间(ibdata1)和 redo log(ib_log_fileX)无关了。所以,图中就没画出这两部分。

                                           

从图中可以看到:

  1. 读Page 1的时候,直接从内存返回。虽然磁盘上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。

  2. 要读Page 2的时候,需要把Page 2从磁盘读入内存中,然后应用change buffer里面的操作日志,生成一个正确的版本并返回结果。

可以看到,直到需要读Page 2的时候,这个数据页才会被读入内存。

所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随机写磁盘的IO消耗(转成顺序写),而change buffer主要节省的则是随机读磁盘的IO消耗。

MYSQL索引优化选择

一条本来可以执行得很快的语句,却由于MySQL选错了索引,而导致执行速度变得很慢

先建一个简单的表,表里有a、b两个字段,并分别建上索引:

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `a` int(11) DEFAULT NULL,
  `b` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `a` (`a`),
  KEY `b` (`b`)
) ENGINE=InnoDB;

然后,我们往表t中插入10万行记录,取值按整数递增,即:(1,1,1),(2,2,2),(3,3,3) 直到(100000,100000,100000)。

用存储过程来插入数据的:

delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=1;
  while(i<=100000)do
    insert into t values(i, i, i);
    set i=i+1;
  end while;
end;;
delimiter ;
call idata();

分析一条SQL语句:

mysql> select * from t where a between 10000 and 20000;

你一定会说,这个语句还用分析吗,很简单呀,a上有索引,肯定是要使用索引a的。

你说得没错,图1显示的就是使用explain命令看到的这条语句的执行情况。

在我们已经准备好的包含了10万行数据的表上,我们再做如下操作。

session A开启了一个事务。随后,session B把数据都删除后,又调用了 idata这个存储过程,插入了10万行数据。

这时候,session B的查询语句select * from t where a between 10000 and 20000就不会再选择索引a了。我们可以通过慢查询日志(slow log)来查看一下具体的执行情况。

为了说明优化器选择的结果是否正确,增加了一个对照,即:使用force index(a)来让优化器强制使用索引a

下面的三条SQL语句,就是这个实验过程。

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*/
  • 第一句,是将慢查询日志的阈值设置为0,表示这个线程接下来的语句都会被记录入慢查询日志中;
  • 第二句,Q1是session B原来的查询;
  • 第三句,Q2是加了force index(a)来和session B原来的查询语句执行情况对比。

如图3所示是这三条SQL语句执行完成后的慢查询日志。

可以看到,Q1扫描了10万行,显然是走了全表扫描,执行时间是40毫秒。Q2扫描了10001行,执行了21毫秒。也就是说,我们在没有使用force index的时候,MySQL用错了索引,导致了更长的执行时间。这个例子对应的是我们平常不断地删除历史数据和新增数据的场景。

优化器的逻辑

选择索引是优化器的工作。而优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的CPU资源越少。

当然,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。

我们这个简单的查询语句并没有涉及到临时表和排序,所以MySQL选错索引肯定是在判断扫描行数的时候出问题了。

那么,问题就是:扫描行数是怎么判断的?

MySQL在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。

这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。也就是说,这个基数越大,索引的区分度越好。

我们可以使用show index方法,看到一个索引的基数。如图4所示,就是表t的show index 的结果 。虽然这个表的每一行的三个字段值都是一样的,但是在统计信息中,这三个索引的基数值并不同,而且其实都不准确。

那么,MySQL是怎样得到索引的基数的呢?这里,我给你简单介绍一下MySQL采样统计的方法。

为什么要采样统计呢?因为把整张表取出来一行行统计,虽然可以得到精确的结果,但是代价太高了,所以只能选择“采样统计”。

采样统计的时候,InnoDB默认会选择N个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。

而数据表是会持续更新的,索引统计信息也不会固定不变。所以,当变更的数据行数超过1/M的时候,会自动触发重新做一次索引统计。

在MySQL中,有两种存储索引统计的方式,可以通过设置参数innodb_stats_persistent的值来选择:

  • 设置为on的时候,表示统计信息会持久化存储。这时,默认的N是20,M是10。
  • 设置为off的时候,表示统计信息只存储在内存中。这时,默认的N是8,M是16。

由于是采样统计,所以不管N是20还是8,这个基数都是很容易不准的。

你可以从图4中看到,这次的索引统计值(cardinality列)虽然不够精确,但大体上还是差不多的,选错索引一定还有别的原因。

其实索引统计只是一个输入,对于一个具体的语句来说,优化器还要判断,执行这个语句本身要扫描多少行。

接下来,我们再一起看看优化器预估的,这两个语句的扫描行数是多少。

rows这个字段表示的是预计扫描行数。其中,Q1的结果还是符合预期的,rows的值是104620;但是Q2的rows值是37116,偏差就大了。而图1中我们用explain命令看到的rows是只有10001行,是这个偏差误导了优化器的判断。

到这里,可能你的第一个疑问不是为什么不准,而是优化器为什么放着扫描37000行的执行计划不用,却选择了扫描行数是100000的执行计划呢?

这是因为,如果使用索引a,每次从索引a上拿到一个值,都要回到主键索引上查出整行数据,这个代价优化器也要算进去的。

而如果选择扫描10万行,是直接在主键索引上扫描的,没有额外的代价。

优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。当然,从执行时间看来,这个选择并不是最优的。

使用普通索引需要把回表的代价算进去,在图1执行explain的时候,也考虑了这个策略的代价 ,但图1的选择是对的。也就是说,这个策略并没有问题。

所以冤有头债有主,MySQL选错索引,这件事儿还得归咎到没能准确地判断出扫描行数。

既然是统计信息不对,那就修正。analyze table t 命令,可以用来重新统计索引信息。我们来看一下执行效果。

图6 执行analyze table t 命令恢复的explain结果

所以在实践中,如果你发现explain的结果预估的rows值跟实际情况差距比较大,可以采用这个方法来处理。

其实,如果只是索引统计不准确,通过analyze命令可以解决很多问题,但是前面我们说了,优化器可不止是看扫描行数。

依然是基于这个表t,我们看看另外一个语句:

mysql> select * from t where (a between 1 and 1000)  and (b between 50000 and 100000) order by b limit 1;

从条件上看,这个查询没有符合条件的记录,因此会返回空集合。

在开始执行这条语句之前,你可以先设想一下,如果你来选择索引,会选择哪一个呢?

为了便于分析,我们先来看一下a、b这两个索引的结构图。

图7 a、b索引的结构图

如果使用索引a进行查询,那么就是扫描索引a的前1000个值,然后取到对应的id,再到主键索引上去查出每一行,然后根据字段b来过滤。显然这样需要扫描1000行。

如果使用索引b进行查询,那么就是扫描索引b的最后50001个值,与上面的执行过程相同,也是需要回到主键索引上取值再判断,所以需要扫描50001行。

所以你一定会想,如果使用索引a的话,执行速度明显会快很多。那么,下面我们就来看看到底是不是这么一回事儿。

图8是执行explain的结果。

mysql> explain select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;

图8 使用explain方法查看执行计划 2

可以看到,返回结果中key字段显示,这次优化器选择了索引b,而rows字段显示需要扫描的行数是50198。

从这个结果中,你可以得到两个结论:

  1. 扫描行数的估计值依然不准确;

  2. 这个例子里MySQL又选错了索引。

索引选择异常和处理

其实大多数时候优化器都能找到正确的索引,但偶尔你还是会碰到我们上面举例的这两种情况:原本可以执行得很快的SQL语句,执行速度却比你预期的慢很多,你应该怎么办呢?

一种方法是,像我们第一个例子一样,采用force index强行选择一个索引。MySQL会根据词法解析的结果分析出可能可以使用的索引作为候选项,然后在候选列表中依次判断每个索引需要扫描多少行。如果force index指定的索引在候选索引列表中,就直接选择这个索引,不再评估其他索引的执行代价。

我们来看看第二个例子。刚开始分析时,我们认为选择索引a会更好。现在,我们就来看看执行效果:

图9 使用不同索引的语句执行耗时

可以看到,原本语句需要执行2.23秒,而当你使用force index(a)的时候,只用了0.05秒,比优化器的选择快了40多倍。

也就是说,优化器没有选择正确的索引,force index起到了“矫正”的作用。

不过很多程序员不喜欢使用force index,一来这么写不优美,二来如果索引改了名字,这个语句也得改,显得很麻烦。而且如果以后迁移到别的数据库的话,这个语法还可能会不兼容。

但其实使用force index最主要的问题还是变更的及时性。因为选错索引的情况还是比较少出现的,所以开发的时候通常不会先写上force index。而是等到线上出现问题的时候,你才会再去修改SQL语句、加上force index。但是修改之后还要测试和发布,对于生产系统来说,这个过程不够敏捷。

所以,数据库的问题最好还是在数据库内部来解决。那么,在数据库里面该怎样解决呢?

既然优化器放弃了使用索引a,说明a还不够合适

第二种方法就是,我们可以考虑修改语句,引导MySQL使用我们期望的索引。比如,在这个例子里,显然把“order by b limit 1” 改成 “order by b,a limit 1” ,语义的逻辑是相同的。

我们来看看改之后的效果:

图10 order by b,a limit 1 执行结果

之前优化器选择使用索引b,是因为它认为使用索引b可以避免排序(b本身是索引,已经是有序的了,如果选择索引b的话,不需要再做排序,只需要遍历),所以即使扫描行数多,也判定为代价更小。

现在order by b,a 这种写法,要求按照b,a排序,就意味着使用这两个索引都需要排序。因此,扫描行数成了影响决策的主要条件,于是此时优化器选了只需要扫描1000行的索引a。

当然,这种修改并不是通用的优化手段,只是刚好在这个语句里面有limit 1,因此如果有满足条件的记录, order by b limit 1和order by b,a limit 1 都会返回b是最小的那一行,逻辑上一致,才可以这么做。

如果你觉得修改语义这件事儿不太好,这里还有一种改法,图11是执行效果。

mysql> select * from  (select * from t where (a between 1 and 1000)  and (b between 50000 and 100000) order by b limit 100)alias limit 1;

图11 改写SQL的explain

在这个例子里,我们用limit 100让优化器意识到,使用b索引代价是很高的。其实是我们根据数据特征诱导了一下优化器,也不具备通用性。

第三种方法是,在有些场景下,我们可以新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引。

不过,在这个例子中,我没有找到通过新增索引来改变优化器行为的方法。这种情况其实比较少,尤其是经过DBA索引优化过的库,再碰到这个bug,找到一个更合适的索引一般比较难。

如果我说还有一个方法是删掉索引b,你可能会觉得好笑。但实际上我碰到过两次这样的例子,最终是DBA跟业务开发沟通后,发现这个优化器错误选择的索引其实根本没有必要存在,于是就删掉了这个索引,优化器也就重新选择到了正确的索引。

字符串字段索引

假设,现在维护一个支持邮箱登录的系统,用户表是这么定义的:

mysql> create table SUser(
ID bigint unsigned primary key,
email varchar(64), 
... 
)engine=innodb; 

由于要使用邮箱登录,所以业务代码中一定会出现类似于这样的语句:

mysql> select f1, f2 from SUser where email='xxx';

如果email这个字段上没有索引,那么这个语句就只能做全表扫描。同时,MySQL是支持前缀索引的,也就是说,可以定义字符串的一部分作为索引。默认地,如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。

比如,这两个在email字段上创建索引的语句:

mysql> alter table SUser add index index1(email);
或
mysql> alter table SUser add index index2(email(6));

第一个语句创建的index1索引里面,包含了每个记录的整个字符串;而第二个语句创建的index2索引里面,对于每个记录都是只取前6个字节。

   

从图中你可以看到,由于email(6)这个索引结构中每个邮箱字段都只取前6个字节(即:zhangs),所以占用的空间会更小,这就是使用前缀索引的优势。但,这同时带来的损失是,可能会增加额外的记录扫描次数。

接下来,我们再看看下面这个语句,在这两个索引定义下分别是怎么执行的。

select id,name,email from SUser where email='zhangssxyz@xxx.com';

如果使用的是index1(即email整个字符串的索引结构),执行顺序是这样的:

  1. 从index1索引树找到满足索引值是’zhangssxyz@xxx.com’的这条记录,取得ID2的值;

  2. 到主键上查到主键值是ID2的行,判断email的值是正确的,将这行记录加入结果集;

  3. 取index1索引树上刚刚查到的位置的下一条记录,发现已经不满足email='zhangssxyz@xxx.com’的条件了,循环结束。

这个过程中,只需要回主键索引取一次数据,所以系统认为只扫描了一行。

如果使用的是index2(即email(6)索引结构),执行顺序是这样的:

  1. 从index2索引树找到满足索引值是’zhangs’的记录,找到的第一个是ID1;

  2. 到主键上查到主键值是ID1的行,判断出email的值不是’zhangssxyz@xxx.com’,这行记录丢弃;

  3. 取index2上刚刚查到的位置的下一条记录,发现仍然是’zhangs’,取出ID2,再到ID索引上取整行然后判断,这次值对了,将这行记录加入结果集;

  4. 重复上一步,直到在idxe2上取到的值不是’zhangs’时,循环结束。

在这个过程中,要回主键索引取4次数据,也就是扫描了4行。

通过这个对比,你很容易就可以发现,使用前缀索引后,可能会导致查询语句读数据的次数变多。

但是,对于这个查询语句来说,如果你定义的index2不是email(6)而是email(7),也就是说取email字段的前7个字节来构建索引的话,即满足前缀’zhangss’的记录只有一个,也能够直接查到ID2,只扫描一行就结束了。

也就是说使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。

于是,你就有个问题:当要给字符串创建前缀索引时,有什么方法能够确定我应该使用多长的前缀呢?

实际上,我们在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。

首先,你可以使用下面这个语句,算出这个列上有多少个不同的值:

mysql> select count(distinct email) as L from SUser;

然后,依次选取不同长度的前缀来看这个值,比如我们要看一下4~7个字节的前缀索引,可以用这个语句:

mysql> select 
  count(distinct left(email,4))as L4,
  count(distinct left(email,5))as L5,
  count(distinct left(email,6))as L6,
  count(distinct left(email,7))as L7,
from SUser;

当然,使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如5%。然后,在返回的L4~L7中,找出不小于 L * 95%的值,假设这里L6、L7都满足,你就可以选择前缀长度为6。

前缀索引对覆盖索引的影响

前面我们说了使用前缀索引可能会增加扫描行数,这会影响到性能。其实,前缀索引的影响不止如此,我们再看一下另外一个场景。

你先来看看这个SQL语句:

select id,email from SUser where email='zhangssxyz@xxx.com';

与前面例子中的SQL语句

select id,name,email from SUser where email='zhangssxyz@xxx.com';

相比,这个语句只要求返回id和email字段。

所以,如果使用index1(即email整个字符串的索引结构)的话,可以利用覆盖索引,从index1查到结果后直接就返回了,不需要回到ID索引再去查一次。而如果使用index2(即email(6)索引结构)的话,就不得不回到ID索引再去判断email字段的值。

即使你将index2的定义修改为email(18)的前缀索引,这时候虽然index2已经包含了所有的信息,但InnoDB还是要回到id索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信息。

也就是说,使用前缀索引就用不上覆盖索引对查询性能的优化了,这也是你在选择是否使用前缀索引时需要考虑的一个因素。

其他方式

第一种方式是使用倒序存储。如果你存储身份证号的时候把它倒过来存,每次查询的时候,你可以这么写:

mysql> select field_list from t where id_card = reverse('input_id_card_string');

由于身份证号的最后6位没有地址码这样的重复逻辑,所以最后这6位很可能就提供了足够的区分度。当然了,实践中你不要忘记使用count(distinct)方法去做个验证。

第二种方式是使用hash字段。你可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。

mysql> alter table t add id_card_crc int unsigned, add index(id_card_crc);

然后每次插入新记录的时候,都同时用crc32()这个函数得到校验码填到这个新字段。由于校验码可能存在冲突,也就是说两个不同的身份证号通过crc32()函数得到的结果可能是相同的,所以你的查询语句where部分要判断id_card的值是否精确相同。

mysql> select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string'

这样,索引的长度变成了4个字节,比原来小了很多。

接下来,我们再一起看看使用倒序存储和使用hash字段这两种方法的异同点。

首先,它们的相同点是,都不支持范围查询。倒序存储的字段上创建的索引是按照倒序字符串的方式排序的,已经没有办法利用索引方式查出身份证号码在[ID_X, ID_Y]的所有市民了。同样地,hash字段的方式也只能支持等值查询。

它们的区别,主要体现在以下三个方面:

  1. 从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而hash字段方法需要增加一个字段。当然,倒序存储方式使用4个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个hash字段也差不多抵消了。

  2. 在CPU消耗方面,倒序方式每次写和读的时候,都需要额外调用一次reverse函数,而hash字段的方式需要额外调用一次crc32()函数。如果只从这两个函数的计算复杂度来看的话,reverse函数额外消耗的CPU资源会更小些。

  3. 从查询效率上看,使用hash字段方式的查询性能相对更稳定一些。因为crc32算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近1。而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值