@Transactional的5种失效场景和自检清单

上篇事务文章发出来之后,收到了一些朋友的支持和讨论,挺开心的。有朋友想了解我实际项目中踩过哪些坑,我想了想,干脆再写一篇实操的总结,把这些年遇到的事务问题整理一下,希望能抛砖引玉。

先说一个印象最深的。

有一次晚上九点多,正准备收拾东西下班,告警群突然炸了。

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指针,不是代理对象。

画个图就明白了:

sequenceDiagram participant Client as 外部调用 participant Proxy as Spring代理对象 participant Target as OrderServiceImpl Client->>Proxy: createOrder() Note over Proxy: 开启事务 Proxy->>Target: createOrder() Target->>Target: this.deductInventory() Note over Target: 直接调用,绕过代理
事务注解被忽略 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根本不知道出事了,照样提交事务。

graph TD A[方法开始] --> B[开启事务] B --> C[执行业务逻辑] C --> D{有异常抛出?} D -->|有| E[回滚事务] D -->|没有| F[提交事务] G[异常被catch吞掉] --> H[Spring看到没有异常] H --> F

修复方案

要么不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默认只对RuntimeExceptionError回滚。

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(),看有没有CGLIBProxy 同类方法调用
异常是否抛出 检查有没有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对事务的检查特别严格。毕竟事务失效的代价是数据不一致,修数据的成本比修代码高多了。


你们在项目里踩过哪些事务的坑?

有没有什么好的防护手段?

欢迎在评论区聊聊,互相学习一下。

相关推荐
未秃头的程序猿1 小时前
🚀 设计模式在复杂支付系统中的应用:策略+工厂+模板方法模式实战
后端·设计模式
小坏讲微服务1 小时前
SpringCloud整合Scala实现MybatisPlus实现业务增删改查
java·spring·spring cloud·scala·mybatis plus
6***v4171 小时前
搭建Golang gRPC环境:protoc、protoc-gen-go 和 protoc-gen-go-grpc 工具安装教程
开发语言·后端·golang
水痕011 小时前
go使用cobra来启动项目
开发语言·后端·golang
用户345848285051 小时前
python在使用synchronized关键字时,需要注意哪些细节问题?
后端
代码扳手1 小时前
Golang 高效内网文件传输实战:零拷贝、断点续传与 Protobuf 指令解析(含完整源码)
后端·go
银河邮差1 小时前
python实战-用海外代理IP抓LinkedIn热门岗位数据
后端·python
undsky1 小时前
【RuoYi-Eggjs】:让 MySQL 更简单
后端·node.js
程序员西西2 小时前
Spring Boot整合MyBatis调用存储过程?
java·后端