Undo日志用什么存储结构支持无锁并发写入?
5月9日 相见欢投稿 redo日志只有崩溃恢复的时候才能派上用场,undo日志不一样,它承担着多重职责,MySQL崩溃恢复、以及正常提供服务期间,都有它的身影。
按照使用频次,undo日志的多重职责如下:
职责1,为MVCC服务,减少读写事务之间的相互影响,提升数据库的并发能力。
职责2,保证数据库运行过程中的数据一致性。事务回滚时,把事务中被修改的数据恢复到修改之前的状态。
职责3,保证数据库崩溃之后的数据一致性。崩溃恢复过程中,恢复没有完成提交的事务,并根据事务的状态和binlog日志是否写入了该事务的xid信息,共同决定事务是提交还是回滚。
undo日志需要为数据一致性和MVCC服务,除了要支持多事务同时写入日志,还要支持多事务同时读取日志。
为了有更好的读写并发性能,它拥有与redo日志完全不一样的存储结构。
本文我们就来聊聊undo日志的存储结构,它是怎么写入undo日志文件的,以及事务二阶段提交过程中和它有关的操作。
本文内容基于MySQL8。0。29源码。
目录1。概述2。undo表空间3。回滚段3。1什么是回滚段?3。2分配回滚段4。undoslot4。1什么是undoslot?4。2寻找undoslot5。undo段5。1什么是undo段?5。2复用缓存的undo段5。3创建undo段6。undologheader6。1什么是undologheader?6。2复用undologheader6。3创建undologheader7。inode7。1什么是inode?7。2分配inode8。写undo日志9。二阶段提交之undo日志9。1prepare阶段9。2commit阶段10。总结
正文1。概述
undo日志的存储结构比较复杂,我们先以倒序的方式来介绍一下存储结构的各个部分,以便大家有个整体了解。
undologheader:一个事务可能产生多条undo日志,也可能只产生一条undo日志,不管事务产生了多少条undo日志,这些日志都归属于事务对应的日志组,日志组由undologheader负责管理。
undo页:undologheader和undo日志都存储于undo页中。
undo段:为了多个事务同时写undo日志不相互影响,undo日志也使用了无锁设计,InnoDB会为每个事务分配专属的undo段,每个事务只会往自己专属undo段的undo页中写入日志。
一个undo段可能会包含一个或多个undo页,多个undo页会形成undo页面链表。
inode:每个undo段都会关联一个inode。
undo段本身并不具备管理undo页的能力,有了inode这个外挂之后,undo段就可以管理段中的undo页了。
回滚段:一个事务可能会需要14个undo段,很多个事务同时执行,就需要很多个undo段,这些undo段需要有一个地方统筹管理,这个地方就是回滚段。
undoslot:一个回滚段管理着1024个undo段,每个回滚段的段头页中都有1024个小格子,用来记录undo段中第一个undo页的页号,这个小格子就叫作undoslot。
重要说明:一个回滚段管理1024个undo段,这是基于innodbpagesize是16K(默认值)的情况,本文涉及到页大小的内容,都以16K的页为前提,后面就不再单独说明了。
undo表空间:,一个回滚段能够管理1024个undo段,看起来已经很多了,假设每个事务都只需要1个undo段,如果只有一个回滚段也只能支持1024个事务同时执行。
对于拥有几十核甚至百核以上CPU的服务器来说,这显然会限制它们的发挥。
为了充分发挥服务器的能力,有必要支持更多事务的同时执行,所以就有了undo表空间,一个undo表空间最多可以支持128个回滚段。
不止于此,InnoDB还能够最多支持127个undo表空间,这样算起来,所有回滚段总共能够管理的undo段数量是:102412812716646144。
这么多undo段,是不是瞬间就有了地主家有余粮的感觉?
看了上面的介绍,相信大家能对undo日志有个整体了解,接下来我们就按照这个整体结构,自顶向下来详细介绍其中的每个部分。2。undo表空间
一个独立的undo表空间对应磁盘上的一个文件。
MySQL8。0开始,强制开启了独立的undo表空间,支持创建2127个undo表空间,默认数量为2,可以通过CREATEUNDOTABLESPACE增加undo表空间,通过DROPUNDOTABLESPACE减少undo表空间。
每个undo表空间都可以配置1128个回滚段,可以通过系统变量innodbrollbacksegments来控制每个undo表空间中的回滚段数量,默认值为128。
每个undo表空间中,pageno3的页专门用于保存回滚段的段头页的页号,这个页的类型是FILPAGETYPERSEGARRAY,从Offset56开始,保存着128个回滚段的段头页的页号,如下图所示:
3。回滚段3。1什么是回滚段?
InnoDB中凡是被称为段的东西,都是用来管理数据页的一种逻辑结构。
回滚段也不例外,它也是管理数据页的一种逻辑结构。
回滚段管理了什么页呢?
回滚段有一点点特殊,它只管理一个页,就是回滚段的段头页。
每个回滚段中只有段头页这一个数据页,由此可见,管理数据页并不是它最重要的职责。
在概述小节,我们介绍过,每个回滚段中都有1024个undoslot,可以统筹管理1024个undo段,这才是回滚段最重要的职责。
基于前面的介绍,我们可以给回滚段下一个定义了:回滚段是一种逻辑结构,它负责段头页的分配,以及管理其中1024个undoslot对应的undo段。3。2分配回滚段
开启一个读写事务,或者一个只读事务转换为读写事务时,InnoDB会为事务分配一个回滚段。
默认配置下,2个undo表空间总共有256个回滚段,这么多回滚段,就涉及到怎么均衡使用的问题了。
2个undo表空间,在内存是中一个数组,下标分别为0、1。
每个undo表空间中128个回滚段,在内存中也是一个数组,下标为0127。
以undo表空间下标、回滚段下标组成一个元组,用于表示默认配置下的256个回滚段,如下:
(0,0)、(0,1)、、(0,126)、(0,127)
(1,0)、(1,1)、、(1,126)、(1,127)
分配回滚段的逻辑是按照undo表空间、回滚段轮流着来,顺序是这样的:
(0,0)、(1,0)、(0,1)、(1,1)、、(0,126)、(1,126)、(0,127)、(1,127)
分配顺序用图片展示是这样的(按照箭头顺序分配):
每次分配时,都会记录这次分配的是哪个回滚段。
下次再分配时,按照上面的顺序,把最后一次分配的回滚段之后的那个回滚段分配给事务。
InnoDB中的回滚段,分为普通表回滚段、用户临时表回滚段,前面介绍的是普通表回滚段分配逻辑。
用户临时表只有一个独立undo表空间,默认128个回滚段,需要分配临时表回滚段时,只要轮流分配就行了。4。undoslot4。1什么是undoslot?
每个回滚段的段头页中都有1024个小格子,每个小格子就是一个undoslot,用于记录分配给事务的undo段的段头页页号,如下图所示:
4。2寻找undoslot
一条DML语句即将要修改数据之前,会先记录undo日志。
记录undo日志之前,需要先创建一个undo段。
undo段要把自己交给回滚段管理,这需要在回滚段的段头页中找一个undoslot占个位。
寻找undoslot的过程简单粗暴,从回滚段1024个slot中的第一个slot开始遍历,读取slot的值,只要slot的值等于FILNULL,就说明这个slot没有被别的undo段占用,当前undo就可以占上这个位置。
FILNULL是32位无符号整数的最大值,十六进制表示为0xFFFFFFFFUL,十进制表示为4294967295。
如果遍历到最后一个slot,都没有发现值FILNULL的slot,那就说明分配给当前事务的回滚段没有可用的slot了。
这种情况下,InnoDB并不会再重新给事务分配一个回滚段,而是直接报错:Toomanyactiveconcurrenttransactions。5。undo段5。1什么是undo段?
undo段,也是段字辈,那它自然也是管理数据页的一种逻辑结构了。
undo段管理的数据页就是用来存放undo日志的页,也就是undo页。
按照对于表的增、删、改操作是否需要记录redo日志来分类,undo段可以分为2种类型:
临时表undo段,对于用户临时表的增、删、改操作,数据库崩溃之后重新启动,不需要恢复这些表里的数据,也就是说临时表里的数据不需要保证持久性,因此不需要记录redo日志。
但是,如果事务对用户临时表进行了增、删、改操作,事务回滚时,用户临时表中的数据也需要回滚,所以需要记录undo日志。
普通表undo段,对于普通表的增、删、改操作,数据库崩溃之后重新启动,需要把这些操作修改过的数据,恢复到数据库崩溃时的状态,所以需要记录redo日志。
事务回滚时,对于普通表进行的增、删、改操作,表中的数据也需要回滚,所以需要记录undo日志。
按照增、删、改操作来分类,undo段也可以分为2种类型:
insertundo段,用于保存insert语句产生的undo日志的undo段。
updateundo段,用于保存update、delete语句产生的undo日志的undo段。
为什么要区分insertundo段和updateundo段?
因为insert语句产生的undo日志,在事务提交时,如果undo段不能被缓存起来复用,就会直接释放。
update、delete语句产生的undo日志,在事务提交时,如果undo段不能被缓存起来复用,也不会直接释放,而是要服务于MVCC。
等到undo日志中的历史版本数据不再被其它事务需要时,这些undo日志才能被清除。
关于undo日志什么时候能被清除的细节,留到purge线程清理undo日志的文章再写。
此时,如果undo日志所在的undo段中没有其它有效的undo日志时,undo段才能被释放。
按照前面的2种维度分类,可以形成4种类型的undo段:插入记录到用户临时表,是临时表insertundo段。更新、删除用户临时表中的记录,是临时表updateundo段。插入记录到普通表,是普通表insertundo段。更新、删除普通表中的记录,是普通表updateundo段。
在同一个事务中,以上4种类型的undo段都有可能出现,所以,一个事务中就可能会需要14个undo段。5。2复用缓存的undo段
每创建一个undo段,需要经过一系列的操作:从inode页中找到一个未被使用的inode。分配一个inode页(可能需要)。为undo段分配一个undo页。初始化内存中的undo段对象。初始化内存中的undologheader对象。其它操作。。。
这些初始化操作都是需要时间的,频繁创建就有点浪费时间了。为此,InnoDB设定了一个规则,在事务提交时,符合规则的undo段就可以被缓存起来,给后面的事务重复使用。
undo段可缓存复用的规则,本文后面二阶段提交的commit阶段会介绍。
前面介绍过,事务中使用undo段时,按照2种维度分类会形成4种类型的undo段,这是不是有点复杂?
undo段缓存就比较简单了,只分了2种:insertundo段、updateundo段。
有了undo段缓存之后,就不需要每次分配undo段时都从头开始创建一个了。
如果要为用户临时表、普通表的insert语句分配一个undo段,就去insertundocached链表中(缓存insertundo段的链表)看看有没有undo段可以复用。
如果有,就取链表中的第一个undo段来用;如果没有,就创建一个新的insertundo段。
如果要为用户表、普通表的update、delete语句分配一个undo段,就去updateundocached链表中(缓存updateundo段的链表)看看有没有undo段可以复用。
如果有,就取链表中的第一个undo段来用;如果没有,就创建一个新的updateundo段。5。3创建undo段
InnoDB给事务分配一个undo段时,如果没有缓存的undo段可以复用,需要创建一个新的undo段。
创建一个新的undo段,会经历以下几个主要步骤:
第1步,找到一个inode,undo段会关联一个inode,用于管理段中的页。
inode后面会有一个小节单独介绍,这里先不展开。
第2步,从表空间0号页的FileSpaceHeader中读取FSPSEGID,作为新创建的undo段的ID(segid),把segid写入inode的FSEGID字段,表示这个inode已经被占用了。
第3步,通过inode分配一个新的空闲页作为undo段的段头页。
每个undo段都会有一个UndoSegmentHeader,位于undo段的段头页中,如下图所示:
第4步,把inode的地址信息写入UndoSegmentHeader的TRXUNDOFSEGHEADER字段。
inode的地址由3个部分组成:inode所在页的表空间IDinode所在页的页号inode在页中的Offset
第5步,把段头页加入undo页面链表的最后,undo页面链表的基结点位于UndoSegementHeader的TRXUNDOPAGELIST字段中。
第6步,把undo段的段头页页号写入回滚段中分配给当前undo段的undoslot中,表示这个undoslot被占用了。
经过以上步骤后,undo段就创建成功了,可以继续进行接下来的操作了。6。undologheader6。1什么是undologheader?
一个事务产生的undo日志属于一个日志组,undologheader是日志组的头信息,各字段如下图所示:
介绍几个主要字段:
TRXUNDOTRXID,产生这组undo日志的事务ID。
TRXUNDOTRXNO,事务的提交号,事务提交时会修改这个字段的值。
TRXUNDONEXTLOG,undo段中下一组undo日志的undologheader在页中的Offset。
TRXUNDOPREVLOG,undo段中上一组undo日志的undologheader在页中的Offset。
TRXUNDOHISTORYNODE,表示这组undo日志是history链表的一个结点,purge线程清理TRXUNDOUPDATE类型的undo日志时会用到这个字段。6。2复用undologheader
如果分配给事务的insertundo段,是从insertundocached链表中获取的,undo段中的undologheader是可以直接复用的,但是其中4个字段需要重新初始化:
TRXUNDOTRXID,写入新的事务ID。
TRXUNDOLOGSTART,重置为undologheader之后的位置,表示可以写undo日志的位置。
TRXUNDOFLAGS,undo日志组的标记重置为0。
TRXUNDODICTTRANS,表示当前这组undo日志是否由DDL语句事务产生。
由于updateundo段中的undo日志未被清理之前都需要为MVCC服务,如果分配给事务的updateundo段是复用的undo段,不能复用其中的undologheader,而是会生成一个新的undologheader,追加到上一个事务生成的undo日志之后的位置。6。3创建undologheader
新创建一个insertupdateundo段,或者复用一个updateundo段时,都需要创建一个undologheader。
创建一个新的undologheader,就是把undologheader中的每个字段值按顺序写入undo页中,然后在内存中也会生成一个对应的结构(structtrxundot),并初始化其中的各个字段。
需要单独拿出来说的字段有两个:
TRXUNDOPREVLOG,指向updateundo段中上一个事务生成的undo日志在updateundo段的段头页中的Offset。
TRXUNDONEXTLOG,指向updateundo段下一个事务生成的undo日志在updateundo段的段头页中的Offset。
通过这两个字段,updateundo段中的多组undo日志就形成了链表,purge线程清理undo日志时就可以通过链表找到undo页中的所有undo日志了。7。inode7。1什么是inode?
不管是回滚段、undo段,还是索引段,只要是段,都会关联一个inode。
inode是真正用于管理与它关联的段中数据页的逻辑结构,undo段之所以能够管理其中的undo页,关键就是因为undo段关联了inode。
inode结构如下图所示:
由上图可见,inode中有32个fragmentpageslot,可以管理32个碎片页。
还有3个以extent为单位管理数据页的链表:
页大小为16K时,1个extent中有64个页。
FSEGFREE,这个链表的每个extent中,所有页都没有被使用,全都是空闲页。
FSEGNOTFULL,这个链表的每个extent中,都有一部分页已被使用,另一部分页是空闲页。
FSEGFULL,这个链表的每个extent中,所有页都已经被使用,没有空闲页。
根据页的分配规则,inode关联的段每分配一个页,既有可能从32个fragmentpageslot中找一个空闲页,也有可能从FSEGFREE、FSEGNOTFULL中找一个空闲页。7。2分配inode
undo表空间中,有专门的inode页用于存放inode,每个inode占用192字节,16K的inode页最多能够存放85个inode,如下图所示:
undo表空间0号页的FileSpaceHeader中,有2个管理inode页的链表:
FSPSEGINODESFULL,这个链表中的所有inode页都存放了85个inode,不能再存入新的inode。
FSPSEGINODESFREE,这个链表中的所有inode页都还有空闲空间可以存入新的inode。
FileSpaceHeader中的各字段如下图所示:
每次为事务创建一个新的undo段之前,都会先从FSPSEGINODESFREE链表的第一个inode页中获取一个可用的inode。
从inode页中获取inode的逻辑简单粗暴:
从inode页中的第一个inode开始遍历,直到找到一个FSPSPACEID字段值为0的inode,表示这个inode未被其它undo段占用,可以分配给当前undo段。
不过,有可能会出现一个意外情况,就是FSPSEGINODESFREE链表中没有可用的inode页。
这种情况下,需要先从undo表空间中分配一个碎片页,用作inode页,然后再按照前面介绍的分配inode逻辑,给当前undo段分配一个inode。8。写undo日志
本文不会详细介绍undo日志的格式,但是,每一种类型的undo日志中,都有2个字段,用于把undo日志组中的多条日志组成日志链表,需要介绍一下。
每一条undo日志中,第一个字段是nextrecordstart,占用2字节,保存着下一条undo日志的第一个字节在undo页中的Offset。
每一条undo日志中,最后一个字段是recordstart,占用2字节,保存着当前这条undo日志第一个字节在undo页中Offset。
nextrecordstart、recordstart是为了描述方便而取的名字。
通过这2个字段,同一个undo页中的多条undo日志可以形成一个双向链表,如下图所示:
从前往后遍历undo日志时,通过nextrecordstart就可以直接读取到下一条undo日志的Offset了。
从后往前遍历undo日志时,通过recordstart只能读取到本条日志的Offset,再读取本条日志的Offset2处的字段内容,就能得到上一条undo日志的Offset了。
为什么要把nextrecordstart作为undo日志的第一个字段,recordstart作为undo日志的最后一个字段?
我第一次看到undo日志的这个结构,是在看《MySQL是怎样运行的》这本书的时候,当时感觉这样的结构很不好理解。
研究完源码写本文的时候,我试图为这个结构找到一个合理的解释,以方便大家理解。
但是,想了几种不使用这个结构可能会带来的坏处(例如:占用更多存储空间,遍历undo日志的时候需要更多时间等等),都没有找到必须要这样设计的合理解释。
所以,大家也不用纠结为什么会这样设计,就当它是个普通的链表指针就好了。
当然了,如果有哪位小伙伴发现了这么设计的好处,也欢迎在文末留言或者微信交流。
正常写入undo日志的过程比较简单:先写undo日志数据。再写nextrecordstart、recordstart在其所处undo页中的Offset。
undo日志是每产生一条就往undo日志文件中写入一条(只是写到BufferPool中undo页,由刷盘操作统一刷新到磁盘)。
写undo日志中每个字段的细节就不再展开了。
写undo日志的过程中可能会面临一个临界点:
前面我们提到过,undo段是可以复用的,对于复用的insertundo段,逻辑比较简单,直接覆盖undo段中原来的日志数据就可以了。
对于复用的updateundo段,由于其中的undo日志还需要为MVCC服务,不能被覆盖,需要把新的undo日志追加到原来的undo日志之后。
这样一来就可能会出现2种情况:
情况1,undo页中剩余空间足够写入一条新的undo日志,这种情况就简单了,直接把新的undo日志写入undo页中剩余的空间。
情况2,undo页中剩余空间不够写入一条新的undo日志了,这种情况稍微复杂点,会分三步进行:把undo页中剩余空间的所有字节全部填充为0xff。创建一个新的undo页。把undo日志写入到新的undo页中。
还有一种不应该出现的情况:
由于一条undo日志内容太长,一个空闲的undo页都存不下一条undo日志。
正常情况下不会发生这样的事情,只有MySQL源码有bug的时才会出现。
text、blob系列大字段,存储的内容长度可能超过undo页的大小,更新操作的undo日志有可能会超过undo日志的大小吗?
如果源码没bug的话,不会超过的,对于text、blob系列大字段,记录undo日志时并不是直接把字段内容原封不动的写到undo日志里,而是会做一些处理,只会有少量内容写到undo日志里。
关于text、blob系列大字段具体会往undo日志里写入什么,如果有小伙伴感兴趣,可以留言或者微信交流,后续我可以再进一步研究这些细节,然后写篇文章单独介绍。9。二阶段提交之undo日志9。1prepare阶段
在prepare阶段,undo日志为事务做的最重要的2件事:修改undo段状态,把UndoSegementHeader的TRXUNDOSTATE字段值从TRXUNDOACTIVE修改为TRXUNDOPREPARED,表示undo日志对应的事务已经进入prepare阶段。把事务xid信息写入UndologHeader中。
undo段状态用于崩溃恢复过程中,标记哪些事务需要恢复,哪些事务不用恢复。
xid信息用于崩溃恢复过程中,决定数据库崩溃时处于prepared阶段的事务,是要回滚还是要提交。9。2commit阶段
到了commit阶段,insertundo日志的使命就已经结束了,updateundo日志还需要为MVCC服务。
不管是insertundo段还是updateundo段,只要满足以下2个条件都可以被缓存起来复用:undo段中只有一个undo页。包括FileHeader、PageHeader在内,undo页已使用空间必须小于undo页总字节数的四分之三。
对于insertundo段,如果能复用,会进行以下操作:
步骤1,UndoSegmentHeader的TRXUNDOSTATE字段值由TRXUNDOPREPARED变为TRXUNDOCACHED。
步骤2,事务对应undo日志组的undologheader对象加入回滚段insertundocached链表的最前面,以备下一个事务复用。
如果不能复用,会进行以下操作:
步骤1,UndoSegementHeader的TRXUNDOSTATE字段值由TRXUNDOPREPARED变为TRXUNDOTOFREE。
步骤2,undo段关联的inode的FSEGID字段改为0,表示inode可以被其它undo段使用,然后释放undo段中分配的所有undo页。
步骤3,把insertundo段占用的undoslot值会改为FILNULL,表示这个undoslot处于空闲状态,可以被其它事务使用了。
对于updateundo段,如果能复用,会进行以下操作:
步骤1,UndoSegmentHeader的TRXUNDOSTATE字段值由TRXUNDOPREPARED变为TRXUNDOCACHED。
步骤2,通过undologheader字段TRXUNDOHISTORYNODE把undo日志组加入historylist链表。
purge线程通过遍历historylist链表来清除undo日志。
步骤3,把事务提交号写入undologheader字段TRXUNDOTRXNO。
purge线程用这个字段来判断undo日志是否能够被清除、标记删除的记录是否能够彻底删除。
步骤4,事务对应undo日志组的undologheader对象加入回滚段updateundocached链表的最前面,以备下一个事务复用。
如果不能复用,会进行以下操作:
步骤1,UndoSegementHeader的TRXUNDOSTATE字段值由TRXUNDOPREPARED变为TRXUNDOTOPURGE。
步骤2,updateundo段占用的undoslot的值改为FILNULL,表示这个undoslot处于空闲状态,可以被其它事务使用了。
步骤3,从回滚段RollbackSegnemtHeader中读取TRXRSEGHISTORYSIZE,加上undo段中undo页的数量,然后回写到TRXRSEGHISTORYSIZE中,作为historylist链表中最新的undo页数量。
undo能够复用时,不会修改TRXRSEGHISTORYSIZE字段值。
步骤4,通过undologheader字段TRXUNDOHISTORYNODE把undo日志组加入historylist链表。
purge线程通过遍历historylist链表来清除undo日志。
步骤5,把事务提交号写入undologheader字段TRXUNDOTRXNO。
purge线程用这个字段来判断undo日志是否能够被清除、标记删除的记录是否能够彻底删除。
小结一下,commit阶段,就是undo段能复用就复用,不能复用就直接清理释放(insertundo段),或者等待purge线程清理释放(updateundo段)。10。总结
InnoDB支持2127个独立表空间,每个表空间支持1128个回滚段,每个回滚段支持1024个undoslot,可以管理1024个undo段。
undo段可以分为4种类型:临时表insertundo段、临时表updateundo段、普通表insertundo段、普通表updateundo段。
如果undo段中只有1个undo页,并且undo页中已使用空间小于undo页大小的四分之三,undo段可以被缓存起来复用。
可以复用的insertundo段缓存到insertundocached链表,可用复用的updateundo段缓存到updateundocached链表。
每个undo段都会关联一个inode,用于管理段中的页,inode存放于表空间的inode页中。
一个事务产生的一条或多条undo日志会形成一个日志组,日志组由undologheader负责管理。
多条undo日志通过日志中的nextrecordstart、recordstart形成双向链表。
写undo日志时,如果复用的updateundo段的段头页中剩余空间不够存放一条undo日志时,会分配一个新的undo页,并把undo日志写入到新的undo页中。
以上就是本文的全部内容了,如果本文对你有所帮助,还请帮忙转发、点赞,谢谢