找了一圈,网上的 MySQL 安装教程、环境配置教程确实很多,但真正聊事务底层原理的文章,大多只讲了个皮毛。很多人会把 ACID 背得滚瓜烂熟,知道四种隔离级别分别叫什么,可真要问一句“为什么可重复读在 MySQL 里就能基本解决幻读”,或者“一条 UPDATE 提交成功的时候,磁盘上到底发生了几次写入”,还是会卡壳。
这篇文章我想用一篇实操复盘的方式,从一条看似普通的 UPDATE 语句出发,把 MySQL 事务的底层链路彻底拆开。你会看到 MVCC、undo log、redo log、锁冲突这些概念并不是孤立的知识点,而是一套为了支撑“要么全做、要么全不做”而设计的完整协作机制。同时也会聊到单机事务的原理如何延伸到分布式事务,毕竟现在一聊到订单、库存、支付,分布式事务基本是绕不开的话题。不管你是后端开发、DBA,还是在准备面试,这篇都值得认真看一遍。
1. 一条 UPDATE 语句背后:事务在 MySQL 内部到底经历了什么
1.1 你看到的“执行成功”,不是写完数据就完事
先看一个最简单的场景:
sql复制UPDATE account SET balance = balance - 100 WHERE id = 1;
假设这条语句在一个事务里执行:
sql复制BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;
如果把问题抛给刚接触 MySQL 不久的同学,他大概率会告诉你:事务嘛,就是先把两条 UPDATE 都执行完,然后 COMMIT 提交,如果中途出错就 ROLLBACK 回滚。
这个描述在逻辑层完全正确,但落到底层实现,还有几个关键问题需要回答:回滚靠什么实现?提交之后万一数据库宕机了,怎么保证数据不丢?两个事务同时改同一行数据,靠什么排队?
答案是:回滚靠 undo log,崩溃恢复靠 redo log,并发控制靠锁和 MVCC。
所以你看,光是一条最简单的 UPDATE,就牵扯出了 MySQL 事务底层的三大件。理解事务原理,本质上就是理解这三样东西如何配合。没有它们,ACID 里的原子性、隔离性、持久性全是空话。
1.2 事务的“主战场”不在 Server 层,而在 InnoDB
这里必须强调一个很多初学者会忽略的分层问题。MySQL 整体分两层:上面是 Server 层,负责连接管理、SQL 解析、优化、执行;下面是存储引擎层,InnoDB 才是事务的真正实现者。
为什么 MyISAM 不支持事务,换成 InnoDB 就支持了?因为 Server 层根本不关心事务,它只负责把 SQL 发给存储引擎,InnoDB 在引擎内部实现了事务日志、锁、MVCC 这些机制。所以在讨论 MySQL 事务时,默认语境其实就是 InnoDB 引擎。
我给个更直白的类比:Server 层像外卖平台的接单系统,它只负责把顾客的订单派发给餐厅;InnoDB 才是后厨,真正做菜、保证出餐品质的是它。如果你接单系统找的是个只做简餐的“档口”(MyISAM),那自然没法提供完整宴会级别的事务保证。
既然搞清楚了事务主战场,下一步就该看看 InnoDB 用来支持事务的核心机制是怎么运作的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 隔离级别不是“配置项”,而是快照读与当前读的合力结果
2.1 快照读靠的是 MVCC,不是锁
MySQL 默认的隔离级别是 REPEATABLE READ,很多人在理解这个级别时,容易误以为 InnoDB 是靠“读锁”把数据锁住来实现可重复读的。说实话,我在早期排查问题时也走过这个弯路,后来翻源码和官方文档才彻底想明白:可重复读的“读”,主要是靠 MVCC 的快照读做到的,并没有在每次 SELECT 时给所有行加锁。
MVCC 的全称是 Multi-Version Concurrency Control,多版本并发控制。InnoDB 在每行数据后面隐藏了两个关键字段:
- DB_TRX_ID:最近一次修改这行数据的事务 ID。
- DB_ROLL_PTR:回滚指针,指向该行在 undo log 中的上一个版本。
当一个事务执行普通 SELECT(快照读)时,它会生成一份 ReadView,也就是“我能看到哪些版本”的可见性判断视图。ReadView 里的核心逻辑是:对于一行数据,如果它的 DB_TRX_ID 对应的事务还没提交,那当前事务就不能直接读这个最新版本,而是顺着 DB_ROLL_PTR 找到 undo log 里的历史版本,直到找到一个对当前事务可见的版本。
这里有个非常关键的差异,直接决定 RC 和 RR 的体验不同:
- READ COMMITTED:每次 SELECT 都会生成新的 ReadView。
- REPEATABLE READ:只在事务第一次 SELECT 时生成 ReadView,后面复用。
正因为 RR 复用了同一个 ReadView,所以无论这个事务里执行多少次普通 SELECT,看到的版本始终是第一次查询时那个视图能看到的版本,这就是“可重复读”的底层来源。而 RC 每次查询都重新生成视图,一旦别的事务提交了,当前事务再次 SELECT 就有可能看到提交后的新数据,于是出现不可重复读。
这个机制解释了为什么很多人在 RR 下执行同一条 SELECT 结果永远一致,却一直没搞明白底层是谁在起作用——其实根本上是 MVCC 在替你挡掉了并发干扰。
2.2 当前读的本质是加锁,gap lock 让人又爱又恨
不是所有读取都走快照。如果你在 SELECT 语句后面加上 FOR UPDATE,或者执行 UPDATE、DELETE,InnoDB 必须读取并锁定当前最新版本的数据,这就叫当前读。
当前读的锁规则大致是:
- Record Lock:只锁某一行记录。
- Gap Lock:锁定某个范围之间的间隙,防止其他事务在这个间隙插入新记录。
- Next-Key Lock:Record Lock + Gap Lock 的组合,既锁记录又锁间隙。
默认 RR 隔离级别下,InnoDB 对索引扫描会用 Next-Key Lock。这个锁的意义在于防止幻读。举个例子:
sql复制-- 事务 A
SELECT * FROM orders WHERE order_no >= '1000' AND order_no <= '2000' FOR UPDATE;
如果没有 Gap Lock,事务 A 锁住了满足条件的现有记录,但另一个事务 B 完全可以在 order_no = 1500 的位置插入一条新记录,事务 A 再查一次就会发现多了一行——幻读。有了 Gap Lock 之后,1000 到 2000 这个范围之间的空隙也被标记了,B 事务的插入会被阻塞。
但 Gap Lock 也让很多人头疼:明明只是按条件更新 100 行,结果因为扫描范围里有间隙,导致其他事务无法在这个范围里插入数据,并发度急剧下降。更麻烦的是,Gap Lock 在某些场景下会互相等待,最终形成死锁。
关于死锁的实战排查,后面专门写一节,这里先记住结论:Next-Key Lock 是 RR 下防幻读的关键,但它带来的锁竞争成本也不低。这也是为什么很多高并发团队会把隔离级别调整为 READ COMMITTED,并配合其他手段来控制并发,核心就是不想让 Gap Lock 卡住写入。
3. redo log 与 undo log:原子性和持久性不是靠“感觉”保证的
3.1 为什么必须先写日志再写数据:WAL 机制
我一直觉得,理解 redo log 最好的切入点是这样一个问题:事务提交时,InnoDB 为什么不是直接保证把数据页刷到磁盘,而是先写一条 redo log?
答案很简单:写数据页是随机 IO,而写 redo log 是顺序追加,两者性能差一到两个数量级。如果每次提交都把修改过的数据页立即刷到磁盘,数据库的写性能绝对扛不住。InnoDB 的做法是先写 redo log 到日志文件,并告诉客户端“提交成功”,数据页可以留在内存的 buffer pool 里,等合适的时机再刷到磁盘,这个机制叫 WAL(Write-Ahead Logging),预写日志。
假设事务提交成功,redo log 也持久化了,但数据页还没来得及刷盘,这时候数据库宕机了怎么办?重启时 InnoDB 会读取 redo log,把事务修改重新应用到数据页,这个过程叫崩溃恢复,也就是 Crash Recovery。所以只要 redo log 在,数据就不会丢,这就是持久性的底层保障。
不少 DBA 调优时会把 innodb_flush_log_at_trx_commit 参数拿出来讨论,因为它的取值直接影响性能和安全的平衡:
| 参数值 | 行为 | 性能/安全特点 |
|---|---|---|
| 0 | 每秒刷一次 redo log 到磁盘 | 性能最高,但数据库崩溃时可能丢失最近 1 秒内的事务 |
| 1 | 每次事务提交都刷盘 | 最安全,但每次提交都涉及磁盘 fsync,性能相对低 |
| 2 | 每次提交写入操作系统缓存,每秒刷盘 | 数据库进程崩溃不丢,但操作系统崩溃可能丢最近 1 秒事务 |
线上环境默认配置通常就是 1,双 1 模式(redo log 刷盘 + binlog 刷盘)是金融类业务的标准姿势。我自己在压测时试过把参数调成 0,写入吞吐能涨一大截,但心里始终不踏实,因为一旦物理机断电,那损失是不可接受的。所以这个参数怎么调,本质上是在性能和数据安全之间做取舍,没有绝对正确答案。
3.2 undo log 不只是用来回滚,它还是 MVCC 的版本来源
redo log 负责“重做”,undo log 负责“撤销”。两者作用刚好相反。
当一个事务修改一行数据时,InnoDB 会把修改前的旧数据写入 undo log,并通过 DB_ROLL_PTR 把新旧版本串成一条版本链。如果事务执行到一半需要回滚,InnoDB 就沿着 undo log 把数据恢复到修改前的样子。
但 undo log 的价值远不止回滚。前面讲 MVCC 时说,一个事务读不到其他未提交事务的修改时,需要顺着 DB_ROLL_PTR 找历史版本,这个历史版本其实就是存在 undo log 里的。换句话说,快照读的“快照”不是靠额外的历史表,而是直接复用 undo log 里的版本链。
这里牵出一个非常隐蔽的坑:如果有一个事务特别长,一直不提交,那么它持有的 ReadView 会一直阻止 undo log 中的旧版本被清理。这些旧版本越积越多,undo log 文件就会不断膨胀。我之前处理过一个线上案例,某个定时任务在事务里循环几十万条数据,跑了好几个小时才提交,结果 undo 表空间直接涨到了几十个 G,差点把磁盘撑爆。
所以教训是:事务要短平快,一定不要在一个事务里做大批量循环操作。这个原则从底层原理上就说得通——长事务不只是占连接,还会拖住 undo log 的清理线程,影响整个实例。
3.3 binlog 和 redo log 的两阶段提交,到底在防什么
redo log 是 InnoDB 引擎的日志,binlog 是 MySQL Server 层的逻辑日志。一个数据变更,最终要同时写入这两份日志:redo log 用于 InnoDB 的崩溃恢复,binlog 用于主从复制和时间点恢复。
问题来了:如果 redo log 写成功了,binlog 没写成功,主从复制会丢数据;反过来,如果 binlog 写成功了,redo log 没写成功,从库可能多执行了事务。为了保持两份日志一致,InnoDB 引入了两阶段提交。
两阶段提交简单说就是:
- InnoDB 先把事务标记为 prepare 状态,写 redo log。
- Server 层写 binlog,并刷盘。
- InnoDB 收到 commit 指令后,把 redo log 改成 commit 状态。
如果数据库在第二步之后、第三步之前崩溃,恢复时会判断:redo log 是 prepare,binlog 又已经写入,说明事务可以继续提交;如果 binlog 还没写,说明事务应该回滚。这套机制保证了 redo log 和 binlog 的一致性,在跨实例场景下非常重要,否则主从数据一致就是一句空话。
这也是为什么你去搜 MySQL 面试题,两阶段提交会反复出现的原因——它不是一个孤立的考点,而是理解 MySQL 高可用体系的地基。
4. 脏读、不可重复读、幻读:回到锁和版本链的视角重新看
4.1 脏读为什么在 RC 和 RR 下都不允许
脏读的定义很简单:一个事务读到了另一个事务未提交的数据。为什么脏读不能被允许?因为未提交事务随时可能回滚,一旦回滚,你读到的那条数据就是“不存在”的,基于这种数据的业务判断全部作废。
InnoDB 是怎么阻止脏读的?答案藏在 MVCC 的 ReadView 生成规则里。ReadView 会记录当时活跃的未提交事务 ID 列表,如果一行数据的 DB_TRX_ID 落在活跃事务列表中,说明这个修改还没提交,当前事务就不能读这个版本,必须去版本链里找更早的已提交版本。
所以无论 READ COMMITTED 还是 REPEATABLE READ,都不会出现脏读。脏读只会在一个完全没有隔离机制、直接读最新版本的数据库里出现。从原理上理解了这一点,就不会把脏读和不可重复读混淆。
4.2 不可重复读为什么更难对付
不可重复读的场景是:事务 A 先读某一行数据,事务 B 修改并提交了这一行,事务 A 再读同一行时,发现值变了。这本质上是“同一行记录的版本更新”问题,和幻读的“多条记录数量变化”是两回事。
RC 隔离级别下不可重复读很难避免,因为每次快照读都会生成新的 ReadView,事务 B 一旦提交,事务 A 就能看到 B 的版本。而 RR 隔离级别之所以能可重复读,靠的是复用同一个 ReadView,从机制上直接切断“新提交版本对当前事务可见”的路径。
需要特别指出的是,这里说的是普通 SELECT 这种快照读。如果你在 RR 下先执行了一次 UPDATE(当前读),然后再 SELECT,那查到的可能是更新后的数据,因为 UPDATE 本身是读最新版本并加锁的,它会推动当前事务读取最新已提交的数据。所以网上有人争论 RR 到底能不能完全避免不可重复读,本质上是没分清楚快照读和当前读的使用场景。
4.3 幻读并没有被完全消灭,RR 只是限制了它
MySQL 官网文档写得很明白:默认的 REPEATABLE READ 隔离级别并不能完全防止幻读,只是 InnoDB 通过 Next-Key Lock 在绝大多数场景下避免了你感知到幻读。
为什么说“绝大多数”?因为 Gap Lock 只对加锁的当前读语句生效,对快照读是不起作用的。如果你事务 A 先用普通 SELECT 得到结果集,事务 B 插入一条新记录并提交,事务 A 再次执行普通 SELECT,因为 RR 复用了旧 ReadView,所以看不到这条新插入的记录,看起来好像没幻读。
但如果你事务 A 在 RR 下执行了当前读,然后事务 B 插入记录,事务 A 再次执行当前读,仍然可能看到新记录——当前读会读取最新已提交的数据,因为它的目的就是拿最新值去修改,不能只看快照。还有一个更经典的案例是:事务 A 先普通 SELECT 查不到某条记录,事务 B 插入并提交,事务 A 尝试 UPDATE 这条记录,结果发现能更新成功,这是因为 UPDATE 当前读能看到新提交数据,但 UPDATE 操作本身又不能插入新行,这就让业务逻辑出现“凭空多出来一条数据”的诡异情况。
所以严格来说,真正的无幻读需要串行化隔离级别,也就是加范围锁的范围最大到整张表。这也是 SERIALIZABLE 并发极差的原因。
5. 锁等待、死锁与长事务:事务机制拖垮性能的真实场景复盘
5.1 一个死锁案例的完整排查链路
先摆案例。某订单系统晚上跑批,突然大量报错,错误码是常见的 ERROR 1213: Deadlock found when trying to get lock; try restarting transaction。
我当时的排查步骤:
第一步,用 SHOW ENGINE INNODB STATUS 查看最近一次死锁信息。里面很清楚地记录了造成死锁的两条 SQL 分别持有哪个锁、等待哪个锁。
死锁现场大概是这样的:
事务 1:持有 order 表 id=100 的 Record Lock,等待 order_item 表某个范围的 Gap Lock。
事务 2:持有 order_item 表那个范围的 Gap Lock,等待 order 表 id=100 的 Record Lock。
也就是说,两个事务都以不同顺序访问了 order 表和 order_item 表。事务 1 先锁主表再锁子表,事务 2 先锁子表再锁主表,互相等着对方释放。
找到根因后,问题就好办了。最直接的解决手段是让所有事务都遵循同一个加锁顺序:先锁主表,再锁子表,从源头消除循环等待。再配合缩短事务执行时间,把不必要的 SELECT FOR UPDATE 改成普通 SELECT,死锁频率很快就降下来了。
这里要说一下,死锁不一定都能靠加锁顺序避免,特别是业务场景复杂、SQL 分布分散的时候,经常会有漏网之鱼。这时候让事务具备重试机制就是兜底方案。我在项目里通常会写一个小的重试切面,捕获 1213 错误后自动重试三次,每次重试前随机退避一小段时间,避免多个事务同时重试导致活锁。死锁本身不可怕,可怕的是没有重试机制,让一个偶发问题直接变成线上事故。
5.2 长事务如何让 undo log 失控
长事务的问题在前面提过,但值得展开说。联想起一次真实故障:业务方设计了一个对账功能,把一个大批量文件拆成多批数据,每一批都放在同一个事务里处理,中间还有远程接口调用。整个事务需要跑约 20 分钟,期间任何一个远程接口超时,整个事务直接回滚。
后果是双重的:一是事务执行期间,操作的数据行都持有排他锁,其他会话更新这些行必须等待,前端大量请求被阻塞;二是事务的 ReadView 一直持有,undo log 无法回收旧版本,磁盘空间持续增长,最终影响了其他正常事务。
排查方法很直接,查 information_schema.innodb_trx 表,可以看到当前所有运行中事务的执行时间、状态、锁等待情况,把 WHERE 条件加上 trx_started 早于当前时间几分钟的记录,慢事务一目了然。
我当时给出的优化方案是把大事务拆成小事务,每处理 100 条数据就提交一次,远程接口调用移出事务范围,改用工单状态做补偿。线上问题很快解决。如果你遇到类似性能瓶颈,第一步不是去看慢查询,而是先确认有没有长事务把所有线程都堵住了,这个排查顺序能帮你省下一大截时间。
5.3 事务注解失效,也是底层原理没搞清楚
很多项目用 Spring 的 @Transactional 来控制事务,经常有人说“我明明加了事务注解,怎么异常没回滚?”这个问题的根源,多半是事务注解没生效,或者回滚规则没配对。
据我的经验,常见原因有这么几个:
- 自调用导致事务失效:同一个类里 A 方法调用 B 方法,B 上的事务注解不会生效,因为 B 是被 this 直接调用,没经过 Spring 的代理对象。
- 方法不是 public:Spring 默认用 CGLIB 或 JDK 动态代理实现事务增强,私有方法根本不会代理。
- 抛出的是检查异常:@Transactional 默认只在遇到 RuntimeException 和 Error 时回滚,如果业务方法抛出一个自定义的 Exception,又没有配置 rollbackFor,事务会正常提交,数据就落库了。
- 数据库引擎不是 InnoDB:如果表用的 MyISAM,事务注解写得再漂亮也没用。我见过一次生产环境把表从 InnoDB 迁到 MyISAM 之后所有事务回滚都失效的情况,最终查了半天才发现是引擎问题。
这里建议的做法是:代码里显式指定 rollbackFor = Exception.class,并尽量避免事务方法里包含远程调用、外部 IO、大批量循环,因为这些都会延长事务,增加锁持有时间和连接占用时间。事务是保证数据一致性的工具,不是一个用来执行业务逻辑的容器,它的生命周期应该越短越好。
6. 从 MySQL 本地事务到分布式事务:原理为什么不够用了
6.1 数据库本地事务的边界在哪里
单机单库的时候,MySQL 的事务机制非常顺手:一个 BEGIN、COMMIT,数据原子性就有保障。但业务一旦拆成微服务,订单数据在订单库,库存数据在库存库,单个 MySQL 实例无法再管理跨库的事务。
这时候就有一个没法回避的问题:数据库的 ACID 只能覆盖它自己内部的数据修改,覆盖不了多个服务之间的状态协调。分布式事务要解决的,本质上不是“怎么让两个 MySQL 实例在一个事务里同时提交”,而是“当多个节点各自的本地事务都只能做到各自提交,怎么让它们作为一个整体要么全成功,要么全失败”。
很多人搜分布式事务时,会看到 2PC、TCC、SAGA、可靠消息这些名词。最稳妥的上手思路是理解一个本质:分布式事务的一致性,本质上是在“可用性”和“一致性”之间做取舍,并不是所有场景都需要强一致,先看看业务到底能不能容忍短暂不一致。
6.2 两阶段提交和 MySQL 的关系
2PC 是最容易和 MySQL 挂钩的分布式事务方案,它的实现思路是引入一个协调者角色,第一阶段让所有参与者预执行并上报结果,第二阶段根据投票结果,让所有参与者统一提交或统一回滚。
但 2PC 有个明显的痛点:协调者本身是单点,如果协调者在第二阶段开始前挂了,所有参与者都会卡在中间状态,只能靠人工介入。这个场景反过来让人更理解 MySQL 内部两阶段提交的设计有多重要——MySQL 是在一台机器的两个日志组件之间做协调,崩溃恢复时还能通过日志状态判断是否继续提交;但分布式 2PC 的协调者和参与者之间是跨网络通信,宕机、超时、网络分区都会让状态判断复杂得多。
所以我在实际选型时,很少会把跨服务场景直接切成 2PC,除非是单个服务内部的多个数据源,且强制要求一致性。真正在微服务之间,我更倾向于使用 TCC 或可靠消息,因为它们更灵活,对性能影响也小一些。
6.3 从 undo log 得到的分布式事务补偿灵感
不知道有没有人想过:MySQL 事务能回滚,是因为它提前写了 undo log,记录修改前的状态。如果把它放大到整个分布式系统,也可以借鉴这个思路——补偿操作就是“业务层面的 undo log”。
本地消息表方案就是这个思路的典型实现。生产者本地事务里,既写业务数据,也写一条消息表记录,然后通过定时任务把消息发送给消费者,消费者处理成功后回调修改消息状态。如果消费失败,就根据消息不断重试;如果确实永远失败,再由人工或补偿程序处理。
这个方案的精髓在于:消息表写操作和业务数据写操作在同一个本地事务里,保证了“业务成功,消息就一定发出”这一点,直接规避了分布式事务最头疼的“操作成功但通知失败”问题。
相比那些上来就想上 Seata、RocketMQ 事务消息的团队,我更建议先把本地事务边界搞清楚,再考虑是否需要引入分布式事务框架。很多团队一边在 MySQL 里写着循环远程调用的长事务,一边又在搜索引擎里找分布式事务解决方案,本质上是没搞清楚问题出在哪一层。
说到选型,我个人判断标准很简单:
- 同一个库里的多表更新,用 MySQL 事务就行。
- 跨库但不跨服务的场景,先看是否可以通过合理拆分减少跨库事务。
- 跨服务写操作,优先考虑本地消息表 + 对账补偿,复杂度低,容易上线。
- 如果业务强一致要求很高,且并发压力不大,可以考虑 TCC 或基于 Seata 的 AT 模式,但要接受实现成本和运维成本。
- 如果只是最终一致,可靠消息是最稳妥的路径。
分布式事务没有一个“万能方案”,本质上是牺牲一部分实时一致性,换取系统的可用性和性能。把这个取舍想明白,再回头理解 MySQL 事务底层原理,你会有一种完全不同的感觉:单机事务和分布式事务,其实共享同一套哲学,只是在不同的尺度上解决类似的问题。
最后分享一个我在实际项目中沉淀下来的排查习惯:遇到事务相关的问题,先确认引擎是 InnoDB,再查锁等待、死锁日志和长事务,最后看代码里的事务边界是否正确。顺序不能反,因为大部分问题的信号都藏在最底层的机制里。希望这篇围绕 MySQL 事务底层原理的拆解,能帮你在今后查问题、选方案时少走点弯路。
