前言
redo翻译过来就是『重做』,主要解决提交后(执行了commit命令)的事务在异常情况(比如机器突然宕机)下,重启后可以自己恢复,确保已经提交的事务不会丢失数据。undo翻译过来是『撤销』,主要解决事务修改了多条记录后,依然可以成功撤销(通过执行rollback命令)所有改动,另外也是事务隔离级别得以实现的技术手段。
redo日志
如何确保我们执行的多条写入(insert/update/delete),作为一个整体,可以成功持久化到DB呢?我们知道,事务要求整体的原子性(ACID中的A->Atomicity),要么多条修改都成功,要么整体都撤销这些修改。不能前半部分成功写入,后半部分因为异常原因丢失数据。
实际上,mysql在事务中执行一条写入操作时,不是直接修改data page部分,而是先写入预写日志(俗称WAL -> write ahead log),同时对预写日志执行fsync强制刷盘,刷盘成功才算本条修改命令成功。数据库引擎会有后端线程来定时同步内存buff到数据库data文件中。
WAL预写日志采用追加写(也叫顺序写)的方式,性能比data文件的随机写要好很多。这样,在客户端收到DB的写入成功响应后,有WAL兜底,即便异常宕机重启后,数据库依然可以靠WAL恢复数据到data文件中。另外,WAL+buffer缓存,避免了每次的写入操作都需要执行一个data文件的随机写,减少了磁盘IO压力。
undo日志
undo日志我们说了,有2个作用,一个是在事务最后执行rollback命令时,可以成功回滚(所以需要记住改动前的数据内容),另外一个就是用于实现事务隔离(其他进行中事务对记录的改动,在当前事务执行select查询时是不可见的)。
undo日志的回滚功能
mysql的数据记录和undo日志记录中,都会有2个隐藏的列,分别是TRX_ID和TRX_ROLLBACK_PTR。TRX_ID表示该改动来自于哪个事务,TRX_ROLLBACK_PTR则指向改动前的undo日志记录。
比如我们执行update xxx set name = 'yyy' where id = n, 就会产生修改data page中的id=n的数据记录,同时拷贝改动前的记录信息到undo日志中,并设置最新数据记录的TRX_ROLLBACK_PTR指向这条undo日志的记录。这种由rollback指针串联起来的记录,就形成了回滚链。
不同主键ID的记录会形成不同的undo链条,当执行完commit后,相关记录的undo日志并非立即清理,因为可能有其他进行中的事务正在修改相同记录。比如A事务提交了对于ID=1的记录改动,然后进行中的事务B也修改ID=1的记录,那么事务B会把A刚刚commit的data page数据记录复制到undo日志,并让自己的rollback指针指向该记录。
如下图所示:
基于undo日志的事务隔离实现
mysql有4个隔离级别,分别是:读未提交、读已提交、可重复读(mysql当前默认级别)、串行化。
读未提交就是一个事务可以查询到另外一个进行中的事务执行的改动,该级别是最低级别,始终查询data page的最新记录;读已提交是一个事务可以查询到另外一个已经提交的事务做的改动。如果在两次对相同记录的查询之间,另外一个事务提交了一个对该记录的修改,则第二次会查询到最新值,由于前后两次值不同,看起来让人很困惑;可重复读,就是该事务从begin命令之后,整个事务执行期间查询到的值始终相同,仿佛在事务开始后一直在读一个view视图一样,察觉不到其他事务的改动。串行化,事务是整体串行执行的,所以不会有任何『意外顺序』的数据产生,一般不会设置为这种级别,性能太差。
mysql在事务一开始,就会创建一个逻辑视图,这个视图的数据结构主要包括:进行中的事务ID列表(不含该事务本身),我们就叫他TRX_LIST_AT_BEGIN吧。mysql中事务ID是单调递增的整数。事务在一开始,会记录此时有哪些进行中的事务ID,当事务中需要执行select时,会从data page最新记录,沿着TRX_ROLLBACK_PTR的undo链表,根据当前事务的TRX_ID,找到第一个可见的记录。
判断记录是否可见的依据是(我们假设隔离级别是『可重复读』):
- 如果undo链条中记录的TRX_ID在TRX_LIST_AT_BEGIN中,则该记录不可见,因为这说明该记录是与自己同处进行中的其他事务做的改动。
- 如果当undo链条中记录的TRX_ID比TRX_LIST_AT_BEGIN中最大的事务ID还大,则该记录不可见,因为这说明当前事务开始时,该记录的事务尚未开始,不能读取后来事务的改动。
- 其他情况,则都可见。
注意,这里说的是读可见性,对于update xxx where xyz的语句,where查询不使用上述的可见性机制,where查询直接采用最新的data page记录值。所以,对于where查询,可能会出现一些意想不到的数据。可能出现查询不到但是依然可以更新的情况。假设数据库表t中id=1之前的记录是name='C', 我们看一下这个过程:
事务B开始查询:
begin; select name from t where id = 1 此时查询结果为name = 'C'
然后事务A 执行了:
begin; update t set name = 'A' where id =1; commit;
然后事务B执行:
select name from t where id = 1 此时查询结果依然为name = 'C'
然后事务B执行:
update t set name = 'B' where id = 1 and name = 'C' 显示该命令成功,影响的行数为0
事务B再执行查询:
select name from t where id =1 显示依然是name = 'C', 说明确实没有修改生效。
然而,如果再次执行如下修改:
update t set name = 'B' where id = 1 and name = 'A' 我们预期应该是执行为空,但是实际上,这条修改会成功生效:
然后事务B执行:
select name from t where id =1 此时B发现name成功变为'B'。
参考
xiaolincoding.com/mysql/log/h…
书籍:
MySQL技术内幕 InnoDB存储引擎(第二版)7.2和3.6.2节
数据库系统内幕(Alex Petrov) 第五章