在Java后端开发里,@Transactional是每天都要打交道的注解。不少人对事务失效的印象还停留在「同类调用、方法私有、异常类型不匹配」这老三样,直到在生产环境接连踩坑才发现:真正容易引发线上问题的,全是那些容易被忽略的非典型场景。
今天整理了5个隐蔽性极强的事务失效场景,每个都附代码复现、底层原理和可直接复用的修复代码。看完你会发现,事务失效这事,细节里全是坑。
场景一:异常被try-catch悄悄吞掉,事务假装没看见
踩坑现场
这是最高发、也最隐蔽的事务失效场景。代码逻辑看起来天衣无缝:加了事务注解,方法里也抛了异常,可数据库数据就是没回滚。查了半天代理、方法权限全没问题,最后发现——异常被try-catch默默吃掉了。
错误示例代码
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { try { // 1. 插入订单 orderMapper.insert(order); // 2. 扣减库存 stockMapper.reduce(order.getProductId(), order.getNum()); // 3. 模拟业务异常 int i = 1 / 0; } catch (Exception e) { // 只打印了日志,没把异常抛出去 log.error("下单失败", e); } } }失效原理
Spring事务的回滚逻辑,本质是通过AOP切面捕获方法抛出的异常来触发的。一旦你在方法内部用try-catch把异常捕获并消化掉了,切面就感知不到异常发生,自然也就不会触发回滚操作。在Spring看来,这个方法是「正常执行完成」的。
解决方案
两种常用修复方式,按需选择:
方案1:手动标记事务回滚
捕获异常后,手动通知Spring当前事务需要回滚,适合不想破坏上层调用链路的场景。
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { try { orderMapper.insert(order); stockMapper.reduce(order.getProductId(), order.getNum()); // 业务逻辑... int i = 1 / 0; } catch (Exception e) { log.error("下单失败,订单号:{}", order.getOrderNo(), e); // 手动标记当前事务为回滚状态 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } } }注意:该方式仅标记回滚,方法仍会正常结束;标记后不可再提交新的写操作,否则会抛出异常。
方案2:捕获后重新抛出异常
推荐生产环境使用,异常链路完整,便于全局异常处理和日志排查。
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { try { orderMapper.insert(order); stockMapper.reduce(order.getProductId(), order.getNum()); // 业务逻辑... int i = 1 / 0; } catch (Exception e) { log.error("下单失败,订单号:{}", order.getOrderNo(), e); // 抛出异常,让Spring切面感知并触发回滚 throw new BusinessException("下单失败,请稍后重试", e); } } }场景二:传播级别配错,事务悄悄「裸奔」了
踩坑现场
很多同学为了灵活配置事务,会特意指定propagation传播级别,但很容易踩SUPPORTS的坑。开发者以为加了注解就有事务,实际上在特定调用方式下,方法全程没有事务保护,异常了数据也不会回滚。
错误示例代码
@Service public class StockService { @Autowired private StockMapper stockMapper; // 错误:写操作使用 SUPPORTS,上层无事务时自身也无事务 @Transactional(propagation = Propagation.SUPPORTS, rollbackFor = Exception.class) public void updateStock(Long productId, Integer num) { stockMapper.reduce(productId, num); // 模拟异常 int i = 1 / 0; } } // 调用方:自身未开启事务 @Service public class ProductService { @Autowired private StockService stockService; public void updateProductStock(Long productId, Integer num) { // 无事务环境下调用 SUPPORTS 方法,全程无事务 stockService.updateStock(productId, num); } }失效原理
Propagation.SUPPORTS的官方含义是:如果当前存在事务,就加入事务;如果没有事务,就以非事务方式执行。
很多人误以为它是「支持事务」,默认会开启事务,实则恰恰相反——当调用方没有事务时,这个方法就直接以无事务状态运行,异常了当然不会回滚。
解决方案
方案1:写操作统一使用默认传播级别
写操作强制使用REQUIRED(Spring默认值,可省略不写),确保有事务保护。
@Service public class StockService { @Autowired private StockMapper stockMapper; // 写操作:默认 REQUIRED,无事务则新建,有事务则加入 @Transactional(rollbackFor = Exception.class) public void updateStock(Long productId, Integer num) { stockMapper.reduce(productId, num); // 业务逻辑... int i = 1 / 0; } }方案2:SUPPORTS 仅用于纯查询场景
SUPPORTS的正确定位是优化查询性能,仅在纯读方法中使用,避免不必要的事务开销。
@Service public class StockService { @Autowired private StockMapper stockMapper; // 正确用法:纯查询方法使用 SUPPORTS,兼顾性能与事务兼容 @Transactional(propagation = Propagation.SUPPORTS, readOnly = true) public Stock getStockInfo(Long productId) { return stockMapper.selectByProductId(productId); } }场景三:开个线程去调用,事务直接失效
踩坑现场
为了提升接口性能,把一些次要逻辑放到异步线程里执行,结果发现数据库操作异常了完全不回滚。很多人排查半天代理、异常都没问题,却忽略了「线程」这个关键因素。
错误示例代码
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private LogMapper logMapper; @Transactional(rollbackFor = Exception.class) public void createOrder(Order order) throws InterruptedException { orderMapper.insert(order); // 错误1:手动new线程,跨线程无法共享事务 // 错误2:同类调用this.方法,绕过代理,注解本身失效 new Thread(() -> this.saveOperateLog(order)).start(); Thread.sleep(100); // 主业务异常 int i = 1 / 0; } @Transactional(rollbackFor = Exception.class) public void saveOperateLog(Order order) { logMapper.insertLog(order); int i = 1 / 0; } }失效原理
这里是双重原因叠加导致事务失效:
1.同类调用:this.saveOperateLog()直接调用本类方法,绕过了Spring的代理对象,注解本身就不生效
2.跨线程传递失效:Spring的事务信息通过ThreadLocal存储,天然线程隔离。子线程无法获取主线程的事务上下文,既不会加入主事务,自身的事务也不会生效
解决方案
核心原则:异步逻辑抽取到独立类,通过Spring代理调用;跨线程无法实现本地事务一致性,异步方法独立管理自身事务。
步骤1:抽取异步逻辑到独立Service
@Service public class OperateLogService { @Autowired private LogMapper logMapper; // 异步方法独立开启自己的事务 @Async("taskExecutor") @Transactional(rollbackFor = Exception.class) public void saveOrderLog(Order order) { logMapper.insertLog(order); // 业务逻辑... } }步骤2:主线程中注入Bean调用
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private OperateLogService operateLogService; @Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { // 主业务事务 orderMapper.insert(order); // 正确:通过注入的代理Bean调用异步方法 // 注意:主线程与异步线程事务相互独立,不会一起回滚 operateLogService.saveOrderLog(order); // 主业务逻辑... int i = 1 / 0; } }重要提醒:本地事务无法跨线程传递。如果需要强一致性,不要用异步做写操作;确需跨服务/跨线程一致性,请引入Seata等分布式事务方案。
场景四:表引擎选错了,写再多注解也白搭
踩坑现场
这个坑在老项目迁移里格外常见。新功能写完测试,怎么测事务都不回滚,代码翻了三遍都找不到问题。最后连上数据库一看——表用的是 MyISAM 引擎。
失效原理
@Transactional再强大,也是基于数据库本身的事务能力实现的。
- MySQL 的 MyISAM 存储引擎天生不支持事务,只支持表锁
- 只有 InnoDB 引擎才支持事务、行锁和外键
代码层面无论配置得多完美,数据库底层不支持,一切都是空谈。这种问题隐蔽性极强,因为代码不会报任何错,只是事务默默不生效。
解决方案(附完整SQL)
1. 单张表修改引擎
-- 修改单个表为InnoDB引擎 ALTER TABLE t_order ENGINE = InnoDB;2. 批量查询当前库所有非InnoDB表
-- 查询当前数据库中所有MyISAM引擎的表 SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = '你的数据库名' AND ENGINE != 'InnoDB';3. 建表规范写法(建表时显式指定)
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT '订单号', amount DECIMAL(10,2) NOT NULL COMMENT '订单金额', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单表';场景五:只读事务里写数据,事务直接「罢工」
踩坑现场
很多同学知道readOnly = true可以优化查询性能,就随手给一些「看起来是查询」的方法加上。结果方法里有少量写操作时,要么直接报错,要么事务行为和预期不符。
错误示例代码
@Service public class OrderService { @Autowired private OrderMapper orderMapper; // 错误:只读事务中包含写操作 @Transactional(readOnly = true, rollbackFor = Exception.class) public OrderVO getOrderDetail(Long orderId) { Order order = orderMapper.selectById(orderId); // 写操作:更新查看次数 orderMapper.updateViewCount(orderId); return convert(order); } }失效原理
readOnly = true不是一个「优化提示」这么简单,它会将数据库连接设置为只读模式。在只读事务中执行INSERT、UPDATE、DELETE等写操作,数据库会直接抛出错误;部分场景下还会导致事务状态异常,出现不回滚、提交失败等问题。
很多人踩坑就是因为:方法主体是查询,夹杂了一两句更新操作,又忘了去掉只读配置,排查时很难联想到是这个参数的锅。
解决方案
核心思路:读写职责拆分,纯查询用只读事务,写操作单独走普通事务。
@Service public class OrderService { @Autowired private OrderMapper orderMapper; // 纯查询:使用只读事务优化性能 @Transactional(readOnly = true) public OrderVO getOrderDetail(Long orderId) { Order order = orderMapper.selectById(orderId); return convert(order); } // 写操作:独立方法,普通事务 @Transactional(rollbackFor = Exception.class) public void incrementViewCount(Long orderId) { orderMapper.updateViewCount(orderId); } // 上层调用:按需组合 public OrderVO queryDetailAndAddView(Long orderId) { // 先更新浏览量 incrementViewCount(orderId); // 再查询详情 return getOrderDetail(orderId); } }实践建议:绝大多数普通业务场景下,单表查询的只读优化感知极弱,不要为了「看起来专业」盲目加readOnly,反而容易引入坑。
最后:事务失效速查清单
遇到事务不生效,别上来就瞎改代码,按这个顺序排查,99%的问题都能定位:
排查顺序 | 检查项 | 常见坑点 |
|---|---|---|
1 | 方法是不是public、是不是同类调用 | 最经典的AOP代理绕过问题 |
2 | 异常有没有被try-catch吞掉 | 最高发的非典型场景 |
3 | 传播级别是不是配错了 | SUPPORTS、NOT_SUPPORTED 易踩坑 |
4 | 异常类型是不是匹配 | 默认只回滚RuntimeException |
5 | 是不是跨线程调用 | ThreadLocal 事务不共享 |
6 | 数据库表引擎是不是InnoDB | 历史遗留表容易中招 |
7 | 是不是只读事务里执行了写操作 | readOnly 配置误用 |
开发做久了会发现:很多技术点不在于难,而在于细。事务这个东西入门觉得简单,真正在生产环境摸爬滚打下来,才会发现处处都是细节坑。
希望这篇文章能帮你避开几个生产事故。你们还遇到过什么奇葩的事务失效场景?欢迎评论区一起聊聊~