原创

Spring事务详解:从@Transactional原理到传播机制实战

为什么很多人会把 Spring 事务用错

@Transactional 看起来只是一个注解,真正起作用的却不是注解本身,而是 Spring 在方法调用链外面包了一层事务逻辑

这也是很多问题的根源:

  • 为什么同类方法内部调用,事务失效
  • 为什么 private 方法上加了 @Transactional 没反应
  • 为什么抛了异常却没有回滚
  • 为什么传播机制一多,排查问题就开始混乱
  • 为什么有人以为“加了事务就一定安全”,结果还是出现脏数据、锁等待、长事务

Spring 事务真正要理解的,不是“怎么写”,而是 它到底在什么时机开启、提交、回滚,以及事务边界到底包住了什么代码


Spring 事务的本质:AOP + 事务管理器

Spring 声明式事务的核心,可以概括为三部分:

  1. AOP 拦截
  2. TransactionManager 事务管理器
  3. TransactionInterceptor 事务拦截器

当你在方法上加了 @Transactional,Spring 并不是直接修改这个方法的字节码,而是通过代理对象拦截方法调用:

  • 方法执行前:根据事务属性决定是否开启事务
  • 方法执行中:目标方法正常运行
  • 方法执行后:根据执行结果决定提交还是回滚

整体流程类似这样:

public Object invoke(MethodInvocation invocation) {
    TransactionStatus status = transactionManager.getTransaction(txAttr);
    try {
        Object result = invocation.proceed();
        transactionManager.commit(status);
        return result;
    } catch (Throwable ex) {
        transactionManager.rollback(status);
        throw ex;
    }
}

这段逻辑不复杂,但它说明了一个关键点:

事务生效的前提,不是方法上有注解,而是这个方法调用必须经过 Spring 代理。

很多事务失效问题,最后都能归到这句话上。


@Transactional 到底做了什么

@Transactional 本身只是元数据,真正被解析的是这些属性:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Transactional {

    Propagation propagation() default Propagation.REQUIRED;

    Isolation isolation() default Isolation.DEFAULT;

    int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;

    boolean readOnly() default false;

    Class<? extends Throwable>[] rollbackFor() default {};

    Class<? extends Throwable>[] noRollbackFor() default {};
}

最常用的几个属性分别控制:

  • propagation:事务传播行为
  • isolation:事务隔离级别
  • timeout:事务超时时间
  • readOnly:是否只读事务
  • rollbackFor:遇到哪些异常回滚
  • noRollbackFor:遇到哪些异常不回滚

真正有实战价值的,通常是这三类:

  • 传播机制
  • 回滚规则
  • 只读与长事务控制

因为业务里最容易踩坑的,基本都集中在这里。


事务是怎么开启的

Spring 在调用事务方法时,会先从 TransactionAttributeSource 读取事务属性,再交给对应的 PlatformTransactionManager 去处理。

常见事务管理器有:

  • DataSourceTransactionManager:单数据源 JDBC 场景
  • JpaTransactionManager:JPA / Hibernate 场景
  • HibernateTransactionManager:传统 Hibernate 场景
  • JtaTransactionManager:分布式事务或容器事务场景

DataSourceTransactionManager 为例,它做的事情大致是:

  1. 从数据源拿到连接
  2. autoCommit 设为 false
  3. 将连接绑定到当前线程
  4. 执行业务 SQL
  5. 成功则 commit
  6. 异常则 rollback
  7. 最后释放连接

这里还有一个非常重要的实现细节:Spring 事务大量依赖 ThreadLocal

也就是说,同一个线程内的后续数据库操作,才能感知到当前事务上下文。这就是为什么事务上下文在同步调用里天然顺手,但一到异步线程、线程池、消息队列,就不能直接指望它自动传递。


代理模式决定了事务能否生效

Spring 事务底层依赖代理,而代理通常有两种:

JDK 动态代理

如果目标类实现了接口,Spring 默认优先使用 JDK 动态代理。

特点:

  • 代理的是接口
  • 通过接口类型注入最稳妥
  • 目标方法通常需要是 public

CGLIB 代理

如果目标类没有接口,或者显式要求使用类代理,Spring 会使用 CGLIB。

特点:

  • 通过继承目标类生成子类代理
  • 不能代理 final
  • 不能拦截 final 方法

所以,下面这些情况,事务通常都不会生效:

  • final 方法
  • private 方法
  • static 方法
  • 同类内部调用未经过代理
  • Bean 没被 Spring 管理

