Skip to content

ARIES 与 MySQL InnoDB:事务视角的深度对比

通过对 CS186 课程中 ARIES 理论的学习,以及对 MySQL InnoDB 引擎实现的了解,我将从事务的视角出发,对比分析 ARIES 和 InnoDB 在实现上的异同,深入探讨为什么 InnoDB 需要 Redo LogUndo Log。主要目的是做一个理论上和实践上的对比,来看看到底有哪些异同,同时深入理解一下ARIES以及innodb对事务的实践。


事务与 ACID 特性

事务的四大特性是 ACID原子性(Atomicity)一致性(Consistency)隔离性(Isolation)和持久性(Durability)。其中,一致性通常通过原子性、隔离性以及持久性来保证。本文将主要聚焦于原子性、持久性,并简要讨论隔离性。

隔离性:意向锁的精妙

隔离性确保一个事务的执行不被其他事务干扰。在数据库中,这通常通过锁机制实现。如果仅仅使用简单的读写锁(S/X 锁),并发性能会非常差,因为其粒度不够。

设想一个场景:事务 A 想要给整个表加一个排他锁(X 锁)。如果没有更精细的机制,数据库需要遍历整个表来检查是否有其他事务已经锁定了其中的行,这会非常耗时,严重影响性能。

为了解决这个问题,意向锁(Intention Locks) 应运而生。意向锁分为三种:IS (Intention Shared)IX (Intention Exclusive)SIX (Shared Intention Exclusive)。它们在表级和行级资源之间提供了一种协调机制。

例如,当事务 A 想要更新 users 表中的某一行数据时,它会首先在 users 表上加一个 IX 锁。这个 IX 锁表明:“注意了,这张表里有一行要被修改了!”如果事务 B 也想修改 users 表中的另一行,它同样可以在表上加一个 IX 锁,两者互不干扰,从而实现高并发。然而,如果事务 C 想覆写整个 users 表(需要 X 锁),其 X 锁行为将与 A 和 B 在表上持有的 IX 意向锁发生冲突,导致事务 C 被阻塞,直到 A 和 B 的事务完成。

通过意向锁,A 和 B 对不同行的修改可以并发执行,而 C 的整表覆写操作则会被阻塞。这种机制在保证线程安全的同时,最大化了并发性能,比简单的读写锁提供了更细粒度的互斥性,实现了类似从 Java synchronized 到读写锁,再到更深层次行级锁与表级锁关系的精细化管理。


原子性:事务的最小单位

在最初理解时,原子性可能只是一个“全或无”的概念,但深入学习后,原子性更深层次的意义在于保证事务作为最小的操作单位

原子性确保事务要么全部成功,要么全部失败。在数据库中,尤其是在涉及持久化存储时,原子性的重要性体现在应对系统崩溃的场景。当一个事务执行到一半时发生崩溃,此时最小的执行单位是单个操作而非整个事务。通过日志中的撤销功能(Undo),我们可以回滚该事务的所有操作,确保数据的最小单位仍然是事务,而不是零散的操作。

数据库与 Java 中直接存储在哈希表中的数据不同,它需要持久化保存,以便重启后能恢复到之前的状态。原子性真正发挥作用的场景在于,它通过概念上将事务封装为原子操作,定义了脏数据和正常数据的界限。结合持久化提供的回滚能力,即使事务未执行完成而系统崩溃,也能保证数据最终回到事务开始前的状态,从而确保数据的最小单位是事务。这才是原子性真正被需要的地方。


持久性与 ARIES

既然提到了持久性,我们就来聊聊 ARIES (Algorithms for Recovery and Isolation Exploiting Semantics) 是如何实现它的。

我们设定一个场景:数据库中原有数据,并已生成一个检查点(Checkpoint)事务 A 正在执行,总共涉及 10 行数据,目前执行到第 5 行时,数据库崩溃并重启。 这个场景非常典型,可以帮助我们理解持久性和原子性。

在将每一条修改记录写入磁盘之前,ARIES 遵循 WAL (Write-Ahead Logging) 机制,这意味着数据真正持久化之前,修改记录会首先写入日志

