持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情
面试官:谈谈你对主键索引的理解
如果没有任何索引
数据页在磁盘文件中的物理存储结构,数据页之间是组成双向链表的,然后数据页内部的数据行是组成单向链表的,而且数据行是根据主键从小到大排序的。然后每个数据页里都会有一个页目录,里面根据数据行的主键存放了一个目录,同时数据行是被分散存储到不同的槽位里去的,所以实际上每个数据页的目录里,就是这个页里每个主键跟所在槽位的映射关系。
所以假设你要根据主键查找一条数据,而且假设此时你数据库里那个表就没几条数据,那个表总共就一个数据页,那么就太简单了!首先就会先到数据页的页目录里根据主键进行二分查找。然后通过二分查找在目录里迅速定位到主键对应的数据是在哪个槽位里,然后到那个槽位里去,遍历槽位里每一行数据,就能快速找到那个主键对应的数据了。每个槽位里都有一组数据行,你就是在里面遍历查找就可以了。
但是假设你要是根据非主键的其他字段查找数据呢?那就尴尬了,此时你是没办法使用主键的那种页目录来二分查找的,只能进入到数据页里,根据单向链表依次遍历查找数据了,这就性能很差了。
好,那么现在假如我们有很多数据页呢?对了,一个表里往往都是有大量数据的,可能有多达成百上千个数据页,这些数据页就存放在物理磁盘文件里。所以此时是如何查询数据的呢?假设你要是没有建立任何索引,那么无论是根据主键查询,还是根据其他字段来条件查询,实际上都没有什么取巧的办法。
你一个表里所有数据页都是组成双向链表的吧?好,有链表就好办了,直接从第一个数据页开始遍历所有数据页,从第一个数据页开始,你得先把第一个数据页从磁盘上读取到内存buffer pool的缓存页里来。然后你就在第一个数据页对应的缓存页里,按照上述办法查找,假设是根据主键查找的,你可以在数据页的页目录里二分查找,假设你要是根据其他字段查找的,只能是根据数据页内部的单向链表来遍历查找。
那么假设第一个数据页没找到你要的那条数据呢?没办法,只能根据数据页的双向链表去找下一个数据页,然后读取到buffer pool的缓存页里去,然后按一样的方法在一个缓存页内部查找那条数据。如果依然还是查找不到呢?那只能根据双向链表继续加载下一个数据页到缓存页里来了,以此类推,循环往复。似乎是在做一个数据库里很尴尬的操作:全表扫描?
上述操作过程,就是全表扫描,在你没有任何索引数据结构的时候,无论如何查找数据,说白了都是一个全表扫描的过程,就是根据双向链表依次把磁盘上的数据页加载到缓存页里去,然后在一个缓存页内部来查找那条数据。最坏的情况下,你就得把所有数据页里的每条数据都得遍历一遍,才能找到你需要的那条数据,这就是全表扫描!
页分裂
我们在一个表里不停的插入数据的时候,会涉及到一个页分裂的过程,也就是说,这个表里是如何出现一个又一个的数据页的。正常情况下我们在一个表里插入一些数据后,他们都会进入到一个数据页里去,在数据页内部,他们会组成一个单向链表。
假设你不停的在表里插入数据,那么刚开始是不是就是不停的在一个数据页插入数据?接着数据越来越多,越来越多,此时就要再搞一个数据页了。但是此时会遇到一个问题,索引运作的一个核心基础就是要求你后一个数据页的主键值都大于前面一个数据页的主键值,但是如果你的主键是自增的,那还可以保证这一点,因为你新插入后一个数据页的主键值一定都大于前一个数据页的主键值。但是有时候你的主键并不是自增长的,所以可能会出现你后一个数据页的主键值里,有的主键是小于前一个数据页的主键值的。比如在第一个数据页里有一条数据的主键是10,第二个数据页里居然有一条数据的主键值是8,那此时肯定有问题了。
所以此时就会出现一个过程,叫做页分裂,就是万一你的主键值都是你自己设置的,那么在增加一个新的数据页的时候,实际上会把前一个数据页里主键值较大的,挪动到新的数据页里来,然后把你新插入的主键值较小的数据挪动到上一个数据页里去,保证新数据页里的主键值一定都比上一个数据页里的主键值大。
假设新数据页里,有两条数据的主键值明显是小于上一个数据页的主键值的。第一个数据页里有1、5、6三条数据,第二个数据页里有2、3、4三条数据,明显第二个数据页里的数据的主键值比第一个数据页里的5和6两个主键都小,所以这个是不行的。此时就会出现页分裂的行为,把新数据页里的两条数据挪动到上一个数据页,上一个数据页里挪两条数据到新数据页里去。所以上述就是一个页分裂的过程,核心目标就是保证下一个数据页里的主键值都比上一个数据页里的主键值要大。有了这个过程,保证了每个数据页的主键值,就能为后续的索引打下基础。
主键索引
主键的索引实际上就是主键目录,这个主键目录呢,就是把每个数据页的页号,还有数据页里最小的主键值放在一起,组成一个索引的目录。
现在我们有了主键目录就方便了,直接就可以到主键目录里去搜索,比如你要找id=3的数据,此时就会跟每个数据页的最小主键来比,首先id=3大于了数据页2里的最小主键值1,接着小于了数据页8里的最小主键值4。所以既然如此,你直接就可以定位到id=3的数据一定是在数据页2里的!假设你有很多的数据页,在主键目录里就会有很多的数据页和最小主键值,此时你完全可以根据二分查找的方式来找你要找的id到底在哪个数据页里!所以这个效率是非常之高的,主键目录,就可以认为是主键索引。
而我们的数据页都是一坨一坨的连续数据放在很多磁盘文件里的,所以只要你能够根据主键索引定位到数据所在的数据页,此时假设我们有别的方式存储了数据页跟磁盘文件的对应关系,此时你就可以找到一个磁盘文件。而且我们假设数据页在磁盘文件里的位置也就是offset偏移量,你也是可以知道的,此时就可以直接通过随机读的方式定位到磁盘文件的某个offset偏移量的位置,然后就可以读取连续的一大坨数据页了!
聚簇索引
假设我们要搜索一个主键id对应的行,此时你就应该先去顶层的索引页里去找,通过二分查找的方式,很容易就定位到你应该去下层哪个索引页里继续找。基于索引数据结构去查找主键的一个过程,其实最下层的索引页,都是会有指针引用数据页的,所以实际上索引页之间跟数据页之间是有指针连接起来的。
另外呢,其实索引页自己内部,对于一个层级内的索引页,互相之间都是基于指针组成双向链表的。
假设你把索引页和数据页综合起来看,他们都是连接在一起的,看起来就如同一颗完整的大的B+树一样,从根索引页开始,一直到所有的数据页,其实组成了一颗巨大的B+树。在这颗B+树里,最底层的一层就是数据页,数据页也就是B+树里的叶子节点了!所以,如果一颗大的B+树索引数据结构里,叶子节点就是数据页自己本身,那么此时我们就可以称这颗B+树索引为聚簇索引!也就是说,所有的索引页+数据页组成的B+树就是聚簇索引!
其实在InnoDB存储引擎里,你在对数据增删改的时候,就是直接把你的数据页放在聚簇索引里的,数据就在聚簇索引里,聚簇索引就包含了数据!比如你插入数据,那么就是在数据页里插入数据。如果你的数据页开始进行页分裂了,他此时会调整各个数据页内部的行数据,保证数据页内的主键值都是有顺序的,下一个数据页的所有主键值大于上一个数据页的所有主键值。同时在页分裂的时候,会维护你的上层索引数据结构,在上层索引页里维护你的索引条目,不同的数据页和最小主键值。然后如果你的数据页越来越多,一个索引页放不下了,此时就会再拉出新的索引页,同时再搞一个上层的索引页,上层索引页里存放的索引条目就是下层索引页页号和最小主键值。
按照这个顺序,以此类推,如果你的数据量越大,此时可能就会多出更多的索引页层级来,不过说实话,一般索引页里可以放很多索引条目,所以通常而言,即使你是亿级的大表,基本上大表里建的索引的层级也就三四层而已。这个聚簇索引默认是按照主键来组织的,所以你在增删改数据的时候,一方面会更新数据页,一方面其实会给你自动维护B+树结构的聚簇索引,给新增和更新索引页,这个聚簇索引是默认就会给你建立的。