存储引擎系列(三):不同类型的数据库索引 B+ 树是如何维护的
上篇教程学院君给大家介绍了 MySQL 数据库索引的底层数据结构 —— B+ 树,今天我们来看看不同类型的数据库索引是如何构建对应的 B+ 树的。
我们知道数据库索引通常分为主键索引、唯一索引、普通索引和联合索引,不同索引对应的 B+ 树存储数据是不一样的。
主键索引
通常我们会将一张表的 ID 字段设置为主键索引,比如下面这个创建数据表 posts
的 SQL 语句:
CREATE TABLE `posts` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`content` text COLLATE utf8mb4_unicode_ci NOT NULL,
`user_id` bigint(20) unsigned NOT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
我们通过 PRIMARY KEY (`id`)
设置 id
字段为主键,并且该字段通过 AUTO_INCREMENT
标记为自增字段。
对于包含主键索引的数据表,当我们插入记录到数据表时(对于自增字段,不指定 ID 字段值的情况下,系统会自动获取当前 ID 最大值加 1 作为插入记录的 ID 值),会先在当前主键索引对应 B+ 树叶子节点最后一个数据页中查看是否还有剩余空间,如果有的话,则插入到对应数据页最后一条数据的后面(B+ 树叶子节点中的数据记录会按照索引字段值升序排列,而主键 ID 是自增的,所以肯定是已存在记录中最大的,前面数据页定位的逻辑也是这样),否则的话,需要新创建一个数据页来存储数据。
如果插入的记录指定了 id
字段值,并且这个 id
值不是当前数据记录中最大的(数据表由于删除过记录存在空洞),则需要定位到要插入的数据页和插入位置进行插入,如果对应数据页没有剩余空间,则需要开辟新的数据页,插入位置之后的数据记录也要调整以便可以顺利将待插入记录插入进来(这个过程叫做页分裂,显然,页分裂性能损耗较大,有页分裂就有与之相对的页合并,当删除记录较多,数据页存在较多空洞时,就会进行页合并操作),从而确保叶子节点里的数据记录是按照主键索引升序排列的。另外,存储在叶子节点数据页中的数据记录显然是一个单链表结构,这样设计的好处是避免每次插入、删除记录需要移动该位置之后的所有记录。
注:为了提升操作效率,数据库插入记录是在内存中进行的,这个我们在前面介绍日志写入的时候提到过,因此新增的记录并没有立即写入到磁盘。
这里可能有同学会疑惑,数据库底层是按照什么规则对索引字段值进行排序的,这个时候,我们前面介绍的字符集和排序规则就派上用场了,MySQL 是按照索引字段值对应字符集的排序规则对其进行升序排序的。
对于 InnoDB 主键索引对应的 B+ 树而言,叶子节点中存放数据记录的 data
部分存放的是完整的数据记录(一条记录的所有字段信息),因此我们也可以将主键索引称之为聚簇索引,通过聚簇索引,可以直接获取到完整的数据记录,这就是所谓的索引即数据,数据即索引。
唯一索引和普通索引
对于唯一索引和普通索引这种非主键索引,在创建表时分别可以通过 UNIQUE KEY
和 KEY
关键字进行设置:
CREATE TABLE `users` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`email_verified_at` timestamp NULL DEFAULT NULL,
`password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`remember_token` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `users_email_unique` (`email`),
KEY `users_name_index` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
在上面这个 users
表中,将 email
设置为了唯一索引,将 name
设置为普通索引。MySQL 会为每个索引字段维护一棵 B+ 树,因此,对于 users
表而言,拥有三棵 B+ 树,分别是主键索引、唯一索引和普通索引对应的 B+ 树。
关于主键索引,上面已经介绍过,唯一索引和普通索引插入记录的 B+ 树维护逻辑和主键索引类似,叶子节点中的数据记录都是按照对应索引值升序排序,这不过这里的索引值从主键 ID 变成了 email
和 name
,排序规则也是根据索引字段对应的排序规则(没有指定则继承自表排序规则,数据表也没有指定则继承自所在的数据库全局设置)。
和主键索引不同的是,唯一索引和普通索引对应 B+ 树叶子节点存放数据记录的地方存储的不是完整的数据记录,而是所属记录的主键索引值,这样设计的好处是避免数据冗余,因为一张表可能存在多个索引,每个索引对应 B+ 树都存储完整的数据记录会导致不必要的空间浪费,如果数据表很大的话,内存和磁盘空间可能很快就被吃完了,所以好处是显而易见的,但是也有弊端,那就是要获取完整的数据记录,需要再通过主键索引对应的 B+ 树查询一次(也就是说获取完整表记录要遍历两棵 B+ 树),我们将这个过程称作回表,也因此,我们这种非主键索引称之为二级索引。
但也不见得所有的非主键索引查询都要回表,如果一条 SQL 语句只需要获取主键字段信息,那么从非主键索引对应 B+ 树就可以获得主键字段值直接返回了,这个时候就不需要回表了:
select id from users where name = '学院君';
另外,唯一索引和普通索引从查询性能上看不分伯仲,因为所有的索引 B+ 树叶子节点都是排好序的,对于一个命中索引的查询,都是通过二分查找到对应的记录并返回,只是普通索引对应的记录可能不止一条而已,唯一索引的一个优势是可以在数据库层面进行兜底避免有重复记录出现,但是这个去重逻辑也可以在业务代码层完成。当然,如果普通索引设置不合理,一个索引字段值对应多条记录,多到要全表扫描,那就是另一回事了,比如在为某个状态字段设置了普通索引,而所有记录的状态值都是一样的,这个时候通过该状态值查询,就是等同于一次全表扫描了。
注:对于 MyISAM 存储引擎而言,由于索引和数据是分开存储的,所以即便是主键索引,也要再次查询才能返回完整数据记录,因此,在 MyISAM 中,所有的索引都是二级索引。
联合索引
有的时候,业务代码中经常用到的 SQL 查询语句可能包含多个查询条件,并且某些查询字段会多次用到:
select * from votes where voteable_type = ? and voteable_id = ?;
select * from votes where voteable_type = ?;
这个时候,为了提高查询效率,同时也为了避免维护不必要的 B+ 树(B+ 树越多,数据库写入性能越差),我们可以设置联合索引:
CREATE TABLE `votes` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`voteable_type` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,
`voteable_id` bigint(20) unsigned NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `votes_voteable_type_voteable_id_index` (`voteable_type`,`voteable_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
根据业务代码用到的查询条件,这里我们将 voteable_type
和 voteable_id
设置为了联合索引。
联合索引也叫组合索引,和普通索引一样通过 KEY
关键字设置,只是包含多个字段而已。如果为每个字段设置联合索引,则需要多维护一棵 B+ 树,并且进行如下 SQL 语句查询时:
select * from votes where voteable_type = ? and voteable_id = ?
第一个查询条件命中索引,然后需要在 voteable_type
获取到的所有记录中(经历一次回表)依次判断每条记录是否满足第二个 voteable_id
对应的查询条件,如果 voteable_type
查询返回的结果很多,则可能出现慢查询,最差的情况甚至出现全表扫描。
而使用联合索引后,只会维护一棵 B+ 树,这棵 B+ 树的叶子节点数据记录会按照联合索引包含的所有字段进行排序,这里的排序规则是先按照 voteable_type
字段值进行升序排序,voteable_type
值相同的情况下再按照 voteable_id
字段值进行升序排序:
显然,如果某个查询语句是这样的话,不会应用到任何索引:
select * from votes where voteable_id = ?
我们可以通过 explain
语句进行验证:
和唯一索引、普通索引一样,联合索引的数据记录部分存储的也是对应记录的主键 ID,所以联合索引本质上也是一个二级索引。如果查询字段只有 voteable_type
、voteable_id
和 id
,也不会进行回表操作:
select voteable_type, voteable_id, id from votes where voteable_type = ? and voteable_id = ?;
我们可以把只包含索引的查询称之为覆盖索引。
另外,对于所有二级索引的 B+ 树而言,由于数据记录存储的只有主键信息,所以主键长度越小,二级索引的叶子节点就越小,占用的空间也越小,从性能和存储空间方面综合考量,自增主键往往是最合理的选择(整型数据相对字符串类型占用空间小,自增字段无需对插入位置进行定位,直接放到最后一个数据页的最后面的位置即可)。
维护索引的代价
通过前面这么多的分析,我们可以得知设置数据库索引主要是为了优化查询性能,因为对于数据库主要应用场景的 Web 项目而言,往往是读多写少,查询语句占据了数据库操作的 90% 以上份额,所以合理设置索引提升查询效率非常有必要,但是维护这些索引也需要付出代价:
- 空间上的代价:每个索引都对应着一棵 B+ 树,每棵 B+ 树的每个叶子节点都是一个数据页,数据页默认的大小是 16 KB,因此维护索引需要额外的存储空间;
- 时间上的代价:插入、修改、删除记录这些数据库写入操作都会引起索引 B+ 树的调整和自平衡,甚至产生页分裂和页合并,这些操作都需要额外的时间成本,会对数据库性能造成一定的损耗。
好了,关于不同类型数据库索引的 B+ 树维护我们就简单介绍到这里,下篇教程,学院君将给大家介绍不同类型的查询语句如何命中索引提升查询效率。