真正容易被忽略的,是第四个。


为什么同类内部调用会导致事务失效

这是最经典的 Spring 事务坑。

看下面的代码:

@Service
public class OrderService {

    @Transactional
    public void createOrder() {
        saveOrder();
    }

    @Transactional
    public void saveOrder() {
        // 保存订单
    }
}

很多人以为 createOrder()saveOrder(),后者的事务也会生效。

实际上不一定。

原因是:createOrder() 内部调用 saveOrder() 用的是 this.saveOrder(),这是对象内部直接调用,没有经过 Spring 代理,所以 saveOrder() 上的事务拦截器根本没有机会执行。

这会带来什么问题

如果两个方法传播行为不同,比如:

@Transactional
public void createOrder() {
    saveOrder();
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOrder() {
    // 希望开启新事务
}

你本来希望 saveOrder() 开启一个新事务,结果因为内部调用没有走代理,REQUIRES_NEW 根本没生效。

这类问题特别隐蔽,因为代码看起来完全“正确”。

常见解决方式

方式一:拆到另一个 Bean

这是最推荐的方式。

@Service
public class OrderService {

    @Autowired
    private OrderTxService orderTxService;

    public void createOrder() {
        orderTxService.saveOrder();
    }
}

@Service
public class OrderTxService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveOrder() {
        // 保存订单
    }
}

因为跨 Bean 调用会经过代理,所以事务才能生效。

方式二:获取当前代理对象再调用

@EnableAspectJAutoProxy(exposeProxy = true)
((OrderService) AopContext.currentProxy()).saveOrder();

这个方案能用,但不够干净,耦合了 AOP 细节。除非是历史代码修补,否则不建议作为常规设计。


为什么抛了异常却没有回滚

这也是第二大高频误区。

Spring 默认的回滚规则是:

  • 遇到 RuntimeExceptionError 才回滚
  • 遇到受检异常(Exception)默认不回滚

看代码:

@Transactional
public void createOrder() throws Exception {
    save();
    throw new Exception("业务异常");
}

很多人直觉上觉得这里会回滚。

实际上默认不会。

因为 Exception 是受检异常,不属于 Spring 默认回滚的异常类型。

正确写法

@Transactional(rollbackFor = Exception.class)
public void createOrder() throws Exception {
    save();
    throw new Exception("业务异常");
}

还有一种更隐蔽的情况:异常被吃掉了

@Transactional
public void createOrder() {
    try {
        save();
        int i = 1 / 0;
    } catch (Exception e) {
        log.error("异常", e);
    }
}

这里也不会回滚。

因为异常没有抛出到事务拦截器外层,Spring 会认为方法正常结束,于是直接提交事务。

这类代码在“日志打得很全”的项目里特别常见。开发以为自己处理得很稳,数据库却已经提交了半截数据。

想手动标记回滚怎么办

TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

例如:

@Transactional
public void createOrder() {
    try {
        save();
        riskyOperation();
    } catch (Exception e) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        log.error("异常", e);
    }
}

这时即使异常被 catch 了,事务也会回滚。

但这个方案也不该滥用。能直接抛异常时,优先直接抛,让事务边界保持清晰。


传播机制才是事务真正复杂的地方

传播机制解决的是一个问题:

当前方法执行时,如果外部已经存在事务,应该怎么处理?

Spring 提供了 7 种传播行为,但真正常用的没有那么多。把它们全背下来意义不大,先把常见的几种吃透更重要。


REQUIRED:默认值,加入当前事务

@Transactional(propagation = Propagation.REQUIRED)

语义:

  • 当前有事务,就加入当前事务
  • 当前没有事务,就新建事务

这是默认传播行为,也是业务代码里最常用的。

适用场景

同一个业务动作内,多个数据库操作必须一起成功、一起失败。

例如创建订单:

@Transactional
public void createOrder() {
    orderDao.insert(order);
    orderItemDao.batchInsert(items);
    stockDao.lockStock(productId, count);
}

这三个操作本来就应该在一个事务里,REQUIRED 是最自然的选择。

风险点

REQUIRED 方便,但也容易把事务范围越包越大。

比如一个方法里:

  • 查数据库
  • 调第三方接口
  • 发消息
  • 做复杂计算
  • 最后再更新数据库

如果整段都放在一个事务里,问题就来了:

  • 数据库连接长时间占用
  • 锁持有时间过长
  • 并发下降
  • 死锁概率升高
  • 事务失败回滚成本变大

事务不是越大越安全,通常是 越精确越安全


