运维知识
悠悠
2026年5月1日

面试必问!MySQL 事务到底是怎么实现的?这篇文章讲透了

说实话,这个问题我被问过不止一次。每次有人来问我 MySQL 事务是怎么回事,我都发现大家普遍停留在「ACID 四个特性」这个层面,背得挺溜,但真要问你 MySQL 底层是怎么实现原子性的,怎么保证崩了数据不丢,怎么做到多个事务并发跑还互不干扰——很多人就开始含糊了。

这篇文章我就把这块彻底说清楚。不搞那些花里胡哨的,直接从底层机制讲起,生产上遇到过的坑也会顺带提一嘴。


先说说事务是什么

事务这个概念说白了就是:把一组操作捆绑成一个整体,要么全部成功,要么全部失败,不允许中间状态存在。

举个最经典的例子,转账。A 给 B 转 500 块,数据库层面是两步操作:A 的账户减 500,B 的账户加 500。这两步必须同时成功或者同时失败,不然 A 扣了钱 B 没收到,或者 B 收到了 A 没扣,这都是灾难性的数据错误。

这就是事务要解决的核心问题。

MySQL 里事务主要是 InnoDB 引擎实现的,MyISAM 不支持事务,这个要先知道。

ACID 大家都背过:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。但背概念没用,我们要知道 MySQL 是用什么技术手段实现这四个特性的。

  • 原子性 → 靠 undo log
  • 持久性 → 靠 redo log
  • 隔离性 → 靠 锁 + MVCC
  • 一致性 → 是上面三个共同作用的结果

下面一个一个展开说。


Undo Log:原子性的保障

undo log 翻译过来叫回滚日志。它的核心思想很简单:在你修改数据之前,先把原来的数据记下来,万一事务失败了,就拿这个日志把数据恢复回去。

你执行了一条 INSERT,undo log 里就记一条 DELETE;你执行了 UPDATE,undo log 里就记一条把数据改回去的 UPDATE;你执行了 DELETE,undo log 里就记一条 INSERT

这样,当事务需要回滚的时候,MySQL 就把 undo log 里的操作反向执行一遍,数据就回到了事务开始之前的状态。

有一点要注意:undo log 一定是优先于数据修改落盘的,这个顺序不能乱。如果数据先改了,undo log 还没写,这时候崩了,你连回滚的依据都没有了。

实际上 undo log 不只是用来回滚,它还承担着 MVCC 的职责,后面会说到。


Redo Log:持久性的保障

redo log 这块是我觉得 MySQL 设计里最精妙的地方之一。

先说问题背景。MySQL 的数据最终是存在磁盘上的,但读写操作都是在内存里的 Buffer Pool 里进行的,不是每次改完数据都立刻写磁盘。这样做是为了性能,磁盘随机 IO 太慢了。但这就带来了一个风险:数据在内存里改了,还没来得及刷到磁盘,MySQL 突然崩了,数据就丢了。

怎么解决?redo log 就是答案。

redo log 记录的是数据页的物理修改,每次事务提交的时候,不需要立刻把数据页刷到磁盘,但必须先把 redo log 写到磁盘。redo log 是顺序写的,顺序写磁盘的速度比随机写快很多,这个性能差距在机械硬盘时代尤其明显。

这个机制有个专业名字叫 WAL(Write-Ahead Logging),意思就是先写日志再写数据。

MySQL 崩溃重启之后,会扫描 redo log,把已经提交但还没来得及刷盘的数据重新应用一遍,数据就恢复了。这就是为什么事务一旦提交,就算服务器崩了,数据也不会丢。

redo log 有个重要的参数:innodb_flush_log_at_trx_commit,这个参数控制 redo log 的刷盘策略:

  • 设置为 1:每次事务提交都强制刷盘,最安全,但性能最差
  • 设置为 2:提交时写到操作系统的缓存,每秒刷一次盘,折中方案
  • 设置为 0:每秒刷一次盘,性能最好,但崩溃可能丢 1 秒数据

生产环境一般金融类业务设置 1,对数据安全性要求没那么高的业务可以设置 2。设置 0 风险比较大,不建议。

-- 查看当前配置
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';

-- 查看 redo log 相关配置
SHOW VARIABLES LIKE 'innodb_log%';

MVCC:隔离性的核心机制

