MySQL 的事务是如何实现的?
MySQL 的事务是一个非常复杂的话题,在思考这个问题的时候,想要即触及重点又不显得啰嗦,就需要采用"总-分-总"的结构,并紧扣 ACID 这一核心模型来展开。
一句话核心
MySQL(InnoDB 引擎)的事务实现,本质上就是为了保证 ACID特性。它的底层主要依赖两类日志(redo log & undolog)和一套并发控制机制(锁 & MVCC)来共同完成。
- 原子性(Atomicity):靠 Undo Log。原子性保证事务要么全部成功,要么全部失败。这是通过 Undo Log(回滚日志)实现的。它记录了数据的逻辑反向操作(比如 Insert 对应 Delete)。如果事务失败或回滚,利用 Undo Log 就能把数据恢复到事务开始之前的状态。
- 持久性(Durability):靠 Redo Log(WAL)。持久性保证提交的数据不丢失。这是通过 Redo Log(重放日志)实现的。它遵循 WAL(Write-Ahead Logging)原则,事务提交时先写日志再刷盘。即使宕机,重启后也能通过 Redo Log 重放来恢复数据。另外,Bin Log 和 Redo Log 通过两阶段提交(2PC)来保证逻辑一致性。
- 隔离性(Isolation):靠锁和 MVCC
机制。隔离性是为了解决并发问题,InnoDB 提供了两种手段。
- 写操作(当前读):依赖锁机制(如 Record Lock、Gap Lock、Next-Key Lock)来防止脏读和幻读。
- 读操作(快照读):依赖 MVCC(多版本并发控制),通过 ReadView + Undo Log 版本链实现不加锁的非阻塞读,极大提高了并发性能。
- 一致性(Consistency):一致性是事务的最终目标。它是通过上述的原子性、持久性和隔离性共同保障的,同时还需与业务层面的逻辑约束(如外键)来配合。
所以简单来说,Redo Log 保证了数据不丢,Undo Log 保证了可以后悔,MVCC 和锁保证了并发安全,最终实现了事务的一致性。
redo log 是怎么实现持久性的?
对 ACID 有了全局视野上的认知后,那么第一个问题就来了,redo log 是如何实现持久性的?
一句话核心
Redo Log 实现持久性的核心思想是 WAL(Write-AheadLogging,日志先行)技术,简单来说就是:先顺序写日志,后提交事务,再写磁盘数据。
具体来说包含 3 个方面:
- 利用顺序写替代随机写(性能基础):当事务修改数据时,InnoDB 只是修改了内存(Buffer Pool)中的数据页,此时数据变成了脏页。 如果每次修改都直接把数据页刷入磁盘,那是随机 I/O,性能非常差。 所以,InnoDB 先把对数据页的物理修改记录到 Redo Log 中。Redo Log 是追加写的,属于顺序 I/O,速度非常快。只要日志落盘了,事务就算成功了。
- 刷盘策略:为了保证日志真的落盘,InnoDB 提供了
innodb_flush_log_at_trx_commit参数。 实现 ACID 中严格持久性的关键在于将该参数设置为 1。 这意味着:每次事务提交时,都会强制调用fsync将 Redo Log Buffer 中的日志刷入磁盘。只有刷盘成功,事务才算 Commit 成功。 - 崩溃恢复:如果 MySQL 宕机,内存里的脏页还没来得及刷入磁盘(丢失了)。 但在重启时,InnoDB 会读取磁盘上的 Redo Log,根据日志里的物理修改记录(比如把第 10 页偏移量 50 的值改为 A),重新把这些操作在内存里执行一遍。 这个过程叫 Crash Recovery,它保证了即使宕机,提交过的数据也绝对不会丢失。
另外,Redo Log 是循环写入的(Circular Buffer),配合 Checkpoint 机制。当脏页真正刷入磁盘后,对应的 Redo Log 空间就可以被覆盖重用了。
redo log 是如何进行崩溃恢复的?(持久性+原子性)
接下来最复杂的地方就是 redo log 它凭什么能支持崩溃恢复?
redo log 的存储结构是怎样的?
首先我们要看一下 redo log 的底层结构是怎样的:
- redo log 是循环写入的,底层会将 redo log 分成一个个的 redo log block,每个 block 大小为 512MB。
- 事务中执行修改类 SQL 的时候,在修改完内存数据(buffer pool)每条会生成 LSN(Log Sequence Number),然后 WAL 写到 redo log block 中。注意,这里一个事务会生成多个 LSN,多个事务是并发执行的,所以多个事务之间的 LSN 是可能存在交替的。也就是说,为提交事务的 LSN 也可能会被刷盘哦!(后面会详细展开为什么可以这样)
- LSN 是逻辑上日志按照时间顺序从小到大的编码,在 innodb 上,LSN 是一个 64 位的整数,取的是从数据库安装启动开始,到当前所写入的总的日志字节数。

