MySQL InnoDB事务实现原理深度剖析
MySQL InnoDB事务实现原理深度剖析
一、引言
在关系型数据库领域,事务机制因其原子性、一致性等特性深受开发者青睐。InnoDB作为MySQL的默认存储引擎,其事务实现兼顾了高性能与数据安全。本文将深入剖析InnoDB事务的实现细节,从源码角度解读其工作原理
二、事务基础概念
2.1 ACID特性
InnoDB严格遵循事务的ACID特性:
原子性(Atomicity):事务作为一个整体被执行,对数据的修改要么全部执行,要么全部回滚。InnoDB通过undo log来保证原子性,在操作任何数据之前,首先将数据备份到undo log中,如果执行ROLLBACK语句,系统可以利用undo log将数据恢复到事务开始前的状态。
一致性(Consistency):事务应确保数据库从一个一致状态转变到另一个一致状态。以转账为例,A账户和B账户在转账前后资金总额保持不变。InnoDB通过crash recovery和double write buffer机制保证数据的一致性。
隔离性(Isolation):多个并发事务相互隔离,互不影响。InnoDB通过锁机制和MVCC(多版本并发控制)实现隔离性。
持久性(Durability):事务一旦提交,对数据库的修改应永久保存。InnoDB通过redo log实现持久性,在事务提交前将redo log持久化到磁盘,即使系统崩溃也能根据redo log恢复数据。
2.2 事务实现原理
在InnoDB中,事务ACID特性的实现原理是:
原子性、一致性、持久性是根据
redo log和undo log实现的隔离性是根据
锁和MVCC来实现的

后面我们会详细介绍redo log和undo log、mvcc的原理,怎么保证事务的这些特点。
三、事务并发问题与隔离级别
在多个事务并发执行时,若不加以控制,会出现数据不一致的问题。SQL标准定义了三种典型并发问题,并提出了四种隔离级别来权衡性能与一致性。
3.1 并发事务带来的问题
脏读(Dirty Read)
一个事务读取了另一个未提交事务修改的数据。如果那个事务最终回滚,则读取的数据是无效的。
例如下图中,事务A将余额修改成200,但尚未提交;事务B读到200并基于此做业务处理;随后事务A回滚,余额恢复为1000,但事务B已使用了错误的数据。

不可重复读(Non-Repeatable Read)
一个事务内两次读取同一行数据,得到不同的结果。这是因为在两次读取之间,另一个事务提交了对该行的修改。
例如,事务A第一次读取余额为100;事务B修改余额为200并提交;事务A再次读取得到200,导致前后不一致。

幻读(Phantom Read)
一个事务内两次执行相同的查询,但第二次返回了第一次没有出现的行(或少了行)。这是由于其他事务在区间内插入了新数据或删除了数据。
例如,事务A查询所有余额大于100的客户,得到3条记录;事务B插入一个新客户且余额为150并提交;事务A再次查询得到4条记录,就像出现了“幻影”行。

3.2 SQL标准定义的隔离级别
为了解决上述问题,SQL:1992标准定义了四个隔离级别,级别越高,一致性越强,但并发性能通常越低。
- READ UNCOMMITTED(读未提交)
允许脏读,即可能读到未提交的数据。实现最简单,通常不加锁或加极弱的锁。 - READ COMMITTED(读已提交)
禁止脏读,但可能发生不可重复读和幻读。大多数数据库的默认级别(Oracle、SQL Server等),但MySQL InnoDB的默认级别是可重复读。 - REPEATABLE READ(可重复读)
禁止脏读和不可重复读,但幻读仍可能发生。InnoDB通过多版本并发控制(MVCC)和间隙锁(Next-Key Lock)在可重复读级别下实际上解决了幻读问题。 - SERIALIZABLE(可串行化)
最高隔离级别,强制事务串行执行,避免所有并发问题,但性能最低。通常通过加锁(如表锁或行锁)实现,也可能使用MVCC加锁读。
四、事务原理-redolog
Redo Log被称为重做日志。记录了事务提交时数据也的数据修改,是用来实现事务的持久性。
该日志文件有两部分组件:重做日志缓存(redo log buffer)以及重做日志文件(redo log file),前者是在内存中,后者是在磁盘中。
当事务提交之后,会把所有修改信息都存到该日志文件中,用户在刷新脏页到磁盘,发生错误时,进行数据恢复使用。
我们抛出一个问题,来说明为啥需要redolog日志。
4.1 问题
下面客户端对MySQL内的数据进行更新,其执行流程如下:
1 | |
但是如果后台线程将脏页刷新到磁盘的过程,出现了问题。 此时事务已经提交,那么就会导致磁盘和缓存区的数据不一致了,那么持久性就无法保证了。

