简单学习一下ibd数据文件解析
简单学习一下数据文件解析
这是尝试使用Golang语言简单解析MySQL 8.0的数据文件(*.ibd)过程的一个简单介绍,解析是反序列化的一个过程,或者叫解码的过程。
1 为什么要解析
虽然有很多开源的代码已经实现了这个解码过程,例如使用C实现的undrop-for-innodb[1],支持到MySQL 5.7版本,后续未作更新。
姜老师的py_innodb_page_info.py也可以对数据文件进行分析(分析页的类型),对MySQL 8.0版本的兼容好像还没来得及做。
还有Ali使用Java实现了数据文件的解析[2],并且一定程度兼容MySQL 8.0版本。但是自己通过解析数据文件过程对数据文件编码/解码、恢复已删除数据等进行深入学习。
解析过程枯燥乏味,有种按图索骥的感觉,即通过文档或者源码描述的页结构,建立对应结构体来解析,难度可能并不复杂,通过对存储建立更为立体全面的认识,补齐MySQL知识拼图很有意义。
2 使用Golang解析数据文件
选择使用Golang来实现这个过程,则是因为其比C++简单的多,易上手,不用过多关注垃圾回收问题,代码简洁,有丰富的软件包以供引用,不需费力自己实现。
当然还有更多有吸引力的特性,例如并发性(goroutine)与通道(channel),对gRPC与Protocol Buffers一流的支持等,这些优秀的特性在本文解析MySQL数据文件时并没有用到,则无需赘言。
3 文件
3.1 linux文件
文件是对I/O设备的一个抽象概念,比如我们不需要了解磁盘技术就可以处理磁盘文件内容,就是通过操作文件这个抽象来进行的。
在CSAPP(ComputerSystem: A programer perspective)中文版第三版中,对文件有过精辟的定义:文件就是字节序列,仅此而已。那么对MySQL数据文件的解析,可视为对字节序列的解析,解析的过程由此可以简单地视为对编码规则的逆向过程,即解码。
3.2 MySQL 8.0数据文件
MySQL在8.0版本中对数据字典做了重新调整,元数据统一存储到InnoDB数据字典表。不再在Server层中冗余表结构信息(不再存储在ibdata中),也不再在frm文件中存储。
不仅将元数据信息存储在数据字典表中,同时也冗余了一份在SDI(Serialized Dictionary Information)中,而对于InnoDB,SDI数据则直接存储在ibd数据文件中,文件中有SDI类型页来进行存储。因为元数据信息均存储在InnoDB引擎表中,支持crash-safe,结合一套DDL_LOG机制,以此实现了DDL原子性。
在文件的开头(并不是非常靠前的位置,大概是第四个数据页,page number=3,这在之前的版本中是所谓的根页:root page)冗余表结构信息,非常类似于Avro的编码格式,即在包含几百万记录的大文件开头包含模式(描述数据的组成,包括元素组成,数据类型等)信息,后续每个记录则不再冗余这部分信息,以达到节省存储、传输成本目的。
而在解析数据时,模式是必要的,需要首先获取表结构信息,以便于后续数据解码过程。所以在这次解析数据文件时,首先需要获取数据文件中SDI页中的表结构(模式)。
为简化解析过程,默认数据库是设置了UTF-8字符集,即数据文件是以UTF-8进行编码的。并且数据库启用了innodb_file_per_table参数,表被存储在他们自己的表空间里(*.ibd)。
4 解析文件
解析并不是非常复杂的过程。就程序角度而言首先是打开文件,然后是解析文件,最后是关闭文件。
但是过程很有可能产生其他未知的问题,例如不同的编码格式,复杂的数据类型等,只是在本文解析中选择了回避而已。
Golang提供了os.OpenFile()函数来打开文件(建议以只读模式打开),并提供了file.Close()函数来关闭文件,留下了解析过程来自己实现。
4.1 获取表结构
像JSON或者XML这种自我描述的数据,其自我描述部分比较冗余,在大文件中,冗余过多信息很不明智。而在MySQL的数据文件中,数据增长是个显著问题,自我描述的部分需要精简,不能每写入一条数据,就要跟随写入表名、列名。
所以MySQL仅在文件开头保存了表结构信息(在更早的版本,表结构信息则是存储在frm文件中,并在数据字典中冗余)。
这种方式的编码可以很节省存储空间,但解析打开数据文件,输出的是字节序列,显然是人类不可读的,需要找到模式(表结构信息)来解读成人类可读,才达到解析的目的。
如果可以通过数据库,很容易获得表结构信息,但如果仅有一个数据文件,获取表结果信息只能从SDI中解析获取,过程比较麻烦。
幸运的是,MySQL官方提供了专有工具:ibd2sdi。通过ibd2sdi默认获取的是所有的列和索引信息,是以JSON格式输出的。
4.2 结构体和切片
MySQL的数据文件中是以页(默认大小为16k)为单位存储,索引可以利用B-tree的查找特性快速的定位到页,在页内的数据则是通过非常类似于二分查找的方式来定位。
在解析数据文件时,也是以页为单位,InnoDB中大概有31种类型页,在源码storage/innobase/include/fil0fil.h中可以看到相关定义。
真正存储用户数据的是FIL_PAGE_INDEX(数字编码是17855,解析到page_type=17855,代表这是一个索引页)类型的页,这不代表其他类型的页没有用处,在描述文件中页的使用的情况(FIL_PAGE_TYPE_FSP_HDR),空页分配(FIL_PAGE_TYPE_ALLOCATED),insert buffer(FIL_PAGE_IBUF_FREE_LIST),压缩页(FIL_PAGE_COMPRESSED)等,需要各种类型页进行存储不同的信息。
而不同的页,在解析过程中,则使用Golang的结构体(struct)来进行抽象表示,这类似于Java的类,是对具体事务的抽象,也非常类似于C语言的结构体。解析不同的数据页,则使用相应的结构体,为了简化解析过程(毕竟是学习过程),只对大概六种页进行了解析。
不光是页在解析过程中使用结构体,在页中的一些部分也是用了结构体来进行抽象,例如每个数据页的开头会有38个字节的File Header[3],在解析过程中,也是通过结构体进行解析的。
第3节中说文件是字节序列,InnoDB数据文件通常很大,整体打开后则是一个超长的字节数组,并不方便进行整体解析,或者数据文件远超内存大小,全部载入内存不太可能,需要对其逐步读取,或者说叫做进行切割,例如按照页的默认大小进行切割,即将数据文件切割成若干个16k大小的片段(数据文件必然是页大小的整数倍)。
在Golang中,这种数组片段的数据结构叫做切片,即slice。这让人想到早餐面包,一整块面包并不方便抹上果酱,那么可以切成面包片,后续就可以很好的处理了。在解析部分的代码中,会经常的使用结构体和切片这两种数据结构进行解析字节序列。
4.3 页
4.3.1 页结构
在数据文件中,页是以16K大小,即16384 Byte(这是一个默认大小,由innodb_page_size参数控制)的字节序列存储的,一个数据文件包含很多个数据页。在内存中,则以B-tree形式来组织,B-tree的每一个节点,就是一个页,非叶子节点通过指针指向下一层节点(页)。
当事务在记录上加逻辑锁时,在B-tree上则是添加的栓锁(latch)来防止页(在页分裂和收缩时,还可能包含页的父节点)被其他线程修改。标准数据页(INDEX page)包含七个部分,组成结构如下列表格所示[4]。
Name | Size | Remarks |
---|---|---|
File Header | 38 | 页的一些通用信息 |
Page Header | 56 | 不同数据页专有的一些信息 |
Infimum/Supremum | 26 | 两个虚拟的行记录,即最大值最小值 |
User Records | 实际存储的行记录内容 | |
Free Space | 页中尚未使用的空间 | |
Page Directory | 页中的某些记录的相对位置(slot) | |
File Trailer | 8 | 校验信息,4byte的checksum,4byte的Low32lsn |
其中,38个字节长度的File Header在几乎所有类型的页中都存在。其结构包含八个部分,组成结构如下:
Name | Size | Remarks |
---|---|---|
FIL_PAGE_SPACE | 4 | ID of the space the page |
FIL_PAGE_OFFSET | 4 | ordinal page number from start of space |
FIL_PAGE_PREV | 4 | offset of previous page in key order |
FIL_PAGE_NEXT | 4 | offset of next page in key order |
FIL_PAGE_LSN | 8 | log serial number of page's latest log record |
FIL_PAGE_TYPE | 2 | current defined page type |
FIL_PAGE_FILE_FLUSH_LSN | 8 | log serial number |
FIL_PAGE_ARCH_LOG_NO | 4 | archived log file number |
而56个字节长度的Page Header包含14个部分,组成结构如下:
Name | Size | Remarks |
---|---|---|
PAGE_N_DIR_SLOTS | 2 | number of directory slots |
PAGE_HEAP_TOP | 2 | record pointer to first record in heap |
PAGE_N_HEAP | 2 | number of heap records; initial value = 2 |
PAGE_FREE | 2 | record pointer to first free record |
PAGE_GARBAGE | 2 | number of bytes in deleted records |
PAGE_LAST_INSERT | 2 | record pointer to the last inserted record |
PAGE_DIRECTION | 2 | either PAGE_LEFT, PAGE_RIGHT, or PAGE_NO_DIRECTION |
PAGE_N_DIRECTION | 2 | number of consecutive inserts in the same direction |
PAGE_N_RECS | 2 | number of user records |
PAGE_MAX_TRX_ID | 8 | the highest ID of a transaction |
PAGE_LEVEL | 2 | level within the index (0 for a leaf page) |
PAGE_INDEX_ID | 8 | identifier of the index the page belongs to |
PAGE_BTR_SEG_LEAF | 10 | file segment header for the leaf pages in a B-tree |
PAGE_BTR_SEG_TOP | 10 | file segment header for the non-leaf pages in a B-tree |
以上是对INDEX类型的数据页结构的描述,不再赘述其他类型页。像页中的file hader、page header部分或者其他类型的页,在代码中都会有相应的结构体来描述其数据结构。
更多关于数据页结构信息除了参考官网,还可以参考Jeremy Cole在GitHub上开的一个项目,InnoDB Diagrams[5]。InnoDB Diagrams可能更多的是基于MySQL 5.7版本,但是仍有很好的借鉴意义。
4.3.2 页类型
上文说到InnoDB中大概有30余种类型页,这里只解析了其中6种,主要目的则是解析FIL_PAGE_INDEX中的数据,其余类型页的解析则互有侧重。
- FIL_PAGE_TYPE_FSP_HDR用于分配、管理extent和page,包含了表空间当前使用的page数量,分配的page数量,并且存储了256个XDES Entry(Extent),维护了256M大小的Extent,如果用完256个区,则会追加生成FIL_PAGE_TYPE_XDES类型的页
- FIL_PAGE_IBUF_BITMAP用于跟踪随后的每个page的change buffer信息,使用4个bit来描述每个page的change buffer信息
- FIL_PAGE_INODE管理数据文件中的segement,每个索引占用2个segment,分别用于管理叶子节点和非叶子节点。每个inode页可以存储FSP_SEG_INODES_PER_PAGE(默认为85)个记录(Inode Entry(Segment))。FIL_PAGE_INODE管理段,而段信息管理Extent(区)信息
- FIL_PAGE_SDI存储Serialized Dictionary Information(SDI, 词典序列化信息), 存储了这个表空间的一些数据字典(Data Dictionary)信息
- FIL_PAGE_INDEX在内存中构造B-tree所需要的数据就存放在这个类型的页中,B-tree的每一个叶子节点就指向了这样的一个页。结构在各处都有详细介绍,其中的File Header,Page Header部分在上文有介绍。
- FIL_PAGE_TYPE_ALLOCATED已经分配但还未使用的页,即空页
4.4 解码
4.4.1 记录解析
编码和解码经常组合出现,但因为我们已经默认拿到了数据文件(完成了编码),不再介绍编码过程。
在本文中的解码,是将字节序列解码为人类可读的字符串。也就是解码为数据在存入数据库时,业务写入时的样子。
通常编程语言会提供encode和decode方法进行编码和解码操作,在这次解析中,是参考MySQL源码后自实现的解码过程。
实际上是按存储数据的字节长度不同,对一个字节长度的数据进行位运算左移(左移位数根据字节长度确定,左移n位就是乘以2的n次方),然后每个字节进行或计算。
这里贴源码来,下列代码是unittest\gunit\innodb\lob\mach0data.h中的一段,是对4个连续字节的数据写入和读取的过程,即mach_write_to_4()函数和mach_read_from_4()函数,可以看到位运算的移位操作。
注意:源码在读取字节数据时,将数据转换为ulint类型,这个类型为无符号类型。
inline void mach_write_to_4(byte *b, ulint n) { b[0] = (byte)(n >> 24); b[1] = (byte)(n >> 16); b[2] = (byte)(n >> 8); b[3] = (byte)n;}/** The following function is used to fetch data from 4 consecutivebytes. The most significant byte is at the lowest address.@param[in] b pointer to four bytes.@return ulint integer */inline ulint mach_read_from_4(const byte *b) { return (((ulint)(b[0]) << 24) | ((ulint)(b[1]) << 16) | ((ulint)(b[2]) << 8) | (ulint)(b[3]));}