需要注意的是,redo log 也不是直接写磁盘的,也是先写 redo log buffer,然后再刷盘的。

所以要保证真正的持久性,还需要结合 redo log 的刷盘策略配置:innodb_flush_log_at_trx_commit ,InnoDB 提供了 3 种策略:
0:每秒刷一次磁盘1:每提交一个事务,就刷一次磁盘。最安全!这是保证持久性的必要条件!也是 InnoDB 的默认值!2:每次事务提交时,写到 OS Cache,但不立即 Flush。Flush 由后台线程每秒做一次。
redo log 存的是什么?
下一个问题就是 redo log 存的到底是什么?有这么几种选项:
| 形式 | 逻辑 | 缺点 |
|---|---|---|
| 类似 bin log 的 statement | 记原始 SQL 语句 insert/update/delete。 |
1. 恢复速度慢:如果数据库宕机前执行了 10 万次
UPDATE,重启时要恢复,就必须把这 10 万条 SQL
重新解析、重新优化、重新执行一遍。这消耗大量的 CPU
和时间,会导致数据库重启时间长到无法接受。2. 不确定性:如果 SQL 语句里包含了 now()、rand()
或者依赖索引顺序的操作,重放时的结果可能和宕机前不一样,导致数据不一致。3. 无法修复物理损坏: 如果磁盘上的 B+ 树数据页结构坏了(比如分裂到一半断电),单纯重放 SQL 是修不好这个页面的物理结构的。 |
| 类似 bin log 的 RAW 格式 | 记录每张表的每条记录的修改前的值,修改后的值,类似
(表, 行, 修改前的值, 修改后的值)。 |
假设一个 INSERT 操作引发了 B+ 树的 页分裂 (Page
Split)。这个操作不仅写入了数据,还修改了父节点指针、兄弟节点指针、Page
Header
等。如果在页分裂的中间断电了,数据页处于“半分裂”的损坏状态。此时,如果你只知道插入了
id=5, val=abc,你根本不知道该怎么把那个损坏的 B+ 树指针修好。 |
| 记录修改的每个 Page 的字节数据 | 由于每个 Page 有 16KB,记录这 16KB 里面哪些部分被修改了。一个 Page 如果被修改了多个地方,就会有多条物理日志。 | 1. 日志空间爆炸:InnoDB 的页大小是 16KB。如果你只是修改了一个
int 字段(4字节),却要把整个 16KB
的页都记下来,或者记录大量复杂的 Diff,日志量会极其庞大。2. 并发控制复杂:纯物理日志要求恢复时必须严格按照物理状态回放。如果采用这种方式,往往需要在写日志时对页面加更重的锁,影响高并发性能。 |
redo log 的日志叫physiological logging,翻译过来就是
physical + logical logging,即物理逻辑日志。
简单来说
先以 Page 为单位记录日志,每个 Page 里面再采取逻辑记法(记录 Page里面哪一行被修改了)。即物理到页,逻辑到行。
格式结构:Type + Space ID
+ Page Number + Data
- 物理层面:它精准记录了要修改哪个表空间 (Space ID) 的哪个数据页 (Page Number)。
- 逻辑层面:在数据页内部,它记录的是逻辑操作。
1 | 物理定位 (Physical) 逻辑操作 (Logical) |
假设我们要执行 UPDATE users SET age = 20 WHERE id = 1;
假设这条记录在 Space 5 的 Page
100,记录在页内的偏移量是 600。
Redo Log 可能会记成这样一条记录:
1 | 类型: MLOG_4BYTES (写入4字节整数) |
恢复过程是这样的:
- 物理寻址:根据
Space 5+Page 100,直接从磁盘读出这个页加载到内存。 - 逻辑重放:根据类型
MLOG_4BYTES,找到页内偏移量600的位置。 - 执行修改:把那里的 4 个字节直接覆盖为
20。
所以,Redo Log 是物理位置 + 逻辑操作的结合。这种设计既利用了物理定位的幂等性(恢复时可以直接定位到页),又节省了大量的存储空间(不用记录整个页面的变化)。
既然 Redo Log 是写在固定大小的 Block 里的,那如果我要写入的一组操作(比如一个事务)特别大,跨了多个 Block 怎么办?或者写到一半断电了,只有半条 Log 怎么办?这就涉及到了 MTR (Mini-Transaction) 的原子性设计。
MTR (Mini-Transaction) 是 InnoDB 修改底层数据页的最小原子单位。
你可以把它理解为底层的微事务。一个普通的 SQL 事务可能包含很多条 SQL,而每一条 SQL 在底层执行时,可能会触发多次 MTR。
你在 B+ 树插入一条记录,可能需要:
- 修改叶子节点页。
- 修改索引页。
- 如果触发页分裂,还要修改父节点、甚至增加树的高度。
MTR 承诺上述这 1、2、3 步涉及的所有 Redo Log,必须作为一个整体写入日志文件。要么全有,要么全无。
MTR 的运行遵循一个"先收集、后批发"的模式:
- 收集阶段 (Private Buffer): 当一个线程要修改数据页时,它会先在自己的私有内存区域开辟一块空间,把这次操作产生的所有 Physiological Log 记录下来。
- 原子提交 (Commit to Log Buffer):
当这个原子操作完成时(比如页分裂完成了),MTR 会执行
commit。此时,它会将私有缓冲区里的所有日志一次性拷贝到全局的Redo Log Buffer中。 - 打标 (The End Marker): MTR
会在这一组日志的最后一条记录后面,附带一个特殊的标记(
MLOG_MULTI_REC_END)。
End Marker 的作用:
- 恢复时的逻辑:InnoDB 在崩溃恢复扫描 Redo Log 时,会一组一组地看。
- 完整性校验:如果它发现一组日志开头了,但扫描到最后没看到
MLOG_MULTI_REC_END标记,它就认为这组日志是损坏且不完整的。 - 处理策略:直接丢弃这组不完整的日志,不进行重放。因为没看到 End Marker,说明这个 MTR 还没 Commit。根据 WAL 原则,既然日志没 Commit,那么磁盘上的物理数据页肯定也没改(或者改了也会被 Undo 回滚)。丢弃它保证了数据库永远不会停留在 B+ 树断裂的中间状态。
redo log 恢复过程是怎样的?
直到了存什么、怎么存之后,最后就是怎么用的问题了。
ARIES 算法(Algorithms for Recovery and Isolation Exploiting Semantics)是现代主流数据库(InnoDB、SQL Server、Oracle)崩溃恢复算法的鼻祖。在 MySQL InnoDB 的实现中,虽然有很多工程上的优化,但其核心思想完全继承自 ARIES。
ARIES 主要分为三个阶段:
- 分析阶段:确定哪些数据页是脏页(做 redo),确定哪些事务未提交(做 undo)。
- 执行 redo。
- 执行 undo。
为什么先执行 redo 再执行 undo?
因为 undo log 也是"数据",它也要"写 redo log"!所以 redo 后,undo log 就恢复如初了!这个时候才可以 undo。
那问题就来了,怎么知道哪些数据页是脏页?怎么知道哪些事务未提交?
直观的想法就是对整个内存做一个快照,但是这数据量太大了,所以为了减少这个数据量,InnoDB
维护了两个表,并定期做 checkpoint,这称为
fuzzy checkpoint。
- 活跃事务表:当前所有未提交事务的合集,每个事务维护了一个关键变量
lastLSN。lastLSN是该事务产生的最后一条日志的 LSN。 - 脏页表:当前所有未刷盘的 Page
的集合(包括已提交事务和未提交事务),每个 Page 维护了一个关键变量
recoveryLSN。recoveryLSN是导致该 Page 成为脏页的最早的 LSN。比如一个 Page 本来是 clean,然后事务 1 修改了它,对应的 LSN 是 LSN1,之后事务 2,事务 3 又修改了它,对应 LSN2 和 LSN3,这个时候recoveryLSN就是 LSN1。
每次 fuzzy checkpoint,就把这 2
张表的数据生成一个快照,形成一条 checkpoint 日志,记入 redo log。

