深入理解MySQL事务:从ACID到MVCC,一文彻底搞懂

📅 2026/7/3 2:03:31
深入理解MySQL事务:从ACID到MVCC,一文彻底搞懂
一、事务的ACID四个字母背后的权衡事务有四个基本特性合称ACID。但很多人背熟了这四个字母却不理解它们之间的关系。原子性Atomicity事务是一个不可分割的工作单元要么全部执行要么全部不执行。比如转账操作扣账户A的钱和加账户B的钱必须同时成功或同时失败。一致性Consistency事务执行前后数据库从一个一致性状态变到另一个一致性状态。简单说事务执行的结果必须符合所有预定义的规则——约束、触发器、业务逻辑等。以转账为例转账前后两个账户的总金额应该保持不变。隔离性Isolation多个事务并发执行时一个事务的执行不能被其他事务干扰。这是事务最复杂的部分也是后面要重点展开的内容。持久性Durability一旦事务提交它对数据库的改变就是永久性的即使系统发生故障也不会丢失。这四个特性并非平起平坐。实际上原子性、隔离性、持久性都是为了实现一致性而服务的。一致性是最终目标其他三个是手段。二、隔离性并发控制的核心隔离性之所以复杂是因为我们要在数据一致性和系统并发性能之间做权衡。隔离得越严格数据越安全但并发性能越差隔离得越宽松并发性能越好但可能出现各种数据异常。2.1 三个数据异常要理解隔离级别先要明白隔离性要防范什么问题。有三个经典的数据异常脏读一个事务读取了另一个未提交事务修改的数据。举个例子事务A把账户余额从100改成200但还没提交。事务B读取到余额是200然后事务A回滚了余额回到100。事务B读到的200就是个脏数据。不可重复读一个事务内多次读取同一条记录每次读到的数据不一样。事务A第一次读取余额是100然后事务B修改余额为200并提交事务A再次读取时发现余额变成了200。同一个事务内同样的查询得到了不同的结果。幻读一个事务内多次执行同一个查询返回的记录条数不一样。事务A查询id10的用户返回了10条记录。事务B插入了一条id11的新用户并提交。事务A再次查询时返回了11条记录多出来的那条就像幻觉一样。2.2 四个隔离级别SQL标准定义了四个隔离级别每个级别允许出现的数据异常不同隔离级别脏读不可重复读幻读读未提交可能可能可能读已提交不可能可能可能可重复读不可能不可能特定场景可能串行化不可能不可能不可能读未提交几乎不加锁一个事务还没提交它做的修改就能被其他事务看到。这是最危险的级别实际生产中极少使用。读已提交事务只能读到其他事务已经提交的数据。这解决了脏读问题但不可重复读和幻读依然存在。这是Oracle、SQL Server等数据库的默认级别。可重复读MySQL InnoDB的默认级别。它保证了一个事务内多次读取同一条记录的结果是一致的解决了不可重复读并通过MVCC和间隙锁的组合在大部分场景下避免了幻读。串行化最高隔离级别所有事务串行执行完全不存在并发问题。但性能最差几乎不会在生产环境使用。2.3 MySQL的默认级别为什么是可重复读这是个有意思的问题。SQL标准的默认级别是读已提交但MySQL选择了可重复读。历史原因是MySQL的主从复制早期基于语句Statement格式时如果使用读已提交主库和从库的执行结果可能不一致。比如UPDATE user SET age18 WHERE age10在主库和从库执行时可能影响不同的行。使用可重复读可以保证主从数据一致。虽然现在MySQL已经支持行格式Row的二进制日志读已提交也不会导致主从不一致但可重复读作为默认级别已经沿用了下来。三、MVCC无锁的一致性读MVCCMulti-Version Concurrency Control多版本并发控制是InnoDB实现高性能事务的核心技术。它的核心思想是通过保存数据的多个历史版本实现读操作不阻塞写操作写操作也不阻塞读操作。3.1 隐藏的三列在InnoDB中每行记录除了用户定义的字段外还隐藏了几个系统字段DB_TRX_ID最近修改该行的事务IDDB_ROLL_PTR回滚指针指向该行的undo log用于获取历史版本DB_ROW_ID行ID如果没有主键时会使用每次修改一行数据时InnoDB不会直接覆盖旧数据而是通过undo log记录修改前的版本然后更新当前行的数据。这样就形成了一条版本链。3.2 Read View的奥秘当一个事务执行查询时InnoDB会生成一个Read View读视图它决定了当前事务能看到哪些数据版本。Read View的核心是一个事务ID列表trx_ids和几个边界值up_limit_id当前活跃事务中最小的事务IDlow_limit_id下一个将被分配的事务IDtrx_ids当前所有活跃事务的ID列表判断一条记录的某个版本是否可见核心规则是如果版本的事务ID up_limit_id说明该事务在Read View创建前已提交可见如果版本的事务ID low_limit_id说明该事务在Read View创建后开启不可见如果up_limit_id 版本事务ID low_limit_id需要判断该事务ID是否在trx_ids列表中在列表中说明该事务在Read View创建时仍活跃不可见需要沿着undo log找更早的版本不在列表中说明该事务已提交可见3.3 一个具体的例子假设初始数据id1, name张三该行的事务ID是99。事务A事务ID100BEGIN; SELECT * FROM user WHERE id1; -- 读取到张三事务B事务ID101BEGIN; UPDATE user SET name李四 WHERE id1; -- 修改为李四 COMMIT;事务A再次查询SELECT * FROM user WHERE id1; -- 仍然读取到张三事务A第二次查询时InnoDB会创建Read View。此时事务BID101已提交但因为在事务A的Read View中101 low_limit_id或仍在活跃列表中具体取决于生成时机这条记录的新版本李四对事务A不可见。事务A沿着undo log找到旧版本读取到张三。这就是可重复读的核心实现机制事务开始时生成的Read View在整个事务期间保持不变所以同一个查询始终读到相同的数据。3.4 快照读和当前读理解了MVCC就能区分两种不同的读取方式快照读普通SELECT语句读取的是事务开始时的快照版本不加锁。通过MVCC实现性能高。当前读SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE等语句读取的是数据的最新版本会加锁。当前读不走MVCC需要配合间隙锁来解决幻读。这个区分非常重要。很多人以为可重复读就是所有查询都永远读同一个数据但只有快照读才走MVCC当前读读到的是最新数据。四、间隙锁当前读的幻读防御MVCC解决了快照读的幻读问题但当前读特别是范围查询仍可能产生幻读。为此InnoDB引入了间隙锁。4.1 什么是间隙锁间隙锁锁定的是索引记录之间的间隙——不包含记录本身只包含两个值之间的区间。假设user表的id列有数据1、3、5、10。间隙锁可以锁定这些区间(-∞, 1)(1, 3)(3, 5)(5, 10)(10, ∞)4.2 临键锁在可重复读隔离级别下InnoDB默认使用的是临键锁Next-Key Lock它是记录锁和间隙锁的组合锁定范围是左开右闭区间。比如锁定id5实际上锁定的是(3, 5]即间隙(3, 5)被间隙锁锁定id5这条记录被记录锁锁定4.3 幻读是怎么被阻止的假设事务A执行SELECT * FROM user WHERE id 2 FOR UPDATE;表中数据id1, 3, 5, 10在可重复读级别下当前读会加临键锁扫描到3锁定(2, 3]扫描到5锁定(3, 5]扫描到10锁定(5, 10]扫描到末尾锁定(10, ∞)这些间隙被锁住后其他事务无法在(2, ∞)范围内插入新数据。事务B想插入id4或id7全部被阻塞。4.4 为什么说可重复读没有完全解决幻读这里有一个容易踩的坑也是面试官常问的考点。在可重复读隔离级别下快照读没有幻读问题由MVCC保证当前读通过间隙锁防止了幻读。但在特定场景下幻读仍然可能发生场景先快照读再当前读修改时间事务A事务BT1BEGIN;T2SELECT * FROM user WHERE id5; -- 快照读查不到数据T3INSERT INTO user VALUES (5, 新记录); COMMIT;T4UPDATE user SET name被改了 WHERE id5; -- 当前读成功修改T5SELECT * FROM user WHERE id5; -- 再次快照读竟然看到了这条记录T2时刻事务A的快照读看不到id5的记录因为事务A开启时这条数据还不存在。事务B在T3插入了id5并提交。T4时刻事务A执行UPDATE这是一个当前读它会读到最新的数据包括事务B刚插入的那条然后修改它。关键点来了UPDATE操作会将修改后的记录的trx_id改为事务A的ID。所以在T5时刻事务A再次执行快照读时会发现这条记录的版本号满足可见性条件——因为它的trx_id现在是事务A自己的ID。于是事务A看见了本不该存在的记录幻读发生了。所以准确的说法是在可重复读隔离级别下MVCC加间隙锁的组合在绝大部分场景下避免了幻读但并不能100%保证在所有边界条件下都不发生幻读。4.5 一个真实的业务场景假设你在做一个订单系统业务逻辑是这样的-- 检查用户今天是否已经下过单 SELECT * FROM orders WHERE user_id100 AND create_time 2024-01-01; -- 如果没有订单则创建新订单 INSERT INTO orders (user_id, amount) VALUES (100, 99.00);如果你的隔离级别是可重复读且使用了快照读普通SELECT这个逻辑在高并发下可能出问题两个事务同时执行SELECT都返回空然后各自插入了一条订单——用户一天下了两单违反了业务规则。解决方案有两个将SELECT改为SELECT ... FOR UPDATE当前读利用间隙锁阻止并发插入在数据库层面建立唯一索引UNIQUE KEY (user_id, date)让重复插入直接报错五、原子性一条UPDATE是原子的吗这是一个容易被忽视但很本质的问题。简单回答在单语句层面UPDATE是原子的但在事务层面需要显式控制。5.1 单语句的原子性MySQL保证单条语句是原子的。执行UPDATE account SET balance balance - 100 WHERE id 1时要么完全成功更新了一行要么完全失败一行都没更新或者遇到错误回滚这里有个小细节如果UPDATE匹配了10行更新到第5行时发生了错误前4行的修改会回滚不会出现改了4行第5行失败的状态。这就是单语句的原子性保证。5.2 为什么需要事务保证多语句原子性但在真实业务中一条SQL往往解决不了问题。扣余额和加积分往往是两条SQL必须保证它们要么都成功要么都失败。这就是事务的原子性。START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; UPDATE points SET amount amount 10 WHERE id 1; COMMIT;5.3 原子性的实现undo log原子性的底层实现依赖于undolog。每个修改操作都会记录对应的undo logINSERT操作对应一条DELETE undo logUPDATE操作对应一条将数据恢复到旧版本的undo logDELETE操作对应一条INSERT undo log如果事务需要回滚InnoDB会反向执行这些undo log将数据恢复到事务开始前的状态。如果事务提交这些undo log会被标记为可清理等待后台线程回收。undo log还承担了另一个重要职责为MVCC提供历史版本。这就是为什么事务提交后undo log不会立即删除——可能还有长事务需要读取旧版本数据。六、原子性在业务中的实际应用6.1 不适合脏读的场景理解事务的隔离性后我们就能理解为什么某些场景绝对不能容忍脏读。以余额扣减为例事务A正在执行扣款操作更新余额但未提交事务B读取了余额并判断是否充足。如果事务A最终回滚了事务B基于错误余额做出的判断就导致了业务异常——可能超卖可能扣错钱。银行转账是另一个经典场景。从一个账户扣钱并加到另一个账户如果允许脏读用户可能在转账过程中看到中间状态产生不必要的恐慌。6.2 带余额的分布式事务场景在微服务架构中事务的边界从单库扩展到了多个服务。假设一个下单流程涉及订单服务、库存服务、账户服务下单 → 扣库存库存服务→ 扣余额账户服务→ 创建订单订单服务这三个操作分布在三个不同的数据库中无法通过单个MySQL事务来保证原子性。这时就需要引入分布式事务方案比如TCCTry-Confirm-Cancel、Saga模式或两阶段提交。分布式事务的核心思想与单机事务类似——要么全成功要么全失败——但实现难度和性能开销都大得多。这也是为什么很多架构设计会尽量避免分布式事务比如通过最终一致性来替代强一致性。七、持久性提交了就一定不丢吗事务提交后数据修改被持久化到磁盘即使系统崩溃也不会丢失。这是持久性的定义。7.1 redo log持久性的基石InnoDB通过**redo log重做日志**来保证持久性。当修改数据时InnoDB会先写redo log再修改内存中的数据页最后在合适的时机将数据刷新到磁盘。Write-Ahead LoggingWAL日志先行即在数据写入磁盘之前修改的日志必须先写入磁盘。这是持久性的核心原则。如果在数据刷新到磁盘之前系统崩溃了重启后InnoDB会读取redo log重新执行那些尚未刷新到磁盘的修改确保数据不丢失。7.2 redo log的刷盘策略redo log本身也有缓冲区和刷盘策略。参数innodb_flush_log_at_trx_commit控制着事务提交时redo log的刷盘行为0每秒刷一次事务提交时不刷盘。性能最好但可能丢失1秒的数据1默认每次事务提交都刷盘。最安全但性能开销大2每次事务提交都写入操作系统缓存但不主动刷盘。MySQL崩溃不影响但操作系统崩溃可能丢失数据对于金融、支付等场景强烈建议使用默认值1。7.3 一个重要的权衡这里体现了一个经典的系统设计权衡性能优先innodb_flush_log_at_trx_commit2或0减少磁盘I/O提高吞吐量数据安全优先innodb_flush_log_at_trx_commit1每次提交都刷盘保证持久性没有绝对的对错取决于业务对数据丢失的容忍度。八、总结回顾全文我们从ACID四个特性出发一路深入到了MySQL事务的底层实现事务的本质将多个操作捆绑成一个不可分割的执行单元保证数据的一致性。隔离性通过MVCC实现无锁的快照读通过间隙锁保护当前读在可重复读级别下做到了性能与一致性的平衡。MVCC核心机制利用隐藏字段DB_TRX_ID、DB_ROLL_PTR和undo log形成版本链配合Read View实现一致性读是InnoDB高并发能力的基石。间隙锁可重复读级别下当前读防止幻读的关键武器也是并发性能的主要消耗者。原子性单语句天然原子多语句需要通过START TRANSACTIONCOMMIT/ROLLBACK显式控制底层依赖undo log实现回滚。持久性依赖redo log的WAL机制在性能和安全性之间提供可配置的权衡点。分布式事务当业务突破单库边界时单机事务不再适用需要引入分布式事务方案或最终一致性设计。最后关于如何选择隔离级别实践中的建议是金融、支付等对一致性要求极高的场景考虑串行化或使用可重复读恰当的悲观锁设计大多数互联网业务读已提交足够用业务逻辑如唯一索引处理幻读换取更高的并发性能核心交易链路使用可重复读但要深入理解间隙锁的行为避免死锁频繁发生理解这些机制不是为了在代码中炫技而是为了在出现数据异常、死锁、性能瓶颈时能够快速定位问题做出正确的决策。希望这篇文章能帮你建立对MySQL事务的系统认知。