从@Transactional到MySQL隔离级别:事务失效排查指南

发布时间:2026/10/3 4:42:58
从@Transactional到MySQL隔离级别:事务失效排查指南 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到commitMySQL到底做了什么理解事务失效之前必须先把事务边界这件事捋清楚。在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采用WALWrite-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不会可能可能每次快照读都生成新的ReadViewREPEATABLE 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方法加TransactionalSpring/CGLIB无法代理private方法改成public通过外部Bean调用final方法或final类CGLIB无法继承final类和覆写final方法去掉final修饰方法内catch住异常代理层没收到异常无法回滚抛出指定异常或手动标记回滚抛受检异常且未指定rollbackFor默认只回滚RuntimeException显式声明rollbackForException.classnew出来的对象调用事务方法对象不是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.jdbcDEBUG logging.level.org.springframework.transactionDEBUG重启后调用一次出问题的方法日志里会明确打印出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也救不了你。