我们来看一下这 2 个表形成的 checkpoint 日志具体是如何运用的。假设我们有一段如上图所示的日志执行流。
(一)首先是分析阶段,我们检查 Crash 最近的 Checkpoint2:
- 这个时候活跃事务表有
{T2, T3},此时还没有 T4、T5。从此处开始遍历到 Redo Log 末尾。遍历的过程中,遇到 T2 的结尾MLOG_MULTI_REC_END,就把 T2 从集合中剔除。当遇到事务 T4 的时候,就把 T4 加入到集合中。当遇到事务 T5 的时候,就把 T5 加入到集合中。最终集合就变成了{T2, T3, T4}。 - 假设这个时候的脏页有
{P2, P3},从后遍历遇到新的页就将其加入脏页集合,最终集合可能是{P1, P2, P3, P4, P5}。
(二)进行 Redo:
- 我们取脏页集合中最小的 recoveryLSN,得到
firstLSN。从firstLSN遍历到 Redo Log 到末尾,把每条 Redo Log 对应的 Page 全部重新刷一次盘。 - 我们不怕重复刷盘,这是因为 Redo Log 是幂等的。磁盘上每一个 Page
都有一个关键字段
pageLSN。这个 LSN 记录这个 Page 刷盘时最后一次修改它的日志对应的 LSN。如果重放日志的 LSN <= pageLSN,则不修改对应日志的 Page,略过这条日志。 - Redo 完成后,就保证了所有的脏页都已经刷到磁盘上了,并且未提交的事务
{T2, T3, T4}对应的页也写入了磁盘,这个时候就要做回滚了。
(三)进行 Undo:
- 在分析阶段我们已经找出了未提交事务集合
{T2, T3, T4}。从最后一条日志逆向遍历,因为每条日志都有一个prevLSN字段,所以可以沿着 T3、T4、T5 各自的日志链一直回溯到 T3 的第一条日志。 - 所以 Undo,是指每一道一条属于 T3、T4、T5 的 Log,就生成一条逆向的 SQL 来执行,其对应执行的 Redo Log 是 Compensation Log Record(CLR),会在 Redo Log 尾部继续追加,由此来实现回滚功能。
在进行 Undo 的时候,还有可能会遇到一个问题,回滚到一半,宕机,重启,再回滚,要进行"回滚的回滚"。

