mysql 的一行记录,最终肯定是存储在磁盘上,也就是肉眼可见的文件上,今天我们的目标很简单,就是看看它到底是怎么存的。
当然还有一个更重要的目标,就是以这个为引子,带大家完全通过一手资料,来揭秘这个问题的答案。
所以大家阅读时,不要完全奔着寻找这个答案去,如果很少通过一手资料去研究问题,那么相信完整读过本文,你会有收获和启发的。
我们先准备好三样东西。
1. 准备好一个 mysql 5.7 并将其启动。
2. 准备好 mysql 的官方文档放在旁边: https://dev.mysql.com
3. 准备好 mysql 的源码,万一要用呢,别怕: https://dev.mysql.com/downloads/mysql/5.7.html
一手资料,就是官方文档 源码 二进制文件,其中二进制文件是我们自己去磁盘中找的,一会就知道了。
Let's Go!
mysql 会把文件存在哪里呢?先找到他。
代码语言:javascript复制mysql> SHOW VARIABLES LIKE 'datadir';
--------------- ---------------------------------------------
| Variable_name | Value |
--------------- ---------------------------------------------
| datadir | C:ProgramDataMySQLMySQL Server 5.7Data |
--------------- ---------------------------------------------
1 row in set, 1 warning (0.00 sec)
我是 windows,就在这里了,进入这个目录。
这些是啥先不管,盯着它看就好了,我们继续。
第一步:创建数据库
代码语言:javascript复制mysql> create database flash;
盯着刚刚的文件夹看,此时会多出一个文件夹
同时这个文件夹里会多出一个文件,叫:
|-- flash |-- db.opt |-- flash |-- performance_schema
看一眼它里面的内容,就知道他是干嘛的了。
代码语言:javascript复制default-character-set=latin1
default-collation=latin1_swedish_ci
default-character-set 是默认字符集,default-collation 是默认字符序。
字符集大家都了解,就不展开了。
字符序就是字符的排序和比较规则,一般以 _ci 结尾的表示大小写不敏感,_cs 结尾的表示大小写敏感,_bin 结尾的表示用编码值进行比较。
含义知道了,那我们重新设置它应该会有所变化,我们把这个数据库设置为开发时常用的 utf8mb4 格式。
代码语言:javascript复制ALTER SCHEMA `flash` DEFAULT CHARACTER SET utf8mb4;
再看 db.opt 文件,内容已经发生了变化。
代码语言:javascript复制default-character-set=utf8mb4
default-collation=utf8mb4_general_ci
OK,那我们现在对这个文件有了个初步认识,创建一个新的数据库时,首先会多出一个以数据库名为名称的文件夹,然后文件夹里面会多出一个描述数据库配置的 db.opt 文件,我们继续!
第二步:创建表
创建一张 student 表,三列,其中 id 是主键。
CREATE
TABLE
`flash`.`student` (
`id`
INT
NOT
NULL,
`name`
VARCHAR(10) NOT
NULL,
`age`
INT
NULL,
PRIMARY KEY (`id`)
ENGINE = InnoDB
DEFAULT
CHARACTER
SET = utf8mb4;
此时 flash 文件夹中,多出了两个文件
|-- flash |-- db.opt |-- student.frm |-- student.ibd |-- flash |-- performance_schema
为了严谨,我们先看下 db.opt 文件有没有变化,发现没有任何变化,说明创建表对这个 db.opt 配置信息文件,没有影响。
再点开 student.frm,坏了,乱码了。
说明这个文件不是文本文件,用二进制方式打开它。
我把一些关键的地方都标上了含义,那这个文件的作用大家就一目了然了,就是记录表结构嘛,具体的格式可以看 frm 文件结构的官方文档(写得太复杂了...我反正是没看):
https://dev.mysql.com/doc/internals/en/frm-file-format.html
db.opt 记录了数据库信息,student.frm 记录了表结构信息,那重头戏自然就在那个 student.ibd 文件了,这里一定存着具体的数据呀,索引呀等信息吧。
打开它!
果不其然还是乱码,那还是二进制打开它!截取了中间信息较为丰富的某部分。
发现一点也看不懂。
第三步:插入数据
我们加几条数据看看。
代码语言:javascript复制INSERT INTO `flash`.`student` (`id`, `name`, `age`) VALUES ('1', 'dibingfa2', '2');
INSERT INTO `flash`.`student` (`id`, `name`, `age`) VALUES ('2', 'dibingfa2', '2');
INSERT INTO `flash`.`student` (`id`, `name`, `age`) VALUES ('3', 'dibingfa3', '2');
INSERT INTO `flash`.`student` (`id`, `name`, `age`) VALUES ('4', 'dibingfa4', '2');
INSERT INTO `flash`.`student` (`id`, `name`, `age`) VALUES ('5', 'dibingfa5', '2');
INSERT INTO `flash`.`student` (`id`, `name`, `age`) VALUES ('6', 'dibingfa6', '2');
INSERT INTO `flash`.`student` (`id`, `name`, `age`) VALUES ('7', 'dibingfa7', '2');
代码语言:javascript复制再二进制打开它!
发现有些东西我们可以看出点端倪了!猜测下这部分就是每一行的记录信息吧。
我们插入了七条数据,我发现这些二进制串有一段可以分割成七对,我把他单独拿出来,并且按行分割。
我们将第一行记录拆解,第一行记录的表数据是这样的。
1 dibingfa 2
在 ibd 文件中是这样的。
08 00 00 00 10 00 24 80 00 00 01 00 00 00 00 0A 07 A7 00 00 01 1B 01 10 64 69 62 69 6E 67 66 61 80 00 00 02
这串数据代表啥意思呢?由于本文只能参考官方文档,我们看这里,即 Innodb 行格式。
https://dev.mysql.com/doc/refman/5.7/en/innodb-row-format.html
看这部分(我们的行格式是 DYNAMIC 类型,不过参考 COMPACT 类型描述也行,因为几乎一样,之后再说):
不要看这么一大长串就害怕,我们一点点来,别着急。
这个文档,我可能没资格评价,但我个人觉得写的很烂,一大堆废话也没说明白格式是什么样子,每个字节表示什么。不过也可能作用并非如此。
好的官方文档应该是能把每个字节和每一位都解释清楚的,无奈这个文档不行,那我们就去找更接近一手资料的源码。
我找到了源码,还是很清晰的,注释上就写明了每一行记录的磁盘数据格式,太好了,不用看代码了。
再贴上刚刚的第一行记录。
08 00 00 00 10 00 24 80 00 00 01 00 00 00 00 0A 07 A7 00 00 01 1B 01 10 64 69 62 69 6E 67 66 61 80 00 00 02
来一点点看,第一部分。
| length of the last non-null variable-length field of data ... ...|
...
| length of first variable-length field of data |
这部分是变长字段长度列表,就是依次记录所有变长字段的长度,由于我们只有一个变长字段 varchar(10) 的 name,所以就是 08,我们存储的 "dibingfa" 刚好是 8 个字节,对上了。
那如果是多个,很显然,就这样存。
错!应该是这样存,也就是逆序存放,具体为啥后面说。
OK,这就是第一个字节 08 所表示的含义。
再往下。
| SQL-null flags (1 bit per nullable field), padded to full bytes |
第二个结构叫 NUll 值列表,用 1 位表示一个 NULL 值,要填充满一个字节,那往下的一个字节是 00,一看我们的记录中也确实没有 NULL 值,对上了。
具体来说,同样也是逆序存放的。
继续。
| 4 bits used to delete mark a record, and mark a predefined
minimum record in alphabetical order |
| 4 bits giving the number of records owned by this record
(this term is explained in page0page.h) |
| 13 bits giving the order number of this record in the
heap of the index page |
| 3 bits record type: 000=conventional, 001=node pointer (inside B-tree),
010=infimum, 011=supremum, 1xx=reserved |
| two bytes giving a relative pointer to the next record in the page |
ORIGIN of the record
这五个字节很乱,放在一块叫记录头信息,00 00 10 00 24,其表示删除状态,记录类型,下一条记录的相对位置等。
这一大坨先放一放,因为涉及到好多额外的知识。
继续往下看。
| first field of data |
...
| last field of data |
剩下全都是具体的列数据了,从第一列到最后一列。
第一列是 ID 列,是 INT 类型的 1,占四个字节 80 00 00 01。开头的 80 是因为,正数要以 1 开头,这是 mysql 规定的,0x80 的二进制就是 1000 0000,所以这也对上了。
第二列是 name 列,是 "dibingfa" 这样一个 varchar 类型的字符串。
可是与后面怎么也对应不上,这是咋回事呢?
还记不记得,mysql 每行记录会有几个隐藏列,rowid,事务 ID,回滚指针?没错,就是他们。
其中,因为有主键,所以 rowid 就不存在了,也可以说第一列要么是 mysql 为我们生成的 6 字节的 rowid,要么是用户定义的主键或其他 Unique 键,优先以用户定义的键为准。
下面我们一块看一下这五个列。(三个隐藏列,两个我们定义的列)
主键 ID:80 00 00 01
事务 ID:00 00 00 00 0A 07
回滚指针:A7 00 00 01 1B 01 10
name 列(dibingfa):64 69 62 69 6E 67 66 61
age 列(2):80 00 00 02
其中 age 列同刚刚说的一样,mysql 会为正数的前面,加一个 1,所以 age 为 2,在磁盘上存储的就是 80 00 00 02。
事务 ID 和回滚指针就涉及到事务、隔离级别和 MVCC 这一大坨八股文的知识点,这里不做展开。
行记录格式整体结构
总结下,整个一行记录的格式,叫做 mysql 的行记录格式,ROW_FORMAT。
这个 ROW_FORMAT 可以有不同的值,代表存储这一行记录的不同数据结构,其枚举记录在 remOtypes.h 文件中。
代码语言:javascript复制/** Innodb row types are a subset of the MySQL global enum row_type.
They are made into their own enum so that switch statements can account
for each of them. */
enum rec_format_enum {
REC_FORMAT_REDUNDANT = 0, /*!< REDUNDANT row format */
REC_FORMAT_COMPACT = 1, /*!< COMPACT row format */
REC_FORMAT_COMPRESSED = 2, /*!< COMPRESSED row format */
REC_FORMAT_DYNAMIC = 3 /*!< DYNAMIC row format */
};
我电脑上用的是 mysql 5.7,其默认的行记录格式是 DYNAMIC,这个在源码中也可以找到答案,在 ha_innodb.cc 中。
代码语言:javascript复制static ulong innodb_default_row_format = DEFAULT_ROW_FORMAT_DYNAMIC;
当然,可以用如下命令查询你的行格式。
show table status from flash like 'student';
所以我们今天以上讲述的格式,都是 DYNAMIC 格式的结构,总结起来如下:
记录源信息
变长字段列表
NULL 值列表
记录头信息
具体记录的各列信息
rowid 或 主键(隐藏列)
事务 ID(隐藏列)
回滚指针(隐藏列)
列 1
列 2
...
列 n
刚刚那七条记录,整体分析下,就如下图。
然后多个行,一次紧密地排列,通过记录头中的下一条记录的相对位置指针信息,可以快速找到下一条记录的起始位置。
再宏观一点看,整个 ibd 文件,划分了很多个块,每个块 16 KB,我们这几行记录信息,在第四个块的某个区域内。
具体为什么是这个区域呢?因为这里是用户记录部分,前面还有文件头、页面头等信息,共占用 120 个字节,咱们今天讲的行记录部分,就从第 121 个字节开始。
慢慢的,我再和大家一起把其他部分搞清楚,那 mysql 这块我们就从最原始的磁盘数据入手,将 data 文件夹下的所有文件都搞清楚了。
最原始的数据都搞清楚了,原理还担心么?
再聊几句
其实,不要被行记录格式这种名词吓到,它只是个协议或规定罢了。
就是 mysql 规定了一种将一行记录存储在磁盘中的格式,以便于 mysql 自己的程序可以根据这个结构认识这一行记录。
所以这种协议,首先要满足让 mysql 知道全部想知道的信息,比如 mysql 现在能仅仅通过 ibd 文件里的这些二进制数,知道每个字段的值都是什么吗?不能,因为它不知道表结构是什么样子,也就没法知道两个字段值之间的界限在哪里。
所以不难想到,它一定利用了 frm 文件中存储的表结构信息。
其次,要让 mysql 在知道这些信息的同时,还能更方便地利用这个结构,占用更少的存储空间,以及提升程序的便利性。
拿占用更少存储空间这块来讲,NULL 值完全可以当做普通列,也存储在后面,然后规定一个 NULL 值的二进制标识符即可。但 DYNAMIC 行记录格式规定前面放一个 NULL 值列表的结构,并且仅仅用 1 位来表示一个 NULL 值记录,这样就极大节省了空间。
再说便利性这块,上面说了变长字段长度列表和 NULL 值列表,都是逆序存储的,看似很别扭,其实就是为了程序的便利性,这里留给大家自己探索吧。
文章附赠
恭喜读到了这里,文章附赠一份小礼物,就是本文输出的一些文件。
大家加我好友(公众号菜单栏中有个联系我),看朋友圈第一条,即可获得这些文件的下载路径。
哦对了,顺便提一下,记得很久之前还是用 sqlyog 去连 mysql 的,其实 mysql 下载并安装后,自带的 workbench 就很好用了。
所以非常方便本机做实验。
大家方便的时候也自己玩一玩哦,我们下期见。