面试必问!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:最后一次修改这行数据的事务 IDDB_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
判断一个数据版本是否对当前事务可见,规则大概是这样:
- 如果这个版本的
DB_TRX_ID小于min_trx_id,说明这个版本是在 Read View 创建之前就已经提交的,可见 - 如果
DB_TRX_ID大于等于max_trx_id,说明这个版本是在 Read View 创建之后才开始的事务改的,不可见 - 如果在这两者之间,就看
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 或者 UPDATE、DELETE),那就不走 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。
事务提交和崩溃恢复的完整流程
把前面说的串起来,看看一个事务从开始到提交,底层到底发生了什么:
- 事务开始,分配事务 ID
- 执行 SQL 操作,修改 Buffer Pool 里的内存数据
- 每次修改,先写 undo log(记录原始数据,用于回滚和 MVCC)
- 再把修改记录到 redo log buffer
- 事务提交时,把 redo log buffer 里的内容刷到磁盘(这步完成,事务就算持久化了)
- 释放事务持有的锁
- 后台线程异步把 Buffer Pool 里的脏页刷到磁盘数据文件
崩溃恢复时:
- 扫描 redo log,找到已提交但没有刷盘的事务,重新应用
- 找到未提交的事务,用 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 优化、故障排查、架构设计这些内容后续都会出,关注不迷路。
公众号:运维躬行录
个人博客:躬行笔记