假设事务 T 有三条日志,对应 LSN 分别为 600、900 和 1000。
- 首先对 1000 进行回滚,生成对应的 LSN=1200
的日志,这条日志里面会有一个字段叫做
UndoNxtLSN,记录的是其对应的被回滚的日志的前一条日志,即 UndoNxtLSN=900. - 宕机重启时,遇到 LSN=1200 的 CLR,会忽略,然后看到 UndoNxtLSN=900,会定位到 LSN=900 的日志,为其生成对应的 CLR 日志 LSN=1600,然后继续回滚。
- 然后 LSN=1700 的日志,回滚的是 600 的。
这样就能保证回滚日志和之前的日志一一对应,不会出现"回滚嵌套"的情况。
小节
到此为止,我们已经对事物的 A(原子性)和 D(持久性)有了一个全面的理解,这里对 Redo Log 做一个简单的总结:
- 一个事务赌赢多条 Redo Log,事务的 Redo Log 不是连续存储的,靠
prevLSN实现回溯。 - Redo Log 不保证事务的原子性,而是保证了持久性。无论提交的、未提交的事务的日志,都会进入 Redo Log。从而使得 Redo Log 回放完毕,数据库就恢复到了宕机之前的状态,称为 Repeating History。
- 同时,把未提交的事务挑出来进行回滚。回滚通过 Checkpoint 记录的
活跃事务表+每个事务日志中的开始/结束标记+Undo Log来实现。 - Redo Log 具有幂等性,通过每个 Page 里面的
pageLSN进行实现。 - 事务不存在物理回滚,所有的回滚操作都被转化成了 Compensation Log Record 进行 Commit。
undo log 是怎么实现隔离性的?
前面我们将了 redo log 是如何实现持久性的,并且顺带将了崩溃恢复过程中,undo log 是如何实现原子性的。接下来我们来看下,undo log 是如何实现隔离性的。
真的需要 undo log 吗?
这是笔者在其他资料都看不到的一个问题(当然,也可能是看得太少了 😭),但是《软件架构设计·大型网站技术架构与业务架构融合之道》这书就着重讲解了这个问题,讲的太好了 👏🏻!
事务回滚有 4 种场景:
- 人为回滚。业务异常,客户端主动请求回滚。
- 宕机回滚。
- 人为回滚 + 宕机回滚。
- 宕机回滚 + 宕机回滚。
数据库面临两个核心决策:
- 何时把脏页写盘? (是否允许在事务提交前写?) -> STEAL vs NO-STEAL
- 事务提交时必须写盘吗? (是否要求提交时同步刷盘?) -> FORCE vs NO-FORCE
| 类型 | 说明 | 分析 | 问题 |
|---|---|---|---|
| Force + No Steal | 已提交的事务必须写入磁盘,未提交的事务不允许写入磁盘。 | 不需要 Log。因为磁盘上都已提交的,内存的宕机直接消失。 | 每次提交都需要 I/O,性能太差。 |
| No Force + No Steal | 已提交的事务可以不写入磁盘,未提交是事务不允许写入磁盘。 | 只需要 Redo Log,只需要在恢复的时候重放 Redo Log 即可。 | 事务必须提交了,才允许开始写入磁盘,I/O 效率相对较低,但还可以接受。 |
| Force + Steal | 已提交的事务必须写入磁盘,未提交的事务也写入磁盘。 | 只需要 Undo Log,只需要在恢复的时候利用 Undo Log 回滚未提交事务即可。 | 每次提交都需要 I/O,性能太差。 |
| 👉 No Force + Steal | 事务有没有提交,都可以写入磁盘,想啥时候写啥时候写。 | Redo Log 用于重放已提交事务,Undo Log 用于回滚未提交事务。 | 效果最好! |
undo log 的存储结构是怎样的?
要理解 InnoDB Undo Log 的底层实现,我们不能把它想象成一个简单的文本日志文件(像 error.log 那样)。
从第一性原理来看,InnoDB 把 Undo Log 当作"特殊的只读数据"来管理。 这意味着:Undo Log 也是存在于 Page(页) 里的,它也受 Buffer Pool 缓存,它修改时也会产生 Redo Log,它也会被刷入磁盘(前面的崩溃恢复算法也说明了这一点)。
我们需要从 微观(单条记录长什么样) 到 宏观(如何在磁盘和内存中组织) 两个维度来拆解。
微观视角:Undo Log Record(物理二进制结构)
Undo Log 并不是记录原来的整个页面,而是记录逻辑上的反向操作。根据操作类型的不同,它的物理结构分为两类,这种区分是为了极致的存储效率。
当你执行 INSERT 时,产生的 Undo Log。
- 如果我要回滚一个插入,我只需要知道主键(Primary Key)是什么,然后把它删掉就行了。
- 物理结构: 非常简单,只记录了
<Table ID, Primary Key>。 - 生命周期: 极短。事务一提交(Commit),这个 Undo Log 就没用了(因为新插入的数据对于其他早于它的事务是天然不可见的,不需要通过 Undo Log 来构建历史版本)。所以它会在提交后直接删除。

