29.再聊undolog

196 阅读14分钟

作用:提供回滚和MVCC

undo log有两个作用:提供回滚和多个行版本控制(MVCC)

主要就是提供了回滚的作用,undo log保存了事务发生之前的数据的一个版本,可以用于回滚。

但其还有另一个主要作用,可以提供多版本并发控制下的读(MVCC),就是多个行版本控制,也即非锁定读(即当前读),保证事务的原子性。

Undo 日志

比如A有200块钱, B有50 块钱,现在A要给B转100块。

(1) 开始事务 T1 (假设T1是个事务的内部编号)

(2) A余额 = A余额 -100

(3) B余额 = B余额 + 100

(4) 提交事务 T1

会对此事务记录Undo的日志文件,记录下事务开始之前的他俩账号余额:

[开始事务 T1]

[事务T1, A原有余额,200]

[事务T1, B原有余额,50]

如果事务执行到一半挂了,数据库重启以后我就根据undo的日志文件来恢复。

如果第三步还没执行完就断电了, 数据库重启以后就需要根据undo日志复原,要是系统恢复的过程中又断电了,下次重启再次恢复,此操作拥有幂等性,重复多少次都没有问题。

如何判断哪些事务需要恢复

恢复之后需要在日志文件中加上一行 [回滚事务 T1] , 这样下一次恢复就不用再考虑T1这个事务了。

[开始事务 T1]

[事务T1, A原有余额,200]

[事务T1, B原有余额,50]

[提交事务 T1]

Undo日志文件中不仅仅只有余额, 事务的开始和结束也会记录,如果我在日志文件中看到了[提交事务 T1], 或者 [回滚事务 T1], 就表示此事务已经结束,不用再去理会它了, 更不用去恢复。 如果我只看到 [开始事务 T1], 而找不到提交或回滚,那就得恢复。

日志从缓冲区写入磁盘的时机

两条规则:

  1. 在最新余额写入硬盘之前, 一定要先把相关的Undo日志记录写入硬盘。 例如[事务T1, A原有余额,200] 一定要在A的新余额=100写入硬盘之前写入。
  2. [提交事务 T1] 这样的Undo日志记录一定要在所有的新余额写入硬盘之后再写入。
操作数据缓冲区Undo日志缓冲区
1开始事务T1开始事务T1
2A余额 = A余额 -100A新余额:100事务T1,A原有余额,200
3把undo日志缓冲区内容写入磁盘ps:此步骤会清空undo日志缓冲区
4把A新余额写入磁盘
5B余额 = B余额 + 100B新余额:150
6把undo日志缓冲区内容写入磁盘
7把B新余额写入磁盘
8提交事务T1提交事务T1
9把undo日志缓冲区内容写入磁盘

情况一:

如果系统在第4步和第5步之间崩溃,A的余额写入了硬盘,但是B的还没写入, Undo日志看起来是这样的:

[开始事务 T1]

[事务T1, A原有余额,200]

由于找不到事务结束的日志, 进行恢复操作, 把A的原有余额给恢复了。

情况二:

如果是在第7步和第8步之间系统崩溃,A和B的最新余额都写入了硬盘,但是没有提交事务, 那Undo日志看起来是这样的:

[开始事务 T1]

[事务T1, 旺财原有余额,200]

[事务T1, 小强原有余额,50]

由于没有事务结束的日志,也需要进行恢复,把A和B的原有余额恢复成200和50

情况三:

如果是在第8步和第9步之间系统崩溃, A和B的最新余额都写入了硬盘也提交了事务, 但是提交事务的操作没有写入Undo 日志,Undo日志还是这样:

[开始事务 T1]

[事务T1, 旺财原有余额,200]

[事务T1, 小强原有余额,50]

由于没有事务结束的日志,需要进行恢复,把A和B原有余额恢复成200和50

格式内容

逻辑格式的日志,在执行undo的时候,仅仅是将数据从逻辑上恢复至事务之前的状态,而不是从物理页面上操作实现的,这一点是不同于redo log的。

