事务
开篇:为什么 @Transactional 有时候不生效?
你一定遇到过这种场景:明明方法上加了 @Transactional,数据库操作却没有回滚。你反复检查代码,确认异常确实抛出了,可事务就是不起作用。
这大概是 Spring 开发中最经典的"灵异事件"了。而且它往往发生在最不该出问题的时候——线上已经跑了一段时间,某位同事改了一行代码,事务就悄悄失效了。
要理解它为什么不生效,就得先搞清楚 Spring 事务到底是怎么工作的。这篇文章会带你从原理出发,一步步揭开声明式事务的面纱。
一、Spring 事务基础
两种事务管理方式
Spring 提供了两种事务管理方式,就像开车有手动挡和自动挡一样。
编程式事务(手动挡) —— 你自己控制事务的开启、提交和回滚。代码里写得清清楚楚,什么地方开事务,什么地方提交,什么时候回滚:
public void transfer() {
TransactionDefinition def = new DefaultTransactionDefinition();
TransactionStatus status = transactionManager.getTransaction(def);
try {
accountDao.debit(fromAccount, amount);
accountDao.credit(toAccount, amount);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}也可以用更简洁的 TransactionTemplate,少写一些模板代码:
@Autowired
private TransactionTemplate transactionTemplate;
public void transfer() {
transactionTemplate.execute(status -> {
accountDao.debit(fromAccount, amount);
accountDao.credit(toAccount, amount);
return null;
});
}声明式事务(自动挡) —— 加一个注解就搞定,简洁到令人感动:
@Transactional(rollbackFor = Exception.class)
public void transfer() {
accountDao.debit(fromAccount, amount);
accountDao.credit(toAccount, amount);
}声明式事务用起来确实方便,但正因为"太方便",反而容易出问题。就像自动挡的车,你不了解它的换挡逻辑,可能在某些路况下会出乎你的意料。
声明式事务的粒度问题
在展开原理之前,先聊一个很多人忽略的问题:声明式事务的最小粒度是方法级别。
如果你只想让方法中的几行代码在事务中运行,用声明式事务做不到——你必须把那几行代码抽成一个单独的方法。而编程式事务可以精确控制到代码块级别。
更麻烦的是,声明式事务太"透明"了。有些开发者可能没注意到一个方法被事务包裹着,就在里面加了 RPC 调用、消息发送、缓存更新等操作。这会带来两个问题:
- 这些操作无法回滚,一旦本地事务回滚了,RPC 调用却已经成功了,数据就不一致了
- 事务中有远程调用会拉长事务持有数据库连接的时间,连接池很快就会耗尽
所以阿里巴巴 Java 开发手册建议:在事务场景中优先考虑编程式事务。
声明式事务的实现原理
@Transactional 的底层靠的是 AOP 代理。整个过程可以用三步概括:
注解解析阶段,Spring 启动时通过 SpringTransactionAnnotationParser 扫描被 @Transactional 标记的类和方法,解析注解中的传播行为、隔离级别、rollbackFor 等属性,转化为 TransactionAttribute 对象。
代理创建阶段,Spring 为这些类创建代理对象。这和 AOP 的代理创建是同一套机制——在 Bean 的后置处理器中,匹配到需要事务管理的方法后,创建 JDK 动态代理或 CGLIB 代理。
事务拦截阶段,TransactionInterceptor 拦截方法调用,核心逻辑在 invokeWithinTransaction() 方法中:
// 简化后的核心逻辑
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, methodId);
Object retVal;
try {
retVal = invocation.proceedWithInvocation(); // 执行业务方法
} catch (Throwable ex) {
completeTransactionAfterThrowing(txInfo, ex); // 异常 -> 回滚
throw ex;
} finally {
cleanupTransactionInfo(txInfo);
}
commitTransactionAfterReturning(txInfo); // 正常 -> 提交
return retVal;理解了这个流程,你就能明白一个核心规律:代理失效 = 事务失效。
二、@Transactional 详解
@Transactional 提供了多个属性,让你精确控制事务行为:
| 属性 | 作用 | 默认值 |
|---|---|---|
propagation | 传播行为,决定事务的创建和加入策略 | REQUIRED |
isolation | 隔离级别 | 使用数据库默认 |
rollbackFor | 哪些异常触发回滚 | RuntimeException + Error |
noRollbackFor | 哪些异常不触发回滚 | 无 |
timeout | 超时时间(秒),超时自动回滚 | -1(不超时) |
readOnly | 是否只读,可触发数据库优化 | false |
最容易踩坑的是 rollbackFor。默认情况下,只有运行时异常(RuntimeException)和 Error 才会触发回滚。如果你的方法抛了一个受检异常(比如 IOException),事务是不会回滚的。所以推荐始终显式指定:
@Transactional(rollbackFor = Exception.class)
public void importData() throws IOException {
// 现在不管抛什么异常都会回滚
}三、七种传播行为
传播行为决定了"方法 A 调用方法 B 时,B 要不要加入 A 的事务"。这是面试的绝对高频考点,也是实际开发中最容易混淆的部分。
用生活中的类比来理解:
| 传播行为 | 含义 | 生活类比 |
|---|---|---|
| REQUIRED | 有事务就加入,没有就新建 | "你开了桌我就一起打,没开我自己开一桌" |
| REQUIRES_NEW | 不管有没有,都新建一个独立事务 | "我不管你在干嘛,我自己单干" |
| SUPPORTS | 有事务就加入,没有就不用事务 | "有饭局我就去,没有我就随便吃" |
| NOT_SUPPORTED | 有事务就挂起,自己不用事务 | "你们先暂停,等我做完再继续" |
| MANDATORY | 必须在事务中运行,否则报错 | "不带我一起干就别叫我" |
| NEVER | 不能在事务中运行,否则报错 | "你们别拉我下水" |
| NESTED | 创建嵌套事务(利用保存点) | "我在大计划里开一个小计划,小计划失败不影响大计划" |
REQUIRED vs REQUIRES_NEW
实际开发中用得最多的是 REQUIRED(默认)和 REQUIRES_NEW。一个典型场景是"记录操作日志":
@Service
public class OrderService {
@Autowired
private LogService logService;
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) {
orderDao.insert(order);
logService.saveLog("创建订单: " + order.getId());
}
}
@Service
public class LogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(String content) {
logDao.insert(content);
}
}这样即使日志保存失败,也不会影响订单创建。反过来,订单创建失败回滚了,日志依然会保留——因为它在自己独立的事务中。
NESTED 的特殊之处
NESTED 和 REQUIRES_NEW 容易混淆。区别在于:
REQUIRES_NEW是完全独立的事务,互不影响NESTED是嵌套事务,子事务回滚不影响父事务,但父事务回滚会连带子事务一起回滚
NESTED 底层通过数据库的保存点(Savepoint)实现,所以需要数据库支持。
场景题:读写分离下的传播行为
一个长事务方法 A 在读写分离架构下既有写操作也有读操作,它调用了一个读库方法 B,B 该用什么传播行为?
- 如果 B 是最后一步:用
NOT_SUPPORTED,避免读操作报错导致整个写事务回滚 - 如果 B 是中间步骤:用
REQUIRED,因为 B 失败了后续写操作不该继续执行
四、事务失效的八大场景
这是面试和实战中最高频的知识点。按照根因可以分为三类:
第一类:代理失效
1. 方法不是 public 的
Spring AOP 默认只能代理 public 方法。private 方法只能通过 this 调用,根本不走代理对象。protected 方法虽然子类可见,但 Spring 的事务拦截器不会处理它。
2. 同一个类中的方法自调用
@Service
public class UserService {
public void register() {
this.bindPhone(); // this 调用,不走代理
}
@Transactional
public void bindPhone() {
// 事务不会生效!
}
}解决办法有三种:
- 把
bindPhone()移到另一个 Service 中 - 注入自身:
@Autowired private UserService self;,然后调用self.bindPhone() - 使用
AopContext.currentProxy()获取代理对象
3. final 或 static 方法
CGLIB 通过生成子类来创建代理,无法覆盖 final 方法。static 方法属于类而非对象实例,也无法被代理拦截。
4. Bean 没有被 Spring 管理
自己 new 出来的对象没有经过 Spring 容器,自然没有代理对象,事务不会生效。
第二类:注解配置错误
5. propagation 设置错误
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void updateStock() {
// 即使外层有事务,这里也不参与,不会回滚
}6. rollbackFor 设置错误
只指定了 RuntimeException,结果抛了受检异常(如 SQLException),事务不回滚。
7. 用错了注解包
不小心导入了 javax.transaction.Transactional 而不是 org.springframework.transaction.annotation.Transactional。IDE 自动导包时两个都会出现在候选列表里,一定要看清楚。
第三类:异常被"吞掉"
8. 异常被 catch 了
@Transactional
public void doSomething() {
try {
dao.update();
} catch (Exception e) {
log.error("出错了", e); // 异常被吞掉,事务感知不到
}
}还有一种更隐蔽的情况:你的方法上有另一个自定义注解,对应的 AOP 切面在外层 catch 了异常。事务切面在内层,感知不到异常,就不会回滚。
真实案例
作者亲历过一次线上事故:一位同事新增了一个统一异常处理切面,在切面中 catch 了所有异常做日志记录。结果整个 Service 层的事务全部失效——因为事务切面在异常处理切面的内层,异常被外层吞掉了。排查了很久才发现是切面执行顺序的问题。后来通过移除不必要的异常捕获解决了问题。
补充:多线程下事务失效
@Transactional 使用 ThreadLocal 存储事务上下文,每个线程有自己的事务上下文副本。所以在新线程中的操作不会被包含在原事务中:
@Transactional
public void batchProcess() {
executor.submit(() -> {
dao.update(); // 新线程,不在原事务中
});
}底层原因在 DataSourceTransactionManager.doBegin() 方法中:它将数据库连接绑定到当前线程的 ThreadLocal 上。新线程拿不到这个连接,自然不在同一个事务中。
如果需要跨线程事务管理,只能用编程式事务手动控制。
五、事务事件监听
有时候我们希望在"事务提交成功后"再执行某些操作(比如发邮件、发消息),Spring 提供了 @TransactionalEventListener 来优雅地实现:
@Service
public class UserService {
@Autowired
private ApplicationEventPublisher eventPublisher;
@Transactional
public void register(User user) {
userMapper.save(user);
eventPublisher.publishEvent(new UserRegisteredEvent(user));
}
}
@Component
public class WelcomeEmailListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onRegistered(UserRegisteredEvent event) {
sendWelcomeEmail(event.getUser()); // 事务提交后才发邮件
}
}phase 属性决定了监听器的触发时机:
| Phase | 触发时机 | 典型用途 |
|---|---|---|
BEFORE_COMMIT | 事务提交前,抛异常会导致回滚 | 数据校验 |
AFTER_COMMIT | 事务成功提交后(默认) | 发通知、同步缓存 |
AFTER_ROLLBACK | 事务回滚后 | 补偿、告警 |
AFTER_COMPLETION | 不管提交还是回滚都执行 | 清理资源 |
六、@Transactional 与 @Async 的组合
当事务遇上异步,情况会变得微妙。分三种情况讨论:
情况一:同一个方法同时加 @Transactional 和 @Async
事务生效。方法在异步线程中执行,但该线程内部的事务是完整的。
情况二:@Transactional 方法 A 调用 @Async 方法 B
A 和 B 各自独立。A 回滚不影响 B,B 回滚也不影响 A——因为它们在不同线程中,ThreadLocal 隔离。
@Transactional
public void register() {
userMapper.save(user); // 主线程事务
asyncService.sendNotification(user); // 新线程,独立事务
throw new RuntimeException(); // 主线程回滚,但通知已经发出去了
}情况三:@Async 方法 A 调用 @Transactional 方法 B
B 的事务独立生效。B 抛异常会回滚 B 自己的事务,A 没有事务则不受影响。
核心原因始终是同一个:ThreadLocal 是线程隔离的,不同线程的事务上下文互不相干。
七、常见面试题精选
1. Spring 事务的实现原理?
Spring 的声明式事务基于 AOP 代理。启动时扫描 @Transactional 注解,为目标类创建代理对象。方法调用时,TransactionInterceptor 拦截请求,调用 PlatformTransactionManager 开启事务,方法成功则提交,抛异常则根据 rollbackFor 配置决定是否回滚。
2. 七种传播行为,默认是哪个?
默认是 REQUIRED——有事务就加入,没有就新建。最常搭配的是 REQUIRES_NEW,用于"不管外层怎样,我要独立事务"的场景,比如操作日志记录。
3. 事务失效的常见原因?
八个场景,归为三类:代理失效(自调用、非public、final/static、非 Spring 管理的 Bean)、注解配置错误(propagation、rollbackFor、用错注解包)、异常被吞掉(catch 或其他切面拦截了异常)。
4. 多线程下 @Transactional 能生效吗?
不能。事务上下文存在 ThreadLocal 中,线程隔离。新线程的数据库操作不在原事务范围内。需要跨线程事务管理只能用编程式事务。
5. 编程式事务和声明式事务怎么选?
声明式事务简洁但透明度低,容易被忽略导致误用。编程式事务代码侵入性强,但控制精确、边界清晰。在复杂业务场景(涉及 RPC、消息、多线程)中,推荐编程式事务。简单的单库 CRUD 场景用声明式即可。
八、事务排查清单
当你遇到"事务不生效"的问题时,按照这个清单逐项排查:
- 方法是 public 的吗?
- 是不是同一个类的自调用(this 调用)?
- 方法是 final 或 static 的吗?
- Bean 是 Spring 容器管理的吗?有没有 @Service/@Component?
- propagation 设置对吗?是不是用了 NOT_SUPPORTED 或 NEVER?
- rollbackFor 有没有覆盖到实际抛出的异常类型?
- 导入的 @Transactional 是 Spring 的包还是 javax 的包?
- 异常有没有被 try-catch 吃掉?有没有其他切面在外层捕获了异常?
- 有没有用多线程?新线程里的操作不在原事务中。
- 数据库引擎支持事务吗?比如 MySQL 的 MyISAM 不支持。
按照这个清单走一遍,90% 的事务失效问题都能定位到。
小结
Spring 事务看似简单——加个注解就行——但背后的代理机制、传播行为、失效场景构成了一张复杂的知识网。记住三个核心要点:
- 事务靠代理:理解了 AOP 代理,就理解了事务为什么会失效
- 传播行为决定事务边界:方法之间的调用关系直接影响事务的创建和传播方式
- 异常是回滚的信号:异常被吞掉,Spring 就感知不到,事务就不会回滚
下次再遇到"@Transactional 不生效"的问题,按照这八大场景逐一排查,基本都能找到原因。