百度360必应搜狗淘宝本站头条
当前位置:网站首页 > IT技术 > 正文

一种探究 InnoDB 的存储格式的新方式

wptr33 2025-02-11 13:47 31 浏览

文件的存储结构包含了系统大量的实现细节,比如 java 的 class 文件结构,rocksdb 的存储结构。MySQL InnoDB 的存储格式比较复杂,但确实我们理解 MySQL 技术内幕不必可少的一环。传统的分析的方法有下面这两个

  • jeremycole 的 innodb_ruby
  • 阿里开源的 Java 版本 innodb-java-reader

但这两个版本都不那么直观,我还是想原滋原味的通过分析 .ibd 文件来实现,以求达到两个目的:可视化、模板化。于是我想到了宇宙最强的二进制分析工具 010 Editor。

010 Editor 介绍

010 Editor 是一款商业软件,是二进制分析中十分强力的工具,能够解析多种文件格式并以可视化界面呈现。它有一个强大的内部引擎使得任何人都可以定制所需的解析脚本或解析模板。在 010 Editor的官网上已经有仓库存放了大量的脚本和模板库供大家学习和使用,比如 .zip、.exe、.class 等格式。但 MySQL 的 .ibd 文件官方仓库没有模板,只能自己撸一个了。

接下来介绍的大部分例子来源于小孩子大佬的掘金小册,算是作为一个补充吧。

万事开头难

010 模板的基本语法和 C 语言类似,本质就是把整个文件的二进制数据当作输入,将其映射到一个我们自己定义的结构体,在其中,我们可以自己来处理内部的逻辑。

我们知道 MySQL 以页为基本单位来管理存储空间,一个页的大小一般是 16KB,因此 ibd 文件大小除以 16KB 就可以知道有多少页了,也可以用循环读取 16KB 大小的方式直到文件末尾。

新建一个 test_01.bt 文件,内容如下。

#define PAGE_SIZE (16 * 1024)

// 定义一个 Page 结构体,表示一页的大小,暂时不解析里面的内容,先占个位
typedef struct Page {
	byte content[PAGE_SIZE];
};

// 一直读取直到文件结束
while(!FEof()) {
	Page page; // 读取 16KB 的内容映射到 Page 结构体中
}
复制代码

也可以这么写

#define PAGE_SIZE (16 * 1024)

// 定义一个 Page 结构体,表示一页的大小,暂时不解析里面的内容,先占个位
typedef struct Page(int index) {
	byte content[PAGE_SIZE];
};

local int file_size = FileSize();
local int i;
for (i = 0; i < file_size / PAGE_SIZE; ++i) {
	Page page(i); // 读取 16KB 的内容映射到 Page 结构体中
}
复制代码

导入这个模板文件,就可以在界面中这个 ibd 文件有 6 个 Page 页,具体里面是什么,我们稍后再解析。

?

众所周不知,ibd 文件有很多种不同类型的 Page,但不同类型 Page 都以前 38 字节表示 File Header,描述了一些针对各种页都通用的一些信息,比如这个页的页面类型、页编号、它的上一个页、下一个页等。

它的组成结构如下。

名称

占用空间大小

描述

FIL_PAGE_SPACE_OR_CHKSUM

4字节

页的校验和(checksum值)

FIL_PAGE_OFFSET

4字节

页号

FIL_PAGE_PREV

4字节

上一个页的页号

FIL_PAGE_NEXT

4字节

下一个页的页号

FIL_PAGE_LSN

8字节

页面被最后修改时对应的日志序列位置(英文名是:Log Sequence Number)

FIL_PAGE_TYPE

2字节

该页的类型

FIL_PAGE_FILE_FLUSH_LSN

8字节

仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值

FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID

4字节

页属于哪个表空间

这样在 010 中我们就可以定义一个结构体来表示 File Header。

// 38 字节的 File Header,存储了页的一些通用信息
typedef struct FilHeader {
    u4 FIL_PAGE_SPACE_OR_CHKSUM;
    u4 FIL_PAGE_OFFSET;
    u4 FIL_PAGE_PREV;
    u4 FIL_PAGE_NEXT;
    u8 FIL_PAGE_LSN;
    PAGE_TYPE FIL_PAGE_TYPE;
    u8 FIL_PAGE_FILE_FLUSH_LSN;
    u4 FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID;
};
复制代码

其中 PAGE_TYPE 是一个枚举类,表示页面的类型,常见的页面类型如下。

类型名称

十六进制

描述

FIL_PAGE_TYPE_ALLOCATED