什么情况下会触发脏页刷新:
- 内存写满了,这个时候就会引发 flush 操作,对应到 InnoDB 就是 redo log 写满了;
- 系统的内存不足了,当需要新的内存页的时候,就会淘汰一些内存页,如果淘汰的是脏页这个时候就会触发 flush 操作;
- 系统空闲的时候,MySQL 会同步内存中的数据到磁盘也会触发 flush 操作;
- MySQL 服务关闭的时候也会刷脏页,触发 flush 操作。
4.2 redolog如何解决
那么redolog是如何解决的这个问题呢?
1 | |

注意:为啥我们不在事务提交时,进行脏页刷新到磁盘空间呢?还需要后台线程处理
- 性能瓶颈:随机写 vs 顺序写
- 脏页刷新是随机写:缓冲池(Buffer Pool)中的数据页是随机分布的。将脏页刷新到磁盘,意味着需要将分散在内存各处的数据,写入磁盘上不连续的物理位置。磁盘的随机写入性能极低,会成为系统吞吐量的瓶颈
- Redo Log 是顺序写:Redo Log 记录的是数据页的物理修改日志,它以追加的方式顺序写入磁盘上的日志文件。顺序写入的效率远高于随机写入,是磁盘 I/O 的最优模式。
如果在事务提交时就同步刷新所有脏页,数据库的性能将严重依赖于磁盘的随机写能力,这在高并发场景下是不可接受的。
五、事务原理-undolog
5.1 什么是Undo Log?
Undo:意为撤销或取消,以撤销操作为目的,返回某个状态的操作。
Undo Log:数据库事务开始之前,会将要修改的记录放到Undo日志里,当事务回滚时或者数据库崩溃时,可以利用UndoLog撤销未提交事务对数据库产生的影响。
Undo Log是事务原子性的保证。在事务中更新数据的前置操作其实是要先写入一个Undo Log
5.2 undo log的作用
在MySQL中,undo log日志的作用主要有两个:
5.2.1 提供回滚操作【实现事务的原子性】
我们在进行数据更新操作的时候,不仅会记录redo log,还会记录undo log,如果因为某些原因导致事务回滚,那么这个时候MySQL就要执行回滚(rollback)操作,利用undo log将数据恢复到事务开始之前的状态。
如我们执行下面一条删除语句:
1 | |
那么此时undo log会记录一条对应的insert 语句【反向操作的语句】,以保证在事务回滚时,将数据还原回去。
再比如我们执行一条update语句:
1 | |
此时undo log会记录一条相反的update语句,如下:
1 | |
如果这个修改出现异常,可以使用undo log日志来实现回滚操作,以保证事务的一致性。
5.2.2 提供多版本控制【实现MVCC】
MVCC,即多版本控制。在MySQL数据库InnoDB存储引擎中,用undo Log来实现多版本并发控制(MVCC)。当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据版本是怎样的,从而让用户能够读取到当前事务操作之前的数据【快照读】。
解释一下什么是快照读和当前读:
快照读:SQL读取的数据是快照版本【可见版本】,也就是历史版本,不用加锁,普通的SELECT就是快照读。
当前读:SQL读取的数据是最新版本。通过锁机制来保证读取的数据无法通过其他事务进行修改UPDATE、DELETE、INSERT、SELECT … LOCK IN SHARE MODE、SELECT … FOR UPDATE都是当前读。
5.3 undo log存储
Undo log的存储由InnoDB存储引擎实现,数据保存在InnoDB的数据文件中,innodb存储引擎对undo的管理采用段(segment)的方式,具体来说是一种命名为回滚段(rollback segment)的数据结构。
1 | |
5.4 Undo log的工作原理
在更新数据之前,MySQL会提前生成undo log日志,当事务提交的时候,并不会立即删除undo log,因为后面可能需要进行回滚操作,要执行回滚(rollback)操作时,从缓存中读取数据。undo log日志的删除是通过通过后台purge线程进行回收处理的。

