1. 前言
通过前面的文章,我们已经知道了索引页的结构可以使得它们不用在物理上连续也能正常工作,只是不连续会导致大量的随机IO性能较差,为了解决这个问题InnoDB引入了区的概念,物理上连续的64个页就是一个区,这样InnoDB就可以以“区”为单位来分配空间了。同时,为了避免少量数据也占用2个区的问题,InnoDB采取先零散页后完整区的分配策略,当某个段使用的零散页数量超过32时,开始以区为单位进行分配。
介绍区时我们稍微提到了“段”的概念,但没有细讲,今天的文章我们重点分析。先提几个问题:
- 为什么一棵B+树会有两个段?
- 为什么使用的零散页数量超过32才开始分配完整区?
- 段的三条XDES Entry链表基节点保存在哪里?
- 段对应的INODE Entry节点又保存在哪里?
2. 段的概念
相较于区,段(Segment)只是一个逻辑上的概念,段是由一些零散页和若干个区组成的。
一棵B+树索引对应两个段,分别是叶子节点段和非叶子节点段,也就是说,对于用户记录和目录项记录,InnoDB是分开存储的,为什么要这么做呢?
很多查询场景下,InnoDB都需要顺序的扫描叶子节点的记录,如果一个区里既存储用户记录,又存储目录项记录,那么这个顺序扫描的性能就会受到影响,所以InnoDB才给一棵B+树分配了两个段。
为了更好的管理这些段,InnoDB给每个段都创建了一个INODE Entry
节点,每个节点占用固定的192字节,结构如下:
名称 | 大小 | 说明 |
---|---|---|
Segment ID | 8字节 | 每个段都有唯一一个ID |
NOT_FULL_N_USED | 4字节 | NOT_FULL链表已经使用的页面数量 |
List Base Node * 3 | 16字节 * 3 | 三条XDES Entry链表基节点 |
Magic Number | 4字节 | 魔数,固定值,标记节点是否初始化。 |
Fragment Array Entry * 32 | 4字节 * 32 | 32个零散页的页号 |
- Segment ID
每个段都有唯一一个ID,如果区隶属于某个段,那么XDES Entry里的Segment ID和这里一致。
- NOT_FULL_N_USED
记录该段的NOT_FULL链表里已经使用了多少个页。
- List Base Node
隶属于段的区对应的XDES Entry会根据区内页的使用情况自动串联成三条链表,分别是FREE链表、NOT_FULL链表、FULL链表,每条链表对应一个链表基节点List Base Node,基节点记录了链表的页面数量和头尾节点指针。
- Fragment Array Entry
段由32个零散页和若干个区组成,区会形成三条链表,另外的32个零散页就存储在这里,记录的是页号,所以你现在知道为啥是32个零散页了吧?
看完INODE Entry的结构,是不是有一种豁然开朗的感觉,这已经回答了前面三个问题了。第4个问题,这些INODE Entry节点存储在哪里呢?
2.1 FIL_PAGE_INODE
InnoDB为了不同的目的设计了很多不同类型的页,比如有存放记录的索引页,有存放XDES Entry的XDES页。为了存放INODE Entry,InnoDB专门设计了FIL_PAGE_INODE
页,简称“INODE页”,页结构如下:
名称 | 大小 | 说明 |
---|---|---|
File Header | 38字节 | 所有页的通用文件头信息 |
List Node for | ||
INODE Page List | 12字节 | 存储上一个和下一个INODE页的指针 |
INODE Entry | 192字节*85 | 85个INODE Entry节点 |
Empty Space | 6字节 | 尚未使用的空间 |
File Trailer | 8字节 | 所有页的通用文件尾信息,校验页是否完整 |
- List Node for INODE Page List
由于页大小的限制,一个INODE页最多只能存储85个INODE Entry节点,当表中段特别多的时候,一个INODE页是存储不下的,InnoDB会使用多个页来存储,为了管理这些INODE页,InnoDB使用该属性将这些页串联成两条链表:SEG_INODES_FULL链表和SEG_INODES_FREE链表,前者代表页已用完,后者代表页还空闲。
- INODE Entry
85个INODE Entry节点,紧凑的排列在一起,一共占用16320个字节。
第一个INODE页固定存储在独立表空间的第1个组的第3个页面里,第1组的第1个页面存储的是XDES Entry。
2.2 INODE Entry链表基节点
我们已经知道,一个页最多存储85个INODE Entry节点,当表中段特别多时只能使用多个页面来存储了,这些页面会根据空闲情况串联成FREE和FULL两条链表,那么InnoDB如何找到这两条链表呢?
和XDES Entry一样,InnoDB也为INODE Entry链表设计了链表基节点,每个基节点占用固定的16字节,结构和XDES Entry链表基节点一样,4字节存储链表节点数,12字节存储头尾节点指针。
一张表对应两条INODE Entry链表,也就是说需要两个INODE Entry链表基节点,这俩基节点存储在哪里呢?
独立表空间的第1个组的第1个页面是固定的,页类型是FIL_PAGE_TYPE_FSP_HDR
,它有一个叫File Space Header
的部分,占用112字节,最后的32个字节分别用来存储INODE Entry FULL链表基节点和FREE链表基节点。
还有印象吗?这里还存储了隶属于表空间的三条XDES Entry链表基节点。
如此一来,InnoDB就可以很轻松的访问到这两条链表了,因为链表基节点位置是固定的。InnoDB每创建一个段都会创建一个对应的INODE Entry节点,节点的存储过程是这样的:如果SEG_INODES_FREE链表不为空,则取出一个空闲页面存储节点,页面满了则将其从链表中移除并添加到SEG_INODES_FULL链表。如果SEG_INODES_FREE为空,则从表空间的FREE_FRAG中申请一个页面,将页面类型改为INODE,然后加入到SEG_INODES_FREE链表使用,重复前面的流程。
2.3 Segment Header
一棵B+树索引对应两个段,每个段对应一个INODE Entry,当我们向B+树插入记录需要申请空间时,首先必须要找到这棵B+树对应的两个段,才能找到隶属于段的空闲区。现在的问题是,InnoDB如何找到B+树对应的段呢?
大家还记得索引页的结构吗?索引页有一个Page Header
部分,里面有20个字节非常重要,如下:
名称 | 大小 | 描述 |
---|---|---|
PAGE_BTR_SEG_LEAF | 10字节 | B+树叶子段的头部信息,仅在B+树的Root页定义 |
PAGE_BTR_SEG_TOP | 10字节 | B+树非叶子段的头部信息,仅在B+树的Root页定义 |
一棵B+树的根节点页的Page Header部分,会使用20个字节,分别记录该索引对应的叶子节点段和非叶子节点段信息,这个结构被叫作「Segment Header」,每个Segment Header占用固定的10字节,如下:
名称 | 大小 | 描述 |
---|---|---|
Space ID of the INODE Entry | 4字节 | INODE Entry节点所在表空间ID |
Page Number of the INODE Entry | 4字节 | INODE Entry节点所在页的页号 |
Byte Offset of the INODE Entry | 2字节 | INODE Entry节点所在页的地址偏移量 |
如此一来,InnoDB就可以根据B+树的根节点页面,知晓两个段对应的INODE Entry所在的页面和地址偏移量,从而快速定位到准确的INODE Entry节点,找到隶属于区的空闲页就不再是难事了。
3. 总结
段是一个逻辑上的概念,它由32个零散页和若干个区组成。一棵B+树索引对应两个段,分别是叶子节点段和非叶子节点段,之所以将用户记录和目录项记录分开存储,是因为大多数查询场景,InnoDB需要顺序扫描叶子节点的记录,如果两者混合存储在同一个区里,就会影响扫描的效率。InnoDB为了更好的管理段,会为每个段创建一个INODE Entry节点,这些节点统一存储在表空间的第1组的第3个页面里,页面类型是FIL_PAGE_INODE
,一个页最多存储85个INODE Entry,所以多个INODE页会根据页的使用情况自动串联成FREE和FULL两条链表,链表的基节点存储在表空间第1组的第1个页的File Space Header
里。为了方便找到B+树索引对应的段,InnoDB在B+树索引的根页面的Page Header部分使用20个字节,分别存储叶子节点段和非叶子节点段的信息,这个结构叫作「Segment Header」,存储了INODE Entry节点所在表空间以及所在页的页号和地址偏移量。当InnoDB要为B+树分配空间时,先从根页面找到对应的INODE Entry,再找到隶属于当前段的XDES NOT_FULL链表,从空闲区里取出一个页。