Spring 事务失效的六种常见场景分析与解决方案
在 Spring 应用程序开发中,声明式事务(@Transactional)是保证数据一致性的核心机制。然而,在实际应用中,开发者常遇到注解已添加但事务未回滚的情况。本文基于最近在领券业务中遇到的并发与事务冲突问题,深入分析 Spring 事务失效的六种典型场景,并提供相应的解决方案。
场景一:事务方法访问权限非 public
问题描述
将 @Transactional 注解应用于非 public 修饰的方法(如 protected、private 或包级私有方法)时,事务将失效。
示例代码
@Service
public class UserService {
@Transactional
protected void updateUser(User user) {
// 业务逻辑
}
}
原因分析
Spring 的声明式事务依赖于 AOP。Spring AOP 的默认实现(无论是 JDK 动态代理还是 CGLIB)通常要求目标方法必须是public,以便代理对象能够正确拦截并增强该方法。
此外,Spring 源码中的 AbstractFallbackTransactionAttributeSource.computeTransactionAttribute 方法显式规定了仅处理 public 方法:
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null; // 非 public 方法返回 null,不应用事务配置
}
解决方案
确保被 @Transactional 注解修饰的方法访问权限为 public。
场景二:同一类内部方法自调用
这是开发中最易被忽视的失效原因。
问题描述
在一个没有事务注解的普通方法内部,直接调用同一类中被 @Transactional 注解修饰的方法,事务将失效。
示例代码
@Service
public class UserCouponServiceImpl implements IUserCouponService {
// 入口方法,无事务注解,但在内部调用了事务方法
@Override
public void receiveCoupon(Long couponId) {
// ... 前置校验
// 【关键点】内部直接调用事务方法,事务失效
this.saveUserCouponAndUpdateCoupon(coupon, userId);
}
@Transactional
public void saveUserCouponAndUpdateCoupon(Coupon coupon, Long userId){
// 数据库操作:扣减库存、保存记录
couponMapper.plusIssueNum(couponId);
save(userCoupon);
}
}
原因分析:为什么 this 调用会导致事务失效?
Spring 的声明式事务是基于 AOP 和 动态代理 实现的。
- 代理对象的拦截机制:当 Spring 容器启动时,会为使用了
@Transactional的 Bean 创建一个代理对象。这个代理对象持有一个指向原始目标对象(Target,即UserCouponServiceImpl实例)的引用。 - 外部调用的流程:当 Controller 调用
userCouponService.receiveCoupon(...)时,实际上是在调用代理对象的方法。代理对象会检查该方法是否有@Transactional注解。- 如果有,代理对象会在调用目标方法前开启事务,调用后提交事务。
- 如果没有(如
receiveCoupon),代理对象会直接将请求转发给原始目标对象。
- 内部调用的陷阱:一旦进入原始目标对象的方法内部(如
receiveCoupon执行中),代码执行流就已经脱离了代理对象的控制。此时,代码中直接调用的saveUserCouponAndUpdateCoupon(...)等同于this.saveUserCouponAndUpdateCoupon(...)。这里的this指向的是原始目标对象本身,而不是代理对象。 - 结论:由于绕过了代理对象直接使用service对象,Spring 的事务拦截器无法介入,最终导致AOP实现的事务逻辑根本没有执行。
解决方案
方案 A:直接给入口方法添加事务注解(常规解法)
最简单的解决方法是给入口方法 receiveCoupon 也添加 @Transactional 注解。这样,事务在进入 receiveCoupon 时就已经开启,后续的调用都在同一个事务上下文中运行。
@Transactional // 简单粗暴,直接加事务
public void receiveCoupon(Long couponId) {
saveUserCouponAndUpdateCoupon(coupon, userId);
}
但是,在并发场景下,这种方案往往不可行。
方案 B:强制使用代理对象调用(高并发场景推荐)
在我的领券业务中,为了防止超卖,我们需要使用 synchronized 锁来控制并发。
- 如果使用方案 A:事务包裹了锁(Transaction 包含 synchronized)。
- 执行顺序:
开启事务 -> 加锁 -> 执行业务 -> 解锁 -> 提交事务。 - 风险:线程 A 解锁后,事务尚未提交。此时线程 B 获取锁并读取数据,读到的仍然是旧数据(因为线程 A 的事务还没提交),导致锁失效,发生超卖。
- 执行顺序:
- 为了解决锁失效:我们必须保证锁的范围大于事务(synchronized 包含 Transaction)。
- 执行顺序:
加锁 -> 开启事务 -> 执行业务 -> 提交事务 -> 解锁。 - 这就要求
receiveCoupon方法不能加事务注解(它是加锁的地方),只有内部调用的saveUserCouponAndUpdateCoupon方法需要加事务。
- 执行顺序:
这就回到了最初的问题:内部调用会导致事务失效。
为了同时满足“锁包事务”和“事务生效”两个条件,我们必须在 receiveCoupon 内部,手动获取当前的代理对象来调用事务方法,强行让调用逻辑重新经过 Spring AOP 的拦截器链。
实现步骤:
-
引入 AspectJ 依赖:
<dependency> <groupId>org.aspectjgroupId> <artifactId>aspectjweaverartifactId> dependency> -
开启代理暴露(在启动类或配置类):
@EnableAspectJAutoProxy(exposeProxy = true) -
使用 AopContext 获取代理对象并调用:
参考 UserCouponServiceImpl.java 中的实现:public void receiveCoupon(Long couponId) { // ... 省略校验逻辑 synchronized (userId.toString().intern()) { // 【核心代码】从 ThreadLocal 中获取当前 AOP 代理对象 IUserCouponService proxy = (IUserCouponService) AopContext.currentProxy(); // 通过代理对象调用,触发事务切面逻辑 proxy.saveUserCouponAndUpdateCoupon(coupon, userId); } }
通过这种在service调用的方法使用代理对象调用的方式,我们既控制了事务的粒度(只包裹核心数据库操作),又避免了内部调用绕过代理机制导致的事务失效,完美解决了高并发下的数据一致性问题。
场景三:事务方法内部捕获异常且未抛出
问题描述
在事务方法内部使用 try-catch 块捕获了异常,且在 catch 块中未再次抛出异常,导致事务提交而非回滚。
示例代码
@Service
public class OrderService {
@Transactional
public void createOrder() {
try {
insertOrder();
reduceStock(); // 假设此处抛出异常
} catch (Exception e) {
e.printStackTrace();
// 异常被吞噬,未向外抛出
}
}
}
原因分析
Spring AOP 代理对象在调用目标方法后,会检查方法执行过程中是否抛出了异常。
- 如果捕获到异常,且异常类型符合回滚规则,则执行回滚。
- 如果目标方法内部自行处理了异常(即 catch 后未抛出),代理对象将认为方法执行成功,从而提交事务。
解决方案
- 避免吞噬异常:在
catch块处理完日志或其他逻辑后,务必将异常再次抛出。 - 手动标记回滚:如果业务逻辑要求不能抛出异常,则必须在
catch块中手动标记事务状态为回滚:TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
场景四:异常类型不匹配
问题描述
方法抛出了异常,但异常类型是检查型异常(Checked Exception,如 IOException、SQLException),而 @Transactional 使用了默认配置。
示例代码
@Service
public class OrderService {
@Transactional // 使用默认配置
public void createOrder() throws IOException {
insertOrder();
if (errorCondition) {
throw new IOException("IO Error");
}
}
}
原因分析
Spring 的 @Transactional 注解默认配置的 rollbackFor 属性仅包含 RuntimeException 和 Error。这意味着,对于所有继承自 Exception 但非 RuntimeException 的检查型异常,Spring 默认不会触发回滚。
解决方案
显式配置 rollbackFor 属性,建议指定为 Exception.class 以覆盖所有异常类型:
@Transactional(rollbackFor = Exception.class)
场景五:事务传播行为配置错误
问题描述
在嵌套事务场景中,内部方法的传播行为配置导致其事务独立于外部事务,从而破坏了整体原子性。
示例代码
@Service
public class OrderService {
@Transactional
public void createOrder(){
insertOrder();
try {
stockService.reduceStock(); // 即使外部回滚,此方法可能已提交
} catch (Exception e) {
// ...
}
throw new RuntimeException("Error");
}
}
@Service
public class StockService {
// REQUIRES_NEW 开启独立事务
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void reduceStock() {
// ...
}
}
原因分析
Propagation.REQUIRES_NEW 策略会挂起当前事务,并开启一个新的物理事务。即使外部调用方(createOrder)后续发生异常并回滚,reduceStock 方法的独立事务一旦提交,其数据变更将永久生效,导致数据不一致。
解决方案
根据业务一致性需求正确选择传播行为。对于大多数需要保证原子性的组合操作,应使用默认的 Propagation.REQUIRED,确保所有方法在同一个逻辑事务中运行。
场景六:类未被 Spring 容器管理
问题描述
调用 @Transactional 方法的对象实例并非由 Spring 容器创建和管理。
示例代码
// 缺少 @Service 或 @Component 注解
public class OrderService {
@Transactional
public void createOrder() {
// ...
}
}
或者:
OrderService service = new OrderService(); // 手动 new 实例
service.createOrder();
原因分析
Spring 的声明式事务完全依赖于 IoC 容器对 Bean 的生命周期管理和 AOP 代理生成。如果一个类没有被注册为 Spring Bean(缺少 @Service、@Component 等注解),或者对象是通过 new 关键字手动实例化的,Spring 容器无法感知该对象,也就无法为其创建代理并织入事务切面逻辑。
解决方案
- 确保业务类上添加了
@Service、@Component等组件注解。 - 在其他组件中使用该类时,必须通过依赖注入(
@Autowired或构造器注入)获取实例,严禁手动实例化。
总结
Spring 事务失效问题通常源于对 Spring AOP 代理机制理解的偏差。在排查此类问题时,应重点关注以下三个维度:
- 代理机制:是否存在对象自调用、类是否被容器管理、方法可见性是否合规。
- 异常处理:异常是否被捕获吞噬、异常类型是否在回滚范围内。
- 事务配置:传播行为是否符合业务预期。
在我的项目领券业务场景中,我们为了兼顾并发控制(synchronized)和事务原子性,采用了手动获取代理对象(AopContext.currentProxy())的方案,有效解决了自调用导致的事务失效问题。
如果这篇文章对你有帮助,请 点赞、收藏、关注 一波!你的支持是我持续输出高质量技术干货的最大动力!!!
相关阅读:
《天机学堂day10笔记》









