前言:学习MySQL的时候,想到一个问题,记录在文件中是怎么存放的?
MySQL技术内幕InnoDB存储引擎--InnoDB数据页结构
:通过 hexdump
来分析xx.ibd
文件,记录对应的是一串二进制
MySQL是怎样运行的--记录在页中的存储 pg 73
:记录存放在页中的User Records
中,插入新记录时向Free Space
申请空间
MySQL是怎样运行的--记录头的秘密 pg 76
:User Records
中的记录紧密排列。将记录紧密排列的这种结构称为堆heap,记录在堆中的相对位置称为 heap_no
,该值随记录的插入递增
MySQL是怎样运行的--update操作对应的undo日志 pg 348
:执行 delete语句时,分为delete mark
阶段、purge
阶段
MySQL是怎样运行的--update操作对应的undo日志 pg 353
:执行 update 语句时,分更新主键和不更新主键的情况,最终分为:处理旧记录,插入新数据
反复思考上面的信息后:初步认为,无论插入顺序如何。从物理层面来说,记录在文件中是顺序排放的
从逻辑层面来说,记录是按照主键大小排序并通过链表组织的
考虑到:若记录的逻辑位置和物理位置是对应的,那么在现有结构中插入数据就会导致插入位置之后的记录都要移动
但是缺少验证方式,于是想通过 innodb_ruby 分析一波
实验数据说明: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');
预期结果:按照主键顺序排列,ID=400
的记录会放到ID=300
的记录后面,
若是在物理文件中采用 append
的方式添加则新添加记录在数据页中的偏移量会比ID=800
记录的偏移量值大
innodb_space -s ibdata1 -T rush/t -p 3 page-records
说明:
① 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
条记录,记录在页中的偏移量是递增的
INSERT INTO `t`(`ID`, `k`, `s`) VALUES (400, 4, 'dd');
同样执行上述innodb_space -s ibdata1 -T rush/t -p 3 page-records
① 通过 page-records
打印的信息
可以看到:ID=400
的记录在页中的偏移量为329
实际上这里测试不严谨,应该先判断是否出现由于插入记录导致页分裂的情况,不过这里的数据量很小,没有新增页。
下面是查看页信息的指令
② 通过space-page-type-regions
查看页信息
innodb_space -s ibdata1 -T rush/t space-page-type-regions
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
的记录
说明:查询的时候,该记录信息在第4条,infimum
和supremum
这两条虚记录并不显示
即检索的结果和通过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
对记录分组,组偏移量为最大记录的offset
,owned
为该组中的记录数
infimum--owned 1
:自成一组,即该组仅有一条记录
supremum--owned 5
:记录范围为1-8
,这里并不是因为普通记录只能容纳4条,
而是supremum
组满了,需要分裂,而conventional
类型的组至少容纳4条记录
ID=400--owned 4
:普通组最少4条,而ID=400的记录在本组中主键值最大,将其作为带头大哥
从这里看出在分组的时候还是按照链表的顺序即索引的顺序,否则无法利用二分查找
还有一点需要说明:仅带头大哥
的 owned
非 0
MySQL 是怎样运行的 pg 347 :
Page Header 部分中有一个 PAGE_FREE 属性,指向由被删除记录组成的垃圾链表中的头节点
阶段1:delete mark :设置记录的 deleted_flag = 1
,此时事务未提交,记录还在正常的记录链表中
阶段2:purge :将记录从正常记录链表中移除,加入到垃圾链表,此阶段在事务提交后执行
实验方案:开启事务,删除ID=700
的记录,观察事务提交前后的相关信息
包括:page-dump页记录, record-dump行记录,
① 开启事务,删除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
可以看到:即使删除一条记录,在事务未提交前,另一个 session 中仍然可以查询到待删除的记录
③ record-dump
行记录
innodb_space -s ibdata1 -T rush/t -p 3 -R 271 record-dump
使用 -R offset
检索指定偏移量的行记录信息,该offset值可以通过page-records
查看,不可任意指定
上述使用ID=700
待删除记录和ID=400
未删除记录对比
可以看到上述有两处不同:待删除记录 Deleted : true
,Insert : false
【有点不同,没有 delete_flag 信息】
④ page-dump
该记录在页中的信息
innodb_space -s ibdata1 -T rush/t -p 3 page-dump
同其他记录对比了下:仅上述两处不同,info_flags,和 is_insert
其他记录 info_flags = 0,由于修改实际上先处理旧记录再插入新记录,最后看到的info_flags
应该同 insert
① commit 提交事务
mysql> commit;
Query OK, 0 rows affected (0.01 sec)
② 开启新 session
查看表 t
由于单条SQL自动提交事务,这里可以看到修改后的情况
③ page-dump
页记录
已经查找不到 ID=700
的记录,而且从打印的行记录信息来看,该记录的前驱指向了后继
④ page-directory-summary
页目录
上述分别为事务提交前后的测试结果,可以看到事务提交后,该记录才不再显示到page-records
列表中
⑤ page-records
页记录信息
从④中可以看到 ID=700
的记录再第三组中,也即同supremum
在同一组,因此提交后,owned=4
问题:删除记录后,文件中记录原来的位置怎么处理?
page-illustrate
页插图
可以看到:删除一条记录后,其他记录的位置并不受影响,而该条记录的位置被划归到垃圾链表中
所以再次回归主题:记录在文件中物理位置和逻辑位置并不是对应的,逻辑位置依靠链表来组织
那么问题又来了,记录存放的时候紧密排列,修改的时候存储空间变化,怎么处理记录的位置?
留到3.修改一条记录
进行分析
测试目的:新插入存储空间相同的记录,验证是否会先使用垃圾链表中的空间
INSERT INTO `t`(`ID`, `k`, `s`) VALUES (900, 9, 'ii');
① session 下检索结果
② page-illustrate
页插图
注意看红框里面,上幅图里面,此处被标记为Garbage
,现在这里有了新的记录
通过打印记录在页中的偏移情况,进一步验证是否复用了Garbage
空间
③ page-records
记录的页内偏移
新记录ID=900
的记录复用了删除记录ID=700
的位置
需要注意的是:这里能复用的前提是,两条记录的存储空间大小相同
MySQL是怎样运行的
:每当新插入记录时,首先判断垃圾链表头节点代表的已删除记录所占用的存储空间是否足够容纳这条新插入的记录
后面还有一句:若不能容纳则直接向页面申请新空闲来容纳新记录的节点
拿本书作者常说的一句话:遍历是不可能遍历的,这辈子都不可能遍历
那么由于删除记录产生的碎片空间如何处理?
上图也看到了是有统计Garbage
空间的,若最后页面满到无法存放一条新记录时,
会统计Garbage
空间的大小以及剩余空间判断能否用来存放新记录,
若能存放,则尝试重组页面记录。意味着将Garbage
空间归并到剩余空间中,形成连续可用空间再插入记录
pg 353
:执行 UPDATE 语句时,分为 更新主键 和 不更新主键 两种情况
① 不更新主键:
就地更新:被更新的每个列,更新后列与更新前列占用的存储空间同样大
先删除旧记录,再插入新记录:被更新的列在更新前后所占用的存储空间大小不一致
② 更新主键:
事务提交前:对旧记录进行 delete mark 操作
创建新记录并将其插入到聚簇索引中
实验方案:分为存储空间变化,不变两种情况,另外验证对于变化的情况,事务未提交前会不会删除记录
存储空间不变:更新ID=400
的记录,设置s=DD
存储空间变小:更新ID=800
的记录,设置s=h
存储空间变大:更新ID=200
的记录,设置s=bbbb
① 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 端查询
执行这条查询,足足用了39.448s
,或许应该分析一波慢查询日志
但是提交后查询就很快,可能跟 buffer pool 有关,也或许跟快照读有关
③ page-records
页记录
显式开启事务,此时事务未提交,但通过page-records
查寻s
列的值已经变化
但前后 ID=400
的记录偏移量并未变化,即仍然在原来的位置上,而且并没有走delete mark
那套流程
① 更新 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
页记录
存储空间变小,仍然会在原来的位置上,但暂时看不到先删除的痕迹
③ page-dump
页中行记录
对比上述删除中的操作,info_flags
标识并未变化
以上操作均在事务未提交时进行,此时查询到的记录列已经更新,基本说明和2.删除一条记录
的流程不同
问题说明:书上写的是只要列变化,就会先删除,但是在其中一列变化,总体不变的情况下是否会变化还需要验证
① 更新 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
页记录
存储空间变大,ID=200
的偏移量为358
排在最后一条记录的后面
③ page-directory-summary
页目录
页目录变化情况,其实暗含了ID=200
的记录已经执行了删除再插入的操作
原因如下:忘记截插入ID=900
的图了,不过还是可以进行推理一波
插入ID=900
是为了验证复用了ID=700
的空间,且由于ID=700
在supremum
组
相当于supremum
组记录数一删一添后并未变化,即 owned=5
但是删除 ID=200
的记录后,conventional
组的记录owned=3
这是不满足最小4条记录规定的
因此会将supremum
组中的一条记录划归到conventional
说明:此图未删除ID=700
时的分组情况,conventional
组最大记录为ID=400
,将其标记为带头大哥
而删除记录ID=200
后情况应该变成这样
所以ID=500
的记录才能被标记为带头大哥
然后就是插入记录,由于conventional
组,允许4-8
条记录,所以情况变成了现在这样
从而从侧面验证了MySQL是怎样运行的 pg 83
非主键顺序插入的时候,普通组的记录数不完全是4
而书上普通组记录全是4
的原因是:
主键顺序插入,新记录都会分到supremum
组,然后不断分裂新组,而新组至少4条记录
④ page-illustrate
页插图
分析上图可以得出的结论:
ID=800
由于更新后减少了存储空间,更新后仍在原位,并与下一条记录ID=400
之间形成碎片
ID=200
由于更新后增加了存储空间,删除了原位置上的记录,并append
到记录最后
以上操作均在事务未提交时进行,此时查询到相关记录均已经更新,
基本说明和2.删除一条记录
的流程不同,即更新记录但不更新主键的情况下,若需要删除记录则直接删除
MySQL是怎样运行的 pg 356
更新主键:意味着记录在聚簇索引中的位置将会发生改变
这种情况下分两步进行:
将旧记录进行 delete mark
操作
创建新记录,并插入到聚簇索引中
注意:上面未更新主键的情况,已经反复验证不会走delete mark
操作
而更新主键时是否会走delete mark
操作呢?
测试方案:更新ID=500
的记录为ID=700
,事务提交前分析该记录的相关变更信息
测试分析:有无必要做存储空间变化的实验,若存储空间不变的情况下都是append
到最后一条记录后面
则存储空间变化的情况就没必要做了,此时的 UPDATE
操作相当于是先执行DELETE
再执行INSERT
【后面验证该句有误】
① 更新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
页记录
注意:此时出现了两条记录
其中更新主键后的记录ID=700
复用了ID=200
更新后删除的位置
原记录ID=500
还在原来位置上
③ page-dump
页行记录中的链接关系
可以看到ID=500
的记录并未在链表中被移除,其next=242
指向ID=600
的记录
而更新为ID=700
的记录,已经与前后链表节点建立的连接,其next=300
指向ID=800
的记录
④ page-illustrate
页插图
更新ID=200
时产生的Garbage
被ID=700
的记录重用,不过,若是存储空间变大,会插入到最后一条记录后面
这里也说明:不可能重用原来空间的原因,事务未提交时,原来的记录还未物理及逻辑删除
④ page-directory-summary
页目录
注意:此时 supremum = 5
,即确实在执行插入ID=700
这条记录
也即Delete Mark
线程和Insert
线程是独立执行的
MySQL是怎样运行的 pg356
:更新主键对旧记录的处理是事务提交前仅执行delete mark
,
事务提交后由purge
线程将其加入到垃圾链表中
① commit 事务
mysql> commit;
Query OK, 0 rows affected (0.02 sec)
② page-records
页记录
仅剩下ID=700
的记录
④ page-illustrate
页插图
ID=500
的记录被删除
④ page-directory-summary
页目录
ID=500
的记录被删除后,ID=400
的记录成为新的带头大哥
普通组中允许最少 4 条记录,因此不需要再从supremum
中瓜分
写在最后:本文仅分析了-p 3
,由于有两个索引,至少有两个索引页,
-p 4
为二级索引页,在修改记录的过程中,也会涉及二级索引页的变化
总结:
插入记录:先判断垃圾链表首节点能否复用,不能复用则append
到最后一条记录后面
删除记录:先走delete mark
阶段,即此时事务未提交,记录未删除,种种与该记录有关的内容暂未变更
而事务提交后,由 purge
线程将该记录加入到垃圾链表,但在物理文件上,该记录的位置仍然保留,
最后若有必要重组文件空间,则会将该部分的碎片空间和剩余空闲空间合并
更新记录:分为更新主键和不更新主键
更新主键=删除记录+插入记录【两者并行,删除记录受限于事务是否提交】
不更新主键:根据存储空间是否变化,决定是否复用当前位置原地更新,
若变大或变小则先删除【不走delete mark
】再插入,接着就是插入的流程
变小的那种情况,忘记根据page-directory-summary
验证是否是先删除了
不过由于存储空间变小复用原来的位置,由于是写覆盖,
若不先删除原有记录,剩余空间上还会保留旧记录,因此必定会先删除