拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL事务核心原理与实战:InnoDB、Spring注解、分布式一致性

MySQL事务核心原理与实战:InnoDB、Spring注解、分布式一致性

搞技术的人都知道,事务这玩意儿面试必问、写代码必用、出问题必背锅。但说实话,很多人对 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;

这条语句背后发生的事情是:

  1. 连接会话进入事务状态,InnoDB 为该事务分配一个事务 ID。
  2. UPDATE 语句首先定位id = 1这条记录,拿到该记录当前的行锁(如果别的会话已经持有锁,则进入等待队列)。
  3. 把这行记录的旧值写入 undo log,便于后续回滚。
  4. 在内存中修改这行记录的值,并把变更写入 redo log buffer。
  5. 如果有二级索引需要更新,同步处理索引维护。
  6. 执行 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 排查链路:怎么定位到根因

我建议你这样排查,按照链路一步步来:

  1. 先把完整异常栈拉出来,找最内层的rollback-only标记时间点。
  2. 看事务边界:检查 Service 方法的@Transactional配置,以及内部调用的其他 Service 方法的事务传播行为。
  3. 看异常处理逻辑:内层异常是否被try-catch捕获?捕获后是继续抛出还是吞掉?日志里有没有异常被吞掉的痕迹。
  4. 打开 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 简化版方案:本地消息表

这是比较经典的方案,理解成本低,适合绝大多数中小心规模业务。核心思路是:把“扣库存”这个操作变成一张本地消息表里的记录,先保证消息落库,再异步去扣库存。

流程大概是:

  1. 订单服务和库存服务各自有数据库。
  2. 订单服务在自己的数据库里开启事务:插入订单记录、插入一条“待扣库存”的消息到消息表,一起提交。
  3. 有一个异步任务或者消息中间件的消费者读取这条消息,调用库存服务扣减库存。
  4. 扣减成功,更新消息状态为“已完成”;失败则重试,直到成功或人工介入。

这个方案的优点是不需要引入额外的分布式事务框架,订单数据和消息数据在同一事务里,天然保持了本地一致性。缺点是需要自己处理和重试、幂等,方案相对土一些。

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 事务这关,姿势摆正、原理吃透、工具用熟,基本就稳了。

返回列表