- Undo Number 是Undo的一个递增编号
- Table ID 用来表示是哪张表的修改。
- 下面一组 Key Fields 的长度不定,因为对应表的主键可能由多个 field 组成,这里需要记录 Record 完整的主键信息,回滚的时候可以通过这个信息在索引中定位到对应的 Record。
- 除此之外,在 Undo Record 的头尾还各留了两个字节用户记录其前序和后继 Undo Record 的位置。
当你执行 UPDATE 或 DELETE 时,产生的 Undo
Log。
- 如果我要回滚一个更新,我必须知道更新前的旧值。如果我要支持 MVCC,我也需要这个旧值。
- 物理结构: 比较复杂。它需要记录
<Table ID, Primary Key, 修改列的旧值 (Old Value)>。 - 生命周期: 很长。事务提交后,不能马上删除。因为可能有别的长事务(Read View)还在运行,它们可能需要通过这个 Undo Log 来看快照读。
- 只有当系统里没有任何一个事务需要看这个历史版本时,它才会被 Purge 线程 清理掉。

除了跟 Insert Undo Record 相同的头尾信息,以及主键 Key Fileds 之外,Update Undo Record 增加了:
- Transaction Id 记录了产生这个历史版本事务 Id,用作后续 MVCC 中的版本可见性判断。
- Rollptr 指向的是该记录的上一个版本的位置,包括 space number,page number 和 page 内的 offset。沿着 Rollptr 可以找到一个 Record 的所有历史版本。
- Update Fields 中记录的就是当前这个 Record 版本相对于其之后的一次修改的 Delta 信息,包括所有被修改的 Field 的编号,长度和历史值。
宏观视角:组织结构(磁盘与内存)
InnoDB 是如何管理成千上万个并发事务产生的 Undo Log Record 呢?
这涉及到一个层级结构:Tablespace -> Rollback Segment -> Undo Log Segment -> Undo Page。