0

最新分配,还没使用

FIL_PAGE_UNDO_LOG

2

Undo日志页

FIL_PAGE_INODE

3

段信息节点

FIL_PAGE_IBUF_FREE_LIST

4

Insert Buffer空闲列表

FIL_PAGE_IBUF_BITMAP

5

Insert Buffer位图

FIL_PAGE_TYPE_SYS

6

系统页

FIL_PAGE_TYPE_TRX_SYS

7

事务系统数据

FIL_PAGE_TYPE_FSP_HDR

8

表空间头部信息

FIL_PAGE_TYPE_XDES

9

扩展描述页

FIL_PAGE_TYPE_BLOB

A

溢出页

FIL_PAGE_INDEX

45BF

索引页,也就是我们所说的数据页

针对不同的页面类型,我们需要做不同的解析。综合上面的内容,我们就可以写出新一版的模板脚本。

?

接下来我们重点解析 FIL_PAGE_INDEX 这种类型的页面,也就是我们所说的存数据的页。它的结构如下。

?

我们先来定义一个 PageHeader,如下所示。

// 56 字节的 Page Header
typedef struct PageHeader {
	u2 PAGE_N_DIR_SLOTS;
	u2 PAGE_HEAP_TOP;
	PAGE_FORMAT_ENUM PAGE_FORMAT : 1 ;
	u2 NUM_OF_HEAP_RECORDS : 15 ;
	u2 PAGE_FREE;
	u2 PAGE_GARBAGE;
	u2 PAGE_LAST_INSERT;
	u2 PAGE_DIRECTION;
	u2 PAGE_N_DIRECTION;
	u2 PAGE_N_RECS;
	u8 PAGE_MAX_TRX_ID;
	u2 PAGE_LEVEL;
	u8 PAGE_INDEX_ID;
	u1 PAGE_BTR_SEG_LEAF[10];
	u1 PAGE_BTR_SEG_TOP[10];
};
复制代码

紧随 Page Header 之后的是两个特殊的记录 Infimum + Supremum,这是两个虚拟的行记录,表示了该页中的最小记录和最大记录。

Infimum 和 Supremum 由两部分组成,5 字节的记录头和 8 字节的字符串。

?

用脚本定义如下所示。

// 记录类型枚举类
enum  RECORD_TYPE {
		 CONVENTIONAL	  = 0,
		 NODE_POINTER   = 1,
		 INFIMUM        = 2,
		 SUPREMUM       = 3
};

// 5 字节记录头
typedef struct   {
	u1                       : 2;
	u1 delete_mask           : 1;
	u1 min_rec_mask          : 1;
	u1 n_owned               : 4;
    u2 heap_no               : 13;
    RECORD_TYPE record_type  : 3;
    short next_record        : 16; 
} RecordHeader;

// 最大最小虚拟记录
typedef struct  {
    RecordHeader record_header; // 5 字节的记录头
    char text[8];               // 8 字节的单词
} InfimumSupremumRecord;
复制代码

运行脚本结果如下所示。

?

接下来我们就可以通过 infimum 和 supremum 遍历整个链表。

?

// 定义一个 Page 结构体
typedef struct Page(int index) {
	FilHeader file_header;  // 38 字节的 file header
	if (file_header.FIL_PAGE_TYPE == FIL_PAGE_INDEX) {
		PageHeader page_header;                // 56 字节的 page header
		InfimumSupremumRecord infimum_record;  // 13 字节的虚拟最小记录
		local int page_start_pos = index * PAGE_SIZE;
		local int infimum_record_pos = FTell() - page_start_pos - 8; // 获取 infimum 记录的起始地址
		InfimumSupremumRecord supremum_record; // 13 字节的虚拟最大记录
		local int supremum_record_pos = FTell() - page_start_pos - 8; // 获取 supremum 记录的起始地址
		// 第一条记录的相对位置
		local int next_rec_rel_pos = infimum_record_pos + infimum_record.record_header.next_record;

		// 遍历链表
		while(next_rec_rel_pos != supremum_record_pos) {
			FSeek(page_start_pos + next_rec_rel_pos - 5); // 往前走五个字节,读取记录头,便于获取下一条记录的相对位置
			RecordHeader record_header;
			next_rec_rel_pos += record_header.next_record;
		}
	}
	// 跳到 Page 页的尾部
	FSeek((index + 1) * PAGE_SIZE);
};
复制代码

至此,我们已经完成了一个最简单的 ibd 文件解析逻辑。剩下的逻辑,就是根据不同的表结构和记录格式做解析。

