☰
MySQL事务实战:从ACID到MVCC,彻底解决锁与死锁难题
2026/10/13 13:36:37 网站建设 项目流程

上周线上又出事了。一条看起来特别简单的update语句,执行超时,把整个接口链路拖到报警。查了半天,最后定位到一个事务没提交,行锁没释放,后面的请求全堵在锁等待上。这种问题,相信不少同学都遇到过。MySQL 的事务、锁、隔离级别,平时写 CRUD 感觉不到,一到线上出故障,全得回来补课。

这篇文章我就把 MySQL 事务彻底拆开讲一遍。从 ACID 底层原理讲到 MVCC 多版本控制,从 redo log 刷盘讲到死锁排查,全程用实际踩过的坑给你串起来。不管你是刚入门的新手,还是写了好几年 SQL 的老手,只要你想把事务这块的底子打牢,这篇文章都值得花十分钟看完。

1. 先搞清楚事务到底在解决什么问题

1.1 一个转账场景引出的事务需求

事务的概念,教科书上讲得比较绕,我习惯用转账来理解。A 给 B 转 100 块钱,至少涉及两步:A 的账户扣 100,B 的账户加 100。如果第一步执行完,第二步执行前数据库崩了,A 的钱扣了但 B 没收到,这就是数据不一致。

事务要解决的问题,就是把这多个操作绑定成一个整体。要么全部成功,要么全部失败,不允许出现“做了一半”的状态。这个“全有或全无”的保证,在任何有状态的数据系统里都是刚需,不只是 MySQL。

日常业务里,事务出现的场景远比想象中多。订单创建时扣库存、下单时锁优惠券、用户注册时初始化账户,这些都是天然需要事务的地方。很多同学写代码的时候根本没意识到,一条INSERT单独的语句其实也是一个事务,只是 MySQL 默认自动提交了,让你感觉不到事务的存在。

1.2 ACID 四个特性逐个拆解

ACID 这四个字母,背过的人很多,真正理解的人不多。这里我逐个拆开,用大白话讲清楚。

原子性(Atomicity),就是事务里的操作绑成一个原子,不可拆分。实现原子性的核心是 undo log,后面会专门讲。简单理解就是:如果事务执行到一半失败了,MySQL 利用 undo log 把已经执行的修改全部回滚,恢复成事务开始前的样子。

一致性(Consistency),这个比较特殊,它不是 MySQL 的某个机制直接保证的,而是以上三个特性 + 业务约束共同作用的结果。比如转账前总金额是 1000,转账后还得是 1000,这种业务层面的约束,需要应用代码自己保证。MySQL 能做的,是通过原子性、隔离性、持久性让数据库不会因为并发或崩溃导致状态混乱。

隔离性(Isolation),解决的是多个事务并发执行时相互干扰的问题。两个事务同时改同一行数据,到底谁先谁后?怎么保证不会互相覆盖?这靠锁和 MVCC 实现。隔离性有强弱之分,对应四种隔离级别,后面详细讲。

持久性(Durability),事务提交后,修改必须永久保存下来。你提交了一个事务,结果机器突然断电,重启后发现数据丢了一部分,这是绝对不可接受的。实现持久性的核心是 redo log,配合 WAL 机制保证数据不丢。

这四个特性不是孤立的。原子性和持久性分别依赖 undo log 和 redo log,隔离性依赖锁和 MVCC,一致性则是前三者的综合结果。理清这层关系,后面看 InnoDB 的源码和参数配置就不会懵。

2. 隔离级别与 MVCC 的底层实现

2.1 四种隔离级别能解决什么问题

隔离级别解决的,是并发事务之间的三类问题。

脏读:事务 A 读到了事务 B 未提交的数据。如果 B 回滚了,A 读到的就是脏数据。

不可重复读:事务 A 内同一查询执行两次,结果不一致。原因是事务 B 在 A 执行期间修改并提交了数据。重点在于“修改”,同一行记录内容变了。

幻读:事务 A 内同一查询执行两次,结果集不一样。原因是事务 B 在 A 执行期间新增或删除了记录。重点在于“数量变了”,会出现原本不存在的新行。

