1. 从一次库存扣减事故谈起:事务到底在保护什么
1.1 事故现场:代码里明明加了@Transactional,库存还是成了负数
上周帮一个创业团队排查线上库存问题,现象很典型:爆款商品卖着卖着,库存表里出现了负数。负责的同事把扣减方法翻出来,第一句话就是"我这里加了@Transactional,为什么还会超卖?"
方法大概长这样:
@Transactional public void deductStock(Long skuId, Integer count) { Stock stock = stockMapper.selectById(skuId); if (stock.getStock() >= count) { stockMapper.updateStock(skuId, stock.getStock() - count); } }这段代码的逻辑很直观:先查库存,判断够不够,够了就扣减。加了事务注解之后,理论上应该要么查出结果并更新,要么异常回滚。但并发压测一跑,两个请求同时进来,都查到库存还剩1件,都判断"1 >= 1"成立,然后都执行了set stock = stock - 1,最终库存变成0甚至负数。这不是事务没生效,而是事务根本管不住"先查后写"这种竞态。
我后来在文章底部分享过一个判断:事务是数据库对一批操作做的"要么全成、要么全散"的保证,但它不负责帮你把业务判断和写入变成一个原子操作。上面这段代码里,select和update虽然在一个事务里,可两个事务之间没有任何互斥。事务A查到1,事务B也能查到1,两个人都认为自己可以扣,最后就超卖了。
1.2 事务的本质:不是锁,更不是并发安全的通行证
很多人对事务的认知停留在"加了注解就安全了"这个层面,这是最危险的误解。可以打个比方:事务像一张工单,begin是开单,commit是交单,rollback是作废重填。工单系统保证的是"这张单子要么被完整执行,要么被作废",但它不保证"两个人不会同时填同一笔单"。
超卖问题真要解决,通常靠三种手段:一是更新语句加条件,比如update stock set stock = stock - 1 where sku_id = ? and stock >= 1,然后根据受影响行数判断是否扣减成功;二是用select ... for update给查询加锁;三是用乐观锁版本号。这些本质上都是并发控制手段,和事务是两个维度的东西。事务解决的是"失败后的回滚一致性",锁解决的是"并发下的相互干扰"。
这里面还有个容易被忽略的细节:InnoDB的普通select默认是快照读,不加任何锁。即使你的事务隔离级别是可重复读,快照读也只是给你一个一致的视图,不会锁住数据让别人不能改。只有for update、lock in share mode,以及update、delete、insert这些当前读,才会走锁机制。所以"查询"本身几乎不产生阻塞,这也让"先查后写"的竞态特别容易发生。
1.3 事务边界:从begin到commit,MySQL到底做了什么
理解事务失效之前,必须先把事务边界这件事捋清楚。在MySQL里,一个事务从START TRANSACTION开始,到COMMIT或ROLLBACK结束。这个过程中,InnoDB会给事务分配事务ID,记录undo日志,操作的数据页先改在内存的Buffer Pool里,提交时写redo log并刷盘。
Spring的@Transactional做的事情,本质上就是帮你把这段边界包在目标方法外面:方法进入前开启事务,方法正常结束就提交,方法抛出指定异常就回滚。但这里有三个细节,几乎决定了后面所有失效场景:
- 事务和数据库连接是绑定的。同一个事务里所有SQL必须走同一个连接,Spring通过ThreadLocal把连接绑定到当前线程。
- 一旦代码内部开了新线程,或者通过异步方式调用,连接就变了,事务自然被拆成多个。
- 注解只对Spring管理的Bean方法有效,自己
new出来的对象,方法上的注解根本不会被解析。
很多"事务失效"问题,追到根上都是边界问题,而不是MySQL本身的问题。
2. InnoDB的ACID落地方式:redo log、undo log与MVCC是怎么配合的
2.1 原子性靠的是undo log,不是"出错了倒回去重跑"
ACID里的原子性,在InnoDB中的实现载体是undo log。可以简单理解成:每次修改数据之前,InnoDB会把"修改前的样子"记录到undo log里。如果事务中途失败或者主动回滚,就根据undo里记录的反向操作把数据还原回去。
具体来说:
insert的undo记录主键信息,回滚时根据主键删除这条记录。delete的undo记录整行的完整数据,回滚时把这一行插回去。update的undo记录被修改列的旧值,回滚时把旧值恢复。
这个机制在MySQL 8.0里有一个明显改善:undo log被放到了独立的undo表空间里,并且支持自动truncate。早几年用5.7的时候,长事务跑完经常发现undo文件撑得很大,还得手动处理;8.0之后系统会在事务提交后自动回收不再需要的undo段,运维负担小了很多。
还有个值得说的点:事务回滚时并不是"把内存里的数据页全部倒回",而是借助版本链,把已经被修改过的数据行逐条恢复到undo里记录的状态。如果事务修改了很多行,回滚其实也是要消耗时间的。所以实践中尽量避免在同一个事务里做大量无关紧要的修改,回滚成本一点都不低。
2.2 一致性:数据库只负责约束,业务语义得自己定义
一致性(Consistency)经常被误认为"数据库保证数据不出错"。准确说,InnoDB能够保证的是:约束不被破坏,比如主键唯一、非空、外键等。但"这笔转账必须同时扣付款方、增加收款方"这种业务一致性,数据库根本不知道,它只知道这两条SQL在同一个事务里,要么都成,要么都回滚。
所以"事务保证一致性"这句话,正确的理解是:事务通过原子性、隔离性和持久性,给应用层提供了一个可以构建业务一致性的环境。你自己得先定义好什么是一致状态,再把相关的修改放进同一个事务边界里。如果你的代码把扣款和加款写在了两个方法里,并且两个方法各自开了事务,那数据库再强大也救不了你。
2.3 隔离性:MVCC和锁如何织成一张网
隔离性的实现比很多人想象的复杂。InnoDB用了两套机制协作:MVCC(多版本并发控制)负责让读不阻塞写、写不阻塞读;锁负责让真正的写操作互斥。
MVCC的核心是版本链。每一行数据除了业务字段,还隐藏着trx_id(最近修改它的事务ID)和roll_pointer(指向undo log里的旧版本)。当多个事务同时操作同一行时,这行数据在逻辑上就形成了一条由当前版本指向历史版本的链。读操作根据事务生成的一致性视图(ReadView)来判断哪些版本可见。
锁的部分,InnoDB有行锁、间隙锁、next-key lock、意向锁、共享锁、排他锁等。select for update加的是排他锁;普通update、delete也是排他锁;普通的select完全不加锁,靠MVCC读快照。这种设计的好处是读写互不阻塞,代价是你要清楚"查询看到的快照"和"当前实际数据"可能不一致。
隔离级别之所以有四档,本质就是控制"在不同事务之间,数据可见到什么程度"。这个我放在下一章展开。
2.4 持久性:redo log、刷盘策略与崩溃恢复的取舍
持久性依赖的是redo log。InnoDB的数据页先改在内存里,如果每次提交都把整个数据页刷到磁盘,性能会低到没法用。所以InnoDB采用WAL(Write-Ahead Logging)策略:先写redo log,再提交事务。崩溃恢复时,根据redo log把已经提交但还没刷入磁盘的修改重新应用一遍。
redo log刷盘策略是innodb_flush_log_at_trx_commit,三个值对应三种取舍:
| 参数值 | 行为 | 安全性 | 性能 |
|---|---|---|---|
| 0 | 事务提交时不写盘,由后台每秒刷一次 | 崩溃可能丢最近1秒数据 | 最高 |
| 1 | 每次事务提交都强制刷盘 | 最安全,基本不丢数据 | 相对最低 |
| 2 | 每次提交写入OS缓存,每秒刷盘 | 操作系统崩溃会丢数据,MySQL崩溃不丢 | 介于两者之间 |
生产环境主库建议用1,这是默认值。从库或者对数据丢失不敏感的场景可以适当放宽。MySQL 8.0.30版本之后,innodb_log_file_size被innodb_redo_log_capacity取代,默认100MB,并且支持自动调大。到2026年再看这个配置项,已经相当稳定,网上很多旧教程还在教你怎么手动设置多个超大redo文件,这些内容基本过时了。
崩溃恢复里还有一个两阶段提交:InnoDB的prepare阶段加上binlog写入,再进入commit阶段。这个机制保证了redo log和binlog的一致,也是主从复制不出乱子的关键。你在排查事务问题时如果看到奇怪的"XA"相关日志,别慌,这就是MySQL内部在协调redo和binlog,不是你的业务开了分布式事务。
3. 隔离级别不是配置项而是选择:脏读、不可重复读与幻读的边界
3.1 四种隔离级别到底屏蔽了哪些现象
隔离级别是面试必问,也是实际排查问题时最先要确认的基线。MySQL支持四种:读未提交、读已提交、可重复读、串行化。它们之间的差异,用一张表就能讲清楚:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 能读到其他事务未提交的数据 |
| READ COMMITTED | 不会 | 可能 | 可能 | 每次快照读都生成新的ReadView |
| REPEATABLE READ | 不会 | 不会 | MySQL中基本避免,但未完全消除 | 事务内首次快照读的ReadView被复用 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 所有读隐式加锁,并发极低 |
绝大多数人背得出这张表,但不知道背后的实现差异。以读已提交和可重复读为例:RC下,事务内每次普通select都会生成一个新的ReadView,所以同一个事务里查两次同一行,如果这期间另一个事务提交了修改,你看到的值可能不同——这就是不可重复读。RR下,事务执行第一条select时生成ReadView,之后整个事务复用它,所以同样的查询看到了始终一致的快照。
3.2 可重复读下仍有"幻读"争议
MySQL的RR级别对幻读做了很多工作,不是简单一句"不会幻读"能概括的。快照读场景下,RR确实不会出现幻读,因为你始终看的是同一个版本数据集。但当前读场景,比如select ... for update或者update ... where,情况就不同了。
举个例子:事务A执行select * from product where price > 100 for update,这时InnoDB不仅锁住符合条件的行,还会在索引范围上加上间隙锁,阻止其他事务插入新的price > 100的记录。这确实堵住了不少幻读路径。但如果事务A的第一步是普通select,不涉及锁,另一个事务B偷偷插入了一条符合条件的记录并提交,然后事务A再执行update product set ... where price > 100,这时update是当前读,可能看到并更新这条"本不该出现"的新数据。严格意义上,这依然算一种幻读。
所以面试时最稳妥的表述是:MySQL的RR级别在绝大多数场景下通过MVCC和next-key lock避免了幻读,但在混合快照读与当前读的复杂场景下,不能保证完全不存在。实际业务里,这种情况发生率不高,但排查诡异数据时要知道有这种可能。
3.3 为什么MySQL默认使用RR,以及生产环境怎么调
MySQL默认隔离级别是REPEATABLE READ,很多人不理解:Oracle默认是READ COMMITTED,为什么MySQL要反着来?重要原因是历史包袱:早期binlog格式以statement为主,也就是记录SQL语句本身。在RC级别下,某些语句在从库重放时可能产生与主库不一样的结果,RR配合间隙锁,能更好地保证statement复制的主从一致。
到了MySQL 5.7.7之后,binlog_format默认已经是ROW,也就是记录行的变更,RC级别在复制安全性上已经没有本质问题了。所以现在很多互联网团队会主动把隔离级别切到READ COMMITTED,目的是减少间隙锁带来的死锁和锁等待。RR下两个事务互相在对方持有间隙锁的范围内插入数据,非常容易形成死锁;而RC只锁住真实存在的行,死锁概率低很多。
修改方式分两层。数据库层面:
SET GLOBAL transaction_isolation = 'READ-COMMITTED'; SET SESSION transaction_isolation = 'READ-COMMITTED';应用层面,Spring可以在注解上单独指定:
@Transactional(isolation = Isolation.READ_COMMITTED) public void someMethod() { // ... }这里想提醒一点:假如你的业务依赖"同一个事务里两次查询结果必须一样",并且用的是RC,那这个前提就不成立了。切RC之前要想清楚业务是否有这种依赖,不要为了并发性能盲目改隔离级别。
4. Spring声明式事务的生效机制:代理、事务边界与自调用陷阱
4.1 @Transactional是怎么被AOP织入的
Spring的声明式事务,本质是AOP的一个环绕通知。Spring容器在启动时会扫描带@Transactional的Bean,为它们生成代理对象。你在调用Bean方法时,真正执行的是一个代理链路:先是事务拦截器,它负责创建连接、关闭自动提交、开启事务;然后执行你写的目标方法;最后根据方法是否抛出异常来决定提交还是回滚。
这里有个关键认知:事务的开始和结束,都不在你的方法体里,而是在代理层。
@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public void createOrder() { // 业务逻辑 } }Spring容器里注入的OrderService其实是一个代理对象,方法上那些逻辑被包装了。如果这个类没有被Spring管理,或者调用时绕过了代理,注解就是个摆设。
异常回滚的默认规则也容易踩坑:Spring默认只对RuntimeException和Error回滚,普通受检异常(比如Exception直接抛出)不会触发回滚。所以规范的写法是显式声明rollbackFor = Exception.class,或者确保业务异常都是运行时异常。见过太多生产事故,就是方法里抛了个受检异常,事务照样提交了,数据局部更新,排查半天才发现是rollback规则没写好。
4.2 自调用失效:为什么this.method()绕过了代理
这是线上最常见的事务失效原因,也是我讲课时每次都要强调的。看下面的代码:
@Service public class OrderService { public void createOrder() { // 这里使用this调用事务方法 this.deductStock(); } @Transactional public void deductStock() { // 扣库存逻辑 } }表面上看,deductStock上标了事务注解,但它真的没生效。原因是this.deductStock()里的this,指的是原始对象,不是Spring生成的那个代理对象。代理对象只在你从Spring容器里拿Bean时才会生效,内部this调用天然绕过了代理。你在方法内部调用自己类的另一个方法,本质是this.method(),代理完全不知道这件事发生了。
解决办法有三种:
- 把
deductStock放到另一个Service类里,通过注入的Bean调它,这样调用会经过代理。 - 在
createOrder上直接标注事务注解,由于createOrder本身就是通过代理执行的,整个方法体都在事务内。 - 使用
AopContext.currentProxy()拿到当前代理,再调用方法,但需要在启动类上显式开启@EnableAspectJAutoProxy(exposeProxy = true)。
我个人的习惯是优先用第一种和第二种,把事务边界放在最外层方法上,而不是依赖内部方法的注解。自调用不仅导致失效,还会让事务传播行为全部错乱。你本来指望内部方法新建一个事务挂起当前事务,结果它根本没走代理,也就不会开新事务。
4.3 那些让事务悄悄失效的写法清单
把网上各种失效案例整理一遍,排在第一位的永远是自调用,其次还有这些:
| 场景 | 原因 | 处理方式 |
|---|---|---|
| private方法加@Transactional | Spring/CGLIB无法代理private方法 | 改成public,通过外部Bean调用 |
| final方法或final类 | CGLIB无法继承final类和覆写final方法 | 去掉final修饰 |
| 方法内catch住异常 | 代理层没收到异常,无法回滚 | 抛出指定异常,或手动标记回滚 |
| 抛受检异常且未指定rollbackFor | 默认只回滚RuntimeException | 显式声明rollbackFor=Exception.class |
| new出来的对象调用事务方法 | 对象不是Spring管理的Bean | 改为注入方式使用 |
| @Async异步方法里的事务 | 线程变了,连接不复用,事务被拆开 | 在异步方法内部单独开启事务 |
| 多数据源且未指定事务管理器 | 事务管理器选错 | 注解上指定transactionManager |
其中"catch住异常"这个场景尤其隐蔽。很多业务为了日志可读性,会在方法内部做try-catch,然后只记录error日志,不往外抛。结果事务正常提交了,部分数据已经改掉,等发现时根本没法回滚。正确的做法是:如果确认需要事务回滚,catch到异常后要么直接throw,要么通过TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记。后者适合需要在回滚前做一些补偿操作的场景,比如记录错误流水再标记回滚。
说到多数据源,Spring里如果不指定transactionManager,默认会找一个实现PlatformTransactionManager的Bean。数据源A的事务管理器存在时没问题,一旦接了第二个数据源,就有可能出现选错管理器、事务根本没覆盖到另一个库的情况。多数据源场景下最好明确写出来:
@Transactional(transactionManager = "orderTransactionManager", rollbackFor = Exception.class)5. 2026年排查实录:事务失效的完整核对链路
5.1 从应用端到MySQL端的核对顺序
遇到"事务没生效"的第一反应不应该改代码,而是先确认事实。我的排查顺序如下:
第一个动作:打开Spring的JDBC事务日志。
logging.level.org.springframework.jdbc=DEBUG logging.level.org.springframework.transaction=DEBUG重启后调用一次出问题的方法,日志里会明确打印出Getting connection、Creating new transaction、Committing transaction这些字样。如果压根没有这些日志,说明代理都没进,问题在应用层;如果有日志但提交和回滚不符合预期,继续往下看。
第二个动作:确认MySQL端真实执行顺序。开通用日志是最直接的:
SET GLOBAL general_log = ON; SET GLOBAL log_output = 'TABLE';然后查询mysql.general_log表,看事务方法执行期间是否出现BEGIN、update、COMMIT。如果SQL执行序列是分散的,每条语句都自动提交,说明事务压根没包住。排查完记得关掉general_log,它只能在测试环境开,生产环境会带来很大的性能开销。
第三个动作:确认是不是同一个连接。在事务方法里临时打印connection.getAutoCommit(),或者通过日志观察连接ID,能快速判断线程切换是否把连接换掉了。
第四个动作:检查代码层面的代理情况。如果Bean的类型是com.sun.proxy.$Proxy,说明走了JDK动态代理;如果是EnhancerBySpringCGLIB,说明是CGLIB代理。如果当前对象不是代理,那事务拦截器自然没机会执行。
5.2 三个典型失效案例复盘
案例一:循环调用内部事务方法。同事写了一个批量同步方法,循环里调this.syncOne(id),而syncOne上有@Transactional。结果是每一条记录都单独自动提交,中间某条失败后,前面成功的不会回滚。这不是事务注解没用,是调用路径没走代理。解法是把整个循环放进入一个事务方法,或者改用TransactionTemplate包住循环体,按业务需要决定是否分段提交。
案例二:异步线程里的事务。主方法有事务注解,内部调了一个@Async方法,异步方法里写了流水表。主方法后面抛异常回滚,但异步方法里已经提交的流水回不去了。这里的问题本质上是事务边界跨了线程,Spring的事务管理器靠ThreadLocal维护连接,新线程里拿不到原连接,自然各管各的。如果业务要求主流程失败时流水也必须消失,就不该用异步写入,应当用本地消息表配合定时任务,或者把两张表的写入放在同一个事务方法里。
案例三:catch吞异常导致部分提交。扣积分服务和发券服务在一个事务里,扣积分成功,发券时第三方接口抛了异常,代码里catch住只打了日志,事务提交,积分扣了券没发。这是典型的默认回滚规则没触发。遇到这种场景,要么重新抛出业务异常并声明rollbackFor,要么在catch里手动setRollbackOnly。这里我还建议在catch里记录一下标记,方便事后对账,但前提是事务不能再提交。
5.3 兜底方案:编程式事务与TransactionTemplate
声明式事务失效场景多、排查链路长,有时候我更推荐直接用编程式事务做兜底。Spring的TransactionTemplate用起来很简单:
@Service public class OrderService { private final TransactionTemplate transactionTemplate; public void createOrder() { transactionTemplate.execute(status -> { try { // 业务逻辑,比如扣库存、写订单 } catch (Exception e) { status.setRollbackOnly(); throw e; } return null; }); } }编程式事务的好处是:事务边界极其明确,不会被自调用掩盖;可以为不同的代码块配置不同的事务策略,灵活性高;所有异常处理都在你自己手里,不会出现Spring默认回滚规则带来的意外。缺点是代码侵入性比注解强,事务逻辑散落在业务代码里。
回到文章开头那个超卖事故。如果只解决事务回滚问题,其实库存照样会负数。真正的修复应该是用条件更新:
UPDATE inventory SET stock = stock - 1 WHERE sku_id = ? AND stock >= 1;然后根据affectedRows判断是否扣减成功,失败就抛出业务异常让事务回滚。这样一来,并发下的相互覆盖问题被SQL条件本身挡住了,事务只负责在失败时回滚订单记录,各司其职,问题才真正闭环。
每次排查完这种问题,我都会跟团队强调同一句话:事务不是保险丝,它是一道边界。先把业务边界画清楚,再去挑事务工具,顺序反了,2026年的MySQL和Spring也救不了你。