B+树索引其本质就是B+树在数据库中的实现,但是B+索引在数据库中有一个特点就是其高扇出性,因此在数据库中,B+树的高度一般都在2~3层,也就是对于查找某一键值的行记录,最多只需要2到3次IO,这倒不错。因为我们知道现在一般的磁盘每秒至少可以做100次IO,2~3次的IO意味着查询时间只需0.02~0.03秒。
数据库中的B+树索引可以分为聚集索引(clustered index)和辅助聚集索引(secondary index)辅助聚集索引有时也称非聚集索引(non-clustered index)。
但是不管是聚集还是非聚集的索引,其内部都是B+树的,即高度平衡的,叶节点存放着所有的数据。聚集索引与非聚集索引不同的是,叶节点存放的是否是一整行的信息。
聚集索引
InnoDB存储引擎表是索引组织表,即表中数据按照主键顺序存放。而聚集索引就是按照每张表的主键构造一颗B+树,并且叶节点中存放着整张表的行记录数据,因此也让聚集索引的叶节点成为数据页。聚集索引的这个特性决定了索引组织表中数据也是索引的一部分。同B+树数据结构一样,每个数据页都通过一个双向链表来进行链接。
由于实际的数据页只能按照一颗B+树进行排序,因此每张表只能拥有一个聚集索引。在许多情况下,查询优化器非常倾向于采用聚集索引,因为聚集索引能够让我们在索引的叶节点上直接找到数据。此外,由于定义了数据的逻辑顺序,聚集索引能够特别快地访问针对范围值的查询。查询优化器能够快速发现某一段范围的数据页需要扫描。
现在我们来看一张表,我们以人为的方式让其每个页只能存放两个行记录,如:
create table t(a int not null primary key,b varchar(8000));
insert into t select 1,repeat('a',7000);
insert into t select 2,repeat('a',7000);
insert into t select 3,repeat(a',7000);
insert into t select 4,repeat('a',7000);
可以看到,我们表的定义和插入方式使得目前每个页只能存放两个行记录,我们用py_innodb_page_info工具来分析表空间,可得:py_innodb_page_info.py-v mytest/t.ibd
page level为0000的即是数据页。我们要分析的是page level为0001的页,该页是B+树的根,我们来看看索引的根页中存放的数据。
我们直接通过页尾的Page Directory来分析,从00 63可以知道该页中行开始的位置。接着通过Recorder Header来分析,0xc063开始的值为69 6e 66 69 6d 75 6d 00,就代表infimum伪行记录。之前的5个字节01 00 02 00 1b就是Recorder Header,分析第4位到第8位的值1代表该行记录中只有一个记录(需要记住的是,InnoDB的Page Directory是稀疏的),即infimum记录本身。我们通过Recorder Header中最后的两个字节00 1b来判断下一条记录的位置,即c063+1b=c073,读取键值可得80 01,就是主键为1的键值(我们制定的INT是无符号的,因此二进制是0x8001,而不是0x0001),80 01后值00 00 00 04代表指向数据页的页号。以同样的方式,可以找到80 02和80 04这两个键值以及它们指向的数据页。
通过以上对于非数据页节点的分析,我们发现数据页上存放的是完整的行记录,而在非数据页的索引页中,存放的仅仅是键值以及指向数据页的偏移量,而不是一个完整的行记录。因此我们构造的这颗二叉树大致如图5-14所示。
许多数据库的文档会这样告诉读者:聚集索引按照顺序物理地存储数据。但是试想,如果聚集索引必须按照特定顺序存放物理记录的话,则维护成本即显得非常之高了。所以,聚集索引的存储并不是物理上的连续,相反是逻辑上连续的。这其中有两点:一是我们前面说过的页通过双向链表链接,页按照主键的顺序排列。另一点是每个页中的记录也是通过双向链表进行维护,物理存储上可以同样不按照主键存储。
聚集索引的另一个好处是,它对于主键的排序查找和范围查找速度非常快。叶节点的数据就是我们要查询的数据,如我们要查询一张注册用户的表,查询最后注册的10位用户,由于B+树索引是双向链表的,我们可以快速找到最后一个数据页,并取出10条记录,我们用Explain进行分析,可得:
explain select * from Profile order by id limit 10;
另一个是范围查询(range query),如果要查找主键某一范围内的数据,通过叶节点的上层中间节点就可以得到页的范围,之后直接读取数据页即可,又如:
explain select * from Profile where id>10 and id<10000;
Explain得到了MySQL的执行计划(execute plan),并且rows列给出了一个查询结果的预估返回行数。要注意的是,rows代表的是一个预估值,不是确切的值,如果我们实际进行这句SQL的查询,可以看到实际上只有9 946行记录:
select count(*) from Profile where id>10 and id<10000;
辅助索引
对于辅助索引(也称非聚集索引),叶级别不包含行的全部数据。叶节点除了包含键值以外,每个叶级别中的索引行中还包含了一个书签(bookmark),该书签用来告诉InnoDB存储引擎,哪里可以找到与索引相对应的行数据。因为InnoDB存储引擎表是索引组织表,因此InnoDB存储引擎的辅助索引的书签就是相应行数据的聚集索引键。显示了InnoDB存储引擎中辅助索引与聚集索引的关系。
辅助索引的存在并不影响数据在聚集索引中的组织,因此每张表上可以有多个辅助索引。当通过辅助索引来寻找数据时,InnoDB存储引擎会遍历辅助索引并通过叶级别的指针获得指向主键索引的主键,然后再通过主键索引来找到一个完整的行记录。举例来说,如果在一颗高度为3的辅助索引树中查找数据,那么需要对这颗辅助索引遍历3次找到指定主键;如果聚集索引树的高度同样为3,那么还需要对聚集索引进行3次查找,才能最终找到一个完整的行数据所在的页,因此一共需要6次逻辑IO来访问最终的一个数据页。
对于其他的一些数据库,如Microsoft SQL Server数据库,其表类型有一种不是索引组织表,称为堆表。在数据的存放按插入顺序方面,与MySQL的MyISAM存储引擎有些类似。堆表的特性决定了堆表上的索引都是非聚集的,但是堆表没有主键。因此这时书签是一个行标识符(row identifier,RID),可以用如“文件号:页号:槽号”的格式来定位实际的行。
堆表的非聚集索引既然不需要再通过主键对聚集索引进行查找,那不是速度会更快吗?答案是也许,在某些只读的情况下,书签为行标识符方式的非聚集索引可能会比书签为主键方式的非聚集索引快。但是考虑在OLTP(OnLine Transaction Processing,在线事务处理)应用的情况下,表可能还需要发生插入、更新、删除等DML操作。当进行这类操作时,书签为行标识符方式的非聚集索引可能需要不断更新行标识符所指向数据页的位置,这时的开销可能就会大于书签为主键方式的非聚集索引了。
Microsoft SQL Server数据库DBA问过这样的问题,为什么在SQL Server上还要使用索引组织表?堆表的书签性使得非聚集查找可以比主键书签方式更快,并且非聚集可能在一张表中存在多个,我们需要对多个非聚集索引的查找。而且对于非聚集索引的离散读取,索引组织表上的非聚集索引会比堆表上的聚集索引慢一些。当然,在一些情况下,使用堆表的确会比索引组织表更快,但是我觉得大部分是由于存在于OLAP(On-Line Analytical Processing,在线分析处理)的应用。其次就是前面提到的,表中数据是否需要更新,并且更新会否影响到物理地址的变更。此外另一个不能忽视的是对于排序和范围查找,索引组织表可以通过B+树的中间节点就找到要查找的所有页,然后进行读取,而堆表的特性决定了这对其是不能实现的。最后,非聚集索引的离散读,的确是存在上述情况,但是一般的数据库都通过实现预读(read ahead)技术来避免多次的离散读操作。因此,具体是建堆表还是索引组织表,这取决于你的应用,不存在哪个更优的情况。这和InnoDB存储引擎好还是MyISAM存储引擎好的问题是一样的,具体情况具体分析。
接下来,我们通过阅读表空间文件来分析InnoDB存储引擎的非聚集索引,我们还是分析上一小节所用的表t。
不同的是,在表t上再建立一个列c,并对列c创建非聚集索引:
alter table t add c int not null;
update t set c=0-a;
alter table t add key idx_c(c);
show index from t;
select a,c from t;
然后用py_innodb_page_info工具来分析表空间,可得:py_innodb_page_info.py-v t.ibd
对比前一次我们的分析,可以看到这次多了一个页。分析page offset为4的页,该页为非聚集索引所在页,通过工具hexdump分析可得:
因为只有4行数据,并且列c只有4个字节,因此在一个非聚集索引页中即可完成,整理分析可得下图所示的关系:
显示了表t中辅助索引idx_c和聚集索引的关系。可以看到辅助索引的叶节点中包含了列c的值和主键的值。这里键值因为我特意设为负值,你会发现-1以7f ff ff ff的方式进行内部存储。7(0111)最高位为0,代表负值,实际的值应该取反后,加1,即得-1。
B+树索引的管理
索引的创建和删除可以通过两种方法,一种是ALTER TABLE,另一种是CREATE/DROP INDEX。ALTER TABLE创建索引的语法为:
ALTER TABLE tbl_name
|ADD{INDEX|KEY}[index_name]
[index_type] (index_col_name,……) [index_option]……
ALTER TABLE tbl_name
DROP PRIMARY KEY
|DROP {INDEX|KEY} index_name
CREATE/DROP INDEX的语法同样很简单:
CREATE [UNIQUE] INDEX index_name
[index_type]
ON tbl_name(index_col_name,……)
DROP INDEX index_name ON tbl_name
索引可以索引整个列的数据,也可以只索引一个列的开头部分数据,如前面我们创建的表t,b列为varchar(8000),但是我们可以只索引前100个字段,如:
alter table t add key idx_b (b(100));
目前MySQL数据库存在的一个普遍问题是,所有对于索引的添加或者删除操作,MySQL数据库是先创建一张新的临时表,然后把数据导入临时表,删除原表,再把临时表重名为原来的表名。因此对于一张大表,添加和删除索引需要很长的时间。对于从Microsoft SQL Server或Oracle数据库的DBA来说,MySQL数据库的索引维护始终让他们非常苦恼。
InnoDB存储引擎从版本InnoDB Plugin开始,支持一种称为快速索引创建方法。当然这种方法只限定于辅助索引,对于主键的创建和删除还是需要重建一张表。对于辅助索引的创建,InnoDB存储引擎会对表加上一个S锁。在创建的过程中,不需要重建表,因此速度极快。但是在创建的过程中,由于上了S锁,因此创建的过程中该表只能进行读操作。删除辅助索引操作就更简单了,只需在InnoDB存储引擎的内部视图更新下,将辅助索引的空间标记为可用,并删除MySQL内部视图上对于该表的索引定义即可。
查看表中索引的信息可以使用SHOW INDEX语句。如我们来分析表t,之前先加一个联合索引,可得:
alter table t add key idx_a_b(a,c);
show index from t;
因为在表t上有3个索引:一个主键索引,c列上的索引,和b列前100个字节构成的索引。
接着我们来具体讲解每个列的含义:
- Table:索引所在的表名。
- Non_unique:非唯一的索引,可以看到primary key是0,因为必须是唯一的。
- Key_name:索引的名称,我们可以通过这个名称来DROP INDEX。
- Seq_in_index:索引中该列的位置,如果看联合索引idx_a_b就比较直观了。
- Column_name:索引的列
- Collation:列以什么方式存储在索引中。可以是'A'或者NULL。B+树索引总是A,即排序的。如果使用了Heap存储引擎,并且建立了Hash索引,这里就会显示NULL了。因为Hash根据Hash桶来存放索引数据,而不是对数据进行排序。
- Cardinality:非常关键的值,表示索引中唯一值的数目的估计值。Cardinality/表的行数应尽可能接近1,如果非常小,那么需要考虑是否还需要建这个索引。
- Sub_part:是否是列的部分被索引。如果看idx_b这个索引,这里显示100,表示我们只索引b列的前100个字符。如果索引整个列,则该字段为NULL。
- Packed:关键字如何被压缩。如果没有被压缩,则为NULL。
- Null:是否索引的列含有NULL值。可以看到idx_b这里为Yes。因为我们定义的b列允许NULL值。
- Index_type:索引的类型。InnoDB存储引擎只支持B+树索引,所以这里显示的都是BTREE。
- Comment:注释。
Cardinality值非常关键,优化器会根据这个值来判断是否使用这个索引。但是这个值并不是实时更新的,并非每次索引的更新都会更新该值,因为这样代价太大了。因此这个值是不太准确的,只是一个大概的值。上面显示的结果主键的Cardinality为2,但是很显然我们表中有4条记录,这个值应该是4。如果需要更新索引Cardinality的信息,可以使用ANALYZE TABLE命令。如:
analyze table t;
show index from t;
这时的Cardinality的值就对了。不过,在每个系统上可能得到的结果不一样,因为ANALYZE TABLE现在还存在一些问题,可能会影响得到最后的结果。
另一个问题是MySQL数据库对于Cardinality计数的问题,在运行一段时间后,可能会看到下面的结果:
show index from Profile;
Cardinality为NULL,在某些情况下可能会发生索引建立了、但是没有用到,或者explain两条基本一样的语句,但是最终出来的结果不一样。一个使用索引,另外一个使用全表扫描,这时最好的解决办法就是做一次ANALYZE TABLE的操作。因此我建议在一个非高峰时间,对应用程序下的几张核心表做ANALYZE TABLE操作,这能使优化器和索引更好地为你工作。