这张图展示了 InnoDB 是如何在磁盘和内存中从宏观到微观组织 Undo Log 的。这就像是一个巨大的"档案管理系统"。
为了更直观地理解,我们可以把这个结构比喻成一个大型档案馆。我们由外向内,层层拆解:
第一层:Undo Tablespace (档案馆大楼)
- 图中位置: 最外层的深蓝色大框
InnoDB Undo Tablespace。 - 物理对应: 磁盘上的文件,比如
undo_001,undo_002(或者老版本在ibdata1中)。 - 解释: 这是存放档案的物理大楼。所有回滚记录最终都要落在这里。自 MySQL 8.0 开始,Undo Log 默认使用独立的表空间文件,不再挤在系统表空间里,这样管理更灵活。
第二层:Rollback Segment (档案室/管理员)
- 图中位置: 左上角的
Rollback Segment (rseg 0 ... 127)。 - 解释: 为了不让成千上万个事务打架,InnoDB 把大楼划分成了 128 个档案室(rseg)。每个事务开始时,会被分配给其中一个 rseg 来管理。这样,不同 rseg 下的事务就不会产生严重的锁竞争。
第三层:Undo Slot (档案柜/目录槽)
- 图中位置: 左边中间放大的
Undo Slot 0 ... 1023。 - 解释: 走进第 5
号档案室(
rseg 5),你会看到墙上有一排排的格子,这就是 Slot。每个 rseg 有 1024 个 Slot。当一个事务开启时,它会占用一个 Slot。这个 Slot 并不直接存数据,而是存放指针,指向真正存放档案的地方。
第四层:Undo Log Segment (档案卷宗/链表)
- 图中位置: 右边的大框,由箭头连接的三个
Undo Page。 - 解释: 这是最核心的存储结构。
- 动态扩展: 事务刚开始写 Undo Log 时,可能只需要一张纸(一个 Page)。但如果是个大事务(比如更新了 100 万行数据),一张纸不够写,就需要申请第二张、第三张。
- 链表结构:
图中的箭头展示了它们是如何连起来的:
Start Page(首页) ->Middle Page(中间页) ->End Page(尾页)。这组成了一个逻辑上的 Segment(段)。 - 左边的
Undo Slot指针,实际上就是指向这个链表的头部。
第五层:Undo Page (档案纸/物理页)
- 图中位置: 右边的三个蓝色小方块。
- 解释: 这是磁盘读写的最小单位(默认为 16KB)。
- Page Header/Footer: 每一页都有头和尾,记录了校验和、页号、以及上一页/下一页的指针(双向链表)。
- Undo Log Record: 页中间那一堆
Update, ID=A、Insert, ID=B就是真正的回滚记录。
当一个事务需要修改数据时:
- Space Alloc: 在 Undo Tablespace 中定位。
- Rseg Assign: 事务被调度到第 \(N\) 号 Rollback Segment。
- Slot Reserving: 在 Rseg Header 中找到一个空闲的 Undo Slot,将其状态置为占用。
- Segment Init: 如果 Slot 指向空,则通过 FSEG 申请一个新的 Undo Log Segment(包含至少一个 Page);如果 Slot 指向已缓存的 Segment,则复用之。
- Page Write: 事务在 Undo Page 中顺序写入 Undo Log Record。若当前页写满,则通过 Segment 申请新页,并更新页头的双向链表指针。
这么设计的两大好处:
- 为了高并发:128 个 Segment × 1024 个 Slot = 支持 13 万 个并发事务同时写 Undo Log。
- 为了大事务:通过 Page 链表(Segment),一个事务可以无限写入 Undo Log,而不会受到单页 16KB 的限制。
undo log 如何实现 MVCC?
在回答这个问题之前,我们先来思考一下多线程编程中,读写的并发问题有哪些策略?
- 互斥锁
- 读写锁
- CopyOnWrite
这 3 种策略,从上到下,并发度越来越高。而 InnoDB 用的就是 CopyOnWrite 的思想,即 Undo Log 的本质思想就是 CopyOnWrite。
每个事务修改记录之前,都会把该记录拷贝一份出来,拷贝出来的这个备份在Undo Log 里,因为事务有唯一的编号(Trx ID),ID从小到大递增,每一次修改,就是一个版本,因为 Undo Log维护了数据的从旧到新的每个版本,各个版本之间的记录通过链表串联。
那内存中是如何把数据行和 Undo Log 连起来的呢?
我们在 Buffer Pool 里的每一行数据,实际上都有两个隐藏列:
DB_TRX_ID:最近修改这条数据的事务 ID。DB_ROLL_PTR:回滚指针。
这个指针指向哪里?
它包含三个信息:Space ID (表空间号), Page No
(页号), Offset (页内偏移量)。 它精准地指向了Undo
Page 中存放的那条对应的 Update Undo Log
Record。

