数据库核心-redo、undo
一、redo日志
InnoDB操作以页为单位操作数据。并且首先操作内存中缓冲池的数据,然后刷新到disk中,但如果事务提交后宕机、未能刷新到disk中,就会造成不一致情况。
重做日志: 系统重启时按照修改步骤重新更新数据页
- redo日志占用空间小
- 顺序写入disk,不会随机IO
通用结构:
- type:redo日志类型
- space ID:表空间ID
- page number:页号
- data:具体内容
物理日志:记录的是内存操作,某个页面某个偏移量处修改了几个字节的值
根据修改字节数不同,又分为不同大小的日志。
但是,一个普通的insert语句,涉及的修改处不只是data的添加,还有页头信息的各种相关修改,杂七杂八,如果把这个修改点都记录下来,那这个物理redo日志是不是太多了,如果都写在一起,岂不是太冗余了,因此就有新的redo日志类型,针对不同行格式的数据产生不同的redo日志。这些日志既有物理页面意思,又有逻辑层面意思。只会把插入一条记录的必备要素记下来,之后重做时根据相关函数实现插入。
1.1Mini-Transaction
一个insert会涉及多个redo日志,但如果插入过程只记录了一部分redo日志,会导致重做结果不确定性,这是不能忍受的因此我们在执行具有原子性操作时,必须以组的形式记录日志。重做时,该组redo要么都恢复,要么都不做。
而Innodb中,有一个特殊类型的日志,该日志只有一个字段type。因此,需要保证原子性的一系列redo日志必须以该类型日志结尾。这样重做时,只要恢复到了该类型日志时,才认为完成了一个操作,不然,放弃之前恢复的其他结果。
**Mini-Transaction:**对底层页面进行一次原子访问的过程;一个MTR可以包含一组redo,用来记录某个原子操作。一条sql语句包含若干MTR;
1.2redo写入步骤
redo日志都被放在512字节的页中,类似于一个数据page,但这里称为block,同样有块头,存储基本信息,比如,块编号、已经使用多少字节等等。
同数据页,redoblock并不是直接写入disk,而是先写入bufferpool,(redo log buffer redo日志缓冲区),这片内存被划分为若干个连续的block。
写入buffer是顺序写入的,因此需要指明应该从buffer的哪个位置开始写——buf_free,该位置之后的就是空闲区;
一个事务由多个MTR组成,而一个MTR会产生多个日志,这些日志并不是产生就写入buffer,而是以MTR为单位,MTR结束时整个相关日志一起写入buffer,
刷盘时机:
- buffer空间不足:已经写入50%,就需要刷盘
- 事务提交:redo日志就是为了保持一致性,因此事务提交时,pagebuffer内容可能不会及时刷新,但redobuffer一定会及时刷新。
- 脏页刷新:因为特殊需求,pagebuffer中脏页刷新到disk,脏页对应的redo也需要提前刷新,并且因为redobuffer是顺序写入的,因此脏页日志前面的日志也会刷新
- 后台线程默认频率刷新redobuffer
- checkpoint刷新
磁盘中的redo日志由多个文件组成,成循环方式相连——日志文件组
日志文件组的前4个block2048字节记录管理信息,之后开始记录日志内容。
lsn-记录已经写入的redo日志量,这个值包括了head和tail,并且初始值就是8704,简单分析一下,系统刚启动,buffer刚刚初始化,lsn就要+12字节,因为虽然没日志,但第一个block的head要包括进去,然后随着工作,开始写日志,lsn不断增加,第一个block满了,就会使用第二个,lsn就要加个tail和head,不断重复下去。。。
bufferpool中会有一个flush链表,来记录被修改过的脏页信息,都是一个一个控制块,如果某个页第一次被修改,就会加入到flush链表的头部,如果之前修改过,那就修改对应控制块信息就行了;控制块中有两个信息oldest和newest,分别表示页面最早开始修改的lsn和最新修改的lsn。例如一个新的页被修改,对应的MTR在刚开始修改页时的lsn会记录在oldest中,之后修改完后,最新的lsn会记录在newest中。如果是之前修改过的页,那对应控制快的oldest不会变,只会修改newest
checkpoint: redo日志容量有限,随着bufferpool中的脏页被刷新到disk中,那么对应的redo日志占用的disk空间就没用了,就可以被重复利用。利用checkpoint_lsn表示当前系统中可以被覆盖的redo日志总量是多少。
步骤:
- 计算当前可以被覆盖的内存lsn最大是多少。flush链表尾部是最早修改的,该控制块的oldestlsn则表示最早的lsn,那么该lsn之前的就是可以被覆盖的!找到了可以被覆盖的lsn最大值。
- 将checkpoint_lsn与对应的redo日志文件组偏移量、checkpoint编号写到日志文件管理信息中。
崩溃恢复: 只需要恢复checkpoint_lsn之后的redo日志记录,虽有其中有的脏页可能已经刷新到disk中,但不确定是哪些,因为是异步进行的。
在redo日志组中第一个文件的管理信息中,两个block存储了checkpoint_lsn的信息,但我们需要最新的,而其中的checkpoint_no表示checkpoint次数,因此只需要对比两个该参数的值,大的表示记录的最新的信息,然后找到存储的checkpoint_lsn、redo日志文件组偏移量checkpoint_offset,这样起始位置就找到了,而终止位置,每个block头信息都有个对应存储量,没有存储满的就是最后一个,就找到中止位置了。
这样顺序遍历就可以恢复日志记录,但效率有点慢;
利用redo日志的spaceID和pageid计算哈希值,把两个属性相同的放在同一个槽中,因为这是操作同一个表的,放在一起效率更高,避免读取页面的随机IO,加快恢复速度。
刚才说某些脏页可能被刷新到disk中了,每个页头都一个变量表示最新修改的lsn,如果执行checkpoint,那么刷新的页中记录的改变量值就会大于整个redo日志组的checkpoint_lsn,所以处于[checkpoint_lsn,变量]之间的日志就不需要redo,而小于checkpoint_lsn的本身已经写到disk中了,因此只需要redo大于变量的那部分。
二、undo日志
redo保证了事务一致性,那么原子性呢,需要事务执行一半的操作撤销,也就是回滚——undo log
事务只有在进行增删改、创建临时表时才会分配事务id,只读事务没有事务id,默认为0
row_id,当表中没有定义主键并且没有不允许为NULL的unique键时,系统就会自动加row_id的隐藏列,而trx_id表示操作该记录的最新事务id,都很好理解了。
undo日志因为操作不同而分为不同的日志类型。
插入:
另一个隐藏列roll_point就是一个指针,指向了改记录对应的undo日志,也就表示改记录的上一个历史版本,MVCC会用到。
删除:
页面头信息会根据next_point组成一个正常记录的单向链表;同时page_free指向删除链表,表示空间可以重复利用的。而delete操作,首先会将记录删除标记位设置为1,此时仍处于正常链表中,是一个中间状态记录(delete mark)。当delete操作提交时,才会将记录移动到删除链表中的头结点处(purge)。值得注意的是,当插入记录时,首先判断删除链表头结点的空间是否可以存储新纪录,如果不可以,直接另开辟空间存储,不会遍历删除链表!如果可以,就重复利用该空间,当然,会有一小部分空间得不到利用,称为碎片空间,该部分会在整个页的删除节点都利用完了,此时才会使用,将整个链表重构,这样那些碎片空间就整理在一起了,可以被利用,但同时也会耗费性能。
而undo肯定是考虑事务未提交的情况下哎,也就只考虑delete mark阶段,
更新:
更新就情况很多了,涉及主键更新、不涉及的;更新前后大小一致的、不一致的。
如果更新前后大小一致,直接原地更新即可!如果大小不一致,会讲旧记录删掉,插入新纪录以实现更新效果;
如果不更新主键,会有一种新的undo日志类型来表示,记录各种信息。但如果更新主键,那就麻烦了,因为聚簇索引有排序,如果更新的值变化很大,索引中的位置就要变化,跨度多个页,因此首先是旧记录的deletemark,然后更新后记录的插入。事务提交之后deletemark才会执行purge,与之前的更新大小不一致做区分!
之前都是聚簇索引,如果说是二级索引,插入和删除操作差不多,但更新不一样会对二级索引b+树进行更改。
会有专门存储undo日志的page,大体结构与其他page一致,不过会有个undopageheader,这是特有的,里面记录了存储什么类型undo日志(大类,并不是前面介绍的很细致的undo类型:广义的插入和更新;因为插入undo事务提交后会删除,而更新的需要支持mvcc),写入终止位置偏移量,写入undo日志起始位置偏移量。这是从空间页的角度看undo日志的。
事务角度: 一个事务涉及多个语句,一个语句会产生多个undo日志,所以事务执行产生很多undo日志,一个page可能放不写,因此需要把同一个事务的undo日志连成链条。而之前说的undo页面有两大类分开存储,因此,一个事务有两条双向链表页面,而Innodb规定对于普通表和临时表的操作产生不同的undo日志,因此又变成了4个链表!并且并不是一次都分配空间的,而是随着事务执行,用到哪个就分配哪个,因此4个是上限,实际多少个根据事务操作而定。不同事务之间链表不同。
undolog写入过程:
首先回顾一下段的概念,逻辑上的内存划分方法,根据程序员自己需求进行划分,索引被划为为两个段-叶子节点段、非叶子节点段。逻辑上将离散的页面分成两部分。每个段有一个INODE Entry结构记录段信息,每个页面的id,段id、链表地址等等。而为了重定位这个结构,有一个Segment Header记录了哪个INODE的表空间、页号,偏移量。就能找到那个段了。
每一个undo页面链表对应一个段,链表中的节点空间都是从这个段中申请的,所以链表头节点有个undo log segment header结构,存储这个段的相关信息,一个段也叫一组。
重用undo页面:如果修改的记录很少,undo页面链表很占用空间,不值得,因此事务提交后会重用该事务的undo页面链表。能重用的页面满足两个要求:
- 页面链表只包含一个undo页面
- 页面已经使用的空间小于整个页面空间的3/4
而页面链表分为了insert和update两种,不同类型链表重用策略不一样
insert: 插入记录日志会在事务提交后就没用了,因此重用时直接覆盖原本的旧undo记录即可!
update: 这个undo日志在事务提交后不能被删除,需要支持MVCC,因此不能覆盖,在后面接着写就好。
回滚段: 我们知道一个事务执行时最多分配4个undo页面链表,为了统一管理,有一个Rollback Segment Header的页面,存储了该事务所有页面链表的first undo page页号,称为undo slot
,根据这个信息管理具体的页面链表
每个RSH都对应一个段,就是回滚段
,这个段只有一个页面。
执行过程:
- 初始化时,没有undo日志,因此回滚段中的undo slot为null。
- 开始执行,需要undo日志,如果slot为null,那么就在表空间新创建一个段,用来记录页面链表,申请一个页面当做头页,更新undo slot;如果不是null,表示已经有一个页面链表了,需要跳到下一个undo slot,重复之前步骤
一个回滚段中有1024个undo slot,如果都不为null,说明都有分配了,那新事务就不能获得undo日志链表,就会报错。
当事务提交时,所有undoslot都会被处理:
- 如果指向的页面链表可以被重用,就处于被缓存状态,两种类型链表对应两种类型的缓存链表,分别插入。
- 如果不能被重用,再根据类型判断,如果是insert,那就释放掉;如果是update,放入history中,MVCC中的版本链!
之前说过一个回滚段只有1024个slot,显然太少了,因此随着更新,定义了128个回滚段,对应128个RSH页面,在系统表空间的第5号页面的某个区域内有128个8字节的格子,每个格子有两个属性,一个id,另一个是页号,页号就是回滚段的RSH页号,这就方便多了。这些回滚段也是有分类的,下面详细描述一下:
1.
1~32号回滚段属于一类,这些回滚段必须再临时表空间中。我们在写undo日志过程本身就是写数据,那也会产生redo日志。但对于临时表来说,修改临时表产生的undo日志只会在系统运行中有效,如果系统崩溃,重启时是不需要恢复对应页面的。因此针对临时表的undo日志,没有对应的redo日志!因此操作临时表的undo日志需要单独区分
2.
0,33~127属于一类,0号回滚段必须在系统表空间中,其他的可以在系统表空间,也可以在自己配制的undo表空间中,同时写undo的过程伴随有redo日志。
MVCC中用到的回滚指针,指向就是一个undo日志,根据这个undo日志可以得到某一个版本的数据记录,构成了一个版本链。
事务执行过程中分配Undo页面链表的过程:
- 事务在执行过程中对普通表的记录首次做改动之前,首先会到系统表空间的第 5 号页面中分配一个回滚段(其实就是获取一个 Rollback Segment Header 页面的地址)。一旦某个回滚段被分配给了这个事务,那么之后该事务中再对普通表的记录做改动时,就不会重复分配了。使用传说中的 round-robin (循环使用)方式来分配回滚段。比如当前事务分配了第 0 号回滚段,那么下一个事务就要分配第 33 号回滚段,下下个事务就要分配第 34 号回滚段,简单一点的说就是这些回滚段被轮着分配给不同的事务(就是这么简单粗暴,没啥好说的)。
- 在分配到回滚段后,首先看一下这个回滚段的两个 cached链表 有没有已经缓存了的 undo slot ,比如如果事务做的是 INSERT 操作,就去回滚段对应的 insert undo cached链表 中看看有没有缓存的 undo slot ;如果事务做的是 DELETE 操作,就去回滚段对应的 update undo cached链表 中看看有没有缓存的 undoslot 。如果有缓存的 undo slot ,那么就把这个缓存的 undo slot 分配给该事务。如果没有缓存的 undo slot 可供分配,那么就要到 Rollback Segment Header 页面中找一个可用的 undoslot 分配给当前事务。
- 找到可用的 undo slot 后,如果该 undo slot 是从 cached链表 中获取的,那么它对应的 Undo Log Segment 已经分配了,否则的话需要重新分配一个 Undo Log Segment ,然后从该 Undo Log Segment 中申请 一个页面作为 Undo页面 链表的 first undo page 。
- 然后事务就可以把 undo日志 写入到上边申请的 Undo页面 链表了
最后一个问题,系统崩溃后未提交事务写的redo日志怎么办?
:
系统崩溃,有可能,一个未提交的事务写的redo日志已经刷盘到disk,这样系统重启后redo的话,岂不是把未提交事务做的一半更改还原了?显然违背了事务原子性,因此需要将这部分数据undo!所以需要找到那些未提交事务的id,进行undo操作。
系统重启后,首先在第5号页面的128回滚段中,看其中1024个undoslot哪些不为空,然后判断这些不为空的undo页面链表中第一个页面的undologsegmentheader的属性STATE中判断该undo页面链表是否处于活跃状态。处于活跃状态就表明,系统宕机前该事务还没有提交,就是我们需要undo的事务,根据该undo页面链表中记录的各种属性来找出对应undo日志,进行回滚!