不同类型的操作产生的undo日志的格式也是不同的。

存储方式

采用段的形式记录, 每个回滚段(rollback segment)有1024个 undo log segment。

产生时机

事务开始之前,将当前数据的版本生成undo log,undo 也会产生 redo 来保证undo log的可靠性

日志类型

由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo日志。

在事务进行如下操作时会产生undo log日志: ① 在常规表上的insert操作 ② 在常规表上的update和delete操作 ③ 在临时表上的insert操作 ④ 在临时表上的update和delete操作 在InnoDB引擎中,undo log格式分为两种:

insert undo log

指在insert操作中产生的undo log,因为insert操作只对事务本身可见,对其他事务不可见,所以undo log可以在事务提交后直接删除,不需要进行purge操作。

update undo log

指在delete和update操作产生的undolog:因为需要提供MVCC操作,所以不能在事务提交时就删除,提交时放入undolog链表,等待purge线程进行最后的删除。

delete

delete操作实际上不会直接删除,而是将delete对象打上delete flag,标记为删除,最终的删除操作是purge线程完成的。

阶段一:仅仅将记录的delete_mask标识位设置为1,其他的不做修改(其实会修改记录的trx_id、roll_pointer这些隐藏列的值)。

mysql把这个阶段称之为delete mark。在对一条记录进行delete mark操作前,需要把该记录的旧的trx_id和roll_pointer隐藏列的值都给记到对应的undo日志中来。这样有一个好处,那就是可以通过undo日志的old roll_pointer找到记录在修改之前对应的undo日志。比方说在一个事务中,我们先插入了一条记录,然后又执行对该记录的删除操作,这个过程的示意图就是这样:

阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS、上次插入记录的位置PAGE_LAST_INSERT、垃圾链表头节点的指针PAGE_FREE、页面中可重用的字节数量PAGE_GARBAGE、还有页目录的一些信息等等,这个阶段称之为purge。

由purge线程判断是否由其他事务在使用undo段中表的上一个事务之前的版本信息,决定是否可以清理undo log日志空间。

update

分为两种情况:update的列是否是主键列。 如果不是主键列,在undo log中直接反向记录是如何update的。即update是直接进行的。

在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。

存储空间不发生变化:就地更新(in-place update)

更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。

存储空间发生变化:

先删除掉旧记录,再插入新记录

如果是主键列

update分两部执行:先删除该行(delete remark),再插入一行目标行。 undo log会产生redo log -> 需要实现持久性保护

什么时候释放:   当事务提交之后,undo log并不能立马被删除,而是放入待清理的链表,由purge线程判断是否由其他事务在使用undo段中表的上一个事务之前的版本信息,决定是否可以清理undo log的日志空间。

purge

大家有没有发现两件事儿:

  • 我们说insert undo在事务提交之后就可以被释放掉了,而update undo由于还需要支持MVCC,不能立即删除掉。
  • 为了支持MVCC,对于delete mark操作来说,仅仅是在记录上打一个删除标记,并没有真正将它删除掉。

随着系统的运行,在确定系统中包含最早产生的那个ReadView的事务不会再访问某些update undo日志以及被打了删除标记的记录后,有一个后台运行的purge线程会把它们真正的删除掉。

为什么要避免长事务

基于上面的说明,我们来讨论一下为什么建议你尽量不要使用长事务。

1.长事务有可能会导致

长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。

在MySQL 5.5及以前的版本,回滚日志是跟数据字典一起放在ibdata文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。我见过数据只有20GB,而回滚段有200GB的库。最终只好为了清理回滚段,重建整个库。

除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库,这个我们会在后面讲锁的时候展开。

在可重复读的隔离级别下,如何理解当系统里没有比这个回滚日志更早的 read-view 的时候,这个回滚日志就会被删除?

这也是尽量不要使用长事务的主要原因。

比如,在某个时刻(今天上午9:00)开启了一个事务A(对于可重复读隔离级别,此时一个视图read-view A也创建了),这是一个很长的事务……

事务A在今天上午9:20的时候,查询了一个记录R1的一个字段f1的值为1……

