上篇事务文章发出来之后,收到了一些朋友的支持和讨论,挺开心的。有朋友想了解我实际项目中踩过哪些坑,我想了想,干脆再写一篇实操的总结,把这些年遇到的事务问题整理一下,希望能抛砖引玉。
先说一个印象最深的。
有一次晚上九点多,正准备收拾东西下班,告警群突然炸了。
ini
2025-06-04 21:07:33.892 ERROR - SQL执行异常
订单创建成功,但库存未扣减
orderId=20250604210733001, productId=1001, quantity=2
我当时就懵了。订单都创建成功了,库存怎么会没扣?这俩操作明明在一个事务里啊。
赶紧打开代码看了一眼:
typescript
@Service
public class OrderServiceImpl implements OrderService {
@Transactional
public void createOrder(OrderDTO orderDTO) {
// 1. 保存订单
orderMapper.insert(orderDTO);
// 2. 扣减库存
deductInventory(orderDTO.getProductId(), orderDTO.getQuantity());
}
@Transactional
public void deductInventory(Long productId, Integer quantity) {
Inventory inventory = inventoryMapper.selectById(productId);
if (inventory.getStock() < quantity) {
throw new BusinessException("库存不足");
}
inventory.setStock(inventory.getStock() - quantity);
inventoryMapper.updateById(inventory);
}
}
看起来没毛病啊,两个方法都加了@Transactional。
但仔细一想,不对,问题就出在这。
第一个坑:同类方法调用,事务根本没生效
这是最隐蔽的一个坑。
createOrder()调用deductInventory(),看起来都有事务注解,但实际上deductInventory()的事务根本没生效。
为什么?因为Spring事务是基于AOP代理实现的。同类内部方法调用时,走的是this指针,不是代理对象。
画个图就明白了:
事务注解被忽略 Target-->>Proxy: 返回 Note over Proxy: 提交事务
关键在第4步:this.deductInventory()是直接调用,没走代理,所以那个@Transactional形同虚设。
当时查日志,发现deductInventory()里抛了"库存不足"的异常,但订单居然没回滚。就是因为这俩方法压根不在同一个事务里------准确说,deductInventory()根本没有事务。
怎么验证事务有没有生效
加一行日志就能看出来:
typescript
@Transactional
public void deductInventory(Long productId, Integer quantity) {
// 打印当前类名,看看是不是代理对象
log.info("当前类:{}", this.getClass().getName());
// ...
}
如果输出是OrderServiceImpl,说明没走代理。 如果输出是OrderServiceImpl$$EnhancerBySpringCGLIB$$xxx,说明走了代理。
我当时一试,果然是前者。
修复方案
方案一:拆成两个Service
typescript
@Service
public class OrderServiceImpl {
@Autowired
private InventoryService inventoryService;
@Transactional
public void createOrder(OrderDTO orderDTO) {
orderMapper.insert(orderDTO);
inventoryService.deductInventory(...); // 走代理
}
}
@Service
public class InventoryService {
@Transactional
public void deductInventory(...) {
// 这里的事务生效了
}
}
方案二:注入自身代理
typescript
@Service
public class OrderServiceImpl {
@Autowired
private OrderService selfProxy; // 注入自己
@Transactional
public void createOrder(OrderDTO orderDTO) {
orderMapper.insert(orderDTO);
selfProxy.deductInventory(...); // 通过代理调用
}
}
我们选了方案一,拆分更清晰。
第二个坑:异常被吞了,事务不知道要回滚
改完上线后,过了两天又出问题了。这次更诡异:没报错,但数据不一致。
查了半天,发现是这段代码的锅:
typescript
@Transactional
public void createOrder(OrderDTO orderDTO) {
try {
orderMapper.insert(orderDTO);
inventoryService.deductInventory(...);
} catch (Exception e) {
log.error("创建订单失败", e);
// 然后呢?没了
}
}
写这段代码的同事估计是想"记录一下日志",结果把异常吞掉了。
Spring事务的回滚机制是靠异常触发的。你把异常catch住不往外抛,Spring根本不知道出事了,照样提交事务。
修复方案
要么不catch,让异常自然抛出:
typescript
@Transactional
public void createOrder(OrderDTO orderDTO) {
orderMapper.insert(orderDTO);
inventoryService.deductInventory(...);
// 异常自然抛出,事务自动回滚
}
要么catch后手动标记回滚:
typescript
@Transactional
public void createOrder(OrderDTO orderDTO) {
try {
orderMapper.insert(orderDTO);
inventoryService.deductInventory(...);
} catch (Exception e) {
log.error("创建订单失败", e);
// 手动标记回滚
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw e; // 最好还是抛出去
}
}
我个人建议:别在事务方法里catch异常,除非你真的知道自己在干什么。
第三个坑:rollbackFor没配,Checked异常不回滚
又过了几天,又出问题了。我当时真的想骂人。
这次是调用外部接口,抛了个IOException,结果事务没回滚。
java
@Transactional
public void createOrder(OrderDTO orderDTO) throws IOException {
orderMapper.insert(orderDTO);
// 调用外部服务,可能抛IOException
externalService.notify(orderDTO);
}
查了一下才知道,Spring默认只对RuntimeException和Error回滚。
IOException是Checked异常,不是RuntimeException的子类,所以默认不回滚。
php
Exception
├── RuntimeException(默认回滚)
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ └── ...
└── Checked异常(默认不回滚)
├── IOException
├── SQLException
└── ...
这个设计据说是沿用了EJB的规范,但我觉得挺坑的。大部分业务代码里,不管什么异常都应该回滚才对。
修复方案
显式声明rollbackFor:
java
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO orderDTO) throws IOException {
// 现在所有异常都会回滚了
}
从那以后,我写@Transactional必加rollbackFor = Exception.class,已经形成肌肉记忆了。
进阶思考:Exception.class是不是最佳实践?
后来我看了一些资料,发现这个问题有争议。
一派观点 :直接用rollbackFor = Exception.class,简单粗暴,所有异常都回滚,不容易遗漏。
另一派观点:应该精确指定异常类型,比如:
csharp
@Transactional(rollbackFor = {OrderException.class, InventoryException.class})
public void createOrder() {
// 只有这两种异常才回滚
}
理由是:
- 有些Checked异常可能不需要回滚(比如网络超时,可以重试)
- 精确控制让代码意图更清晰
- 避免把不该回滚的情况也回滚了
我的实践:
对于核心业务(订单、支付、库存),我还是用Exception.class,宁可多回滚也不能数据不一致。
对于边缘业务(日志、通知),可以精确控制,甚至不用事务。
没有绝对的对错,看业务场景。
第四个坑:private方法,事务直接失效
这个坑比较低级,但还是有人会踩。
typescript
@Service
public class OrderService {
@Transactional
private void doCreateOrder(OrderDTO orderDTO) { // private方法
orderMapper.insert(orderDTO);
}
}
Spring AOP代理不会处理private方法,@Transactional直接被忽略。
这个在Spring源码里写得很清楚:
kotlin
// AbstractFallbackTransactionAttributeSource
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null; // 非public方法返回null,不生成事务属性
}
修复方案
改成public就行了。这个没啥好说的。
第五个坑:数据库引擎不支持事务
这个坑我们没踩,但听别的团队说过。
他们迁移老系统的时候,发现事务死活不生效。查了半天,最后发现表是MyISAM引擎。
sql
-- MyISAM不支持事务
CREATE TABLE `order` (
`id` bigint NOT NULL,
...
) ENGINE=MyISAM;
MyISAM根本不支持事务,你加多少@Transactional都没用。
检查方法
ini
SHOW TABLE STATUS WHERE Name = 'order';
看Engine列,如果是MyISAM,改成InnoDB:
ini
ALTER TABLE `order` ENGINE=InnoDB;
自检清单
踩了这么多坑之后,我们整理了一份自检清单,代码review的时候必查:
| 检查项 | 检查方法 | 常见问题 |
|---|---|---|
| 是否走代理 | 打印this.getClass().getName(),看有没有CGLIB或Proxy |
同类方法调用 |
| 异常是否抛出 | 检查有没有catch块吞异常 | catch后没throw |
| rollbackFor配了没 | 搜索@Transactional,看有没有rollbackFor |
Checked异常不回滚 |
| 方法是否public | 检查方法修饰符 | private/protected不生效 |
| 数据库引擎 | SHOW TABLE STATUS |
MyISAM不支持事务 |
另外推荐一个debug技巧:在application.yml里加上事务日志:
yaml
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
这样每次事务开启、提交、回滚都会打印日志,排查问题方便很多:
csharp
DEBUG - Creating new transaction with name [xxx]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG - Acquired Connection [xxx] for JDBC transaction
DEBUG - Committing JDBC transaction on Connection [xxx]
防护方案:怎么避免这些坑
光靠人肉review不靠谱,最好有自动化的手段。
1. 代码规范检查
在团队里约定:
@Transactional必须加rollbackFor = Exception.class- 事务方法里不允许catch异常后不处理
- 事务方法必须是public
可以用Checkstyle或者SonarQube配置规则。
2. 单元测试验证事务
写个测试用例,故意抛异常,验证数据有没有回滚:
scss
@Test
public void testTransactionRollback() {
try {
orderService.createOrder(badOrderDTO); // 会抛异常
} catch (Exception e) {
// ignore
}
// 验证订单没有插入
Order order = orderMapper.selectByOrderNo(badOrderDTO.getOrderNo());
assertNull(order); // 应该是null,说明回滚了
}
3. 上线前的事务检查脚本
我们写了个简单的脚本,扫描代码里的@Transactional:
- 检查有没有配
rollbackFor - 检查方法是不是public
- 检查方法里有没有可疑的catch块
上线前跑一遍,能发现大部分问题。
更复杂的场景怎么办
上面说的都是基础的坑。实际业务中还有更复杂的场景:
- 事务里要发MQ消息,事务回滚了消息却发出去了
- 批量操作100条数据,想让成功的继续,失败的单独处理
- 想记录操作日志,但又不希望日志跟着业务一起回滚
这些场景用普通的@Transactional搞不定,需要用到编程式事务、事务同步器、事务传播机制这些高级玩法。
我之前写过一篇文章专门讲这个:@Transactional做不到的5件事,我用这6种方法解决了
另外,如果你想看复杂事务怎么设计才能不翻车,可以看看这篇:6张表、14步业务逻辑,Mall订单事务凭什么比你的3步事务还稳?
最后
事务这个东西,看起来简单,加个注解就完事了。但真到生产环境出问题的时候,排查起来能把人折腾死。
我们团队踩了这么多坑之后,现在代码review对事务的检查特别严格。毕竟事务失效的代价是数据不一致,修数据的成本比修代码高多了。
你们在项目里踩过哪些事务的坑?
有没有什么好的防护手段?
欢迎在评论区聊聊,互相学习一下。