下单、扣库存、改余额,这三件事只要做了一半成功、一半失败,用户当场就能在订单列表里看到自己账户莫名扣了钱,库存却纹丝不动。这种事故在早期项目里几乎每个团队都踩过。Spring事务就是为了让你在一套统一的管理机制下,把这类“多个操作要么一起成功、要么一起回滚”的业务场景从手写样板代码里解放出来。
这篇内容我不是来给你背概念,后面所有讨论都围绕真实项目怎么落地展开:事务管理器怎么选,@Transactional 的参数到底怎么配,哪些代码写法会让事务悄悄失效,以及事务传播行为在实际业务里的判断逻辑。适合正在写业务代码的 Java 后端开发者,尤其是用 Spring Boot 做微服务、做过订单或支付类功能、但对事务的理解还停留在“加个注解就行”这一层的朋友。
1. 先搞清楚Spring事务到底帮你管了什么
很多人一上来就盯着 @Transactional 怎么用,但有一个更底层的问题没想明白:数据库明明自己有事务,为什么还要 Spring 再来管一层?这个问题的答案,决定了你后面遇到事务失效问题时能不能快速定位。
1.1 从一次支付失败说起:事务是什么、为什么必须原子
假设你在做一个商品下单功能,核心流程是三件事:创建订单、扣减库存、扣减用户余额。这三件事如果分开执行,第一件成功了、第二件失败了,这个订单就成了“僵尸订单”——用户付了钱但库里没有货,或者货被扣了但订单没生成。
数据库事务解决的就是这个问题。它把一组 SQL 操作打包成一个“原子单元”,这个单元内的操作要么全部执行成功,要么全部回滚到执行之前的状态。这套机制在数据库层面已经非常成熟,ACID 四个特性里,原子性(Atomicity)最为关键:中间任何一步失败,前面做的操作全部撤销,数据始终不会处于半完成状态。
但问题是,你在 Java 代码里怎么使用这个能力?最早的 JDBC 写法是每个业务方法里手动 setAutoCommit(false),然后 try-catch 提交或回滚,最后还要在 finally 里 close 连接。一个稍微像样点的业务方法,光是事务处理的样板代码就占了一大半,而且每个方法都得这样写一遍,很难保证不写错。Spring 事务就是把这一层重复劳动抽象成统一机制,让开发者只需要声明“这个方法需要事务”,剩下的提交、回滚、连接管理全部交给框架处理。
1.2 Spring事务的本质:站在数据库事务之上的协调器
Spring 事务本身不实现事务,它只是数据库事务的管理者和调度者。核心是 PlatformTransactionManager 这个接口,它定义了获取事务、提交事务、回滚事务三个基本操作。具体实现则根据你的数据访问技术来选择,后面会详细讲。
这里要建立一个关键认知:Spring 管的是事务的边界和时机,不管数据怎么读写。真正的事务能力在数据库连接里,Spring 通过持有同一个数据库连接,让多个 DAO 操作共享事务上下文。这也就解释了为什么事务要求“同一个连接贯穿整个方法”,一旦连接换了,事务自然就断了。
举个例子。一个 Service 方法里有 A、B、C 三个操作,Spring 在进入方法前向事务管理器请求一个事务,事务管理器从数据源里拿一个连接,开启事务,把这个连接绑定到当前线程。方法内所有的数据库操作都通过这个连接去执行。方法正常结束就提交,抛出异常就回滚,最后把连接释放回连接池。整个过程你完全不用碰连接、不用碰 commit 或 rollback,这就是声明式事务最大的价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心配置实操:事务管理器与@Transactional参数逐个拆解
理解事务基本原理后,接着要解决的是配置层面的问题。Spring Boot 默认帮你做了很多自动化配置,但一旦自己定制数据源、引入多数据源或者迁移到非主流 ORM,这些默认配置就可能失效。
2.1 三种事务管理器,选型只看数据访问方式
PlatformTransactionManager 只是个接口,Spring 针对不同的数据访问技术提供了不同实现,日常开发最常接触的是这三个:
| 实现类 | 适用场景 | 底层机制 |
|---|---|---|
| DataSourceTransactionManager | 使用 JDBC、MyBatis 等直接操作数据源的情况 | 作用于 javax.sql.DataSource,通过连接的事务 API 控制 |
| JpaTransactionManager | 使用 JPA / Hibernate 的情况 | 作用于 EntityManagerFactory,通过 EntityManager 管理事务 |
| JtaTransactionManager | 分布式多数据源场景(较少直接使用) | 依赖应用服务器提供的 JTA 实现,管理跨数据源的两阶段提交 |
数据访问方式决定选哪个。用 Spring Boot + MyBatis 时,只要你在项目中引入 spring-jdbc 或使用 spring-boot-starter-jdbc,Spring Boot 会自动给 DataSourceTransactionManager 配好 bean,这也是大多数人不需要手动配置的原因。
但如果你同时使用 JPA 和 JDBC 模板,或者项目里配置了两个数据源,就一定要把“某个事务管理器负责哪个数据源”这件事理清楚,否则很容易出现“事务配置在 A 数据源,实际写操作却发生在 B 数据源”的局面。
2.2 @Transactional的七个参数,用对了才叫声明式事务
@Transactional 的常用参数我整理成一张清单,逐个说明使用要点:
| 参数名 | 作用 | 使用要点 |
|---|---|---|
| value / transactionManager | 指定使用哪个事务管理器 bean | 多数据源时必填,否则按类型自动匹配,可能匹配错 |
| propagation | 事务传播行为 | 默认 REQUIRED,微服务内部跨 Service 调用需深思熟虑 |
| isolation | 事务隔离级别 | 用数据库默认级别即可,乱调会带来性能损耗或死锁 |
| timeout | 事务超时时间(秒) | 默认取数据库配置,业务上可单独控制执行上限 |
| readOnly | 是否只读事务 | 只读查询可设为 true,优化性能并避免写操作误入 |
| rollbackFor | 指定哪些异常触发回滚 | 默认只检查 RuntimeException 和 Error,检查异常需显式声明 |
| noRollbackFor | 指定哪些异常不触发回滚 | 业务上确定某个异常不需要回滚时使用 |
这里面有几个点经常被忽略。第一是 readOnly,它不是说“你只发了 select 语句,事务就不加锁了”,而是告诉事务管理器整个事务只读,部分数据库会对这种事务做优化。如果你在 readOnly 为 true 的事务里执行了写操作,数据库可能会直接报错,所以它也是防止误操作的一种保护。第二是 timeout,方法是同步执行,如果一个事务里包含了远程调用、消息发送这类耗时操作,事务会被拖住,连接一直不释放,高并发时连接池很容易被占满。第三是 rollbackFor,这是实战里最坑的一个,下面单独说。
2.3 自建数据源时,事务管理器这样装配才不会翻车
Spring Boot 自动配置很省心,但多数据源或自定义连接池时一定要手动装配。一个典型的多数据源配置里,你要同时处理 DataSource、事务管理器、以及让 @Transactional 找到正确管理器的关系。下面的配置是常见的双数据源场景:
java复制@Configuration
public class DataSourceConfig {
@Bean(name = "orderDataSource")
@ConfigurationProperties(prefix = "spring.datasource.order")
public DataSource orderDataSource() {
return DataSourceBuilder.create().build();
}
@Bean(name = "userDataSource")
@ConfigurationProperties(prefix = "spring.datasource.user")
public DataSource userDataSource() {
return DataSourceBuilder.create().build();
}
@Bean(name = "orderTransactionManager")
public PlatformTransactionManager orderTransactionManager(
@Qualifier("orderDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean(name = "userTransactionManager")
public PlatformTransactionManager userTransactionManager(
@Qualifier("userDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
使用的时候在 @Transactional 注解里明确指定事务管理器:
java复制@Transactional(transactionManager = "orderTransactionManager", rollbackFor = Exception.class)
public void createOrder(OrderRequest request) {
// 订单库的操作
}
这段配置里最关键的是 @Qualifier 指定数据源。为什么必须指定?因为同一个类型有多个 bean 时,Spring 不知道自动注入哪个,事务管理器拿到的数据源可能不是你期望的那个库。我见过一个真实案例:两个数据源,事务管理器绑定的是 A 库,业务操作却写在 B 库上,结果事务一直没生效,查了一整天才定位到是注入错数据源。
3. 事务代码落地:从下单场景到传播行为的实践记录
配置层面理顺后,回到业务代码本身。这一章我用一个模拟项目 X 的下单场景串联整个事务落地过程,包括声明式事务怎么写、传播行为怎么选、什么时候必须改用编程式事务。
3.1 一个完整的下单事务案例
项目背景:模拟商城系统,下单操作需要同时写订单表、扣减库存表、更新用户余额表。三个操作分别由不同 Service 类的方法实现,最后在一个门面方法里统一编排,保证要么全部成功,要么全部回滚。
java复制@Service
public class OrderFacadeService {
@Resource
private OrderService orderService;
@Resource
private InventoryService inventoryService;
@Resource
private AccountService accountService;
@Transactional(rollbackFor = Exception.class)
public void placeOrder(OrderRequest request) {
// 1. 创建订单
orderService.createOrder(request);
// 2. 扣减库存
inventoryService.deductStock(request.getSkuId(), request.getQuantity());
// 3. 扣减余额
accountService.deductBalance(request.getUserId(), request.getPayAmount());
}
}
这里有一个要点:事务只能加在门面方法上,不要分散加在三个子方法上。如果 createOrder 里自己开了事务,deductStock 里又自己开了一个事务,那么这两个事务很可能用的是不同数据库连接,门面方法上的事务根本管不到它们。事务的正确边界是整个业务操作的起点,不是每个小方法。
三个子方法不用加 @Transactional,因为它们会被门面方法的事务覆盖。这里关键前提是它们都通过同一个数据源访问数据库,且事务管理器正确绑定这个数据源。如果某个子方法需要独立提交,那就涉及到传播行为,下一节展开。
3.2 传播行为的判断逻辑:REQUIRED、REQUIRES_NEW与NESTED怎么选
传播行为解决的是“当方法 A 调用方法 B,而 A 已经在事务里时,B 应该怎么处理事务”的问题。最常用的几个:
REQUIRED(默认):如果当前存在事务,则加入这个事务;如果当前没有事务,则新建一个。绝大多数业务场景用这个就够了,它让多个子操作天然处于同一个事务边界内。
REQUIRES_NEW:无论当前是否存在事务,都新建一个独立事务,并把当前事务挂起。这个常用于子操作不能跟随主事务回滚的场景。典型例子:订单支付成功后写一条操作日志。如果它在主事务里,订单回滚了日志也没了,但审计日志要求必须保留;如果主事务失败但日志提交成功,结果就是“操作失败却有日志”,这符合审计预期。所以这种情况下日志写入必须用 REQUIRES_NEW 独立事务。
NESTED:在有事务的环境下,创建一个嵌套事务,它使用数据库的 savepoint 实现。嵌套事务回滚只会回滚到这个 savepoint,不会导致外层事务整体回滚。它的典型场景是:一个批量处理任务中,每条数据处理失败只希望跳过本条,而不希望整个批次全部回滚。不过要注意,并不是所有数据库和连接池都支持 savepoint,使用前最好确认一下。
用一张表对比一下:
| 传播行为 | 当前有事务时 | 当前无事务时 | 使用场景 |
|---|---|---|---|
| REQUIRED | 加入当前事务 | 新建事务 | 多步操作需保持一致性的默认选项 |
| REQUIRES_NEW | 挂起当前事务,新建独立事务 | 新建事务 | 日志、计数器等不能被回滚的旁路操作 |
| NESTED | 在 savepoint 上嵌套事务 | 新建事务 | 批量任务每条独立回滚,但不影响整体 |
| SUPPORTS | 加入当前事务 | 无事务执行 | 只读查询、非核心逻辑 |
选择传播行为时还有一个重要提醒:REQUIRES_NEW 不是万能的,它会导致两次数据库连接获取、两个独立事务,如果高频调用会明显增加数据库压力。所以能用 REQUIRED 就不要轻易升级成 REQUIRES_NEW,否则大量子事务会打满连接池。我已经见过数次因为日志方法误加 REQUIRES_NEW,导致高并发下连接池耗尽的事故。
3.3 编程式事务TransactionTemplate的适用场合
@Transactional 虽然简单,但它的静态声明特性在少数场景下反而成了缺点。比如你需要在执行过程中根据业务结果动态决定提交还是回滚,或者事务内需要捕获某部分异常、不影响主流程,再或者一个异步任务里要独立控制事务边界。这些情况下,用 TransactionTemplate 写编程式事务更合适。
java复制@Service
public class RewardService {
@Resource
private TransactionTemplate transactionTemplate;
public void issueReward(String userId, int points) {
transactionTemplate.execute(status -> {
try {
// 给用户加积分
userPointMapper.increase(userId, points);
// 记录奖励发放日志
rewardLogMapper.insert(userId, points);
return Boolean.TRUE;
} catch (Exception ex) {
// 动态决定回滚
status.setRollbackOnly();
log.error("发放积分失败,userId={}", userId, ex);
return Boolean.FALSE;
}
});
}
}
TransactionTemplate 实际使用中要注意两点。第一,execute 方法内部默认在异常抛出时自动回滚,但如果你自己把异常 catch 掉并且没有调用 setRollbackOnly,事务会正常提交,这跟 @Transactional 的失效原理完全一致。第二,如果要在无返回值的方法里用事务,可以调用 executeWithoutResult,避免硬造返回值。我自己写异步任务脚本时更倾向用 TransactionTemplate,因为逻辑更直观,也不会出现自调用代理失效的问题。
4. 排查实录:事务“不生效”的七种典型现场
事务不生效是 Spring 开发中出了名的高频问题,而且往往不是一眼能看穿的。我遇到的案例大多跟代理机制、异常处理和数据库引擎有关,下面按出现频率从高到低讲。
4.1 自调用:最大概率踩到的代理失效坑
这是最经典的问题,没有之一。Spring 声明式事务基于 AOP 代理实现,当你在某个加了 @Transactional 的类里,通过 this 调用同类中的另一个事务方法时会发现事务根本没有效果。
原因很简单:AOP 代理是 Spring 生成的代理对象,这个对象看起来和原类一样,但在方法调用前会插入事务逻辑,包括开启事务、异常后回滚。而 this 指向的是原始对象,不是代理对象,直接调用 this.method() 等于绕过了整个代理层,事务逻辑当然不会执行。
java复制@Service
public class UserService {
@Transactional
public void register(User user) {
insertUser(user);
// 直接调用本类方法,事务代理不生效
this.initMemberPermission(user);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void initMemberPermission(User user) {
permissionMapper.init(user.getId());
}
}
修复方式有三种:把 initMemberPermission 移到另一个 Service 类里;或者注入自身的代理对象;或者在配置里暴露代理并使用 AopContext 获取当前代理。第三种用起来简单,但要在启动类配置 exposeProxy=true:
java复制@EnableAspectJAutoProxy(exposeProxy = true)
// 使用方式
((UserService) AopContext.currentProxy()).initMemberPermission(user);
我更推荐第一种,拆分类本身也符合单一职责原则,事务边界也会更清晰。
4.2 异常被吞掉:catch住异常却不抛出
代码解释异常、捕获异常但不抛出,这是业务代码里最危险的操作。看下面这段:
java复制@Transactional(rollbackFor = Exception.class)
public void createOrderAndDeduct() {
orderService.createOrder(request);
try {
inventoryService.deductStock(request.getSkuId());
} catch (Exception ex) {
log.error("扣减库存失败:{}", ex.getMessage());
}
// 方法正常返回,事务管理器认为没有异常,提交事务,但扣减其实失败了
}
代理在判断是否回滚时,看的不是你的 log,而是方法是否向上抛出了一个匹配 rollbackFor 的异常。你在方法内部把异常捕获了,方法正常返回,事务自然提交,订单数据和库存数据就处于不一致状态。
很多人不理解 Spring 为什么不做“方法内部出现异常也算失败”的判断。因为代理根本不知道方法内部发生了异常,它在方法外部,只能看到方法调用返回的情况。所以一个原则要刻在脑子里:让事务感知到异常,就必须让异常抛出去。不要为了日志或者其他目的把异常包死在事务方法内部。
4.3 方法级别与可见性的限制
Spring 事务代理对方法有硬性要求。@Transactional 只能加载 public 方法上,非 public 方法上的注解不会生效。为什么?
对于 JDK 动态代理,它只能代理接口方法,非接口方法根本无法拦截,更早版本直接不支持;对于 CGLIB 代理,虽然可以代理非 public 方法,但 Spring 出于安全与设计考虑明确不代理非 public 事务方法。另外 protected、private 方法被 this 调用时也不会触发代理。所以写事务方法时一定要用 public,而且要尽量保证它是外部调用的入口。
还有一个跟可见性相关的坑容易被忽略:如果类没有被 Spring 管理(比如你手动 new 出来的对象),那么 @Transactional 完全不生效。这个看起来像废话,但我在代码里真的见过有人在一个非 Spring 管理的工具类方法上加了 @Transactional,还满怀期待它能回滚。
4.4 数据库引擎不支持事务
这种情况在传统项目里遇到过,尤其是一些历史库还在用 MyISAM 引擎。MyISAM 的典型特点就是不支持事务,无论你的 Spring 配置多么正确,它依然会在失败时部分更新。
排查方法特别简单:执行 show table status(以 MySQL 为例),看 Engine 字段是 InnoDB 还是 MyISAM。如果是 InnoDB,事务支持没问题;如果是 MyISAM,要么做表迁移,要么改造表结构。这种问题一旦出现,往往不是单个表,而是一整批表,所以排查时要全库扫一遍。
4.5 多线程环境下的“事务失灵”
Spring 事务通过绑定在 ThreadLocal 上的数据库连接实现,也就是说事务是和当前线程绑定在一起的。如果你在事务方法里手动开了一个线程去执行数据库操作,那个子线程里的连接是从连接池新拿的,根本不在父线程的事务上下文里。
java复制@Transactional(rollbackFor = Exception.class)
public void processOrder(Order order) {
orderMapper.update(order);
executor.submit(() -> {
// 子线程中的操作不参与外层事务
auditService.saveAudit(order);
});
}
子线程里执行的操作即使失败,也不会导致父事务回滚;反过来,父事务回滚后,子线程的操作可能已经提交了。所以对跨线程的数据一致性需求,一定不要把事务的希望寄托在线程池上。真要做异步又有强一致性要求的操作,得走事务消息或本地消息表之类的方案,而不是简单开个线程。
4.6 传播行为配置错位导致的假失效
这个坑和自调用有点类似,但常常被误归类为“事务失效”。看下面这个场景:
java复制@Service
public class OuterService {
@Resource
private OuterService self;
@Transactional(propagation = Propagation.REQUIRED)
public void outer() {
User user = userMapper.selectById(1L);
// 希望这个方法在独立事务中提交
self.inner();
// 模拟后续异常
if (user == null) {
throw new RuntimeException("fail");
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() {
logMapper.insert("inner finished");
}
}
如果 inner 方法能走代理且传播行为是 REQUIRES_NEW,它确实会独立提交。但如果你把 inner 改成 NESTED,或者因为代理问题根本没生效,那么你会发现日志记录也被回滚了,看起来像事务不生效。这里本质不是框架坏了,而是传播行为配错或者代理没触发。排查时先确认调用是否经过代理,再确认传播行为是否符合预期。
4.7 排查事务问题的三板斧
遇到事务“不生效”,我建议按这个顺序排查:
第一步,确认执行的数据库操作是否走了同一个数据源和同一个连接。多数据源配置尤其容易在这里出问题。第二步,开启 Spring 事务埋点日志,观察方法进入时是否真正创建了新事务,关键配置:
yaml复制logging:
level:
org.springframework.transaction.interceptor: TRACE
如果日志显示 No transaction 或者 Rolling back without having transaction,说明事务根本没开启;如果是 Participating in existing transaction,说明加入了已有事务。日志会直接告诉你幕后发生了什么。
第三步,检查方法的可见性、异常抛出情况、数据源是否一致。这三个环节占掉了我遇到的事务问题九成以上。
5. 到了这一步,再聊聊分布式事务的边界
Spring 事务解决的是“单库单应用”内的一致性。但一旦业务拆成多个微服务,或者一个操作要跨多个数据库,仅仅依赖 @Transactional 就不够了。
5.1 Spring事务管不了跨库与跨服务的一致性
单个应用内,即使有多个数据源,也可以通过 JTA 或者中间件实现分布式事务,但代价极高:性能损耗、实现复杂度、网络抖动导致的悬挂事务,都会让你头疼。微服务架构下,一个用户下单操作可能涉及订单服务(写订单库)、库存服务(写库存库)、账户服务(写账户库),三个服务各自的本地事务没法互相感知。这时你需要的不是更复杂的本地事务,而是一套分布式事务协调方案。
5.2 轻量方案:本地消息表与事务消息的基本思路
分布式事务的完整方案展开是非常大的话题,这里只给一个方向判断。如果业务允许“最终一致”,最常用的轻量级做法是本地消息表:
把主业务操作和写消息这两件事放在同一个本地事务中。比如下单服务在事务里写入订单记录,同时插入一条“待发送消息”。事务提交后,后台任务扫描这张消息表,把消息可靠地投递到下游;下游消费成功后回调确认,消息表再删除记录。如果投递失败,就不断重试。这个方案的核心就是利用了 Spring 本地事务的可靠性来保证消息不丢,实现成本低,也不引入额外的中间件,是很多系统从 0 到 1 的首选。
如果你已经在用消息中间件,又希望省掉本地消息表的维护工作,可以调研“事务消息”能力——先把半消息发出去,本地事务提交后再确认发送,否则回滚消息。这个机制本质上也是本地事务思想的延展,只是把“消息表”下沉到中间件内部。
总之,单体阶段把 Spring 事务理解透,能帮你解决 90% 以上的数据一致性问题;真正到了跨服务阶段,再来谈分布式事务也不迟,不要一开始就给自己上最复杂的方案。
我个人在实际排查事务问题的体会是:Nine 成以上所谓“Spring 事务不生效”,根本不是 Spring 的 bug,而是代码里没有让事务知道“这个方法出错了”。把异常捕捉习惯改一改,把自调用改成外部注入代理调用,把非 public 方法改成 public,你基本就能避开这个知识点里最容易踩的大坑。至于传播行为那套命名,刚开始会觉得抽象,多写几个独立日志类、批处理任务,自然就明白了。