对应关系如下表所示:

隔离级别脏读不可重复读幻读
READ UNCOMMITTED(读未提交)可能可能可能
READ COMMITTED(读已提交)不可能可能可能
REPEATABLE READ(可重复读)不可能不可能可能(InnoDB 实际可防)
SERIALIZABLE(串行化)不可能不可能不可能

这里有一个关键点很多人搞错:MySQL 默认的隔离级别是REPEATABLE READ,但 MySQL 在这个级别下通过 MVCC + 间隙锁,实际上已经把幻读问题解决了。也就是说,MySQL 的 RR 隔离级别,实际隔离效果接近 SERIALIZABLE 但又保留了更高的并发性能。

为什么 MySQL 要把默认级别设为 RR?历史原因是主从复制时代的兼容性问题,早期的 binlog 是 statement 格式,如果使用 RC 级别,某些语句在从库执行结果会和主库不一致。虽然现在推荐 row 格式的 binlog 后 RC 也安全了,但默认值一直没变。了解这段背景就行,实际使用时很多团队会主动改成 RC 提升并发性能。

2.2 MVCC 多版本控制的完整链路

MVCC(Multi-Version Concurrency Control),多版本并发控制,是 InnoDB 实现高并发读的核心机制。核心思想是:读操作不阻塞写操作,写操作不阻塞读操作。

每一行数据在 InnoDB 里都有两个隐藏列:

  • DB_TRX_ID:最近一次对该行执行修改操作的事务 ID。
  • DB_ROLL_PTR:回滚指针,指向 undo log 中该行之前的版本。

每次对行记录进行修改,InnoDB 不会直接覆盖旧值,而是先把旧值写入 undo log,形成一条版本链,然后修改数据行,更新DB_TRX_ID和DB_ROLL_PTR。这样一行数据在内存里就存在多个版本,通过 undo log 串成链表。

读操作执行时,InnoDB 会根据当前事务的快照(Read View)来判断哪个版本可见。Read View 有四个核心字段:

  • m_ids:生成 Read View 时当前系统中活跃(未提交)的事务 ID 列表。
  • min_trx_id:m_ids中最小的值。
  • max_trx_id:生成 Read View 时系统分配的下一个事务 ID,注意这个值不是活跃事务的最大值,而是预分配值。
  • creator_trx_id:生成该 Read View 的事务自己的 ID。

判断规则简单说一下:如果某行版本的DB_TRX_ID小于min_trx_id,说明该版本在 Read View 生成前已经提交,可见;如果大于等于max_trx_id,说明该版本是 Read View 生成后产生的,不可见;如果落在min_trx_id和max_trx_id之间,则判断是否在m_ids里,不在说明已提交,可见,在则不可见。

这里最大的区别在于 Read View 的生成时机。RC 级别下,每次执行快照读都会生成一个新的 Read View,所以能读到其他事务新提交的数据,出现不可重复读。RR 级别下,只有第一次快照读时生成 Read View,后续一直复用同一个,所以整个事务期间看到的数据版本是一致的。

有个很经典的坑要说一下。RR 级别下的 MVCC 只能保证“快照读”的一致性,如果你在事务里执行的是“当前读”,比如SELECT ... FOR UPDATE、UPDATE、DELETE这些操作,读的是最新已提交版本,不走 Read View 判断。这也是为什么在 RR 下依然需要通过间隙锁来防止幻读,因为 MVCC 对当前读无能为力。

3. 三个日志撑起事务的可靠性

3.1 undo log 与 redo log 的分工

一个事务的执行过程,会有三类日志文件参与:undo log、redo log、binlog。这三者的职责完全不同,不能混为一谈。

undo log是逻辑日志,记录的是“如何撤销这条修改”。事务回滚时,InnoDB 根据 undo log 里的记录反向执行操作,把数据恢复原状。比如你执行了UPDATE user SET age = 20 WHERE id = 1,undo log 会记录原始 age 值,回滚时把 age 改回去即可。

同时,undo log 也是 MVCC 版本链的载体。因为没有 undo log 里的旧版本数据,Read View 判断可见性后也找不到可读的历史版本。所以 undo log 承担了“回滚”和“多版本读”两个职责。

