跳转到内容
123xiao | 无名键客

《Java开发踩坑实战:排查并修复 Spring 事务失效的 8 个高频场景》

字数: 0 阅读时长: 1 分钟

背景与问题

在 Java 后端项目里,@Transactional 往往是大家最熟悉、也最容易“过度信任”的注解之一。很多人第一次遇到事务问题,反应都是:

“我明明加了 @Transactional,为什么数据还是提交了?”

我自己刚做业务开发时,就踩过这个坑:外层方法抛异常,按理说应该整批回滚,结果数据库里已经插进去半截数据。排查半天才发现,问题根本不在 SQL,不在数据库隔离级别,而在 Spring 事务代理根本没生效

这类问题非常典型,而且常常不是单一原因导致的。本文不讲空泛概念,直接围绕 8 个高频事务失效场景 展开,带你按“现象复现 -> 原理解释 -> 排查路径 -> 修复方案”的方式走一遍。

适合你如果正遇到这些问题:

  • @Transactional 加了像没加
  • 异常抛了,事务没回滚
  • 嵌套调用中只有一部分回滚
  • 事务里调用异步、私有方法、内部方法后行为异常
  • 线上偶发脏数据,怀疑事务边界不对

核心原理

在开始查坑之前,先把最关键的一句话记住:

Spring 事务默认是基于 AOP 代理实现的,只有“经过代理对象的方法调用”才能触发事务增强。

这句话决定了大部分坑的根源。

1. Spring 事务生效链路

flowchart TD
    A[业务代码调用 Service 方法] --> B{是否通过 Spring 代理对象调用?}
    B -- 否 --> C[不会进入事务拦截器]
    C --> D[@Transactional 失效]
    B -- 是 --> E[进入 TransactionInterceptor]
    E --> F[创建/加入事务]
    F --> G[执行目标方法]
    G --> H{是否抛出可回滚异常?}
    H -- 是 --> I[回滚事务]
    H -- 否 --> J[提交事务]

2. 事务本质上做了什么

事务拦截器在方法执行前后,大致做了几件事:

  1. 根据传播机制决定是否开启新事务
  2. 绑定数据库连接到当前线程
  3. 方法正常结束则提交
  4. 方法抛出匹配规则的异常则回滚

也就是说,事务是否生效,核心看三点:

  • 方法调用是否走代理
  • 异常是否被 Spring 判定为需要回滚
  • 当前线程和当前事务上下文是否一致

3. 传播机制是另一个高频坑源

很多“我以为会回滚”的场景,其实是传播机制导致的。

sequenceDiagram
    participant C as Controller
    participant A as OrderService
    participant B as StockService
    participant DB as Database

    C->>A: placeOrder()
    A->>A: 开启事务 REQUIRED
    A->>B: deductStock()

    alt B = REQUIRED
        B->>DB: 加入同一事务
    else B = REQUIRES_NEW
        B->>DB: 新开事务
    end

    A-->>C: 抛异常/正常返回

如果 StockService.deductStock()REQUIRES_NEW,那么外层 placeOrder() 失败时,库存扣减那部分可能已经提交了。这不是 Spring 出错,而是传播行为就是这么定义的。


现象复现

下面先准备一个最小可运行示例,后续 8 个坑都基于它展开。

示例环境

  • Spring Boot
  • Spring Data JPA
  • H2 内存数据库
  • Java 8+

可运行代码

1)启动类

package demo.tx;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TxDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(TxDemoApplication.class, args);
    }
}

2)实体类

package demo.tx.entity;

import javax.persistence.*;

@Entity
@Table(name = "account")
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String owner;

    private Integer balance;

    public Account() {
    }

    public Account(String owner, Integer balance) {
        this.owner = owner;
        this.balance = balance;
    }

    public Long getId() {
        return id;
    }

    public String getOwner() {
        return owner;
    }

    public Integer getBalance() {
        return balance;
    }

    public void setOwner(String owner) {
        this.owner = owner;
    }

    public void setBalance(Integer balance) {
        this.balance = balance;
    }
}

3)Repository

package demo.tx.repository;

import demo.tx.entity.Account;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AccountRepository extends JpaRepository<Account, Long> {
}

4)Service:正常事务示例

package demo.tx.service;

import demo.tx.entity.Account;
import demo.tx.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AccountService {

    private final AccountRepository accountRepository;

    public AccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional
    public void createTwoAccountsThenFail() {
        accountRepository.save(new Account("alice", 100));
        accountRepository.save(new Account("bob", 200));
        throw new RuntimeException("模拟异常,理论上应整体回滚");
    }
}

