背景与问题
在 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. 事务本质上做了什么
事务拦截器在方法执行前后,大致做了几件事:
- 根据传播机制决定是否开启新事务
- 绑定数据库连接到当前线程
- 方法正常结束则提交
- 方法抛出匹配规则的异常则回滚
也就是说,事务是否生效,核心看三点:
- 方法调用是否走代理
- 异常是否被 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 后,alice 和 bob 都不会落库,因为抛出的是 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 @Transactionalprotected @Transactional- 非接口暴露且依赖 JDK 动态代理的场景
场景 3:抛了受检异常,默认不回滚
错误写法
@Transactional
public void createThenThrowChecked() throws Exception {
accountRepository.save(new Account("checked-ex", 100));
throw new Exception("受检异常");
}
现象
方法抛异常了,但数据提交了。
原因
Spring 默认只对以下异常回滚:
RuntimeExceptionError
受检异常(如 Exception、IOException)默认不会触发回滚。
修复方案
@Transactional(rollbackFor = Exception.class)
public void createThenThrowChecked() throws Exception {
accountRepository.save(new Account("checked-ex", 100));
throw new Exception("受检异常");
}
排查提示
如果你看到业务代码里经常:
throws Exceptionthrows 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 exception | rollbackFor=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. 关键链路要有失败补偿思维
当业务跨数据库、跨服务时,本地事务不是万能药。中级开发最容易卡在这里:明明单库事务都写对了,业务还是不一致。
这时要接受边界:
- 单库内:优先本地事务
- 跨服务:优先事件驱动或补偿机制
- 强一致要求极高:再考虑分布式事务框架,但要评估代价
止血方案:线上已经出问题时怎么办
如果线上已经发现“部分提交”或“事务失效”,先别急着大改架构,我建议按这个顺序止血:
-
先定位影响范围
- 哪些接口
- 哪些表
- 从哪个版本开始
-
先保证新增请求不继续制造脏数据
- 临时降级入口
- 对异常链路做开关控制
- 必要时先关闭异步分支
-
补数据前先确认事务边界问题
- 是内部调用?
- 是异常被吞?
- 是传播机制导致部分提交?
-
编写补偿脚本
- 修正半成功数据
- 对账后再恢复流量
补偿 SQL 一定先在测试环境演练,别为了修事务再制造一次事故。
总结
Spring 事务失效,大多数时候不是“Spring 不靠谱”,而是我们对它的工作方式理解得不够具体。
你只要记住三个抓手,排查效率会高很多:
- 有没有走代理
- 异常有没有正确抛出并匹配回滚规则
- 线程和传播机制是不是符合预期
本文讲的 8 个高频场景里,最常见的还是这几个:
- 同类内部调用
- 异常被吞
- 受检异常没配置回滚
REQUIRES_NEW用错@Async断开事务上下文
如果你想把事务问题一次性压下去,我给的可执行建议是:
- 事务方法统一
public - 事务逻辑放在独立 Service,避免内部调用
- 默认抛业务运行时异常,必要时显式
rollbackFor - 严控
REQUIRES_NEW使用场景 - 不在事务里做异步和外部副作用
- 多数据源明确指定事务管理器
最后一句很实在:
别把 @Transactional 当成“加了就万无一失”的保险符,它更像一套有边界条件的机制。懂边界,才不容易踩坑。