搞技术的人都知道事务这玩意儿面试必问、写代码必用、出问题必背锅。但说实话很多人对 MySQL 事务的理解停留在“ACID 四个字母”和“隔离级别有四种”这种背诵层面。线上真出问题了——比如报错Transaction rolled back because it has been marked as rollback-only或者数据怎么查都对不上账——一下子就慌了。这篇内容我想从实战角度把 MySQL 事务从头到尾捋一遍包括 InnoDB 底层到底怎么实现的、四种隔离级别各自有什么坑、Spring 事务注解在真实项目里怎么埋雷以及分布式场景下订单和库存保持一致性的常见方案。不管你是刚学 MySQL 的初学者还是写过几年业务代码想补底层原理的开发这篇文章应该都能给到你一些别处看不到的操作细节和排查思路。1. 事务到底在做什么——从一条 UPDATE 语句的真实生命周期说起很多人把事务当成一个抽象概念觉得“开启事务、执行 SQL、提交或回滚”就完事了。但实际在 InnoDB 引擎里一条 UPDATE 语句的生命周期远比想象中复杂。想真正理解事务我建议先把这条链路上的每个环节看清楚。1.1 事务的本质让多个操作变成一个“不可分割”的操作你把事务理解成一个装了一批 SQL 的盒子。盒子里要么全部成功要么全部不生效。比如转账场景A 扣 100 块B 加 100 块这两条 UPDATE 必须放在同一个事务里。如果 A 扣款成功、B 加钱失败事务回滚A 的钱也得还回去。这是事务最朴素、也最核心的价值。但“怎么保证”这一点才是 MySQL 真正花力气的地方。InnoDB 通过锁、日志、版本链等一系列机制协同工作任何一个环节出问题数据一致性就会被破坏。1.2 一条 UPDATE 在事务里经历了什么假如你执行START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; COMMIT;这条语句背后发生的事情是连接会话进入事务状态InnoDB 为该事务分配一个事务 ID。UPDATE 语句首先定位id 1这条记录拿到该记录当前的行锁如果别的会话已经持有锁则进入等待队列。把这行记录的旧值写入 undo log便于后续回滚。在内存中修改这行记录的值并把变更写入 redo log buffer。如果有二级索引需要更新同步处理索引维护。执行 COMMIT 时InnoDB 将 redo log buffer 刷入 redo log 文件根据innodb_flush_log_at_trx_commit参数决定刷盘策略然后释放锁。你会发现回滚能力靠 undo log崩溃恢复靠 redo log并发隔离靠锁和 MVCC。事务的 ACID 不是一句空话它是一系列底层组件协作的结果。后面我会单独展开 redo 和 undo 的细节。1.3 查看当前事务状态的两个实用命令排查事务问题最常用的命令你先记住这两个-- 查看当前运行中的事务 SELECT * FROM information_schema.innodb_trx\G; -- 查看锁等待情况 SELECT * FROM sys.innodb_lock_waits\G;innodb_trx表能告诉你谁在跑长事务、事务已经跑多久了、当前状态是 RUNNING 还是 LOCK WAIT。有一次我排查线上死锁靠的就是先从这里找到长时间未提交的事务再顺藤摸瓜找到对应的代码。提示线上数据库如果有事务长期处于 RUNNING 状态但什么都没干大概率是代码里开了事务忘记提交。这种“空事务”不占锁但会导致 undo log 不断膨胀后续查询的版本链越拉越长性能会逐渐劣化。2. 隔离级别不是选着玩的——读已提交和可重复读的真实差异隔离级别是事务最容易被忽视、又最容易出问题的部分。MySQL 默认是 REPEATABLE READ可重复读Oracle 默认是 READ COMMITTED读已提交。很多从 Oracle 转过来的同学第一周就会被 MySQL 的默认隔离级别坑一次。2.1 四种隔离级别能防住什么直接看这张表这是我对隔离级别最简练的总结。隔离级别脏读不可重复读幻读实现方式READ UNCOMMITTED可能可能可能直接读最新数据版本READ COMMITTED不会可能可能每次 SELECT 生成新快照REPEATABLE READ不会不会基本不会InnoDB 通过间隙锁解决事务内首次 SELECT 生成快照SERIALIZABLE不会不会不会全部加锁并发极低先说脏读。READ UNCOMMITTED 级别下你能读到别人事务里还没提交的数据。这个数据可能下一秒就被回滚了而你已经在它的基础上做了业务判断。我在生产环境几乎没见过谁敢用这个级别它只适合某些对数据准确性要求极低的统计场景。再说不可重复读。同一个事务里第一次 SELECT 和第二次 SELECT 读到的同一行数据不一样。READ COMMITTED 允许这种情况REPEATABLE READ 不允许。幻读是最容易混淆的。它指的是同一个事务里两次范围查询返回的行数不一样比如第一次查到 10 行第二次查到 11 行多出来的那行是另一个事务刚插入的。MySQL 的 InnoDB 在 REPEATABLE READ 下通过间隙锁Gap Lock和 next-key lock 把这个情况基本堵住了这也是 MySQL 的 REPEATABLE READ 比其他数据库的可重复读“更强”的原因。2.2 MySQL 默认的可重复读为什么经常让你踩坑工作中很多人会遇到这种现象一个事务里先查询某条记录不存在然后尝试插入结果插入失败或插入后查询结果对不上。这就是快照读和当前读的差异导致的。在 REPEATABLE READ 下普通的 SELECT 是快照读读的是事务开始时的数据版本。而 INSERT、UPDATE、DELETE 属于当前读读的是最新已提交的数据。这意味着你 SELECT 出来的结果和你要更新时看到的数据未必是同一个版本。举个例子你就明白了事务 ASELECT * FROM order WHERE id 100;查出不存在。事务 B插入了一条id 100的记录并提交。事务 A执行INSERT INTO order (id, ...) VALUES (100, ...);结果报主键冲突。这就是典型的事务内查询结果“误导”了后续写入操作的场景。你明明查过不存在插的时候却冲突了。如果读的是 READ COMMITTED每次 SELECT 都是最新已提交数据就不会这么诡异。这也是很多团队偏要把隔离级别改成 READ COMMITTED 的原因。2.3 什么时候需要调整隔离级别如果业务对同一行数据在同一个事务内多次读取的一致性有强要求比如账单明细统计、库存结算保持 REPEATABLE READ 是合适的。但如果你的业务主要是写入多、查询少而且 SELECT 结果对后续写入没有强依赖READ COMMITTED 能帮你减少不少间隙锁和死锁问题。调整方式SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;注意GLOBAL 级别只影响新建立的连接已有连接不受影响。SESSION 级别只影响当前会话。生产环境调整要谨慎最好先查一下当前连接的隔离级别SELECT transaction_isolation;我在实际项目里见过因为全局隔离级别改动导致部分旧连接还跑在 REPEATABLE READ、新连接在 READ COMMITTED 的混乱情况所以如果你要调整一定要确保应用重启或连接池重置连接。3. 深入 InnoDB 底层MVCC、redo log、undo log 怎么协同工作搞懂这一节你才敢说自己“深入理解”了 MySQL 事务。很多面试题问到 MVCC 就结束了但工程上怎么用 MVCC 做一致性快照、redo 和 undo 到底怎么配合这些才是实战价值所在。3.1 MVCC 一致性非锁定读的秘密版本链和 ReadViewMVCC 的核心思路是不通过加锁来阻塞读操作而是通过数据多版本化让读操作读取到一个一致的快照。每一行记录在 InnoDB 里都隐藏着几个字段DB_TRX_ID最近一次修改该行的事务 ID和DB_ROLL_PTR指向 undo log 的指针。多个历史版本通过这个指针串联成一条版本链。当一个 SELECT 想要读取数据时InnoDB 会生成一个 ReadView里面记录了当前活跃事务的 ID 列表。判断某个版本是否可见的规则大概是如果版本的事务 ID 比 ReadView 里最小活跃事务 ID 还小说明该版本已提交可见。如果版本的事务 ID 在活跃事务列表里说明该事务还没提交不可见。如果版本的事务 ID 比最大活跃事务 ID 还大说明该版本是未来事务不可见。不可见就沿着版本链往下找直到找到可见的版本。这就是“快照读”的实现原理。READ COMMITTED 每次 SELECT 都会生成新的 ReadView所以能看到其他事务新提交的数据。REPEATABLE READ 只在事务内第一次 SELECT 时生成 ReadView后面一直沿用所以看到的数据始终一致。3.2 redo log 和 undo log一个管恢复一个管回滚这两类日志经常被混淆。简单说redo log重做日志记录“数据页做了什么修改”用于崩溃恢复。即使内存中的数据页还没刷到磁盘MySQL 宕机了重启后也能通过 redo log 把变更重放回去保证已提交事务不丢失。undo log回滚日志记录“数据的旧版本”用于事务回滚和 MVCC 版本链读取。事务回滚时通过 undo log 把数据恢复成修改前的样子。关键参数是innodb_flush_log_at_trx_commit参数值行为安全性性能0每秒刷一次磁盘可能丢最多 1 秒已提交事务最快1每次提交都刷盘不丢数据最慢2写入操作系统缓存每秒刷盘操作系统崩溃可能丢数据折中默认值是 1这也是保证 ACID 中 D持久性的关键。如果你想在吞吐量和安全性之间做权衡可以评估换成 2。但换成 0 或 2 之前你得先想清楚你的业务能接受崩溃时丢多少数据有一次客户把参数调成 0觉得性能提升明显结果机房断电后丢了半分钟订单数据业务上非常被动。3.3 长事务为什么是性能杀手长事务对系统最大的伤害不是它自己慢而是它会拖累所有访问同一行记录的其他事务。因为长事务持有锁的时间长其他事务不断堆积在锁等待队列里。长事务对应的 undo log 不能立即清理版本链越来越长后续查询需要遍历的版本越来越多。如果长事务还涉及 DDL可能触发 online DDL 的锁等待超时。所以在线上我处理过最典型的问题就是一个统计报表的查询事务不小心执行了一个多小时导致核心订单表的写入请求全部堆积。定位到之后直接 KILL 掉那个事务系统立刻恢复。建议你在运维侧加一个监控定期检查information_schema.innodb_trx里超过 30 秒的事务并设置告警。4. 亲身踩坑Transaction rolled back because it has been marked as rollback-only说实话这个报错我早期遇到时一脸懵因为错误信息根本不提是哪个事务、哪行代码导致的。它的完整形式通常是这样org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only这是搜索热词里出现频率很高的问题我把它单拎出来讲透。4.1 这个报错到底是怎么发生的这个报错几乎只出现在 Spring 事务嵌套场景中。Spring 的默认事务传播级别是 REQUIRED也就是如果外层已经有事务内层方法就加入同一个事务而不是新建一个事务。问题就出在“加入同一个事务”上。内层方法如果抛出了运行时异常会把整个事务标记为 rollback-only。但调用方如果捕获了这个异常没有继续抛出外层代码继续执行最后正常返回并调用提交。结果呢Spring 发现事务已被标记为 rollback-only不管你代码层面怎么正常返回它都不能提交于是抛出 UnexpectedRollbackException。我刚工作时写过一个很典型的错误代码Transactional public void createOrder(OrderDTO dto) { try { inventoryService.deduct(dto.getSkuId(), dto.getCount()); } catch (Exception e) { log.error(扣库存失败但订单继续创建, e); } orderMapper.insert(dto); }inventoryService.deduct方法自己加了Transactional扣减库存时抛了异常导致事务被标记为 rollback-only但我在外层把它吞了。订单继续插入最后方法返回时UnexpectedRollbackException 直接抛出来订单也没创建成功。这种问题最坑的地方在于内层报错你明明捕获了日志也打了可外层事务还是整体回滚看起来就像是“神秘力量”把数据卷回去了。4.2 排查链路怎么定位到根因我建议你这样排查按照链路一步步来先把完整异常栈拉出来找最内层的rollback-only标记时间点。看事务边界检查 Service 方法的Transactional配置以及内部调用的其他 Service 方法的事务传播行为。看异常处理逻辑内层异常是否被try-catch捕获捕获后是继续抛出还是吞掉日志里有没有异常被吞掉的痕迹。打开 Spring 的事务日志定位具体是哪个事务被标记在application.yml里配置logging.level.org.springframework.transactionDEBUG观察控制台输出的Participating transaction failed - marking existing transaction as rollback-only我在一次排查中就是靠 DEBUG 日志发现内层一个校验方法抛出IllegalArgumentException后被标记 rollback-only外层又把异常吞了继续写数据。定位到具体方法之后问题一下就清晰了。4.3 修复方式的取舍不同业务场景有不同的修法我列三种常用方案内层事务改成独立事务给内层方法加上Transactional(propagation Propagation.REQUIRES_NEW)。这样内层方法会挂起外层事务另开一个事务内层回滚不会标记外层事务。适合“扣库存失败但主流程还要继续”的场景。外层不吞异常直接向上抛让统一异常处理器兜底。这样事务回滚是“预期内”的不会出现 UnexpectedRollbackException。方法拆分把“必须保证一致”的逻辑和“允许独立失败”的逻辑拆成不同事务边界避免嵌套。我特别提醒一下 REQUIRES_NEW 的代价内层事务是全新事务它提交了就不会因为外层回滚而回滚。如果你期望“外层回滚时内层也跟着回滚”那 REQUIRES_NEW 会带来数据不一致的隐患。所以这个方案要慎用必须确认“独立提交”是你真正想要的行为。4.4 面试里经常追问的一个变体面试官常爱问内层方法加Transactional之后同类内部调用是不是真的开启了事务答案是不是。Spring 事务基于 AOP 代理只有通过代理对象调用方法时事务注解才会生效。同一个类里的方法直接this.method()调用绕过了代理层事务注解根本不生效。解决办法是把被调方法放到另一个 Service 类里或者注入自身代理。这个问题在面试和实践中都是高频坑。5. 本地事务之外订单扣库存的分布式事务一致性怎么做项目一旦拆分服务单库事务就管不到全局了。最常见的场景就是订单服务和库存服务独立部署、独立数据库你下单时要同时“建订单”和“扣库存”两边必须一致。热词里的“订单与库存分布式事务”说的就是这类问题。5.1 为什么分布式事务不能走普通事务因为没有一个数据库能同时控制两个服务的提交。如果先扣库存再建订单可能库存扣成功订单创建失败如果先建订单再扣库存可能订单建好库存却不够。本地事务解决不了这种跨资源的一致性问题。5.2 简化版方案本地消息表这是比较经典的方案理解成本低适合绝大多数中小心规模业务。核心思路是把“扣库存”这个操作变成一张本地消息表里的记录先保证消息落库再异步去扣库存。流程大概是订单服务和库存服务各自有数据库。订单服务在自己的数据库里开启事务插入订单记录、插入一条“待扣库存”的消息到消息表一起提交。有一个异步任务或者消息中间件的消费者读取这条消息调用库存服务扣减库存。扣减成功更新消息状态为“已完成”失败则重试直到成功或人工介入。这个方案的优点是不需要引入额外的分布式事务框架订单数据和消息数据在同一事务里天然保持了本地一致性。缺点是需要自己处理和重试、幂等方案相对土一些。5.3 更标准化的框架方案Seata AT 和消息事务如果你所在团队有专门的中间件团队可以考虑 Seata AT 模式。AT 模式的核心是全局事务管理器协调多个资源的事务提交/回滚对业务代码侵入很小。它会记录每个分支事务的 undo log全局回滚时反向补偿。另外现在很多消息中间件支持事务消息比如 RocketMQ 的事务消息。它通过“半消息”机制保证本地事务和消息发送的原子性先发一条半消息再执行本地事务本地事务提交成功才把半消息变成可消费消息。这个方案在订单创建后发消息触发下游业务时非常好用。我个人的建议是如果系统还在早期流量不大本地消息表足够如果团队已经上了微服务全家桶有足够的运维能力再考虑 Seata 或事务消息。分布式事务没有银弹每一个方案都在一致性、可用性、性能之间做取舍。6. 事务和连接池、JDBC 的那些坑注解下的事务边界可能和你以为的不一样最后这部分聊点工程细节。很多代码里加了Transactional就以为万事大吉实际上事务边界和连接的管理有一堆容易被忽略的细节。6.1 JDBC 事务的基础逻辑在没有 Spring 的世界里JDBC 事务长这样Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 执行多条 SQL conn.commit(); } catch (Exception e) { conn.rollback(); } finally { conn.close(); }关键点有两处setAutoCommit(false)之后这个连接上多条 SQL 才属于同一事务conn.close()并不是真的关闭连接而是把连接还给连接池。所以事务的本质是某条连接上的“非自动提交状态多组 SQL提交或回滚”。一旦你用的连接和另一个请求用的连接不是同一条它们之间的事务彼此毫无关系。6.2 连接池为什么会影响事务Spring 的Transactional会通过 AOP 在方法执行前从连接池拿一个连接、绑定到当前线程方法结束后提交或回滚并释放连接。看起来没问题但如果连接池配置不当或者事务内执行了大量 SQL连接被占用时间过长连接池被耗尽其他请求拿不到连接系统就雪崩了。所以我建议你关注连接池里的几个参数initialSize初始连接数。maxActive最大活跃连接数。maxWait获取连接的超时时间太长会让请求阻塞太短会让高峰时期大量请求失败。minEvictableIdleTimeMillis空闲连接回收时间。有一次我负责的系统高峰期频繁出现“Cannot get JDBC connection”异常查下来发现是某个定时任务在事务里循环几千次执行 SQL一条连接占用了一个多小时直接把连接池打满。后来我把事务里的批量处理拆成一批批提交问题立刻消失。6.3 事务注解自调用和异步导致的边界混乱除了前面提到的同类型内部调用导致事务不生效异步方法里的事务也是一个大坑。比如Transactional public void order() { asyncService.sendMessage(); orderMapper.insert(dto); }如果sendMessage是异步的它在新线程里执行新线程没有继承当前事务的连接绑定所以即使加上Transactional也是开启新事务和外部事务没有关系。你和面试官聊到这里时可以多说一句Spring 事务是基于 ThreadLocal 实现的异步线程拿不到原线程的事务上下文。提示如果你需要事务结束后再发消息可以用TransactionSynchronizationManager.registerSynchronization()注册回调在afterCommit里发。这个细节极少有人注意到但在处理“事务提交后再通知下游”这类需求时非常有用。最后分享一个自用的排查事务超时小脚本每次排查事务问题我习惯用下面这段 SQL 快速定位SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds, trx_rows_locked, trx_rows_modified FROM information_schema.innodb_trx WHERE trx_state RUNNING ORDER BY running_seconds DESC;如果发现有事务运行超过 30 秒我会继续查它的 SQLSELECT * FROM sys.sys_session WHERE trx_id 你查到的事务ID\G;还可以配合SHOW ENGINE INNODB STATUS\G里的 LATEST DETECTED DEADLOCK 段落看死锁现场。这套组合拳帮你应对绝大多数线上事务问题。MySQL 事务这关姿势摆正、原理吃透、工具用熟基本就稳了。