Skip to content

26 数据库无法启动,如何读取InnoDB文件中的数据?(下)

你好,我是俊达

这一讲中,我们接着上一讲的内容,介绍InnoDB中B+树的物理结构,以及InnoDB管理数据文件的一些数据结构。

先创建一个测试表,写入一些数据。

CREATE TABLE t_btree (
  id varchar(100) NOT NULL,
  a varchar(100),
  padding varchar(1024),
  PRIMARY KEY (id),
  KEY idx_a (a)
) ENGINE=InnoDB;

insert into t_btree(id, a, padding)
select concat('PK', rpad('', 93, '-'), 10000 + n),
    concat('KEY', rpad('', 92, '-'), 10000 + n),
    rpad('', 512, 'DATA')
from numbers
order by n;

InnoDB中的B+树

之前我们提到过,InnoDB中表和索引都是B+树的结构,下面就是B+树的一个简单示意图。

图片

B+树由根节点(root page)、分支节点(branch page)和叶子节点(leaf page)组成。每个页面由头部信息和页面记录组成。分支页面的记录中,有指向下一个层级的页面编号。页面头部还记录了同一层级中相邻页面的编号,这些页面组成一个双向链表。页面记录包含Key和Value两个部分。对于主键,Key就是组成Primary Key的字段,分支页面中的Value值是页面编号(Page Number),叶子页面中,Value值包括隐藏字段DB_TRX_ID,DB_ROLL_PTR,以及表结构中Primary Key之外的字段。

二级索引中,分支页面中的记录由索引定义中的字段、主键字段和页面编号组成,而叶子页面中的记录由索引字段和主键字段组成。

这个结构,在物理文件中是怎么体现的呢?我们来看一下测试表t_btree中的索引结构。先查看索引的元数据,聚簇索引的根页面是page 4,idx_a的根页面是page 5,表空间ID是411(0x019B)。

mysql> SET SESSION debug='+d,skip_dd_table_access_check';

mysql> select t2.name, t2.se_private_data
  from mysql.tables t1, mysql.indexes t2
  where t1.id = t2.table_id
  and t1.name = 't_btree';

+---------+---------------------------------------------------------+
| name    | se_private_data                                         |
+---------+---------------------------------------------------------+
| idx_a   | id=779;root=5;space_id=411;table_id=1477;trx_id=122435; |
| PRIMARY | id=778;root=4;space_id=411;table_id=1477;trx_id=122435; |
+---------+---------------------------------------------------------+

然后再查看索引字段,聚簇索引里包含了表中所有的字段,还有2个隐藏字段db_trx_id、db_roll_ptr。二级索引的字段包含了主键字段。

mysql> use mysql;

mysql> select t2.name, t4.ordinal_position as pos,  t4.hidden, t3.name,
    t3.hidden, t3.column_type_utf8
from tables t1, indexes t2, columns t3, index_column_usage t4
where t1.id = t2.table_id
and t1.id = t3.table_id
and t2.id = t4.index_id
and t3.id = t4.column_id
and t1.name = 't_btree'
order by t2.name, t4.ordinal_position

+---------+-----+--------+-------------+---------+------------------+
| name    | pos | hidden | name        | hidden  | column_type_utf8 |
+---------+-----+--------+-------------+---------+------------------+
| idx_a   |   1 |      0 | a           | Visible | varchar(100)     |
| idx_a   |   2 |      1 | id          | Visible | varchar(100)     |
| PRIMARY |   1 |      0 | id          | Visible | varchar(100)     |
| PRIMARY |   2 |      1 | DB_TRX_ID   | SE      |                  |
| PRIMARY |   3 |      1 | DB_ROLL_PTR | SE      |                  |
| PRIMARY |   4 |      1 | a           | Visible | varchar(100)     |
| PRIMARY |   5 |      1 | padding     | Visible | varchar(1024)    |
+---------+-----+--------+-------------+---------+------------------+

聚簇索引结构

ROOT

聚簇索引的根页面编号是0x04,Page 4的内容见下面这个图,图上标注了一些关键信息。