今天上午9:25的时候,一个事务B(随之而来的read-view B)被开启了,它更新了R1.f1的值为2(同时创建了一个由2到1的回滚日志),这是一个短事务,事务随后就被commit了。

今天上午9:30的时候,一个事务C(随之而来的read-view C)也被开启了,它更新了R1.f1的值为3(同时也创建了一个由3到2的回滚日志),这是一个短事务,事务随后就被commit了。

假如中间被修改了100万次....

到了下午3:00了,长事务A还没有commit,为了保证事务在执行期间看到的数据在前后必须是一致的,那些老的事务视图、回滚日志就必须存在了,这就占用了大量的存储空间。

源于此,我们应该尽量不要使用长事务

事务的启动方式

如前面所述,长事务有这些潜在风险,我当然是建议你尽量避免。其实很多时候业务开发同学并不是有意使用长事务,通常是由于误用所致。MySQL的事务启动方式有以下几种:

1.显式启动事务语句, begin 或 start transaction。配套的提交语句是commit,回滚语句是rollback。

2.set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个select语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行commit 或 rollback 语句,或者断开连接。有些客户端连接框架会默认连接成功后先执行一个set autocommit=0的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。

因此,我会建议你总是使用set autocommit=1, 通过显式语句的方式来启动事务。

但是有的开发同学会纠结“多一次交互”的问题。对于一个需要频繁使用事务的业务,第二种方式每个事务在开始时都不需要主动执行一次 “begin”,减少了语句的交互次数。如果你也有这个顾虑,我建议你使用commit work and chain语法。

在autocommit为1的情况下,用begin显式启动的事务,如果执行commit则提交事务。如果执行 commit work and chain,则是提交事务并自动启动下一个事务,这样也省去了再次执行begin语句的开销。同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中。

你可以在information_schema库的innodb_trx这个表中查询长事务,比如下面这个语句,用于查找持续时间超过60s的事务。

select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60

如何避免长事务

这个问题,我们可以从应用开发端和数据库端来看。

首先,从应用开发端来看:需要设置自动提交事务。

确认set autocommit=1,这个确认工作可以在测试环境中开展,把MySQL的general_log开起来,然后随便跑一个业务逻辑,通过general_log的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成1。

其次确认是否有不必要的只读事务。

有些框架会习惯不管什么语句先用begin/commit框起来。我见过有些是业务并没有这个需要,但是也把好几个select语句放到了事务中。这种只读事务可以去掉。

然后:业务连接数据库的时候,根据业务本身的预估,通过SET MAX_EXECUTION_TIME命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。

最后,从数据库端来看:监控 information_schema.Innodb_trx表,设置长事务阈值,超过就报警/或者kill;

Percona的pt-kill这个工具不错,推荐使用;

在业务功能测试阶段要求输出所有的general_log,分析日志行为提前发现问题;

如果使用的是MySQL 5.6或者更新版本,把innodb_undo_tablespaces用于设定创建的undo表空间的个数 设置成2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

总结

在数据修改的流程中,会记录一条与当前操作相反的逻辑日志到undo log中(可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录),如果因为某些原因导致事务异常失败了,可以借助该undo log进行回滚,保证事务的完整性,所以undo log也必不可少。undo是在事务开始之前保存的被修改数据的一个版本,产生undo日志的时候,同样会伴随类似于保护事务持久化机制的redolog的产生。

默认情况下undo文件是保持在共享表空间的,也即ibdatafile文件中,当数据库中发生一些大的事务性操作的时候,要生成大量的undo信息,全部保存在共享表空间中的。因此共享表空间可能会变的很大,默认情况下,也就是undo 日志使用共享表空间的时候,被“撑大”的共享表空间是不会也不能自动收缩的。因此,mysql5.7之后的“独立undo 表空间”的配置就显得很有必要了。

innodb存储引擎可将所有数据存放于ibdata*的共享表空间,也可将每张表存放于独立的.ibd文件的独立表空间。 共享表空间以及独立表空间都是针对数据的存储方式而言的。