日志:Redo Log 和 Undo Log · 语雀 (yuque.com)
通过写入日志来保证原子性、持久性是业界的主流做法。
介绍 Redo Log 和 Undo Log
Redo Log 是什么:Redo Log 被称为重做日志。
Undo Log 是什么:Undo Log 被称为撤销日志、回滚日志。
技术是为了解决问题而生的,通过 Redo Log 我们可以实现崩溃恢复,防止数据更新丢失,保证事务的持久性。也就是说,在机器故障恢复后,系统仍然能够通过 Redo Log 中的信息,持久化已经提交的事务的操作结果。
技术是为了解决问题而生的,Undo Log 的作用 / 功能:
- 事务回滚:可以对提前写入的数据变动进行擦除,实现事务回滚,保证事务的原子性。
- 实现 MVCC 机制:Undo Log 也用于实现 MVCC 机制,存储记录的多个版本的 Undo Log,形成版本链。
Undo Log 中存储了回滚需要的数据。在事务回滚或者崩溃恢复时,根据 Undo Log 中的信息对提前写入的数据变动进行擦除。
Redo Log 和 Undo Log 都是用于实现事务的特性,并且都是在存储引擎层实现的。由于只有 InnoDB 存储引擎支持事务,因此只有使用 InnoDB 存储引擎的表才会使用 Redo Log 和 Undo Log。
实现本地事务的原子性、持久性
“Write-Ahead Log”日志方案
MySQL 的 InnoDB 存储引擎使用“Write-Ahead Log”日志方案实现本地事务的原子性、持久性。
“提前写入”(Write-Ahead),就是在事务提交之前,允许将变动数据写入磁盘。与“提前写入”相反的就是,在事务提交之前,不允许将变动数据写入磁盘,而是等到事务提交之后再写入。
“提前写入”的好处是:有利于利用空闲 I/O 资源。但“提前写入”同时也引入了新的问题:在事务提交之前就有部分变动数据被写入磁盘,那么如果事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。“Write-Ahead Log”日志方案给出的解决办法是:增加了一种被称为 Undo Log 的日志,用于进行事务回滚。
变动数据写入磁盘前,必须先记录 Undo Log,Undo Log 中存储了回滚需要的数据。在事务回滚或者崩溃恢复时,根据 Undo Log 中的信息对提前写入的数据变动进行擦除。
“Write-Ahead Log”在崩溃恢复时,会经历以下三个阶段:
- 分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,找出所有没有 End Record 的事务,组成待恢复的事务集合(一般包括 Transaction Table 和 Dirty Page Table)。
- 重做阶段(Redo):该阶段依据分析阶段中,产生的待恢复的事务集合来重演历史(Repeat History),找出所有包含 Commit Record 的日志,将它们写入磁盘,写入完成后增加一条 End Record,然后移除出待恢复事务集合。
- 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的待恢复事务集合,此时剩下的都是需要回滚的事务(被称为 Loser),根据 Undo Log 中的信息回滚这些事务。
MySQL 中一条 SQL 更新语句的执行过程
以下的执行过程限定在,使用 InnoDB 存储引擎的表
事务开始
申请加锁:表锁、MDL 锁、行锁、索引区间锁(看情况加哪几种锁)
执行器找存储引擎取数据。
- 如果记录所在的数据页本来就在内存(innodb_buffer_cache)中,存储引擎就直接返回给执行器;
- 否则,存储引擎需要先将该数据页从磁盘读取到内存,然后再返回给执行器。
执行器拿到存储引擎给的行数据,进行更新操作后,再调用存储引擎接口写入这行新数据。
存储引擎将回滚需要的数据记录到 Undo Log,并将这个更新操作记录到 Redo Log,此时 Redo Log 处于 prepare 状态。并将这行新数据更新到内存(innodb_buffer_cache)中。同时,然后告知执行器执行完成了,随时可以提交事务。
手动事务 commit:执行器生成这个操作的 Binary Log,并把 Binary Log 写入磁盘。
执行器调用存储引擎的提交事务接口,存储引擎把刚刚写入的 Redo Log 改成 commit 状态。
事务结束
Redo Log 的两阶段提交 & 崩溃恢复
在上面【MySQL 中一条 SQL 更新语句的执行过程】部分,最后将 Redo Log 的写入拆成了两个步骤:prepare 和 commit,这就是"两阶段提交"。
“两阶段提交”的作用 / 目的:
- 将事务设置为 prepare,为了在崩溃重启时,能够知道事务的状态
- 保证两份日志(Binary Log 和 Redo Log)之间的逻辑一致。
如果先写 Redo Log,再写 Binary Log 或者 先写 Binary Log,再写 Redo Log,写入第一个日志后,如果此时发生了崩溃,那么第二个日志没有写入,就造成了两个日志的不一致。数据库的状态就有可能和用 Binary Log 恢复出来的库的状态不一致。备库利用 Binary Log 进行数据同步,就会出现主备库数据不一致的问题。具体的讲解可以看极客时间的专栏《MySQL实战45讲》
而使用“两阶段提交”,遵守“崩溃恢复时,判断事务该提交、还是该回滚的规则”,就可以保证两份日志(Binary Log 和 Redo Log)之间的逻辑一致。
“崩溃恢复时,判断事务该提交、还是该回滚的规则”如下:
- 如果 Redo Log 里面的事务是完整的,也就是已经有了 commit 标识,那么利用该 Redo Log 中的信息,持久化事务的操作结果;
- 如果 Redo Log 里面的事务只有完整的 prepare,则判断对应事务的 Binary Log 是否存在并完整:
a. 如果是,利用该 Redo Log 中的信息,持久化事务的操作结果;
b. 如果否,则回滚事务,根据 Undo Log 中的信息对提前写入的数据变动进行擦除。
如果事务写入 Redo Log 处于 prepare 阶段之后、写 Binary Log 之前,发生了崩溃(也就是时刻 A 发生了崩溃),由于此时 Binary Log 还没写,Redo Log 也还没处于 commit 状态,所以崩溃恢复的时候,这个事务会回滚。这时 Binary Log 还没写,所以也不会传到备库。主库和备库的数据状态一致。
如果事务写入 Binary Log 之后,Redo Log 还没处于 commit 状态之前,发生了崩溃(也就是时刻 B 发生了崩溃),根据崩溃恢复时的判断规则中第 2 条,Redo Log 处于 prepare 阶段,Binary Log 完整,所以崩溃恢复的时候,会利用该 Redo Log 中的信息,持久化事务的操作结果。这时 Binary Log 已经写了,所以会传到备库。主库和备库的数据状态一致。
Binary Log 的写入在崩溃恢复时,判断事务该提交还是该回滚时,起到了至关重要的作用,只有 Binary Log 写入成功才能保证两份日志(Binary Log 和 Redo Log)之间的逻辑一致,才能考虑提交。
Redo Log 配置的选项
- innodb_log_buffer_size:Redo Log Buffer 的内存大小
- innodb_log_file_size:单个 Redo Log 文件的空间大小
- innodb_log_files_in_group:Redo Log 文件的数量(默认值是 2)
- innodb_log_group_home_dir:Redo Log 文件的存储目录(默认值是 .\ ,即数据目录)
- innodb_flush_log_at_trx_commit:Redo Log 的写入策略
Redo Log 的写入策略
我们在【MySQL 中一条 SQL 更新语句的执行过程】部分的第 5 步中说:存储引擎将这行新数据更新到内存(innodb_buffer_cache)中。同时,将这个更新操作记录到 Redo Log,此时 Redo Log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
生成的 Redo Log 是存储在 Redo Log Buffer 后就返回,还是必须写入磁盘后才能返回呢?这就是 Redo Log 的写入策略。Redo Log 的写入策略由 innodb_flush_log_at_trx_commit 参数控制。
我们可以通过修改该参数的值,设置 Redo Log 的写入策略,该参数可选的值有 3 个:
- 值为 0 :表示每次事务提交时,只是把 Redo Log 存储到内存(Redo Log Buffer)就返回,不关心写入文件
- 值为 1 :表示每次事务提交时,将 Redo Log Buffer 中的内容写入并同步到文件后才能返回(write + fsync 才能返回,这是参数的默认值)
- 值为 2 :表示每次事务提交时,只是把 Redo Log Buffer 中的内容写入内核缓冲区,但不对文件进行同步,何时同步由操作系统来决定(write,fsync 的时机由操作系统决定)
Redo Log 文件组
MySQL 的数据目录(使用 show variables like 'datadir' 查看)下默认有两个名为 ib_logfile0 和
ib_logfile1 的文件,Redo Log Buffer 中的 Redo Log 默认情况下就是刷新到这两个磁盘文件中。
数据目录的位置也可以通过以下命令查看:select @@datadir;
如果我们对默认的 Redo Log 文件组不满意,可以通过下边几个启动参数来调节:
- innodb_log_buffer_size:每个 Redo Log 文件的空间大小
- innodb_log_file_size:每个 Redo Log 文件的最大空间大小
- innodb_log_files_in_group:Redo Log 文件组中所有 Redo Log 文件的数量(默认值是 2,最大值是 100)
- innodb_log_group_home_dir:Redo Log 文件的存储目录(默认值是 .\ ,即 MySQL 的数据目录)
从上边的描述中可以看到,磁盘上的 Redo Log 文件不只一个,而是以一个 日志文件组 的形式出现的。这些文件
以 ib_logfile[数字] ( 数字 可以是 0 、 1 、 2 ...)的形式进行命名。
在将 Redo Log 写入 日志文件组 时,是从 ib_logfile0 开始写,如果 ib_logfile0 写满了,就接着 ib_logfile1 写,同理, ib_logfile1 写满了就去写 ib_logfile2 ,依此类推。
如果写到最后一个文件该怎么办呢?那就重新转到 ib_logfile0 继续写,所以整个过程如下图所示:
总共的 Redo Log 文件空间大小其实就是:innodb_log_file_size × innodb_log_files_in_group 。(单个文件的空间大小 * 文件组中的文件个数)
如果采用循环使用的方式向 Redo Log 文件组里写数据的话,那就会造成追尾,也就是后写入的 Redo Log 覆盖掉前边写的 Redo Log。为了解决 Redo Log 的覆盖写入问题,InnoDB 的设计者提出了 checkpoint 的概念。
Redo Log 写入 Redo Log Buffer
Redo Log 的格式
InnoDB 的设计者为 Redo Log 定义了多种类型,以应对事务对数据库的不同修改场景,但是绝大部分类型的 Redo Log 都有下边这种通用的结构:
各个部分的详细释义如下:
- type:该条 Redo Log 的类型
- space ID :表空间ID
- page number :页号
- data :该条 Redo Log 的具体内容
在 MySQL 5.7.21 这个版本中,InnoDB 的设计者一共为 Redo Log 设计了 53 种不同的类型。各种类型的 Redo Log 的不同之处在于 data 的具体结构不同。
Redo Log 的具体格式可以看掘金小册《MySQL 是怎样运行的:从根儿上理解 MySQL》
这些类型的 Redo Log 既包含 物理 层面的意思,也包含 逻辑 层面的意思,具体指:
- 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了什么修改。
- 逻辑层面看,在系统奔溃重启时,并不能直接根据 Redo Log 中的信息,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统奔溃前的样子。
总结来说,Redo Log 中记录的是该操作对哪个表空间的哪个页的哪个偏移量进行了什么修改。
Mini-Transaction
一个事务可能包含多条 SQL 语句,每一条 SQL 语句可能包含多个「对底层页面的操作」,每个「对底层页面的操作」可能包含多个 Redo Log。这样的一个「对底层页面的操作」的过程被称为 Mini-Transaction,简称 mtr。
「对底层页面的操作」比如说:
- 向聚簇索引对应的 B+ 树的某个页面中插入一条记录,插入一条记录这个操作可能包含多个 Redo Log
我们需要保证一个「对底层页面的操作」对应的多个 Redo Log 不可分割,即一个「对底层页面的操作」是原子的,这个操作对应的 Redo Log 要么都写入磁盘,要么都不写入磁盘。所以 InnoDB 的设计者规定在执行这些需要保证原子性的操作时必须以 组 的形式来记录 Redo Log,在进行奔溃恢复时,针对某个组中的 Redo Log,要么把全部的 Redo Log 都恢复掉,要么一个 Redo Log 也不恢复。
那么 InnoDB 的设计者是怎么做到分组的呢?InnoDB 的设计者在一个「对底层页面的操作」的最后一个 Redo Log 后面加上一个特殊类型的 Redo Log。相当于某个需要保证原子性的操作产生的一系列 Redo Log 必须要以一个特殊类型的 Redo Log 结尾,这样在奔溃恢复时:
- 只有当解析到特殊类型的 Redo Log 时,才认为解析到了一组完整的 Redo Log,才会进行恢复。
- 否则的话直接放弃前边解析到的 Redo Log。
Redo Log Buffer
Redo Log Buffer 就是在服务器启动时,向操作系统申请的大一片连续的内存空间。
这片连续的内存空间被划分为若干个连续的用来存储 Redo Log 的数据页。
用来存储 Redo Log 的数据页被称为 Redo Log Block。
我们可以通过启动参数 innodb_log_buffer_size 来指定 Redo Log Buffer 的大小。
- LOG_BLOCK_CHECKPOINT_NO :表示 checkpoint 的序号
Redo Log 写入 Redo Log Buffer
我们前边说过一个 mtr 执行过程中可能产生若干个 Redo Log ,这些 Redo Log 是一个不可分割的组,所以其实并不是每生成一个 Redo Log,就将其插入到 Redo Log Buffer 中,而是每个 mtr 运行过程中产生的日志先暂时存到一个地方,当该 mtr 结束的时候,将过程中产生的一组 Redo Log 再全部复制到 Redo Log Buffer 中。
不同的事务可能是并发执行的,所以不同事务的 mtr 可能是交替执行的。每当一个 mtr 执行完成时,伴随
该 mtr 生成的一组 Redo Log 就需要被复制到 Redo Log Buffer 中,也就是说不同事务的 mtr 可能是交替写入 Redo Log Buffer 的。
Redo Log Buffer 中 Redo Log 的刷盘时机
mtr 运行过程中产生的一组 Redo Log 在 mtr 结束时会被复制到 Redo Log Buffer 中,在一些情况下 Redo Log Buffer 中的 Redo Log 会被写回磁盘,Redo Log 的刷盘时机如下:
- Redo Log Buffer 的空间不足时,执行刷盘操作
- 一个事务提交时,执行刷盘操作(需要设置指定参数)
- 将某个脏页刷新到磁盘前,会先保证该脏页对应的 Redo Log 刷新到磁盘中(Redo Log 是顺序写入的,因此在将某个脏页对应的 Redo Log 从 Redo Log Buffer 刷新到磁盘时,也会保证将在其之前产生的 Redo Log 也刷新到磁盘中。
- 后台线程不停的执行刷盘操作
- 正常关闭服务器时,执行刷盘操作
- 做 checkpoint 时,执行刷盘操作
Redo Log Buffer 的空间不足时,执行刷盘操作
Redo Log Buffer 的空间是有限的,空间大小由 innodb_log_buffer_size 来指定。
InnoDB 的设计者认为:如果 Redo Log Buffer 的内存被占用 1 / 2,就需要把 Redo Log Buffer 中的 Redo Log 刷新到磁盘。
一个事务提交时,执行刷盘操作
在前面【Redo Log 的写入策略】部分,讲到我们可以通过设置 innodb_flush_log_at_trx_commit 参数的值,在事务提交时执行刷盘操作后才能返回。
事务提交时执行刷盘操作后才能返回是 Redo Log 的默认写入策略。
后台线程不停的执行刷盘操作
后台有一个线程,每秒都会执行一次刷盘操作。后台线程执行刷盘操作的频率可以通过参数设置。
具体通过哪个参数设置,我也不清楚。
正常关闭服务器时,执行刷盘操作
做 checkpoint 时,执行刷盘操作
等等
Undo Log 的写回策略
MySQL中的 Undo Log 严格的讲不是 Log,而是数据,因此它的管理和落盘都跟数据是一样的:
- Undo 的磁盘结构并不是顺序的,而是像数据一样按 Page 管理
- Undo 写入时,也像数据一样产生对应的 Redo Log
- Undo 的 Page 也像数据一样缓存在 Buffer Pool 中,跟数据 Page 一起做 LRU 换入换出,以及刷脏。Undo Page 的刷脏也像数据一样要等到对应的 Redo Log 落盘之后
之所以这样实现,首要的原因是 MySQL 中的 Undo Log 不只是承担 Crash Recovery 时保证 Atomic 的作用,更需要承担 MVCC 对历史版本的管理的作用,设计目标是高事务并发,方便的管理和维护。因此当做数据更合适。
但既然还叫 Log,就还是需要有 Undo Log 的责任,那就是保证 Crash Recovery 时,如果看到数据的修改,一定要能看到其对应 Undo 的修改,这样才有机会通过事务的回滚保证 Crash Atomic。标准的 Undo Log 这一步是靠 WAL 实现的,也就是要求 Undo 写入先于数据落盘。而 InnoDB 中 Undo Log 作为一种特殊的数据,这一步是通过 redo 的 min-transaction 保证的,简单的说就是数据的修改和对应的 Undo 修改,他们所对应的 Redo Log 被放到同一个 min-transaction 中,同一个 min-transaction 中的所有 Redo Log 在 Crash Recovery 时以一个整体进行重放,要么全部重放,要么全部丢弃。
Undo Log 配置的选项
- innodb_max_undo_log_size:值为,单个 Undo Log 最大可占用的字节存储空间;单位为,字节(默认值是 1 G)。
- innodb_undo_directory:Undo Log 文件的存储目录(默认值是 .\ ,即数据目录)表示回滚日志的存储目录是数据目录,数据目录的位置可以通过查询变量“datadir”来查看。
- innodb_undo_log_encrypt:Undo Log 是否加密(默认值是 off,即不加密)。
- innodb_undo_log_truncate:Undo Log 是否自动截断回收(默认值是 on,即自动截断回收)。这个变量有效的前提是设置了使用独立表空间。
- innodb_undo_tablespaces:值为 Undo Log 的独立表空间的数量(默认值是 0,即 Undo Log 没有独立的表空间,默认记录到共享表空间 ibdata 文件中)
参考资料
20 | 日志(下):系统故障,如何恢复数据? (geekbang.org)
MySQL 是怎样运行的:从根儿上理解 MySQL - 小孩子4919 - 掘金课程 (juejin.cn)