扣了库存没生成订单,@Transactional 一个字没生效
十一月中旬那次上线后,客服陆续接到投诉:用户付款成功,但订单列表里查不到。我翻数据库,发现 t_stock 的库存扣了,t_order 里却没有对应记录。同一个 @Transactional 方法里的两个操作,一个成了一个没成。
这种"事务没生效"的问题我后来陆陆续续遇到了好几种,整理成 7 个场景,按我们项目里实际踩到的顺序排。
@Service
public class OrderServiceImpl implements OrderService {
@Transactional
public void createOrder(OrderDTO dto) {
stockMapper.deduct(dto.getSkuId(), dto.getQty()); // 执行了
orderMapper.insert(buildOrder(dto)); // 没执行
}
}
场景一:异常被 catch 吞掉了(就是这次)
完整代码是这样的:
@Transactional
public void createOrder(OrderDTO dto) {
try {
stockMapper.deduct(dto.getSkuId(), dto.getQty());
orderMapper.insert(buildOrder(dto));
} catch (Exception e) {
log.error("下单失败", e); // 我以为是好习惯,结果把异常吞了
}
}
我写的 try-catch 把异常消化掉了,方法正常返回。Spring 的事务拦截器是在目标方法抛出异常时才决定回滚的,它看不到异常,就认为一切正常,直接提交。
正确做法是 catch 之后要么重新抛出,要么手动标记回滚:
@Transactional
public void createOrder(OrderDTO dto) {
try {
stockMapper.deduct(dto.getSkuId(), dto.getQty());
orderMapper.insert(buildOrder(dto));
} catch (Exception e) {
log.error("下单失败", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 显式标记
}
}
用 setRollbackOnly() 的好处是不会再抛异常出去,接口能返回自定义的错误信息。但要注意,它只是标记,不会中断方法执行,标记之后的代码如果还有数据库操作,那些操作同样会被回滚。
场景二:异常类型不对,默认不回滚
就算异常没被吞,也不是所有异常都会触发回滚。Spring 的默认规则是:只对 RuntimeException 和 Error 回滚,受检异常(Exception)不回滚。
看源码 DefaultTransactionAttribute:
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
所以下面这段代码,抛出 IOException 之后事务照样提交:
@Transactional
public void createOrder(OrderDTO dto) throws IOException {
orderMapper.insert(buildOrder(dto));
notifyDownstream(dto); // 抛 IOException,事务不回滚!
}
这个默认行为其实是照抄 EJB 的,但业务里经常需要回滚受检异常。两种改法:
// 方式一:显式声明
@Transactional(rollbackFor = Exception.class)
// 方式二:把受检异常包成 RuntimeException
try {
notifyDownstream(dto);
} catch (IOException e) {
throw new BizException("下游通知失败", e);
}
我们项目统一用了第一种,并且写了个自定义注解把 rollbackFor = Exception.class 固化下来,避免每个人漏配:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface BizTransactional {
}
场景三:同一个类里的方法自调用
这个是我遇到次数最多的。看代码:
@Service
public class OrderServiceImpl {
public void batchCreate(List<OrderDTO> list) {
for (OrderDTO dto : list) {
this.createOrder(dto); // this 自调用,事务失效
}
}
@Transactional
public void createOrder(OrderDTO dto) {
// ...
}
}
Spring 的声明式事务是通过 AOP 代理实现的。调用 orderService.createOrder() 时,实际调用的是代理对象的方法,代理在前后织入了开启事务、提交/回滚的逻辑。而 this.createOrder() 里的 this 是目标对象本身,不是代理,所以绕过了增强。
三种解决办法:
// 1. 注入自己(Spring 支持自注入,但看着别扭)
@Autowired
private OrderService self;
self.createOrder(dto);
// 2. 从 AopContext 拿当前代理,需要开启 exposeProxy
((OrderService) AopContext.currentProxy()).createOrder(dto);
// 配置:@EnableAspectJAutoProxy(exposeProxy = true)
// 3. 拆成两个类(我最终用的,结构最清晰)
@Service
public class OrderBatchService {
@Autowired
private OrderService orderService;
public void batchCreate(List<OrderDTO> list) {
for (OrderDTO dto : list) {
orderService.createOrder(dto);
}
}
}
场景四:方法不是 public
@Transactional
private void createOrder(OrderDTO dto) { // private,事务失效
// ...
}
@Transactional
protected void createOrder(OrderDTO dto) { // protected,同样失效
// ...
}
Spring 的 AbstractFallbackTransactionAttributeSource 里有这么一段:
protected TransactionAttribute computeTransactionAttribute(Method method, Class<?> targetClass) {
// 只处理 public 方法
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null;
}
// ...
}
返回 null 意味着没有事务属性,注解被忽略。而且它不会报错,默默失效,特别坑。
需要说明的是,这个限制只对基于代理的 AOP 成立(CGLIB 理论上能代理 protected 方法,但 Spring 事务这块统一做了 public 检查)。用 AspectJ 织入的话没这个限制,不过我们项目没用。
另外,注解加在 final 方法或者 final 类上也会失效——CGLIB 靠生成子类做代理,final 方法没法重写。
场景五:数据库引擎不支持事务
这个是测试同事发现的。我们在测试环境跑得好好的,上线到预发环境事务就不生效。查下来是建表语句的问题:
SHOW TABLE STATUS WHERE Name = 't_order'\G
*************************** 1. row ***************************
Name: t_order
Engine: MyISAM ← 罪魁祸首
Version: 10
MySQL 5.7 默认引擎是 InnoDB,但预发那套库是用老脚本建的,指定了 MyISAM。MyISAM 完全不支持事务,所有 SQL 都是自动提交的。
-- 查看当前默认引擎
SHOW VARIABLES LIKE 'default_storage_engine';
-- 转换引擎
ALTER TABLE t_order ENGINE = InnoDB;
顺便,MySQL 里有些语句会隐式提交事务,比如 ALTER TABLE、TRUNCATE、DROP。这些 DDL 执行时会先把当前事务提交掉,所以别在事务方法里做 DDL。
场景六:传播行为用错
默认情况下 @Transactional 的传播行为是 REQUIRED,即"有就加入,没有就新建"。这个行为导致了我们另一个 bug:
@Service
public class JobService {
@Transactional
public void syncJob() {
for (Long orderId : fetchFailedOrders()) {
try {
orderService.retry(orderId); // 内部抛异常
} catch (Exception e) {
log.error("订单 {} 重试失败", orderId, e); // 以为只影响这一条
}
}
}
}
@Service
public class OrderService {
@Transactional
public void retry(Long orderId) {
// ...
throw new RuntimeException("重试失败");
}
}
retry 用的是默认的 REQUIRED,它加入了外层 syncJob 的事务。内层方法抛异常时,Spring 会把整个事务标记为 rollback-only。等外层方法正常结束准备提交时,发现事务已被标记回滚,直接抛:
org.springframework.transaction.UnexpectedRollbackException:
Transaction rolled back because it has been marked as rollback-only
这个报错信息我第一次看到完全懵了。正确的做法是让内层方法独立开事务:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void retry(Long orderId) {
// 挂起外层事务,新建独立事务,异常只回滚自己
}
REQUIRES_NEW 会挂起当前事务并新建一个,内层回滚不影响外层。代价是每次都要新建连接,1000 条重试就是 1000 次事务提交,性能要评估。
传播行为有 7 种,我常用的就三个:
| 传播行为 | 含义 | 使用场景 |
|---|---|---|
| REQUIRED(默认) | 有则加入,无则新建 | 绝大多数场景 |
| REQUIRES_NEW | 挂起当前,新建独立事务 | 批量任务里单条失败不影响整体 |
| SUPPORTS | 有则加入,无则非事务运行 | 查询方法,不强制开事务 |
| NOT_SUPPORTED | 挂起事务,非事务执行 | 大批量查询,避免长事务 |
| NESTED | 嵌套事务,靠 savepoint 实现 | 需要部分回滚,用得少 |
场景七:多线程环境下事务不传播
最后一个。我做过一个批量导入,为了快用了线程池:
@Transactional
public void batchImport(List<OrderDTO> list) {
// 主线程插入一条批次记录
batchMapper.insert(newBatchRecord());
list.parallelStream().forEach(dto -> {
orderMapper.insert(buildOrder(dto)); // 子线程,不在事务里
});
throw new RuntimeException("测试回滚"); // 只有批次记录被回滚
}
Spring 的事务信息是绑定在 ThreadLocal 上的(TransactionSynchronizationManager),子线程拿不到父线程的数据库连接,自然也就不在同一个事务里。结果就是:批次记录回滚了,1000 条订单记录全部留在库里。
这个没有优雅的解法。我的处理方式是:多线程只做数据校验和转换,数据库操作统一收回到主线程批量执行。
@Transactional
public void batchImport(List<OrderDTO> list) {
List<Order> orders = list.parallelStream()
.map(this::validateAndBuild) // 只做计算
.collect(Collectors.toList());
orderMapper.batchInsert(orders); // 主线程批量入库
}
1000 条数据用 MyBatis 的 foreach 批量插入,实测耗时 340ms,比并行插入慢不了多少,但事务是完整的。
排查清单
遇到事务不生效,我现在按顺序检查:
- 方法是不是 public;
- 有没有 try-catch 吞掉异常;
- 抛的是不是受检异常(看要不要加
rollbackFor); - 是不是
this.自调用; - 表引擎是不是 InnoDB;
- 传播行为对不对;
- 有没有跨线程。
另外有个快速验证的办法,开 SQL 日志看有没有 SET autocommit=0 和 commit:
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
logging.level.org.mybatis.spring.SqlSessionUtils=DEBUG
DEBUG o.s.j.d.DataSourceTransactionManager - Creating new transaction with name [...]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG o.s.j.d.DataSourceTransactionManager - Acquired Connection [...] from DataSource
DEBUG o.s.j.d.DataSourceTransactionManager - Switching JDBC Connection [...] to manual commit
DEBUG o.s.j.d.DataSourceTransactionManager - Initiating transaction rollback
如果日志里连 Creating new transaction 都没有,那就是注解压根没被解析到,直接按上面 1-4 条查。
那天排查到根因是 try-catch 之后,师傅说了句我一直记着的话:写 catch 之前先想清楚这个异常该由谁处理,能处理就处理,处理不了就抛出去,别打一行日志就当解决了。