REQUIRES_NEW:挂起当前事务,开启新事务

@Transactional(propagation = Propagation.REQUIRES_NEW)

语义:

  • 无论外部有没有事务,都会开启一个新的独立事务
  • 如果外部有事务,先挂起外部事务

这是实战里很有用、也很容易被误用的一种传播行为。

典型场景:记录日志、审计、补偿信息

@Service
public class OrderService {

    @Autowired
    private AuditService auditService;

    @Transactional
    public void createOrder() {
        orderDao.insert(order);
        auditService.record(order);
        int i = 1 / 0;
    }
}
@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(Order order) {
        auditDao.insert(order);
    }
}

结果是:

  • record() 会在自己的事务里提交
  • createOrder() 外层异常回滚
  • 审计日志仍然保留

这很适合做:

  • 操作审计
  • 异常记录
  • 补偿任务登记
  • 失败原因留痕

风险点

REQUIRES_NEW 不是“更强的事务”,而是“另起一摊”。

所以它的副作用也很明显:

  • 外层回滚,不影响内层已提交事务
  • 容易造成业务数据和日志数据不一致
  • 嵌套层级一多,调用链变得难以推断

真正麻烦的地方不在语法,而在于你以为“都是一个流程”,但数据库里已经变成了多个独立提交点。


SUPPORTS:有事务就加入,没有就非事务运行

@Transactional(propagation = Propagation.SUPPORTS)

语义:

  • 当前有事务,则加入
  • 当前没有事务,则直接以非事务方式执行

这通常适合 读操作

比如查询类服务:

@Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
public OrderDetail queryOrder(Long orderId) {
    return orderDao.findDetail(orderId);
}

这种写法的好处是:

  • 如果外层本来有事务,就沿用上下文
  • 如果没有,也不强制开事务

但它不适合需要强一致性的写操作。因为没有外部事务时,它根本不会自己开启事务。


MANDATORY:必须在事务里运行

@Transactional(propagation = Propagation.MANDATORY)

语义:

  • 当前必须已经存在事务
  • 如果没有事务,直接抛异常

适合那些 明确要求只能作为事务子步骤执行 的方法。

例如:

@Transactional(propagation = Propagation.MANDATORY)
public void deductStock(Long productId, Integer count) {
    stockDao.deduct(productId, count);
}

这个方法不允许被独立调用,必须放在订单创建、支付确认之类的事务流程中执行。

这种传播行为在大型项目里很有价值,因为它能把“调用约束”显式表达出来,而不是靠文档提醒。


NOT_SUPPORTED:挂起事务,以非事务方式执行

@Transactional(propagation = Propagation.NOT_SUPPORTED)

语义:

  • 如果当前有事务,先挂起
  • 当前方法以非事务方式执行

适合那些 不应该被事务包住的耗时逻辑,例如:

  • 调外部 HTTP 接口
  • 查询不要求一致性的报表数据
  • 执行耗时计算
  • 文件处理

例如:

@Transactional
public void processOrder() {
    orderDao.updateStatus(orderId, "PROCESSING");
    remoteService.call();
}

如果 remoteService.call() 很慢,事务就会一直占着连接和锁。

可以改成:

@Transactional
public void processOrder() {
    orderDao.updateStatus(orderId, "PROCESSING");
    remoteService.callWithoutTransaction();
}
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void callWithoutTransaction() {
    remoteService.call();
}

当然,真正更常见的优化方式是直接把事务边界重新切小,而不是机械地到处加 NOT_SUPPORTED


NEVER:必须在无事务环境下执行

@Transactional(propagation = Propagation.NEVER)

语义:

  • 当前如果存在事务,直接抛异常
  • 必须在非事务环境执行

这个用得不多,但适合那些明确不能被事务包住的逻辑,比如某些只允许独立执行的底层操作。


NESTED:嵌套事务不是新事务

这是最容易被误解的一种。

@Transactional(propagation = Propagation.NESTED)

语义:

  • 如果当前没有事务,就新建事务
  • 如果当前已有事务,则在当前事务内建立一个保存点(Savepoint)

重点来了:

NESTED 不是开启一个全新的独立事务,它仍然属于外层事务的一部分。

这意味着:

  • 内层可以回滚到保存点
  • 外层仍然可以决定整个事务最终提交或回滚

REQUIRES_NEW 的区别

NESTED

  • 仍属于外层事务
  • 依赖数据库保存点
  • 外层最终回滚,内层也保不住

REQUIRES_NEW

  • 完全独立新事务
  • 外层挂起
  • 内层提交后,不受外层回滚影响

