写业务代码时,你有没有遇到过这样的情况:用户下单成功了,但库存没扣减;或者转账操作一半失败,钱被转走却没到账?这类问题,八成是事务边界没划对。
事务边界不是“加个@Transactional就完事”
很多人一提事务,第一反应就是 Spring 的 @Transactional 注解。但光加注解不等于事务就稳了——它只告诉框架“这段代码要进事务”,真正起作用的,是事务边界的起点和终点在哪里。
简单说:事务边界,就是数据库操作从“开始受事务保护”到“提交或回滚”的这一段范围。边界划得不对,该回滚的没回滚,不该进来的进了事务,数据就容易出岔子。
常见的事务边界定义方式
最典型的是基于方法的边界。比如在 Spring 中:
@Service
public class OrderService {
@Transactional
public void createOrder(String userId, String itemId) {
// 1. 扣减库存(调用InventoryService)
inventoryService.decrease(itemId, 1);
// 2. 创建订单(本地DB操作)
orderMapper.insert(new Order(userId, itemId));
// 3. 发送通知(可能异步,不参与事务)
notifyService.sendAsync("order_created", userId);
}
}这里事务边界从 createOrder 方法进入时开启,到方法正常返回时提交;如果中途抛出未捕获的运行时异常(如 RuntimeException),则回滚。注意:notifyService.sendAsync() 虽然写在方法里,但如果它是异步调用、跨线程或发 MQ,就不在当前事务范围内——它不属于事务边界内。
边界容易踩坑的几个地方
1. 自调用失效:同一个类里,A 方法加了 @Transactional,B 方法没加,B 调用 A —— 这时事务不会生效。因为代理对象没介入,A 实际执行的是原始方法,事务边界根本没建立。
2. try-catch 吞掉异常:方法里把异常 catch 住了,又没重新 throw,事务感知不到失败,就会照常提交。
3. 非数据库操作混进来:比如在事务方法里调用了 HTTP 接口、写了本地文件、发了邮件——这些操作没法回滚,但它们的逻辑却依赖数据库已提交的状态,结果就是“数据库回滚了,外部系统却已执行”,状态不一致。
手动控制边界更灵活
有些场景不适合用声明式事务,比如需要动态决定是否开启事务,或跨多个服务协调。这时可以用编程式事务:
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
inventoryService.decrease(itemId, 1);
orderMapper.insert(order);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}这样事务边界完全由你用 getTransaction() 和 commit()/rollback() 控制,粒度更细,也更容易调试。
说到底,事务边界不是配置出来的,而是根据业务语义想清楚的:这笔操作,哪些步骤必须“全成功或全失败”?哪些可以事后补偿?想清楚这点,再选工具,代码才不容易埋雷。