如上图:
1、事务A执行update操作,此时事务还没提交,会将数据进行备份到对应的undo buffer,然后由undo buffer持久化到磁盘中的undo log文件中,此时undo log保存了未提交之前的操作日志,接着将操作的数据,也就是Teacher表的数据持久保存到InnoDB的数据文件IBD。
2、此时事务B进行查询操作,直接从undo buffer缓存中进行读取,这时事务A还没提交事务,如果要回滚(rollback)事务,是不读磁盘的,先直接从undo buffer缓存读取。
四、事务原理-多版本并发控制(MVCC)
4.1 MVCC的基本介绍
MVCC是一种提高并发度的技术。在传统数据库系统中,只有读读操作可以并行;引入多版本后,只有写写操作相互阻塞,读写、写读都可以并行,大幅提升了并发性能。
MVCC可以维护一个数据的多个版本,使得读写操作没有冲突。快照读为MySQL实现MVCC提供了一个非阻塞读功能。
MVCC的实现还需要依赖于数据库记录中的三个隐式字段、undolog日志、readView。
为什么需要MVCC呢?
数据库通常使用锁来实现隔离性。最原生的锁,锁住一个资源后会禁止其他任何线程访问同一个资源。但是很多应用的一个特点都是读多写少的场景,很多数据的读取次数远大于修改的次数,而读取数据间互相排斥显得不是很必要。
所以就使用了一种读写锁的方法,读锁和读锁之间不互斥,而写锁和写锁、读锁都互斥。这样就很大提升了系统的并发能力。
之后人们发现并发度还是不够,又提出了能不能让读写之间也不冲突的方法,就是读取数据时通过一种类似快照的方式将数据保存下来,这样读锁就和写锁不冲突了,不同的事务session会看到自己特定版本的数据。当然快照是一种概念模型,不同的数据库可能用不同的方式来实现这种功能。
4.2 快照读和当前读
4.2.1 快照读
简单的select(不加锁)就是快照读,快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读。
Read Committed:每次select,都生成一个快照读。
Repeatable Read:开启事务后第一个select语句才是快照读的地方。
Serializable:快照读会退化为当前读
4.2.2 当前读
读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们日常的操作,如:select … lock in share mode(共享锁),select …for update、update、insert、delete(排他锁)都是一种当前读。
假设要update一条记录,但是另一个事务已经delete这条数据并且commit了,如果不加锁就会产生冲突。所以update的时候肯定要是当前读,得到最新的信息并且锁定相应的记录。
4.3 MVCC的实现原理
上节我们说过,MVCC的实现原理依赖三点: 三个隐式字段、undolog日志、readView,我们依次来介绍
4.3.1 隐式字段
我们user_stu这张表中,有三个字段(id字段非主键),而右边则是三个隐藏字段。

三个隐式字段的含义:
| 隐藏字段 | 含义 |
|---|---|
| DB_TRX_ID | 最近修改事务ID,记录插入这条记录或最后一次修改该记录的事务ID |
| DB_ROLL_PTR | 回滚指针,指向这条记录的上一个版本,用于配合undo log,指向上一个版本。 |
| DB_ROW_ID | 隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段。 |
这几个字段的具体使用,我们在下一节来讲解。
4.3.2 undo log版本链
undo log是回滚日志,在insert、update、delete的时候产生的便于数据回滚的日志。
当insert的时候,产生的undo log日志旨在回滚时需要,在事务提交后,可被立即删除。
而update、delete的时候,产生的undo log日志不仅在回滚时需要,不会立即删除。
我们以一个例子来解释下版本链的实现:
以下有4个事务,他们先后开始执行执行SQL,同时对id为30的数据进行修改:

此时最开始的记录是:

下面依次执行,我们看下每个时间点,记录的数据,以及undo log的记录。
第一次修改:事务2:修改id为30记录,age改为3
在事务2执行时,会在变更前首先写入一条undo log数据,这条undo log日志写入是变更之前的数据
然后执行更新操作,在更新时,会将age更新3
在更新的同时,还会更新DB_TRX_ID和DB_ROLL_PTR两个隐式字段。
1 | |

第二次修改:事务3:修改id为30记录,name改为A3
在事务3执行时,会在变更前首先写入一条undo log数据,这条undo log日志写入是变更之前的数据
然后执行更新操作,在更新时,会将name更新A3
在更新的同时,还会更新DB_TRX_ID和DB_ROLL_PTR两个隐式字段。
1 | |

第二次修改:事务4:修改id为30记录,age改为10
在事务4执行时,会在变更前首先写入一条undo log数据,这条undo log日志写入是变更之前的数据
然后执行更新操作,在更新时,会将age更新10
在更新的同时,还会更新DB_TRX_ID和DB_ROLL_PTR两个隐式字段。
1 | |