5)测试接口

package demo.tx.controller;

import demo.tx.service.AccountService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DemoController {

    private final AccountService accountService;

    public DemoController(AccountService accountService) {
        this.accountService = accountService;
    }

    @GetMapping("/demo/ok")
    public String testTx() {
        try {
            accountService.createTwoAccountsThenFail();
        } catch (Exception e) {
            return "error: " + e.getMessage();
        }
        return "ok";
    }
}

6)配置文件

spring:
  datasource:
    url: jdbc:h2:mem:txdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
  h2:
    console:
      enabled: true

在这个基线版本里,访问 /demo/ok 后,alicebob 都不会落库,因为抛出的是 RuntimeException,默认会回滚。


8 个高频场景:常见坑与排查

下面进入重点。每个坑我都尽量从“你会看到什么现象”开始说。


场景 1:同类内部调用,事务失效

这是最经典、也最隐蔽的坑。

错误写法

package demo.tx.service;

import demo.tx.entity.Account;
import demo.tx.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InternalCallService {

    private final AccountRepository accountRepository;

    public InternalCallService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    public void outer() {
        innerTx();
    }

    @Transactional
    public void innerTx() {
        accountRepository.save(new Account("inner-call", 100));
        throw new RuntimeException("内部调用异常");
    }
}

现象

调用 outer() 时,innerTx() 上的事务不生效,数据可能直接提交。

原因

outer() 调用 this.innerTx(),属于 对象内部调用,没有经过 Spring AOP 代理,因此事务拦截器没机会介入。

修复方案

方案 A:拆到另一个 Bean

package demo.tx.service;

import demo.tx.entity.Account;
import demo.tx.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TxInnerService {

    private final AccountRepository accountRepository;

    public TxInnerService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional
    public void innerTx() {
        accountRepository.save(new Account("fixed-inner", 100));
        throw new RuntimeException("异常触发回滚");
    }
}
package demo.tx.service;

import org.springframework.stereotype.Service;

@Service
public class TxOuterService {

    private final TxInnerService txInnerService;

    public TxOuterService(TxInnerService txInnerService) {
        this.txInnerService = txInnerService;
    }

    public void outer() {
        txInnerService.innerTx();
    }
}

方案 B:通过代理对象调用

不太推荐,维护性一般,但确实能用。

排查提示

只要看到下面这种结构,就要高度警惕:

  • 一个类里 public methodA()@Transactional methodB()
  • methodB() 没有从别的 Bean 被调用过

场景 2:方法不是 public,事务不生效

错误写法

@Transactional
protected void protectedTx() {
    // ...
}

或者:

@Transactional
private void privateTx() {
    // ...
}

现象

方法执行了,但事务像没加一样。

原因

Spring 基于代理时,事务增强通常作用于 可被代理拦截的外部方法调用private 方法天然不会被代理覆盖,protected、包级可见方法在常见代理方式下也容易出现不符合预期的情况。

建议

  • 事务方法统一使用 public
  • 不要把 @Transactional 标在 private 方法上赌运气

排查提示

代码评审时看到这些就可以直接指出:

  • private @Transactional
  • protected @Transactional
  • 非接口暴露且依赖 JDK 动态代理的场景

场景 3:抛了受检异常,默认不回滚

错误写法

@Transactional
public void createThenThrowChecked() throws Exception {
    accountRepository.save(new Account("checked-ex", 100));
    throw new Exception("受检异常");
}

现象

方法抛异常了,但数据提交了。

原因

Spring 默认只对以下异常回滚:

  • RuntimeException
  • Error

受检异常(如 ExceptionIOException)默认不会触发回滚。

修复方案

@Transactional(rollbackFor = Exception.class)
public void createThenThrowChecked() throws Exception {
    accountRepository.save(new Account("checked-ex", 100));
    throw new Exception("受检异常");
}

排查提示

如果你看到业务代码里经常:

  • throws Exception
  • throws IOException
  • 自定义异常继承自 Exception

那就必须检查 rollbackFor 配置。


场景 4:异常被吞掉了,事务当然不会回滚

错误写法

@Transactional
public void createThenCatch() {
    try {
        accountRepository.save(new Account("catch-ex", 100));
        int x = 1 / 0;
    } catch (Exception e) {
        System.out.println("异常被吃掉了: " + e.getMessage());
    }
}

现象

日志里有异常,但数据库数据还是提交了。

原因

对于事务拦截器来说,方法最终是正常返回的,它当然就会提交事务。

修复方案

