搞技术的人都知道,事务这玩意儿面试必问、写代码必用、出问题必背锅。但说实话,很多人对 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 出来的结果和你要更新时看到的数据,未必是同一个版本。
举个例子你就明白了:
- 事务 A:
SELECT * 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 一致性非锁定读的秘密:版本链和 ReadView
MVCC 的核心思路是:不通过加锁来阻塞读操作,而是通过数据多版本化,让读操作读取到一个一致的快照。
每一行记录在 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.transaction=DEBUG - 观察控制台输出的
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 秒,我会继续查它的 SQL:
SELECT * FROM sys.sys_session WHERE trx_id = '你查到的事务ID'\G;还可以配合SHOW ENGINE INNODB STATUS\G里的 LATEST DETECTED DEADLOCK 段落看死锁现场。
这套组合拳,帮你应对绝大多数线上事务问题。MySQL 事务这关,姿势摆正、原理吃透、工具用熟,基本就稳了。