Java架构直通车——InnoDB事务是如何通过日志来实现的?

InnoDB的日志分为redo log和undo log。

Redo log

redo log叫做重做日志,是用来实现事务的持久性(用于数据库的崩溃恢复),当事务提交之后会把所有修改信息都会存到该日志中。该日志由两部分组成,一个是在内存里的redo log buffer,另一个是在磁盘里的redo log文件。

mysql 为了提升性能不会把每次的修改都实时同步到磁盘,而是会先存到Buffer Pool(缓冲池)里头,把这个当作缓存来用。然后使用后台线程去做缓冲池和磁盘之间的同步。

那么问题来了,如果还没来的同步的时候宕机或断电了怎么办?由于buffer pool是在内存里的, 这样会导致丢部分已提交事务的修改信息!
所以引入了redo log来记录已成功提交事务的修改信息,之后,系统重启后读取redo log恢复最新数据。虽然redo log也有内存buffer缓冲的部分,如果要严格保证数据不丢失,就要在事务提交前做一次磁盘写入,但是这种IO操作相比于buffer pool这种以页(16kb)为管理单位的随机写入,它做的是几个字节的顺序写入,效率要高得多。

比如下面的操作,从银行卡账户转账到理财账户表:
Java架构直通车——InnoDB事务是如何通过日志来实现的?

于是我们又引入了一个问题,既然redo log也是分为内存和磁盘两个部分,不是也会丢失事务吗?

redo log buffer会不会丢失事务?

要了解上面这个问题,还要详细说一说redo log buffer的原理。

当一条 SQL 更新完 Buffer Pool 中的缓存页后,就会记录一条 redo log 日志,前面提到了 redo log 日志是存储在磁盘上的,那么此时是不是立马就将 redo log 日志写入磁盘呢?显然不是的,而是先写入一个叫做 redo log buffer 的缓存中,redo log buffer 是一块不同于 buffer pool 的内存缓存区,在 MySQL 启动的时候,向内存中申请的一块内存区域,它是 redo log 日志缓冲区,默认大小是 16MB,由参数 innodb_log_buffer_size 控制。

redo log buffer 内部又可以划分为许多 redo log block,每个 redo log block 大小为 512 字节。我们写入的 redo log 日志,最终实际上是先写入在 redo log buffer 的 redo log block 中,然后在某一个合适的时间点,将这条 redo log 所在的 redo log block 刷入到磁盘中

这个合适的时间点究竟是什么时候呢?

  • MySQL 正常关闭的时候;
  • MySQL 的后台线程每隔一段时间定时的讲 redo log buffer 刷入到磁盘,默认是每隔 1s 刷一次;
  • 当 redo log buffer 中的日志写入量超过 redo log buffer 内存的一半时,即超过 8MB 时,会触发 redo log buffer 的刷盘;
  • 当事务提交时,根据配置的参数 innodb_flush_log_at_trx_commit 来决定是否刷盘。
    如果innodb_flush_log_at_trx_commit 参数配置为 0,表示事务提交时,不进行 redo log buffer 的刷盘操作;
    如果配置为 1,表示事务提交时,会将此时事务所对应的 redo log 所在的 redo log block 从内存写入到磁盘,同时调用 fysnc,确保数据落入到磁盘;
    如果配置为 2,表示只是将日志写入到操作系统的缓存,而不进行 fysnc 操作。(进程在向磁盘写入数据时,是先将数据写入到操作系统的缓存中:os cache,再调用 fsync 方法,才会将数据从 os cache 中刷新到磁盘上)

实际上要严格保证数据不丢失,必须得保证 innodb_flush_log_at_trx_commit 配置为 1。

在实际的生产环境中,通常要求是的是“双 1 配置”,即将 innodb_flush_log_at_trx_commit 设置为 1,另外一个 1 指的是写 binlog 时,将 sync_binlog 设置为 1,这样 binlog 的数据就不会丢失。


如何保证数据不丢失?

  1. MySQL Server 层的执行器调用 InnoDB 存储引擎的数据更新接口;
  2. 存储引擎更新 Buffer Pool 中的缓存页,
  3. 同时存储引擎记录一条 redo log 到 redo log buffer 中,并将该条 redo log 的状态标记为 prepare 状态;
  4. 接着存储引擎告诉执行器,可以提交事务了。执行器接到通知后,会写 binlog 日志,然后提交事务;
  5. 存储引擎接到提交事务的通知后,将 redo log 的日志状态标记为 commit 状态;
  6. 接着根据 innodb_flush_log_at_commit 参数的配置,决定是否将 redo log buffer 中的日志刷入到磁盘。

将 redo log 日志标记为 prepare 状态和 commit 状态,这种做法称之为两阶段事务提交,它能保证事务在提交后,数据不丢失。为什么呢?redo log 在进行数据重做时,只有读到了 commit 标识,才会认为这条 redo log 日志是完整的,才会进行数据重做,否则会认为这个 redo log 日志不完整,不会进行数据重做。

例如,如果在 redo log 处于 prepare 状态后,buffer pool 中的缓存页(脏页)也还没来得及刷入到磁盘,写完 biglog 后就出现了宕机或者断电,此时提交的事务是失败的,那么在 MySQL 重启后,进行数据重做时,在 redo log 日志中由于该事务的 redo log 日志没有 commit 标识,那么就不会进行数据重做,磁盘上数据还是原来的数据,也就是事务没有提交,这符合我们的逻辑。

双“1”设置为什么还是要经过磁盘?

既然生产环境一般建议将 innodb_flush_log_at_trx_commit 设置为 1,也就是说每次更新数据时,最终还是要将 redo log 写入到磁盘,也就是还是会发生一次磁盘 IO,而我为什么不直接停止使用 redo log,而在每次更新数据时,也不要直接更新内存了,直接将数据更新到磁盘,这样也是发生了一次磁盘 IO,何必引入 redo log 这一机制呢?

首先引入 redo log 机制是十分必要的。因为写 redo log 时,我们将 redo log 日志追加到文件末尾,虽然也是一次磁盘 IO,但是这是顺序写操作(不需要移动磁头);而对于直接将数据更新到磁盘,这个操作发生的是随机写操作(需要移动磁头做寻址)。

从另一方面来讲,通常一次更新操作,我们往往只会涉及到修改几个字节的数据,而如果因为仅仅修改几个字节的数据,就将整个数据页写入到磁盘(无论是磁盘还是 buffer pool,他们管理数据的单位都是以页为单位),这个代价未免也太了(每个数据页默认是 16KB),而一条 redo log 日志的大小可能就只有几个字节,因此每次磁盘 IO 写入的数据量更小,那么耗时也会更短。

Undo log

undo log 叫做回滚日志,保证事务的原子性,记录事务修改之前的数据信息,因此假如由于系统错误或者rollback操作而回滚的话可以根据undo log的信息来进行回滚到没被修改前的状态。

他正好跟前面所说的重做日志所记录的相反,重做日志记录数据被修改后的信息。undo log主要记录的是数据的逻辑变化,为了在发生错误时回滚之前的操作,需要将之前的操作都记录下来,然后在发生错误时才可以回滚。

Java架构直通车——InnoDB事务是如何通过日志来实现的?

总结

事务的原子性是通过 undo log 来实现的
事务的持久性性是通过 redo log 来实现的
事务的隔离性是通过 (读写锁+MVCC)来实现的

Undo log是InnoDB MVCC事务特性的重要组成部分。