方案 A:捕获后重新抛出

@Transactional
public void createThenCatchAndThrow() {
    try {
        accountRepository.save(new Account("catch-rethrow", 100));
        int x = 1 / 0;
    } catch (Exception e) {
        throw new RuntimeException("包装后抛出", e);
    }
}

方案 B:手工标记回滚

package demo.tx.service;

import demo.tx.entity.Account;
import demo.tx.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatchService {

    private final AccountRepository accountRepository;

    public CatchService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional
    public void createThenMarkRollbackOnly() {
        try {
            accountRepository.save(new Account("rollback-only", 100));
            int x = 1 / 0;
        } catch (Exception e) {
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        }
    }
}

排查提示

重点搜这些代码:

  • catch (Exception e) {}
  • log.error(...); return;
  • try-catch 包住事务主逻辑后没有继续抛异常

场景 5:使用了 @Async,事务上下文断了

错误写法

package demo.tx.service;

import demo.tx.entity.Account;
import demo.tx.repository.AccountRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AsyncTxService {

    private final AccountRepository accountRepository;

    public AsyncTxService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional
    public void createAndAsync() {
        accountRepository.save(new Account("main-thread", 100));
        asyncSave();
        throw new RuntimeException("外层异常");
    }

    @Async
    public void asyncSave() {
        accountRepository.save(new Account("async-thread", 200));
    }
}

现象

外层事务回滚了,但异步线程里的数据可能已经提交了。

原因

Spring 事务上下文通常绑定在线程上。@Async 会切换线程执行,新线程不会自动继承当前事务

修复方案

  • 不要指望异步方法天然共享事务
  • 异步逻辑如果需要事务,单独声明事务边界
  • 更稳妥的做法是:事务内只做主数据提交,异步任务通过消息/事件驱动

排查提示

只要出现:

  • @Transactional + @Async
  • 事务里发线程池任务
  • CompletableFuture.runAsync(...)

都要重新审视事务边界。


场景 6:数据库引擎不支持事务,或者自动提交配置有问题

这个坑在开发环境尤其容易被忽略。

常见现象

  • 本地测起来“像没回滚”
  • 换数据库后行为不一致
  • 某些表回滚,某些表不回滚

原因

不是所有数据库表都支持事务。典型例子:

  • MySQL 的 MyISAM 不支持事务
  • autocommit 配置异常也会影响行为
  • 多数据源下某个数据源没接入事务管理器

排查 SQL

SHOW VARIABLES LIKE 'autocommit';
SHOW TABLE STATUS WHERE Name = 'account';

如果是 MySQL,重点关注 Engine 是否为 InnoDB

建议

  • 生产库统一使用支持事务的引擎
  • 多数据源项目明确每个 TransactionManager 绑定关系
  • 不要把“事务没回滚”都甩锅给代码

场景 7:传播机制配置不当,导致“部分提交”

错误示例

package demo.tx.service;

import demo.tx.entity.Account;
import demo.tx.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PropagationService {

    private final AccountRepository accountRepository;
    private final SubTxService subTxService;

    public PropagationService(AccountRepository accountRepository, SubTxService subTxService) {
        this.accountRepository = accountRepository;
        this.subTxService = subTxService;
    }

    @Transactional
    public void outer() {
        accountRepository.save(new Account("outer", 100));
        subTxService.innerNewTx();
        throw new RuntimeException("外层失败");
    }
}

@Service
class SubTxService {

    private final AccountRepository accountRepository;

    public SubTxService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void innerNewTx() {
        accountRepository.save(new Account("inner-new", 200));
    }
}

现象

外层回滚了,但 inner-new 还在。

原因

REQUIRES_NEW 会挂起外层事务,自己开启一个全新的事务。内层已经提交,外层回滚不影响它。

修复建议

根据业务目标选传播机制:

  • 想整体成功整体失败:通常用 REQUIRED
  • 想审计日志、补偿记录独立提交:可以用 REQUIRES_NEW
  • 千万别默认“新事务更安全”,很多数据不一致就这么来的

传播机制速查图

stateDiagram-v2
    [*] --> NoTx
    NoTx --> RequiredTx: REQUIRED
    RequiredTx --> JoinExisting: REQUIRED
    RequiredTx --> NewTx: REQUIRES_NEW
    RequiredTx --> Error: NEVER
    NoTx --> RunWithoutTx: SUPPORTS
    RequiredTx --> NestedTx: NESTED

场景 8:多数据源/多事务管理器,事务管错库了

