一、什么是undo日志
如何理解undo日志
数据库事务是mysql执行操作的最小逻辑单位,一个事务可以包含一个或者多个sql语句,这些sql要么都执行成功要么都执行失败,这就是数据库事务四大特性之一的原子性。
原子性能实现的关键是在失败的时候能够发生回滚,这依赖于 undo log 日志。事务在更改数据之前会将要更改的数据备份到undo log中(undo log会保存更改前的数据,这是一个行级别的历史数据),如果发生了错误或者用户执行了rollback,就可以通过undo log将数据恢复到事务开始之前的状态。
我们可以这样理解undo日志记录了些什么:
在插入一条记录时,至少要把这条记录的主键值记下来,这样之后回滚时只需要把这个主键值对应的记录删掉就好了;
在删除一条记录时,至少要把这条记录中的所有数据内容都记下来 ,这样之后回滚时再把这些内容组成的记录插入到表中就好了;
在修改一条记录时,至少要把被更新的列的旧值记下来,这样之后回滚时再把这些列更新为旧值就好了。
undo日志种类和格式
undo日志分为3类:
insert类型的undo日志(TRX_UNDO_INSERT_REC)
delete操作的undo日志(TRX_UNDO_DEL_MARK_REC)
更新操作的undo日志(TRX_UNDO_UPD_EXIST_REC)
和redo日志不同的是,一个redo日志记录一个页的一处修改,因此一条sql会产生多条redo日志;而一个undo日志记录一条记录的修改,因此一条sql只会产生一条undo日志(某些情况下会产生2条)。
在一个事务中,对某个表执行增删改查操作时会为这个事务分配一个事务id,如果事务中全是查询语句,那么这个事务不会被分配事务id。
之前我们说主键索引的页内记录有几个隐藏列,其中一个隐藏列就是事务id(trx_id),表示对这行记录最近是被哪一个事务所修改。
undo日志会保存到undo页中,undo日志的通用格式包含:该undo日志在undo页的页内地址、undo日志对应的记录所在的表id、undo日志编号、undo日志类型、下一条undo日志的地址。
每一条用户数据记录的一个写操作都对应一条undo记录,因此聚簇索引的每一条叶子节点记录都对应一个undo日志,那么如何知道一条数据记录的undo日志存放在哪里呢?主键B+树的页内记录有一个roll_pointer的隐藏字段,它指向对应的undo日志在undo页中的地址,如下图:
二、undo日志存了什么
下面分别介绍3中类型的日志和他们对应的行为。
insert操作对应的undo日志
如果事务执行了一条insert操作,想要回滚该操作,只需根据刚刚被插入的记录的主键id将记录删除即可。因此insert操作的undo日志只需记录新增行的id即可。如果主键是一个联合索引,那么需要记录联合索引中的所有字段值。
当我们向某个表插入一条记录时,需要向聚簇索引和所有二级索引都插入一条记录。但生成undo日志时,只需要针对聚簇索引的行生成一条undo日志。后面说到的delete操作和update操作也是只针对聚簇索引的行的改动来生成undo日志的。
delete操作对应的日志
在事务中delete一条记录会发生什么?
我们看下图,一个页面中的已删除的记录会被链入“垃圾链表”中,垃圾链表中的记录所占的空间是可重用空间,页的page header的page_free属性指向垃圾链表的头结点,deleted_flag表示该记录是否已被删除。
当我在事务中delete一条记录,会经历两个阶段:
1、将要删除的记录的 deleted_flag 置为1(其实还会修改记录的 trx_id、roll_pointer值,但这里我们不关注),该阶段称为 delete mark。此时该记录会处于一种介于删除和未删除的中间状态。
2、当事务提交时,pruge线程会把该记录从正常记录链表移到垃圾链表的头结点,也就是真正的把该记录删掉。这个阶段成为pruge。
生成某条记录的delete undo日志时,只发生了第一阶段,如果对该操作进行回滚,只需将deleted_flag置为0即可。
下面是delete类型的undo日志格式(delete_mark类型):
相比于insert的undo日志,它多出了一下字段:旧记录的事务id 和 旧记录的undo日志指针roll_pointer、旧记录的所有索引字段值。
Q1:为什么要delete的undo日志要记下该记录的所有索引字段值?
因为删除一条记录还要从所有二级索引的B+树中删除对应的记录,记下所有索引字段值方便在事务提交时删除二级索引中对应的记录。
Q2:新记录插入页的时候,如何利用可重用空间?
插入新记录时,先判断垃圾链表的头结点对应的记录所占用的空间是否能容纳新记录,如果不能就直接向页面申请新的空间来存储这个记录(而并不会尝试遍历整个垃圾链表,以找到一个可以容纳新记录的节点),如果可以则直接重用,并让page_free指向垃圾链表的下一个节点。
如果新插入记录所占的空间小于垃圾链表头结点记录所占的空间,就会产生碎片。随着新记录越插越多,碎片就会越来越多,当碎片多到一定比例,innodb就会重新组织页内的记录,组织的过程就是申请一个临时页面,把页面的记录依次紧密的插入到这个临时页,再把临时页的内容复制到本页,这个过程会比较耗费性能。
update操作对应的undo日志
分为两类:更新主键的update 和 不更新主键的update。
不更新主键的update 又可以分为 就地更新 和 非就地更新。
a. 就地修改
所谓的就地更新是 被更新的所有列 和 它在更新前的列 所占的存储空间一样大。
比如有一个记录是这样的:
执行下面的update语句
update t set key1 = "ABCD" and col = "手枪" where id = 2;
这样的话,被修改字段 key1 和 col 在占用空间上没有变化,这就是就地修改。
就地修改在底层的行为最简单,直接在页的对应记录的字段上原地修改新值即可。
b. 非就地修改
如果 被更新的所有列 和 它在更新前的列 所占的存储空间不一样(非就地修改),那么需要在主键索引中先删除旧记录,再在旧记录所在的页中插入新记录。这里所说的删除不再是假删除 delete mark,而是真的把记录移动到垃圾链表。
如果sql也修改了二级索引的值,那么也要在二级索引的页删除记录(是假删除 delete mark),并在另外一个二级索引页插入新记录。
如果新创建的记录占用的存储空间不超过旧记录占用的空间,那么可以直接重用加入到垃圾链表中的旧记录所占用的存储空间,否则需要在页面中新申请一块空间供新记录使用,如果没有可用的空间,就进行页面分裂操作,然后再插入新记录。
就地修改和非就地修改会生成一条 update_exist 类型的undo日志。
下面是不改变主键值的更新的undo日志格式(update_exist),会记录所有被更新列在更新之前的值:
c. 更新主键的update
假设更新操作把id = 5 改为 id = 1000,那么更新主键的update在底层的行为就是在主键索引的页中删除id=5的记录(假删除delete mark),并添加 id=1000的记录到另一个页。
这个过程会产生一个 delete类型的undo日志 和 一个 insert类型的undo日志。
所以更新主键的update 会产生2条undo日志。
其实还有一种名为 TRX_ UNDO_ UPD _ DEL_ REC的 undo 日志类型这里没有介绍,主要是想避免引入过多的复杂性,毕竟了解undo log日志的原理才是我们的初衷。
三、版本链和undo日志的存放
版本链
update 和 delete 这两种undo日志记录了对应数据行的 旧roll_pointer ,我们假设一条记录在一次事务中经过了4次修改,那么该条记录会产生4条undo日志,每条undo日志都会记下该数据行的上一个undo日志的roll_pointer,而数据行本身记下了它最新一次undo日志的roll_pointer,那么这些undo日志相当于组织成了一条版本链。
undo日志的组织结构
undo日志存放在undo日志页中,而undo页面以链表的形式组织在一起,undo页分为两种:insert类型的undo日志(里面只放insert类型的undo日志) 和 update类型的undo日志(放update和delete类型的undo日志)。
这两种不同类型的undo日志页分别用 insert undo 链表 和 update undo 链表管理。
之所以把 undo 日志分成 2 个大类,是因为insert类型的 undo 日志在事务提交后可以直接删除,而其他类型的 undo 日志还需要为 MVCC(多版本并发控制)服务,
不能直接删除掉,因此对它们的处理需要区别对待。下图为一个页内的undo日志,他们是紧密相连的。
临时表和普通表的undo日志也会分开管理,因此一个事务最多有4条undo链表,且每创建一个事务都会创建4条这样的undo链表:
在一个事务中,对不同表的不同行的DML操作产生的所有undo日志都存放在这4条链表中。
两个事务会创建不同的2条链表,例如事务A和事务B都对数据行X进行修改,那么这两个修改产生的undo日志会放在两个不同的update undo链表中。(而某一条记录在不同事务中的所有变更所产生的undo日志是用版本链链接,和这里的undo页链表没关系。)
其中每一个undo链表的第一个页面会放该链表的一些控制信息,比如事务id。
由此我们可以想象得出一个事务是如何回滚的,事务发生过程中会记住它对应的几条undo链表的头结点和末尾节点页号,只需要从末尾节点往前遍历这些undo页内的undo日志,并按顺序执行这些undo日志的逆操作即可实现回滚。
一个事务在一个undo链表产生的undo日志称为一组undo日志,例如上图的事务就产生了4组undo日志。某些情况下,一个事务提交之后,后续的其他事务可以重复利用这个undo页链表,而非为新事务申请创建新的undo链表,这将导致一个undo页面可能存放多组事务的undo日志(这是为了节省undo页空间)。链表头结点会记录下一组和上一组undo日志在页内的偏移量。
下面我们说说重用undo页面的事情。
重用undo页
innodb会为每个事务单独分配undo页链表(最多可单独分配4个链表),这样是为了提高并发执行的多个事务写入undo日志的性能(因为每个事务把undo日志写入链表肯定要先对链表上锁)。
但这会造成浪费undo页面空间的问题。比如,一个事务可能只产生3个undo日志,这个事务的undo链表就只有1个undo页,而且这个undo页只用了一丢丢空间就不用了。而实际上新的事务可以继续往这个undo页的日志堆里继续追加undo日志,已达到多个事务重用或者说共用undo页的目的。
为此innodb会在某些情况下让不同事务重用undo页链表中的undo页。重用undo页需要满足2个条件:
1、该链表只包含一个undo页;
2、该undo页使用的空间小于整个页面的3/4,否则undo页面的空间只剩一点点的情况下重用很可能会申请新页面,导致新页面剩余很多空间造成浪费。
insert 链表 和 update链表的重用策略是不同的,由于insert undo日志在事务提交之后可以直接丢弃,因此重用insert undo页面可以直接覆盖里面的旧undo日志。而update undo页的重用不能覆盖旧undo日志只能追加,因为这些undo日志要用来做MVCC。
重用undo页的事务不能是并发的事务,必须是一个事务结束后,另一个事务才能重用上一个事务的undo页。也就是说,一个事务的undo日志在页内是紧密相连的,不会出现多个事务的undo日志在一个页内交错的情况。
回滚段
所有的undo页都存放在段中管理,而且一个undo页链表对应一个undo段,申请undo页时也是由undo段向区申请的,但是这些undo段(undo log segment)不是我们本节要说的回滚段(rollback segment)。
所有undo链表的头结点(first undo page)的页号都会保存到一个单独的页,undo链表头结点的页号称为 undo slot,我们可以把这个单独页看成是存放所有undo链表基节点的仓库。而这个单独的页会被放入到一个单独的段中,这个段就是回滚段(rollback segment)。
回滚段里只有一个页,这个页我们称为回滚段头部,或者直接叫做回滚页。通过这个页可以找到指定链表的头结点页号。
一个回滚段只有1024个 undo slot,一个slot对应一条undo链表。这意味着一个回滚段最多能支持1024个事务同时执行,为了提高并发事务数,innodb会存在128个回滚段容纳更多的undo链表的头结点,因此最多能支持1024*128个事务同时执行。
这128个回滚段的回滚页的页号会被存放到系统表空间的第5号页面:
undo日志在崩溃恢复的作用
如果系统崩溃时某个事务还未提交,但是该事务的部分redo日志已经刷盘,那么为了保证事务的原子性,这个事务发生的所有更改是不应该恢复的,也就是说已刷盘的这部分redo日志是不完整的,不能对这些redo日志恢复数据。然而实际上mysql会对这些不完整的redo日志进行数据恢复。
此时undo日志就会在崩溃恢复中起到作用,mysql会扫描该事务所有undo链表的第一个节点,查看里面的 TRX_UNDO_ACTIVE 属性,它表示该链表对应的事务是否活跃。如果活跃,说明在系统崩溃时该事务没有提交,那么系统就会按照该链表中的undo日志回滚刚刚恢复的redo日志数据以保证原子性。