在Spring项目里,@Transactional大概是所有注解里最“看着简单、用起来坑最多”的一个。很多人习惯性地在Service方法上加一个@Transactional,以为事务就万无一失了,结果线上数据对不上、钱多扣了、库存超卖了,最后查出来是事务压根没生效。这个场景我遇到过太多次,而且有个规律:事务失效的问题往往不是报错,而是“不报错但结果不对”,所以特别隐蔽,排查起来也很费劲。
这篇文章我把平时帮同事和自己排查事务失效问题的经验整理成一套完整的内容,包括@Transactional的底层工作方式、常见的失效场景、传播机制的误用,以及一套可以直接照着用的排查流程。不管你是刚接触Spring事务,还是已经写了两三年业务代码,这些内容都能帮你少踩几个坑。尤其是自调用、异常被吞、非public方法这几个经典场景,几乎每个项目中都能碰到。
1. 先搞懂@Transactional是怎么工作的
1.1 AOP代理:事务注解背后的“代理机制”
要理解事务为什么会失效,首先得清楚@Transactional不是“魔法”。Spring里事务功能的实现核心是AOP(面向切面编程),具体说就是:Spring容器在启动时,会为带有@Transactional的Bean生成一个代理对象。当外部调用这个Bean的方法时,实际上调用的是代理对象的方法,代理对象在方法执行前开启事务,在方法执行后根据结果决定提交还是回滚。
这个过程就好比你请了一个前台助理。你对外公布的联系方式是助理的电话,外人打电话进来,助理先记录、再转接给你。正常通话没问题,但如果绕过助理直接打你的私人电话,助理就完全不知道这通电话的存在,自然也没法帮你做记录。事务代理就是这个助理,只有走代理的调用才被纳入事务管理。
这里的关键点是:外部调用才会走代理。如果是在同一个类内部,一个方法直接调用另一个带有@Transactional的方法,实际上调用的是this对象的方法,而不是代理对象的方法,那事务配置就不会生效。很多人第一次遇到自调用失效的问题时都懵了,原因就在这里。
1.2 反向理解:哪些场景代理根本不会生效
理解代理机制之后,很多失效场景就能反向推导出来。
第一,如果目标对象不是由Spring容器管理的Bean,而是自己new出来的对象,那Spring根本不会给它生成代理。比如在工具类里new一个Service,或者在静态方法里手动new一个对象再调方法,事务必然不生效。这相当于你根本没把助理的电话号码公布出去,外人怎么可能通过助理找到你。
第二,如果目标方法不是public的,事务也不会生效。Spring的@Transactional底层依赖AOP,而AOP对非public方法的支持是有限的。虽然有些高级配置看起来能处理非public方法,但官方文档明确说,@Transactional应该放在public方法上。我建议你直接记住这个结论:非public方法上的事务配置,在大多数情况下都不会起作用,而且不同版本之间行为还可能有差异,属于典型的“看着没问题,实际靠不住”的写法。
第三,如果类本身没有被Spring扫描到,比如没有加@Component、@Service等注解,或者包扫描路径没覆盖到,那代理根本就不会生成。这一类属于配置层面的问题,排查起来其实最简单,但出问题的人也不少。
2. 事务失效的几种常见原因与正确写法
2.1 方法上的访问权限:非public方法事务不生效
这是一个非常经典的失效场景。先看一段错误示例:
@Service public class OrderService { @Transactional void createOrder(OrderDTO dto) { insertOrder(dto); updateStock(dto.getProductId(), dto.getQuantity()); } }这段代码里,createOrder方法没有加public修饰符,默认是包级可见。也就是说,这个方法是“当前包内可以访问”,但Spring的动态代理在默认配置下,对非public方法的事务支持是打折扣的。实际效果就是:方法正常执行,但事务没有开启。如果执行到updateStock抛异常了,前面的insertOrder也不会回滚。
正确的写法是:
@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { insertOrder(dto); updateStock(dto.getProductId(), dto.getQuantity()); } }这里多说一句,不仅方法要是public,被@Transactional修饰的方法所在的类也必须是public的,并且要能被Spring正常扫描到。类如果是package-private,代理生成同样可能出问题。我见过有人在同一个包下测试没问题,因为测试类和Service在同一个包,调用方直接访问到了内部方法,结果一到线上不同包就出问题,这类问题特别容易让人误判。
2.2 方法内部自调用:最隐蔽的失效场景
自调用是事务失效里最隐蔽、最容易踩坑的一类。很多人对代理机制有了解,但写代码的时候一顺手就忘了。看下面这个例子:
@Service public class OrderService { public void createOrderAndNotify(OrderDTO dto) { createOrder(dto); // 这里的调用不会走代理 notifyUser(dto.getUserId()); } @Transactional public void createOrder(OrderDTO dto) { insertOrder(dto); updateStock(dto.getProductId(), dto.getQuantity()); } }createOrderAndNotify是一个普通方法,内部直接调用了this.createOrder。虽然createOrder上有@Transactional,但因为这是同一个类内部的直接调用,不会经过Spring的代理对象,事务完全不会开启。调用方不会收到任何错误提示,但数据库操作就是不在一个事务里,一旦后面出了问题,数据就会处于部分更新的状态。
那问题来了:如果一个方法既要对外提供事务能力,又要在内部被复用,该怎么处理?有三种常见方案。
第一种,把事务方法拆到另一个Service里:
@Service public class OrderService { private final OrderTransactionService orderTransactionService; public void createOrderAndNotify(OrderDTO dto) { orderTransactionService.createOrder(dto); notifyUser(dto.getUserId()); } } @Service public class OrderTransactionService { @Transactional public void createOrder(OrderDTO dto) { insertOrder(dto); updateStock(dto.getProductId(), dto.getQuantity()); } }第二种,在当前类注入自身代理对象(Spring循环依赖在Spring Boot 2.6以后默认关闭,但这类写法仍然存在,需要留意):
@Service public class OrderService { // 注入自身代理 @Autowired private OrderService self; public void createOrderAndNotify(OrderDTO dto) { self.createOrder(dto); notifyUser(dto.getUserId()); } @Transactional public void createOrder(OrderDTO dto) { insertOrder(dto); updateStock(dto.getProductId(), dto.getQuantity()); } }第三种,使用AopContext.currentProxy()获取当前代理对象,但需要额外配置exposeProxy。我个人的习惯是第一优先拆Service,因为这样职责更清晰,代码看起来也更顺。自调用这个坑,我建议让每个团队里的新人都知道,它不像空指针那样立刻报错,属于“安静地出错”,危害更大。
2.3 异常被吞掉:try-catch之后事务静默失效
这个场景在代码里特别常见,尤其是接了第三方接口或者写复杂业务逻辑的时候,很多人会在事务方法里做异常捕获,结果把事务的回滚信号一起吞掉了。看例子:
@Transactional public void payOrder(OrderDTO dto) { try { deductBalance(dto.getUserId(), dto.getAmount()); updateOrderStatus(dto.getOrderId(), PayStatus.PAID); } catch (Exception e) { log.error("支付失败", e); // 注意:异常被捕获了,没有重新抛出 } }这个写法的直接后果是:如果deductBalance扣款成功,但updateOrderStatus更新状态失败,异常被catch住,方法正常返回,事务正常提交。用户的余额扣了,订单还是待支付状态,对账的时候就会差钱。
解决思路有两条。
第一条,如果业务上确实需要捕获异常并做日志记录,那么必须在catch块中重新抛出,让事务感知到异常:
@Transactional public void payOrder(OrderDTO dto) { try { deductBalance(dto.getUserId(), dto.getAmount()); updateOrderStatus(dto.getOrderId(), PayStatus.PAID); } catch (Exception e) { log.error("支付失败", e); throw new RuntimeException("支付失败", e); } }第二条,把需要捕获异常的逻辑拆到事务方法之外,事务方法内部只做会被事务保护的数据操作,异常由外层的方法统一处理。比如Controller调用Service时,在Controller层捕获异常做提示。
我这里要说一个更细的点:很多人以为只要抛异常就会回滚,其实不然。Spring的事务回滚原则是,默认只在运行时异常(RuntimeException)和错误(Error)时回滚,受检异常(Checked Exception)默认不会触发回滚。所以如果你在catch块里throw了一个业务异常,而那个业务异常继承的是Exception而不是RuntimeException,那事务仍然不会回滚。这就是下面要说的另一个问题。
2.4 异常类型不匹配:checked exception默认不回滚
看这个例子:
@Transactional public void createOrder(OrderDTO dto) throws BusinessException { insertOrder(dto); updateStock(dto.getProductId(), dto.getQuantity()); if (stockNotEnough) { throw new BusinessException("库存不足"); // 受检异常 } }假设BusinessException继承自Exception,那么当它抛出时,事务会正常提交。你没看错,方法明明抛了异常,但事务提交了。这在Spring的默认配置下就是这样的行为,很多人在第一次遇到时都很难相信。
解决方式是在@Transactional中显式指定rollbackFor:
@Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) throws BusinessException { insertOrder(dto); updateStock(dto.getProductId(), dto.getQuantity()); if (stockNotEnough) { throw new BusinessException("库存不足"); } }到这里我建议形成一个习惯:所有@Transactional注解,都显式写上rollbackFor = Exception.class。不要依赖默认行为,因为默认行为太容易出边界问题。比如ArithmeticException是运行时异常,默认会回滚;但SQLException是受检异常,默认不会回滚。你的方法里声明了throws SQLException,数据库操作中途报错了自己处理的还行,要是往上抛了,事务却提交了,那数据就全是脏的。
下面这个表格把异常类型和默认回滚行为的对应关系列清楚,方便对照:
| 异常类型 | 例子 | 默认是否回滚 |
|---|---|---|
| 运行时异常(RuntimeException) | NullPointerException、IllegalArgumentException | 是 |
| 错误(Error) | OutOfMemoryError、StackOverflowError | 是 |
| 受检异常(Checked Exception) | IOException、SQLException、自定义BusinessException | 否 |
| 受检异常且显式指定rollbackFor | @Transactional(rollbackFor = Exception.class) | 是 |
2.5 类没有被Spring管理:new出来的对象没有“魔法”
很多人会在工具类或者工厂类里手动new一个Service,然后调用它的方法,期待事务生效。看下面这个典型错误:
public class OrderService { @Transactional public void createOrder(OrderDTO dto) { insertOrder(dto); updateStock(dto.getProductId(), dto.getQuantity()); } } // 在使用的地方 OrderService orderService = new OrderService(); orderService.createOrder(dto);OrderService类没有加@Service注解,Spring容器里根本不存在这个Bean的代理对象。代码里手动new出来的orderService就是普普通通的Java对象,它的createOrder方法上的@Transactional注解不会被任何机制解析,事务自然不可能生效。虽然这个场景看起来太基础,但我在真实项目里见过不止一次,尤其是在老系统改造或者同事手写工具类时容易出现。
还有一种情况,是类本身被Spring管理,但调用时通过静态方法获取实例,比如自己写了一个静态工厂返回了一个非代理对象。这里面的坑在于,Spring的依赖注入默认注入的就是代理对象,但如果手动从ApplicationContext里拿Bean时做了类型转换或者绕过代理,也可能导致事务失效。我的建议是:代码中一律通过依赖注入来使用Service,不要自己去new,也不要在静态上下文里去“捞”实例。这样最稳。
2.6 多线程调用:事务传播不到子线程
Spring事务默认是基于ThreadLocal实现的,事务上下文绑定在当前线程上。如果你在事务方法里开了子线程,在子线程里执行数据库操作,那么子线程的执行不会加入到父线程的事务中。看这个例子:
@Transactional public void batchProcess(List<OrderDTO> orders) { orders.parallelStream().forEach(order -> { updateOrder(order); // 这里在并行流子线程中执行 }); } private void updateOrder(OrderDTO order) { orderMapper.update(order); }这段代码存在两个层面的问题。一是parallelStream使用的是ForkJoinPool的公共线程池,updateOrder的执行线程和batchProcess的主线程不是同一个,事务上下文传递不过去。二是如果updateOrder内部还访问了ThreadLocal中的某些上下文参数,子线程中也拿不到。所以并行流在处理事务方法时,数据一致性是无法保证的。你需要自己去手动管理子线程的事务,这也是为什么真正需要并发的场景里,应该考虑用编程式事务,而不是依赖@Transactional的隐式事务。
如果要让子线程参与事务,正确的做法通常有两种。一种是子线程内部自己开启事务,每个子线程处理自己的数据,通过计数器保证整体进度,失败时做补偿。另一种是不要让事务方法内部开线程,而是先在一个线程里处理数据,再通过消息队列异步处理后续流程,这样每个流程都有自己独立的事务边界,反而更容易保证数据一致。很多架构上追求异步化的团队,其实都有意避免在事务方法里再开线程,因为事务和异步本身是一对矛盾。
2.7 数据库存储引擎层面的限制
这个很多人会忽略,属于“环境不给力”。@Transactional要生效,底层数据库必须支持事务。如果用的MySQL,那表引擎必须是InnoDB,而不能是MyISAM。MyISAM引擎不支持事务,即使代码里加了@Transactional,SQL执行出错后也没有回滚能力。诊断方法很简单:
SHOW TABLE STATUS LIKE 'your_table_name';看返回结果中的Engine字段,如果是MyISAM,就需要转换成InnoDB。转换命令:
ALTER TABLE your_table_name ENGINE = InnoDB;还有一个常见问题是在同一个事务里访问了不支持事务的其他资源,比如缓存、搜索引擎索引等。这类资源天然没有ACID的保证,事务回滚不会把缓存数据也回滚掉。写代码时要把“数据库事务”和“分布式环境下多个数据源之间的一致性”分清楚,前者可以靠@Transactional解决,后者需要引入分布式事务方案。很多人把一个普通事务方法里加了Redis操作,就以为Redis的操作也能跟着一起回滚,这是不对的。Redis的操作要么在事务提交后再执行,要么通过其他机制做补偿。
另外,Spring的事务管理器和数据源要匹配。如果你配置了多数据源,但@Transactional没有指定对应的transactionManager,事务可能管理的是另一个数据源,看起来像是失效。这个问题在多数据源项目里比较典型,后面会单独说。
3. 事务传播机制与常见误用组合
3.1 传播级别选了REQUIRES_NEW,结果连不上
Spring的@Transactional有个propagation属性,默认是Propagation.REQUIRED。REQUIRED的含义是:如果当前没有事务,就创建一个新事务;如果当前已经有事务,则加入当前事务。这个设置适合绝大多数场景。
但有些人为了强制新开事务,会设置Propagation.REQUIRES_NEW。它的行为是:无论当前有没有事务,都挂起当前事务,新开一个独立事务。注意,REQUIRES_NEW不会“加入”外部事务,而是“另起炉灶”。外部事务发生异常回滚时,REQUIRES_NEW里的操作已经独立提交了,不会被回滚回调。换句话说,REQUIRES_NEW内部执行成功的操作,外部事务管不了。
什么时候会用到REQUIRES_NEW?典型场景是:你希望某个操作不随着主事务的回滚而回滚。比如一个订单创建失败要回滚主数据,但希望把失败原因记录到一张日志表里,这时候写日志的操作可以用REQUIRES_NEW,确保日志本身能成功写入。但如果不是这种明确的需求,别随意使用REQUIRES_NEW,因为它会打破“大事务包含小事务”的直观预期,非常容易造成数据不一致。
还需要注意一个点:REQUIRES_NEW会独占数据库连接。如果一个方法里先开启了外部事务,拿到了连接1,又调用了REQUIRES_NEW方法,Spring会再从连接池里拿连接2给新事务用。如果连接池最大连接数设置得不够大,嵌套调用层级多了,可能出现连接池耗尽的问题。现象就是系统运行一段时间后偶发超时,查日志发现Waiting for connection timeout。
3.2 noRollbackFor与rollbackFor的组合使用
除了rollbackFor,Spring还提供了noRollbackFor参数,用于指定哪些异常不触发回滚。这在一些特定的业务场景里是有用的,比如你希望把某些异常视为“可接受的失败”,不因为这类异常就把整个大事务回滚掉。
举个例子:
@Transactional(rollbackFor = Exception.class, noRollbackFor = BusinessWarnException.class) public void createOrder(OrderDTO dto) { insertOrder(dto); updateStock(dto.getProductId(), dto.getQuantity()); throw new BusinessWarnException("只是提示,不滚"); }这里BusinessWarnException虽然继承自RuntimeException,但因为配置了noRollbackFor,抛出后不会触发回滚。注意,这里有个容易理解错的地方:noRollbackFor不是“不抛异常”,而是“抛异常但不回滚”。该方法最终还是会以异常结束,事务提交,调用方需要自行处理这个异常。
我建议noRollbackFor的使用一定要克制,它是典型的“知道的人多,用得好的人少”的特性。如果一个团队里对这块没有明确约定,尽量不要用。因为一旦外部事务嵌套调用了这个方法,方法内部noRollbackFor不回滚,但外部事务可能因为其他异常回滚,两边行为不一致,排查难度非常大。
3.3 事务超时与锁等待:不是失效,但很像失效
有时候你碰到的问题并不是事务完全没生效,而是事务开启后因为超时或锁等待,导致数据没有按预期回滚或提交。这类问题表现上和事务失效很像,但根因完全不同。
Spring的@Transactional支持timeout属性,单位是秒:
@Transactional(timeout = 5) public void heavyProcess() { // 执行超过5秒会抛出事务超时异常 }如果方法执行时间超过timeout设置,Spring会让事务回滚并抛出异常。但在实际使用中,timeout的语义在不同数据库和不同事务管理器下可能略有差异,所以尽量不要依赖这个参数来兜底。它是给你主动设置一个保护上限的,不是给你做精确控制的。
锁等待的情况要更复杂一些。比如方法A更新了订单状态,但事务迟迟没有提交,持有行锁;另一个线程的方法B想更新同一行,就会一直等待。如果等待时间超过了数据库的innodb_lock_wait_timeout设置(默认50秒),B会抛出Lock wait timeout exceeded异常。这个异常会让B的事务回滚,但A的事务可能还在继续。从B的视角看,事务像是“失效”了:明明加了@Transactional,异常也抛了,但就是没成功。真实原因是锁竞争,不是事务配置问题。
遇到这类问题,排查方向是先看数据库的锁等待情况,而不是怀疑Spring配置。MySQL里可以用:
SELECT * FROM information_schema.innodb_trx; SELECT * FROM information_schema.innodb_lock_waits;查看当前事务和锁等待的具体情况。我见过一些团队为了排查事务问题,把所有@Transactional配置翻了个遍,最后发现是慢SQL导致的长事务,大量的锁等待把连接池拖垮了。所以排查事务问题时,数据库层面的诊断和代码层面的检查要同步进行。
事务的隔离级别也会影响锁的行为。MySQL默认的隔离级别是REPEATABLE READ,事务内多次读取同一数据,结果一致。如果某些业务场景希望读到其他事务已提交的数据,可以配置@Transactional(isolation = Isolation.READ_COMMITTED)。但隔离级别的调整要谨慎,它直接影响并发场景下的一致性和性能,不建议在不熟悉的情况下频繁切换。
4. 事务失效的排查思路与工具
4.1 开启Spring事务日志
遇到事务疑似失效时,第一个反应不应该是猜,而是看日志。Spring提供了事务管理器的日志输出,可以在配置文件中开启:
logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction: DEBUG开启之后,运行时会在日志里看到这类信息:
Using transaction object [org.springframework.jdbc.datasource.ConnectionHolder] Creating new transaction with name [com.example.service.OrderService.createOrder]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT Initiating transaction commit Initiating transaction rollback如果方法上加了@Transactional但日志里根本没有Creating new transaction,说明代理压根没有生效,优先检查这个类是否被Spring管理、方法是否是public。如果看到了Creating new transaction但没有看到transaction commit或rollback,说明事务可能已经超时,或者事务在另一个线程里执行,日志没有打印完整。
日志排查是第一步,先确认事务到底有没有开启,再去看为什么不回滚。很多人在代码层面反复修改,结果问题根本不在那里,日志一开就明白了。
4.2 从日志里识别事务代理是否生效
日志里出现的字面信息很有讲究。如果日志出现了Creating new transaction,说明本次调用确实进入了代理的事务拦截逻辑,事务创建成功。如果日志里没有这行信息,但同时类上有@Transactional注解,那基本可以判断问题出在调用方式上,比如自调用或new对象。
如果创建了事务,方法抛了异常,但日志里没有Initiating transaction rollback,那就要看抛的异常类型是否满足回滚条件。比如抛了一个受检异常,且注解上没有配rollbackFor,那么日志里就只会有Initiating transaction commit,这就能解释为什么事务“失效”了。这种场景下,日志是能直接给出答案的。
这里给个实际建议:排查事务问题时,不要一上来就看业务代码,先开日志跑一遍,把“事务是否创建”和“事务是否回滚”这两个关键信息确认了,再回头看代码。这样能省下一大半时间,也避免在错误的方向上反复纠结。
4.3 快速定位:事务失效检查清单
我把这些年遇到的事务失效场景整理成了一份检查清单,按照优先级从高到低排列,排查时按顺序过一遍:
| 序号 | 检查项 | 确认方法 |
|---|---|---|
| 1 | 方法是否为public | 看代码修饰符 |
| 2 | 类是否被Spring管理 | 是否有@Service/@Component,是否能被扫描到 |
| 3 | 是否通过代理调用(不是自调用) | 检查调用方是否通过注入的Bean调用 |
| 4 | 异常是否被try-catch吞掉 | 看方法内是否有catch后未重新抛出 |
| 5 | 异常类型是否满足回滚条件 | 检查rollbackFor配置,异常是否RuntimeException |
| 6 | 事务方法是否在子线程中执行 | 看是否使用了线程池或并行流 |
| 7 | 数据库是否支持事务 | 检查MySQL表引擎是否InnoDB |
| 8 | 事务管理器是否匹配数据源 | 多数据源场景下检查transactionManager |
| 9 | 是否配置了传播级别导致每次新开事务 | 检查propagation属性的实际配置 |
| 10 | 数据库有无锁等待或超时 | 查询information_schema里的事务和锁等待 |
这套清单在我自己排查问题时反复使用,基本能覆盖九成以上的事务失效场景。如果你按顺序过一遍还没解决,那大概率不是“失效”,而是业务逻辑本身的问题,比如补偿逻辑缺失、缓存不一致等,建议往这个方向继续排查。
5. 实测:一个容易踩坑的完整案例
5.1 场景复现
模拟一个常见的电商场景:用户下订单,需要扣减库存、插入订单记录、给用户加积分。三个操作需要保证原子性,任何一个失败都要全部回滚。最初的代码如下:
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Autowired private PointMapper pointMapper; @Override @Transactional public void createOrder(OrderDTO orderDTO) { orderMapper.insert(orderDTO); stockMapper.deduct(orderDTO.getProductId(), orderDTO.getQuantity()); pointMapper.add(orderDTO.getUserId(), orderDTO.getAmount()); } }如果Controller层直接注入OrderService接口,走的也是代理对象,这个代码看起来没有大问题。但如果把调用方式变成自调用或异常被吞,问题就会出现。为了完整演示这个排查流程,我在下面把几个典型问题放在同一个案例里模拟。
5.2 逐步排查与解决
第一轮:日志排查。开启事务日志后,发现日志中根本没有Creating new transaction,说明事务没有创建。检查发现,调用方在同一个Service内部调用了createOrder方法:
@Service public class OrderServiceImpl implements OrderService { @Override public void createOrderWithCheck(OrderDTO orderDTO) { createOrder(orderDTO); // 自调用 } @Override @Transactional public void createOrder(OrderDTO orderDTO) { orderMapper.insert(orderDTO); stockMapper.deduct(orderDTO.getProductId(), orderDTO.getQuantity()); pointMapper.add(orderDTO.getUserId(), orderDTO.getAmount()); } }createOrderWithCheck是一个普通方法,它内部直接调用了this.createOrder,所以@Transactional没有生效。这个场景的修复方式前面已经提过,最好把三个数据操作抽到一个独立的Bean里,避免自调用。
第二轮:异常类型排查。假设自调用问题修复后,日志中出现了Creating new transaction,但抛异常后仍然只看到transaction commit。检查代码发现,业务方法里抛的是自定义的BizException,这个异常继承的是Exception:
public class BizException extends Exception { public BizException(String message) { super(message); } }由于默认不回滚受检异常,事务就提交了。修复方式就是把@Transactional改成@Transactional(rollbackFor = Exception.class),或者把BizException改成继承RuntimeException。我个人倾向于两个都做,既让异常继承RuntimeException,又显式配置rollbackFor,双保险。
第三轮:并发排查。还有一个隐藏问题,在createOrderWithCheck里,如果对用户下单做了并发控制,比如用分布式锁或者乐观锁,但这些锁在事务提交前就被释放了,可能导致另一个线程读到旧数据。这虽然不是事务失效,但和事务的提交时机强相关,容易被误判。这类问题的排查方向是确认锁的释放是在事务提交之后:
@Transactional public void createOrder(OrderDTO orderDTO) { // 业务操作 orderMapper.insert(orderDTO); stockMapper.deduct(orderDTO.getProductId(), orderDTO.getQuantity()); pointMapper.add(orderDTO.getUserId(), orderDTO.getAmount()); // 不要把锁释放放在这里,事务还没提交 }锁的释放应该由事务拦截器在事务提交后统一处理,或者使用Spring的TransactionSynchronizationManager注册回调。
5.3 最终代码
综合上面的排查,最终推荐的写法是:
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Autowired private PointMapper pointMapper; @Override @Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO orderDTO) { orderMapper.insert(orderDTO); stockMapper.deduct(orderDTO.getProductId(), orderDTO.getQuantity()); pointMapper.add(orderDTO.getUserId(), orderDTO.getAmount()); } }外部如果有校验逻辑,把校验逻辑放在独立方法里,或者放在调用的Controller层,不要放在同一个Bean里产生自调用。如果需要记录失败原因并且不希望被回滚影响,再把记录操作拆到另一个独立Service里,用REQUIRES_NEW,或者干脆在事务提交后再处理。
这套代码写完之后,开启事务日志再跑一遍,应该能看到完整的Creating new transaction、Initiating transaction commit/rollback这三类日志,整个链路就稳定了。如果你也遇到了事务不生效的问题,建议先把这份检查清单存下来,遇到问题按顺序过,基本上都能快速找到原因。踩过几次坑之后你会发现,事务失效并不神秘,它背后永远只有一个问题:Spring的代理到底有没有起作用。