恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
MySQL事务实战:从ACID到MVCC,彻底解决锁与死锁难题
首页
资讯中心
/
MySQL事务实战:从ACID到MVCC,彻底解决锁与死锁难题
MySQL事务实战:从ACID到MVCC,彻底解决锁与死锁难题
发布时间:2026/10/13 13:36:41
上周线上又出事了。一条看起来特别简单的update语句执行超时把整个接口链路拖到报警。查了半天最后定位到一个事务没提交行锁没释放后面的请求全堵在锁等待上。这种问题相信不少同学都遇到过。MySQL 的事务、锁、隔离级别平时写 CRUD 感觉不到一到线上出故障全得回来补课。这篇文章我就把 MySQL 事务彻底拆开讲一遍。从 ACID 底层原理讲到 MVCC 多版本控制从 redo log 刷盘讲到死锁排查全程用实际踩过的坑给你串起来。不管你是刚入门的新手还是写了好几年 SQL 的老手只要你想把事务这块的底子打牢这篇文章都值得花十分钟看完。1. 先搞清楚事务到底在解决什么问题1.1 一个转账场景引出的事务需求事务的概念教科书上讲得比较绕我习惯用转账来理解。A 给 B 转 100 块钱至少涉及两步A 的账户扣 100B 的账户加 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 多版本控制的完整链路MVCCMulti-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_idm_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 1undo log 会记录原始 age 值回滚时把 age 改回去即可。同时undo log 也是 MVCC 版本链的载体。因为没有 undo log 里的旧版本数据Read View 判断可见性后也找不到可读的历史版本。所以 undo log 承担了“回滚”和“多版本读”两个职责。redo log是物理日志记录的是“对数据页做了什么修改”。它是 InnoDB 引擎层的日志主要解决持久性问题。InnoDB 的数据是存储在磁盘上的但每次修改都直接写磁盘性能太差所以数据页先在内存的 Buffer Pool 里修改再异步刷回磁盘。这里就有个风险事务提交了但数据页还在内存里此时断电就会丢数据。redo log 的解决方案是 WALWrite-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 的 id1 行 age 字段从 18 改为 20”用于主从复制和数据恢复。redo log 是循环写的容量有限binlog 是追加写的可以长期保存。这里有个关键问题事务提交时redo log 和 binlog 都要落盘但一个事务在 InnoDB 和 Server 层是拆分记录的如何保证两份日志的一致性MySQL 的答案是两阶段提交。事务提交的大致过程如下准备阶段PrepareInnoDB 把事务状态标记为 prepare将 redo log 刷盘。写入 binlogServer 层将 binlog 写入磁盘并刷盘。提交阶段CommitInnoDB 把事务状态改为 commit事务完成。为什么需要这个流程想象一下崩溃发生在不同时机的后果。如果先写 binlog 再写 redo logbinlog 写完了 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;除了给 id5 这行加记录锁还会在区间 (1, 5] 和 (5, 10) 加间隙锁。这意味着其他事务不能插入 id 在 2 到 9 之间的新记录也不能修改 id5 这行。锁的范围看起来很大但这是为了防止幻读必须付出的代价。有一个常见误解间隙锁只在没有命中索引时才加整表锁。实际上只要查询走了索引即使不是唯一索引InnoDB 都会在扫描到的索引区间加临键锁或者间隙锁。只有完全走主键等值查询且记录存在时才只加记录锁。锁的兼容性也是一个重点。行锁之间是互斥的间隙锁之间是兼容的间隙锁和插入意向锁之间是互斥的。这意味着多个事务可以同时持有同一个间隙的间隙锁但如果有一个事务尝试在这个间隙里插入数据就会进入锁等待。4.2 一个典型的死锁案例分析死锁的经典场景是两个事务互相持有对方需要的锁。实际业务里非常容易出现特别是两个接口反向操作同一批数据的时候。场景复现假设有一张账户余额表存在 id1 和 id2 两条记录。-- 事务 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 先执行第一条语句锁住 id1事务 B 执行第一条语句锁住 id2。然后 A 继续执行第二条语句想锁 id2发现被 B 持有等待B 执行第二条语句想锁 id1发现被 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的受检异常不会触发回滚。如果你在业务方法里抛出的是一个自定义的受检异常事务不会回滚数据就保持着写一半的状态。解决方式是指定 rollbackForTransactional(rollbackFor Exception.class)第二种失效类内部方法自调用。Transactional是基于 AOP 代理实现的如果同类里的方法 A 调用方法 BB 有事务注解但这个调用走的是this引用没有经过代理对象事务注解根本不生效。这个坑很隐蔽代码看着没问题事务就是不动。第三种失效事务传播行为不当。比如外层方法没有事务内层方法设置了REQUIRED则会各自开启事务如果设置成NOT_SUPPORTED内层方法会挂起当前事务以无事务方式执行外层事务失败时内层的已经提交了无法回滚。排查事务是否生效最简单的方式是把 SQL 日志打开。在配置里加上spring.jpa.show-sqltrue或者配置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 的事务系统就不会给你惹大麻烦。