重温 Compact/Dynamic 行格式

Dynamic 是 MySQL 默认的行格式,它与 Compact 基本类似,组成结构如下。

?

我们用小册子中的例子来做实验。

建表和初始化语句如下。

CREATE TABLE record_format_demo (
        c1 VARCHAR(10),
        c2 VARCHAR(10) NOT NULL,
        c3 CHAR(10),
        c4 VARCHAR(10)
) CHARSET=ascii ROW_FORMAT=COMPACT;

INSERT INTO record_format_demo(c1, c2, c3, c4) VALUES('aaaa', 'bbb', 'cc', 'd'), ('eeee', 'fff', NULL, NULL);
复制代码

这个表没有定义主键,MySQL 会生成一个 6 字节的值作为主键。除此之外,还会增加 6 字节的 transaction_id 和 7 字节的 roll_pointer。因此我们可以在脚本中增加一个行记录的结构体。

typedef struct {
	u8 row_id       : 48; // 行ID,唯一标识一条记录
	u8 trx_id       : 48; // 事务ID
	u8 roll_pointer : 56 ; // 回滚指针
} Row;
复制代码

然后在前面的 while 循环的 记录头后面增加记录的这个结构体。

// 遍历链表
while(next_rec_rel_pos != supremum_record_pos) {
	FSeek(page_start_pos + next_rec_rel_pos - 5); // 往前走五个字节,读取记录头,便于获取下一条记录的相对位置
	RecordHeader record_header;
	if (record_header.record_type == CONVENTIONAL) {
		Row row;
	}
	next_rec_rel_pos += record_header.next_record;
}
复制代码

图片蓝色部分背景的就是一行记录的前三个部分,row_id + transaction_id + roll_pointer。

?

紧随其后的是第一行的 c1、c2、c3、c4 这四个字段真正的值,其中 c3 的类型是 char(10),实际长度小于 10 则后面补空格(0x20),这就是 char 类型的数据,末尾存不进空格的原因。

第一行的可变长字段长度列表为 01 03 04,分别对应 c4、c2、c1,它是根据表字段逆序排列的。c3 字段因为是 char 类型,且表编码是 ASCII,非变长,因此长度是确定的 10 字节,没有必要在变长字段长度列表里记录。然后我们来看 NULL 值列表,因为第一行的所有字段都不为 NULL,所以这个字段为 0。

接下来我们来看第二行数据,

c1

c2

c3

c4

eeee

fff

NULL

NULL

对应的二进制列表如下。

?

可以看到此时的 c4 字段因为为 NULL,没有必要在变长列表里记录,因此变长列表为 03 04。NULL 列表转为 0x06(00000110),NULL 值列表也是逆序排的,所以它的值为 0110,又因为 c2 定义不为 NULL,011 表示c1 字段不为 NULL,c3、c4 字段为 NULL。

这个例子是在表编码为 ASCII 的情况下产生的。关于 char 类型的数据,如果是在变长编码比如 UTF8 的情况下,在可变长列表中,是否需要记录 char 类型数据实际的长度呢?

我们来试一下,把表编码改为 utf8,插入"一二三四五六七八九十",

CREATE TABLE record_format_demo2 (
        c1 VARCHAR(10),
        c2 VARCHAR(10) NOT NULL,
        c3 CHAR(10),
        c4 VARCHAR(10)
) CHARSET=utf8 ROW_FORMAT=COMPACT;

INSERT INTO `record_format_demo2` (`c1`, `c2`, `c3`, `c4`) VALUES 
	('gggg','hhh','一二三四五六七八九十','j');
复制代码

对应的文件信息如下。

?

可以看到此时变长列表出现了 4 个,char 类型的 c3 字段也在其中,长度为 30(0x1E),这里的每个汉字都用三个字节来表示。

从存储结构看 varchar 超长行是如何处理的

这里继续沿用了小册中的例子

