深入Spring事务:隔离级别、传播机制与事务失效排查实战

深入Spring事务:隔离级别、传播机制与事务失效排查实战 先声明一句这篇不是拿着概念给你念一遍而是把我过去几年在实际项目里和Spring事务打交道时踩过的坑、理清的思路、能用上的代码全部捋一遍。如果你正准备做订单、库存、支付、对账这类强一致性的业务或者正在准备大厂面试这篇文章应该能帮你把“事务隔离级别”和“事务传播机制”这块拼图彻底拼完整。开发的都知道Spring事务用起来很简单无非一个Transactional注解但很多人就是在这上面翻车事务没生效、隔离级别配了没效果、内层事务异常结果外层也回滚了、明明提交了但锁还被占住。要搞明白这些问题光记住定义不够得从底层来看这两个机制到底在干什么。1. 事务的“地基”为什么先讲ACID和后端并发控制1.1 ACID到底约束了什么事务的基石是四个特性原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。很多人背得滚瓜烂熟但真要解释清楚“为什么有了原子性还不够”还是会卡壳。我习惯用一个转账例子把四个特性串起来A账户给B账户转1000元这个操作在数据库里至少拆成两条SQL——A账户扣1000B账户加1000。原子性保证的是这两条SQL要么都成功要么都失败不存在只扣不加的情况。持久性保证的是事务一旦提交即使数据库宕机数据也不会丢这块靠的是重做日志。一致性在事务模型里有点抽象它指的是事务执行前后数据库的完整性约束不被破坏。比如账户余额不能为负数、库存不能超卖、总账必须相等。数据库底层其实不负责你的业务一致性它只提供机制约束、触发器、事务业务一致性要靠程序员在SQL和代码里保证。而隔离性恰恰是这四个特性里最容易出问题的。因为并发情况下多个事务同时在跑如果完全不隔离A事务读到了B事务没提交的数据那账就算错了。1.2 数据库并发控制的两条路线说到隔离性就绕不开数据库并发控制的实现方式。业界主要两条路线悲观锁和MVCC。悲观锁的思路很直接你读或写这条数据之前先把它锁住别的操作只能等着。SELECT ... FOR UPDATE就是典型的悲观锁它适合并发冲突激烈的场景比如扣减库存。MVCC多版本并发控制是另一条路它不在读操作上互相阻塞而是通过保存数据的多个历史版本让读操作去读“快照”从而实现读写不互斥。这也是MySQL InnoDB在性能上能扛住高并发读的原因。Spring事务本身不实现这些并发控制它只是把事务边界和配置翻译成底层数据库的行为。所以理解隔离级别本质上是在理解“数据库愿意容忍哪些并发异常”。2. 事务隔离级别四个档位与三类异常2.1 三类典型的并发异常先讲三个异常这是判断隔离级别的基础也是面试官最爱连环问的点。脏读事务A修改了一条数据但还没提交事务B就读到了修改后的值。如果事务A回滚了事务B前面读到的就是一个数据库中从未真实存在过的“脏数据”。这种情况只出现在读未提交级别危害最大。不可重复读事务A先读了一次某行记录事务B在这期间修改并提交了这行记录事务A再读一次发现值变了。重点在于同一行数据两次读到的结果不一致。幻读事务A按某个条件查出一批数据事务B插入了一条满足条件的新记录并提交事务A再用同样的条件查询发现多了一行“凭空出现”的记录。重点在于记录数量变了。之前有同行问“不可重复读和幻读怎么区分”答案很朴素一个是改UPDATE一个是增INSERT对应行数和内容的变化。2.2 四种隔离级别怎么选SQL标准定义了四个隔离级别从宽松到严格依次是读未提交READ UNCOMMITTED允许读取未提交数据脏读、不可重复读、幻读都可能发生。读已提交READ COMMITTED只能读已提交数据可以避免脏读但不可重复读和幻读仍可能发生。可重复读REPEATABLE READ同一事务内多次读同一数据结果一致避免脏读和不可重复读但理论上仍可能幻读。串行化SERIALIZABLE事务之间完全串行执行所有异常都被杜绝但并发性能最低。这里必须点名MySQL的“特例”MySQL默认隔离级别是可重复读。可按照SQL标准可重复读级别下幻读问题并没有被解决但InnoDB存储引擎通过间隙锁Gap Lock和MVCC在绝大多数场景下把幻读也解决了。Oracle和PostgreSQL默认用的是读已提交原因在于可重复读对某些复杂查询场景不够灵活读已提交能在性能和一致性之间取得更好的平衡。2.3 MySQL里的“快照读”和“当前读”聊到可重复读必须说清楚“快照读”和“当前读”。理解了这个很多关于事务的怪问题都会迎刃而解。快照读就是我们平时执行的普通SELECT语句。在可重复读隔离级别下事务第一次快照读时生成一个ReadView后续读都基于这个快照。不管别的事务提交了什么你看到的就是事务开始时的那个“历史世界”。这也是MySQL可重复读能防住幻读的核心手段。当前读则不一样SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT操作都会读当前已提交的最新数据并对涉及的行加锁。为了防幻读InnoDB在可重复读下还会对扫描范围加上间隙锁锁住一个区间保证别的事务无法插入新记录。这也引出一个重要结论如果你在可重复读级别下先SELECT出来一批数据再在代码里判断数量、然后INSERT新记录判断逻辑可能基于旧快照但写入瞬间另一个事务可能已经插入并通过了当前读加锁你得依赖唯一索引或锁来兜底。3. Spring事务隔离级别的配置姿势3.1 Transactional注解怎么控制隔离级别Spring里通过Transactional的isolation属性指定隔离级别Transactional(isolation Isolation.READ_COMMITTED) public void updateOrderAmount(Long orderId, BigDecimal amount) { // 业务逻辑 }Isolation枚举对应关系如下Spring枚举数据库实现DEFAULT使用底层数据库默认隔离级别READ_UNCOMMITTED读未提交READ_COMMITTED读已提交REPEATABLE_READ可重复读SERIALIZABLE串行化单独设置isolation Isolation.DEFAULT是最常见的因为MySQL默认可重复读Oracle默认读已提交大多数场景下交给数据库默认值就够。需要自己要改级别时建议优先考虑读已提交。尤其在高并发统计、报表、多步查询场景里可重复读的快照虽然稳定但如果事务时间较长会导致undo log不能及时清理MVCC版本链变得很长访问性能反而下降。读已提交每次读拿最新快照版本链短空间回收更及时。3.2 实操演示配置隔离级别后的效果差异拿一个线上真实场景来演示结算系统需要对订单金额做二次校验流程是先读金额做一系列计算再读一次金额。在可重复读级别下两次读到的金额一定相同哪怕中间订单金额被另一个已提交事务修改这里计算结果依然基于旧值。这在“读取期间数据必须保持一致”的场景下是特性但在“需要实时感知最新数据”的场景下可能就是安全漏洞。要模拟不可重复读把事务隔离级别降到读已提交然后开两个会话-- 事务A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT amount FROM t_order WHERE order_id 1001; -- 此时事务B修改金额为999并提交 -- 事务A再次查询 SELECT amount FROM t_order WHERE order_id 1001;第二个SELECT会看到999这就是不可重复读。Spring层面配置Isolation.READ_COMMITTED后Transactional会把这个会话级设置下发给数据库连接效果与手写SQL一致。有一个实操心得值得单独说隔离级别的配置粒度是“事务”不是“数据库配置”。同一个连接池里的连接在每次获取时都会根据事务注解重置隔离级别所以你不用担心一个事务设置了读未提交会影响别的线程。4. 事务传播机制七种行为逐一拆解隔离级别解决的是“一个事务内部多次读怎么写”传播机制解决的是“多个方法之间怎么共享事务边界”。这是两个维度千万别混淆。4.1 REQUIRED默认的“同生共死”REQUIRED是Spring默认的传播行为如果当前没有事务就新建一个事务如果当前已有事务就加入当前事务。绝大多数业务用这个就够了。Service public class OrderService { Transactional public void createOrder(OrderDO order) { orderMapper.insert(order); inventoryService.deduct(order.getProductId(), order.getQuantity()); } } Service public class InventoryService { Transactional(propagation Propagation.REQUIRED) public void deduct(Long productId, Integer quantity) { inventoryMapper.deduct(productId, quantity); } }createOrder开启事务后deduct方法检测到已经存在事务就选择加入。两个方法在同一个物理事务里任何地方抛异常整个业务都会回滚。这也是下单和扣库存场景里最标准的写法。这里有个细节容易忽略加入同一个事务意味着两个方法使用的是同一个数据库连接锁也是同一个事务持有的。如果deduct执行的SQL耗时较长那订单表所在的连接也一直被占用连接池和锁的占用时间会比较长。4.2 REQUIRES_NEW独立事务的“保护舱”REQUIRES_NEW的作用是挂起当前事务然后新开一个完全独立的事务。新事务提交或回滚完全不影响外部事务。这个机制最常见的应用场景是写业务日志和操作记录。比如订单创建失败要回滚但失败原因必须记录下来。如果日志写入在同一个事务里回滚会把日志一起回滚等于什么都没留下。我用REQUIRES_NEW把日志写入隔离到独立事务里外层怎么回滚日志都能保存。Transactional public void handleOrder() { try { // 核心业务可能抛出异常 } catch (Exception e) { operationLogService.saveLog(orderId, e.getMessage()); throw e; } } Service public class OperationLogService { Transactional(propagation Propagation.REQUIRES_NEW) public void saveLog(Long orderId, String message) { logMapper.insert(...); } }需要留意的是REQUIRES_NEW会短暂挂起外层事务如果外层事务已经持有行锁内层事务去操作同一行数据就会有锁等待甚至死锁。我之前在一个定时任务里给大量订单做状态流转内部调了一个REQUIRES_NEW的方法去更新同一条订单记录结果偶发性死锁。后来把更新操作收敛到内层事务里一次完成才彻底解决。4.3 NESTED嵌套事务与SavepointNESTED是“有保存点的嵌套事务”。如果当前没有事务它就和REQUIRED一样新建事务如果有事务就设置一个保存点Savepoint内层方法的回滚只回滚到保存点位置不会把外部事务整个回滚。听起来和REQUIRES_NEW很像本质区别在于REQUIRES_NEW是物理上的独立连接、独立事务NESTED还是同一个物理事务只是多了保存点。外层如果最终回滚内层即使已经“提交”了保存点也会跟着一起回滚。典型场景是批量处理数据。循环处理一批任务单个任务失败不应该影响其他任务但主事务又希望统一控制最终是否整体提交。Nested模式下每个子任务失败只回滚本任务的保存点。需要知道一个限制NESTED是依赖数据库保存点能力的MySQL和PostgreSQL都支持但某些场景下连接池代理或者特殊数据库实现可能有问题。选型时要确认底层数据库真的支持SAVEPOINT。4.4 SUPPORTS、MANDATORY、NOT_SUPPORTED、NEVER四种不太常用的传播行为我直接结合业务场景说明SUPPORTS有事务就加入没有事务就以非事务方式执行。适合那些“有事务更好没事务也行”的辅助查询方法。比如一个查询方法如果被事务内调用就参与事务独立调用也无所谓。MANDATORY强制要求当前必须存在事务否则抛异常。我一般用在那些“不能脱离事务独立执行”的内部处理逻辑上比如数据一致性校验方法。如果测试代码忘了开事务一调就抛IllegalTransactionStateException能最早暴露问题。NOT_SUPPORTED如果当前有事务先把事务挂起然后以非事务方式执行。适合那些“事务里执行反而危险”的操作比如在一段很耗时的外部接口调用、或者一些基础数据清理操作。不过要谨慎挂起事务本身也是有代价的连接还是会占着。NEVER当前存在事务就抛异常。用的比较少适合在绝对不允许事务的操作上兜底比如某些需要立即感知DDL结果或AUTO COMMIT的脚本方法。5. 从理论落到业务下单、扣库存与事务提交后释放锁5.1 经典场景订单和库存的事务边界“订单与库存分布式事务”是热搜词也是面试高频题。先说单体应用下的标准做法订单表、库存表在同一个库里直接用一个Transactional方法把“插入订单”和“扣减库存”包起来。Transactional public void createOrderWithDeduct(OrderCreateRequest request) { OrderDO order new OrderDO(); order.setUserId(request.getUserId()); order.setProductId(request.getProductId()); order.setAmount(request.getAmount()); orderMapper.insert(order); int rows inventoryMapper.deduct(request.getProductId(), request.getQuantity()); if (rows 0) { throw new BusinessException(库存不足); } }这段代码的关键在于deduct方法用带条件的UPDATE来实现原子扣减UPDATE t_inventory SET stock stock - #{quantity} WHERE product_id #{productId} AND stock #{quantity};返回影响行数来判断是否扣减成功。如果单纯先SELECT再UPDATE并发下肯定超卖。先查再改这个方案在事务里看起来安全但不是真正的原子操作必须靠条件更新或FOR UPDATE而条件更新是性能最好的。在单体架构下这个方案已经能满足一致性要求。问题更多出在微服务拆分开以后订单服务和库存服务不在一个库一个本地事务无论如何也覆盖不了两个库。这就要谈分布式事务了。5.2 事务提交后释放锁的正确姿势还有一个业务上很容易踩的坑在事务里给订单加锁然后在事务内释放锁。Transactional public void payOrder(Long orderId) { OrderDO order orderMapper.selectByIdForUpdate(orderId); // 业务处理 redisLock.unlock(orderId); // 错误示范 }问题在于unlock执行时事务还没提交。其他线程在提交前看到的还是未提交数据如果这个线程又抢到锁去处理同一订单很可能会读到旧状态产生重复处理或数据错乱。正确做法是把锁释放挪到事务提交之后Transactional public void payOrder(Long orderId) { OrderDO order orderMapper.selectByIdForUpdate(orderId); // 业务处理 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { redisLock.unlock(orderId); } } ); }更优雅的方式是用TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)监听事务提交事件。这个方案能保证事务回滚时不释放锁因为afterCommit不会执行事务提交后立刻释放锁业务逻辑和锁管理完全解耦。这个细节在面试中很加分实际开发中也能避免一类很难排查的并发BUG。5.3 分布式事务简析从本地事务到Seata如果业务真的拆分成了订单服务和库存服务两个独立应用各自连各自的库本地事务已经无能为力。这时候业界有几种主流方案两阶段提交2PC数据库层面协调多个资源强一致但性能差高并发场景基本不用。TCCTry-Confirm-Cancel业务层面实现补偿逻辑性能好但侵入性强每个操作都要写Try、Confirm、Cancel三个方法。本地消息表在一个本地事务里写业务数据和消息表然后通过MQ消费消息驱动下游操作属于最终一致性方案。Seata AT模式对业务代码侵入极小核心原理是拦截SQL解析出前镜像和后镜像在全局事务提交时对比数据冲突则回滚类似对所有分支事务实现自动补偿。拿Seata的AT模式举例一个分布式事务包含了全局事务IDXID的分发传递。调用链上每个服务都通过拦截器把XID传到下一个服务当全局事务需要回滚时TC事务协调器通知所有分支事务AT模式根据undo_log里的镜像数据反向补偿。分布式事务的本质是CAP理论里的取舍你要强一致就要接受性能损失你要高可用和高性能就必须走最终一致性。Spring的传播机制在这种场景下仍然有用但它只是本地事务的“一种编写约定”管不到别的服务。6. 事务失效排查实录这些坑我都踩过6.1 自调用为什么会让事务失效这是事务领域第一大坑同类内部方法调用Transactional完全失效。Service public class UserService { public void outerMethod() { // 事务不生效 this.innerMethod(); } Transactional public void innerMethod() { // 数据库操作 } }原因在于Spring事务默认走的是AOP代理。Transactional生效的前提是调用方拿到的是代理对象代理对象会在方法前后开启、提交、回滚事务。但this.innerMethod()调用的是当前对象自身也就是原始对象根本没有代理参与事务自然不生效。解决思路有三个第一把innerMethod拆到另一个Bean里通过注入依赖调用第二注入ApplicationContext显式获取代理Bean第三把事务逻辑放到TransactionTemplate里手动控制事务边界代码直白还不容易出错。我个人的倾向是拆Bean。把“带事务的原子操作”放到独立的Service组件里调用方自然就用了代理语义也清楚。6.2 事务失效原因的完整清单我整理了一张排查清单每一行都是真实项目里碰到过的场景原因解决方式方法不是publicSpring默认用CGLIB或JDK动态代理非public方法不经过代理改成public同类自调用this调用绕过代理拆Bean或使用AopContext.currentProxy()异常被catch事务感知不到异常自然不回滚catch里重新抛出RuntimeException或使用TransactionTemplate抛出检查异常RuntimeException以外的异常默认不回滚rollbackFor Exception.class数据库引擎不支持事务比如MyISAM表改用InnoDB方法通过this调用静态方法等代理失效确保经过Spring容器Bean调用finally里释放资源异常可能掩盖原始异常导致事务结果异常谨慎处理finally中的资源释放6.3 快速定位事务是否生效的手段排查事务问题我一般按以下顺序来第一步检查日志级别。在application.yml里加一行配置logging: level: org.springframework.transaction.interceptor: TRACE启动后看日志中是否出现“Getting transaction for [方法名]”和“Completing transaction”这类关键信息。没有就说明事务压根没被代理。第二步代码里确认当前是否存在活动事务boolean txActive TransactionSynchronizationManager.isActualTransactionActive();在方法入口打点输出这个值能立刻判断当前代码是否运行在事务中。第三步确认异常类型。如果是FileNotFoundException、IOException这类检查异常默认不会触发回滚。解决方式是在注解上加上rollbackFor Exception.class或者直接抛出RuntimeException。7. 给新手的几个实操原则与我的体会7.1 尽量避免在事务里做远程调用事务内做HTTP调用、RPC调用、MQ发送是性能与死锁的大杀器。事务期间数据库连接和行锁一直被占着如果被调用的服务响应慢、甚至调用了当前服务会造成连接池耗尽极端情况下两个服务互相等锁形成分布式死锁。建议的方式是本地事务里只写业务数据和消息表事务提交后再发送MQ或调用远程接口。这也是“本地消息表”这种最终一致性方案的核心思路既保证了数据落地又把连接占用时间压到了最短。我在实际项目里的要求只有一条事务方法里禁止任何网络IO。如果确实要在事务后通知下游就用TransactionalEventListener加MQ。7.2 事务粒度不是越小越好要匹配业务边界有人觉得事务越小越好于是把一次业务操作拆成多个短事务。但事务粒度过小中间状态会暴露给其他线程比如“订单已创建但金额未计算完成”的中间状态如果被定时任务扫描到可能被误处理。我的经验是按“业务不变量”切事务。需要一起成功或一起失败的数据变更放一个事务不要求强一致的辅助信息拆出去。比如创建订单和扣库存必须同生共死那就放一个事务但发送站内信这种丢了也无伤大雅的没必要参与进来。7.3 事务隔离级别和传播机制选型建议最后给一个可以直接当模板的选型清单普通增删改REQUIRED默认隔离级别。查询类服务SUPPORTS让调用方决定是否参与事务。操作日志、账单流水REQUIRES_NEW防止核心事务回滚时连累日志。强制校验类方法MANDATORY防止被无事务调用方误用。长耗时外部交互NOT_SUPPORTED尽量别让数据库事务等外部IO。批量处理有独立失败点NESTED用保存点隔离失败任务。7.4 一个让我记忆深刻的复盘前两年做一个结算改造一个方法内有十几个数据库操作当时为了图快把几个子方法全部标了REQUIRES_NEW。上线后出了一次数据异常主事务回滚了但某个子事务已经提交导致“主单被回滚流水却留在库里”。那次复盘后我把所有REQUIRES_NEW都重新审视了一遍能改REQUIRED的全都改了只在真正需要独立提交的日志表和异步消息表上保留独立事务。从那以后这个模块再也没有出现过数据不一致。事务这个东西表面上是个注解背后其实是数据库和业务架构的取舍。多花点时间把隔离级别、传播机制和它们与锁的关系弄明白省下的排查时间绝对翻倍。上面这些经验和方法都是我从一个个线上故障里抠出来的建议你在项目里也用最小成本先复盘一遍自己的事务写法该清理的清理该补配置的补配置。