资讯动态

MySQL事务机制全解析:隔离级别、MVCC、锁与Spring实践

发布时间:2026/9/9 15:17:06 来源:尧图企业网站定制
很多后端开发对事务的认知停留在“能回滚”这一层写了Transactional事务就是安全的语句执行失败数据就会自动恢复。但真正把项目跑起来后问题往往比想象中复杂——数据明明提交了另一个线程却读不到两个订单同时扣库存直接死锁加了大事务数据库连接池被拖垮。这些现象背后其实都指向同一个事实MySQL 事务不是“能不能回滚”这么简单它是一套由隔离级别、MVCC、锁、日志共同组成的并发控制体系。这篇文章会从一个日常业务场景出发把 MySQL 事务从概念到原理、再到真实验证和排错方式完整过一遍。文章尽量不用学院派的定义堆砌而是用开发中真正会遇到的情况来解释ACID 各自靠什么保证四种隔离级别怎么选Transactional有哪些“看起来正常但实际不生效”的坑事务死锁和长事务出现后怎么定位单机事务之外分布式事务又该怎么理解。内容有点长建议先收藏再读。如果你正在准备面试或者写业务代码时经常遇到数据一致性问题这篇文章应该能帮你把事务这条线彻底理顺。1. 这篇文章真正要解决的问题先看一个非常常见的业务场景用户下单系统需要完成“扣减库存 创建订单 写入操作流水”三个动作。在单体应用和单库环境下最简单可靠的做法就是把这三个动作放进同一个事务里要么全部成功要么全部失败。这个需求看起来直白但落到 MySQL 上会引出一连串问题第一个动作刚扣完库存第二个动作还没执行这时候如果有另一个请求来查库存它应该看到旧值还是新值如果第三个动作失败库存和订单都必须回退MySQL 靠什么记住“之前是什么样”两个用户同时抢最后一件商品都执行了扣库存数据库怎么防止两个人都扣成功两个事务互相持有对方需要的锁MySQL 会怎么处理那么问题来了单体单库的老方案搬到微服务架构、分库分表之后为什么不灵了这些问题的答案基本都藏在 MySQL 事务的隔离级别、锁机制、MVCC 和日志体系里。普通的 CRUD 开发可能不会天天写底层原理但只要遇到并发量稍微上来、数据偶尔对不上这种情况对事务机制的理解深度就直接决定了排查速度。这篇文章要解决的核心问题只有一个把 MySQL 事务从“SQL 里的 BEGIN/COMMIT”提升到“一套完整的并发控制机制”来理解。读完你应该能回答ACID 分别靠什么支撑四种隔离级别各自解决什么问题事务和锁、MVCC 是什么关系真实项目中事务应该怎么写、怎么排查、怎么避免烂尾。2. 事务的四大特性ACID 到底在说什么先回到最基础的概念。事务是一组数据库操作的逻辑单元这些操作要么全部执行成功要么全部不执行。传统教材把事务的要求总结为 ACID 四个特性但这四个特性并不是并列的四条规则它们之间有明确的层次关系。2.1 原子性Atomicity操作要么全做要么全不做原子性保证一个事务内的多条语句是一个不可分割的整体。转账是一个最经典的例子A 账户扣 100B 账户加 100这两条 UPDATE 必须同时成功或同时失败。如果只执行了第一条就报错B 账户永远不会收到这 100 元账就平不了。MySQL 实现原子性的核心机制是undo log回滚日志。事务执行过程中先把操作前的旧数据记录到 undo log如果事务中途失败或者主动执行ROLLBACKMySQL 就根据 undo log 里的旧值把数据恢复回去。可以这样理解undo log 就像是数据库的“后悔药”无论执行了多少条语句只要还没提交都能按原路径退回。2.2 一致性Consistency数据从不合法状态到不合法状态的转变一致性是一个更容易被误解的概念。它不是说“事务执行完后数据一定正确”而是说事务只能把数据库从一个一致状态带到另一个一致状态。所谓一致状态包括约束条件、级联规则、业务规则等比如账户余额不能为负、订单状态不能从“已支付”直接跳到“已取消”。很多教材把一致性当作一个独立的特性但实际上它更像是一个目标原子性、隔离性和持久性都是为了达成一致性而服务的手段。没有原子性转账中途失败会导致余额凭空消失没有隔离性并发事务互相读到中间数据约束校验就会被绕过没有持久性事务提交后数据丢失一致性也无从谈起。2.3 隔离性Isolation并发事务之间互不干扰隔离性解决的是并发场景下的可见性问题。当多个事务同时操作同一批数据时每个事务应该感知不到其他事务的中间状态。这里的“应该”是有弹性空间的说数据库提供了不同级别的隔离强度从“完全不隔离”到“完全串行”性能和一致性各有取舍。MySQL 里隔离性由两个机制共同保障一个是锁控制不同事务对同一数据的写冲突另一个是MVCC多版本并发控制通过快照读让读操作不阻塞写操作从而提升并发性能。这块是本文的核心内容后面单独展开。2.4 持久性Durability提交后的数据不能丢持久性保证事务一旦提交即使数据库崩溃、机器断电数据也不会丢失。MySQL 依赖redo log重做日志实现这一点。当事务提交时InnoDB 并不会立刻把修改后的数据页刷到磁盘而是先把 redo log 写入磁盘。等到系统突然崩溃时内存里的数据页丢失也没关系下次启动时可以通过 redo log 把未刷盘的重做记录重放一遍恢复已提交事务的数据。注意区分undo log和redo log的职责undo log 负责回滚未完成的事务redo log 负责恢复已经提交但尚未落盘的数据。一个管“撤销”一个管“重做”这两者共同构成了 InnoDB 崩溃恢复的基础。3. 隔离级别的本质并发问题与四种隔离级别隔离性不是只有“开”和“关”两种状态它是一把可以调节的旋钮。SQL 标准定义了三种并发场景下的异常现象并提供了四种隔离级别来控制这些异常。3.1 三个并发问题脏读Dirty Read事务 A 更新了一行数据但还没提交事务 B 读取到了这条未提交的数据。如果事务 A 之后回滚事务 B 就相当于读到了根本不存在的“假数据”。这在业务上往往很致命比如 B 根据未提交的库存做出下单判断。不可重复读Non-Repeatable Read事务 A 内两次读取同一行数据第一次读到值 x第二次却读到值 y。原因在于两次读取之间另一个事务提交了对这行数据的修改。注意这里强调的是“同一行数据内容不一致”。幻读Phantom Read事务 A 内两次执行同一条查询语句第一次返回 10 行第二次却返回 11 行。多出来的那行是另一个事务刚刚插入的就像“幻觉”一样出现。不可重复读针对“行内容变化”幻读针对“行数变化”。这三个问题听起来有点像很多新手容易混淆。简单记忆方式是脏读读的是未提交数据不可重复读是同一条数据前后不一致幻读是同一范围的行数前后不一致。3.2 四种隔离级别及对比隔离级别脏读不可重复读幻读说明READ UNCOMMITTED可能可能可能几乎不使用读未提交数据READ COMMITTED不会可能可能Oracle 默认解决脏读REPEATABLE READ不会不会InnoDB 下基本避免MySQL 默认级别SERIALIZABLE不会不会不会串行执行并发性能最低这里有个关键点SQL 标准认为REPEATABLE READ不能避免幻读但 InnoDB 通过next-key lock临键锁在绝大多数场景下解决了幻读问题。所以在 MySQL 里“可重复读不会出现幻读”基本成立但要理解它依赖的是锁机制而不是单纯的 MVCC。这属于一个考点级别的问题后面讲锁的时候会解释。3.3 为什么 MySQL 默认选择 REPEATABLE READMySQL 的默认隔离级别是REPEATABLE READ这和 Oracle 默认的READ COMMITTED不同。原因可以追溯到 MySQL 主从复制时代。早期 MySQL 使用基于 statement 的 binlog 复制即把执行的 SQL 语句同步到从库重放。如果主库使用READ COMMITTED级别容易出现主库和从库数据不一致的情况。为了兼容这个历史约束MySQL 选择把默认级别设为REPEATABLE READ。从 MySQL 8.0 开始基于 row 的 binlog 格式成为默认已经基本消除了这个隐患你可以在配置文件中显式调整隔离级别transaction-isolationREAD-COMMITTED但在没有明确理由时建议保留默认的REPEATABLE READ。只要理解了它的并发机制绝大多数业务场景都不会有问题。4. 深入到机制层MVCC、undo log 与锁隔离级别解决的是“应该看到什么”MVCC 和锁解决的是“用什么方式做到看到该看到的”。这一节是 MySQL 事务最核心的机制也是面试中最常考的部分。4.1 快照读与当前读在理解 MVCC 之前必须先分清两种读取方式快照读Snapshot Read普通SELECT语句不加任何锁。它读取的是记录在某个时间点上的历史版本不会阻塞别的事务的写操作。当前读Current ReadSELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT操作读取的是记录最新已提交的版本并且会加锁。举个实际例子事务 A 执行SELECT * FROM account WHERE id 1;这是一次快照读它看到的是事务开始时的数据快照。如果同时有另一个事务正在修改 id1 这行数据快照读并不会等待锁而是直接读历史版本。这就让“读”和“写”在绝大部分场景下不再互相阻塞极大地提升了并发性能。4.2 MVCC如何实现多版本快照MVCC 的全称是 Multi-Version Concurrency Control多版本并发控制。InnoDB 的表结构里每一行记录除了业务字段之外还隐藏着几个关键列其中最重要的是trx_id最近一次修改这行记录的事务 ID。roll_pointer指向 undo log 中该记录上一个版本的指针。修改一行记录时InnoDB 不会直接覆盖旧数据而是先把旧版本写入 undo log然后生成新版本并通过 roll_pointer 把新旧版本串成一条版本链。当快照读发生时事务通过一个名为ReadView的结构来判断版本链上哪个版本对当前事务可见。用通俗的话说MVCC 提供了一套规则让每个事务在“我可以看到别人未提交的数据吗”“我可以看到别人已经提交但比我晚提交的数据吗”这些问题上有一致答案。READ COMMITTED每次 SELECT 都重新生成 ReadView所以两次 SELECT 可能看到不同的已提交版本REPEATABLE READ只在事务第一次 SELECT 时生成 ReadView 并复用所以事务内后续的普通 SELECT 都基于同一个快照天然避免了不可重复读。4.3 锁机制与事务释放锁的时机MVCC 解决了“读”的并发问题但写操作之间必须互斥否则两个事务同时改一行数据后写覆盖先写数据就会错乱。InnoDB 的锁按照粒度可以分为行锁Record Lock只锁住索引上的某一条记录。间隙锁Gap Lock锁住索引记录之间的间隙防止其他事务在这个间隙插入数据。临键锁Next-Key Lock行锁与间隙锁的组合既锁住记录也锁住记录前面的间隙。临键锁正是 MySQL 在REPEATABLE READ下解决幻读的关键。假设一个事务执行SELECT * FROM order WHERE amount 100 FOR UPDATE;InnoDB 不仅会锁住满足条件的已有记录还会锁住这些记录之间的间隙让其他事务无法在这个范围内插入新的满足条件的记录。于是再次执行同一条查询时行数不会增加幻读被阻止了。关于锁的释放时机有一个经常被问到的点MySQL 的锁是什么时候释放的答案不是执行完一条语句就释放而是要等到事务提交COMMIT或回滚ROLLBACK时才统一释放。这就是所谓的两阶段锁协议锁在事务执行过程中逐步获得在事务结束时统一释放。由于锁的持有时间通常覆盖了整个事务生命周期所以事务里任何一条语句执行过慢都有可能让其他事务长时间等待。实际项目中很多死锁和锁等待超时不是因为 SQL 本身写错了而是因为事务里混入了慢查询、远程调用或者大量计算导致锁被持有过久。4.4 当前读、间隙锁与隔离级别的组合到这里可以整理一个对应的结论READ COMMITTED下InnoDB 主要使用行锁间隙锁基本不启用因此无法在底层阻止幻读。但它的锁范围小并发性相对更高。REPEATABLE READ下InnoDB 默认启用临键锁范围查询时锁住的不只是已存在的记录还包括范围内的间隙所以能阻止幻读。这也解释了为什么 MySQL 可以在默认级别下声称解决了幻读。如果你修改成READ COMMITTED就必须在业务层面额外考虑幻读带来的影响。5. 事务的代码实践从 SQL 到 Spring 注解理解机制之后回到实际开发。事务在代码层面最常见的三种写法是原生 SQL、JDBC 编程式事务、Spring 声明式事务。5.1 原生 SQL 事务在 MySQL 命令行或客户端工具中执行事务语法很简单START TRANSACTION; UPDATE account SET balance balance - 100 WHERE user_id 1 AND balance 100; UPDATE account SET balance balance 100 WHERE user_id 2; INSERT INTO transfer_log (from_user, to_user, amount, create_time) VALUES (1, 2, 100, NOW()); COMMIT;如果中间某一步失败可以手动回滚ROLLBACK;有几个细节需要注意DDL 语句如CREATE TABLE、ALTER TABLE在 MySQL 中会隐式提交当前事务所以不要在事务中间执行 DDL。只有 InnoDB 引擎支持事务MyISAM 引擎即使写了START TRANSACTION也不会有真正的事务效果。START TRANSACTION只是开启事务真正让本次事务生效的引擎能力来自 InnoDB 的 undo log 和锁机制。5.2 JDBC 编程式事务在 Java 原生 JDBC 中事务需要手动控制连接import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; public class TransferService { private final javax.sql.DataSource dataSource; public TransferService(javax.sql.DataSource dataSource) { this.dataSource dataSource; } public void transfer(int fromUserId, int toUserId, double amount) throws Exception { Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); try (PreparedStatement ps1 conn.prepareStatement( UPDATE account SET balance balance - ? WHERE user_id ? AND balance ?)) { ps1.setDouble(1, amount); ps1.setInt(2, fromUserId); ps1.setDouble(3, amount); int rows ps1.executeUpdate(); if (rows 0) { throw new RuntimeException(余额不足); } } try (PreparedStatement ps2 conn.prepareStatement( UPDATE account SET balance balance ? WHERE user_id ?)) { ps2.setDouble(1, amount); ps2.setInt(2, toUserId); ps2.executeUpdate(); } conn.commit(); } catch (SQLException | RuntimeException e) { if (conn ! null) { conn.rollback(); } throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } } }这段代码演示了编程式事务的基本套路关闭自动提交业务操作完成后统一 commit出现异常时 rollback。实际项目中直接使用原生 JDBC 的越来越少了但这个底层逻辑值得理解因为所有框架的事务抽象最终都指向这个过程。5.3 Spring Transactional 声明式事务现代 Spring Boot 项目最常用的是Transactional注解。它用 AOP 在方法调用前后自动打开、提交或回滚事务让开发人员不必手写事务管理代码。典型的一个下单扣库存例子// 文件路径src/main/java/com/example/order/service/OrderService.java Service public class OrderService { private final OrderMapper orderMapper; private final StockMapper stockMapper; public OrderService(OrderMapper orderMapper, StockMapper stockMapper) { this.orderMapper orderMapper; this.stockMapper stockMapper; } Transactional(rollbackFor Exception.class) public void createOrder(Long userId, Long skuId, Integer count) { // 1. 扣减库存 int rows stockMapper.deduct(skuId, count); if (rows 0) { throw new BizException(库存不足); } // 2. 创建订单 Order order new Order(); order.setUserId(userId); order.setSkuId(skuId); order.setCount(count); orderMapper.insert(order); // 3. 写入订单操作流水 orderLogMapper.insertLog(userId, skuId, CREATE); } }这里最值得强调的是rollbackFor Exception.class。Spring 默认只对RuntimeException和Error回滚对于受检异常checked exception不会回滚。也就是说如果业务代码捕获了异常但没有重新抛出或者方法声明throws Exception却没有指定rollbackFor事务很可能不会回滚数据处于一种“部分更新”的中间状态。Transactional注解还可以指定传播行为和隔离级别Transactional( rollbackFor Exception.class, isolation Isolation.REPEATABLE_READ, propagation Propagation.REQUIRED ) public void createOrder(Long userId, Long skuId, Integer count) { // ... }传播行为propagation解决的是事务方法嵌套调用时如何分配事务。REQUIRED表示如果当前没有事务就新建一个如果有事务就加入当前事务REQUIRES_NEW表示无论如何都新开一个独立事务常用于操作日志记录等场景。默认值是REQUIRED绝大多数业务推荐保持默认随意使用REQUIRES_NEW会破坏业务整体的事务一致性。5.4 事务不生效的经典坑只看Transactional的表面用法很容易误以为只要加了注解事务就自动生效。实际项目里事务不生效最常见的有以下几种场景6. 事务常见问题与排查方法问题现象可能原因排查方式解决方案Transactional加了不生效同类内部自调用没有经过 Spring AOP 代理在方法内调用同类其他事务方法时检查是否走代理打印当前对象 class 确认把事务方法拆分到另一个 Bean或通过AopContext.currentProxy()调代理尽量不要自调用事务抛异常后没有回滚方法内部 try-catch 吞掉了异常或受检异常没配rollbackFor检查 catch 块是否重新抛出运行时异常查看异常日志不要吞异常显式配置rollbackFor Exception.class事务方法不执行任何 SQL却报NotSupportedException当前方法被声明为Transactional(NOT_SUPPORTED)且执行了事务性操作检查注解传播行为配置调整传播行为出现死锁Deadlock found多个事务加锁顺序不一致循环等待查看错误日志中的死锁详情分析涉及的表和索引用SHOW ENGINE INNODB STATUS查看最近一次死锁统一加锁顺序保证更新条件能走索引缩小锁范围必要时用锁等待超时参数控制锁等待超时Lock wait timeout exceeded某个事务持锁时间过长通常是因为事务里执行了慢查询、远程调用或长事务通过performance_schema或information_schema.innodb_trx查看未提交事务找出持锁事务的 SQL缩短事务执行时间拆分大事务事务内不做远程调用长事务拖垮数据库事务执行时间太长持有大量锁导致 undo log 膨胀、连接池耗尽查询information_schema.innodb_trx中trx_started时间很长的记录结合 slow log 定位慢 SQL拆小事务控制事务内操作量适当调整max_execution_time数据库明明提交了业务却查不到最新值应用使用了快照读长连接中的事务或连接池复用导致隔离级别下的旧快照查看当前会话隔离级别确认查询是否处于未提交事务中注意连接池中连接的隔离级别配置业务上区分需要当前读的场景MyISAM 表不支持事务表引擎错误查询SHOW TABLE STATUS LIKE table_name确认 engine改为 InnoDB这里要特别提醒一个排查原则遇到事务问题第一步不是改代码而是确认“事务到底有没有开启、锁到底持有多久”。在 MySQL 中可以直接执行SELECT * FROM information_schema.innodb_trx\G;这个视图可以查看当前所有 InnoDB 事务包括事务开始时间、正在执行的 SQL、锁等待情况。配合SHOW ENGINE INNODB STATUS\G;可以查看最近一次死锁的详细信息。这两条命令是排查事务相关问题的“第一现场”比盲目加日志高效得多。7. 事务最佳实践与工程建议结合前面的机制和踩坑经验可以梳理出几条在工程中比较稳妥的事务使用建议。7.1 让事务尽可能短事务内每多执行一条语句锁被持有的时间就多一分其他事务等待的概率也随之增大。一个典型错误是在事务里执行耗时的批量计算或报表查询。相反应该在事务开始前把数据准备好事务内只做必要的更新、插入和删除操作。7.2 事务内绝对不要做远程调用在实际项目中通过Transactional方法里进行 HTTP 调用、RPC 调用或消息发送是一个非常隐蔽的性能杀手。远程调用往往耗时几十毫秒甚至几百毫秒如果这段等待发生在事务里等于让数据库锁白白占用了同样长的时间。一旦远程服务出现抖动很容易引发数据库端的锁等待雪崩。7.3 更新操作要命中索引行锁和间隙锁都建立在索引之上。如果UPDATE语句的WHERE条件没有使用索引InnoDB 就需要扫描尽可能多的记录来确定影响范围锁的范围会扩大甚至可能导致性能严重下滑。实际项目中每个更新条件都应该经过EXPLAIN分析确认命中索引后再放行。7.4 合理选择隔离级别大多数业务场景下默认的REPEATABLE READ足够可靠。如果系统对并发性能要求较高并且你明确理解幻读不会造成问题可以调整为READ COMMITTED。但要避免在业务代码中混用不同的隔离级别否则排查数据问题时会出现大量混乱。7.5 建立长事务和大事务的监控生产环境里需要建立对长事务的主动监控。可以通过定时扫描information_schema.innodb_trx把执行时间超过阈值的 SQL 记录下来并推送到告警体系。及时拆分和处理大事务比等到数据库连接耗尽后的救火要有效得多。7.6 事务是共享资源不是业务代码的遮羞布一个值得反复强调的观点是事务不能解决所有一致性问题它只能在单机、单数据库的限制下提供一致性保障。业务代码中的幂等、重试、补偿、对账同样属于数据一致性的护城河不能因为有了事务就完全依赖数据库。8. 分布式事务事务的边界扩展理解了单机事务之后很容易产生一个疑问既然 MySQL 事务这么好用微服务架构下的事务为什么不能直接套用原因在于分布式环境下一个业务操作往往横跨多个服务、多个数据库。比如“订单与库存”就是典型场景订单服务写入订单数据库存服务扣减库存二者可能分属不同的数据库MySQL 本地事务的能力完全无法覆盖。这就是分布式事务要解决的问题。分布式事务的核心矛盾来自 CAP 理论在分布式环境下一致性、可用性、分区容错性三者无法同时完美满足任何分布式事务方案本质上都是在一致性和可用性之间做取舍。常见的方案包括两阶段提交2PC引入协调者先投票再提交。性能较差协调者存在单点风险。TCCTry-Confirm-Cancel对业务侵入性较强需要为每个提供 try、confirm、cancel 三个接口。基于消息的最终一致性本地消息表加 MQ核心思路是把业务操作和消息发送放在同一个本地事务里然后通过可靠消息队列推进后续步骤最终达到数据一致。Seata AT 模式Seata 是阿里开源的一款分布式事务框架AT 模式在业务改动很小的前提下通过全局事务协调器和 undo log 来实现两阶段提交是目前 Java 生态里比较主流的接入方式。关于 Seata AT 模式的原理简单概括是各个分支事务在本地执行 SQL并生成 undo log事务协调器记录全局事务状态提交时先做全局提交确认再做分支提交如果某个分支失败则根据 undo log 反向补偿。它的优点是业务代码改动小缺点是需要额外维护事务协调器并且在大并发下性能和一致性仍然有损耗。很多团队在引入分布式事务之前会先问一个问题这个场景真的需要强一致吗如果业务可以通过状态机、幂等、重试和定期对账来达到最终一致那么引入分布式事务框架可能反而增加了系统复杂度和风险。比如订单状态从“创建”到“已支付”可以先改订单状态再异步通知其他服务处理库存扣减配合对账补偿。只有当业务流程确实需要在同一时刻保证多个服务的数据强一致时才值得考虑 Seata 或其他分布式事务方案。9. 怎样在真实项目中用好事务到这里MySQL 事务的体系已经比较完整了。最后不打算做长篇总结只想分享一个在实际项目里比较实用的检查清单。每当你准备写一个带事务功能的方法时可以依次问自己三个问题。第一个问题这个事务是不是真的需要开启很多查询方法不需要加事务加了事务反而让数据库维持长连接快照、增加无意义的开销。第二个问题事务里的操作是否都足够快如果方法里有远程调用、循环更新、批量插入就应该先优化拆分。批量操作非常大的场景可以考虑分批提交而不是放进单个大事务。第三个问题如果事务失败业务上有没有补偿或对账手段事务只能保证“回滚”不能保证“业务永远正确”。下单后如果支付环节失败订单状态如何流转消息发送失败后如何重新投递这些都需要在事务之外的业务逻辑里兜底。回答完这三个问题再动手写代码事务踩坑的概率会小很多。MySQL 事务这套知识网上讲解很多但最有效的掌握方式还是在自己的项目中实践一遍故意制造一次死锁查一查SHOW ENGINE INNODB STATUS的输出开一个长事务观察它对其他连接的影响。把这些现象亲手验证过一次事务对你来说就不再只是面试题而是真正能帮你定位问题的工具。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价