CREATE TABLE `varchar_size_demo_1` (
  `c` varchar(65532) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=ascii ROW_FORMAT=COMPACT;

INSERT INTO varchar_size_demo_1(c) VALUES(REPEAT('a', 65532));
复制代码

MySQL 一页大小为 16k,这里很显然需要至少 4 页才能存下所有的内容。那这部分数据是如何组织的呢?小册给出的图已经比较清晰的说明了 COMPACT 格式下的页面布局。

?

知道了这一结构,我们可以继续定制化针对于这个表的模板。

typedef struct OverflowPagePointer {
	u4 space;
	u4 page_no;     // 页号
	u4 page_offset; // 页内偏移量
	u4 : 4 * 8;		// 空
	u4 length;      // 剩余长度
};
typedef struct {
	u8 row_id       : 48; // 行ID,唯一标识一条记录
	u8 trx_id       : 48; // 事务ID
	u8 roll_pointer : 56 ; // 回滚指针
	char data[768]; // 前 768 字节内容
	OverflowPagePointer overflow_page_pointer; // 20 字节的溢出结构体
} Row;
复制代码

这里没有说的是 20 字节的其它的页的地址,里面存的到底是什么。通过阅读源码,我提取除了这部分的结构,也就是上面的 OverflowPage。

?

可以看到溢出记录指针指向了页号为 4,偏移量为 38 的 Page 页的内容。我们来看页号为 4 的内容区域

?

通过 File Header 可以知道,这个 Page 页面的类型为 BLOB 类型。紧随 File Header 之后的是 8 字节的类似于头的信息,前 4 个字节 length 表示当前页面中存储的溢出数据的长度,接下来的 4 字节表示存储溢出数据的下一个页号是什么。基于此,我们可以实现 BLOB 类型的页面的数据格式解析。

BLOB 类型的页面格式由 8 字节的头信息加实际的数据组成,头信息由 4 字节表示的长度信息加 4 字节的下一个 Page 页号组成。

因此我们可以写出来 BLOB 类型的结构体。

typedef struct {
	u4 length;
	u4 next_page_number;
	char data[length];
} Blob;
复制代码

修改我们的模板脚本,增加 BLOB 类型页面的判断。

if (file_header.FIL_PAGE_TYPE == FIL_PAGE_INDEX) {
	PageHeader page_header;                // 56 字节的 page header
	InfimumSupremumRecord infimum_record;  // 13 字节的虚拟最小记录
	...
	}
} else if (file_header.FIL_PAGE_TYPE == FIL_PAGE_TYPE_BLOB) {
	 Blob blob;
}
复制代码

运行脚本得到的结果如下。

?

Dynamic 记录与 Compact 行格式类似,它把所有的真实记录都存储到了其它页面中,这里不展开。

从存储结构看看叶子节点与非叶子节点是如何表示的

这里我们创建一个 t2 表,写一个存储过程插入一千行数据。