现象

  • A 库回滚了,B 库没回滚
  • 指定了 @Transactional,但只有一个数据源生效
  • 项目里同时有 MySQL、读写分离、消息库时特别常见

原因

Spring 事务是由具体的 PlatformTransactionManager 管理的。多数据源场景下,如果没有显式指定,可能默认只使用了某一个事务管理器。

示例

@Transactional(transactionManager = "orderTransactionManager")
public void createOrder() {
    // 这里只会受 orderTransactionManager 管控
}

修复建议

  • 明确为每个数据源配置事务管理器
  • 在关键业务方法上显式指定 transactionManager
  • 跨库强一致不要幻想“一个本地事务全搞定”,该上分布式事务、消息最终一致性时要果断上

排查提示

搜项目里这些配置:

  • 多个 DataSource
  • 多个 PlatformTransactionManager
  • @Primary
  • @EnableJpaRepositories / sqlSessionFactory 的扫描范围

定位路径:我通常怎么查

如果线上出现“事务失效”,不要一上来就改注解。我的习惯是按下面顺序查。

1. 先确认是不是走了代理

最先回答这几个问题:

  • 这个事务方法是不是 public
  • 是不是被 Spring 容器管理的 Bean?
  • 是不是同类内部直接调用?
  • 有没有 new XxxService() 自己创建对象?

如果对象不是 Spring 管理的,或者是内部调用,后面都不用查了。

2. 再看异常有没有真的抛出去

关注点:

  • RuntimeException 还是受检异常?
  • 有没有被 catch 吞掉?
  • 异常是不是在事务方法之外才抛出?

3. 再看传播机制

重点看:

  • 内外层方法分别是什么传播属性?
  • 有没有 REQUIRES_NEW
  • 有没有异步线程、事件监听、消息发送

4. 最后看基础设施

比如:

  • 数据库引擎是否支持事务
  • 数据源和事务管理器是否匹配
  • 是否多数据源
  • ORM 是否延迟刷盘导致误判

一份事务失效排查清单

这个清单我建议直接收藏,出问题时从上往下过。

flowchart TD
    A[发现事务没回滚] --> B{方法是否由Spring Bean管理?}
    B -- 否 --> B1[改为交给Spring托管]
    B -- 是 --> C{是否通过代理调用?}
    C -- 否 --> C1[拆分Bean或改调用路径]
    C -- 是 --> D{方法是否为public?}
    D -- 否 --> D1[改为public]
    D -- 是 --> E{异常是否抛出到代理层?}
    E -- 否 --> E1[不要吞异常或手动setRollbackOnly]
    E -- 是 --> F{异常类型是否可回滚?}
    F -- 否 --> F1[配置rollbackFor]
    F -- 是 --> G{是否有Async/新线程?}
    G -- 是 --> G1[重设事务边界]
    G -- 否 --> H{传播机制是否符合预期?}
    H -- 否 --> H1[调整REQUIRED/REQUIRES_NEW]
    H -- 是 --> I[检查数据库与事务管理器配置]

实战代码:一个统一演示入口

下面给一个可运行的演示控制器,你可以分别调用不同接口观察事务行为。

package demo.tx.controller;

import demo.tx.service.CatchService;
import demo.tx.service.InternalCallService;
import demo.tx.service.PropagationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TroubleshootingController {

    private final InternalCallService internalCallService;
    private final CatchService catchService;
    private final PropagationService propagationService;

    public TroubleshootingController(
            InternalCallService internalCallService,
            CatchService catchService,
            PropagationService propagationService) {
        this.internalCallService = internalCallService;
        this.catchService = catchService;
        this.propagationService = propagationService;
    }

    @GetMapping("/tx/internal")
    public String internal() {
        try {
            internalCallService.outer();
        } catch (Exception e) {
            return e.getMessage();
        }
        return "done";
    }

    @GetMapping("/tx/catch")
    public String catchEx() {
        catchService.createThenMarkRollbackOnly();
        return "done";
    }

    @GetMapping("/tx/propagation")
    public String propagation() {
        try {
            propagationService.outer();
        } catch (Exception e) {
            return e.getMessage();
        }
        return "done";
    }
}

你可以在 H2 控制台里查询数据:

SELECT * FROM ACCOUNT;

建议每测一个场景前重启应用,避免历史数据干扰判断。


常见坑与排查总结表