当前的日志可能类似这样:

LSN_C1 - CHECKPOINT - - (Active Txs: [], Dirty Pages: []) // 检查点,记录了崩溃时活跃事务和脏页的状态
... (其他已提交事务的日志记录,或非事务性操作) ...
LSN_A_start TxA BEGIN - - (事务 A 开始)
LSN_A_R1 TxA UPDATE P_R1 LSN_A_start (事务 A 修改第 1 行所在页面 P_R1: OldValue_R1 -> NewValue_R1)
LSN_A_R2 TxA UPDATE P_R2 LSN_A_R1 (事务 A 修改第 2 行所在页面 P_R2: OldValue_R2 -> NewValue_R2)
LSN_A_R3 TxA UPDATE P_R3 LSN_A_R2 (事务 A 修改第 3 行所在页面 P_R3: OldValue_R3 -> NewValue_R3)
LSN_A_R4 TxA UPDATE P_R4 LSN_A_R3 (事务 A 修改第 4 行所在页面 P_R4: OldValue_R4 -> NewValue_R4)
LSN_A_R5 TxA UPDATE P_R5 LSN_A_R4 (事务 A 修改第 5 行所在页面 P_R5: OldValue_R5 -> NewValue_R5)

此时系统崩溃并重启。在实际的 ARIES 实现中,数据库启动时会从日志的末尾开始反向扫描,目的是快速找到最新的检查点记录。找到最新检查点后,才会从检查点记录的 RedoLSN(恢复起始 LSN,通常是检查点时刻最老的脏页的 recLSN 或检查点本身的 LSN)开始正向扫描日志进行恢复。

整个恢复过程分为三个阶段,这三个阶段的组合提供了 ARIES 的持久性和原子性能力:分析阶段 (Analysis)重做阶段 (Redo)撤销阶段 (Undo)

检查点不仅仅是简单地记录当前状态,它更是复杂恢复机制的关键点。ARIES 采用 No-Force/Steal 策略,这对其高性能至关重要:

  • No-Force:在事务提交时,不强制将所有修改过的脏页立即刷盘。这极大地提高了事务提交的性能。
  • Steal:允许将未提交事务修改的脏页提前刷盘。这有助于缓冲区的管理,防止内存耗尽。

1. 分析阶段 (Analysis)

在分析阶段,系统会根据检查点中存储的脏页表 (Dirty Page Table, DPT)活跃事务表 (Active Transaction Table, ATT) 重建崩溃前的内存状态。

  • DPT 记录了在检查点生成时哪些页面是脏的(Dirty Pages),以及每个脏页的 recLSN (Recovery LSN),即该脏页中最早需要重做的日志记录的 LSN。如果 DPT 为空,意味着所有脏数据页都已成功写入磁盘。
  • ATT 记录了崩溃时所有正在进行的活跃事务,包括每个事务的 lastLSN(其最后一条日志记录的 LSN)。如果 ATT 为空,代表数据库中没有正在进行的事务。

通过重建 DPTATT,系统能够确定哪些修改可能未持久化,以及哪些事务是未完成的。在我们的场景中,事务 A 的五条记录(LSN_A_R1LSN_A_R5)会在分析阶段被发现是未完成的事务。由于事务 A 在崩溃时未提交或中止,它将被标记为 recovery_abort 状态,指示其在撤销阶段需要被撤销。

例如,我们场景中的事务 A,就将进入 recovery_abort 状态,指示这个事务需要在撤销阶段被撤销。

再分析几个不同情况:

  • 假设事务在崩溃前已经 commit 了,但其修改尚未写入磁盘,那么这个 commit 状态会转变为 complete,然后对应的数据在之后的重做阶段会被重做到磁盘上。
  • 如果事务在崩溃前已明确写入 ABORT 日志记录(即已处于中止阶段),它将在分析阶段从活跃事务列表中移除,在 Undo 阶段不再需要额外处理。

简而言之,分析阶段就是通过检查点和扫描日志,精确识别出哪些逻辑上已完成但未刷盘的操作需要重做,以及哪些未完成的事务需要撤销。

