Skip to content

Spring 事务详解

很多人会用 @Transactional,但如果继续追问:它到底为什么能生效,什么时候会失效,传播行为又在说什么?

就很容易开始发虚。

这篇文章专门把 Spring 事务这条主线单独拉出来,重点讲:

  1. Spring 事务到底是什么
  2. 它解决什么问题
  3. 声明式事务为什么能工作
  4. 传播行为和隔离级别到底在控制什么
  5. @Transactional 常见失效场景有哪些

1. Spring 事务到底是什么

Spring 事务不是数据库事务本身,而是 Spring 对事务边界、传播行为、回滚规则和资源管理的一套统一抽象。数据库事务真正执行时,底层仍然依赖数据库能力;Spring 做的是把事务从零散的手动控制,变成统一、可声明、可治理的框架能力。


2. 它主要解决什么问题

如果没有 Spring 事务,业务代码经常会变成:

  1. 手动开启事务
  2. 调很多数据库操作
  3. 出错时手动回滚
  4. 最后手动提交

这种方式的问题非常明显:

  1. 样板代码多
  2. 事务边界容易混乱
  3. 出错时容易漏回滚
  4. 横切到很多业务方法里

Spring 事务要解决的核心问题就是:让事务边界以更统一、更声明式的方式表达出来。


3. 声明式事务和编程式事务有什么区别

3.1 声明式事务

这是最常见的方式。

例如:

java
@Transactional
public void createOrder() {
}

它的重点是:你声明这里需要事务,具体开启、提交、回滚由框架处理。

例如一个更完整的订单示例:

java
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final StockService stockService;

    public OrderService(OrderRepository orderRepository, StockService stockService) {
        this.orderRepository = orderRepository;
        this.stockService = stockService;
    }

    @Transactional
    public void createOrder(CreateOrderCommand command) {
        orderRepository.save(command.toOrder());
        stockService.deduct(command.getProductId(), command.getCount());
    }
}

3.2 编程式事务

它更偏:由开发者自己控制事务开始、提交和回滚。

它更灵活,但侵入性更强。

例如更常见的工程写法是用 TransactionTemplate

java
@Service
public class OrderApplicationService {

    private final TransactionTemplate transactionTemplate;
    private final OrderRepository orderRepository;
    private final StockService stockService;

    public OrderApplicationService(
            TransactionTemplate transactionTemplate,
            OrderRepository orderRepository,
            StockService stockService) {
        this.transactionTemplate = transactionTemplate;
        this.orderRepository = orderRepository;
        this.stockService = stockService;
    }

    /**
     * createOrder
     * 功能:通过编程式事务完成“保存订单 + 扣减库存”。
     * 核心职责:
     * 1. 显式声明事务边界
     * 2. 在同一个事务里执行订单写入和库存扣减
     * 3. 出现异常时主动标记回滚
     * 参数:
     * - command: 创建订单命令
     * 返回值:
     * - Long: 新订单 ID
     */
    public Long createOrder(CreateOrderCommand command) {
        return transactionTemplate.execute(status -> {
            try {
                Order order = command.toOrder();
                orderRepository.save(order);

                stockService.deduct(command.getProductId(), command.getCount());

                return order.getId();
            } catch (Exception exception) {
                status.setRollbackOnly();
                throw exception;
            }
        });
    }
}

这段代码里最值得注意的是:

  1. 事务边界不是由 @Transactional 隐式声明,而是由 transactionTemplate.execute(...) 明确包起来
  2. status.setRollbackOnly() 表示当前事务应当回滚
  3. 这类方式通常更适合“事务边界需要在代码里显式控制”的场景

它常见于:

  1. 需要在同一个方法里分段控制事务
  2. 事务逻辑不是很适合直接挂在某个公开方法上
  3. 希望把事务控制和业务分支判断写在一起

所以实际工程里更常见的,还是声明式事务。


4. @Transactional 为什么能工作

这条线一定要和 AOP 连起来看。

Spring 声明式事务通常依赖代理机制。也就是说:

  1. 业务方法本身不直接管理事务
  2. Spring 为目标对象创建代理
  3. 请求先进入代理
  4. 代理在方法前后织入事务开启、提交和回滚逻辑

所以 @Transactional 真正能生效,不是因为注解本身有魔法,而是因为方法调用经过了 Spring 代理对象。

