@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对事务的检查特别严格。毕竟事务失效的代价是数据不一致,修数据的成本比修代码高多了。


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

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

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

相关推荐
用户298698530147 分钟前
.NET 文档自动化:Spire.Doc 设置奇偶页页眉/页脚的最佳实践
后端·c#·.net
序安InToo38 分钟前
第6课|注释与代码风格
后端·操作系统·嵌入式
xyy12338 分钟前
C#: Newtonsoft.Json 到 System.Text.Json 迁移避坑指南
后端
洋洋技术笔记40 分钟前
Spring Boot Web MVC配置详解
spring boot·后端
JxWang0541 分钟前
VS Code 配置 Markdown 环境
后端
navms44 分钟前
搞懂线程池,先把 Worker 机制啃明白
后端
JxWang0544 分钟前
离线数仓的优化及重构
后端
Nyarlathotep01131 小时前
gin01:初探gin的启动
后端·go
JxWang051 小时前
安卓手机配置通用多屏协同及自动化脚本
后端
JxWang051 小时前
Windows Terminal 配置 oh-my-posh
后端