原理
- 数据库最终所有的数据(包括我们建的各种表和表里的数据)都是要存放在磁盘上的文件的
- 然后在文件里存放的物理格式就是数据页
准备
当前有表如下
create table index_demo{
c1 int,
c2, int,
c3 char(1),
primary key (c1)
} ROW_FORMAT = COMPACT;
数据页内部的结构
- 我们知道数据是一行一行插入的,当需要插入数据时,会先申请一个数据页,然后将它按照一定的格式写入到数据页中,其行列格式由
ROW_FORMAT = COMPACT
指定 - 数据页内部的数据行将按照主键值从小到大串联成一个串联成一个单向链表
举个例子:
- 比如现在我们插入3条记录1、3、5,那么数据页内部如下:
从上面可以看到,里面是一行一行的数据:
- 刚开始的一行是起始行,它的行类型是2,就是最小的一行,然后它有一个指针指向了下一行数据
- 每一行数据都有自己每个字段的值,然后每一行通过一个指针不停的指向下一个数据,普通的数据行的类型都是0,而且它们是按照主键大小组织的单向链表
- 最后一行是一个类型为3的,就是代表最大的一行
页空间不足了!!!
- 假设你不停地往表里插入数据,那么刚开始就是不停的在一个数据页插入数据。但随着数据越来越多,超出了一个数据页的容量,这个时候就必须要再申请一个数据页来存放新数据行了
- 但是此时会遇到一个问题:索引运作的一个核心机制就是要求你后一个数据页的主键值都大于前一个数据页的主键值。
- 如果你的主键是自增的,那还可以保证这一点,因为你新插入后一个数据页的主键值一定都大于前一个数据页的主键值
- 但是有时候你的主键并不是自增的,所以可能会出现你后一个数据页的主键值里,有的主键是小于前一个数据页的主键值的。
- 对于第二种情况,这个时候就会出现一个过程,叫做页分裂。就是万一你的主键值都是你自己设置的,那么在增加一个新的数据页的时候,实际上会把前一个数据页里主键值较大的,挪动到新的数据页里来,然后把你新插入的主键值较小的数据挪动到上一个数据页里去,保证新数据页里的主键值一定都比上一个数据页里的主键值大。
举个例子
接着上面,我们又想插入一行记录4:
- 但是发现页空间不够了,所以会再申请一个空页
- 新申请的数据页可能不是连续的,它们是通过维护上一个也和下一个也的编号而建立了链接关系(比如新申请了一个页28而不是页11)
- 因为新插入记录的主键值4<上一页的最大主键值5。而我们需要保证上一页的主键值必须大于下一页的主键值,所以需要进行一些移动。
上面就是一个页分裂的过程,核心目标是保证下一个数据页里的主键都比上一个数据页里的主键值要大
总结:在你不停的往表里插入数据的时候,会搞出来一个一个的数据页,如果你的主键不是自增的,那么就可能有一个数据行的挪动过程,保证你下一个数据页的主键值都大于上一个数据页的主键值
数据页之间是怎么存储的
但是上面可以发现有两个问题:
数据页在磁盘文件里是怎么存储的呢?
- 大量的数据页是一页一页的存放的,然后两个相邻的数据页之间会采用双向链表的格式相互引用。
那么上图中在磁盘文件里到底是怎么弄出来的呢?
- 其实一个数据页在磁盘文件里就是一段数据,可能是二进制或者别的特殊格式的数据,然后数据页里包含两个指针,一个指针指向自己上一个数据页的物理地址,一个指向执行自己下一个数据页的物理地址。如下:
DataPage: xx=xx, xx=xx, linked_list_pre_pointer=15367, linked_list_next_pointer=34126 ||
DataPage: xx=xx, xx=xx, linked_list_pre_pointer=23789, linked_list_next_pointer=46589 ||
DataPage: xx=xx, xx=xx, linked_list_pre_pointer=33198, linked_list_next_pointer=55681
- 每个数据页在磁盘文件里都是连续的一段数据
- 每个数据页里,可以认为就是DataPage打头到||符号的一段磁盘里的连续的数据。
- 每个数据页,都有一个指针指向自己上一个数据页在磁盘文件里的起始物理位置,比如linked_list_pre_pointer=15367,就是指向了上一个数据页在磁盘文件里的起始物理位置,那个15367可以认为就是在磁盘文件里的position或者offset
- 同理,也有一个指针指向自己下一个数据页的物理位置。
- 也就是说,一个磁盘文件里的多个数据页通过指针组成了一个双向链表。
然后,数据页内部会有数据行,数据行按照主键值从小到大串联成一个串联成一个单向链表,如下图:
页目录(每个数据页都有自己的页目录)
问题的引出
从上面我们可以知道,数据页内部的数据行是按照主键大小组织成的单向链表。
如果已经定位到了某一页,现在我们想要在这个数据页中根据主键值查找某条记录,怎么找呢?
- 一般来说,直接遍历就可以找到。但是当单向链表太长了,那么性能就很低了。
- 怎么办呢?这就引入了页目录,每个数据页都有自己的页目录。
页目录的制作过程
- 页目录制作过程如下:
- 数据页都会将行数据划分成几个组
- 每个组中最大的那条记录在页面中的地址偏移量会被单独提取出来,按照顺序存储到靠近页尾部的地方
- 这个地方就是“页目录”。页目录中的这些地址偏移量叫做槽。
- 各个槽之间是挨着的,而且它们代表的记录的主键值从小到大排序。
- 这样我们就能快速找到主键对应的数据行的实际存储位置
- 首先先到数据页的页目录里根据主键进行二分查找,这样就可以迅速定位到主键对应的数据是在哪个槽位里
- 然后到那个槽位里去,遍历槽位里每一行数据,就能快速找到那个主键对应的数据了。
那么假设你是要根据非主键的其他字段查找呢?
- 这时可能是没有办法使用主键的页目录进行二分查找的
- 只能进入到数据页里,根据单向链表依次遍历查找。
- 此时性能会很差。
主键目录
问题的引出
现在我们要根据主键来查找某条索引,但是现在有很多数据页,那应该怎么定位到对应主键在哪个数据页上呢?
从上面我们知道:
- 数据页页号可能不是连续的
- 数据页之间是双向链表结构
在目前情况下:
- 无论是根据主键来找还是非主键来找,实际上都是没有什么取巧的方法
- 因为一个表里所有数据页都是组成双向链表的,此时就只能从第一个数据页开始遍历所有数据页。从第一个数据页开始,你需要先把第一个数据页从磁盘上读取到内存buffer pool的缓存页里来。
- 然后你就在第一个数据页对应的缓存页里,按照上述办法查找,假设是根据主键查找的,你可以在数据页的页目录里二分查找,假设你要是根据其他字段查找的,只能是根据数据页内部的单向链表来遍历查找
- 假设第一个数据页没找到你要的那条数据呢?没办法,就只能根据双向链表去找下一个数据页,然后读取到buffer pool的缓存页里去,然后按照一样的方法在一个缓存页内部查找那条数据。
- 以此类推,循环往复。
也就是说:
- 如果没有建立索引,就只能对数据库进行全表扫描,就是根据双向链表依次把磁盘上的数据页加载的缓存页里去,然后在一个缓存页内部来查找那条数据。
- 最坏的情况下,你需要把所有数据页里的每条数据都遍历一遍才行,才能找到你需要的那条数据,这就是全表扫描
怎么改进呢?
- 和页目录一样,可以为所有的页建立一个目录
- 由于数据页的编号可能不是连续的,为快速根据主键值定位页目录,需要给它们建立一个目录,每个目录项包括两个部分:
- 页的用户记录中的最小主键值key
- 页号page_no
- 这些目录项在物理上是连续顺序存储的,所以我们可以用二分法快速定位主键所在页目录编号
- 这个东西就叫做主键目录。 主键目录就是把每个数据页的页号,还有数据页的最小的主键值放在一起,组成一个索引的目录。
有了主键目录的加持,那找数据就非常快了,过程如下
- 二分查找主键目录,找到对应的数据页
- 进入数据页,二分查找数据页目录,找到对应的行数据
B+树的诞生
聚簇索引
新的问题
上面的所有目录项必须在物理上连续存储,但是:
- InnoDB使用页作为管理存储空间的基本单位,也就是最多只有16KB的连续存储空间。如果页目录大小超过了16KB怎么办?
- 我们经常需要对记录进行增删改操作,如果我们把页28中的记录都删除,那么需要移除目录项2,也就意味着我们需要把目录项2后的目录项向前移动一下,这样就太慢了;如果不移除目录项2,又会浪费太多空间。怎么办?
InnoBD是怎么处理的?
- 解决方案:使用之前存储用户记录的数据页来存储目录项,这样就可以解决第一个问题了
- 那么又有一个问题了:怎么区分一条记录是用户记录还是目录项记录呢?行记录的头信息中有一项叫做record_type,其取值为0时表示用户记录,取值为1时表示目录项记录,这样就区分了
如果一个页不能存储更多目录项了,怎么办?
就会申请一个新的页来存储目录项
但是问题是:这样页在存储空间中并不是连续的,如果目录项记录页太多了,又会浪费存储空间
怎么办呢?解决方法是为这些存储目录项记录的页再生成一个更高级的目录
…随着表中记录的增加,这个目录的层级会继续增加。这就是B+树,其叶子节点存储的是真正的行数据,其非叶子节点(也叫做内节点)存储的是目录项记录
B+树的特点:
- 使用记录主键值的大小进行记录和页的排序。也就是说:
- 页(用户记录页、目录项记录页)中的记录按照主键的大小顺序形成了一个单向链表
- 用户记录页根据页中记录的主键大小形成了一个双向链表
- 目录项记录页分成不同的层级,在同一层级中的页也是根据页中主键大小顺序形成一个双向链表
- 这意味着聚簇索引只能在搜索条件是主键值时才能发挥作用
- B+树的叶子节点存储的是完整的用户记录
这样的B+数就叫做聚簇索引。
实际上B+树的形成过程如下:
- 每当为某个表创建一个B+树索引时,都会为这个索引创建一个根节点页面。
- 最开始表中没有数据时,每个B+数索引对应的根节点中是空的
- 随着表中插入数据,先把用户记录存储到这个根节点中
- 当根节点可用节点用完了又插入记录,此时会先把根节点中所有记录复制到一个新分配的页中,然后对这个新页进行页分裂操作,得到另一个新页。这时新插入的记录会根据键值的大小分配到页a或者页b中。根节点此时就升级为目录项记录页
这个过程中,可以看出,一个B+树索引的根节点自创建起(页号)就不会改变,此后每次需要用到这个索引时,都会从固定的那个地方取出根节点的页号,从而访问这个索引
InnoDB必须至少要有一个聚簇索引
- 如果表设置了主键,则主键就是聚簇索引
- 如果表没有主键,则会默认第一个NOT NULL,且唯一(UNIQUE)的列作为聚簇索引
- 以上都没有,则会默认创建一个隐藏的row_id作为聚簇索引
二级索引
如果需要用别的列作为搜索条件怎么办?
我们可以多建几颗B+数,并且不同的B+树中的数据采用不同的排序规则。如下我们用c2的大小作为排序规则:
这个B+树和上面B+数不同的是:
- 它使用记录c2列的大小进行记录和页的排序
- 页(叶子节点和非叶子节点)中记录是按照c2的大小顺序形成的单向链表
- 用户记录页是根据页中记录的c2列大小顺序形成的双向链表
- 目录项记录页分成不同的层级,在同一层级中的页也是根据页中c2大小顺序形成一个双向链表
- B+数的叶子节点存储的并不是完整的用户记录,而是c2列+主键这两个列的值
- 目录项记录中不再是主键+页号,而是c2列+页号
因为c2不满足唯一性约束,所以满足搜索条件的可能有多个,因此,我们先根据这个B+树通过c2值定位主键,然后再用主键去聚簇索引中定位完整的用户记录(这个过程叫做回表),找到一条记录之后再返回当前B+树继续找下一个c2值,然后定位主键,再回表找到对应叶子节点…
为什么需要回表而不是把完整的用户记录放到二级索引的叶子节点呢?为了节省空间,我们不可能每建立一颗B+树就把所有的用户记录复制一遍。
这种以非主键列的大小为排序规则而建立的B+树需要执行的回表操作才能定位到完整的用户记录,所以这种B+树也叫做二级索引/辅助索引。
二级索引和聚簇索引使用的是一样的记录行格式,只是二级索引存储的列是不完整的
联合索引
我们也可以同时为多个列建立索引。比如以c2和c3的大小排序,也就是
- 先把各个记录和页按照c2进行排序
- 如果c2相同,在按照c3进行排序
这样的索引叫做联合索引。联合索引树的特点:
- 每个目录项记录都是由c2、c3、页号这3部分组成,各个记录先按照c2排序,如果c2相同,再按照c3排序
- 叶子节点的用户记录由c2、c3、主键c1组成
提问
问题:没有索引的时候,数据库是查找数据的
- 数据页的物理存储结构:数据页之间是组成双向链表的,数据页内部的数据行是组成单向链表的,每个数据页内根据主键做了一个页目录
- 然后一般来说,在没有索引的情况下,所有的数据查询,其实在物理层面都是全表扫描,依次扫描每个数据页内部的每个数据行。
- 但是这个速度非常慢,所以一般肯定是不能让查询走全表扫描的。
- 因此正在在数据库中的查询,必须要用索引来加速查询的执行。
问题:用户记录和目录项记录有什么不同
- 目录项记录的record_type=1,用户记录的record_type=0
- 目录项记录只有主键值和页编号两个列,用户记录的列是用户自己定义的,可能有很多,另外还有InnoDB自己添加的隐藏列
- 目录项记录的min_rec_flag=1,用户记录的min_rec_flag=0
除了上面几点外,这两者就没有差别了:
- 它们用到都是数据页,页的组成结构也是一样的;
- 都会为主键值生成页目录,这样就可以按照主键值快速查找了
小结
- InnoDB存储印象的索引是一颗B+树,完整的用户记录都存储在B+树第0层的叶子节点,其他层次的节点都属于内节点,内节点存储的是目录项记录。
- InnoDB的索引分为两种:
- 聚簇索引:以主键值大小作为页和记录的排序规则,在叶子节点处存储的记录包含了表中所有列
- 二级索引:以索引列的大小为页和记录的排序规则,在叶子节点处存储的记录包含索引列+主键
- 每个索引都对应一颗B+树
- InnoDB存储引擎会为主键自动建立聚簇索引
- 我们可以为感兴趣的列建立二级索引。
- 如果向通过二级索引查找完整的用户记录,需要执行回表操作,也就是在通过二级索引找到主键值之后,再到聚簇索引中查找完整的回表记录
- B+中的每层记录都是按照索引列的值从小到大的顺序排序组成的双向链表,而且每个表每记录都是按照索引列顺序形成单向链表
- 通过索引查找记录时,是从B+树的跟节点开始一层一层往下搜索的,由于每个页面中的记录都划分成了若干个组,每个组中的索引列值最大的记录在页内的偏移量都被当做槽依次存放到页目录中,这样就可以二分快速定位了