redo 和 undo 概述

MySQL 的隔离性是通过锁机制来保证的,而原子性、一致性和持久性则是通过数据库的预写式日志(Write-Ahead Logging,WAL),具体来说是 redo log 和 undo log 来保证的。

redo log

redo log 也就是重做日志,用来实现事务的持久性,即 ACID 中的 D。我们知道 InnoDB 存储引擎是以页为单位来管理存储空间的,我们的增删改查等操作本质上都是在访问和操作数据页,而在真正访问数据页之前,需要先把磁盘上的数据页读到内存中,具体来说是 Buffer Pool 中。为了保证持久性(就是对于一个已经提交的事务,即使系统发生了崩溃,这个事务对数据库所做的更改也不能丢失),需要把内存中的修改同步回磁盘(fsync),一个简单的做法就是在事务提交之前将该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有几个问题。

首先,刷新一个完整的数据页过于浪费。有时我们可能仅仅修改了数据页中的一个字节,但是在事务提交之前却不得不将一个完整的数据页从内存刷新到磁盘。其次,随机 IO 刷新比较慢。一个事务可能包含多条语句,即使是一条语句也有可能需要修改多个数据页,而且修改的这些页面也有可能并不相邻,这就意味着将它们刷新到磁盘时,需要进行很多随机 IO,而随机 IO 要比顺序 IO 慢很多。

其实我们没有必要在每次事务提交时就把内存中所有修改过的页面都刷新到磁盘上,我们只需要把修改了哪些东西记录一下即可。比方说某个事务将系统表空间的第 100 号页面中的偏移量为 1000 的那个字节的值由 1 改为了 2,我们只需要记录这个即可。这样即使系统突然崩溃了,在重启之后只要按照上述内容重新更新一下数据页,系统就能恢复该事务对数据库所做的修改。

这样做有很多好处。首先,redo 日志所占用的空间很小。存储表空间的 ID、页号、偏移量以及需要更新的值,这些内容所需要的存储空间很小。同时并发的事务共享 redo log 存储空间,它们的 redo log 按照语句的执行顺序,依次交替地记录在一起,以减少日志占用的空间。其次,redo 日志其实是批量写入的。事务对数据页所做的更改不会直接写入日志文件,而是先写入 redo log buffer,然后再将 buffer 中的数据以每秒钟一次的频率一并写入日志文件中。同时 redo log 只进行顺序追加的操作,也就是说它使用的是顺序 IO,因此性能更好。

数据丢失的问题

对于随机写性能差的情况,常见的优化方法有两个:一个是先写日志,将随机写优化为顺序写;另一个就是将单次写优化为批量写。既然要实现批量写,就需要引入缓存。这里使用沈剑老师的图来展示 redo log 的三层架构。

redo log 架构

redo log 最终落盘的流程为:首先,事务提交时,会将对数据页的修改写入 log buffer。接着只有当 MySQL 发起系统调用写文件时,log buffer 中的数据才会写到系统缓存中。最后在写文件的系统调用完成后,还需要调用 fsync 方法落盘,这也是最慢的一步。如果不进行 flush,那么什么时候落盘是由操作系统决定的。

在 redo log 的三层架构中,MySQL 做了一次批量写优化,操作系统也做了一次批量写优化,这样确实能够提升性能,但是缺点也很明显。MySQL 在事务提交时,将 redo log 写入缓存中后,就会认为事务提交成功。如果 MySQL 在 log buffer 中的数据在写入操作系统缓存之前就崩溃了,那么就会出现数据丢失。同样的,如果在操作系统的缓存没有落盘之前,系统崩溃,那么也会出现数据丢失。

对于有的业务来说,可能允许性能较低但不允许数据丢失;而有的业务可能必须要高性能高吞吐,但是可以能够容忍少量的数据丢失。MySQL 提供了一个 innodb_flush_log_at_trx_commit 参数,通过它可以控制事务提交时 redo log 的刷新策略,从而适应不同的业务需要。

目的 描述
0 最佳性能 此时每隔一秒才会将 log buffer 中的数据批量地写入到操作系统缓存中,同时 MySQL 会主动调用 fsync。如果数据库发生崩溃,这种策略可能会导致 1 秒的数据丢失
1 强一致性 每次事务提交时,都会将 log buffer 中的数据写入到操作系统缓存中,同时 MySQL 会主动调用 fsync。这种策略是 MySQL 默认的策略
2 平衡性能与一致性 每次事务提交时,都会将 log buffer 中的数据写入到操作系统缓存中,然后每隔一秒主动调用 fsync 将操作系统缓存落盘,由于操作系统也会不定时地调用 fsync,所以在这种策略下,如果操作系统崩溃,那么最多也就丢失 1 秒的数据,而操作系统与数据库相比,出现崩溃的概率更低

undo log

重做日志记录了事务的行为,通过它可以对数据页进行重做操作。但是事务有时还需要进行回滚,这时就需要记录回滚前的状态,比如插入一条记录,那么至少要把这条记录的主键值记录下来;修改一条记录,至少要把修改前的旧值记录下来。因此在对数据库进行修改时,InnoDB 存储引擎不但会产生 redo,还会产生一定量的 undo。这样如果用户执行的事务或者语句由于某种原因失败了,又或者用户使用 ROLLBACK 语句请求回滚,就可以利用这些 undo 信息将数据回滚到修改之前的状态。

undo 存放在数据库内部的一个特殊段(segment)中,这个段叫做 undo 段(undo segment)。默认情况下,undo 段位于共享表空间内,当然也可以通过配置将部分段放到自定义的 undo 表空间中,不过只能在系统初始化(创建数据目录)的时候指定。

事实上 undo 只是逻辑日志,使用它并不能将数据库物理地恢复到执行语句或事务之前的状态,只能将数据库逻辑地恢复到之前的状态,所有的修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能大不相同。这是因为实际上可能会有数十、数百甚至上千个并发事务在访问数据库,可能一个事务在修改当前页的某几条记录,而同时还有别的事务也在修改同一页的其他几条记录,因此不能将一个数据页回滚到事务开始的状态,这样会影响其他事务正在进行的工作。比如用户执行了一个新增 10 万条记录的事务,这个事务会导致分配一个新的段,即表空间会增大。在用户执行 ROLLBACK 之后,会将插入的数据全部删除,但是表空间并不会因此而收缩。

除了回滚操作,undo 的另外一个作用是 MVCC。即在 InnoDB 存储引擎中 MVCC 是通过 undo 来完成的。当用户读取一条记录时,如果该记录已经被其他事务占用,当前事务可以通过 undo 来读取之前的版本的行信息,以此实现非锁定读取。

需要注意的是,undo log 也会产生 redo log,这是因为 undo log 也需要持久性的保证。

参考

事务已提交,数据却丢了,赶紧检查下这个配置!!!