redo log是物理日志,记录的是“对数据页做了什么修改”。它是 InnoDB 引擎层的日志,主要解决持久性问题。InnoDB 的数据是存储在磁盘上的,但每次修改都直接写磁盘性能太差,所以数据页先在内存的 Buffer Pool 里修改,再异步刷回磁盘。

这里就有个风险:事务提交了,但数据页还在内存里,此时断电就会丢数据。redo log 的解决方案是 WAL(Write-Ahead Logging)机制——数据页落盘前,先把 redo log 落盘。这样即使数据页没来得及刷盘,重启后也能根据 redo log 重放修改,数据不丢。

redo log 的刷盘策略由参数innodb_flush_log_at_trx_commit控制:

参数值行为数据安全性性能
0事务提交时不写 redo log,由后台线程每秒刷一次最差,每秒可能丢数据最高
1每次提交都刷盘最好,不丢数据最低
2每次提交写入操作系统缓存,由系统决定何时落盘中等,操作系统宕机会丢中等

生产环境默认是 1,配合sync_binlog = 1,保证事务提交后数据绝对不丢。如果对性能要求极高且能容忍最多一秒的数据丢失,可以折中设为 2,但我不建议在核心业务上这么干。

3.2 binlog 与两阶段提交

binlog 是 MySQL Server 层的日志,和 InnoDB 引擎层的 redo log 不是一回事。binlog 记录的是逻辑操作,比如“表 user 的 id=1 行 age 字段从 18 改为 20”,用于主从复制和数据恢复。redo log 是循环写的,容量有限,binlog 是追加写的,可以长期保存。

这里有个关键问题:事务提交时,redo log 和 binlog 都要落盘,但一个事务在 InnoDB 和 Server 层是拆分记录的,如何保证两份日志的一致性?

MySQL 的答案是两阶段提交。

事务提交的大致过程如下:

  1. 准备阶段(Prepare):InnoDB 把事务状态标记为 prepare,将 redo log 刷盘。
  2. 写入 binlog:Server 层将 binlog 写入磁盘并刷盘。
  3. 提交阶段(Commit):InnoDB 把事务状态改为 commit,事务完成。

为什么需要这个流程?想象一下崩溃发生在不同时机的后果。如果先写 binlog 再写 redo log,binlog 写完了 redo log 还没写,崩溃后从库用 binlog 恢复了数据,主库用 redo log 恢复却没有这条修改,主从就数据不一致了。反过来一样。两阶段提交通过 prepare 和 commit 两个状态,配合崩溃恢复时的判断规则,保证 redo log 和 binlog 要么都生效,要么都不生效。

崩溃恢复时的判断逻辑是:如果 redo log 里的事务处于 prepare 状态,检查 binlog 里有没有对应事务的完整记录。有,则事务提交生效;没有,则回滚。

这个机制保证了数据库的“持久性”不只是在单机上成立,在跨机房的场景也保持一致。MySQL 8.0 里对两阶段提交做了优化,原理依然不变,理解这个流程对排查主从延迟和复制异常都很有帮助。

4. 锁机制与死锁排查实战

4.1 InnoDB 的行锁、间隙锁与 next-key lock

说事务绕不开锁。InnoDB 的锁粒度分为表锁和行锁,行锁下面又细分了几种,这里重点讲行锁相关的。

记录锁(Record Lock),就是锁定某一行记录。SELECT ... FOR UPDATE和UPDATE操作命中主键或唯一索引时,加的就是记录锁。

间隙锁(Gap Lock),锁定的是一个区间范围,但不包含边界记录。作用是防止其他事务在这个区间内插入新行,解决幻读问题。注意,间隙锁只在 RR 隔离级别下生效,RC 级别下只有记录锁。

临键锁(Next-Key Lock),是记录锁 + 间隙锁的组合,锁定一个区间以及区间的右边界记录。InnoDB 默认使用临键锁,它是 RR 级别防幻读的核心武器。

举个实际例子。表里有一个自增主键,现有 id 为 1、5、10 三条记录。在 RR 级别下执行:

