Spring事务详解:从@Transactional原理到传播机制实战
- 发布时间:2026-05-28 04:05:25
- 本文热度:浏览 9 赞 0 评论 0
- 文章标签: Spring事务 @Transactional Spring AOP
- 全文共1字,阅读约需1分钟
为什么很多人会把 Spring 事务用错
@Transactional 看起来只是一个注解,真正起作用的却不是注解本身,而是 Spring 在方法调用链外面包了一层事务逻辑。
这也是很多问题的根源:
- 为什么同类方法内部调用,事务失效
- 为什么
private方法上加了@Transactional没反应 - 为什么抛了异常却没有回滚
- 为什么传播机制一多,排查问题就开始混乱
- 为什么有人以为“加了事务就一定安全”,结果还是出现脏数据、锁等待、长事务
Spring 事务真正要理解的,不是“怎么写”,而是 它到底在什么时机开启、提交、回滚,以及事务边界到底包住了什么代码。
Spring 事务的本质:AOP + 事务管理器
Spring 声明式事务的核心,可以概括为三部分:
- AOP 拦截
- TransactionManager 事务管理器
- 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 为例,它做的事情大致是:
- 从数据源拿到连接
- 把
autoCommit设为false - 将连接绑定到当前线程
- 执行业务 SQL
- 成功则
commit - 异常则
rollback - 最后释放连接
这里还有一个非常重要的实现细节: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 默认的回滚规则是:
- 遇到
RuntimeException和Error才回滚 - 遇到受检异常(
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_UNCOMMITTEDREAD_COMMITTEDREPEATABLE_READSERIALIZABLEDEFAULT
真正要注意的是: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_ATTEMPTORDER_CREATE_FAILEDORDER_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 这个注解本身,而是三个问题:
- 调用有没有经过代理
- 异常能不能正确触发回滚
- 传播行为是否符合真实业务边界
把这三个点吃透,很多事务问题其实都不神秘。
再往前一步,真正成熟的事务设计也不只是“会用传播机制”,而是知道:
- 哪些操作必须放在一个事务里
- 哪些操作绝对不能放进长事务
- 哪些失败该回滚
- 哪些记录应该独立提交
- 哪些一致性已经不是本地事务该解决的问题
很多项目的事务问题,表面看是注解没配好,实质上是 业务边界没画清楚。