这块是整个事务机制里最复杂的,也是面试最爱考的。

MVCC 全称 Multi-Version Concurrency Control,多版本并发控制。它解决的核心问题是:怎么让读操作不加锁,同时还能保证数据的隔离性?

传统的做法是读写都加锁,读的时候其他人不能写,写的时候其他人不能读,这样数据是安全了,但并发性能很差。MVCC 的思路是给数据维护多个版本,不同的事务看到不同版本的数据,读操作基本不需要加锁。

InnoDB 是怎么实现多版本的?

InnoDB 在每行数据上隐式地加了几个字段:

  • DB_TRX_ID:最后一次修改这行数据的事务 ID
  • DB_ROLL_PTR:指向 undo log 的指针,通过这个可以找到这行数据的历史版本
  • DB_ROW_ID:隐式的行 ID

每次事务修改一行数据,不会直接覆盖原来的数据,而是创建一个新版本,旧版本通过 DB_ROLL_PTR 串起来,形成一个版本链。

举个例子,一行数据初始值 age = 20,事务 A(ID=100)把它改成了 25,事务 B(ID=101)又把它改成了 30,这行数据就有了三个版本,通过版本链串联在一起。

Read View 是什么?

光有版本链还不够,还需要一个机制来决定:当前事务应该看哪个版本的数据?这就是 Read View(读视图)的作用。

