上篇文章说了我们可以用begin 和statr transaction,提交可以commit,rollback回滚,可以指定回滚到保存点,也可以设置全局变量set autocommit off。也会隐式提交,比如开启事务后,如果操作或者新增了表,比如create table等语句,会隐式提交前面的sql。
本篇文章会频繁用到我们前面说过的innoDb记录行格式,页面格式,索引原理,表空间组成等各基础知识,如果下面的文章理解不了,建议先去阅读之前的文章。
Redo日志是什么
我们知道innoDB是以页为单位来存储空间的,我们在增删查该数据本质就是访问表空间的页面,读页面,写页面,创建页面等。前面说的buffer pool时候,在查询时,需要把磁盘上的页面数据缓存到内存的buffer pool才能访问。但是说道事务持久性,对于已提交的事务,但是在事务提交后发生了系统崩溃,这个数据的更改对于数据库也不能丢失。
但如果我们只在内存的buffer pool中修改了页面,假设事务提交后出现故障,导致内存里的数据都失效了,那么这个已提交的事务对数据库中所更改的也跟着丢失,但我们不能忍受的。那么如何保证持久性呢,一个很简单的做法就是在事务提交完成之前,吧所有修改的页面刷新到磁盘上,但这样简单粗暴的做法有点问题:
刷新完整数据太浪费:有时候我们在页仅仅修改了一个字节,我们又知道innoDB是以页为单位来进行磁盘I/O,也就是我们提交时候不得不把一个完整的页面刷新到磁盘,我们又知道一个页是16kb,只要修改一个字节就刷新16kb到磁盘太浪费了。
随机I/O刷起来比较慢:一个事务里可能有多个sql,一个sql里面可能改变多个不同的页,但是这些页面不会是相邻的,这就意味着要把这些不相邻的页刷新到磁盘上是随机I/O,相对于传统机械硬盘来说,随机I/O比顺序I/O慢很多。
怎么办呢,回到我们的初心,我们是为了在提交事务的时候,即是发生系统宕机,也能在重启的时候,吧修改的数据恢复,所以我们实现这个目的,没必要每次吧修改的数据刷新到磁盘,可以用一个文件吧这个修改日志记录下来,于是redo 日志就出来了,比如
将第0号表空间的100号页面偏移量为1000处的值更新为2。
这样在系统崩溃的情况下,也可以再重启后按redo日志里面的内容重新持久化。与之前事务提交时吧修改内存里的数据刷新到磁盘相比,吧redo日志刷到磁盘的好处:
redo日志占用的空间非常小:存储表空间id,页号,偏移量以及需要更新的值,占用空间非常小。
redo日志是顺序写入磁盘的:在执行事务中,每执行一个sql,可能会有上千条redo 日志,这些都是顺序I/O写入磁盘的。
Redo日志格式
我们前面知道了redo日志记录的是记录到磁盘I/O的数据,innoDB有多种不同类型的redo日志,但绝大部分redo日志都有这种通用格式:
Type:该条redo日志类型。(mysql5.7.21版本后,innoDB有53种不同类型)
SpaceId:表空间id。
Page number:页号。
Data :该条redo日志j具体内容。
简单的redo日志类型
我们前面说过,innoDB的记录行格式说过,如果表没有主见或者唯一键,则innoDB会添加一个row_id的隐藏列作为主键,为这个隐藏列赋值的方式如下:
服务器会在内存维护一个全局变量,每当给含row_id表插入一条记录的时候,全局的row_id都会增加1。
每当这个变量值为256的倍数时,就会把该变量的值刷新到系统表空间页号7的页面中一个称为max row id的属性处。
当系统启动时,会将max row id属性加载到内存,将该值加上256之后赋值给我们前边提到的全局变量(因为在上次关机时该全局变量的值可能大于max row id属性值)。
这种max row id属性占用的存储空间是8个字节,当某个事物向某个包含row_id的表插入一条记录,并且为该记录分配的row_id是256的倍数时,就会向系统表空间页号7的页面相应偏移量处写入8个字节的值。但是我们知道,这个写入实际是buffer pool中完成的,我们需要为这条修改的数据记录一条redo日志,以便在系统不小心宕机时可以根据redo日志恢复出来。这种修改很简单,只需要在redo日志记录在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容是啥就好了,mysql吧这种简单的redo日志称为物理日志,并且根据页面写入数据的多少划分多少种不同的redo日志类型:
MLOG_1BYTE(type字段对应的十进制数字是1):表示在页面某个偏移量处写入1个字节的redo日志类型。
MOLG_2BYTE(type字段对应的十进制数字是2):表示在某个页面偏移量处写入2个字节的redo日志类型。
MOLG_4BYTE(type字段对应的十进制数字是4):表示在某个页面偏移量处写入4个字节的redo日志类型
MOLG_8BYTE(type字段对应的十进制数字是8):表示在某个页面偏移量处写入8个字节的redo日志类型。
MOLG_WRITE_STRING(type字段对应的十进制数字是30):表示在某个页面偏移量处写入一串字符串。
我们前面提高的max_row_id属性实际占用8个字节,所以在修改页面该属性时,会记录一条MOLG_8BYTE的redo日志,MOLG_8BYTE的redo日志结构如下:type,spaceid,page number,offset,具体数据。其中offset记录的就是偏移量,其他的结构与molg_8byte类似。但是molg_write_string与前面不同,因为不确定具体占用多少字节,所以需要在日志中加入len字段,表示具体占用的字节数。(只要将len字段填上1,2,4,8这些数字,不就可以代替哪些molg_8byte吗,还不是为了省空间,能少一个len字段就少一个)
复杂一些的redo日志类型
有时候执行一条语句会修改多个页面,比如系统数据页面和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的b+树)。以一条insert语句为例,除了要给b+树插入数据,也可能更新系统数据max_row_id的值,不过对于我们用户来说,更关心b+树的更新:
表中包含多少索引,一条insert语句会更新多少棵b+树。
针对某一棵树,即可能更新叶子节点,也可能更新内节点,也可能创建新的页面(在该记录插入叶子节点的剩余空间比较少,不足以存放该记录,就会进行页分裂,在内节点添加目录项记录)。
在语法执行过程中,insert语句所有修改的页面都需要保存到redo日志去。这句话说得轻巧,但需要怎么做才能保存进去呢。比方说如果定位到叶子节点剩余空间足够,那么只要记录一条mlog_write_string类型的redo的日志就好了吗?当然不是,别忘了数据存储的页还有file header,page header,page directory等等,所以每往叶子节点插入一条数据,还有其他地方需要更新:
可能更新page Directory的槽信息。
Page header 各种页面统计信息,比如page_n_dir_slots表示槽数量可能会更改,page_heap_top代表未使用的空间,page_n_heap代表本页中的记录数量可能会更改等各种信息。
我们都知道数据页的记录是按索引组成的一个单向链表,每插入一条数据, 每插入一条数据,还需要更新上一条记录的记录头信息中next_recored属性来维护单向列表。
如果我们使用上面介绍的简单物理redo日志来记录这些修改,有两种解决方案:
方案一:在每个修改的地方都记录一条redo日志。
也就是只要有地方修改就记录一条,这种显而易见,修改的地方和需要记录的地方太多。
方案二:将整个页面第一个修改的地方和最后一个修改的地方之间的所有数据当做是一条redo日志的中的具体数据。这样缺点也很明显,不可能这之间的所有数据都会更改,哪些没有更改的数据也全部记录到redo日志里,不是非常浪费内存吗。
正因为这些方案比较浪费,所以innoDB本着勤俭节约的初心,设计出了更完善的redo日志存储方案:
MLOG_REC_INSERT(对应的十进制数字为9):表示插入一条使用非紧凑行格式的记录时redo日志类型。
MLOG_COMP_REC_ISNERT(对应的十进制数字为38):表示插入一条使用紧凑行格式的记录时redo日志类型。
(注意:Redundant行格式是比较原始的,非紧凑。而之后新的compact,dynamic,等新的行格式,就是紧凑的,占用内存更小)
MLOG_COMP_PAGE_CREATE(type字段对应的十进制数字为58):表示创建一个存储紧凑行格式记录的页面redo日志类型。
MLOG_COMP_REC_DELETE(type字段对应的十进制数字为42):表示删除一个存储紧凑行格式记录的redo类型日志。
MLOG_COMP_LIST_START_DELTE(type字段对应的十进制数字为44):表示从某条记录开始删除页面一系列使用紧凑行格式记录的redo类型日志。
MLOG_COMP_LIST_END_DELETE(type字段对应的是十进制数字为33):和start首位呼应使用,表示删除的一系列记录结束的位子。
(注意:我们前面说过,数据页存储的数据是按主键索引从小到大顺序排序的,所以我们如果删除连续的数,一个个记录效率很低,所以直接记录删除的头部和删除的尾部就好)
MLOG_ZIP_PAGE_COMPRESS(type表示对应十进制的51):表示压缩一个数据页的redo日志类型。
等等其他的后面用到在介绍。这些日志都包含了物理层面的意思,也包含了逻辑层面的意思,具体指:
物理层面:这些日志都记录了对哪个
逻辑层面:在系统崩溃重启的时候,并不能吧这些日志直接记载,而是先要执行一些函数,需要调用一些事先准备好的函数,在吧页面恢复成系统崩溃时的样子。
我们直接用MLOG_COMP_REC_INSERT插入一条紧凑行格式记录为例子,查看一下他的redo日志结构:
type:
spaceID:
page_number:
n_fields:该条记录代表有多少个字段。
n_uniques:决定该记录唯一的字段数量。
field1_len:
fileld2_len:
等等fileldn_len:这些统一代表各个字段占用存储空间的大小。
Offset:前一条记录的地址。
End_Seg_len:从当前字段可以计算出当前记录占用存储空间的大小。
Info_bits:表示记录头信息前4个比特位的值以及recore_type的值。
Extra_size:记录的额信息占用空间的大小。
Mismatch_index:为了节省redo日志大小而设立的字段,可忽略。
记录的真是数据。
而MLOG_COMP_REC_INSERT的redo日志有点需要注意的是:
我们前面说过,在数据页里,无论是叶子节点还内节点,都是按索引列从小到大排序的。对于二级索引来说,索引列值相同时,记录还需要按主键进行排序。N_Uniques代表该记录,需要几个字段才能确定唯一性,这样插入一条记录时,就可以按照之前的n_uniques个字段进行排序。对于聚簇索引来说,n_uniques的值代表主键的列数,对于其他耳机索引来说,n_uniques代表二级索引列数+主键列数。这里需要注意,唯一二级索引可能为null,该值仍然为索引列数+主键列数。
Field1_len~fieldn_len代表着该记录若干字段占用存储空间的大小,需要注意的是,这里不管是该字段类型是固定长度(int)还是可变长度varchar的,该字段占用的大小都要写入redo日志。
Offset代表该记录的前一条记录页面中的地址。为啥要记录前一条地址呢?为啥要记录前一个页面的地址呢,因为每新增一个记录,都需要修改头记录里的next recored的属性,所以插入新的数据,需要修改上一条记录的next recored属性,方便组成单向链表。
我们前面说过一条记录由额外数据和真实数据组成,这两部分占用的大小,就是一条记录占用空间的总大小。通过end_seg_len的值可以间接计算出一条记录占用的总大小,为啥不直接记录一条记录占用的总大小呢?因为为了节省存储空间,这里面计算的稍微有点复杂,总之就是为了节省redo日志占的大小。
(额外数据包含变长字段长度列表,null值列表,头部信息,后面就是真实数据,compact行如果发生数据存储溢出,真实数据列表会存储一部分真实数据,之后存储的就是指向页的页号,dynamic则在真实数据列表存储的全部都是指向页的页号)
Mismatch_index值也是为了节省redo日志大小而设立的。
很显然,MLOG_COMP_REC_INSERT并没有记录page_n_dir_slots的值修改了啥,page_heap_top修改了啥,page_n_heap修改了啥,而只是吧页中插入的一条记录必备的要素记录下来,之后系统崩溃时,服务器会调用相关某个页面插入一条记录的那个函数,而redo日志中的那些数据就可以被当做调用函数所需要的参数,在调用完函数后,这些page_n_dir_slots,page_heap_top,page_n_heap等值会恢复到系统崩溃前的样子。
Redo日志小结:上面只是吧redo日志都详细介绍了遍,如果不是为了解析redo日志工具,则没必要研究的透透,上面象征介绍几个类型的redo日志,让大家明白:redo日志会吧事务在执行过程中对数据库所做的修改都记录下来,在系统崩溃的时候,又吧事务所做的任何修改都恢复。(注意:为了节省空间,redo日志会吧数据压缩,比如space_id和page_number一般占用4个字节,但压缩后会更小)