innodb_ruby 视角下 MySQL 记录增删改

前言:学习MySQL的时候,想到一个问题,记录在文件中是怎么存放的?

MySQL技术内幕InnoDB存储引擎--InnoDB数据页结构:通过 hexdump来分析xx.ibd文件,记录对应的是一串二进制

MySQL是怎样运行的--记录在页中的存储 pg 73:记录存放在页中的User Records中,插入新记录时向Free Space申请空间

MySQL是怎样运行的--记录头的秘密 pg 76User Records中的记录紧密排列。将记录紧密排列的这种结构称为堆heap,记录在堆中的相对位置称为 heap_no,该值随记录的插入递增

MySQL是怎样运行的--update操作对应的undo日志 pg 348:执行 delete语句时,分为delete mark阶段、purge阶段

MySQL是怎样运行的--update操作对应的undo日志 pg 353:执行 update 语句时,分更新主键和不更新主键的情况,最终分为:处理旧记录,插入新数据

反复思考上面的信息后:初步认为,无论插入顺序如何。从物理层面来说,记录在文件中是顺序排放的

从逻辑层面来说,记录是按照主键大小排序并通过链表组织的

考虑到:若记录的逻辑位置和物理位置是对应的,那么在现有结构中插入数据就会导致插入位置之后的记录都要移动

但是缺少验证方式,于是想通过 innodb_ruby 分析一波

1. 插入一条记录

实验数据说明:rush数据库下t数据表

① 表结构