4.1 @Transactional 常用属性

这部分在实际开发里很常见:

属性作用
propagation指定传播行为
isolation指定隔离级别
rollbackFor指定哪些异常回滚
noRollbackFor指定哪些异常不回滚
readOnly声明只读事务
timeout指定超时时间

例如:

java
@Transactional(
        propagation = Propagation.REQUIRED,
        isolation = Isolation.READ_COMMITTED,
        rollbackFor = Exception.class,
        timeout = 5)
public void pay(Long orderId) {
}

5. 事务边界到底在说什么

事务边界 可以理解成 哪些数据库操作应该被视为同一个整体,要么一起成功,要么一起失败

例如创建订单时,可能同时涉及:

  1. 写订单表
  2. 扣库存
  3. 写支付记录

这时真正要先想清楚的是:这几步到底是不是一个事务整体。

事务问题首先是业务边界问题,然后才是框架注解问题。

例如下面这种写法就更接近合理事务边界:

java
@Transactional
public void payOrder(Long orderId) {
    orderRepository.updateStatus(orderId, "PAID");
    paymentRepository.savePaymentLog(orderId);
}

但如果把远程调用、长时间等待也塞进事务里,就会把事务边界拉得过长:

java
@Transactional
public void payOrder(Long orderId) {
    orderRepository.updateStatus(orderId, "PAYING");
    remotePaymentClient.call(orderId);
    Thread.sleep(3000);
    paymentRepository.savePaymentLog(orderId);
}

6. 传播行为到底在控制什么

传播行为最常见的场景是:一个已经在事务中的方法,又调用了另一个事务方法。

这时要回答的问题是:后进来的这个方法,到底加入当前事务,还是单独开新事务。

这就是传播行为。

6.1 REQUIRED:必须加入当前事务,若没有则新建

REQUIRED 可以理解成“需要事务就复用,没有事务就创建”。

它是最常见的传播行为,也是默认值。

含义是:

  1. 当前有事务,就加入当前事务
  2. 当前没有事务,就新建一个事务

一个更具体的 REQUIRED 示例

最常见的场景就是:下单方法里调用扣库存方法,而这两步本来就应该属于同一个事务整体。

java
@Service
public class OrderApplicationService {

    private final OrderService orderService;
    private final StockService stockService;

    public OrderApplicationService(OrderService orderService, StockService stockService) {
        this.orderService = orderService;
        this.stockService = stockService;
    }

    @Transactional
    public void createOrder(CreateOrderCommand command) {
        orderService.saveOrder(command);              // REQUIRED,加入当前事务
        stockService.deductStock(command);            // REQUIRED,加入当前事务
    }
}
java
@Service
public class StockService {

    @Transactional(propagation = Propagation.REQUIRED)
    public void deductStock(CreateOrderCommand command) {
        // 扣库存
        throw new IllegalStateException("库存不足");
    }
}

这段代码里,createOrder()deductStock() 最终属于同一个事务。

所以执行结果是:

  1. 外层方法先保存订单
  2. 内层扣库存抛异常
  3. 整个事务一起回滚
  4. 订单数据和库存数据都不会提交

这就是 REQUIRED 最适合记住的印象:本来就是同一笔业务,就一起成,要么一起败。

6.2 REQUIRES_NEW:总是开启一个全新的事务

REQUIRES_NEW 可以理解成“挂起外层事务,自己单独开一个新事务”。

含义是:

  1. 不管外面有没有事务
  2. 自己都新开一个独立事务

一个更具体的 REQUIRES_NEW 示例

它特别常见于:主业务失败了,但审计日志、操作留痕、补偿记录仍然希望单独保存。

java
@Service
public class OrderFacade {

    private final OrderService orderService;
    private final AuditService auditService;

    public OrderFacade(OrderService orderService, AuditService auditService) {
        this.orderService = orderService;
        this.auditService = auditService;
    }

    @Transactional
    public void create(CreateOrderCommand command) {
        orderService.saveOrder(command);          // REQUIRED,加入外层事务
        auditService.recordOperation(command);    // REQUIRES_NEW,单独新开事务
        throw new IllegalStateException("后续流程失败");
    }
}
java
@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordOperation(CreateOrderCommand command) {
        // 保存审计日志
    }
}