当事务提交后,Insert Undo Log 被释放了,但 Update Undo Log 被留下来了。 这些提交后的 Update Undo Log 会被加入到一个全局的大链表——History List。这个链表是给 Purge 线程 用的。
Purge 线程像一个扫地僧,它顺着 History List 扫描,一旦发现某个 Undo Log 对应的事务 ID 已经太老了(比当前所有活跃事务的 Read View 都老),说明再也没人需要看这个版本了,就把它物理删除,释放空间。
undo log 结合 redo log
我们前面说,undo log 其实也是一种"数据",它也是要写 Redo Log 的。我们可以用一个例子来将这 2 者进行结合。
假设有如下一个事务:
1 | start transaction |
把 Undo Log 和 Redo Log 加进去,此事务类似下面伪代码所示:
1 | start transaction |
binlog 是如何保证集群一致性的?
由于 MySQL 是插件式存储引擎架构,Binlog 作为 Server 层的全局日志,必须通过 两阶段提交(2PC) 与存储引擎层的 Redo Log 强绑定,才能在逻辑上构成一个完整的、可恢复的事务。
之所以需要这个机制,是因为 Redo Log和 Binlog 是独立的。如果不同步,就会出现"主库数据恢复了,但从库没同步"或者"从库同步了,但主库崩溃后数据丢了"的灾难。
Redo Log 保证了物理一致性(崩溃恢复),而 Binlog保证了逻辑一致性(主从复制、数据回滚/闪回)。
binlog 如何跟 redo log 进行配合?
因为 Binlog 和 Redo log 两个是独立的,所以要保证它们的一致性,需要遵循如下的两阶段提交(2PC)过程:
Prepare 阶段:
- InnoDB 将修改记录到 Redo Log。
- 将该事务的 XID(全局事务 ID)写入 Redo Log。
- 将 Redo Log 状态置为
TRX_PREPARE并刷盘。
Commit 阶段:
- Server 层将事务的逻辑操作写入 Binlog 文件并刷盘。
- InnoDB 收到 Binlog 成功的反馈,在 Redo Log 中记录
commit标记,事务正式完成。