2. 重做阶段 (Redo)

重做阶段是真正提供持久化能力的关键。LSN(Log Sequence Number) 是日志的编号,表示日志记录的顺序。重做有两个基本原则:

  • 基于 DPT 的最小 recLSN:系统从分析阶段重建的 DPT 中最小的 recLSN 开始正向扫描日志,因为任何小于 recLSNLSN 所对应的操作肯定已被刷盘,无需重做。这保证了我们从恢复所需的最早日志点开始重做,避免不必要的重复工作。
  • 幂等性保证:重做操作是幂等的,即重复执行不会产生错误结果。这是通过比较日志记录的 LSN数据页的 pageLSN 来实现的。每个数据页头部存储着一个特殊的字段 pageLSN,它记录了最后一次成功应用到该页面的日志记录的 LSN。在重做一条日志记录时,只有当日志记录的 LSN 大于数据页的 pageLSN 时,该日志记录才会被应用到内存页上。这保证了即使在恢复过程中同一条日志被多次读取,也不会重复执行已经应用过的操作,从而确保了重做操作的幂等性。

在实际重做过程中,对于每一条可重做的日志,系统都会将其应用到相应的数据页上,引用到内存当中。例如,在我们的场景中,事务 A 对 P_R1 到 P_R5 的修改 (LSN_A_R1 到 LSN_A_R5) 都会被重新应用到对应的内存数据页上。

3. 撤销阶段 (Undo)

在重做阶段,我们重复历史,使其回归到数据库崩溃前的状态,这包含了脏数据(即未完成事务的日志)。不同于分析和重做阶段是从前往后,撤销阶段通过反向遍历的方式进行撤销。每当执行一个撤销操作,都生成一个补偿日志记录 (Compensation Log Record, CLR),并将其写入日志,然后执行实际的撤销操作。

CLR 本身不会被再次撤销,它包含了一个指针,指向下一个需要被重做的记录。生成的对应 CLR 这个补偿日志记录被应用到内存上,然后所有的撤销操作都被记录下来,记录进行了哪里。CLR 的设计精妙之处在于,它不仅记录了 Undo 的进度,更重要的是,它确保了 Undo 操作本身的原子性。如果一个 Undo 操作在执行过程中(例如,回滚一条记录时)发生崩溃,那么这个未完成的 Undo 操作本身也会被视为一个需要恢复的“事务”。由于 CLR 记录了该 Undo 操作的完成状态,在下次恢复时,系统可以通过重做 CLR 来确保这个 Undo 操作能够完整地完成,避免了 Undo 操作自身被中断导致的数据不一致问题。

假如没有 CLR 会怎么样呢? 如果没有 CLR,假设我们回滚了事务 A 对 P_R5 的修改后再次崩溃,日志中将没有任何记录表明此回滚已完成。第二次启动时,系统会再次识别到事务 A 是活跃事务,并可能再次尝试撤销相同的修改,导致重复回滚,从而引发数据不一致(例如,value = value - 10 的操作被执行了两次)。CLR 通过记录 Undo 的进度(通过 undoNextLSN),并保证 Undo 操作本身的原子性,从而防止了恢复时的重复撤销,也避免了恢复过程中的循环依赖和无限撤销。

在这个阶段,我们从后往前进行数据恢复,并应用到内存。所有处于 recovery_aborting 状态的事务都会被加入一个撤销队列,然后循环取出 LSN 号最大的日志进行撤销,并写入 CLR,保存撤销进度。等到事务全部撤销完毕,所有的脏页都被清理完毕,整个撤销阶段就完成了。这样我们的日志记录状态就删除了这个进行到一半的事务,保证了事务要么 commit 了全部完成,要么里面的内容全部被撤销了。

需要注意的是,CLR 本身也是 Redo Log 的一部分,也应遵循 WAL 机制,所以我们先记录到重做日志,告诉在恢复过程中它也应该被重做。


ARIES 流程总结