CREATE TABLE `t2` (
  `id` int(11) NOT NULL,
  `a` int(11) DEFAULT NULL,
  `b` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_a` (`a`)
) ENGINE=InnoDB;


drop procedure if exists gen_data;
delimiter ;;
create procedure gen_data()
begin
    declare i int;
    set i = 1;
    while(i<=1000) do
        insert into t2 values(i, i, i);
        set i = i + 1;
    end while;
end;;
delimiter ;
call gen_data();
复制代码

这样表中就存在了从 1~1000 的 1000 行数据。

接下来我们来看这个表的存储结构,运行脚本,首先来看第 3 页(第 0、1、2 页都是系统页,先不管)。

?

可以看到第 3 页的记录类型为 1(NODE_POINTER),1 表示B+树非叶子节点记录,完整的记录类型如下。

  • 0 表示普通记录
  • 1 表示B+树非叶子节点记录
  • 2 表示最小记录
  • 3 表示最大记录

同时从第 3 页的 page_level 值可以看到值为 1,表示是非叶子节点(page_level 为 0)

?

对于 NODE_POINTER 类型的记录,它其实存储的是指向的下一级 LEVEL 页面的最小主键 id 和 页面的 Page 号,定义它的类型如下所示。

typedef struct NonLeafRecord {
	u4 min_key_on_child_page;
	u4 child_page_num;
};

// 处理最高位为 1 的情况
string read_int(u4 n) {
	string ret;
	SPrintf(ret, "%u", n & 0x7FFFFFFF);
	return ret;
}
复制代码

然后在代码里加入 NODE_POINTER 类型的处理逻辑。

while(next_rec_rel_pos != supremum_record_pos) {
	FSeek(page_start_pos + next_rec_rel_pos - 5); // 往前走五个字节,读取记录头,便于获取下一条记录的相对位置
	RecordHeader record_header;
	if (record_header.record_type == CONVENTIONAL) {
		Row row;
	} else if (record_header.record_type == NODE_POINTER) { // 处理非叶子节点的数据
		NonLeafRecord record;
	}
	next_rec_rel_pos += record_header.next_record;
}
复制代码

运行后得到的结果如下。

?

可以看到这里有三个非叶子节点,分别指向了第 5、6、7 页的叶子节点页面。这三个页面的最小主键值分别为 1、242、725。我们用一个图来表示。

?

其它

这里的模板文件还只是覆盖了 MySQL 文件格式很小的部分,可以作为一个最简单的模板,继续完善扩展支持。文章中用到的所有示例模板代码我已经放到了 github 上 github.com/arthur-zhan… ,有兴趣的同学可以继续完善。


作者:挖坑的张师傅
链接:
https://juejin.cn/post/6952875157203451941

来源:掘金

相关推荐

oracle数据导入导出_oracle数据导入导出工具

关于oracle的数据导入导出,这个功能的使用场景,一般是换服务环境,把原先的oracle数据导入到另外一台oracle数据库,或者导出备份使用。只不过oracle的导入导出命令不好记忆,稍稍有点复杂...

继续学习Python中的while true/break语句

上次讲到if语句的用法,大家在微信公众号问了小编很多问题,那么小编在这几种解决一下,1.else和elif是子模块,不能单独使用2.一个if语句中可以包括很多个elif语句,但结尾只能有一个else解...

python continue和break的区别_python中break语句和continue语句的区别

python中循环语句经常会使用continue和break,那么这2者的区别是?continue是跳出本次循环,进行下一次循环;break是跳出整个循环;例如:...

简单学Python——关键字6——break和continue

Python退出循环,有break语句和continue语句两种实现方式。break语句和continue语句的区别:break语句作用是终止循环。continue语句作用是跳出本轮循环,继续下一次循...

2-1,0基础学Python之 break退出循环、 continue继续循环 多重循

用for循环或者while循环时,如果要在循环体内直接退出循环,可以使用break语句。比如计算1至100的整数和,我们用while来实现:sum=0x=1whileTrue...

Python 中 break 和 continue 傻傻分不清

大家好啊,我是大田。今天分享一下break和continue在代码中的执行效果是什么,进一步区分出二者的区别。一、continue例1:当小明3岁时不打印年龄,其余年龄正常循环打印。可以看...

python中的流程控制语句:continue、break 和 return使用方法

Python中,continue、break和return是控制流程的关键语句,用于在循环或函数中提前退出或跳过某些操作。它们的用途和区别如下:1.continue(跳过当前循环的剩余部分,进...

L017:continue和break - 教程文案

continue和break在Python中,continue和break是用于控制循环(如for和while)执行流程的关键字,它们的作用如下:1.continue:跳过当前迭代,...

作为前端开发者,你都经历过怎样的面试?

已经裸辞1个月了,最近开始投简历找工作,遇到各种各样的面试,今天分享一下。其实在职的时候也做过面试官,面试官时,感觉自己问的问题很难区分候选人的能力,最好的办法就是看看候选人的github上的代码仓库...

面试被问 const 是否不可变?这样回答才显功底

作为前端开发者,我在学习ES6特性时,总被const的"善变"搞得一头雾水——为什么用const声明的数组还能push元素?为什么基本类型赋值就会报错?直到翻遍MDN文档、对着内存图反...

2023金九银十必看前端面试题!2w字精品!

导文2023金九银十必看前端面试题!金九银十黄金期来了想要跳槽的小伙伴快来看啊CSS1.请解释CSS的盒模型是什么,并描述其组成部分。答案:CSS的盒模型是用于布局和定位元素的概念。它由内容区域...

前端面试总结_前端面试题整理

记得当时大二的时候,看到实验室的学长学姐忙于各种春招,有些收获了大厂offer,有些还在苦苦面试,其实那时候的心里还蛮忐忑的,不知道自己大三的时候会是什么样的一个水平,所以从19年的寒假放完,大二下学...

由浅入深,66条JavaScript面试知识点(七)

作者:JakeZhang转发链接:https://juejin.im/post/5ef8377f6fb9a07e693a6061目录由浅入深,66条JavaScript面试知识点(一)由浅入深,66...

2024前端面试真题之—VUE篇_前端面试题vue2020及答案

添加图片注释,不超过140字(可选)1.vue的生命周期有哪些及每个生命周期做了什么?beforeCreate是newVue()之后触发的第一个钩子,在当前阶段data、methods、com...

今年最常见的前端面试题,你会做几道?

在面试或招聘前端开发人员时,期望、现实和需求之间总是存在着巨大差距。面试其实是一个交流想法的地方,挑战人们的思考方式,并客观地分析给定的问题。可以通过面试了解人们如何做出决策,了解一个人对技术和解决问...