写业务代码的时候,事务似乎很简单:begin、几条SQL、commit 或者 rollback。但一旦你开始处理线上死锁、排查“历史数据查不到”、或者被面试官追问“RC 和 RR 隔离级别下 MVCC 到底怎么实现的”,光会用事务就不够了。这时候绕不开的就是 MySQL 里三个最核心的底层机制:redo log、undo log 和 MVCC。
说直白一点,redo log 保证了你的事务提交后数据不会丢,undo log 保证了你的事务回滚时能还原现场,而 MVCC 则在不加锁的情况下让你读到“某个时间点”的一致性快照。这三者互相配合,构成了 InnoDB 事务引擎的骨架。这篇文章我从一次简单的 UPDATE 语句开始拆解,讲清楚一份数据从内存到磁盘的全过程、回滚的底层逻辑、以及多版本链条是怎么串起来的。适合正在准备 MySQL 面试、需要排查线上事务问题、或者想真正理解 InnoDB 原理的开发者和 DBA 阅读。
1. 一条 UPDATE 语句的完整旅程
理解 redo log 和 undo log 最好的方式,不是直接背概念,而是跟踪一条 UPDATE 语句执行时,InnoDB 内部到底发生了什么。操作很简单,就用下面这条 SQL 做例子:
UPDATE user SET age = 28 WHERE id = 1;假设 id = 1 这一行原来的 age 值是 25。这条语句执行成功后,从存储引擎层面看,至少要完成四件事:记录旧值以便回滚、修改内存中的数据页、记录重做日志、最终将变更刷到磁盘。下面逐一拆开看。
1.1 第一步:先写 undo log
事务要具备回滚能力,必须先把旧值保存下来。所以在真正修改数据之前,InnoDB 会先把这一行的旧值写入 undo log。这里写入的是修改前的镜像,包括被修改的字段旧值、主键信息、以及指向更早版本的指针。
这里要强调一个容易混淆的点:undo log 不是“修改完了再记录新值”,而是“修改之前先记录旧值”。逻辑顺序是:读旧值 -> 写 undo 日志到 undo 表空间 -> 修改内存中的 buffer pool 数据页。如果事务中途回滚,InnoDB 就拿着 undo log 里的旧值反向恢复,把这行数据恢复到修改前的样子。
undo log 不光服务回滚,它还是 MVCC 多版本链条的物理载体。每一行数据的多个历史版本,就是通过 undo log 串成一条链的。这条链在 InnoDB 里叫 rollback segment,数据行头部有两个隐藏字段:DB_TRX_ID(最近修改这行的事务 ID)和 DB_ROLL_PTR(指向 undo log 的指针)。因为这两个字段的存在,你可以把这行数据理解为一个“版本链表的头节点”。
1.2 第二步:修改 buffer pool 中的数据页
写完 undo log 后,修改操作直接作用在内存中的数据页上,也就是 buffer pool 里的 page。这一步很快,因为不碰磁盘。把 age 从 25 改成 28,在这一瞬间,磁盘上的数据文件还是旧值 25,内存里的数据页已经是新值 28。
缓冲池就是 InnoDB 在内存里开辟的一块区域,专门用来缓存数据页和索引页。读取数据先看缓冲池有没有,没有才去磁盘;修改数据也先改缓冲池,随后再异步刷回磁盘。这套机制极大地提升了读写性能,但也带来了一个新问题:如果事务提交后、脏页还没刷回磁盘之前,数据库突然崩溃,内存里的修改就全丢了。这时候就需要 redo log 出场。
1.3 第三步:写 redo log 并提交
redo log 记录的是“物理修改内容”,也就是对某个数据页上的某个偏移量做了什么样的变更。它跟 undo log 不一样:undo log 保存的是旧值,用于恢复;redo log 保存的是新值,用于重放。
事务提交时,InnoDB 会把这条修改产生的 redo log 按顺序写入磁盘上的 redo log 文件。只要这个日志落盘了,事务就算真正提交成功。之后哪怕 buffer pool 里的脏页还没刷到磁盘,或者数据库直接崩溃,重启后 InnoDB 也能通过 redo log 重放,把数据页恢复到崩溃前的状态,保证已提交事务的数据不丢失。
这就是所谓的 WAL(Write-Ahead Logging)机制:数据页可以晚点刷盘,但日志必须先落盘。在这个场景里,redo log 就是“先记账、后付钱”的账本。我在实际排障中遇到过一个典型案例:某台服务器突然断电,重启后应用层反馈“刚才明明提示提交成功了,怎么数据不见了”。最后排查下来,就是手动把innodb_flush_log_at_trx_commit改成了 0,redo log 没有在事务提交时强制刷盘。这个问题放到第 5 章具体讲。
1.4 第四步:后台线程刷脏页
提交完成后,修改已经写进 redo log,但数据页还在内存里。InnoDB 的后台线程会在合适的时机把这些脏页刷回磁盘。刷盘动作可以由空闲时机触发,也可以由 redo log 空间不足或 buffer pool 空间压力触发。
从这一刻开始,磁盘上的数据文件才真正变成 age = 28。需要注意的是,刷脏页的过程如果发生了崩溃,也不需要担心:因为 redo log 里已经有完整的重放记录,重启后 InnoDB 会把没有刷成功的部分重新执行一遍。所以脏页刷盘在性能上是异步的,在可靠性上则是兜底的。
2. 深入拆解 redo log:崩溃恢复的基石
既然 redo log 承担着“崩溃后数据不丢”的重任,光知道概念是不够的,还需要理解它的文件结构、写入时机和刷盘策略。这一节把 redo log 的底层机制彻底讲透。
2.1 redo log 的记录格式与 LSN
redo log 里记录的不是 SQL 语句,而是物理操作。格式大致包含:日志类型、表空间 ID、数据页号、偏移量、修改长度、修改后的内容。这意味着 redo log 可以直接定位到“某个数据页的某个位置”,重放时不需要理解业务逻辑,纯粹按日志内容覆盖数据页。
每个 redo log 都有一个全局递增的编号,叫 LSN(Log Sequence Number,日志序列号)。LSN 同时存在于 redo log 和对应的数据页中,它就像一个水位标记。InnoDB 在崩溃恢复时,只需要从最近一次 checkpoint 记录的 LSN 位置开始,往后重放日志即可。每个数据页也记录了自己最后被刷盘的 LSN,重放时如果日志中的 LSN 小于等于页上的 LSN,说明这个改动已经生效,直接跳过。这套机制避免了重复重放的问题。
2.2 redo log 的环形写入与 checkpoint
redo log 文件是固定大小的,多个文件组成一个组,写入时采用环形覆盖方式。你可以把它想象成一圈环形跑道,写入指针一直在前进,跑到尽头就绕回起点继续写。但问题是,旧日志可能会被新日志覆盖。所以 InnoDB 有一个 checkpoint 机制:当系统要覆盖某段日志时,必须先确保这段日志对应的脏页已经刷盘。如果脏页还没刷完,就必须停下来先刷盘,推进 checkpoint 位点。
这也就是为什么innodb_log_file_size不能设置的太小:文件越小,环形空间越容易写满,系统就会频繁地强制刷脏页,导致性能抖动。我见过很多把日志文件默认值改小导致写入吞吐量骤降的案例,这在“事务提交量大但数据量小”的系统中表现特别明显。一般建议把 redo log 总大小设成能够容纳 30 分钟到 1 小时的写入量。
2.3 刷盘策略的关键参数
redo log 的落盘时机直接关系到性能和可靠性的取舍。关键参数就是innodb_flush_log_at_trx_commit,它有 3 个取值,适用场景完全不同:
| 参数值 | 行为 | 可靠性 | 性能 |
|---|---|---|---|
| 1 | 每次事务提交都把 redo log 刷到磁盘 | 最高,最多丢失一个事务 | 最慢,但默认值 |
| 0 | 事务提交时不刷盘,由后台线程每秒刷一次 | 低,崩溃时可能丢 1 秒内的日志 | 最快 |
| 2 | 事务提交时写入操作系统缓存,由系统每秒刷盘 | 中,操作系统崩溃时丢,MySQL 崩溃不丢 | 快 |
生产环境默认就是 1,这也是唯一的“提交成功即持久化成功”的保证。如果业务对吞吐量要求极高且能容忍丢失最近 1 秒的数据,可以把参数调成 2,这在很多大促场景下是常用策略。但调成 0 我基本不建议,因为 MySQL 进程本身崩溃都可能丢数据,风险完全不可控。
2.4 崩溃恢复的过程
当 InnoDB 重启时,第一步是扫描 redo log,找到最近一个 checkpoint 点。从这个点开始向后扫描所有日志记录,逐条应用到对应的数据页上。应用完成后,数据文件就恢复到了崩溃前的最后一致状态。然后再通过 undo log 把未提交的事务回滚掉,最终数据库才对外提供服务。
值得补充的一个坑是:如果 redo log 文件配置得很小,而系统长期处于高写入状态,checkpoint 跟不上写入进度,就会出现“日志写满、性能卡死”的现象。你的应用数据量不大,但产生大量 redo 日志的场景(比如批量 UPDATE 大表)特别容易触发这个问题。所以 redo log 的总大小一定要结合业务峰值来评估,而不是套用一个固定模板。
3. undo log 的底层逻辑:回滚与版本链
undo log 这个名字容易让人觉得它只是“反着执行一遍 SQL”,实际上它的实现远没有这么简单。我要先纠正一个常见误解:undo log 保存的不是“逆向 SQL”,而是修改前的数据版本快照。只有拿到完整的旧值,InnoDB 才能精确地把数据页恢复到某个时间点的状态。
3.1 undo log 的两种类型
按照操作类型,undo log 分为两种:
- insert undo log:INSERT 语句产生的日志,记录新增行的主键。回滚时直接根据主键删除这些行即可。insert undo log 在事务提交后可以立即清理,因为它不会参与 MVCC 的版本链(新插入的行不会被更早的事务看到)。
- update undo log:UPDATE 和 DELETE 产生的日志,记录修改前的完整旧值和主键信息。这类日志不仅用于回滚,还是 MVCC 版本链的重要组成部分。事务提交后也不能立刻清理,需要等所有可能引用这个版本的事务结束后才能 purge。
这里要特别解释为什么 DELETE 也会产生 update undo 而不是 delete undo。因为 InnoDB 的删除是“标记删除”,先在记录上打一个删除标记,随后由后台 purge 线程真正物理删除。在标记删除时,原值仍然需要保留在版本链上,供 MVCC 读取历史版本。所以 DELETE 在 undo log 层面跟 UPDATE 是一样的,都属于 update undo log。
3.2 undo log 是 MVCC 的物理基础
每一行数据都有一个隐形的版本链,链上的每个节点都对应一个 undo log 记录。数据行上的 DB_ROLL_PTR 指向最近一次修改的 undo log,而 undo log 里又有指针指向更早的版本。当某个事务需要读取历史版本时,就沿着这条链往下找,直到找到对当前事务可见的版本。
这个过程看起来很简单,但实际还有一个容易被忽略的细节:对于二级索引,InnoDB 还通过版本链上的主键信息反查聚簇索引来获取历史版本。因为二级索引不存储 DB_ROLL_PTR 和 DB_TRX_ID,所以如果查询需要历史版本而二级索引记录不满足要求,就必须回表到聚簇索引查找。这也是为什么某些“按二级索引查询 + 隔离级别条件”的 SQL 在并发修改下会慢一些的原因。
3.3 undo 表空间的管理
undo log 物理上存储在 undo 表空间中,也就是innodb_undo_tablespaces参数指定的文件。MySQL 8.0 之后,undo 表空间被独立管理,不再混入系统表空间,这算是一个架构上的改进。里面按回滚段划分,每个回滚段又包含多个 slot。事务执行时从回滚段里分配 undo log 存储空间。
这里必须说一个常见的运维痛点:undo log 膨胀问题。如果存在长时间未提交的事务,它持有的 undo log 会一直保留,不能被 purge。时间一长,undo 表空间会不断增长,磁盘空间被大量占用。更严重的是,这个长事务会让版本链无限拉长,导致查询读取时沿版本链找历史版本的路程变长,性能严重下降。
关于“长事务会导致 undo 膨胀”这个点,我给一个实际案例。之前线上有个应用在凌晨跑批任务,开启了事务后每处理一批数据就 sleep 几秒,整体事务持续了 4 个小时。等早上监控告警的时候,undo 表空间已经从 100MB 长到了 12GB。解决思路就是:抓出长事务、kill 掉、然后等 purge 线程慢慢回收空间。所以监控里一定要有information_schema.innodb_trx的最长事务时间阈值告警。
4. MVCC 多版本并发控制原理
MVCC 全称 Multi-Version Concurrency Control,核心思想是:同一行数据在数据库里可以同时存在多个版本,不同的事务可以看到不同版本的数据。这样读操作不会被写操作阻塞,写操作也不会被读操作阻塞,实现了高并发下的读写分离。
4.1 三个隐藏字段与版本链
InnoDB 聚簇索引记录中除了业务字段,还有三个隐藏字段:
- DB_TRX_ID:最近一次修改该行的事务 ID,6 字节。每次事务更新该行时都会更新这个字段。
- DB_ROLL_PTR:回滚指针,7 字节,指向 undo log 记录。通过这个指针可以找到更早的版本。
- DB_ROW_ID:当表没有显式定义主键时,InnoDB 自动生成的一个递增行 ID,6 字节。
有了 DB_TRX_ID 和 DB_ROLL_PTR,同一行数据的不同版本就串成了一条链。最新版本在链头,每次 UPDATE 都会在链头插入一个新版本,旧版本下沉到链表更深处。需要注意的是,刚才讲过 insert undo 不参与版本链,所以一条记录被 INSERT 出来时,它只有一个版本;第一次 UPDATE 之后,版本链才开始出现两个节点。
4.2 ReadView:快照读的判断依据
MVCC 最核心的问题就是:一个事务在执行快照读时,如何判断哪一版本的数据对它是可见的?答案是生成一份 ReadView。ReadView 的概念用一个生活中的例子类比特别贴切:你在一个房间里拍了一张照片,这张照片记录了房间里当前所有人的状态。之后再有人进房间或离开房间,照片里依然是之前那些人和那个状态。ReadView 就是事务执行快照读瞬间的一个“快照”,它记录四个关键信息:
- m_ids:当前活跃且未提交的事务 ID 列表。
- min_trx_id:活跃事务中最小的事务 ID。
- max_trx_id:下一个将要分配的事务 ID,也就是所有已创建事务 ID 的上界。
- creator_trx_id:创建这个 ReadView 的事务自己的 ID。
4.3 可见性判断规则
当事务 A 沿着版本链读取某个版本时,会取出这个版本上的 DB_TRX_ID,记为 trx_id,然后按以下规则判断:
- 如果 trx_id == creator_trx_id,说明这个版本是当前事务自己修改的,可见。
- 如果 trx_id < min_trx_id,说明这个版本在 ReadView 生成之前就已经提交了,可见。
- 如果 trx_id >= max_trx_id,说明这个版本是在 ReadView 生成之后才开启的事务修改的,不可见。
- 如果 min_trx_id <= trx_id < max_trx_id,说明该事务可能活跃也可能已提交。此时检查 trx_id 是否在 m_ids 列表中:如果在,说明事务还没提交,不可见;如果不在,说明事务已提交,可见。
规则不复杂,但执行判断时有几个容易晕的点。比如第 4 条里“已提交但事务 ID 落在活跃区间”的情况,在并发较高的系统里非常常见。事务 ID 是递增分配的,ReadView 生成之后,新开启的事务 ID 会不断增大。一个事务 ID 为 20 的事务,在 max_trx_id 已经增长到 30 的时候,它虽然小于 30,但已经提交了,只是不在 m_ids 里。这种情况就必须依据 m_ids 列表精确判断,而不能简单用大小判断。
4.4 RR 与 RC 隔离级别下的差异
MVCC 在 Read Committed(RC)和 Repeatable Read(RR)两种隔离级别下的实现差异非常大,这是面试高频考点,也是实际开发中必须搞清楚的。
RC 级别下,每次执行快照读都会生成一个新的 ReadView。所以同一个事务里,两次 SELECT 可能读到不同的数据,因为两次 ReadView 的可见性区间不同。这就是“不可重复读”的根源。
RR 级别下,ReadView 只在事务第一次执行快照读时生成一次,之后整个事务期间都复用这个 ReadView。所以无论执行多少次 SELECT,看到的都是同一个快照,自然就避免了不可重复读。这也是为什么 InnoDB 默认采用 RR 隔离级别——MVCC 已经提供了可重复读的能力,而且读取不需要加锁。
但要强调一点:MVCC 解决的是快照读的可重复读问题,当前读(如SELECT ... FOR UPDATE、UPDATE、DELETE)不走 MVCC。当前读始终读取最新已提交版本,并且对读取的记录加锁。所以即使你在 RR 隔离级别下执行多次SELECT ... FOR UPDATE,也不会获取到旧版本的数据,而是永远读到当前最新数据并加锁。这是理解 RR 下为什么还会出现死锁的关键。
4.5 一个完整的 MVCC 读取示例
为了把上面这些规则串起来,我构造一个简单的例子。假设 user 表初始有一条数据:id=1,age=25,事务 ID 为 10。现在有三个事务并发执行:
- 事务 A(trx_id = 20):执行
UPDATE user SET age = 28 WHERE id = 1,未提交。 - 事务 B(trx_id = 21):执行
SELECT * FROM user WHERE id = 1。 - 此时 active 事务列表 m_ids = [20, 21],min_trx_id = 20,max_trx_id = 22。
事务 B 的查询沿着版本链走。链头是事务 A 修改后的版本(age=28,DB_TRX_ID=20),由于 trx_id=20 在 m_ids 中,不可见。继续沿着 DB_ROLL_PTR 往旧版本找,找到 age=25,DB_TRX_ID=10。trx_id=10 < min_trx_id=20,说明这个版本在 ReadView 生成之前就已提交,可见。所以事务 B 读到的是 age=25。这就是“未提交事务的修改对别的事务不可见”的实现方式。
如果事务 A 随后提交,事务 B 再次执行 SELECT(RR 下复用同一个 ReadView),依然读到 age=25;但如果事务 B 在 RC 级别下,第二次 SELECT 会生成新的 ReadView,此时事务 A 已提交,不再出现在 m_ids 里,于是读到 age=28。
5. 常见问题与排查技巧实录
原理讲完了,接下来把我在实际运维和面试辅导中反复遇到的典型问题整理成速查表。这些问题每一个都在真实场景中踩过坑,排查思路和解决方式可以直接复用。
| 问题现象 | 根因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 提交成功但重启后数据丢失 | innodb_flush_log_at_trx_commit被设为 0 或 2 | 检查参数值 | 调回 1,或接受丢失窗口 |
| redo log 写满导致性能骤降 | innodb_log_file_size太小 | 监控Log_writes、Log_waits指标 | 增大日志文件总大小 |
| undo 表空间持续膨胀 | 存在长时间未提交事务 | 查询information_schema.innodb_trx | kill 长事务,等待 purge |
| 长事务导致其他查询变慢 | 版本链过长 | 检查事务持续时间与 undo 大小 | 拆分事务,缩短事务时长 |
| RR 下当前读读到最新值 | 误解了 MVCC 适用范围 | 确认 SQL 是否为FOR UPDATE/UPDATE | 当前读走锁机制,非 MVCC |
| 并发更新同一行频繁死锁 | 事务加锁顺序不一致 | 开启死锁日志,分析锁等待关系 | 统一加锁顺序,缩短事务时间 |
5.1 正确排查活跃长事务
长事务是 undo 膨胀和版本链过长的元凶。很多时候你感觉线上查询突然变慢,但 CPU、内存、磁盘看起来都正常,这时就要怀疑是不是有长事务在拖版本链。排查命令很直接:
SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified, trx_mysql_thread_id FROM information_schema.innodb_trx ORDER BY trx_started ASC;重点关注trx_started最早的事务,它往往是版本链上最早的一个“锚点”。只要它不结束,它之后产生的所有旧版本都不能被 purge,undo 空间就会不断膨胀。找到了具体的事务 ID 之后,通过trx_mysql_thread_id找到对应连接,再跟业务方确认是否可以 kill。
需要特别提醒一点:在 kill 长事务之前,一定要先确认它的状态。如果事务正在持有行锁,kill 之后行锁会释放,等待该锁的其他事务会立刻继续执行。这在业务上可能造成“雪崩式”的短暂拥堵,所以建议在低峰期操作,或者在 kill 之前先暂停该事务对应的应用流量。
5.2 redo log 参数监测与调整
对于 redo log 来说,最直接的监控指标就是是否出现了log_waits。可以从SHOW ENGINE INNODB STATUS的日志段看出当前日志写入位置和 checkpoint 位置之间的距离。如果两者经常靠得很近,说明 redo log 空间接近写满,必须扩大文件大小。
调整 redo log 大小在 MySQL 8.0.30 之前需要重启实例,8.0.30 之后支持动态调整。操作步骤是设置innodb_redo_log_capacity,需要特别注意的是调大以后磁盘上要预留足够的空间。redo log 的文件是提前分配好的,不会动态缩小,所以扩容前先看磁盘剩余空间。我遇到过一次因为 redo 扩容导致磁盘空间被占满的例子,扩容前没算好余量,最后不得不先删 binlog 腾空间,非常狼狈。
5.3 MVCC 相关的死锁场景分析
MVCC 本身不会导致死锁,死锁一定是因为存在锁竞争,而当前读才会申请锁。最常见的死锁场景是:两个事务分别更新了不同的记录,之后又交叉更新对方持有的记录。比如事务 A 先更新 id=1 再更新 id=2,事务 B 先更新 id=2 再更新 id=1,此时两个事务相互等待,死锁发生。
排查死锁最有效的方式是开启死锁日志:
SHOW ENGINE INNODB STATUS;或者直接在配置中开启:
innodb_print_all_deadlocks = 1开启后,每次死锁都会把详细的锁等待信息写入错误日志,包括每个事务持有的锁、正在等待的锁、以及对应的 SQL。分析时重点关注两条事务获取锁的顺序是否一致,然后调整应用层加锁顺序。对于 RR 隔离级别还有一个隐藏坑:INSERT ... ON DUPLICATE KEY UPDATE会产生插入意向锁和间隙锁,两个事务同时插入同一个唯一键的不同值时也可能互相阻塞。这类问题的排查思路就是看LOCK_TYPE和LOCK_MODE字段,间隙锁和插入意向锁是重灾区。
6. 面试高频考点与学习建议
这篇文章聊了不少底层细节,最后我再从准备面试的角度做一个知识串联,帮大家把零散的点串成一张完整的图景。一个人对 InnoDB 事务理解到什么程度,其实从几个连续追问中就能看出层次。
最基础的追问是:MySQL 为什么需要 redo log?能回答出“先写日志、后写数据,保证崩溃恢复”只能算及格。再往下问:redo log 和 binlog 有什么区别?能答出一个是 InnoDB 层的物理日志、一个是 Server 层的逻辑日志,记录内容和用途不同,才算第一层过关。继续追问:事务提交时两阶段提交中 redo log 的 prepare 和 commit 之间的崩溃恢复怎么保证一致性?这是 MySQL 8.0 以前双写机制设计的经典考点。
MVCC 相关的追问通常从“RR 是如何解决不可重复读的”开始,这里要答到 ReadView 的生成时机和判断规则。面试官如果深入问“ReadView 判断时 trx_id 正好在 min 和 max 之间怎么处理”,你就要能说出检查 m_ids 列表这个关键动作。能答到这个程度,面试官基本会认为你对 MVCC 有真实理解,而不是背概念。
我个人在学习这套内容时最有效的方法,是把 MySQL 源码里几个核心数据结构的关键路径画出来,比如trx_sys->rw_trx_ids和 ReadView 的 m_ids 之间的关系。但如果你暂时没有精力啃源码,那就多做“连环追问演练”,找一个你熟悉的人互相拷问,直到每个判断规则都能不带思考地脱口而出。纸上得来终觉浅,写一条带并发场景的 SQL 出来,配合SHOW ENGINE INNODB STATUS观察实际行为和书上的结论是否一致,这个验证过程本身比背诵所有知识点都重要。
最后再分享一个我在项目里养成的习惯:每次上线涉及事务的功能之前,先审视一遍三个问题——事务大概会持续多久,涉及哪些行锁和间隙锁,读取是否走快照读。想清楚这三个问题,很多 session 级别的怪问题都能在设计阶段就规避掉。对于刚接触这块内容的读者,我的建议是从小处入手,先启动两个 mysql 客户端,开两个事务,手动验证一下 RC 和 RR 下同一行数据的可见性差异,亲眼看到结果之后再往深里钻,理解会完全不一样。