如果系统在 2PC 过程中宕机,重启后 MySQL 会扫描 Redo Log,根据 XID 去 Binlog 中寻找匹配:
- 判定 1:Redo Log 有
commit标记 \(\to\) 直接提交。 - 判定 2:Redo Log 只有
prepare标记,但 Binlog 中存在该 XID \(\to\) 提交(因为 Binlog 已落盘,从库可能已同步,主库必须保持一致)。 - 判定 3:Redo Log 只有
prepare标记,且 Binlog 中不存在该 XID \(\to\) 回滚(说明宕机发生在写 Binlog 之前)。
既然第 1 阶段 Redo 已经刷盘了,为什么非要等到第 2 阶段 Binlog 刷盘后,事务才算成功?
因为 Redo Log 只管主库自己的崩溃恢复。如果不等 Binlog 刷盘就认为成功,万一主库写完 Redo 挂了,没来得及写 Binlog,从库就不会同步这笔数据。重启后,主库有这笔数,从库没有,主从一致性就彻底崩了。所以 2PC 的核心就是用 Binlog 的落盘来充当全局事务的裁决者。
同 Redo Log 一样,Binlog 也存在一个刷盘策略问题,由参数
sync_binlog 控制:
0:事务提交之后不主动刷盘,依靠操作系统自身的刷盘机制,可能会丢失数据。1:每提交一次事务,刷一次盘。n:每提交 n 次事务,刷一次盘。
显然,0 和 n
都不安全。为了不丢失数据,一般多建议双 1 保证,即
sync_binlog 和 innodb_flush_log_at_trx_commit
的值都取位 1。
刷盘压力那么大怎么缓解?
在双 1 配置下,每个事务都要触发磁盘
IO(fsync)。如果并发量上来,磁盘 IOPS 会直接把数据库卡死。
MySQL 5.6 引入了 BLGC,将提交过程设计成一个流水线。
red:color 核心思想
当有多个事务并发提交时,MySQL维护了一个队列,第一个抢到锁的事务(第一个进入空队列)成为队长(Leader),后面排队的事务成为队员(Follower)。队长会代表所有队员,一次性完成 IO 操作。
这个过程分为三个阶段的队列:
Flush 阶段(写 OS Cache):队长带着一群队员,把他们各自的 Binlog 数据写入操作系统的文件缓存 (OS Cache)。
Sync 阶段(刷磁盘 fsync):队长调用一次
fsync()。因为大家的数据都在 OS Cache 里了,这一次fsync就把这一整车人的 Binlog 全都持久化到磁盘了。原本 10 个事务需要 10 次 fsync,现在 1 次搞定。吞吐量直接翻倍。Commit 阶段(引擎层提交):队长通知 InnoDB 引擎,把这批事务在 Redo Log 里的状态由
Prepare改为Commit。
🚀 Redo Log 也就顺便搭便车了
在没有 BLGC 之前,Redo Log 需要在 Prepare 阶段自己去刷盘。 有了 BLGC 后,MySQL 做了一个极度聪明的优化,叫做 Redo Log Group Commit。
它的时机是在 Flush 阶段
之后,Sync 阶段 之前。
- Flush 阶段:队长把 Binlog 写进 OS Cache。
- Redo Log 刷盘:在队长执行 Binlog 的
fsync之前,InnoDB 发现:"哎?既然你们这一车人都要提交了,那你们对应的 Redo Log (Prepare 状态) 肯定也要落盘啊"。于是,InnoDB 会把这一组事务的 Redo Log 一次性 刷入磁盘。 - Sync 阶段:队长执行 Binlog 的
fsync。

通过这个机制,Redo Log 也享受到了组提交的红利。 原本:N 个事务 = N 次 Redo fsync + N 次 Binlog fsync。 现在:N 个事务(一批)= 1 次 Redo fsync + 1 次 Binlog fsync。
这就是为什么现在的 MySQL 即使开启了双 1
配置,在并发高的时候,性能依然非常强劲的原因。
总结
综上,我们就从 ACID 各个方向阐述了 InnoDB 是如何实现事务的了。回顾整个 MySQL 事务的实现链路,我们可以将其核心设计哲学概括为三重保障,这也是我们在架构设计中可以借鉴的最高准则。
物理层:WAL 与崩溃恢复 —— InnoDB 不直接写磁盘数据页,而是先写 Redo Log。
- 通过 Physiological Logging(定位到页,逻辑修改内容),在保证日志足够小的同时,拥有了修复物理 B+ 树的能力。
- ARIES 算法 保证了即使在极端宕机情况下,数据库也能通过"重做历史"和"逻辑回滚"恢复到一致状态。
逻辑层:MVCC 与锁平衡 —— 为了解决读写冲突,MySQL 并没有简单粗暴地使用全表锁或行锁。
- Undo Log 构建了数据的 History List,配合 ReadView 机制,实现了 RC 和 RR 级别下的快照读,做到了读不阻塞写,写不阻塞读。
- 这本质上就是 CopyOnWrite。
架构层:2PC 与组提交协同
- Binlog 决定了集群的逻辑一致性(主从)。
- Redo Log 决定了单机的物理一致性。
- BLGC (组提交) 则是在双
1安全配置下,通过流水线和搭便车机制,将磁盘 I/O 成本降到最低。
理解 MySQL 事务,不应止步于 ACID 的定义,而应看透其在数据安全(持久性)、高并发(隔离性)与吞吐量(性能)之间所做的精妙权衡。