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

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

csdh11 2025-02-06 14:31 15 浏览

文件的存储结构包含了系统大量的实现细节,比如 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

来源:掘金

相关推荐

探索Java项目中日志系统最佳实践:从入门到精通

探索Java项目中日志系统最佳实践:从入门到精通在现代软件开发中,日志系统如同一位默默无闻却至关重要的管家,它记录了程序运行中的各种事件,为我们排查问题、监控性能和优化系统提供了宝贵的依据。在Java...

用了这么多年的java日志框架,你真的弄懂了吗?

在项目开发过程中,有一个必不可少的环节就是记录日志,相信只要是个程序员都用过,可是咱们自问下,用了这么多年的日志框架,你确定自己真弄懂了日志框架的来龙去脉嘛?下面笔者就详细聊聊java中常用日志框架的...

物理老师教你学Java语言(中篇)(物理专业学编程)

第四章物质的基本结构——类与对象...

一文搞定!Spring Boot3 定时任务操作全攻略

各位互联网大厂的后端开发小伙伴们,在使用SpringBoot3开发项目时,你是否遇到过定时任务实现的难题呢?比如任务调度时间不准确,代码报错却找不到方向,是不是特别头疼?如今,随着互联网业务规模...

你还不懂java的日志系统吗 ?(java的日志类)

一、背景在java的开发中,使用最多也绕不过去的一个话题就是日志,在程序中除了业务代码外,使用最多的就是打印日志。经常听到的这样一句话就是“打个日志调试下”,没错在日常的开发、调试过程中打印日志是常干...

谈谈枚举的新用法--java(java枚举的作用与好处)

问题的由来前段时间改游戏buff功能,干了一件愚蠢的事情,那就是把枚举和运算集合在一起,然后运行一段时间后buff就出现各种问题,我当时懵逼了!事情是这样的,做过游戏的都知道,buff,需要分类型,且...

你还不懂java的日志系统吗(javaw 日志)

一、背景在java的开发中,使用最多也绕不过去的一个话题就是日志,在程序中除了业务代码外,使用最多的就是打印日志。经常听到的这样一句话就是“打个日志调试下”,没错在日常的开发、调试过程中打印日志是常干...

Java 8之后的那些新特性(三):Java System Logger

去年12月份log4j日志框架的一个漏洞,给Java整个行业造成了非常大的影响。这个事情也顺带把log4j这个日志框架推到了争议的最前线。在Java领域,log4j可能相对比较流行。而在log4j之外...

Java开发中的日志管理:让程序“开口说话”

Java开发中的日志管理:让程序“开口说话”日志是程序员的朋友,也是程序的“嘴巴”。它能让程序在运行过程中“开口说话”,告诉我们它的状态、行为以及遇到的问题。在Java开发中,良好的日志管理不仅能帮助...

吊打面试官(十二)--Java语言中ArrayList类一文全掌握

导读...

OS X 效率启动器 Alfred 详解与使用技巧

问:为什么要在Mac上使用效率启动器类应用?答:在非特殊专业用户的环境下,(每天)用户一般可以在系统中进行上百次操作,可以是点击,也可以是拖拽,但这些只是过程,而我们的真正目的是想获得结果,也就是...

Java中 高级的异常处理(java中异常处理的两种方式)

介绍异常处理是软件开发的一个关键方面,尤其是在Java中,这种语言以其稳健性和平台独立性而闻名。正确的异常处理不仅可以防止应用程序崩溃,还有助于调试并向用户提供有意义的反馈。...

【性能调优】全方位教你定位慢SQL,方法介绍下!

1.使用数据库自带工具...

全面了解mysql锁机制(InnoDB)与问题排查

MySQL/InnoDB的加锁,一直是一个常见的话题。例如,数据库如果有高并发请求,如何保证数据完整性?产生死锁问题如何排查并解决?下面是不同锁等级的区别表级锁:开销小,加锁快;不会出现死锁;锁定粒度...

看懂这篇文章,你就懂了数据库死锁产生的场景和解决方法

一、什么是死锁加锁(Locking)是数据库在并发访问时保证数据一致性和完整性的主要机制。任何事务都需要获得相应对象上的锁才能访问数据,读取数据的事务通常只需要获得读锁(共享锁),修改数据的事务需要获...