场景典型现象根因修复方式
同类内部调用@Transactional 像没加没走代理拆分 Bean
非 public 方法方法执行但不回滚无法正确代理拦截改成 public
抛受检异常报错但数据提交默认不回滚 checked exceptionrollbackFor=Exception.class
异常被吞日志有错但事务提交代理层看见的是正常返回重新抛出或手动标记回滚
@Async/新线程主事务回滚,异步数据提交线程上下文切换重新设计事务边界
数据库不支持事务怎么写都不回滚表引擎/自动提交问题使用支持事务的引擎
传播机制不当部分提交REQUIRES_NEW 等行为差异按业务目标选传播属性
多数据源某个库不回滚事务管理器绑定错误指定正确 transactionManager

安全/性能最佳实践

事务问题不只是正确性问题,很多时候还会拖垮性能,甚至放大并发风险。

1. 事务范围尽量小

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

  • 远程 HTTP 调用
  • 复杂文件读写
  • 大批量循环处理
  • 长时间等待用户输入

原因很简单:事务越长,持锁越久,数据库压力越大,死锁概率也越高。

2. 只在真正需要一致性的地方开事务

并不是每个 Service 方法都必须 @Transactional。滥用事务会带来:

  • 额外代理开销
  • 锁资源竞争
  • 回滚范围过大

3. 明确读写事务语义

查询方法可以考虑:

@Transactional(readOnly = true)
public Account query(Long id) {
    return accountRepository.findById(id).orElse(null);
}

readOnly = true 不一定在所有数据库都带来巨大收益,但它至少能表达意图,也能避免误写。

4. 警惕事务里发外部副作用

比如:

  • 发短信
  • 发邮件
  • 推送消息
  • 调三方支付接口

如果数据库事务回滚了,但外部副作用已经发出,就会造成不一致。更稳妥的方案通常是:

  • 事务提交后再触发事件
  • 使用本地消息表 / Outbox 模式
  • 用消息队列做最终一致性

5. 打开必要日志,但别在生产滥开 DEBUG

排查期可临时打开:

logging:
  level:
    org.springframework.transaction: DEBUG
    org.hibernate.SQL: DEBUG

这样能看到:

  • 是否创建事务
  • 是否提交/回滚
  • 实际执行了哪些 SQL

但生产环境不要长期开太细的 SQL/事务日志,容易影响性能并放大日志量。

6. 关键链路要有失败补偿思维

当业务跨数据库、跨服务时,本地事务不是万能药。中级开发最容易卡在这里:明明单库事务都写对了,业务还是不一致。

这时要接受边界:

  • 单库内:优先本地事务
  • 跨服务:优先事件驱动或补偿机制
  • 强一致要求极高:再考虑分布式事务框架,但要评估代价

止血方案:线上已经出问题时怎么办

如果线上已经发现“部分提交”或“事务失效”,先别急着大改架构,我建议按这个顺序止血:

  1. 先定位影响范围

    • 哪些接口
    • 哪些表
    • 从哪个版本开始
  2. 先保证新增请求不继续制造脏数据

    • 临时降级入口
    • 对异常链路做开关控制
    • 必要时先关闭异步分支
  3. 补数据前先确认事务边界问题

    • 是内部调用?
    • 是异常被吞?
    • 是传播机制导致部分提交?
  4. 编写补偿脚本

    • 修正半成功数据
    • 对账后再恢复流量

补偿 SQL 一定先在测试环境演练,别为了修事务再制造一次事故。


总结

Spring 事务失效,大多数时候不是“Spring 不靠谱”,而是我们对它的工作方式理解得不够具体。

你只要记住三个抓手,排查效率会高很多:

  1. 有没有走代理
  2. 异常有没有正确抛出并匹配回滚规则
  3. 线程和传播机制是不是符合预期

本文讲的 8 个高频场景里,最常见的还是这几个:

  • 同类内部调用
  • 异常被吞
  • 受检异常没配置回滚
  • REQUIRES_NEW 用错
  • @Async 断开事务上下文

如果你想把事务问题一次性压下去,我给的可执行建议是:

  • 事务方法统一 public
  • 事务逻辑放在独立 Service,避免内部调用
  • 默认抛业务运行时异常,必要时显式 rollbackFor
  • 严控 REQUIRES_NEW 使用场景
  • 不在事务里做异步和外部副作用
  • 多数据源明确指定事务管理器

最后一句很实在:
别把 @Transactional 当成“加了就万无一失”的保险符,它更像一套有边界条件的机制。懂边界,才不容易踩坑。


分享到:

上一篇
《Docker 镜像构建提速实战:利用多阶段构建、BuildKit 与缓存策略优化中型项目 CI/CD 流程》
下一篇
《从 Prompt 到工作流:中级开发者如何用 AI Agent 快速搭建可落地的自动化业务助手》