图片

  • index id为0x030A,也就是778,和元数据中查到的一样。

  • level为2,这棵B+树的高度为3层。

  • leaf inode和interior inode分别记录了叶子页面和分支页面所在段(Segment)的段描述符(inode)的地址。inode的作用稍后再介绍。

  • 根页面中有4条索引记录,分别是(PK-10000,Page C6)、(PK-11430,Page C7)、(PK-14330,Page C8)、(PK-17230,Page CB)。

Branch

从根页面,我们得到了分支页面的编号,我们来看一下其中一个分支页面Page C6的内容。

图片

  • Level为1。

  • Prev为null,说明这是Level 1中的第一个页面。Next为Page C7。

  • records为0x48,说明页面中有72条索引记录。

  • 图中展示了部分索引条目,分别是(PK-10000,Page 06),(PK-10010,Page 07),(PK-10030,Page 08)。

Leaf

从分支页面中,我们知道了叶子页面的编号,我们来看一下页面07的内容。

图片

  • Level为0,说明这是一个叶子页面。

  • records为0x14,说明页面内有20条记录。因为每行记录比较长,页面内只存了20条记录。

  • Prev为Page 06,Next为Page 08,分别指向当前页面的前后相邻的页面。

  • 每行数据的字段依次为(ID,DB_TRX_ID,DB_ROLL_PTR,A,PADDING),和我们从数据字典中查到的一样。

如果把整个聚簇索引画出来,大概就是下面这个图。

二级索引结构

我们再来看一下二级索引的物理结构。先从根页面开始。

ROOT

我们已经从数据字典中查到,索引idx_a的根页面为Page 05。下图就是Page 05的内容。

图片

Branch

下图展示了第一个分支页面Page C9的内容。

图片

  • prev为空,next指向Page CA。

  • records为0x26,说明页面内有38条记录。

  • level为1。

  • 索引记录由字段A、ID和指向叶子页面的编号组成。图中显示了两条索引记录,分别是(KEY-10000,PK-10000,Page 0B)和(KEY-10038,PK-10038,Page 0C)。

Leaf

叶子页面Page 0B的内容展示在下面这个图中。

图片

  • Prev为null,next指向页面Page 0C。

  • records为0x26,页面内有38条记录。

  • level为0,说明是叶子页面。

  • 图中有两条索引记录,分别是(KEY-10000,PK-10000)和(KEY-10001,PK-10001)。

段空间管理

InnoDB每个索引都由2个段(Segment)组成,在索引的根页面中,记录了这2个段的段描述符的地址。测试表t_btree的两个索引的段描述符地址我整理到了下面这个表格中。

图片

段描述符(INODE)

InnoDB使用段来管理空间,段描述符(inode)记录了段的相关信息,inode中的信息我整理到了下面这个表格中。图片

下面这个图里面,展示了聚簇索引的2个段的描述符。

图片

  • 聚簇索引跳转页面段的ID为3,段内总共有5个页面(包括根页面),编号分别是04、C6、C7、C8、CB。 这和我们在聚簇索引根页面中看到的一致。

  • 聚簇索引叶子页面段ID为4。Not Full链表中有一个区(Extent),使用了15个页面。Full链表中有7个区(Extent)。

数据文件中的区(Extent)可分为两种类型,一种是常规的区,常规的区最多只能分配给1个段使用。还有一种称为碎片区,碎片区的页面可以分配给不同的段使用。

为什么要这么设计呢?因为对于小表,记录数很少,如果给这些表分配一个完整的区(1M),就会浪费空间。另外,对于一些特殊类型的段,比如回滚段,对于小事务而言,每个回滚段需要的空间很小,如果分配一个完整的区,会浪费大量空间。

给段分配空间时,先会从碎片区中分配页面,这些页称为碎片页,页面编号会记录到INODE的碎片页数组中。只有分配给一个段的碎片页数量达到32之后,才会分配完整的区。

每个段里面,所有的区组成了3个链表。

  • Free链表:这个链表中的区是完全空闲的。

  • Not Full链表:这个链表中的区,部分页面已经分配,其他页面是空闲的。

  • Full链表:这个链表中的区,所有的页面都已经分配。

