写了好几年业务代码,真正让我对 MySQL 事务原理产生敬畏心的,是一次线上库存超卖的排查。那个下午代码里明明加了事务注解,数据却还是错了,我把日志翻了个底朝天,最后发现是事务隔离级别和锁机制在背后搞鬼。自那以后我意识到,MySQL 事务不是 begin、commit、rollback 三个关键字那么简单,它背后是一整套精心设计的日志、锁、版本链机制在支撑。这篇文章我打算把 MySQL 事务的底层原理拆开揉碎,从日志系统讲到 MVCC,从锁机制讲到事务失效的真实场景,全程用我实际踩坑的经历穿插着讲,无论你是刚接触 MySQL 的初级开发,还是已经在用事务但偶尔被并发问题折磨的进阶选手,应该都能从这里拿到点真东西。
1. 先搞清楚:事务到底在解决什么问题
1.1 从一次库存超卖事故说起
我特别反感一上来就背 ACID 四个字母的做法。因为脱离了场景,原子性、一致性、隔离性、持久性就是四个空洞的形容词。拿我那次库存超卖来说,业务是用户在秒杀活动里下单,下单逻辑就三步:查库存、扣库存、创建订单。正常情况下这三步是一个整体,要么全部成功,要么全部失败。但问题是多个用户同时下单时,两个请求同时读到库存是 1,各自扣减后库存变成了 -1,这就是超卖。
表面上看是并发问题,本质上是对"临界资源"的竞争缺乏保护。事务要解决的第一件事,就是把"查库存、扣库存、创建订单"这组操作捆绑成一个原子单元。这个单元在执行时不管中间哪个环节出了问题,数据库都要有能力把前面已经做的修改全部抹掉,回到起点。注意"有能力"这三个字,它背后依赖的就是事务日志机制,这一点后面我会重点展开。
另一个被忽视的点是故障恢复。生产环境经常遇到 MySQL 进程突然被杀、服务器断电这样的情况,这时候已经提交的事务不能丢,未提交的事务不能留。如果数据库没有一套持久化保障机制,任何一次宕机都可能把半截数据写进磁盘。事务的持久性承诺讲的就是这个:事务一旦提交,它的修改就是永久的,哪怕下一秒数据库崩溃,重启后数据也得在。
所以理解事务原理之前,先要在脑子里建立这样一个认知框架:事务本质上是数据库对上层应用许下的承诺,而兑现承诺需要底层机制来兜底。日志机制管崩溃恢复,锁机制管并发控制,MVCC 管读写效率,它们各司其职又相互配合。
1.2 ACID 不只是面试题
我面过不少人,ACID 背得滚瓜烂熟,但一问到"可重复读和读已提交在底层实现上有什么区别"就哑火。这不怪他们,因为教科书只讲概念不讲实现,而真正的理解必须落到机制层面。
原子性的实现靠 undo log。每个事务执行修改之前,会先把修改前的数据快照写到 undo log,如果事务中途失败,就用 undo log 里的旧值把数据恢复回去。所以原子性不是数据库"凭空"撤销的,而是有账可查的逐条回滚。
持久性的实现靠 redo log。事务提交时,数据库并不会立刻把修改后的数据刷到磁盘上的数据文件里,而是先写 redo log,这个机制叫 WAL(Write-Ahead Logging)。因为顺序写日志比随机写数据文件快得多,所以事务提交的延迟被大幅降低。等 MySQL 崩溃重启时,再根据 redo log 把已经提交但还没落盘的数据补写进去。
一致性和隔离性更是一对纠缠不清的概念。一致性是最终目标,隔离性是手段之一。数据库通过锁和 MVCC 让并发事务看起来像串行执行一样,从而保证数据从一种一致状态流转到另一种一致状态。我在后面讲隔离级别的时候会具体展开,这里先把这个框架立住:ACID 不是四条孤立的性质,而是相互支撑的整体,其中日志机制和并发控制机制是两个核心支柱。
2. 事务的底层账本:redo log、undo log 与 binlog
2.1 先理清三份日志的分工
MySQL 里其实有三份和事务强相关的日志,很多初学者会搞混:undo log 管回滚,redo log 管崩溃恢复,binlog 管归档复制。前两份是 InnoDB 存储引擎层面的,第三份是 MySQL 服务层面的,它们记录的内容完全不同,作用也完全不同。
这里要先明确一个架构概念。MySQL 分两层:上面是 Server 层,负责连接管理、SQL 解析、优化和执行;下面是存储引擎层,InnoDB 是其中一种引擎。binlog 在 Server 层产生,记录的是逻辑变更,"我把 id=1 的行库存从 5 改成了 3";redo log 和 undo log 在 InnoDB 引擎层产生,记录的是物理变更和回滚信息。事务提交时,这两层都要把日志落盘,才能保证数据不丢。
我见过一个真实事故:有人把 binlog 关了提升性能,结果某天主库宕机后想用 binlog 做时间点恢复,发现没有日志可用,只能恢复到最后一次全量备份,丢了近两小时的数据。所以 binlog 不只是做主从复制用的,它也是数据恢复的最后一根稻草,生产环境千万别关。
2.2 两阶段提交保证双日志一致
有个问题很容易被忽略:redo log 和 binlog 是两份独立的日志,它们记录同一批数据变更,那怎么保证两边都写入成功?如果先写 redo log 成功、再写 binlog 失败,或者反过来,MySQL 崩溃后恢复出来的数据就可能是分裂的——存储引擎层的数据和 Server 层的日志对不上。
MySQL 的解法是内部事务的两阶段提交,这也是面试高频考点。流程是这样的:事务执行过程中,InnoDB 先把变更写入 redo log,此时 redo log 处于 prepare 状态;事务提交时,先写 binlog,写完再把 redo log 标记为 commit 状态。这里的关键就在于 prepare 和 commit 中间有个状态窗口。如果崩溃发生在写 binlog 之前,重启后 redo log 发现是 prepare 状态且 binlog 里没有对应记录,事务就回滚;如果崩溃发生在 binlog 写完之后,重启后发现在 binlog 里有记录但 redo log 还没标记 commit,事务就重新提交。
这个设计精妙在哪里?它保证了两份日志要么都生效,要么都不生效,不管崩溃发生在哪个时间点。实际排查问题时,如果遇到复制报错或者数据不一致,第一反应就应该是去对比主库的 binlog 位点和从库的中继日志位点,而不是瞎猜。
2.3 redo log 与 Buffer Pool 的配合
再往深挖一层:redo log 到底怎么和内存里的 Buffer Pool 配合?InnoDB 的数据读写都先走 Buffer Pool,这是一块内存缓冲。更新一条记录时,先把数据页读到内存里修改,这时候内存里的数据页是脏页,磁盘上还是旧值。事务提交时,把 redo log 刷到磁盘(根据 innodb_flush_log_at_trx_commit 参数的配置不同,可能是每次提交都刷,也可能是每秒批量刷),但脏数据页可以留在内存里慢慢刷回磁盘。
这个机制就是"先写日志,后写数据",好处是显而易见的。内存里改完了就可以返回客户端成功,不用等磁盘随机写完成。但如果脏页还没来得及刷盘就断电了,内存里的修改就没了,这时 redo log 就派上用场:重启后 InnoDB 会重放 redo log,把数据页恢复到崩溃前的状态。因此 redo log 是环形写的,文件大小固定,由 innodb_log_file_size 控制,写满后会覆盖旧的日志。如果磁盘写 redo log 的速度跟不上业务产生的日志量,就会出现日志写满等待的问题,表现为大量线程卡在 log flush 上。
我司之前遇到过这个坑,业务高峰时 InnoDB 的 log_sys 等待非常严重,调大 innodb_log_file_size 到 2G 才缓解。这里贴个排查命令:
-- 查看 InnoDB 日志相关的运行状态 SHOW ENGINE INNODB STATUS\G重点关注 LOG 段的 "Log sequence number" 和 "Log flushed up to",如果两者差值持续偏大,说明日志写入速度跟不上。redo log 大小策略没有标准答案,一般建议是让日志文件能覆盖业务高峰期 20 到 30 分钟的写入量,具体可以结合写入吞吐量来估算。
2.4 undo log:回滚和 MVCC 都靠它
undo log 的定位完全相反——它记录的是"怎么把数据改回去"。执行 INSERT 时,undo log 记录主键值,回滚时删除这条记录;执行 UPDATE 时,undo log 记录修改前的行的完整镜像,回滚时把旧值写回去。但 undo log 不止用于回滚,它还是 MVCC 版本链的核心材料。
InnoDB 里每行数据都有两个隐藏列:一个是事务 ID(DB_TRX_ID),记录最近一次修改此行的事务编号;另一个是回滚指针(DB_ROLL_PTR),指向该行在 undo log 里的上一个版本。每次更新都不直接覆盖旧值,而是生成一个新版本,通过回滚指针串成一条版本链。后面的快照读就是沿着这条版本链找到对当前事务可见的那一版数据。
这个设计非常优雅。它意味着读操作不需要加锁就能拿到一致的数据快照,因为旧版本的数据都保存在 undo log 里。也正因如此,undo log 不能随便清理,只有当版本链上最早活跃的事务结束后,它前面的旧版本才能真正被 purge 线程回收。这就是为什么有人会问"为什么一个长时间运行的事务会导致 undo log 膨胀",因为在它运行期间,它可能用到的所有旧版本都不能删。
3. 隔离级别与 MVCC:并发事务怎么互不打扰
3.1 四种隔离级别到底隔离了什么
隔离级别这个概念的难点在于,它不像原子性那样可以通过日志机制直接解释,它涉及的是"一个事务能看到什么数据"的问题。SQL 标准定义了四种隔离级别:读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE),隔离强度依次递增,并发能力依次递减。
读未提交最松,一个事务能读到另一个事务还没提交的修改,这会产生脏读。读已提交修掉了脏读问题,每次 SELECT 都取最新已提交版本,但又引入一个新的问题:同一个事务里两次 SELECT 可能读到不同结果,这叫不可重复读。可重复读则保证事务内多次读取同一行数据结果一致,这是 MySQL 的默认隔离级别,但它在某些场景下会有幻读问题——事务内两次范围查询,后一次多了几行。
串行化直接把读写都锁死,用最高代价换取最强隔离。理解这个递进关系后,你会明白隔离级别不是在选"安全程度",而是在选"并发度与一致性之间的平衡点"。MySQL 的默认级别是可重复读,这和 Oracle 默认读已提交不同,背后有历史原因也有 MVCC 实现上的差异,但如今看这个默认值在绝大多数场景下是合理的。
3.2 快照读的核心:ReadView
可重复读和读已提交在底层都依赖 MVCC,区别只在 ReadView 的生成时机。
先解释 ReadView 是什么。它是一个事务执行快照读时创建的视图,里面记录了生成时刻系统中活跃事务的 ID 列表,还有几个关键边界值。当读取一行数据时,沿着版本链从新到旧逐个判断:如果版本的事务 ID 小于当前事务 ID 且在活跃列表之外,说明这个版本在 ReadView 生成前就已经提交,可见;如果大于当前事务 ID,说明是未来事务的修改,不可见;如果在活跃列表里,说明这个版本属于尚未提交的事务,也不可见。判断不可见就继续往旧版本找。
到这里,读已提交和可重复读的区别就很直白了:读已提交每次 SELECT 都生成一个新的 ReadView,所以两次 SELECT 之间如果有其他事务提交了修改,第二次就能看到新数据,于是出现不可重复读;可重复读只在第一次 SELECT 时生成 ReadView,后续所有 SELECT 都复用这一个视图,即使其他事务已经提交了修改,也看不到,因为判断规则里那些事务 ID 在活跃列表里或者记录有限制。
一个重要的推论是:可重复读的快照读并不会因为其他事务提交而改变结果,但这不意味着当前事务拿不到最新数据。如果业务确实需要读到最新已提交数据,可以用当前读(比如 SELECT ... FOR UPDATE、UPDATE、DELETE),这些操作总是读最新版本并加锁。很多人在事务实战中困惑的"为什么 update 之后 select 结果变了",根源就是快照读和当前读的混合使用。
3.3 可重复读下真的不会幻读吗
教科书上写可重复读不能完全解决幻读,但很多业务在 MySQL 默认隔离级别下从没遇到过幻读,这就要谈到 InnoDB 的 next-key lock。在可重复读级别下,InnoDB 用临键锁把范围查询涉及的行记录和索引间隙一起锁住,从机制上封堵了新记录插入的可能性。
举个例子:事务 A 执行SELECT * FROM orders WHERE amount > 100 FOR UPDATE,此时没有满足条件的行,但 InnoDB 会在索引上对 (100, 正无穷) 这个间隙加间隙锁,事务 B 想插入 amount=200 的行时就会被阻塞,直到事务 A 提交。这就在绝大多数场景下避免了幻读。但注意,MVCC 的快照读仍然存在"半一致性"的边界情况,只是实际业务里遇到得很少。
串行化则更彻底,所有读都是当前读,全部加锁,自然不存在幻读问题,代价是并发度极低。生产环境很少用串行化,如果业务真的对一致性要求苛刻到必须串行,通常意味着设计上需要重新划分事务边界。
3.4 怎么根据业务场景选隔离级别
这里我给一个经验法则。多数互联网业务,尤其是订单、库存这类强一致场景,直接使用默认的可重复读就够了,因为 MVCC 加 next-key lock 的组合让它既保证了读性能,又防住了大多数并发问题。如果业务是报表统计、数据归档这类允许读到最近已提交数据的场景,可以显式把隔离级别调成读已提交,并发性能会更好,因为间隙锁的开销没了。
但改隔离级别前一定要想清楚代价。读已提交下,同一个事务里两次统计查询可能得到不同结果,如果下游对结果一致性有要求,就得靠应用自己保证,比如把两次查询放在同一个数据库连接里并配合锁。我接触过不少团队把隔离级别改成读已提交后,原本依赖可重复读"快照一致性"的报表数据出现偏差,最后又改回来。这里没有绝对的好与坏,只有适不适合。
4. 锁机制:并发控制的最后一环
4.1 行锁、间隙锁与临键锁
MVCC 解决的是读多写少场景下的快照读性能问题,但遇到写写冲突、当前读冲突时,InnoDB 最终还是得靠锁。锁的粒度从细到粗分别是行锁、间隙锁、临键锁、表锁。行锁锁住索引记录本身,间隙锁锁住索引记录之间的间隙,临键锁是行锁加间隙锁的合体,锁住的是"记录加前面的间隙"。
为什么需要间隙锁?本质是防止幻读在加锁读的场景下发生。不加间隙锁的话,事务 A 用WHERE id > 10 FOR UPDATE锁住了现有记录,事务 B 插入一条 id=11 的记录,事务 A 再次范围查询时就会看到新行,数据一致性被破坏。间隙锁的作用不是锁住具体某一行,而是锁住"这个位置不允许插入"。
间隙锁有个容易踩坑的副作用:即使事务只更新一条不存在的记录,也可能会锁住一个间隙,阻塞其他事务插入。举个真实例子,某表主键是自增 ID,业务方执行DELETE FROM t WHERE id = 9999,id=9999 不存在,但 InnoDB 会在主键索引上对 9999 之前的间隙加锁,如果此时有其他事务想插入 id 在间隙范围内的记录,就会卡住。排查时线上偶发 insert 等待,很多都是这个原因。
4.2 加锁流程与死锁产生条件
InnoDB 加锁遵循索引定位原则:锁是加在索引记录上的。如果 SQL 走了二级索引,InnoDB 会先锁二级索引记录,再回表锁主键索引记录。这也解释了一个经典问题:为什么大表更新没有走索引会导致全表锁住。因为没走索引时,InnoDB 只能全表扫描判断哪些记录匹配,扫描过程中每条记录都会加锁,这实际上等于把整张表锁了个遍。
死锁的产生需要四个条件同时满足:互斥、占有并等待、不可剥夺、循环等待。InnoDB 有死锁检测机制,事务一旦被检测为死锁受害者,会立即回滚其中一个事务并抛出错误。实际业务里最常见的死锁场景是:两个事务分别更新两条记录,但更新顺序相反。
事务一先更新 id=1 再更新 id=2,事务二先更新 id=2 再更新 id=1,两边各持有对方需要的锁,就卡死了。规避手段是让所有事务按同一顺序访问资源。在代码层面,可以在业务代码里对涉及的多行数据先排序再更新,这样两个事务的加锁顺序就一致了,死锁概率大幅下降。
5. 实务中的事务失效与性能陷阱
5.1 Spring 事务失效的经典场景
业务代码里事务注解加了不少,真正生效的却没几个,这是我在帮别人 review 代码时最常看到的问题。最经典的失效场景有几个。第一个是自调用,同类内部一个方法调用另一个带 @Transactional 的方法,事务不会生效,因为 Spring 的事务是通过代理实现的,自调用走的是 this 对象而不是代理对象。解决方案是注入自身代理或者把事务方法拆到另一个 Service 里。
第二个是方法不是 public 的,Spring 默认只对 public 方法做事务增强,private 或 protected 方法加上注解是静默失效的。第三个是异常被吞掉,事务方法内部 catch 了异常然后正常返回,事务自然就提交了,只有运行时异常(默认是 RuntimeException 和 Error)才会触发回滚,检查异常默认不回滚,需要显式配置 rollbackFor。
还有一类隐蔽问题:线程内开启的事务在线程池里复用连接,连接归属混乱。比如在异步线程里写数据,主线程的事务根本管不到子线程,因为事务和数据库连接是线程绑定的。这些场景我都实际排查过,每次最后定位到根因时都发现,不是数据库的问题,而是对 Spring 事务传播机制和 AOP 代理模型理解不到位。
5.2 大事务为什么是性能杀手
大事务指的是执行时间长、涉及数据量大的事务。它的危害在这几个方面特别明显。第一是锁持有时间长,事务不提交锁就不释放,其他事务排队等待,整个数据库的并发能力被拉低。第二是 undo log 膨胀,因为事务活跃期间旧版本不能被清理,可能导致 undo 表空间暴涨。我见过一个极端案例,一个跑了 40 分钟的事务把磁盘空间干爆了,数据库直接只读。
第三是 binlog 和 redo log 的写入压力。长事务意味着大批量变更集中在同一时间写入日志,日志切换变快,磁盘 IO 压力增大。所以在设计阶段就要控制事务粒度:不要在事务里调用远程接口,不要在事务里做耗时计算,能拆分的小事务绝不合并成大事务。一个 10ms 的小事务和一个 10 秒的大事务,在并发场景下对系统的影响差距是数量级的。
5.3 分布式事务的取舍
业务规模上来之后,单库单表扛不住,拆分成微服务后一个操作要跨多个数据库,这时本地事务就不够用了。分布式事务的核心矛盾是:多个独立数据库各自有独立的事务,怎么让它们一起提交或者一起回滚。业界常见的方案有 XA 两阶段提交、TCC(Try-Confirm-Cancel)、Saga、消息事务。
我个人的经验是,能用消息队列做最终一致性的,就别上强一致的分布式事务框架。最终一致性模型对业务容忍度要求高一些,但实现简单、性能好。TCC 适合对一致性要求高且愿意付出开发成本的场景,但它需要业务方实现三个接口,代码量不小。真正的强一致分布式事务,比如 XA,在跨数据库场景下性能损耗很大,很多团队用了一段时间后又会废弃掉。
说实话,分布式事务没有银弹,核心是业务上允许多久达到一致。允许秒级甚至分钟级一致,用消息事务就够了;要求非常严格,就要接受性能和复杂度代价。这块水很深,后面有机会专门写一篇展开,这里先点到为止。
6. 常见问题排查实录
6.1 死锁定位与信息解读
线上遇到死锁报错,第一反应不是改代码,而是先拿证据。MySQL 会记录最近一次死锁的信息,命令是:
SHOW ENGINE INNODB STATUS\G输出里有 LATEST DETECTED DEADLOCK 段落,会显示两个事务各自的 SQL、持有的锁和等待的锁。看这个输出要抓三个关键信息:涉及哪些表哪些索引、加锁顺序是什么、事务执行了哪些 SQL。对照业务代码找到加锁顺序冲突的根源,再决定是调整 SQL 顺序还是调整索引设计。
举一个我排查过的真实案例。表结构是这样的:主键 id,二级索引 user_id。业务代码里事务先UPDATE orders SET status = 1 WHERE user_id = 100,再UPDATE orders SET pay_time = NOW() WHERE id = 12345。两条 SQL 的加锁路径不同,第一条走 user_id 索引,先锁二级索引再回表锁主键;第二条直接走主键。两个事务如果 user_id 相同但 id 不同,就会在回表加主键锁时产生交叉等待。后来我们统一改成先查主键列表,再按主键顺序更新,问题就消失了。
6.2 事务不提交如何慢慢吃掉连接
这个坑非常隐蔽,症状是服务偶发卡顿,数据库连接池告警。打开慢日志发现单条 SQL 执行很快,但连接就是不释放,线程堆积严重。排查下来往往是代码里手动开启了事务却没在 finally 里提交或回滚,异常路径上连接一直挂着。数据库端的表现是 information_schema.innodb_trx 里有长时间运行的事务:
SELECT * FROM information_schema.innodb_trx\G重点看 trx_started 字段,如果事务启动时间距离现在超过几分钟,就基本可以断定是漏提交。解法很简单但特别容易漏:所有手动管理事务的代码,务必用 try-finally 或者 try-with-resources 模式保证事务一定结束。虽然现在大部分团队用 Spring 管理事务,但 JdbcTemplate 手动事务、存储过程里显式事务、以及中间件里有隐式开启事务的功能,都会踩到这个坑。
6.3 隔离级别与锁等待的连环排查
还有一种情况:业务反馈更新超时,SHOW ENGINE INNODB STATUS里大量事务显示在等待锁。这时要分清楚是锁等待还是死锁。锁等待就是单纯的别人没提交,等的时间超过 innodb_lock_wait_timeout(默认 50 秒)就超时抛错。处理思路是找到持锁事务,确认它是否健康、是否需要 kill。
安全 kill 事务前要确认事务对应哪个连接,可以用:
SELECT trx_id, trx_mysql_thread_id, trx_started, trx_state FROM information_schema.innodb_trx;然后用KILL 线程ID把持锁连接断掉,锁就会释放。但这里要提醒一句,kill 只是止损,根因还得找:为什么这个事务持有锁这么久?是业务本身耗时长,还是事务边界设计不合理,还是索引没走对导致锁范围过大。有时候一条本应只锁几行的 update,因为条件列没有索引,把整张表的记录都锁了,这种问题改 SQL 前先补索引,效果立竿见影。
写在最后的一些经验
我处理过的事务问题少说也有几十个,最有价值的一条经验是:排查事务问题,永远先看日志和监控,而不是先改代码。MySQL 的 INNODB STATUS、慢查询日志、performance_schema 里的等待事件,这些工具能帮你把问题定位到具体的事务、具体的锁、具体的索引上,确认根因后再动手。另一条经验是:事务设计要前置,不要在写代码时习惯性甩一个大事务把所有操作包住,你以为的"安全"恰恰是线上故障的温床。理解 MySQL 事务原理,本质上是建立一套"数据在并发和故障面前如何自保"的思维框架,这套框架一旦建立,以后再遇到奇怪的数据问题,你至少知道该往哪个方向查。