简单学习一下ibd数据文件解析
来源:原创投稿
作者:花家舍
简介:数据库技术爱好者。
- GreatSQL社区原创内容未经授权不得随意使用,转载请联系小编并注明来源。
简单学习一下数据文件解析
这是尝试使用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 consecutive
bytes. 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]));
}
在写入时对每个字节进行了右移,在读取时则需要左移,这里涉及到了大小端的内容,有兴趣可以选择深入研究,不再过多介绍。
4.4.2 行记录格式
上一小节是对具体记录(对应数据库中列的具体值)的字节解析过程介绍。
那么如何定位到记录(在Index数据页中的User RECORD部分中定位),除了定位到记录数据的页,还受到参数innodb_default_row_format的设置的影响。
MySQL数据记录是以行的形式存储的的,而行记录的格式在MySQL 5.7.9及以后版本,默认值是DYNAMIC。
而在MySQL 5.6之前的版本,这个参数默认值还是COMPACT。innodb通过fsp页(数据文件的的第一个页,类型是FIL_PAGE_TYPE_FSP_HDR)的flags(长度4 byte)的不同bit位来判断表空间的各种属性,比如是否压缩,是否加密,是否为共享表空间、是否是临时表空间等等。
其中决定行格式类型是flags结构中的FSP_FLAGS_WIDTH_ATOMIC_BLOBS属性,在flags长度4 byte的32个bit位置中是第6位。
为减少程序复杂度,这里默认行格式为DYNAMIC(有兴趣可自行深入了解,详细可参考源码:storage\innobase\include\fsp0types.h)。
COMPACT行格式
记录额外数据 | ... | ... | 记录的数据内容 | ... | ... | ... |
---|---|---|---|---|---|---|
变长字段的长度列表 | NULL值标志位 | 记录头信息 | col1 Data | col2 Data | ... | coln Data |
在记录头信息中,使用5个字节长度来标记,其中有一个delete_mask标记位用来标记当前记录是否已经被删除,更多更详细的行记录格式解释可以参考阿里内核月报:
https://www.bookstack.cn/read/aliyun-rds-core/24bcb047feb68f13.md
每行记录都按照以上格式存储数据。
DYNAMIC与COMPACT比较类似,不同处在于,对待处理行溢出的处理及策略,Dynamic、Compressed行格式会把记录中数据量过大的字段值全部存储到溢出页中,在原页中保留20字节长度的指针,而不会在该记录的数据内容的相应字段处存储该字段值的768个字节长度的前缀数据了。
在这次解析中不会涉及BLOB等大字段类型,所以解析其实是按照COMPACT格式解析的。
4.5 编写代码
代码水平有限,介绍代码水平更有限,文末会有代码地址,可供详细参考。这里捡重点的简明扼要介绍一下。
4.5.1 打开文件
建议使用只读模式打开文件,对文件进行简单的校验。对文件可对数据页中的checksum值进行校验,这需要按页读取时进行校验,这里偷懒只对文件大小进行了简单校验。打开文件:
file, err := os.OpenFile(filePath, os.O_RDONLY, os.ModePerm)
4.5.2 按页读取
数据文件既然是字节序列,那么其实每次可以读取任意多个字节到内存(如果内存允许),但事实上MySQL数据文件是按页来管理,那么读入内存则以页为单位读取。
在MySQL实例运行时,大部分时候,BTREE的根页(root page)以及第二层甚至第三层的页都是常驻内存的,在主键使用bigint类型时,则非叶子节点中中每个目录项长度是主键8字节长度+指针6字节长度,每个页可存放16384/(8+6)≈1170个目录项,即每个节点的扇出为1170。
那么,BTREE的前几层大小:
第0层:16,384 byte
第1层:16384 * 1170 = 19,169,289 byte
第2层:16384 * 1170 * 1170 = 22,428,057,600 byte
可见前三层的数据大概在20GB左右,将其全部放入内存压力不大,前两层数据甚至只有几十MB而已,BTREE的高度达到4层就可以存放千万条数据。
那么真正需要磁盘读取的数据更可能集中在第2层或以上,因为内存的中页面读取遍历很快,减少磁盘操作,所以效率很高。
在这里解析时,则是按序来读取页,当然可以并发读取,那么解析输出的数据顺序则无法保证了。
// 每读取一页,参数j增加16384,来标记文件的读取位置
now, err := file.Seek(j, io.SeekStart)
// mysql_def.UNIV_PAGE_SIZE=16384
b := make([]byte, mysql_def.UNIV_PAGE_SIZE)
// 读取文件,每读取一页,将其放入数组中存放,Golang读取[]文件会默认将其存储在无符号字节数组中:[]uint8,而Java读取文件会放入有符号数组中:int8[]
n, err := file.Read(b)
4.5.3 构造切片
读取的数据放入数组中,在Golang中,切片是根据动态数组来构建,解析位置会随解析进度变化,据此构造了一个struct:
type Input struct {
Data []byte
Position int64
}
var IN Input
Input结构体的元素Data用于存放读取文件的字节序列,长度固定为16384。
元素Position则标记解析位置,随解析递增。这样在读取文件后,就可以将字节序列传递给结构体Input的声明变量IN了。
// 每页解析从第0为开始
slice.IN.Position = 0
// 存放数据页字节序列的数组传递给结构体Data元素
slice.IN.Data = b
4.5.4 构造页结构体并解析数据
MySQL数据文件包含不同类型的页,页中包含不同的部分,例如File Header,File Trailer,Page Header等。
对每一种结构都建立与之对应的结构体,这有点类似于Java的面向对象思想。为的是后面解析数据到相同的结构位置时,代码可抽象重用。
这里以File Header为例,在4.3.1节中介绍了它的组成,包含8个部分共38字节长度,对应的结构体如下:
type FilHeader struct {
PageSpace int64
PageOffset int64
PagePrev int64
PageNext int64
PageLsn int64
PageType string
PageFileFlushLsn int64
PageArchLogNo int64
}
因为数据页中每个页在页头都包含File Header,所以对其38个字节长度的解析,总是从第0字节开始到第37位置结束。FileHeader的解析过程如下:
func FileHeaderReadSlice() FilHeader {
// 声明FilHeader结构体变量
var fh FilHeader
fh.PageSpace = slice.ReadUnsignedInt()
fh.PageOffset = slice.ReadUnsignedInt()
fh.PagePrev = slice.IfUndefined(slice.ReadUnsignedInt())
fh.PageNext = slice.IfUndefined(slice.ReadUnsignedInt())
fh.PageLsn = slice.ReadLongInt64()
fh.PageType = mysql_def.ParsePageType(slice.ReadShortInt16())
fh.PageFileFlushLsn = slice.ReadLongInt64()
fh.PageArchLogNo = slice.ReadUnsignedInt()
return fh
}
代码中的
fh.PageSpace = slice.ReadUnsignedInt()
是在字节序列中读取四个字节长度并解析后赋值给PageSpace(page的space ID),解析后把位置递增4(int存放长度为4字节)。
解析过程是借鉴MySQL源码中的过程,在4.4.1节中有过介绍。例如4字节Int解析过程如下:
func decodeInt32(in Input) int64 {
data := in.Data
index := in.Position
checkPositionIndexes(index, index+mysql_def.SIZE_OF_INT)
return (int64(int8(data[index+3])) & 0xff) | (int64(int8(data[index+2]))&0xff)<<8 | (int64(int8(data[index+1]))&0xff)<<16 | (int64(int8(data[index]))&0xff)<<24
}
按照这种顺序,对File Header的八个部分解析后,获取必要信息(例如PageType,页类型),后续的解析就是根据根据页类型来调用不同结构体的解析来完成全部数据解析。
所不同者就是解析不同字节长度差别,而不同页结构或页中结构长度在MySQL文档或者源码中已经表明,尤其是索引页中的User record,受到row_format参数控制。文章篇幅限制,只介绍了File Header解析流程。
代码完成对页类型的解析,对索引页中中的记录解析还在进行,有兴趣可以继续关注。
5.补充
不可否认,解析过程对很多地方进行了简化,数据页只解析了有限的几种,编码方式只限定了UTF-8,数据类型更是只选择了int和varchar,对复杂的数据类型没有涉及。
对文件也没有进行校验,只是默认了我们解析的是一个正常的数据文件。这是一个简略的解析代码,后续会继续完善。
另外,在解析过程中,没有使用并发,后续可以考虑引入并发来加快解析过程。例如数据文件进行切片后,是按顺序解析的,这里可用Golang的协程来加快解析,不同的页可以调度到不同的协程进行解析。
也许数据文件中大部分都是索引页,这样按页类型并发度不够,这时可以引入线程池,调度不同数量的索引页在不同的线程上进行解析。
另外,后续有增加解析已删除数据的代码。数据被错误的删除,如果使用备份+binlog进行恢复,耗时费力。
迫切需要一种类似于Oracle的闪回工具来对数据进行快速闪回,恢复到数据被修改前的状态。
在现有的几种日志中,在binlog中的更新event中,因为记录了更新前后的数据,使得解析binlog event并反转操作成为可能,基于binlog提供的这项特性,开源社区实现了闪回工具,例如Python实现的binlog2sql和美团DBA团队开发的MyFalsh,可以闪回DML以及部分DDL。
除了binlog以外,还有redo记录了数据更新日志,但因为redo记录的属于change vector(更新向量,即对page的更改,不记录更新前数据状态),且可能包含未提交事务。另外一种日志undo因为page cleaner线程的存在,会被不定期清除,因此只借助redo实现闪回则不太可能。
恢复delete的数据,不妨借助数据文件在删除数据时的mark机制来进行恢复,缺点是需要在数据被错误删除时,需要立即进行锁表,防止进一步的数据操作重用mark为delete的空间被重用。
6.后记
代码地址:https://gitee.com/huajiashe_byte/ibdreader
希望通过抛砖引玉,促进知识交流,共同提升对数据库文件存储的认识。水平所限,难免会出现纰漏和错误,欢迎指正。
注释
注1:undrop-for-innodb:
https://github.com/twindb/undrop-for-innodb
Ali有专文对这个工具进行介绍:
https://www.bookstack.cn/read/aliyun-rds-core/2dd363c8cc64171b.md
注2:
https://github.com/alibaba/innodb-java-reader
注3:参考:
https://dev.mysql.com/doc/internals/en/innodb-fil-header.html
注4:这部分的详细介绍可参见官方网址:
https://dev.mysql.com/doc/internals/en/innodb-page-overview.html
注5:地址是:
https://github.com/jeremycole/innodb_diagrams
Enjoy GreatSQL 😃
本文由博客一文多发平台 OpenWrite 发布!