SELECT * FROM user WHERE id = 5 FOR UPDATE;

除了给 id=5 这行加记录锁,还会在区间 (1, 5] 和 (5, 10) 加间隙锁。这意味着其他事务不能插入 id 在 2 到 9 之间的新记录,也不能修改 id=5 这行。锁的范围看起来很大,但这是为了防止幻读必须付出的代价。

有一个常见误解:间隙锁只在没有命中索引时才加整表锁。实际上,只要查询走了索引(即使不是唯一索引),InnoDB 都会在扫描到的索引区间加临键锁或者间隙锁。只有完全走主键等值查询且记录存在时,才只加记录锁。

锁的兼容性也是一个重点。行锁之间是互斥的,间隙锁之间是兼容的,间隙锁和插入意向锁之间是互斥的。这意味着多个事务可以同时持有同一个间隙的间隙锁,但如果有一个事务尝试在这个间隙里插入数据,就会进入锁等待。

4.2 一个典型的死锁案例分析

死锁的经典场景是两个事务互相持有对方需要的锁。实际业务里非常容易出现,特别是两个接口反向操作同一批数据的时候。

场景复现:

假设有一张账户余额表,存在 id=1 和 id=2 两条记录。

-- 事务 A START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT; -- 事务 B START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 2; UPDATE account SET balance = balance + 100 WHERE id = 1; COMMIT;

如果事务 A 先执行第一条语句锁住 id=1,事务 B 执行第一条语句锁住 id=2。然后 A 继续执行第二条语句想锁 id=2,发现被 B 持有,等待;B 执行第二条语句想锁 id=1,发现被 A 持有,等待。两个事务互相等待,形成死锁。

InnoDB 的死锁检测机制会检测到这种循环等待,主动回滚其中一个事务(通常选择 undo 量较小的事务)。被回滚的事务会收到类似这样的报错:

Deadlock found when trying to get lock; try restarting transaction

排查死锁最直接的方式是查看 InnoDB 的状态输出:

SHOW ENGINE INNODB STATUS;

这个命令的输出里有一个LATEST DETECTED DEADLOCK片段,会记录死锁发生时的 SQL 语句、持有锁和等待锁的详细信息。生产环境排查死锁的第一步就是执行这个命令,截图留档,然后对照业务代码找逻辑冲突。

避免死锁的几个实操建议:

  • 多个事务操作多张表或批量数据时,保证访问顺序一致。比如上面转账的场景,A 和 B 都先操作 id 小的记录,就不会死锁。
  • 尽量缩短事务时间,减少锁持有时长。慢查询是锁等待和死锁的温床。
  • 合理设置innodb_lock_wait_timeout,默认 50 秒偏长,业务侧可以调到 3 到 5 秒,快速失败重试比长时间阻塞用户体验更好。
  • 对核心高并发路径,考虑关闭死锁检测innodb_deadlock_detect = OFF,配合锁等待超时兜底,但这个方案只适合非常确定不会频繁死锁的场景,一般不建议。

5. 事务实战中那些必须避开的坑

5.1 长事务的危害远超你的想象

长事务,指执行时间长、一直未提交的事务。这是生产环境最常见的“隐形杀手”,危害体现在四个方面。

第一,锁持有时间长,导致其他事务大量阻塞。一个事务锁住了一行数据后迟迟不提交,所有要改这行数据的请求全部卡在锁等待上,接口超时,连接池耗尽,最终就是整个服务不可用。

第二,undo log 无法清理。事务未提交,它产生的 undo log 就要保留,对应的历史版本数据也不能清理。时间越长,undo log 膨胀越严重,甚至可能导致磁盘空间被占满。我见过一个案例,一个事务跑了一个小时没提交,undo 表空间涨到几十个 G。

第三,影响 MVCC 的可见性判断。长事务存在期间,其他事务执行快照读时要把长事务加入 m_ids,历史版本链被拉长,读取性能下降。

第四,主从延迟风险。后续会讲到,长事务产生的 binlog 量可能很大,从库应用这些日志的时间也会更长。

如何发现长事务?在 MySQL 5.7+ 里可以直接查:

SELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60\G

把超过 60 秒未结束的活跃事务查出来,根据trx_mysql_thread_id关联到具体的连接和业务,去代码里找为什么迟迟没提交。

开发同学最容易踩的坑是:在代码里手动BEGIN之后,中间穿插了远程调用、MQ 消息发送、文件读写这些耗时操作,导致事务长时间不结束。事务里绝对不能有外部调用,这是铁律。事务只应该用于数据库操作,外部操作必须放在事务提交之后。

5.2 Spring 声明式事务的失效场景

很多 Java 项目用 Spring 的@Transactional管理事务,这个注解好用是好用,但失效场景也挺多,部分还比较隐蔽。

第一种失效:抛出异常类型不匹配。Spring 默认只对RuntimeException和Error回滚,Exception的受检异常不会触发回滚。如果你在业务方法里抛出的是一个自定义的受检异常,事务不会回滚,数据就保持着写一半的状态。解决方式是指定 rollbackFor:

@Transactional(rollbackFor = Exception.class)

第二种失效:类内部方法自调用。@Transactional是基于 AOP 代理实现的,如果同类里的方法 A 调用方法 B,B 有事务注解,但这个调用走的是this引用,没有经过代理对象,事务注解根本不生效。这个坑很隐蔽,代码看着没问题,事务就是不动。

第三种失效:事务传播行为不当。比如外层方法没有事务,内层方法设置了REQUIRED,则会各自开启事务;如果设置成NOT_SUPPORTED,内层方法会挂起当前事务,以无事务方式执行,外层事务失败时内层的已经提交了,无法回滚。

排查事务是否生效,最简单的方式是把 SQL 日志打开。在配置里加上:

spring.jpa.show-sql=true

或者配置log4jdbc,观察有没有实际的BEGIN和COMMIT语句输出。如果异常抛了但没有任何回滚日志,基本可以确定事务注解没生效。

5.3 锁等待超时问题的排查思路

说回开头那个线上事故。一条简单的 update 执行超时,我当时的排查路径是这样的:

第一步,查看当前锁等待情况:

SELECT * FROM performance_schema.data_lock_waits\G

这个视图会明确告诉你哪个事务在等待哪个事务的锁,特别直观。如果字段为空,再查一下sys.innodb_lock_waits视图,这个视图封装了更完整的事务和语句信息:

SELECT * FROM sys.innodb_lock_waits\G

第二步,通过information_schema.innodb_trx找到阻塞源头的事务,拿到它的连接 ID,然后通过:

SELECT * FROM performance_schema.threads WHERE PROCESSLIST_ID = 获取到的连接ID\G

找到它最近执行的 SQL 语句,基本就能定位到是哪条语句锁住了数据。

第三步,确认问题后,如果确认事务已经卡死或者不需要继续,直接 KILL 掉对应的连接:

KILL 连接ID;

这个操作要谨慎,但要果断。线上每多等一分钟,堆积的请求就更多,雪崩的风险就更大。

排查完发现事故原因很无语:某个定时任务的代码里,循环里对每条数据都执行了SELECT ... FOR UPDATE,循环体里还嵌套了一层 HTTP 调用对账,每跑完一条要 5 秒,几千条数据跑下来,事务一直不提交,锁越积越多。最后一条 update 被前面的锁全部挡住。这场事故的本质就是长事务 + 外部调用放在事务里,两条铁律全犯了。

我个人在实际操作中体会比较深的一点是:事务相关的问题,九成以上不是技术复杂,而是代码习惯问题。事务的真正难点不在概念,在于你能不能识别出代码里那些“看起来没问题”的隐患。平时写代码多留个心眼,把事务范围缩到最小,把外部调用全部拿出事务,比任何调优参数都管用。

另外再分享一个小技巧:把innodb_lock_wait_timeout从默认的 50 秒调小到 3 秒,线上锁冲突时快速失败,然后让应用层做重试或降级。对于大多数互联网业务,这个设置能显著降低“卡死”的体感时间。调优参数很多,但先把这些最基础的习惯做对,MySQL 的事务系统就不会给你惹大麻烦。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询