这段代码里,执行结果通常是:

  1. 外层 create() 开启事务
  2. recordOperation() 被调用时,挂起外层事务
  3. 审计日志在自己的新事务里提交成功
  4. 外层方法随后抛异常
  5. 外层订单事务回滚,但审计日志不会跟着回滚

所以 REQUIRES_NEW 最容易记住的一点是:外层失败,不等于内层一定失败;因为它们已经不是同一个事务了。

6.3 SUPPORTS:支持当前事务,但不强求必须有事务

SUPPORTS 可以理解成“有事务就跟着跑,没有事务就直接按非事务方式执行”。

含义是:

  1. 有事务就加入
  2. 没事务就按非事务方式执行

一个更具体的 SUPPORTS 示例

它更适合那种:如果外层本来就在事务里,那就顺手跟着;如果没有事务,也没必要为了它单独开事务。

例如查询订单详情和支付快照:

java
@Service
public class OrderQueryService {

    private final OrderRepository orderRepository;

    public OrderQueryService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
    public OrderDetailDTO queryOrderDetail(Long orderId) {
        return orderRepository.findDetailById(orderId);
    }
}

如果它被下面这个方法调用:

java
@Transactional
public void exportOrderReport(Long orderId) {
    OrderDetailDTO detail = orderQueryService.queryOrderDetail(orderId);
    reportRepository.save(buildReport(detail));
}

那么 queryOrderDetail() 会加入当前事务。

但如果它被普通 Controller 直接调用:

java
@GetMapping("/orders/{id}")
public OrderDetailDTO detail(@PathVariable Long id) {
    return orderQueryService.queryOrderDetail(id);
}

那它就不会自己额外开事务,而是按普通非事务查询执行。

所以 SUPPORTS 更适合理解成:有现成事务就顺着用,没有也别为了它强行开一个。

6.4 一句话区分

可以直接记:

  1. REQUIRED:优先加入当前事务
  2. REQUIRES_NEW:一定新开事务
  3. SUPPORTS:能跟就跟,不能跟就算了

6.5 一个传播行为示例

java
@Service
public class OrderFacade {

    private final OrderService orderService;
    private final AuditService auditService;

    public OrderFacade(OrderService orderService, AuditService auditService) {
        this.orderService = orderService;
        this.auditService = auditService;
    }

    @Transactional
    public void create(CreateOrderCommand command) {
        orderService.saveOrder(command);          // REQUIRED,加入当前事务
        auditService.recordOperation(command);    // REQUIRES_NEW,单独提交审计日志
    }
}
java
@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordOperation(CreateOrderCommand command) {
    }
}

6.6 把 3 个最常见传播行为放在一起看

如果用同一个调用链去记忆,会更容易形成直觉:

场景内层传播行为内层和外层是不是同一个事务外层失败时内层结果
下单 + 扣库存REQUIRED一起回滚
下单 + 记审计日志REQUIRES_NEW不是审计日志可单独提交
导出报表时顺便查订单详情SUPPORTS有事务就加入,没有就不建跟不跟事务取决于调用方

这张表真正想表达的是:

  1. REQUIRED 解决“本来就是一笔业务”
  2. REQUIRES_NEW 解决“我想和外层拆开算”
  3. SUPPORTS 解决“能跟就跟,但别强求”

7. 隔离级别到底在控制什么

隔离级别不是 Spring 发明的,它本质上还是数据库并发隔离策略。

Spring 在这里做的,是:把数据库隔离级别作为事务配置的一部分暴露出来。

它主要解决的是 多个事务并发执行时,彼此之间允许看到什么样的数据变化

常见还是:

  1. 读未提交
  2. 读已提交
  3. 可重复读
  4. 串行化

如果把它们和并发读写里的几类典型异常放到一起看,会更直观:

隔离级别脏读不可重复读幻读
读未提交 READ UNCOMMITTED❌ 会发生❌ 会发生❌ 会发生
读已提交 READ COMMITTED✅ 不会❌ 会发生❌ 会发生
可重复读 REPEATABLE READ✅ 不会✅ 不会⚠️ InnoDB 下通常可避免
串行化 SERIALIZABLE✅ 不会✅ 不会✅ 不会