Read View 里记录了几个关键信息:

  • 当前活跃的事务 ID 列表(m_ids
  • 最小活跃事务 ID(min_trx_id
  • 下一个待分配的事务 ID(max_trx_id
  • 创建这个 Read View 的事务 ID

判断一个数据版本是否对当前事务可见,规则大概是这样:

  1. 如果这个版本的 DB_TRX_ID 小于 min_trx_id,说明这个版本是在 Read View 创建之前就已经提交的,可见
  2. 如果 DB_TRX_ID 大于等于 max_trx_id,说明这个版本是在 Read View 创建之后才开始的事务改的,不可见
  3. 如果在这两者之间,就看 DB_TRX_ID 是不是在活跃事务列表里,在的话说明这个事务还没提交,不可见;不在的话说明已经提交了,可见

如果当前版本不可见,就顺着版本链往前找,直到找到一个可见的版本。


隔离级别和 MVCC 的关系

MySQL 有四个隔离级别:读未提交、读已提交、可重复读、串行化。

读未提交基本不用 MVCC,直接读最新版本,啥都不管,脏读问题很严重,生产上几乎不用。

读已提交(Read Committed):每次执行 SELECT 都重新创建一个 Read View,所以每次都能读到其他事务最新提交的数据。这个级别会有不可重复读的问题——同一个事务里,两次查询同一行数据,结果可能不一样,因为中间有其他事务提交了修改。

可重复读(Repeatable Read):这是 MySQL 的默认隔离级别。它在事务第一次执行 SELECT 的时候创建 Read View,整个事务期间都复用这个 Read View,所以不管其他事务怎么改,你每次查到的都是一样的数据。这就是"可重复读"的含义。

来看个具体的场景:

-- 事务 A 开始
START TRANSACTION;
SELECT age FROM user WHERE id = 1;  -- 读到 age = 20

-- 此时事务 B 把 age 改成了 25 并提交
-- UPDATE user SET age = 25 WHERE id = 1; COMMIT;

-- 事务 A 再次查询
SELECT age FROM user WHERE id = 1;  -- 在 RR 级别下,仍然读到 age = 20
COMMIT;

在可重复读级别下,事务 A 两次查询结果是一样的,事务 B 的修改对 A 不可见,因为 A 的 Read View 是在 B 提交之前创建的。

串行化(Serializable):所有事务串行执行,完全不存在并发问题,但性能最差,基本只在极端场景下用。


幻读问题和间隙锁

说到这里要提一个经典问题:MVCC 能解决幻读吗?

答案是:不能完全解决

幻读是指:同一个事务内,两次范围查询,第二次查到了第一次没有的记录(通常是其他事务插入了新数据)。

MVCC 通过 Read View 可以解决快照读(普通 SELECT)的幻读问题,但如果你用的是当前读(SELECT ... FOR UPDATE 或者 UPDATEDELETE),那就不走 MVCC 了,是直接读最新数据,这时候幻读就可能出现。

InnoDB 解决这个问题用的是 间隙锁(Gap Lock)Next-Key Lock

Next-Key Lock = 行锁 + 间隙锁,它不只锁住符合条件的行,还会锁住这些行之间的"间隙",防止其他事务在这个范围内插入新数据。

-- 这条语句会加 Next-Key Lock
SELECT * FROM user WHERE age BETWEEN 20 AND 30 FOR UPDATE;
-- 锁住了 age 在 20-30 范围内的所有行,以及这个范围内的间隙
-- 其他事务无法在这个范围内插入新记录

这就是为什么 MySQL 在可重复读级别下能基本解决幻读问题,但要注意,这是在当前读的场景下,靠锁来保证的,不是靠 MVCC。


事务提交和崩溃恢复的完整流程

把前面说的串起来,看看一个事务从开始到提交,底层到底发生了什么:

  1. 事务开始,分配事务 ID
  2. 执行 SQL 操作,修改 Buffer Pool 里的内存数据
  3. 每次修改,先写 undo log(记录原始数据,用于回滚和 MVCC)
  4. 再把修改记录到 redo log buffer
  5. 事务提交时,把 redo log buffer 里的内容刷到磁盘(这步完成,事务就算持久化了)
  6. 释放事务持有的锁
  7. 后台线程异步把 Buffer Pool 里的脏页刷到磁盘数据文件

崩溃恢复时:

  1. 扫描 redo log,找到已提交但没有刷盘的事务,重新应用
  2. 找到未提交的事务,用 undo log 回滚

这个设计保证了数据既不会因为崩溃而丢失已提交的数据,也不会因为崩溃而保留未提交的数据。


生产上几个要注意的坑

长事务是大忌。 事务越长,持有锁的时间就越长,其他事务等待的时间就越长,系统并发能力就越差。而且长事务会导致 undo log 不能被清理(因为可能还有其他事务需要读历史版本),undo log 会一直膨胀,磁盘空间会被大量占用。

我之前处理过一个案例,一个业务同学写了个数据迁移脚本,在一个事务里处理了几十万条数据,跑了将近一个小时,这期间 undo log 撑到了几十 GB,把磁盘快打满了,差点影响整个数据库服务。

-- 查看当前活跃事务,找出长事务
SELECT * FROM information_schema.INNODB_TRX 
ORDER BY trx_started;

-- 看看有没有跑了很久的事务
SELECT trx_id, trx_started, trx_state, trx_query 
FROM information_schema.INNODB_TRX 
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

不要在事务里做外部调用。 比如在事务里调用第三方接口、发消息队列,这些操作耗时不可控,会导致事务时间变长,锁持有时间变长。正确的做法是先提交事务,再做外部调用,或者用异步的方式处理。

隔离级别不是越高越好。 串行化虽然安全,但并发性能极差。大多数业务用可重复读就够了,某些读多写少且对一致性要求不那么严格的场景,用读已提交性能更好。

autocommit 要注意。 MySQL 默认开启自动提交,每条 SQL 都是一个独立的事务。如果你用 BEGIN 或者 START TRANSACTION 开启了事务,记得要 COMMIT 或者 ROLLBACK,不然事务会一直挂着。


总结

MySQL 事务的实现,核心就是三个东西:undo log、redo log、MVCC

undo log 保证了原子性,事务失败可以回滚,同时也是 MVCC 多版本数据的存储基础。redo log 保证了持久性,通过 WAL 机制,事务提交后数据不会因为崩溃而丢失。MVCC 加上锁机制保证了隔离性,让并发事务之间互不干扰,同时尽量减少锁的使用,提升并发性能。

这几个机制不是独立运作的,它们相互配合,共同构成了 InnoDB 事务体系。理解了这些,再去看各种隔离级别的行为、幻读的成因、长事务的危害,就都能说清楚了。

下次面试官再问你 MySQL 事务是怎么实现的,希望你不只是背 ACID,而是能说出 undo log 怎么保证原子性,redo log 为什么能保证持久性,MVCC 的 Read View 是怎么判断版本可见性的。这才是真正理解了事务机制。


如果觉得这篇文章对你有帮助,欢迎点赞转发,让更多人看到。我会持续分享生产环境中的运维实战经验,MySQL 优化、故障排查、架构设计这些内容后续都会出,关注不迷路。

公众号:运维躬行录

个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码