ARTICLE DETAIL

资讯详情

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

@Transactional事务失效场景与回滚机制深度解析

@Transactional事务失效场景与回滚机制深度解析 别的不说“为什么我加了Transactional异常也往上抛了数据还是没回滚”——这问题我光面试就听人提过不下二十次还有人说“我明明catch了异常自己处理结果事务也被莫名其妙回滚了”。如果你也遇到过这类一头雾水的时刻那这篇文章你算是看对了。我直接抛结论Transaction在Spring里真正的称呼是Transactional它不是你随便加一个注解就能保护数据的东西它背后是一整套AOP代理机制、回滚规则、传播行为、以及大量“你以为生效、其实失效”的边界场景。我要把事务的正确使用和触发回滚的机制从头到尾拆开讲讲清楚它能解决什么问题也讲清楚哪些场景它完全无用甚至帮倒忙。这篇文章适合三类人看第一类是Java初学转后端开发正被Transactional各种“灵异失效”折磨的第二类是准备Java面试想一次性把事务传播、回滚、隔离级别串成体系背熟的人第三类是工作两三年的小伙伴希望靠这份整理把自己碎片化的认知补完整。我用真实事故开头再看看代理机制、回滚机制、传播行为、隔离级别、经典UnexpectedRollbackException、以及Spring Boot MyBatis的连表事务实操每一步都给你能直接用上的代码和判断逻辑。1. 事务是什么Gitee上那个“订单成功但库存没减”的Bug是怎么来的很多初学Java的朋友对事务的理解停留在“数据库四大特性ACID背下来”的层面一旦落到真实业务里就很容易翻车。我先用最常见的电商下单场景来演示事务存在的意义。1.1 没有事务的时候“数据库扣钱”和“商品扣库”会分成两步假设你写了一个下单方法里面做了两件事第一往订单表插入一条订单记录第二往库存表扣减一个商品的库存。如果这两步之间没有事务保护而第二步因为库存不足抛了个异常那么数据库里就会残留一张订单但是库存没扣。用户在页面看到的是“下单失败”但后台数据里订单其实已经插进去了库存也没变。后续对账的时候你会发现财务说的“订单数”和真实有效订单永远对不上一次两次还能人工处理量大了基本就是事故。我印象最深的一次是朋友公司做秒杀活动一个小时涌进来几万单接口在扣库存环节频繁超时。他们没有在Service方法上做事务控制结果订单表和库存表直接不一致后台对账差了五千多条记录团队连夜手工修正。这个场景就是事务最核心的价值——把多个数据库操作绑定成一个要么全成功、要么全失败的整体。1.2 ACID四个字到底在说什么原子性Atomicity一个事务里的所有操作就像一个人上公交车要么整只脚上都上去要么下来不存在“先上一半”的状态。放在数据库里就是“订单插入”和“库存扣除”要么都提交要么都回滚。一致性Consistency事务执行前后的数据状态必须符合业务规则不能让库存出现负数不能让订单金额和商品总价对不上。隔离性Isolation事务与事务之间要隔离开别好几个用户同时下单时彼此的中间状态互相污染。持久性Durability一旦事务提交结果就要写在磁盘上永久保存不能重启一下数据就丢了。注意日常开发里我们最需要担心的其实是“原子性”和“隔离性”。原子性靠回滚日志和Spring的Transactional去协调隔离性则和数据库的锁机制、事务隔离级别强相关。很多“并发下单库存超卖”的线上事故本质上是隔离性没处理好。2. Transactional工作原理一个代理对象接管了你的方法2.1 Spring AOP代理机制到底做了什么Transactional本身只是一个声明式事务的注解它自己不干活真正干活的是Spring在运行时给Bean创建出来的“代理对象”。代理对象会在你调用目标方法之前帮你开启数据库事务在你方法正常返回时提交事务在你方法抛出指定异常时回滚事务。这样设计的好处是你写业务代码的人不用关心“什么时候begin、什么时候commit、什么时候rollback”这些底层重复动作只要在Service方法上放一个注解框架就能把事务切进去。这也就是IoC和AOP在事务模块上最大的价值。但也正因为是“代理对象在执行”只要你没走代理注解就会失效。记住一句话Transactional要生效必须是通过Spring容器的代理Bean来调用目标方法而不是自己this调用自己。2.2 自调用失效同一个类里方法调方法事务静悄悄没了这是最高频的失效场景没有之一。Service public class OrderService { public void createOrderAndDeductStock(OrderDTO dto) { // 其他业务逻辑... this.saveOrder(dto); this.deductStock(dto); } Transactional(rollbackFor Exception.class) public void saveOrder(OrderDTO dto) { orderMapper.insert(dto); } Transactional(rollbackFor Exception.class) public void deductStock(OrderDTO dto) { stockMapper.deduct(dto.getSkuId(), dto.getCount()); } }这段代码里外部调用createOrderAndDeductStock方法的时候saveOrder和deductStock上的Transactional根本不会生效。原因在于this.saveOrder(dto)是直接调用当前对象的方法没有经过Spring AOP生成的代理对象。代理对象的外壳根本没被触发事务自然无从谈起。正确做法有两种。第一种是把事务注解加到最外层方法上让createOrderAndDeductStock自身成为事务边界Transactional(rollbackFor Exception.class) public void createOrderAndDeductStock(OrderDTO dto) { orderMapper.insert(dto); stockMapper.deduct(dto.getSkuId(), dto.getCount()); }第二种是注入自身的代理或者拆成两个不同的Service类让Bean之间的依赖关系触发代理Service public class OrderService { // 通过构造器注入自己调用代理方法 Autowired private OrderService self; public void createOrderAndDeductStock(OrderDTO dto) { self.saveOrder(dto); self.deductStock(dto); } Transactional(rollbackFor Exception.class) public void saveOrder(OrderDTO dto) { orderMapper.insert(dto); } Transactional(rollbackFor Exception.class) public void deductStock(OrderDTO dto) { stockMapper.deduct(dto.getSkuId(), dto.getCount()); } }实测下来最稳妥的思维方式还是“一个事务就放在最外层不要让事务方法互相嵌套去依赖内层注解”。嵌套一旦多起来传播行为和代理边界都会变得不好排查。2.3 还有哪些“注解看起来在其实没生效”的坑方法被private修饰Spring AOP默认基于CGLIB或JDK动态代理private方法无法被代理增强注解直接忽略。方法在final类上或本身是finalCGLIB代理无法继承和覆写final方法事务同样不生效。类没有被Spring管理比如你随手new了一个Service对象来调方法那肯定没有代理过程注解自然不生效。这也是网上很多人“把Transactional写在工具类里却没用”的根本原因。方法内部自己捕获异常如果异常被try-catch吞掉事务管理器根本感知不到异常信号自然执行提交而不是回滚。这是下一章要细讲的经典失误。3. 回滚机制为什么抛了异常却不回滚3.1 Spring默认只对RuntimeException和Error回滚很多人的困惑是“方法里抛了业务异常也加了Transactional怎么数据还是写进去了”。这就要说到Spring默认的回滚规则了Transactional默认只回滚RuntimeException运行时异常和Error不回滚受检异常checked exception比如Exception的直接子类。换句话说如果你的业务层抛了一个new Exception(库存不足)这个异常属于受检异常默认情况下Spring认为它不会导致事务回滚于是照常提交数据。这个设计初衷是为了兼容早期EJB的一些行为逻辑但落到实际项目里是真的坑人。解决方式就是显式指定回滚规则Transactional(rollbackFor Exception.class) public void deductStock(Long skuId, Integer count) throws Exception { int rows stockMapper.deduct(skuId, count); if (rows 0) { throw new Exception(库存不足回滚); } }3.2 rollbackFor和noRollbackFor到底怎么配rollbackFor Exception.class所有异常都触发回滚。rollbackFor BusinessException.class只有这个自定义业务异常触发回滚。noRollbackFor XXXException.class指定某些异常不回滚。我个人的习惯是对业务服务方法统一写Transactional(rollbackFor Exception.class)宁可全部异常都回滚也不要默认只回滚RuntimeException的坑。除非这个异常是业务上明确“并不需要回滚”的比如第三方回调通知里某些可容忍的校验失败。3.3 try-catch吞异常你亲手断送了回滚信号来看一段反面教材Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { try { orderMapper.insert(dto); int rows stockMapper.deduct(dto.getSkuId(), dto.getCount()); if (rows 0) { throw new ServiceException(库存不足); } } catch (ServiceException e) { log.error(扣库存失败, e); // 没有把异常继续抛出去 } }上面这段代码的问题在于ServiceException被catch住了方法最终正常返回事务管理器看到的是“方法执行成功”于是提交。数据库里订单插进去了库存却没扣你又得到了一张“幽灵订单”。正确做法是捕获异常后要么记录日志然后重新抛出要么抛一个新异常总之事务边界内要么干净结束让事务提交要么异常穿透方法让事务回滚不能“假装没事发生”。Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { try { orderMapper.insert(dto); int rows stockMapper.deduct(dto.getSkuId(), dto.getCount()); if (rows 0) { throw new ServiceException(库存不足); } } catch (ServiceException e) { log.error(扣库存失败, e); throw new ServiceException(创建订单失败库存不足, e); } }4. 传播行为、隔离级别以及那个经典的rollback-only异常4.1 传播行为事务方法之间是怎么“嵌套”的传播行为解决的是“两个都加了Transactional的方法互相调用时事务要不要合并、要不要挂起”的问题。Spring定义了七种实际开发常用三种。REQUIRED默认当前有事务就加入当前没有事务就新建。绝大多数业务场景用它这也是为什么“内层方法不必再标Transactional”的原因。REQUIRES_NEW不管当前有没有事务都新开一个独立事务外层事务挂起内层事务提交或回滚不影响外层事务状态。适合日志记录、审计、通知等“即使主流程失败也要写进去”的场景。NESTED嵌套事务基于保存点来实现内层回滚只回滚到保存点外层仍然可以决定整体是否提交。MySQL的InnoDB支持但使用频率确实不高。这里我必须提醒一个高危场景如果你的外层方法调用了内层方法内层方法标注了REQUIRES_NEW并且内层事务抛了异常那么多半会出现下面这个面试经典异常。4.2 UnexpectedRollbackExceptiontransaction rolled back because it has been marked as rollback-only那段英文全称差不多是UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only这个异常出现的经典场景是这样的Transactional(rollbackFor Exception.class) public void outerMethod() { try { innerMethod(); } catch (Exception e) { log.error(内层方法失败但外层要吞掉主流程继续); } } // 同一个事务传播级别为REQUIRED默认加入外层事务 Transactional(rollbackFor Exception.class) public void innerMethod() { // 执行SQL抛异常 throw new RuntimeException(内层炸了); }为什么外层catch了异常最后提交时却报出UnexpectedRollbackException因为内层方法加入的是外层事务内层方法抛异常时Spring会把整个事务标记为rollback-only。这个标记一旦打上即使外层把异常捕获住、决定“主流程继续”最终事务提交时事务管理器发现事务已经被标记为只能回滚于是一边打日志一边抛出UnexpectedRollbackException强制整个事务回滚。所以记住一点rollback-only是一个“连坐”机制只要事务内任何一环设置了回滚标记整个事务最终就只能回滚外层方法怎么catch都救不回来。想单独隔离内层失败就改为REQUIRES_NEW。4.3 隔离级别快照读、幻读、脏读隔离级别这个知识点面试必考实际开发也绕不开。MySQL InnoDB默认是REPEATABLE_READ可重复读Spring里对应Isolation.REPEATABLE_READ。日常我们主要关注四个级别隔离级别脏读不可重复读幻读说明READ_UNCOMMITTED会会会基本不用可能读到未提交脏数据READ_COMMITTED不会会会很多互联网公司默认Oracle默认REPEATABLE_READ不会不会可能InnoDB对部分场景解决MySQL默认SERIALIZABLE不会不会不会性能极低几乎不用面试时能补一句“MySQL的InnoDB在REPEATABLE_READ级别下使用间隙锁可以在很多场景下避免幻读”会显得你的理解更深一层。不过在实际编码里绝大多数业务并不需要手动修改Transactional的isolation属性保持数据库默认即可因为随意提升隔离级别会明显加大锁竞争拖垮吞吐量。5. 实战Spring Boot MyBatis搭一套“订单库存”事务5.1 建表和基础配置为了把上面的原理落下来我直接给一套可以本地跑的Spring Boot MyBatis示例。先建两张表CREATE TABLE t_order ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) DEFAULT NULL, sku_id bigint(20) DEFAULT NULL, amount int(11) DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_stock ( id bigint(20) NOT NULL AUTO_INCREMENT, sku_id bigint(20) DEFAULT NULL, stock_count int(11) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;对应实体类和Mapper接口这里略过正常MyBatis那一套写法。关键是Service层的代码组织。5.2 事务控制的核心代码Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Autowired private StockMapper stockMapper; Override Transactional(rollbackFor Exception.class) public void createOrderAndDeductStock(Long userId, Long skuId, Integer amount) { // 1. 插入订单 Order order new Order(); order.setUserId(userId); order.setSkuId(skuId); order.setAmount(amount); order.setCreateTime(new Date()); orderMapper.insert(order); // 2. 扣减库存 int rows stockMapper.deductStock(skuId, amount); if (rows 0) { throw new RuntimeException(扣减库存失败当前库存不足); } // 3. 模拟其他业务处理 if (amount 10000) { throw new RuntimeException(超出单笔购买限制强制回滚); } } }这里有几个注意点deductStock的SQL一定要写成“有条件的扣减”比如UPDATE t_stock SET stock_count stock_count - #{amount} WHERE sku_id #{skuId} AND stock_count #{amount}通过影响行数来判断是否扣减成功而不是先查询再判断再更新。后者在高并发下天然有竞态问题。事务方法里抛出RuntimeException后整个方法结束Spring的事务拦截器会感知到异常并执行回滚订单插入和库存扣减的操作都会被撤销。不要在这个方法里做长耗时的远程RPC调用比如调用支付网关。事务期间会持有数据库连接和锁远程调用一旦慢数据库连接池很容易被耗尽。5.3 用日志验证回滚是否生效启动项目调用一次这个下单接口故意传一个超出库存的数量。观察日志如果配了MyBatis的SQL日志你会看到INSERT INTO t_order ...执行成功UPDATE t_stock ...执行影响行数为0Service抛RuntimeExceptionSpring事务拦截器打印类似Rolling back transaction的日志数据库里t_order表没有新增记录如果在日志里看到的是Committing transaction那就要检查你的回滚规则是不是没配好或者异常被吞掉了。这里我再补一个小技巧开发调试时可以把数据源的default-auto-commit和事务日志调低级别比如在application.yml里打开logging.level.org.springframework.transaction.interceptorDEBUG配合日志能清晰看到Getting transaction...、Committing...、Rolling back...对排查失效问题特别有效。6. 事务失效的速查清单和面试高频补充6.1 一张表自查“为什么我的事务没生效”现象可能原因处理方式方法内this调用事务方法代理未经过把事务注解放外层方法或注入自身代理异常被catch吞掉事务管理器感知不到异常捕获后重新抛出抛出的是受检异常Spring默认不拦截受检异常加rollbackFor Exception.classprivate/final方法AOP无法代理改为public非final方法类没有被Spring管理不是Spring Bean加Service等注解并保证被扫描传播行为导致rollback-only内层把事务标记为只能回滚外层不捕获或用REQUIRES_NEW单独隔离事务多线程内执行事务操作事务和线程绑定子线程拿不到事务上下文把事务方法放到独立线程/异步方法内或用编程式事务6.2 面试题补充怎么保证数据一致性分布式事务怎么理解现在面试官很少只问单机事务往往会追加一句“如果订单服务和库存服务不在同一个数据库甚至不在同一个应用里你怎么办”。这个问题的落点就是分布式事务。先记住两个最常被提起的方案两阶段提交2PC/XA强一致性方案协调者先问所有参与者“能不能提交”都准备好后统一提交。性能差、实现复杂互联网高并发场景很少直接用。可靠消息最终一致性本地消息表 / MQ事务消息订单插入成功的同时在本地消息表里也插入一条待发送消息后续定时任务扫表把消息可靠投递到MQ库存服务消费消息再扣库存。这个方案放弃强一致接受短暂的不一致但能保证最终一致是目前实际项目里应用比较多的思路。我在整理这篇文章的时候特意查了下现在的热搜词里面有“订单与库存分布式事务”“分布式事务一致性”这类条目也侧面说明这块确实是高频考点。不过我想提醒一点面试讲分布式事务与其堆术语不如主动提“单机事务都没弄明白谈分布式也只是空中楼阁”然后把Transactional失效的所有边界情况讲清楚反而更容易打动面试官。最后再分享两个小经验第一个经验是关于“事务方法里边不要发MQ消息”的。如果你在事务提交前就发MQ消费者可能秒到了但此时数据库还没提交消费者去查订单就会查不到于是处理失败等消费者重试的时候事务可能已经提交了又会造成消息被重复消费。正确姿势一般是用Spring的TransactionSynchronizationManager.registerSynchronization注册事务同步回调等afterCommit之后再去发MQ。第二个经验是排查线上事务Bug时别只盯着Java代码。先把数据库的隔离级别、是否是InnoDB引擎、事务是否真的开启、以及SQL的影响行数全部确认一遍。很多时候“Transactional失效”其实是“SQL更新了但影响行数为0”或者是“事务快照读导致查不到别人已提交的数据”压根不是注解的问题。从底层往上层排查定位速度反而更快。事务这个知识点说穿了就两句话一个事务边界好好画异常信号让Spring听得见一段业务逻辑能跑通不是本事出了故障还能保持数据一致才是真功夫。
返回列表