别再背面试八股了,这 8 种让事务原地爆炸的骚操作,你都亲手写过几种?
前言:事务失效的本质是"代理失联"
很多朋友找 Bug 找了半天,最后发现 @Transactional quietly 地挂掉了。其实 Spring 声明式事务的本质是 AOP 动态代理。
我们实际调用的 Service 方法是经过 CGLIB 或 JDK 增强过的代理对象,代理对象帮我们开启、提交或回滚事务。一旦绕过了代理对象,事务就形同虚设。
今天我们就结合真实的业务场景,盘点 Spring 事务失效的 8 种核心场景 。针对大家反馈最多的 内部调用 和 try-catch异常,我会给出最深度的解毒方案。
第一大坑:try-catch 吃了异常,事务一脸懵
这是很多新手(甚至老手)最容易犯的错,也是生产环境脏数据的头号元凶。
错误示范
java
@Transactional
public void createOrder(OrderDto dto) {
try {
orderDao.insert(dto); // 插入订单成功
inventoryDao.deduct(dto.getSkuId()); // 扣库存时报错抛异常
} catch (Exception e) {
log.error("扣库存失败了,但我不能让用户看到报错", e);
// 异常被吞了,什么也没做
}
}
底层原理剖析
Spring 事务回滚的逻辑大致是这样的:
java
// 代理逻辑伪代码
try {
// 执行业务方法(即你的 createOrder)
method.invoke(target, args);
// 如果没抛异常,提交事务
commit();
} catch (Exception e) {
// 捕获到异常,执行回滚
rollback();
}
当你在业务代码里把异常 catch 住并且不再抛出 ,对于 Spring 的代理拦截器来说,业务方法正常执行结束了,没抛异常,那自然是 "正常提交" 。库存扣失败了,但订单却提交了,这就是经典的数据不一致。
解毒方案(重点)
方案一:手动强制回滚(最优雅,无需上层感知)
如果你的业务逻辑要求"即使扣库存失败,订单也要生成成功,但库存要回滚",那就必须手动标记回滚状态:
java
@Transactional
public void createOrder(OrderDto dto) {
try {
orderDao.insert(dto);
inventoryDao.deduct(dto.getSkuId());
} catch (Exception e) {
log.error("扣库存异常,事务手动回滚", e);
// 核心!手动将当前事务标记为只回滚,Spring 会在提交时 abort
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
方案二:直接抛出(让代理感知)
java
@Transactional
public void createOrder(OrderDto dto) {
try {
orderDao.insert(dto);
inventoryDao.deduct(dto.getSkuId());
} catch (Exception e) {
log.error("扣库存异常", e);
throw new RuntimeException(e); // 重新抛出运行时异常
}
}
第二大坑:同类内部方法自调用(this 的致命陷阱)
如果你在面试中被问到事务失效,90% 会问这个。这也是掘金上争论最多的话题。
错误示范
java
@Service
public class OrderService {
public void placeOrder() {
// 直接调用,没走代理!
this.createOrder();
}
@Transactional
public void createOrder() {
// 插入订单逻辑
}
}
外部调用 orderService.placeOrder(),你会发现 createOrder 上的事务完全没反应。
根源分析:代理对象 vs 原生对象
Spring 容器里放的是 被代理增强过的 OrderService$$CGLIB 对象。当外部调用 orderService.placeOrder() 时,走的是代理对象。
但进入 placeOrder() 方法内部后,this 指向的是当前的原生对象 (Target Object),而不是代理对象。因此调用 this.createOrder() 时,直接调用了原生方法,AOP 拦截器根本没机会执行,事务自然不存在。
如何验证是否走代理?
java
@Transactional
public void createOrder() {
// 如果输出包含 $$EnhancerBySpringCGLIB,说明走代理了
// 如果只是 com.example.OrderService,说明是原生对象,事务失效
System.out.println("当前类: " + this.getClass().getName());
}
解毒方案(深度剖析)
方案一:拆分类(最符合单一职责)
把事务方法抽离到另一个 Service 中。
java
@Service
public class OrderService {
@Autowired
private InventoryService inventoryService; // 独立代理类
public void placeOrder() {
inventoryService.deductInventory(); // 走代理,事务生效
}
}
方案二:自注入(Spring 允许,最优雅的"自救")
利用 Spring 解决循环依赖的特性,注入自己。
java
@Service
public class OrderService {
@Autowired
private OrderService selfProxy; // 注入自己的代理对象
public void placeOrder() {
// 必须通过代理对象调用!
selfProxy.createOrder();
}
@Transactional
public void createOrder() { ... }
}
方案三:AopContext 强制暴露代理
在启动类开启 exposeProxy = true:
java
@EnableAspectJAutoProxy(exposeProxy = true)
@SpringBootApplication
public class Application { ... }
调用时:
java
public void placeOrder() {
((OrderService) AopContext.currentProxy()).createOrder();
}
第三大坑:方法修饰符不是 public
这是一个硬性规定,没什么商量的余地。
错误示范
java
@Transactional
private void updatePrice() { ... } // 失效
@Transactional
protected void updatePrice() { ... } // 失效
@Transactional
void updatePrice() { ... } // 默认包可见,失效
源码实锤
在 Spring 源码 AbstractFallbackTransactionAttributeSource 中:
java
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null; // 直接不解析事务注解
}
Spring 官方认为事务应该只作用于公共接口方法。如果实在要在非 public 方法上用,需要开启 AspectJ 编织模式(mode=AspectJ),但非常不推荐,平白增加复杂度。
结论 :所有 @Transactional 必须标注在 public 方法上。
第四大坑:传播行为(Propagation)瞎改
很多朋友觉得默认的 REQUIRED 不够高级,非要改成 REQUIRES_NEW 或 NESTED,结果事务莫名其妙不生效或死锁。
典型失效场景 1:SUPPORTS 和 NOT_SUPPORTED
java
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void updateCache() {
// 以为有事务,实际上 Spring 会挂起任何当前事务,根本不开启!
}
典型失效场景 2:REQUIRES_NEW 的"假失效"
你以为内外两个方法都有事务?看代码:
java
@Transactional
public void outer() {
inner(); // 调用内部 REQUIRES_NEW 方法
int i = 1/0; // 这里抛异常
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() { ... }
真相 :outer 抛异常,inner 依然会被回滚 !因为这里又是内部调用 (this.inner()),走的还是原生对象,REQUIRES_NEW 根本没机会创建新事务,内外共用一个连接,一荣俱荣,一损俱损。
解毒方案
- 除非你非常清楚嵌套事务的回滚点(Savepoint)机制,否则无脑使用
@Transactional(propagation = Propagation.REQUIRED)(默认) 。 - 如果真的需要
REQUIRES_NEW,请配合 "自注入" 或 "拆分类" 使用,确保新事务走代理对象。
第五大坑:数据库引擎不支持事务
这个问题在接手老项目时极其常见,排查起来能让人怀疑人生。
错误示范
项目用 MySQL,建表语句是:
sql
CREATE TABLE `order` (
`id` bigint(20) NOT NULL
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
原因分析
@Transactional 底层本质是 JDBC 的 conn.setAutoCommit(false) + conn.commit()。但 MyISAM 存储引擎压根不支持事务 ,无论你怎么发 commit 或 rollback 指令,它都会立即落盘,无法撤销。
解决方案
查看并修改表引擎:
sql
-- 查看表引擎
SHOW TABLE STATUS LIKE 'order';
-- 修改为 InnoDB
ALTER TABLE `order` ENGINE = InnoDB;
注意:MySQL 5.5.5 之后默认引擎就是 InnoDB,但如果是 DBA 手动建的老表,或者迁移过来的数据,千万要检查一下。
第六大坑:多数据源未指定事务管理器(分布式/分库场景必看)
现在微服务或读写分离项目非常普遍,如果你有多个数据源(主库、从库、业务库A、业务库B),只写一个 @Transactional 会出大事。
错误示范
java
@Configuration
public class DataSourceConfig {
@Bean(name = "orderDataSource") ... // 订单库
@Bean(name = "logDataSource") ... // 日志库
}
@Service
public class LogService {
@Transactional // 默认去找 Primary 数据源的事务管理器
public void saveLog() {
// 实际操作的是 logDataSource,但事务管理器是 orderDataSource 的!
// 连接不匹配,事务失效或直接报错
logDao.insert();
}
}
原因分析
Spring 事务管理器的核心是 PlatformTransactionManager,它绑定特定的 DataSource。@Transactional 默认会取容器中类型为 PlatformTransactionManager 且标注了 @Primary 的那个。如果你操作的是另一个数据源,该数据源压根没被这个管理器托管,事务自然不生效。
解毒方案
必须在注解中显式指定事务管理器的 Bean 名称:
java
@Transactional(transactionManager = "logTransactionManager")
public void saveLog() {
logDao.insert();
}
第七大坑:未被 Spring 容器管理 / 未启用事务
这属于基本盘没打牢,但往往容易被忽略。
场景一:类没加 @Service
java
// 忘记加 @Service 或 @Component
public class OrderService {
@Transactional
public void save() { ... }
}
// 这个类都不在 Spring 容器里,谁来给你代理?
场景二:传统 Spring XML 项目没开启注解驱动
Spring Boot 自动配置了 @EnableTransactionManagement,但如果你的老项目是 Spring MVC + XML 配置,必须显式开启:
xml
ini
<tx:annotation-driven transaction-manager="transactionManager" />
或者在配置类上加:
java
@Configuration
@EnableTransactionManagement
public class AppConfig { ... }
第八大坑(番外篇):多线程环境下事务飞了
极少数人会遇到,但遇到了就是大麻烦。
错误示范
java
@Transactional
public void batchSave() {
new Thread(() -> {
// 子线程执行插入
orderDao.insert(order);
}).start();
}
原因分析
Spring 事务管理依赖 ThreadLocal 存储当前线程的数据库连接(ConnectionHolder)。事务是绑定在主线程 上的,子线程运行时去获取连接,根本拿不到主线程的事务上下文,所以子线程里的操作是独立于事务之外的,无法回滚。
解决方案
- 不要在事务方法中手动
new Thread()。 - 如果是异步处理,确保使用
@Async并配好独立的事务传播机制,或者干脆将子线程操作封装成单独的服务,让主线程等待结果。
终极总结:一张速查表搞定所有 Bug
| 坑位 | 失效场景 | 一句话核心原因 | 一键解毒 |
|---|---|---|---|
| 1 | try-catch 吞异常 | 代理捕获不到异常,认为执行成功 | 手动 setRollbackOnly 或重新抛出 |
| 2 | 同类内部自调用 | this 绕过代理,AOP 切面没执行 |
自注入 selfProxy 或拆分类 |
| 3 | 方法非 public | Spring 硬编码只拦截 public | 强制改成 public |
| 4 | 传播行为瞎改 | REQUIRES_NEW 遇内部调用失效 / SUPPORTS 不开启事务 |
非资深玩家只用 REQUIRED |
| 5 | 数据库引擎不支持 | MyISAM 不支持事务 DML 回滚 | 改为 ENGINE=InnoDB |
| 6 | 多数据源未指定管理器 | 事务管理器管不了另一个数据源 | 指定 transactionManager 名称 |
| 7 | 未被容器管理/未开启 | 没有代理对象或解析器 | 加 @Service + @EnableTransactionManagement |
| 8 | 多线程调用 | ThreadLocal 线程隔离,子线程拿不到连接 | 避免事务内手动 new Thread |
结语
@Transactional 用好了是神兵利器,用错了是埋雷高手。记住最核心的一条法则:永远确保你的业务代码是通过 Spring 代理对象进入事务方法的。 下次再遇到事务不回滚,把你 IDE 的断点打在 TransactionInterceptor 上,看看有没有进去;再看一眼控制台打印的类名是不是带 $$CGLIB,这两个动作能解决你 90% 的困惑。
你还在哪些奇葩场景下遇到过事务失效?欢迎评论区补充讨论!