Spring 事务失效,绝大多数情况下不是 Spring 的 bug,而是注解虽然写了、却根本没有走到代理逻辑上。最常见的三类是:方法不是 public、在类内部用 this 自调用、异常被 catch 吞掉或者抛的是受检异常。三者都会让回滚逻辑压根没执行,数据却已经提交进库了。
明明加了注解、异常也抛了,数据库里却留下了一半的数据——这是学 Spring 时最让人抓狂的场景之一。更难受的是它不报错、不告警,只有对账时才发现数据对不上。今天这篇文章,编程狮就把五个真实原因逐个拆开,每个都给出反例代码和正确写法,最后给你一份可以照着一步步走的排查清单。

一、事务失效到底"失"在哪一层
先说结论:@Transactional 不是一个"开关",而是一次 AOP 代理。Spring 会给你的 Service 生成一个代理对象,你在别处注入进来的其实是这个代理。调用方法时先经过代理,由它负责开启事务、执行目标方法、决定提交还是回滚。注解要生效,前提是这次调用必须"经过代理"。
1.1 一个最小可运行示例
下面是一份正常的、能正确回滚的写法,后面所有反例都基于它改造:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountService {
private final AccountDao accountDao;
public AccountService(AccountDao accountDao) {
this.accountDao = accountDao;
}
@Transactional
public void transfer(Long from, Long to, int amount) {
accountDao.decrease(from, amount); // 转出方扣钱
if (amount > 1000) {
throw new RuntimeException("单笔超限"); // 运行时异常,触发回滚
}
accountDao.increase(to, amount); // 转入方加钱
}
}
interface AccountDao {
void decrease(Long id, int amount);
void increase(Long id, int amount);
}
从 Controller 里注入 AccountService 并调用 transfer,走的正是代理,所以扣钱之后抛异常,扣的那一笔会回滚,账户余额不变。
1.2 常见误解
误解一:注解加上了就等于事务生效。 注解只是元数据,真正干活的是代理。只要调用没经过代理——方法是 private、调用者是 this、对象是自己 new 的——注解就是一行摆设。
误解二:异常抛了就一定会回滚。 默认只对 RuntimeException 和 Error 回滚。抛一个 IOException 出去,事务会照常提交。
误解三:事务没回滚就是 Spring 的锅。 绝大多数是配置或使用方式的问题。真正属于框架的,只有极少数版本特有的代理实现差异。
把代理这件事想清楚,后面几类原因都能自己推出来。想了解 AOP 代理的完整机制,Spring 教程 的事务管理章节讲得比较系统。
二、原因一:方法不是 public,注解被直接忽略
第一个原因简单却高频:注解标在非 public 方法上时会被直接忽略,而且不报任何错。
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountService {
private final AccountDao accountDao;
public AccountService(AccountDao accountDao) {
this.accountDao = accountDao;
}
// 不会生效:private 方法无法被代理拦截
@Transactional
private void transfer(Long from, Long to, int amount) {
accountDao.decrease(from, amount);
accountDao.increase(to, amount);
}
}
原因在代理的实现方式上。JDK 动态代理只能拦截接口里声明的方法,跟可见性无关地"看不见"私有方法;CGLIB 靠生成子类来增强,而子类没法覆盖父类的 private 方法。所以不论走哪条路,private 都拦不住。
同一类问题还有两个变体:final 类或 final 方法。 CGLIB 靠继承来生成代理,final 类不能继承、final 方法不能覆盖,结果同样是注解失效。另外,把注解标在 protected 或包级可见的方法上,在 JDK 动态代理下同样不生效,在 CGLIB 下虽然能跑但官方并不建议这么写。
解法只有一句:把需要事务的方法声明为 public,并且不要加 final。
三、原因二:同类内部自调用绕过代理,这是最常见的一个
这一条才是新手最常踩、也最难自己想明白的坑。看这段代码:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// 下面几个示例共用的领域类与 DAO 接口
class Order {
private Long userId;
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
}
interface OrderDao {
void insert(Order order);
}
interface CouponDao {
void insert(Long userId);
}
@Service
public class OrderService {
private final OrderDao orderDao;
private final CouponDao couponDao;
public OrderService(OrderDao orderDao, CouponDao couponDao) {
this.orderDao = orderDao;
this.couponDao = couponDao;
}
public void create(Order order) { // 注意:外层没有任何注解
orderDao.insert(order);
this.sendCoupon(order.getUserId()); // this 指向原始对象,不是代理
}
@Transactional
public void sendCoupon(Long userId) {
couponDao.insert(userId);
throw new RuntimeException("发券失败,应当回滚");
}
}
外层 create 会经过代理,但方法体里的 this.sendCoupon(...) 是目标对象自己调自己——this 是原始对象而不是代理对象,这一步压根没走代理,于是 sendCoupon 上的注解彻底没参与。结果是:优惠券记录插进去了,异常照常往外抛,数据却留在库里。
有三种解法,推荐程度依次递减。
解法一:拆到另一个 Bean(最推荐)。 职责本来就该分开,顺带让调用重新经过代理(Order、OrderDao、CouponDao 沿用上一份定义):
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CouponService {
private final CouponDao couponDao;
public CouponService(CouponDao couponDao) {
this.couponDao = couponDao;
}
@Transactional
public void send(Long userId) {
couponDao.insert(userId);
}
}
@Service
public class OrderService {
private final OrderDao orderDao;
private final CouponService couponService;
public OrderService(OrderDao orderDao, CouponService couponService) {
this.orderDao = orderDao;
this.couponService = couponService;
}
public void create(Order order) {
orderDao.insert(order);
couponService.send(order.getUserId()); // 走代理,注解生效
}
}
解法二:注入自己的代理。 改动最小,但要留意它会形成自依赖,需要配合 @Lazy:
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderDao orderDao;
private final CouponDao couponDao;
private final OrderService self; // 注入的是自己的代理
public OrderService(OrderDao orderDao, CouponDao couponDao, @Lazy OrderService self) {
this.orderDao = orderDao;
this.couponDao = couponDao;
this.self = self;
}
public void create(Order order) {
orderDao.insert(order);
self.sendCoupon(order.getUserId()); // 通过代理调用
}
@Transactional
public void sendCoupon(Long userId) {
couponDao.insert(userId);
}
}
解法三:用 AopContext 拿当前代理。 需要启动类上加 @EnableAspectJAutoProxy(exposeProxy = true),调用处写成 ((OrderService) AopContext.currentProxy()).sendCoupon(...)。能用,但它把业务代码和 Spring API 焊死了,单元测试也难写,只在临时救急时用。
这三个解法思路一致:想办法让调用重新经过代理。 注解参数、传播行为这些细节记不全很正常,随手翻 Spring 速查手册 比翻源码快。
四、原因三、四、五:异常被吞掉、受检异常不回滚、引擎不支持事务
第三个原因是异常被 catch 住没有往外抛。代理判断要不要回滚,靠的是"目标方法有没有把异常抛出来":
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountService {
private static final Logger log = LoggerFactory.getLogger(AccountService.class);
private final AccountDao accountDao;
public AccountService(AccountDao accountDao) {
this.accountDao = accountDao;
}
@Transactional
public void transfer(Long from, Long to, int amount) {
try {
accountDao.decrease(from, amount);
accountDao.increase(to, amount);
} catch (Exception e) {
log.error("转账失败", e); // 异常被吞,事务照常提交
}
}
}
方法正常返回,代理认为一切顺利,于是提交事务。想既记录日志又回滚,有两种写法:把异常重新抛出去,或者手动标记回滚:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
@Service
public class AccountService {
private static final Logger log = LoggerFactory.getLogger(AccountService.class);
private final AccountDao accountDao;
public AccountService(AccountDao accountDao) {
this.accountDao = accountDao;
}
@Transactional
public void transfer(Long from, Long to, int amount) {
try {
accountDao.decrease(from, amount);
accountDao.increase(to, amount);
} catch (Exception e) {
log.error("转账失败", e);
// 不往外抛也行,但要手动告诉代理:这个事务只能回滚
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
}
第四个原因是抛的是受检异常。Spring 默认只对 RuntimeException 和 Error 回滚,受检异常(比如 IOException、SQLException)抛出去事务照样提交:
import java.io.IOException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ImportService {
private final AccountDao accountDao;
public ImportService(AccountDao accountDao) {
this.accountDao = accountDao;
}
@Transactional
public void importData(String file) throws IOException {
accountDao.decrease(1L, 100);
throw new IOException("文件读取失败"); // 受检异常,默认不回滚
}
}
设计成这样,是因为受检异常在语义上表示"调用方预期并应当处理的情况",Spring 假设它不代表数据不一致。但业务上几乎总是想回滚,所以推荐显式声明:
import java.io.IOException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ImportService {
private final AccountDao accountDao;
public ImportService(AccountDao accountDao) {
this.accountDao = accountDao;
}
@Transactional(rollbackFor = Exception.class)
public void importData(String file) throws IOException {
accountDao.decrease(1L, 100);
throw new IOException("文件读取失败"); // 现在会回滚
}
}
顺带说一个容易被忽略的:传播行为配错同样会让事务"看起来失效"。 比如标了 Propagation.NOT_SUPPORTED,方法会以非事务方式执行并挂起外层事务;标了 Propagation.SUPPORTS,在没有外层事务时也是非事务执行。只在确实需要时才改传播行为,默认是 REQUIRED。
第五个原因则跟代码无关——数据库引擎不支持事务。MySQL 的 MyISAM 引擎压根没有事务能力,@Transactional 写得再标准也不会回滚,而且不报错:
SHOW TABLE STATUS WHERE Name = 'account'; -- 看 Engine 这一列
ALTER TABLE account ENGINE = InnoDB; -- 改成 InnoDB
MySQL 5.5 之后默认引擎已经是 InnoDB,但老库、从别处迁移过来的表,以及建表语句里显式写了 ENGINE=MyISAM 的表仍然会遇到。引擎这件事属于数据库层面,MySQL 教程 的存储引擎章节有更完整的对比。
五、怎么查:一份可照做的排查清单
| 原因 | 典型现象 | 解决办法 |
|---|---|---|
| 方法不是 public 或加了 final | 注解静默失效,无任何提示 | 改成 public,去掉 final |
| 同类内部自调用 | 内层方法的事务完全没参与 | 拆成另一个 Bean,或注入自己的代理 |
| 异常被 catch 吞掉 | 走了 catch 分支,数据仍提交 | 重新抛出,或手动标记回滚 |
| 抛的是受检异常 | 异常抛出了却提交了 | 加 rollbackFor = Exception.class |
| 表引擎是 MyISAM | 怎么写都不回滚,且不报错 | 改成 InnoDB |
| 传播行为配错 | 方法以非事务方式执行 | 确认是否需要,默认用 REQUIRED |
按这个顺序查,绝大多数问题在前三步就能定位:
- 方法是
public吗?类和方法有没有final? 不是就改。 - 这次调用是同类内部的吗? 搜索方法体里有没有
this.xxx(),有就按第三节拆类。 - 异常真的抛出来了吗? 看
catch块里有没有重新抛出或标记回滚。 - 抛的是受检异常吗? 是就补
rollbackFor。 - 表引擎是 InnoDB 吗? 跑一次
SHOW TABLE STATUS确认。 - 这个类是被 Spring 管理的吗? 有没有
@Service,有没有在别处new出来过——自己 new 的对象没有代理。 - 传播行为对不对? 检查有没有
NOT_SUPPORTED、SUPPORTS。 - 多数据源场景确认事务管理器。 数据源和事务管理器配错,事务会开在另一个库上。
- 实在查不出来就开日志。 把
org.springframework.jdbc.datasource.DataSourceTransactionManager的日志级别调到 DEBUG,正常生效时能看到Creating new transaction;回滚时能看到Rolling back,一条都没有就说明代理没进去。

总结
事务失效排查就一句话:先确认这次调用有没有经过代理,再确认异常有没有以 Spring 认可的方式抛出来。 前者覆盖了方法非 public、同类自调用、自己 new 对象三类;后者覆盖了异常被吞、受检异常不回滚两类。
- 注解标在非 public 方法上会被静默忽略,
final同样会让代理失效; - 同类内部自调用是最高频的原因,正确解法是拆到另一个 Bean;
- 默认只回滚
RuntimeException和Error,受检异常要显式写rollbackFor; - MySQL 的 MyISAM 引擎不支持事务,写法再对也没用;
- 排查时按清单从前往后走,九步之内基本能定位。
延伸学习
- Spring 框架配套课程 —— 从声明式事务讲到传播行为与隔离级别。
- Spring 事务与 AOP 排错笔记 —— 把代理失效的各类边界情况一次列清。
- 在线运行 Java 代码工具 —— 不用配环境,直接编译运行本文的示例代码。
常见问题
Q:把 @Transactional 加在 Controller 上,为什么没效果?
A:先看 Controller 类有没有被组件扫描管理、方法是不是 public。更关键的是,事务边界应该划在 Service 层而不是 Controller 层:一个 Controller 方法往往调用多个 Service,把事务放在 Controller 上会让粒度变粗,一个无关操作失败就把整批操作回滚掉。建议把注解移到 Service 的 public 方法上。
Q:private 方法上加了 @Transactional,真的完全没用吗?
A:是的,注解会被忽略,而且没有任何日志提示。如果这个方法被同一个类里的 public 方法调用,它实际上是在外层方法的事务里执行的(如果外层有的话),看起来"好像生效了",但那是外层事务的功劳,它自己的注解、传播行为、超时配置全都没参与。这也是这类问题难排查的原因。
Q:写成 rollbackFor = Exception.class,会不会把本来该提交的也回滚了?
A:不会。它只是把"触发回滚的异常范围"从运行时异常扩大到所有异常,正常情况下方法顺利执行完,事务照样提交。真正决定提交还是回滚的,是方法有没有把异常抛出来:没抛就提交,抛了且命中范围就回滚。
Q:REQUIRES_NEW 和 REQUIRED 该怎么选?
A:默认用 REQUIRED:有事务就加入,没有就新建一个,绝大多数业务都符合这个预期。只有当你明确需要"内层操作独立于外层,外层回滚不影响内层已提交的结果"时,才用 REQUIRES_NEW,典型场景是记录操作日志——主流程失败了,日志仍然要留下来。注意它会挂起外层事务另起一个,连接开销更大,别随手用。

免费 AI IDE