数据文件中的链表结构

InnoDB的数据文件中存在大量双向链表结构,这些链表结构上是一样的,都由链表基节点和链表节点组成。链表基节占用16字节,由下面这3个字段组成。

图片

链表节点占用12个字节,由下面这2个字段组成。

图片

下图是InnoDB物理文件中双向链表的一个示意图,基节点中记录了链表头部节点和尾部节点,所有节点通过prev和next指针连接在一起。

图片

我们来看一下测试表t_btree聚簇索引Leaf段中的几个链表。

图片

我们来看一下FULL链表在物理文件中的存储方式,下面这个图中,标注了Full链表中的区描述符。

图片

  • segment id为4。

  • prev和next指向链表中相邻的区。

  • status是区的状态。

  • bitmap中记录了区中每一个页面的状态,一个区有64个页面。

关于区描述符的更多信息,后面我还会详细介绍。

将Full链表中的所有区都整理出来,可以得到下面这个图。

表空间管理

最后,我们来看一下InnoDB是怎么管理空间的。InnoDB中,表和索引数据都存储在表空间中。从系统表information_schema.innodb_tablespaces可以查看当前数据库中的所有表空间。

mysql> select space, name, row_format, space_type
       from information_schema.innodb_tablespaces
       order by space;
+------------+---------------------+----------------------+------------+
| space      | name                | row_format           | space_type |
+------------+---------------------+----------------------+------------+
|          1 | sys/sys_config      | Dynamic              | Single     |
|          3 | hello/test          | Dynamic              | Single     |
| 4294967277 | undo_x001           | Undo                 | Undo       |
| 4294967278 | innodb_undo_002     | Undo                 | Undo       |
| 4294967279 | innodb_undo_001     | Undo                 | Undo       |
| 4294967293 | innodb_temporary    | Compact or Redundant | System     |
| 4294967294 | mysql               | Any                  | General    |
+------------+---------------------+----------------------+------------+

表空间分为几种不同的类型,我整理成了下面这个表格。

图片

InnoDB数据文件格式

InnoDB中,除了系统表空间,其它每个表空间都是由1个数据文件组成。

数据文件有固定的格式,每个数据文件被分割为固定大小的页(Page),相邻的页组成区(Extent)。表和索引的数据通过段(Segment)来组织,每个段由一些页和区组成。

Page

InnoDB以Page为最小单位来分配空间,每个Page的大小由参数innodb_page_size指定。该参数只能在数据库初始化之前配置,数据库初始化完成后,不能再修改。InnoDB Page大小可设置为4K、8K、16K、32K或64K,默认为16K。后续我们以16K的Page大小来分析。

图片

Extent

InnoDB将相邻的页面组成一个区(Extent),每个区的大小固定,具体大小和页的大小有关。对于16K的页面大小,一个区由64个页面组成,大小为1M。

Segment

InnoDB中每一个索引(包括聚簇索引)都由2个段(Segment)组成。索引中所有的叶子页面组成一个段,其他页面组成一个段。每个段由页面(Page)和区(Extent)组成。

数据文件的第一个页面称为文件头(FSP Header),里面存储了整个表空间的相关信息。文件头的具体存储格式,可以参考下面这个图和表格。

图片

图片

表空间文件分为3个区域,FSP Header中的highest page initialized和highest page字段分别记录已初始化空间和未初始化空间的最大页面编号。

图片

已初始化空间区域内的区(Extent),要么加入到FSP Header头部的3个链表中,要么已经分配给某个段(Segment)。未初始化空间区域内的区还没有被格式化,也就是这些区还没有加入到任何链表中。当需要分配空间时,可以将这个区域内的区加入到FSP Header中的空闲列表中,然后再分配给某个表或索引。如果数据文件内的区都已经被使用,分配空间时就需要先扩充文件的长度。

FSP Header维护了几个重要的链表。

  • FREE链表

位于FREE链表中的区完全空闲。当需要分配一个完整的区时,从FREE链表中获取。如果FREE链表中没有可分配的区,则需要将未初始化空间中的区加入进来,或者扩展文件后再将区加入进来。

  • FREE_FRAG链表