通过分析重做撤销的组合,ARIES 保证了每个事务的执行是原子性的,并在系统重启后是持久化的。隔离性由锁机制完成,从而自然达成了一致性。


InnoDB 的 Redo Log 与 Undo Log 分离

在 ARIES 的情境下,所有操作都记录在同一个日志中,一个日志承担了多个职责。而在 InnoDB 中,Redo LogUndo Log分离的Undo Log 存储的是逻辑上的改变,而 Redo Log 存储的是物理上的改变。InnoDB 的检查点信息也存储在 Redo Log 之中。对数据和对 Undo 的修改都将在对应内存页上进行,因此,CLR 和具体的修改都会被视为修改,存储在 Redo Log 中,而没有单独的 CLR 记录,而是记录对 Undo Log 的修改,这本身也是 CLR 的理念。

为什么分开存储?

InnoDB 将 Redo LogUndo Log 分离,这种设计并非简单的职责划分,而是基于更深层次的考虑:

Undo Log:逻辑回滚与 MVCC

Undo Log 存储的是逻辑上的反向操作信息,它记录了如何撤销一个操作。例如:

  • 对于一个 INSERT 操作,Undo Log 记录的是一条 DELETE 该记录的信息。
  • 对于一个 UPDATE 操作,Undo Log 记录的是如何将数据改回旧值的信息。
  • 对于一个 DELETE 操作,Undo Log 记录的是一条 INSERT 回被删除记录的信息。

Undo Log 的格式大致是:undoNo | 事务ID | 操作类型(如INSERT_UNDO、UPDATE_UNDO) | 回滚指针(指向前一个版本) | 关键字段值(如主键) | 撤销所需信息(如旧值或被删除记录的完整信息)。通过回滚指针Undo Log 形成一个版本链,从而提供了回滚的能力。这正是 ARIES 的 Undo 阶段在 InnoDB 中的实现方式。

物理存储:在 InnoDB 中,Undo Log 存储在 Undo Log Segment 中,这些 Segment 位于 共享表空间(System Tablespace,即 ibdata 文件) 或者 独立的 Undo Tablespace 文件(如 undo_001.ibd, undo_002.ibd 等) 中。

Redo Log:物理持久性保障

Redo Log 存储的是物理上的页面修改。它记录了对数据页的具体物理修改操作。Redo Log 的日志格式大致是:LSN | 事务ID | 类型 | 物理位置(页 ID、偏移量) | 修改内容的二进制表示 | 对应的 undoNo(如果修改涉及 Undo Log 页)Redo Log 是事务持久性的保障,记录了所有内存页上的物理修改。

物理存储和刷盘机制Redo Log 通常存储在名为 ib_logfile 的文件中(如 ib_logfile0, ib_logfile1),这些文件组成了固定大小的循环缓冲区Redo Log 的刷盘策略受到 innodb_flush_log_at_trx_commit 等参数的影响:

  • 0:每秒将 Redo Log 缓冲区写入文件并刷盘。
  • 1:每次事务提交时,将 Redo Log 缓冲区写入文件并刷盘(最安全,但性能开销大)。
  • 2:每次事务提交时,将 Redo Log 缓冲区写入文件,但每秒才刷盘一次。 这些参数直接影响着数据库的性能和数据持久性保证。

Redo Log 与 CLR 的根本差异

ARIES 的 CLR 是独立的日志记录类型,它表示一个“撤销操作的完成”这一逻辑事件,并且包含一个 undoNextLSN 指针,指向下一个需要撤销的日志记录。它的核心作用是记录 Undo 的进度,防止恢复时的重复撤销CLR 是逻辑层面的“撤销操作完成”记录。