很多文章会把这两个写得很像,实战里它们差别很大。

示例理解

@Transactional
public void outer() {
    dao.insertA();

    try {
        inner();
    } catch (Exception e) {
        log.error("inner error", e);
    }

    dao.insertB();
}
@Transactional(propagation = Propagation.NESTED)
public void inner() {
    dao.insertC();
    throw new RuntimeException("fail");
}

执行效果通常是:

  • insertC() 回滚到保存点
  • insertA()insertB() 仍可继续
  • 如果 outer() 最终提交,则 A、B 生效,C 失败
  • 如果 outer() 最终整体回滚,则 A、B、C 全部回滚

所以 NESTED 更像“局部失败可回退”,不是“局部独立提交”。


一张表看懂传播机制

传播行为 当前有事务 当前无事务 常见用途
REQUIRED 加入当前事务 新建事务 默认业务方法
REQUIRES_NEW 挂起外层,新建事务 新建事务 审计、日志、补偿记录
SUPPORTS 加入当前事务 非事务执行 查询方法
MANDATORY 加入当前事务 抛异常 强制只能在事务内执行
NOT_SUPPORTED 挂起外层,非事务执行 非事务执行 外部调用、耗时逻辑
NEVER 抛异常 非事务执行 强制非事务运行
NESTED 建立保存点 新建事务 局部回滚

隔离级别不要只会背概念

事务隔离级别主要解决并发读写问题,Spring 中可以这样设置:

@Transactional(isolation = Isolation.READ_COMMITTED)

常见级别:

  • READ_UNCOMMITTED
  • READ_COMMITTED
  • REPEATABLE_READ
  • SERIALIZABLE
  • DEFAULT

真正要注意的是:Spring 只是声明隔离级别,最终是否支持、表现如何,取决于底层数据库

例如:

  • MySQL InnoDB 默认通常是 REPEATABLE_READ
  • PostgreSQL 默认通常是 READ_COMMITTED

所以同一段 Spring 事务代码,换数据库后,行为可能不完全一样。

如果文章重点不在并发控制,其实没必要把脏读、不可重复读、幻读写成长篇理论。业务开发真正更关心的是:

  • 当前数据库默认是什么
  • 这个级别是否足够
  • 是否会引入更重的锁竞争
  • 有没有必要靠业务约束或乐观锁替代高隔离级别

因为很多线上性能问题,不是“不够隔离”,而是“隔离开太高了”。


readOnly = true 不是性能魔法

很多人会把只读事务理解成“查询自动加速开关”,这不准确。

@Transactional(readOnly = true)
public Order query(Long id) {
    return orderDao.findById(id);
}

readOnly = true 的意义主要有几个:

  • 向底层框架表达“这是读操作”
  • 某些 ORM 会减少不必要的脏检查
  • 某些数据库或驱动会做相应优化
  • 帮助开发者表达意图

但它不是万能优化按钮。

例如在 JPA/Hibernate 场景里,它可能帮助减少 flush 和脏检查成本;但在普通 JDBC 场景里,收益通常没有想象中大。

真正重要的是:

只读事务的价值,很多时候在于约束语义和减少误操作,而不是神奇提速。


一个完整示例:下单、扣库存、写审计日志

下面用一个稍微真实一点的例子,把传播行为串起来。

@Service
public class OrderService {

    @Autowired
    private StockService stockService;

    @Autowired
    private AuditService auditService;

    @Transactional
    public void createOrder(Order order) {
        saveOrder(order);
        stockService.deduct(order.getProductId(), order.getCount());
        auditService.recordSuccess(order);

        if (true) {
            throw new RuntimeException("模拟下单失败");
        }
    }

    private void saveOrder(Order order) {
        // 保存订单
    }
}
@Service
public class StockService {

    @Transactional(propagation = Propagation.MANDATORY)
    public void deduct(Long productId, Integer count) {
        // 扣库存
    }
}
@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordSuccess(Order order) {
        // 记录审计日志
    }
}

运行结果

  • createOrder() 开启主事务
  • deduct() 必须加入主事务
  • recordSuccess() 开启独立事务并提交
  • 最后 createOrder() 抛异常,主事务回滚

最终结果通常是:

  • 订单回滚
  • 库存扣减回滚
  • 审计日志保留

这就很适合“主业务失败,但仍需要留下审计痕迹”的场景。

但也必须接受一个现实:你的日志记录的是一次最终失败的操作。如果日志表设计不区分状态,很容易给排查制造误导。