InnoDB将区(Extent)分成两种不同的类型:普通的区和碎片区。普通的区是指分配时分配空间时,将整个区分配给某个段。而碎片区会以页为单位,每次只将1个页分配给某个段。这么设计的原因,前面已经讲过了,主要是为了节省空间。

分配碎片页时,先从FREE_FRAG列表中获取一个碎片区,然后在碎片区中获取一个空闲的页面进行分配。如果碎片区中的所有页都已经分配完,则会将该碎片区移到FULL_FRAG链表中。如果FREE_FRAG链表中没有碎片区可用,则需要从FREE链表中获取一个空闲的区加入进来。

  • FULL_FRAG链表

FREE_FRAG链表中的碎片区,当页面都已经分配后,就会移到FULL_FRAG链表中。FULL_FRAG链表中的区,如果有页被释放,则会移到FREE_FRAG链表中。

  • FREE_INODES链表

INODE用来跟踪段的空间使用情况。每一个段都有一个对应的INODE结构。InnoDB使用专门的页面来存储INODE信息,而FREE_INODES链表将还有空闲INODE的页面链接在一起。InnoDB创建段的时候,需要从FREE_INODES列表中找到一个Inode页面,然后从页面中找到一个未被使用的INODE。

如果FREE_INODES列表中没有可用的页面,则需要重新分配一个空闲的页面,将页面格式化后加入到链表中。如果一个Inode页面中的Inode都已经被分配,则需要将该Inode页面移到FULL_INODES链表中。

  • FULL_INODES链表

FULL_INODES链表用来跟踪已经完全被使用的INODE页面。

区描述符(Extent Descriptor,XDES)

区描述符记录了区的使用情况。数据文件中每一个区都有一个对应的区描述符(XDES)。每一个区描述符占用40字节,具体信息可以参考下面这个表格。

图片

FSP HEADER中,存储了256个区描述符,可以管理256M空间。如果数据文件超过256M,则每256M的第一个页面都会用来存储区描述符。

图片

数据文件中,每256M空间开头的第一个区有点特殊,因为这些区的第一个页面总是用来存放区描述符,第二个页面总是用来存放ibuf位图信息,因此这些区都是碎片区。

我把测试表的第一个页面放在了下面这个图中,对一些重要的字段做了标注。

图片

  • type为8,也就是FIL_PAGE_TYPE_FSP_HDR。

  • highest page为0x04C0,转换字节数是0x4C0 * 16K = 19M,刚好是ibd文件的大小。

  • highest page initialized为0x0380。

  • free list、free frag list、full frag list分别是三个链表。

  • full inode list是空的。

  • free inode list中有一个节点,指向(页面Page 2,偏移0x0026)。

Page 2是一个Inode页面,页面内容参考下面这个图。

图片

  • type为3,也就是FIL_PAGE_INODE

  • prev和next分别指向相邻的Inode页面,这里只有一个inode页面,所以prev和next都为空。

  • 图片中还展示了3个段描述符,segment 1中只有一个页面,segment 2也是一个特殊的段,segment 3属于聚簇索引。

总结

这一讲中,我们通过一个具体的例子,解析了InnoDB聚簇索引和二级索引的存储格式,你可以看到,我们之前关于InnoDB表和索引结构的描述,都是有依据的。

我们还看到了InnoDB管理段和空间的一些数据结构,包括段描述符、区描述符、Inode页面。InnoDB数据文件中,藏着很多链表结构,有单向链表,还有双向链表,后续的课程中,还有更多的链表结构等我们去发现。

思考题

我们知道,在B+树中检索数据时,要先从根页面开始,数据字典表mysql.indexes记录了每个索引的根页面。

但是indexes本身也是一个InnoDB表,因此从indexes表检索数据时,需要先知道这个表的根页面编号,那么MySQL怎么知道indexes表的根页面编号是什么呢?

期待你的思考,欢迎在留言区中与我交流。如果今天的课程让你有所收获,也欢迎转发给有需要的朋友。我们下节课再见!