CREATE TABLE `t` (
  `ID` int(11) NOT NULL,
  `k` int(11) NOT NULL DEFAULT '0',
  `s` varchar(16) NOT NULL DEFAULT '',
  PRIMARY KEY (`ID`),
  KEY `k` (`k`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

② 表数据

INSERT INTO `t`(`ID`, `k`, `s`) VALUES (100, 1, 'aa');
INSERT INTO `t`(`ID`, `k`, `s`) VALUES (200, 2, 'bb');
INSERT INTO `t`(`ID`, `k`, `s`) VALUES (300, 3, 'cc');
INSERT INTO `t`(`ID`, `k`, `s`) VALUES (500, 5, 'ee');
INSERT INTO `t`(`ID`, `k`, `s`) VALUES (600, 6, 'ff');
INSERT INTO `t`(`ID`, `k`, `s`) VALUES (700, 7, 'GG');
INSERT INTO `t`(`ID`, `k`, `s`) VALUES (800, 8, 'hh');

③ 表信息
innodb_ruby 视角下 MySQL 记录增删改_第1张图片
实验方案:向数据表t中插入ID=400的行记录

预期结果:按照主键顺序排列,ID=400的记录会放到ID=300的记录后面,

若是在物理文件中采用 append的方式添加则新添加记录在数据页中的偏移量会比ID=800记录的偏移量值大

1.1 page-records 插入记录前

innodb_space -s ibdata1 -T rush/t -p 3 page-records

innodb_ruby 视角下 MySQL 记录增删改_第2张图片
说明:
innodb_space -s ibdata1:以系统文件的方式开启 innodb_space

-T rush/t:数据库 rush 下的表 t,注意-T仅在 -s模式下才能使用,而且表名后面不带.ibd后缀

-p 3:查看第3页,通过 space-page-type-regions可以查看所有类型页面的分布情况

3页开始是INDEX页,前面 0 - 2页分别为FSP_HDR,IBUF_BITMAP,INODE

Record xxx:记录在页中的偏移 offset

(ID=X00):主键,page 3 为索引,使用 page-dump可以查看索引类型,type=:clustered

实验结果分析:上述的按照主键大小顺序插入6条记录,记录在页中的偏移量是递增的

1.2 page-records 插入记录后

INSERT INTO `t`(`ID`, `k`, `s`) VALUES (400, 4, 'dd');

同样执行上述innodb_space -s ibdata1 -T rush/t -p 3 page-records

① 通过 page-records打印的信息
innodb_ruby 视角下 MySQL 记录增删改_第3张图片
可以看到:ID=400的记录在页中的偏移量为329

实际上这里测试不严谨,应该先判断是否出现由于插入记录导致页分裂的情况,不过这里的数据量很小,没有新增页。

下面是查看页信息的指令

② 通过space-page-type-regions查看页信息

innodb_space -s ibdata1 -T rush/t space-page-type-regions

innodb_ruby 视角下 MySQL 记录增删改_第4张图片
start:3,end:4,count:2,type:INDEX

表示:数据页类型为INDEX的起始页号为3,终止页号为4,共2

说明:2个索引页的原因,创建表的时候有PRIMARY KEY (`ID`)KEY `k` (`k`)

不过,数据量较大的时候即使只有两个索引,应该不止2页【待验证】

page-dump查看记录详细信息

innodb_space -s ibdata1 -T rush/t -p 3 page-dump

仅截取新插入ID=400的记录
innodb_ruby 视角下 MySQL 记录增删改_第5张图片
说明:查询的时候,该记录信息在第4条,infimumsupremum这两条虚记录并不显示

即检索的结果和通过select查询的顺序一致

记录是通过offset标记在页中的位置,通过next串联逻辑位置

ID=300 :id = 300, offset = 184, next = 329

ID=400 :id = 400, offset = 329, next = 213

page-directory-summary查看组信息

当前已插入8条记录,而supremum所在的组最多插入8条记录,因此会分裂组到新组中

innodb_space -s ibdata1 -T rush/t -p 7 page-directory-summary

在这里插入图片描述
说明:页目录 page directory对记录分组,组偏移量为最大记录的offsetowned为该组中的记录数

infimum--owned 1:自成一组,即该组仅有一条记录

supremum--owned 5:记录范围为1-8,这里并不是因为普通记录只能容纳4条,

而是supremum组满了,需要分裂,而conventional类型的组至少容纳4条记录

ID=400--owned 4:普通组最少4条,而ID=400的记录在本组中主键值最大,将其作为带头大哥

从这里看出在分组的时候还是按照链表的顺序即索引的顺序,否则无法利用二分查找

还有一点需要说明:仅带头大哥owned非 0

2. 删除一条记录

MySQL 是怎样运行的 pg 347 :

Page Header 部分中有一个 PAGE_FREE 属性,指向由被删除记录组成的垃圾链表中的头节点

阶段1:delete mark :设置记录的 deleted_flag = 1,此时事务未提交,记录还在正常的记录链表中

阶段2:purge :将记录从正常记录链表中移除,加入到垃圾链表,此阶段在事务提交后执行

实验方案:开启事务,删除ID=700的记录,观察事务提交前后的相关信息

包括:page-dump页记录, record-dump行记录,

2.1 事务提交前

① 开启事务,删除ID=700的记录

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t;
+-----+---+----+
| ID  | k | s  |
+-----+---+----+
| 100 | 1 | aa |
| 200 | 2 | bb |
| 300 | 3 | cc |
| 400 | 4 | dd |
| 500 | 5 | ee |
| 600 | 6 | ff |
| 700 | 7 | GG |
| 800 | 8 | hh |
+-----+---+----+
8 rows in set (0.00 sec)

mysql> delete from t where ID = 700;
Query OK, 1 row affected (0.00 sec)

mysql>

② 开启新session 查看表 t
innodb_ruby 视角下 MySQL 记录增删改_第6张图片
可以看到:即使删除一条记录,在事务未提交前,另一个 session 中仍然可以查询到待删除的记录

record-dump行记录

innodb_space -s ibdata1 -T rush/t -p 3 -R 271 record-dump

innodb_ruby 视角下 MySQL 记录增删改_第7张图片
使用 -R offset检索指定偏移量的行记录信息,该offset值可以通过page-records查看,不可任意指定

上述使用ID=700待删除记录和ID=400未删除记录对比

可以看到上述有两处不同:待删除记录 Deleted : trueInsert : false【有点不同,没有 delete_flag 信息】

page-dump该记录在页中的信息

innodb_space -s ibdata1 -T rush/t -p 3 page-dump

innodb_ruby 视角下 MySQL 记录增删改_第8张图片
同其他记录对比了下:仅上述两处不同,info_flags,和 is_insert

其他记录 info_flags = 0,由于修改实际上先处理旧记录再插入新记录,最后看到的info_flags应该同 insert

2.2 事务提交后

① commit 提交事务

mysql> commit;
Query OK, 0 rows affected (0.01 sec)

② 开启新 session查看表 t
innodb_ruby 视角下 MySQL 记录增删改_第9张图片
由于单条SQL自动提交事务,这里可以看到修改后的情况

page-dump页记录
innodb_ruby 视角下 MySQL 记录增删改_第10张图片
已经查找不到 ID=700的记录,而且从打印的行记录信息来看,该记录的前驱指向了后继

page-directory-summary页目录
innodb_ruby 视角下 MySQL 记录增删改_第11张图片
上述分别为事务提交前后的测试结果,可以看到事务提交后,该记录才不再显示到page-records列表中

page-records页记录信息
innodb_ruby 视角下 MySQL 记录增删改_第12张图片
从④中可以看到 ID=700的记录再第三组中,也即同supremum在同一组,因此提交后,owned=4

2.3 删除记录后

问题:删除记录后,文件中记录原来的位置怎么处理?

page-illustrate页插图
innodb_ruby 视角下 MySQL 记录增删改_第13张图片
可以看到:删除一条记录后,其他记录的位置并不受影响,而该条记录的位置被划归到垃圾链表中

所以再次回归主题:记录在文件中物理位置和逻辑位置并不是对应的,逻辑位置依靠链表来组织

那么问题又来了,记录存放的时候紧密排列,修改的时候存储空间变化,怎么处理记录的位置?

留到3.修改一条记录进行分析

2.4 再次新插入记录

测试目的:新插入存储空间相同的记录,验证是否会先使用垃圾链表中的空间

INSERT INTO `t`(`ID`, `k`, `s`) VALUES (900, 9, 'ii');

① session 下检索结果
innodb_ruby 视角下 MySQL 记录增删改_第14张图片
page-illustrate页插图
innodb_ruby 视角下 MySQL 记录增删改_第15张图片
注意看红框里面,上幅图里面,此处被标记为Garbage,现在这里有了新的记录

通过打印记录在页中的偏移情况,进一步验证是否复用了Garbage空间

page-records记录的页内偏移
innodb_ruby 视角下 MySQL 记录增删改_第16张图片
新记录ID=900的记录复用了删除记录ID=700的位置

需要注意的是:这里能复用的前提是,两条记录的存储空间大小相同

MySQL是怎样运行的:每当新插入记录时,首先判断垃圾链表头节点代表的已删除记录所占用的存储空间是否足够容纳这条新插入的记录

后面还有一句:若不能容纳则直接向页面申请新空闲来容纳新记录的节点

拿本书作者常说的一句话:遍历是不可能遍历的,这辈子都不可能遍历

那么由于删除记录产生的碎片空间如何处理?

上图也看到了是有统计Garbage空间的,若最后页面满到无法存放一条新记录时,

会统计Garbage空间的大小以及剩余空间判断能否用来存放新记录,

若能存放,则尝试重组页面记录。意味着将Garbage空间归并到剩余空间中,形成连续可用空间再插入记录

3. 修改一条记录

pg 353:执行 UPDATE 语句时,分为 更新主键 和 不更新主键 两种情况

① 不更新主键:

  1. 就地更新:被更新的每个列,更新后列与更新前列占用的存储空间同样大

  2. 先删除旧记录,再插入新记录:被更新的列在更新前后所占用的存储空间大小不一致

② 更新主键:

  1. 事务提交前:对旧记录进行 delete mark 操作

  2. 创建新记录并将其插入到聚簇索引中

3.1 不更新主键

实验方案:分为存储空间变化,不变两种情况,另外验证对于变化的情况,事务未提交前会不会删除记录

存储空间不变:更新ID=400的记录,设置s=DD

存储空间变小:更新ID=800的记录,设置s=h

存储空间变大:更新ID=200的记录,设置s=bbbb

3.1.1 存储空间不变

① sql 更新语句

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t set s = 'DD' where ID = 400;
Query OK, 1 row affected (0.00 sec)

② session 端查询
innodb_ruby 视角下 MySQL 记录增删改_第17张图片
执行这条查询,足足用了39.448s,或许应该分析一波慢查询日志

但是提交后查询就很快,可能跟 buffer pool 有关,也或许跟快照读有关

page-records页记录
innodb_ruby 视角下 MySQL 记录增删改_第18张图片
显式开启事务,此时事务未提交,但通过page-records查寻s列的值已经变化

但前后 ID=400的记录偏移量并未变化,即仍然在原来的位置上,而且并没有走delete mark那套流程

3.1.2 存储空间变小

① 更新 ID=800的记录,设置 s=h

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t set s = 'h' where ID = 800;
Query OK, 1 row affected (0.00 sec)

page-records页记录
innodb_ruby 视角下 MySQL 记录增删改_第19张图片
存储空间变小,仍然会在原来的位置上,但暂时看不到先删除的痕迹

page-dump页中行记录
innodb_ruby 视角下 MySQL 记录增删改_第20张图片
对比上述删除中的操作,info_flags标识并未变化

以上操作均在事务未提交时进行,此时查询到的记录列已经更新,基本说明和2.删除一条记录的流程不同

问题说明:书上写的是只要列变化,就会先删除,但是在其中一列变化,总体不变的情况下是否会变化还需要验证

3.1.3 存储空间变大

① 更新 ID=200的记录,设置 s=bbbb

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t set s = 'bbbb' where ID = 200;
Query OK, 1 row affected (0.01 sec)

page-records页记录
innodb_ruby 视角下 MySQL 记录增删改_第21张图片
存储空间变大,ID=200的偏移量为358排在最后一条记录的后面

page-directory-summary页目录
在这里插入图片描述
页目录变化情况,其实暗含了ID=200的记录已经执行了删除再插入的操作

原因如下:忘记截插入ID=900的图了,不过还是可以进行推理一波

插入ID=900是为了验证复用了ID=700的空间,且由于ID=700supremum

相当于supremum组记录数一删一添后并未变化,即 owned=5
在这里插入图片描述
但是删除 ID=200的记录后,conventional组的记录owned=3这是不满足最小4条记录规定的

因此会将supremum组中的一条记录划归到conventional
innodb_ruby 视角下 MySQL 记录增删改_第22张图片
说明:此图未删除ID=700时的分组情况,conventional组最大记录为ID=400,将其标记为带头大哥

而删除记录ID=200后情况应该变成这样
innodb_ruby 视角下 MySQL 记录增删改_第23张图片
所以ID=500的记录才能被标记为带头大哥

然后就是插入记录,由于conventional组,允许4-8条记录,所以情况变成了现在这样
innodb_ruby 视角下 MySQL 记录增删改_第24张图片
从而从侧面验证了MySQL是怎样运行的 pg 83非主键顺序插入的时候,普通组的记录数不完全是4

而书上普通组记录全是4的原因是:

主键顺序插入,新记录都会分到supremum组,然后不断分裂新组,而新组至少4条记录

page-illustrate页插图
innodb_ruby 视角下 MySQL 记录增删改_第25张图片
分析上图可以得出的结论:

  1. ID=800由于更新后减少了存储空间,更新后仍在原位,并与下一条记录ID=400之间形成碎片

  2. ID=200由于更新后增加了存储空间,删除了原位置上的记录,并append到记录最后

以上操作均在事务未提交时进行,此时查询到相关记录均已经更新,

基本说明和2.删除一条记录的流程不同,即更新记录但不更新主键的情况下,若需要删除记录则直接删除

3.2 更新主键

MySQL是怎样运行的 pg 356

更新主键:意味着记录在聚簇索引中的位置将会发生改变

这种情况下分两步进行:

  1. 将旧记录进行 delete mark操作

  2. 创建新记录,并插入到聚簇索引中

注意:上面未更新主键的情况,已经反复验证不会走delete mark操作

而更新主键时是否会走delete mark操作呢?

测试方案:更新ID=500的记录为ID=700,事务提交前分析该记录的相关变更信息

测试分析:有无必要做存储空间变化的实验,若存储空间不变的情况下都是append到最后一条记录后面

则存储空间变化的情况就没必要做了,此时的 UPDATE操作相当于是先执行DELETE再执行INSERT【后面验证该句有误】

3.2.1 事务提交前 delete mark ?

① 更新ID=500的记录为ID=700

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t;
+-----+---+------+
| ID  | k | s    |
+-----+---+------+
| 100 | 1 | aa   |
| 200 | 2 | bbbb |
| 300 | 3 | cc   |
| 400 | 4 | DD   |
| 500 | 5 | ee   |
| 600 | 6 | ff   |
| 800 | 8 | h    |
| 900 | 9 | ii   |
+-----+---+------+
8 rows in set (0.00 sec)

mysql> update t set ID=700 where ID = 500;
Query OK, 1 row affected (0.00 sec)

page-records页记录
innodb_ruby 视角下 MySQL 记录增删改_第26张图片
注意:此时出现了两条记录

其中更新主键后的记录ID=700复用了ID=200更新后删除的位置

原记录ID=500还在原来位置上

page-dump页行记录中的链接关系
innodb_ruby 视角下 MySQL 记录增删改_第27张图片
可以看到ID=500的记录并未在链表中被移除,其next=242指向ID=600的记录

而更新为ID=700的记录,已经与前后链表节点建立的连接,其next=300指向ID=800的记录

page-illustrate页插图
innodb_ruby 视角下 MySQL 记录增删改_第28张图片
更新ID=200时产生的GarbageID=700的记录重用,不过,若是存储空间变大,会插入到最后一条记录后面

这里也说明:不可能重用原来空间的原因,事务未提交时,原来的记录还未物理及逻辑删除

page-directory-summary页目录
innodb_ruby 视角下 MySQL 记录增删改_第29张图片
注意:此时 supremum = 5 ,即确实在执行插入ID=700这条记录

也即Delete Mark线程和Insert线程是独立执行的

MySQL是怎样运行的 pg356:更新主键对旧记录的处理是事务提交前仅执行delete mark

事务提交后由purge线程将其加入到垃圾链表中

3.2.2 事务提交后

① commit 事务

mysql> commit;
Query OK, 0 rows affected (0.02 sec)

page-records页记录
innodb_ruby 视角下 MySQL 记录增删改_第30张图片
仅剩下ID=700的记录

page-illustrate页插图
innodb_ruby 视角下 MySQL 记录增删改_第31张图片
ID=500的记录被删除

page-directory-summary页目录
在这里插入图片描述
ID=500的记录被删除后,ID=400的记录成为新的带头大哥

普通组中允许最少 4 条记录,因此不需要再从supremum中瓜分

写在最后:本文仅分析了-p 3,由于有两个索引,至少有两个索引页,

-p 4为二级索引页,在修改记录的过程中,也会涉及二级索引页的变化

总结:

  1. 插入记录:先判断垃圾链表首节点能否复用,不能复用则append到最后一条记录后面

  2. 删除记录:先走delete mark阶段,即此时事务未提交,记录未删除,种种与该记录有关的内容暂未变更
    而事务提交后,由 purge线程将该记录加入到垃圾链表,但在物理文件上,该记录的位置仍然保留,
    最后若有必要重组文件空间,则会将该部分的碎片空间和剩余空闲空间合并

  3. 更新记录:分为更新主键和不更新主键
    更新主键=删除记录+插入记录【两者并行,删除记录受限于事务是否提交】
    不更新主键:根据存储空间是否变化,决定是否复用当前位置原地更新,
    若变大或变小则先删除【不走delete mark】再插入,接着就是插入的流程

变小的那种情况,忘记根据page-directory-summary验证是否是先删除了

不过由于存储空间变小复用原来的位置,由于是写覆盖,

若不先删除原有记录,剩余空间上还会保留旧记录,因此必定会先删除

你可能感兴趣的:(MySQL,ruby,mysql,数据库)