之前一条磁盘告警让我连续盯了两晚的监控大屏。InnoDB 回滚段所在表空间从 40% 一路涨到 85%,业务侧开始出现大量更新等待,前台订单状态迟迟不刷新。登上去查了一圈,罪魁是一个已经挂了两小时没提交的事务:它中间改了三十多万行,期间所有新产生的旧版本都被它“拖住”,purge 线程根本清不动,回滚段自然越胀越大。
这件事让我一直想写一篇把 MySQL 事务讲透的文章。很多后端开发对事务的理解停留在“begin / commit / rollback 三条命令”的层面,但线上真正出问题时,拼的全是底层机制:redo、undo、锁、隔离级别、MVCC 怎么协作,事务边界怎么设计。这篇我会先把这套拼图讲清楚,再用两个真实故障复盘怎么排查长事务和死锁,最后给出事务优化和日常巡检的最佳实践。无论你是后端、DBA 还是运维,应该都能从里面找到直接能用的 SQL 和排查思路。
1. MySQL事务的底层拼图:redo、undo、锁与MVCC
1.1 先把“持久性”讲透:redo log的WAL设计
我见过很多人会对一个现象感到困惑:一条 update 执行完,明明数据页还没落盘,为什么 MySQL 敢告诉客户端“提交成功”?
靠的就是 WAL,Write-Ahead Logging。InnoDB 对数据页的修改,并不会在每次提交时都把随机脏页刷到磁盘,而是先把这次修改对应的日志顺序写到 redo log 文件里。数据页跟 redo log 的关系,可以类比成你写毕业论文:正文“页面”太多,不可能每次都完整拷到U盘保存;但你有一个小本子,每次改动都按顺序记下来。只要小本子在,哪怕电脑突然断电,等重启后也能照着记录把论文恢复到断电前的状态。
所以 redo log 解决的是持久性,核心动作是“顺序写”。顺序写比随机写快几个数量级,这也是为什么 MySQL 能承受很高的写入 TPS。
这里有个参数直接决定安全边界:innodb_flush_log_at_trx_commit。默认值是 1,表示每次事务提交都要把 redo log 刷到磁盘,这时候只要磁盘没坏,已提交事务不会丢。如果改成 0 或 2,崩溃时确实可能丢最近一小段已提交事务,具体我在第 4 章里展开讲。
再看一个常见误区:有人以为 redo log 越大越好,于是直接改到 8G,理由是避免频繁 checkpoint。但 redo log 过大会造成崩溃恢复偏慢,因为它要重放的日志范围更长。比较稳妥的做法是结合实例的写入量来调,业务高峰期每分钟产生的日志量乘上你期望的恢复窗口,再加一点余量,一般中小型服务 512M 到 1G 就够,写入量大的场景再单独评估。
1.2 undo log:回滚和MVCC共用同一套旧版本
redo 保证了“已提交的不丢”,那“未提交的要回滚”怎么做到?这就轮到 undo log。
每次对一行数据做 UPDATE 或 DELETE,InnoDB 都会把修改前的旧版本写到 undo log,行数据上会有一个隐藏的 roll_pointer 指向它。多个旧版本串起来,就形成一条版本链。
这里建议你把一行数据想象成一个 Git 仓库里的文件:每个事务提交就像一次 commit,undo log 里保留的是历史 commit 的 diff。普通查询读某一版本,其实是在这个版本链上做“版本穿梭”。
MVCC 的核心就是读这个版本链。比如可重复读隔离级别下,事务第一次执行查询时会生成一个 ReadView,记录当时“正在活跃的事务 ID 集合”。之后读取某行时,如果发现该行最新版本是由一个尚未提交的事务改的,就顺着版本链往旧版本找,直到找到一个“在 ReadView 生成时已经提交”的版本。
当所有事务都不需要某个旧版本时,InnoDB 后台的 purge 线程才会清理它。如果有一个长事务一直不提交,它生成的 ReadView 会一直引用很早的版本,导致它后面所有改动对应的旧版本都不能被清理。这就是回滚段暴涨的根本原因,日志上最常见的指标叫 History List Length,这个数字持续高位,说明有旧版本在堆积。
1.3 锁:RR用来防幻读的,正是间隙锁
redo 和 undo 解决的是“事务要么全做、要么全不做”以及崩溃恢复的问题,那多个事务同时写同一行怎么办?靠锁。
InnoDB 的锁机制是按索引记录来加的,主要有三类:
- Record Lock,记录锁,锁住具体的索引记录;
- Gap Lock,间隙锁,锁住索引记录之间的区间,防止其他事务往这个区间插入新记录;
- Next-Key Lock,临键锁,记录锁和间隙锁的组合,锁的是一段左开右闭区间。
默认隔离级别可重复读下,对锁定读(比如 select ... for update)扫描到的范围会加 Next-Key Lock。为什么要加间隙锁?防幻读。比如你查 status=0 的订单准备处理,事务里先查出了 10 条;这期间另一个事务插入了一条 status=0 的新订单,如果你用的普通查询且有间隙锁保护,新数据插不进来,你的事务结束后就不会凭空多出一批“幻影数据”。快照读通过 MVCC 看老版本,当前读则靠间隙锁。
间隙锁是把双刃剑,它扩大了锁的范围。两个事务各自在自己范围内做插入,可能互相阻塞,这也是 RR 隔离级别比 RC 更容易出现锁等待的原因之一。
1.4 ACID四姐妹分别由谁负责
聊到这儿,可以画一张职责表,等以后排查问题时你直接往这几个组件上套:
| ACID属性 | 核心组件 | 简单解释 |
|---|---|---|
| 原子性 | undo log | 事务执行一半失败时,通过 undo 把已修改数据回滚到事务前状态 |
| 持久性 | redo log | 已提交事务的修改有 redo 保障,崩溃后能恢复 |
| 隔离性 | 锁 + MVCC | 写与写之间用锁串行,读与写之间用 MVCC 隔离 |
| 一致性 | 约束 + 业务逻辑 | 主键、外键、唯一约束落库兜底,最终一致性要靠应用层保证 |
很多事务事故,追根溯源都能映射到这张表上。比如回滚段涨,是 undo 清理被阻塞;锁等待加不上,是锁范围太大;主从突然延迟,可能是提交前 fsync 策略和 binlog 组提交没配合好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 隔离级别不是随便拉的开关:一次RC切换事故复盘
2.1 事故现象和第一次误判
有次帮一个订单中心排查锁等待,业务方提出一个很常见的诉求:“我们被锁等待搞得焦头烂额,能不能把隔离级别从可重复读切成读已提交?”当时的开发直觉是 RC 没有间隙锁,锁范围更小,并发能力会提升。
我提醒过要先看代码,但系统还是在一个低峰时段切了,SET GLOBAL transaction_isolation='READ-COMMITTED'。前 20 分钟风平浪静,之后日志开始报错:大量 Duplicate entry for key 'uk_order_no',同一时间出现很多死锁回滚。
第一次误判是觉得唯一索引的重复是不是数据问题。后来把代码翻出来,发现业务逻辑是典型的“先查后插”:先 select 判断某个订单号是否存在,不存在就 insert。在 RR 隔离级别下,事务 A 插入某个订单号后,它在唯一索引区间上的临键锁会挡住另一个事务往同一区间插入;切换成 RC 后,间隙锁没了,两个事务几乎同时执行 select、同时发现不存在、同时往里插,最终只有一个能成功,另一个要么死锁,要么撞上唯一键。
这不是 RC 本身有错,而是应用把数据库的锁串行化当成了幂等兜底。改隔离级别之前,业务层根本没有为唯一键冲突做重试和补偿。
2.2 四种隔离级别在InnoDB里的真实行为
MySQL 提供了四种隔离级别,但真正落到 InnoDB 上时,行为值得逐条说:
| 隔离级别 | 可能发生的并发问题 | InnoDB 是否避免 | 快照读行为 |
|---|---|---|---|
| READ UNCOMMITTED | 脏读、不可重复读、幻读 | 基本不隔离 | 读到最新版本,可能读到未提交数据,不推荐 |
| READ COMMITTED | 不可重复读、幻读 | 避免脏读 | 每条 SQL 都重新生成 ReadView,能读到已提交的新版本 |
| REPEATABLE READ | 幻读 | 快照读 + 间隙锁避免 | 事务内第一次读取时生成 ReadView,后续复用 |
| SERIALIZABLE | 无 | 全部读也转成当前读 | 普通 select 也会加锁,并发最差 |
默认 REPEATABLE READ 不是没理由的。MySQL 老版本默认 RR,有历史兼容原因:早期 binlog 为 statement 格式时,RC 下同一个 SQL 在主库和备库可能产生不同的数据结果,RR 配合间隙锁能规避大量主从不一致问题。现在 binlog 默认 row 格式后,RC 的复制安全性已经不再是障碍,但默认值仍然保留着 RR。
2.3 RR与RC真正的分歧:快照与锁的配合方式
很多人误以为“RC 只是少了间隙锁,性能一定更好”,其实不全面。RC 和 RR 在 InnoDB 里至少有两点本质区别。
第一是快照读的 ReadView 何时建立。RR 下,事务内第一条 select 执行时建立 ReadView,之后整个事务都复用这个视图,因此你在事务里反复查同一条记录,看到
