spring 事务失效怎么回事?附 5 个常见原因+排查清单

编程狮(w3cschool.cn) 2026-08-31 17:33:08 浏览数 (53)
反馈

Spring 事务失效,绝大多数情况下不是 Spring 的 bug,而是注解虽然写了、却根本没有走到代理逻辑上。最常见的三类是:方法不是 public、在类内部用 this 自调用、异常被 catch 吞掉或者抛的是受检异常。三者都会让回滚逻辑压根没执行,数据却已经提交进库了。

明明加了注解、异常也抛了,数据库里却留下了一半的数据——这是学 Spring 时最让人抓狂的场景之一。更难受的是它不报错、不告警,只有对账时才发现数据对不上。今天这篇文章,编程狮就把五个真实原因逐个拆开,每个都给出反例代码和正确写法,最后给你一份可以照着一步步走的排查清单。

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 的——注解就是一行摆设。

误解二:异常抛了就一定会回滚。 默认只对 RuntimeExceptionError 回滚。抛一个 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(最推荐)。 职责本来就该分开,顺带让调用重新经过代理(OrderOrderDaoCouponDao 沿用上一份定义):

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 默认只对 RuntimeExceptionError 回滚,受检异常(比如 IOExceptionSQLException)抛出去事务照样提交:

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

按这个顺序查,绝大多数问题在前三步就能定位:

  1. 方法是 public 吗?类和方法有没有 final 不是就改。
  2. 这次调用是同类内部的吗? 搜索方法体里有没有 this.xxx(),有就按第三节拆类。
  3. 异常真的抛出来了吗?catch 块里有没有重新抛出或标记回滚。
  4. 抛的是受检异常吗? 是就补 rollbackFor
  5. 表引擎是 InnoDB 吗? 跑一次 SHOW TABLE STATUS 确认。
  6. 这个类是被 Spring 管理的吗? 有没有 @Service,有没有在别处 new 出来过——自己 new 的对象没有代理。
  7. 传播行为对不对? 检查有没有 NOT_SUPPORTEDSUPPORTS
  8. 多数据源场景确认事务管理器。 数据源和事务管理器配错,事务会开在另一个库上。
  9. 实在查不出来就开日志。org.springframework.jdbc.datasource.DataSourceTransactionManager 的日志级别调到 DEBUG,正常生效时能看到 Creating new transaction;回滚时能看到 Rolling back,一条都没有就说明代理没进去。

Spring 事务失效的五个常见原因与解法

总结

事务失效排查就一句话:先确认这次调用有没有经过代理,再确认异常有没有以 Spring 认可的方式抛出来。 前者覆盖了方法非 public、同类自调用、自己 new 对象三类;后者覆盖了异常被吞、受检异常不回滚两类。

  • 注解标在非 public 方法上会被静默忽略,final 同样会让代理失效;
  • 同类内部自调用是最高频的原因,正确解法是拆到另一个 Bean;
  • 默认只回滚 RuntimeExceptionError,受检异常要显式写 rollbackFor
  • MySQL 的 MyISAM 引擎不支持事务,写法再对也没用;
  • 排查时按清单从前往后走,九步之内基本能定位。

延伸学习

  1. Spring 框架配套课程 —— 从声明式事务讲到传播行为与隔离级别。
  2. Spring 事务与 AOP 排错笔记 —— 把代理失效的各类边界情况一次列清。
  3. 在线运行 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,典型场景是记录操作日志——主流程失败了,日志仍然要留下来。注意它会挂起外层事务另起一个,连接开销更大,别随手用。

0 人点赞