InnoDB 的 Redo Log 记录的是物理页面的修改。当 InnoDB 执行 UNDO 操作时,它本质上也是对数据页面(包括存储用户数据的页面和存储 Undo Log 的页面)进行了物理修改。这些物理修改同样会生成 Redo Log。所以,Redo Log 记录的是“对数据页 A 的某个偏移量进行了修改”,而不管这个修改是正常的 DML 操作还是因为执行了 UNDO 操作而产生的物理页面变化。Redo Log 本身并不“知道”这是对 Undo Log 的修改,它只记录页面数据的物理变化。因此,Redo Log 记录对 Undo Log 页的修改并非“实现了 CLR 的理念”,而是因为 UNDO 操作本身会修改数据页(包括 Undo Log 页),这些修改自然也需要 Redo Log 来保证其持久化。两者虽然都有保证恢复正确性的目的,但设计理念和实现方式完全不同

Redo Log 和 Undo Log 分离的深层原因

除了 Redo Log 的顺序写入性能MVCC 优化这两个原因外,Redo LogUndo Log 分离的深层原因还在于它们的粒度、生命周期和用途的根本性差异

粒度不同:

  • Redo Log:记录的是物理页面级别的修改,粒度是页或页内偏移量。它关注的是“哪个数据块(物理位置)被修改了,具体修改了什么二进制内容”。这种物理记录方式使得其在崩溃恢复时可以高效地“重放”物理操作。
  • Undo Log:记录的是逻辑行级别的修改的反向操作。它关注的是“某个事务的某个操作如何回滚,如何恢复到某个旧的逻辑版本”。这种逻辑记录方式使得其能够支持事务回滚MVCC

生命周期不同:

  • Redo Log:一旦它所对应的脏页数据被持久化到磁盘,这部分 Redo Log 就可以被覆盖或复用(Redo Log固定大小的循环缓冲区)。它的生命周期相对较短。
  • Undo Log:则需要等到所有引用它的事务(包括 MVCC 的读事务,它们可能需要读取旧版本数据)都结束后才能被清理。Undo Log 的清理是由后台的Purge 线程异步完成的,并且这个过程会受到 MVCC 活跃事务的影响,只有当没有活跃事务再需要访问某个旧版本数据时,对应的 Undo Log 才能被清理。这意味着 Undo Log 的生命周期可能很长,甚至会存在很长时间的历史版本链。将两者合并会导致日志管理极其复杂且效率低下。

用途不同:

  • Redo Log:主要用于崩溃恢复时的 Redo 阶段,以及保证持久性。其设计目标是高效、顺序地记录所有物理变更,以便在崩溃后能快速恢复到崩溃前状态。
  • Undo Log:主要用于事务回滚(Rollback)和MVCC(多版本并发控制)。通过 Undo Log 链,读事务可以访问到数据在特定时间点的历史版本,从而避免读写冲突,提高并发性。

将二者分离,可以更好地优化各自的存储结构和访问模式,确保 Redo Log高效顺序写入,同时 Undo Log 能够灵活支持 MVCC 所需的随机访问长期保留


Binlog 与 Undo Log 的区别

既然 Undo Log 已经记录了逻辑修改,为什么还需要 Binlog 来存储二进制逻辑修改呢?

Undo Log Undo Log 本质上是提供事务回滚能力和多版本并发控制(MVCC)的基础数据结构。它记录的是逻辑上的反向操作,会被清理线程进行异步清除。而 Binlog 的主要目的是提供主从复制功能时间点恢复功能,它记录的是提交后的最终逻辑修改,例如 SQL 语句。Binlog 保留时间极长,可以无限增长。如果将 Undo Log 与 Binlog 合并,那么 Binlog 不仅要记录最终提交的逻辑修改,还要记录所有的版本链,这会导致 Binlog 的体积非常大,从而使主从同步变慢,甚至无法同步。而且,许多旧的版本链可能已经不需要了,Binlog 的设计是为了提供一个最终一致的状态,而不是记录所有的历史版本。

因此,BinlogUndo Log 的区别在于:

  • Binlog最终一致的状态,记录的是最终的逻辑操作,与物理存储结构无关,具有更强的通用性
  • Undo Log 是为了提供回滚能力的临时状态,记录的是逻辑操作的历史版本链。存储在 InnoDB 的数据文件中,通常是专门的 Undo 表空间或共享表空间。

简单来说,Binlog 关注的是“最终发生了什么”,而 Undo Log 关注的是“如何回到以前的状态”。