所以更好的做法通常是记录更明确的事件状态,例如:

  • ORDER_CREATE_ATTEMPT
  • ORDER_CREATE_FAILED
  • ORDER_CREATE_SUCCESS

而不是只写一条模糊的“订单已创建”。


Spring 事务常见失效场景

这一段比传播机制更值得反复检查,因为线上问题多数出在这里。

1. 方法不是 public

Spring 基于代理拦截方法调用,private 方法通常不会被代理增强。

@Transactional
private void save() {
}

通常无效。

2. 同类内部调用

前面已经讲过,这是最高频问题。

3. Bean 没交给 Spring 管理

OrderService orderService = new OrderService();
orderService.createOrder();

这种自己 new 出来的对象,不经过 Spring 容器,也就没有代理,自然没有事务。

4. 异常类型不匹配

抛了受检异常但没配 rollbackFor

5. 异常被捕获后没继续抛出

日志打了,事务提交了。

6. 多线程 / 异步调用导致事务上下文丢失

@Transactional
public void process() {
    asyncService.doAsync();
}

如果 doAsync() 是异步线程执行,它默认不会继承当前事务上下文。

因为事务资源通常绑定在当前线程。

7. 数据库引擎本身不支持事务

例如 MySQL 早期使用 MyISAM,就没有事务能力。代码层面写得再漂亮,也没用。

8. 错误理解 readOnly

以为只读事务里更新语句一定会被拦截。实际上是否严格限制,要看具体框架和数据库实现,不能把它当强一致约束手段。


调试事务问题时,应该看什么

很多人排查事务问题时,只盯着业务代码看。其实更有效的是看这几个点:

看代理有没有生效

确认这个类是否被 Spring 管理,调用是否走了代理,而不是对象内部直接调用。

看事务管理器是不是对的

项目里如果有多个数据源、多个事务管理器,可能你加了 @Transactional,却用错了事务管理器。

例如:

@Transactional(transactionManager = "orderTransactionManager")

多数据源项目里,这个配置经常不能省。

看异常有没有抛到代理层

事务能不能回滚,不取决于你日志里有没有打印异常,而取决于异常有没有真正穿透方法边界。

看 SQL 执行时机

尤其在 JPA/Hibernate 场景里,要分清:

  • 方法里调用了 save
  • 不代表 SQL 已经立刻发到数据库
  • 可能在 flush 时才真正执行

这会影响你对异常位置和回滚时机的判断。

开启事务日志

例如配置日志级别查看 Spring 事务细节:

logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG

这些日志对定位“事务有没有开启、何时提交、是否回滚”非常直接。


实战中的几个建议,比死记传播机制更有用

事务边界尽量放在 Service 层

Controller 层不适合开事务,DAO 层也不适合四处散落事务边界。大多数情况下,事务应该围绕一个完整业务动作,在 Service 层统一定义。

事务方法尽量短

不要把这些操作塞进一个事务里:

  • 远程调用
  • 文件上传
  • 大量循环处理
  • 长时间等待

事务越长,锁和连接占用越糟糕。

不要迷信“大事务包一切”

很多一致性问题,其实应该通过:

  • 状态机
  • 补偿机制
  • 消息最终一致性
  • 幂等设计

来解决,而不是试图把所有动作都塞进一个本地事务。

本地事务能解决的是 单库、单资源、短链路 内的一致性问题。跨服务之后,思路就得换。

REQUIRES_NEW 要慎用

它适合做独立留痕,不适合拿来“补锅”。如果一个系统里到处都是 REQUIRES_NEW,通常说明事务边界设计已经开始混乱了。

对只读查询明确标注 readOnly = true

不是为了赌性能,而是为了表达语义。代码可读性和团队协作里,这个收益其实不小。


总结

Spring 事务难的从来不是 @Transactional 这个注解本身,而是三个问题:

  1. 调用有没有经过代理
  2. 异常能不能正确触发回滚
  3. 传播行为是否符合真实业务边界

把这三个点吃透,很多事务问题其实都不神秘。

再往前一步,真正成熟的事务设计也不只是“会用传播机制”,而是知道:

  • 哪些操作必须放在一个事务里
  • 哪些操作绝对不能放进长事务
  • 哪些失败该回滚
  • 哪些记录应该独立提交
  • 哪些一致性已经不是本地事务该解决的问题

很多项目的事务问题,表面看是注解没配好,实质上是 业务边界没画清楚

正文到此结束
评论插件初始化中...
Loading...