这样就生成了unlog的版本链。会导致该记录的unlog生成一条记录版本链表,链表的头部的最新的旧纪录,链表的尾部就是最早的旧纪录。
4.3.3 readview
readview(读视图)是快照读SQL执行时MVCC提取数据的依据,记录并维护系统当前的活跃的事务(未提交的)id。
readview中包含了四个核心字段:
| 字段 | 含义 | 作用 | 判断规则 |
|---|---|---|---|
creator_trx_id(创建者事务 ID) |
创建这个 Read View 的事务 ID(也就是当前事务) | 自己改的数据,自己一定能看到,防止“看不到自己刚写的数据” | 如果 trx_id == creator_trx_id → 可见 |
m_ids(活跃事务列表) |
在 Read View 创建时,系统中还没提交的事务 ID 集合 | 这个集合中的事务还在跑,它们改的数据 不应该被看到 | 如果 trx_id ∈ m_ids → 不可见 |
min_trx_id(最小活跃事务 ID) |
m_ids 中最小的事务 ID | 比最早活跃事务还早,一定已经提交 | 如果 trx_id < min_trx_id → 可见 |
max_trx_id(下一个将分配的事务 ID) |
系统将要分配的下一个事务 ID(不是最大活跃事务) | 属于“未来事务”,Read View 创建时还不存在 | 如果 trx_id >= max_trx_id → 不可见 |
可见性规则:一致性视图通过一系列规则来决定哪些版本的数据对当前事务是可见的。这些规则通常基于事务的隔离级别(如读已提交、可重复读、串行化等)。
- 读已提交:事务只能看到在它开始之前已经提交的版本。
- 可重复读:事务只能看到在它开始之前已经提交的版本,并且在事务执行期间,其他事务对数据的修改不会影响当前事务的视图。
readview的核心作用就是在可重复读(RR)和读已提交(RC)隔离级别下,确保:
- 可重复读:事务内多次读取同一数据的结果一致。
- 读已提交:只能读取其他事务已提交的数据。
我们在基于上面readview的规则,我们看下事务5中的readview中的字段,并且确定下它读取的是那一条数据?
在RC隔离级别的readview:在RC隔离级别下,每次查询都会生成一个readview。
事务5第一次查询时,readview中对应字段是:
1 | |
那么它对应读取的是undolog的拿一条数据呢?我们依次判断下:
- 首先判断当前的记录,trx_id = 4时,发现,第二条
trx_id < min_rrx_id不满足 - 然后判断第二条,trx_id = 3时,发现,第四条
min_rrx_id<=trx_id <=max_trx_id不满足 - 然后判断第三条,trx_id = 2时,发现这四个条件都可以满足。
因此事务5中第一次查询,是读取的trx_id=2数据

事务5第二次查询时,readview中对应字段是:
1 | |
那么它对应读取的是undolog的拿一条数据呢?我们依次判断下:
- 首先判断当前的记录,trx_id = 4时,发现,第二条
trx_id < min_rrx_id不满足 - 然后判断第二条,trx_id = 3时,发现,第二条
trx_id <=min_rrx_id满足
因此事务5中第一次查询,是读取的trx_id=3数据

在RR隔离级别的readview:在RR隔离级别下,仅在事务第一次执行快照读是生成readview,后续会复用这个readview。
此时readview中的各个字段为:

五、怎么解决幻读问题?
在InnoDB存储引擎的可重复读(RR)隔离级别下,MVCC与间隙锁(Next-Key Lock)的组合机制能够有效避免绝大多数幻读场景,但严格来说,并非在所有情况下都能“完全”解决,其效果取决于操作类型(快照读或当前读)和索引结构。
5.1 MVCC 如何解决部分幻读(快照读)
MVCC(多版本并发控制)通过为事务创建一致性视图(Read View)来实现快照读,从而避免了由其他事务插入新记录导致的幻读。
- 机制:在RR隔离级别下,事务在第一次执行
SELECT时生成一个Read View,此后所有普通的SELECT(快照读)都基于这个快照,看不到其他事务在此之后提交的新增记录。 - 效果:对于纯粹的查询操作,MVCC能完美避免幻读。例如,事务A第一次查询
age > 20得到5条记录,即使事务B插入了一条age=25的记录并提交,事务A后续的快照读仍然只看到最初的5条记录。
5.2 Next-Key Lock如何解决当前读导致的幻读
当事务执行“当前读”操作时,MVCC的快照机制失效,必须依赖锁机制来防止幻读。
- 当前读操作:包括
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE和INSERT。这些操作会读取数据的最新版本,并加锁。 - 间隙锁与Next-Key Lock:InnoDB在RR级别下,对范围查询的当前读会自动使用Next-Key Lock(记录锁 + 间隙锁的组合),锁定索引记录及其前后的“间隙”,阻止其他事务在该范围内插入新记录。
- 例如,执行
SELECT * FROM users WHERE age > 20 FOR UPDATE,InnoDB会锁定所有age > 20的现有记录,以及age值大于20的“间隙”(如(20, 25)、(25, 30)等),从而阻止事务B插入age=22的记录。
- 例如,执行
- 效果:通过Next-Key Lock,InnoDB在当前读场景下彻底防止了幻读,确保事务在修改数据时,其查询范围内的数据“一致性”不会被其他事务的插入破坏。
5.3 为什么说“并非完全解决”?
尽管InnoDB的机制非常完善,但在特定边界条件下,仍可能出现“幻读”现象,这更多是由于事务内部操作的混合导致的:
- 快照读与当前读混合的场景:
- 事务A先执行快照读(
SELECT * FROM users WHERE age > 20),看到5条记录。 - 事务B插入一条
age=25的记录并提交。 - 事务A随后执行
UPDATE users SET name='test' WHERE age > 20(当前读),此时会看到事务B插入的这条新记录,并成功更新它。 - 事务A再次执行
SELECT * FROM users WHERE age > 20(此时仍是当前读,因为事务中已发生过修改,快照已更新),会看到6条记录。 - 结论:这并非InnoDB机制失效,而是事务A自身通过“当前读”引入了新数据,导致结果集变化。这符合数据库的“可重复读”语义——事务内的一致性是基于“事务开始时的快照”,但一旦事务修改了数据,快照就会更新。
- 事务A先执行快照读(
- 唯一索引的特殊情况:
- 对于唯一索引的等值查询(如
SELECT * FROM users WHERE id = 5 FOR UPDATE),如果id=5存在,InnoDB会将Next-Key Lock“降级”为记录锁,因为唯一索引保证了不会有重复值插入,无需锁间隙。 - 但如果查询的值不存在(如
SELECT * FROM users WHERE id = 100 FOR UPDATE,而表中最大id为90),InnoDB仍会锁住(90, +∞)的间隙,防止插入id=100的记录。
- 对于唯一索引的等值查询(如
六、总结
本文深入剖析了MySQL InnoDB存储引擎的事务实现原理,系统阐述了其如何通过核心机制保障事务的ACID特性,并解决并发环境下的数据一致性问题。
- 事务基础:InnoDB严格遵循ACID,其中原子性通过undo log保证,持久性依赖redo log实现,隔离性由锁与MVCC共同支撑,一致性则通过崩溃恢复、double write等机制维护。
- 并发问题与隔离级别:脏读、不可重复读、幻读是事务并发的主要风险。SQL标准定义了四种隔离级别,InnoDB默认采用可重复读(RR),并通过MVCC和间隙锁在该级别下有效解决了幻读问题,实际隔离程度高于标准。
- Redo Log:作为持久性的核心,采用预写日志(WAL)策略,将数据页的物理修改以顺序写方式记录,确保事务提交后即使脏页未刷盘也能通过重做日志恢复,平衡了性能与安全。
- Undo Log:一方面支持事务回滚(原子性),另一方面与隐式字段(DB_TRX_ID、DB_ROLL_PTR)共同构建版本链,为MVCC提供多版本数据,使得快照读无需加锁即可读取历史版本。
- MVCC实现:通过一致性视图(readview)结合版本链,实现非阻塞读。在RC隔离级别下,每次查询生成新的readview;在RR级别下,事务首次快照读生成readview并复用,从而保证可重复读。
- 幻读解决:
- 快照读由MVCC直接避免,事务始终基于初始视图读取数据。
- 当前读(如
SELECT ... FOR UPDATE、UPDATE等)通过Next-Key Lock(记录锁+间隙锁)锁定索引范围,阻止其他事务插入新记录。 - 但严格而言,在快照读与当前读混合或唯一索引等值查询不存在值等特殊场景下,仍可能出现结果集变化,这并非机制缺陷,而是事务自身操作导致的一致视图更新。
综上,InnoDB通过redo log、undo log、MVCC及锁机制的精密协作,在保证数据一致性的同时实现了高并发处理。理解这些底层原理,对于数据库性能调优、问题排查及架构设计具有重要意义。