尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

MySQL事务底层原理:从日志到MVCC的完整拆解

发布时间:2026/9/26 5:49:58

资讯中心
01
ARTICLE

MySQL事务底层原理:从日志到MVCC的完整拆解

MySQL事务底层原理:从日志到MVCC的完整拆解
写了好几年业务代码真正让我对 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这个机制叫 WALWrite-Ahead Logging。因为顺序写日志比随机写数据文件快得多所以事务提交的延迟被大幅降低。等 MySQL 崩溃重启时再根据 redo log 把已经提交但还没落盘的数据补写进去。一致性和隔离性更是一对纠缠不清的概念。一致性是最终目标隔离性是手段之一。数据库通过锁和 MVCC 让并发事务看起来像串行执行一样从而保证数据从一种一致状态流转到另一种一致状态。我在后面讲隔离级别的时候会具体展开这里先把这个框架立住ACID 不是四条孤立的性质而是相互支撑的整体其中日志机制和并发控制机制是两个核心支柱。2. 事务的底层账本redo log、undo log 与 binlog2.1 先理清三份日志的分工MySQL 里其实有三份和事务强相关的日志很多初学者会搞混undo log 管回滚redo log 管崩溃恢复binlog 管归档复制。前两份是 InnoDB 存储引擎层面的第三份是 MySQL 服务层面的它们记录的内容完全不同作用也完全不同。这里要先明确一个架构概念。MySQL 分两层上面是 Server 层负责连接管理、SQL 解析、优化和执行下面是存储引擎层InnoDB 是其中一种引擎。binlog 在 Server 层产生记录的是逻辑变更我把 id1 的行库存从 5 改成了 3redo 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 里每行数据都有两个隐藏列一个是事务 IDDB_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 想插入 amount200 的行时就会被阻塞直到事务 A 提交。这就在绝大多数场景下避免了幻读。但注意MVCC 的快照读仍然存在半一致性的边界情况只是实际业务里遇到得很少。串行化则更彻底所有读都是当前读全部加锁自然不存在幻读问题代价是并发度极低。生产环境很少用串行化如果业务真的对一致性要求苛刻到必须串行通常意味着设计上需要重新划分事务边界。3.4 怎么根据业务场景选隔离级别这里我给一个经验法则。多数互联网业务尤其是订单、库存这类强一致场景直接使用默认的可重复读就够了因为 MVCC 加 next-key lock 的组合让它既保证了读性能又防住了大多数并发问题。如果业务是报表统计、数据归档这类允许读到最近已提交数据的场景可以显式把隔离级别调成读已提交并发性能会更好因为间隙锁的开销没了。但改隔离级别前一定要想清楚代价。读已提交下同一个事务里两次统计查询可能得到不同结果如果下游对结果一致性有要求就得靠应用自己保证比如把两次查询放在同一个数据库连接里并配合锁。我接触过不少团队把隔离级别改成读已提交后原本依赖可重复读快照一致性的报表数据出现偏差最后又改回来。这里没有绝对的好与坏只有适不适合。4. 锁机制并发控制的最后一环4.1 行锁、间隙锁与临键锁MVCC 解决的是读多写少场景下的快照读性能问题但遇到写写冲突、当前读冲突时InnoDB 最终还是得靠锁。锁的粒度从细到粗分别是行锁、间隙锁、临键锁、表锁。行锁锁住索引记录本身间隙锁锁住索引记录之间的间隙临键锁是行锁加间隙锁的合体锁住的是记录加前面的间隙。为什么需要间隙锁本质是防止幻读在加锁读的场景下发生。不加间隙锁的话事务 A 用WHERE id 10 FOR UPDATE锁住了现有记录事务 B 插入一条 id11 的记录事务 A 再次范围查询时就会看到新行数据一致性被破坏。间隙锁的作用不是锁住具体某一行而是锁住这个位置不允许插入。间隙锁有个容易踩坑的副作用即使事务只更新一条不存在的记录也可能会锁住一个间隙阻塞其他事务插入。举个真实例子某表主键是自增 ID业务方执行DELETE FROM t WHERE id 9999id9999 不存在但 InnoDB 会在主键索引上对 9999 之前的间隙加锁如果此时有其他事务想插入 id 在间隙范围内的记录就会卡住。排查时线上偶发 insert 等待很多都是这个原因。4.2 加锁流程与死锁产生条件InnoDB 加锁遵循索引定位原则锁是加在索引记录上的。如果 SQL 走了二级索引InnoDB 会先锁二级索引记录再回表锁主键索引记录。这也解释了一个经典问题为什么大表更新没有走索引会导致全表锁住。因为没走索引时InnoDB 只能全表扫描判断哪些记录匹配扫描过程中每条记录都会加锁这实际上等于把整张表锁了个遍。死锁的产生需要四个条件同时满足互斥、占有并等待、不可剥夺、循环等待。InnoDB 有死锁检测机制事务一旦被检测为死锁受害者会立即回滚其中一个事务并抛出错误。实际业务里最常见的死锁场景是两个事务分别更新两条记录但更新顺序相反。事务一先更新 id1 再更新 id2事务二先更新 id2 再更新 id1两边各持有对方需要的锁就卡死了。规避手段是让所有事务按同一顺序访问资源。在代码层面可以在业务代码里对涉及的多行数据先排序再更新这样两个事务的加锁顺序就一致了死锁概率大幅下降。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 两阶段提交、TCCTry-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 事务原理本质上是建立一套数据在并发和故障面前如何自保的思维框架这套框架一旦建立以后再遇到奇怪的数据问题你至少知道该往哪个方向查。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。