起源:原创投稿

作者:花家舍

简介:数据库技术爱好者。

  • 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]。

NameSizeRemarks
File Header38页的一些通用信息
Page Header56不同数据页专有的一些信息
Infimum/Supremum26两个虚构的行记录,即最大值最小值
User Records理论存储的行记录内容
Free Space页中尚未应用的空间
Page Directory页中的某些记录的绝对地位(slot)
File Trailer8校验信息,4byte的checksum,4byte的Low32lsn

其中,38个字节长度的File Header在简直所有类型的页中都存在。其构造蕴含八个局部,组成构造如下:

NameSizeRemarks
FIL_PAGE_SPACE4ID of the space the page
FIL_PAGE_OFFSET4ordinal page number from start of space
FIL_PAGE_PREV4offset of previous page in key order
FIL_PAGE_NEXT4offset of next page in key order
FIL_PAGE_LSN8log serial number of page's latest log record
FIL_PAGE_TYPE2current defined page type
FIL_PAGE_FILE_FLUSH_LSN8log serial number
FIL_PAGE_ARCH_LOG_NO4archived log file number

而56个字节长度的Page Header蕴含14个局部,组成构造如下:

NameSizeRemarks
PAGE_N_DIR_SLOTS2number of directory slots
PAGE_HEAP_TOP2record pointer to first record in heap
PAGE_N_HEAP2number of heap records; initial value = 2
PAGE_FREE2record pointer to first free record
PAGE_GARBAGE2number of bytes in deleted records
PAGE_LAST_INSERT2record pointer to the last inserted record
PAGE_DIRECTION2either PAGE_LEFT, PAGE_RIGHT, or PAGE_NO_DIRECTION
PAGE_N_DIRECTION2number of consecutive inserts in the same direction
PAGE_N_RECS2number of user records
PAGE_MAX_TRX_ID8the highest ID of a transaction
PAGE_LEVEL2level within the index (0 for a leaf page)
PAGE_INDEX_ID8identifier of the index the page belongs to
PAGE_BTR_SEG_LEAF10file segment header for the leaf pages in a B-tree
PAGE_BTR_SEG_TOP10file 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]));}

在写入时对每个字节进行了右移,在读取时则须要左移,这里波及到了大小端的内容,有趣味能够抉择深入研究,不再过多介绍。

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 Datacol2 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=16384b := 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/in...
`

注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 公布!