这张表真正想表达的是:

  1. 隔离级别越低,并发能力通常越高,但读到不稳定数据的概率也越大
  2. 隔离级别越高,一致性更强,但等待、锁竞争和吞吐代价通常也更明显
  3. REPEATABLE READ 在 MySQL InnoDB 里要结合 MVCC、间隙锁、临键锁这些机制一起理解,不能只把它背成一句抽象定义

所以这里的隔离级别,本质上是在表达数据库并发隔离策略,而不是单独的 Spring 逻辑。

例如:

java
@Transactional(isolation = Isolation.REPEATABLE_READ)
public Order findForReport(Long orderId) {
    return orderRepository.findById(orderId);
}

8. 什么情况下会回滚

这也是事务里特别容易误解的一点。

很多人会简单理解成:只要报错就回滚。

但回滚行为实际上要看:

  1. 抛出的异常类型
  2. 异常是否真的抛出到事务代理层
  3. 是否配置了回滚规则

所以事务回滚不是一句“报错就行”,而是:异常要真正被事务机制感知到。

8.1 回滚规则示例

java
@Transactional(rollbackFor = Exception.class)
public void importData() throws Exception {
    if (true) {
        throw new Exception("import failed");
    }
}

如果你希望某些业务异常不要回滚,也可以显式指定:

java
@Transactional(noRollbackFor = BizWarningException.class)
public void notifyUser() {
    throw new BizWarningException("ignore rollback");
}

9. @Transactional 常见失效场景有哪些

这是工程里特别高频的坑。

9.1 同类内部自调用

这是最经典的一类。

原因是:同类内部直接调用方法,往往不会经过 Spring 代理对象。

没有经过代理,事务增强就没机会执行。

例如:

java
@Service
public class UserService {

    public void register() {
        saveUser(); // 同类内部直接调用,可能不会走事务代理
    }

    @Transactional
    public void saveUser() {
    }
}

9.2 方法没有被 Spring 容器管理

如果对象本身都不在容器里,事务自然也无从增强。

9.3 方法可见性或调用方式不合适

声明式事务是基于代理增强的,所以某些调用路径或可见性条件下,可能无法按预期生效。

9.4 异常被吞掉

如果业务代码内部把异常吃掉了,没有正确抛出到事务层,预期的回滚就可能不发生。

例如:

java
@Transactional
public void createOrder() {
    try {
        orderRepository.save(new Order());
        throw new RuntimeException("fail");
    } catch (Exception ex) {
        // 异常被吞掉,事务层可能感知不到
        log.warn("ignore", ex);
    }
}

9.5 以为加了注解就自动把所有下游都纳入同一事务

事务边界是有实际资源范围的,不是加个注解就跨系统自动统一了。


10. Spring 事务和数据库事务是什么关系

一定要把这层边界讲清楚。

可以直接记成:

  1. Spring 事务负责统一事务表达和控制方式
  2. 数据库事务负责真正的提交、回滚和隔离

所以 Spring 事务不是替代数据库事务,而是站在应用框架层,对数据库事务做统一组织。


11. 为什么长事务会非常麻烦

长事务带来的问题不只是“执行慢”,还包括:

  1. 长时间占用数据库连接
  2. 长时间持有锁
  3. 放大并发冲突
  4. 拖慢连接池和系统吞吐

所以工程上一个很重要的原则是:事务边界尽量短,只包真正需要一起提交回滚的数据库操作。


12. 工程上最常见的几个误区

12.1 误区一:加了 @Transactional 就一定安全

事务是否真正生效,要看调用路径和异常传播,不是只看注解写没写。

12.2 误区二:事务能解决所有一致性问题

事务主要解决单个事务边界内的一致性问题,不等于自动解决跨服务、跨消息链路的一致性。

12.3 误区三:事务越大越稳

事务越大,往往锁持有越久、连接占用越久,反而更容易把系统拖慢。

12.4 误区四:传播行为只是背几个枚举

传播行为真正要回答的是业务边界问题,而不是只背注解参数。


13. 一句话总结

Spring 事务的核心价值,是把数据库事务这件事以更统一、更声明式的方式纳入应用框架管理;而真正要把它用好,关键仍然在于事务边界、传播行为、异常传播和调用路径这几件事是否想清楚了。

基于 VitePress 构建的个人技术笔记。