Spring @Transactional 终极指南:从源码原理到数据库底层
事务管理是每一个Java后端开发者的必修课。@Transactional作为Spring框架最核心的注解之一,用好了是利器,用错了是灾难。本文将从最基础的用法讲起,深入到Spring AOP代理机制、MySQL InnoDB存储引擎的内部反应,再到10种事务失效场景的完整避坑指南,为你呈现一份真正"保姆级"的技术手册。
一、引言:一个价值百万的生产事故
先讲一个真实发生的案例。
某互联网金融公司的用户注册接口,业务逻辑是这样的:
- 插入用户基本信息到
user表 - 为用户创建一条账户记录到
account表 - 发送一条注册成功的MQ消息
上线第三天,运营人员发现了一个诡异的现象:user 表中存在大量新用户,但 account 表中却没有对应的账户数据。经排查发现,第2步由于数据库主键冲突抛出了异常,但第1步的插入操作却已经提交了。
为什么会这样?因为开发人员只在Service类上加了 @Transactional 注解,却忽略了方法内部对异常进行了 try-catch 捕获且没有重新抛出。Spring事务拦截器根本感知不到异常,于是"愉快地"提交了事务。
这个bug最终导致运营团队花了整整两周时间,手动修复了上万条"孤儿数据"。而修复的代价,是该公司损失了数十万元的用户补偿费用。
这个故事告诉我们:@Transactional 是一把双刃剑。用好了保证数据一致性,用不好就是生产环境的定时炸弹。
本文将带你从基础用法到底层源码,从数据库内部机制到10种失效场景,360度无死角地掌握Spring事务管理的精髓。
二、@Transactional 基础快速入门
2.1 什么是声明式事务
在Spring诞生之前,Java开发者需要手动管理事务,代码通常是这样的:
public void transferMoney(Account from, Account to, BigDecimal amount) {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false); // 手动开启事务
// 执行转账操作
deductFrom(from, amount);
addTo(to, amount);
conn.commit(); // 手动提交
} catch (Exception e) {
if (conn != null) conn.rollback(); // 手动回滚
} finally {
if (conn != null) conn.close();
}
}
这种代码充斥着大量的模板代码,而且很容易遗漏异常处理逻辑。声明式事务正是为了解决这个问题而生——开发者只需要通过注解声明事务边界,具体的开启、提交、回滚逻辑全部交给Spring框架处理。
2.2 最简单的使用方式
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private AccountRepository accountRepository;
@Transactional
public void register(User user, Account account) {
userRepository.save(user);
accountRepository.save(account);
// 如果这里抛出异常,两个操作都会回滚
}
}
就这么简单!在方法上加上 @Transactional,Spring就会自动管理这个方法的事务。方法内所有的数据库操作要么全部成功,要么全部失败回滚。
2.3 @Transactional 可以放在哪里
1. 方法级别(最常用,粒度最细)
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) {
// 业务逻辑
}
}
2. 类级别(作用于类中所有public方法)
@Transactional(readOnly = true, timeout = 30)
@Service
public class ReportService {
// 所有方法都继承类级别的事务配置
public List<Report> generateDailyReport() { ... }
public List<Report> generateMonthlyReport() { ... }
// 方法级别覆盖类级别
@Transactional(readOnly = false)
public void updateReportStatus(Long id, String status) { ... }
}
3. 接口或接口方法(不推荐)
// ❌ 不推荐:Java注解不能从接口继承
public interface UserService {
@Transactional
void save(User user);
}
最佳实践:始终在具体实现类(而不是接口)上使用
@Transactional,这样可以避免代理机制带来的各种诡异问题。
三、核心原理深度剖析:为什么 @Transactional 会"魔法般"地工作
要真正用好 @Transactional,必须理解它背后的运作机制。这一章我们从Spring源码层面,彻底讲透事务管理的本质。
3.1 三大核心组件
Spring事务抽象层由三个核心组件构成:
| 组件 | 类名 | 职责 |
|---|---|---|
| 事务拦截器 | TransactionInterceptor | AOP增强的核心实现,所有事务方法的入口 |
| 事务管理器 | PlatformTransactionManager | 真正执行事务操作(开启、提交、回滚) |
| 事务同步管理器 | TransactionSynchronizationManager | 通过ThreadLocal维护当前线程的事务资源 |
3.2 AOP代理机制深度解析
Spring使用AOP(面向切面编程) 为事务方法生成代理对象。当调用 @Transactional 方法时,实际上调用的不是目标对象本身,而是Spring生成的代理对象。
JDK动态代理 vs CGLIB代理
| 代理方式 | 适用场景 | 特点 |
|---|---|---|
| JDK动态代理 | 目标类实现了接口 | 基于接口生成代理,代理对象和目标对象实现同一接口 |
| CGLIB代理 | 目标类未实现接口 | 通过字节码技术生成目标类的子类 |
Spring会根据目标类是否实现了接口,自动选择合适的代理方式。也可以通过配置强制使用CGLIB:
spring.aop.proxy-target-class=true
3.3 TransactionInterceptor 源码级执行流程
TransactionInterceptor 是事务管理的"大脑"。它的 invoke() 方法包含了完整的事务处理逻辑:
// TransactionInterceptor 核心源码(简化版)
public class TransactionInterceptor implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
// 1. 获取目标方法的事务属性(从 @Transactional 注解中解析)
TransactionAttributeSource tas = getTransactionAttributeSource();
TransactionAttribute txAttr = tas.getTransactionAttribute(method, targetClass);
// 2. 确定使用哪个事务管理器
PlatformTransactionManager tm = determineTransactionManager(txAttr);
// 3. 创建或获取事务,返回事务信息对象
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
Object retVal = null;
try {
// 4. 执行目标方法(真实的业务逻辑)
retVal = invocation.proceedWithInvocation();
} catch (Throwable ex) {
// 5. 异常处理:根据配置决定是否回滚
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
} finally {
// 6. 清理事务上下文(解绑ThreadLocal资源)
cleanupTransactionInfo(txInfo);
}
// 7. 正常执行完毕,提交事务
commitTransactionAfterReturning(txInfo);
return retVal;
}
}
关键步骤详解:
第3步 - 获取事务:createTransactionIfNecessary 方法中,事务管理器会根据传播行为(propagation)决定:
- 是新建一个事务连接
- 还是加入已有的事务
- 还是挂起当前事务
第5步 - 异常回滚判定:completeTransactionAfterThrowing 方法会检查异常类型:
- 如果异常类型匹配
rollbackFor规则 → 执行rollback() - 否则 → 执行
commit()
第6步 - 清理上下文:cleanupTransactionInfo 会从当前线程的 ThreadLocal 中解绑数据库连接,避免内存泄漏。
3.4 ThreadLocal 与事务同步机制
这是理解"事务如何在同一个线程中传播"的关键。
TransactionSynchronizationManager 内部维护了多个 ThreadLocal 变量:
// TransactionSynchronizationManager 源码片段
public abstract class TransactionSynchronizationManager {
// 存储当前线程绑定的数据库连接(核心!)
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
// 存储当前事务的同步回调
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<>("Transaction synchronizations");
// 存储当前事务的名称
private static final ThreadLocal<String> currentTransactionName =
new NamedThreadLocal<>("Current transaction name");
}
当 getTransaction() 开启新事务时,Spring会做这样几件事:
// 伪代码:开启事务时绑定资源
public void beginTransaction() {
// 1. 从数据源获取数据库连接
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
// 2. 将连接包装后存入 ThreadLocal
ConnectionHolder conHolder = new ConnectionHolder(conn);
TransactionSynchronizationManager.bindResource(dataSource, conHolder);
}
这意味着:
- 同一个线程中,无论调用多少个DAO/Mapper,拿到的都是同一个数据库连接
- 所有操作都在同一个数据库会话中执行
- 事务提交或回滚时,所有操作一并提交或回滚
重要推论:多线程环境下,子线程无法获取父线程绑定的连接,因此 @Transactional 无法跨线程传播。这就是"多线程导致事务失效"的根本原因。
四、数据库视角:开启事务时,MySQL InnoDB 内部发生了什么?
很多人以为 BEGIN 或 SET autocommit=0 只是一条简单的指令。实际上,MySQL InnoDB 在收到事务开启指令时,内部会发生一系列精密的连锁反应。
4.1 第一阶段:MySQL Server 层
1. 协议层接收指令
当Spring通过JDBC驱动发送 SET autocommit=0 时,MySQL服务端通过 COM_QUERY 命令包接收该请求。
2. 线程上下文标记
MySQL会为当前客户端连接对应的**工作线程(THD, Thread Handler)**打上标记,将该连接的 autocommit 状态从 ON 修改为 OFF。
3. 事务上下文初始化
在内存中创建一个事务上下文结构体,但此时并不立即分配事务ID(trx_id)。InnoDB采用惰性分配策略,事务ID会在执行第一条增删改操作时才真正分配,以减少内部锁竞争。
4.2 第二阶段:InnoDB 存储引擎层
当Server层将事务控制权传递给InnoDB时,真正的"黑盒操作"才刚开始。
1. 事务内存结构(trx_t)的构建
InnoDB在内存中创建一个 trx_t 结构体来管理该事务的生命周期:
// InnoDB 内部事务结构(概念示意)
struct trx_t {
trx_id_t id; // 事务ID(惰性分配)
trx_state_t state; // 状态:ACTIVE / PREPARED / COMMITTED
time_t start_time; // 事务开始时间
uint32_t isolation_level; // 隔离级别
undo_ptr_t* undo_ptr; // Undo日志指针
lock_t* locks; // 锁信息
ReadView* read_view; // 读视图(MVCC快照)
};
此时设置事务状态为 TRX_STATE_ACTIVE,并将当前线程与事务指针绑定。
2. Read View(读视图)的生成时机——极其重要!
MVCC(多版本并发控制)是InnoDB的核心特性。事务开启时是否生成Read View,取决于隔离级别:
-
隔离级别 = REPEATABLE READ(可重复读):执行
BEGIN时,不会立即生成Read View。而是等到执行第一条 SELECT(快照读) 或 第一条 DML(增删改) 时,才生成Read View。这个Read View会记录当前数据库中所有活跃事务的ID列表,确保整个事务期间读取的数据一致。 -
隔离级别 = READ COMMITTED(读已提交):每次执行
SELECT时,都会重新生成一次Read View,因此每次查询都能看到最新已提交的数据。
3. Undo Log 的预留
为了支持回滚和MVCC,InnoDB需要在数据被修改前,将旧版本数据写入Undo日志段:
- 事务开启瞬间,并不会立即写入Undo数据
- 只是在回滚段(Rollback Segment)中预留一个Undo Slot(槽位)
- 这个槽位供后续的
INSERT、UPDATE、DELETE操作存放旧值 - 如果没有修改操作,这个槽位将一直空置
4. Redo Log 的起点标记
Redo Log(重做日志)用于保证事务的持久性(Durability):
- 开启事务时,InnoDB在**Redo Log Buffer(内存缓冲区)**中记录一条
MLOG_TRX_ID类型的日志,标记该事务的起点 - 注意:此时并不会立即刷盘(
fsync),只是写在内存中 - 只有当事务提交(
COMMIT)或Redo Log Buffer写满时,才会触发刷盘操作
5. 锁(Locks)的初始化
- 事务刚开启时,InnoDB不会自动加任何行锁、表锁或意向锁
- 只是在内存的事务对象中,初始化了一个**隐式锁(Implicit Lock)**的结构空间
- InnoDB的行锁是"懒加载"模式——只有在真正修改数据并发生冲突时,才会将隐式锁升级为显式的行锁(Record Lock / Gap Lock)
4.3 关键结论:开启事务时几乎无磁盘IO
| 操作 | 是否发生磁盘IO | 说明 |
|---|---|---|
构建 trx_t 对象 | ❌ 否 | 纯内存操作 |
| 分配事务ID | ❌ 否 | 惰性分配,此时未分配 |
| 预留Undo Slot | ❌ 否 | 只预留指针,未写入数据 |
| Redo Log写入 | ❌ 否 | 只写入Buffer,未刷盘 |
| 行锁分配 | ❌ 否 | 懒加载,此时未分配 |
| 生成Read View | ❌ 否 | RR级别下,延迟到第一条SQL执行 |
一句话总结:Spring开启事务(BEGIN)的瞬间,数据库几乎不会发生磁盘IO写入,纯粹是在内存中初始化了一系列管控结构。所有的磁盘开销(Undo写入、Redo刷盘、行锁内存分配)都会延后到执行具体的增删改查语句时才真正触发。
4.4 为什么长事务是"性能杀手"?
通过上面的原理可以看出,开启事务本身消耗极小,真正的消耗在于"迟迟不提交":
| 问题 | 后果 |
|---|---|
| Undo Log堆积 | 长事务的Undo日志不会被清理线程(Purge Thread)清除,导致Undo表空间膨胀,侵占磁盘空间 |
| Read View长期持有 | 长事务的MVCC快照长期存在,导致后续其他事务的UPDATE操作生成的旧版本无法及时清理,影响索引扫描效率 |
| 锁时间延长 | 行锁被长期持有,阻塞其他会话的修改,导致锁等待超时(Lock Wait Timeout)和死锁(Deadlock)概率激增 |
| Redo Log无法覆盖 | MySQL崩溃恢复时,必须完整重做Redo Log。若有大事务处于活跃状态,Redo Log的检查点(Checkpoint)无法推进,可能导致Redo Log写满,MySQL被迫进入"只读"冻结状态 |
因此,生产环境强制要求:事务要"短、平、快",严禁在事务中调用远程RPC接口!
五、只读事务 vs 读写事务:数据库内部的"差别待遇"
很多人以为 @Transactional(readOnly = true) 只是告诉Spring"不要执行修改操作"。实际上,这个标志会一路传递到数据库内部,触发完全不同的执行路径。
5.1 最直观的区别:写操作禁令
| 事务模式 | 是否允许修改 | 说明 |
|---|---|---|
| 读写事务(默认) | ✅ 允许 | 可以执行任意 SELECT、INSERT、UPDATE、DELETE |
| 只读事务 | ❌ 禁止 | 对普通表(非临时表)执行修改操作会直接抛出异常 |
特例:只读事务中依然可以对**临时表(TEMPORARY TABLE)**执行增删改操作。
5.2 事务ID(trx_id)的分配策略
这是只读事务最大的性能优化点:
-
读写事务:当事务第一次执行修改操作时,InnoDB会分配一个唯一的事务ID(
trx_id),并将该事务加入全局的rw_trx_list(读写事务链表)中。这个ID是MVCC版本链的核心标识。 -
只读事务:默认不分配事务ID。如果只读事务全程只执行查询操作,自始至终都不会获得事务ID,也不会被加入任何全局链表中。这极大地减少了系统维护全局链表时的锁竞争。
5.3 Undo Log 的分配差异
| 事务模式 | Undo Log分配 | 说明 |
|---|---|---|
| 读写事务 | ✅ 需要分配 | 修改数据前,必须将旧版本写入Undo Log,用于回滚和MVCC |
| 只读事务 | ❌ 不分配 | 不修改数据,无需Undo Log,节省大量存储和I/O开销 |
5.4 Read View 的生成差异
- 读写事务:按照隔离级别规则正常生成Read View。在RR级别下,第一条查询时生成快照,整个事务期间保持不变。
- 只读事务:同样生成Read View,但由于不分配事务ID,其快照的可见性判断逻辑更轻量。
5.5 对应用层框架的影响(以Hibernate为例)
readOnly 标志不仅影响数据库,还会影响ORM框架的行为:
- Hibernate的脏检查(Dirty Checking):当
readOnly=true时,Hibernate会关闭自动脏检查机制。这意味着,即使通过get()方法加载了一个实体并修改了它的属性,Hibernate也不会自动生成UPDATESQL去同步数据库。
@Transactional(readOnly = true)
public void queryAndModify() {
User user = userRepository.findById(1L).get();
user.setName("新名字");
// ❌ readOnly=true 时,Hibernate不会自动执行 UPDATE
// 即使执行了 flush() 也不会提交,因为事务是只读的
}
这节省了大量内存和CPU开销,尤其适合报表查询场景。
5.6 不同数据库的行为差异
需要特别注意的是,readOnly 的实现严格程度因数据库而异:
| 数据库 | 行为 |
|---|---|
| MySQL | 强制禁止对普通表的修改操作,尝试修改会直接抛异常 |
| PostgreSQL | 禁止 INSERT/UPDATE/DELETE,但这是一个"高级别的只读概念",不会阻止所有写入磁盘的行为 |
| Oracle | 只读事务下,所有查询看到的是事务开始时的数据快照(类似RR级别),但不同之处在于Oracle的只读事务完全禁止修改 |
| H2(内存数据库) | 可能静默忽略 readOnly 标志,允许执行修改操作 |
因此,在跨数据库或使用内存数据库进行单元测试时,需要特别关注这个行为差异。
六、全面参数详解:每个配置背后的使用场景
6.1 value / transactionManager —— 指定事务管理器
功能:当应用配置了多个数据源(多数据源场景)时,用于指定使用哪个事务管理器。
@Transactional(value = "orderTransactionManager")
public void createOrder(Order order) {
// 使用专门为订单库配置的事务管理器
}
默认值:空字符串,使用默认的 PlatformTransactionManager。
使用场景:微服务架构中的多数据源、读写分离架构。
6.2 propagation —— 事务传播行为
这是最复杂也最重要的参数。它定义了一个事务方法被另一个事务方法调用时,如何处理事务边界。
| 传播行为 | 说明 | 使用场景 |
|---|---|---|
| REQUIRED(默认) | 如果当前存在事务,则加入;否则新建一个事务 | 绝大多数业务场景,多个操作需要在同一事务中 |
| REQUIRES_NEW | 总是新建一个事务。如果当前存在事务,则挂起当前事务 | 操作日志记录:即使主业务失败,日志也必须保存成功 |
| SUPPORTS | 如果当前存在事务则加入;否则以非事务方式执行 | 查询方法,有事务就用,没有也能跑 |
| NOT_SUPPORTED | 以非事务方式执行。如果当前存在事务,则挂起该事务 | 某些不需要事务的批处理操作 |
| MANDATORY | 必须在一个现有事务中执行。如果没有事务则抛异常 | 强制要求调用方开启事务的场景 |
| NEVER | 以非事务方式执行。如果当前存在事务则抛异常 | 某些特定操作不允许在事务中执行 |
| NESTED | 如果当前存在事务,则创建一个嵌套事务;否则等价于REQUIRED | 批量处理:内层失败只回滚当前条,不影响整体 |
重点详解:
REQUIRES_NEW 的底层实现:
当进入标记为 REQUIRES_NEW 的方法时,事务管理器会:
- 将当前
ThreadLocal中绑定的Connection取出并保存(挂起) - 清空
ThreadLocal - 创建一个全新的
Connection绑定进去 - 新方法执行完毕后,提交新事务
- 将之前保存的旧
Connection重新绑定回ThreadLocal(恢复)
NESTED 的底层实现:
嵌套事务依赖数据库的 Savepoint(保存点) 机制:
- 外层事务开启,建立连接
- 进入嵌套方法时,创建保存点(
SAVEPOINT sp1) - 内层操作失败时,回滚到保存点(
ROLLBACK TO SAVEPOINT sp1) - 外层事务最终统一提交或回滚
注意:NESTED 本质是同一个物理连接,只是利用了JDBC的保存点特性。而 REQUIRES_NEW 是完全独立的物理连接。
6.3 isolation —— 事务隔离级别
隔离级别控制并发事务之间的数据可见性。不同的隔离级别在"一致性"和"并发性能"之间做取舍。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ_UNCOMMITTED | ✔ | ✔ | ✔ | 允许读取未提交数据,性能最高但最不安全 |
| READ_COMMITTED | ✘ | ✔ | ✔ | 大多数生产环境的默认选择,平衡性能与一致性 |
| REPEATABLE_READ | ✘ | ✘ | ✔ | MySQL默认级别,保证同一事务内多次读取结果一致 |
| SERIALIZABLE | ✘ | ✘ | ✘ | 串行执行,最安全但性能最差 |
使用建议:
- 绝大部分业务系统使用
READ_COMMITTED即可 - 需要对账、统计等要求绝对一致的场景,考虑
SERIALIZABLE,但务必评估性能影响 READ_UNCOMMITTED几乎不推荐在生产环境使用
6.4 timeout —— 事务超时时间
功能:设置事务的最大执行时间,单位为秒。超过此时间,Spring会自动回滚事务并抛出超时异常。
@Transactional(timeout = 30) // 30秒超时
public void batchProcess(List<Data> list) {
// 如果执行超过30秒,自动回滚
}
默认值:-1(无超时限制,使用数据库的默认超时配置)。
使用场景:
- 预计执行时间较长的批量操作
- 防止异常情况下事务长时间占用数据库连接
6.5 readOnly —— 只读事务标志
功能:标记该事务为只读事务,数据库和ORM框架据此进行优化。
@Transactional(readOnly = true)
public List<Report> generateReport() {
// 纯查询操作
}
默认值:false。
最佳实践:所有纯查询方法都应该加上 readOnly = true,这不仅是一个"契约声明",更能触发数据库和Hibernate的多重性能优化。
6.6 rollbackFor / rollbackForClassName —— 指定回滚异常
这是最容易踩坑的参数!
功能:指定哪些异常类型会触发事务回滚。
默认规则:Spring默认只对未捕获的 RuntimeException 和 Error 触发回滚。检查异常(Checked Exception)默认不会回滚!
// ❌ 错误示例:抛出 SQLException(检查异常)不会回滚
@Transactional
public void updateData() throws SQLException {
jdbcTemplate.update("UPDATE user SET name='test'");
throw new SQLException("模拟数据库异常");
// 事务依然会提交!
}
// ✅ 正确示例:显式指定所有异常都回滚
@Transactional(rollbackFor = Exception.class)
public void updateData() throws SQLException {
jdbcTemplate.update("UPDATE user SET name='test'");
throw new SQLException("现在会回滚了");
}
使用建议:在所有涉及增删改操作的事务方法上,统一添加 rollbackFor = Exception.class,避免检查异常导致的"事务不生效"问题。
6.7 noRollbackFor / noRollbackForClassName —— 指定不回滚异常
功能:指定某些异常即使抛出也不回滚事务(覆盖 rollbackFor 的配置)。
@Transactional(rollbackFor = Exception.class, noRollbackFor = {BusinessWarningException.class})
public void processOrder(Order order) throws Exception {
// BusinessWarningException 抛出时不会回滚
// 其他所有异常都会回滚
}
使用场景:某些业务警告(如"库存不足")不需要回滚整个事务,只需要记录日志并返回特定提示。
七、查询方法到底要不要加 @Transactional?深度决策指南
这是开发中最常见的争论点。我们来逐一分析。
7.1 不加 @Transactional 的后果
如果不加 @Transactional,每个SQL执行完毕后都会立即自动提交(autocommit=true)。这会带来三个问题:
1. ORM懒加载异常
// Service层:不加事务
@Service
public class UserService {
public User getUser(Long id) {
return userRepository.findById(id).orElse(null);
// 这里返回后,Hibernate Session 已关闭
}
}
// Controller层
@RestController
public class UserController {
@GetMapping("/user/{id}")
public UserDTO getUser(@PathVariable Long id) {
User user = userService.getUser(id);
// ❌ 访问 user.getOrders() 时会抛出 LazyInitializationException
return UserDTO.from(user);
}
}
2. 多次查询数据不一致
@Service
public class StatisticsService {
// ❌ 不加事务,两次查询可能读到不同版本的数据
public BigDecimal calculate() {
BigDecimal total = orderRepository.sumTotal(); // 时间点T1
BigDecimal discount = discountRepository.sum(); // 时间点T2(可能已被其他事务修改)
return total.subtract(discount); // 计算结果可能不准确
}
}
3. 数据库连接频繁获取
每次执行SQL都要从连接池获取连接并释放,高并发下增加了连接池的争用压力。
7.2 加 @Transactional(readOnly = true) 的好处
1. 一致性读(MVCC快照)
在整个方法执行期间,持有同一个MVCC快照(Read View)。无论方法内执行多少次查询、查询多少张表,读取的都是事务开启那一瞬间的数据状态。
2. 支持懒加载
JPA/Hibernate的Session在整个事务期间存活,延迟加载的属性可以在任意时刻成功获取。
3. 数据库优化
如第五章所述,MySQL InnoDB会识别 readOnly 标志,不分配事务ID(trx_id)、不分配Undo回滚段,走轻量化执行路径。
7.3 性能误区纠正
很多人认为"加上事务就慢",这是一个严重的误解:
| 场景 | 不加事务 | 加只读事务 |
|---|---|---|
| 单条简单查询 | ✅ 最快,autocommit直接提交 | 多了一次BEGIN和COMMIT的开销 |
| 3条SQL查询 | ❌ 3次隐式提交(3次网络IO) | ✅ 1次BEGIN + 3次执行 + 1次COMMIT(1次网络IO) |
结论:在包含多条SQL的查询方法中,加上 @Transactional(readOnly = true) 不仅不慢,反而可能更快!
7.4 最终决策矩阵
| 场景 | 建议 | 理由 |
|---|---|---|
单条简单查询(select * from user where id=?) | 可不加 | 自动提交模式开销最小 |
| 多表关联查询 / 多条SELECT | 必须加 | 保证数据一致性,共享MVCC快照 |
| 涉及懒加载 | 必须加 | 防止 LazyInitializationException |
| 定时任务中的统计查询 | 必须加 | 长查询期间数据变动不影响统计结果 |
| 高并发极致压测下的单表缓存查询 | 可不加 | 减少事务上下文切换的微秒级损耗 |
7.5 团队规范建议
推荐在项目组内约定:所有Service层方法都必须加 @Transactional:
@Service
public class OrderService {
// 查询方法:只读事务
@Transactional(readOnly = true)
public Order getById(Long id) { ... }
// 修改方法:读写事务 + 所有异常回滚
@Transactional(rollbackFor = Exception.class)
public void save(Order order) { ... }
}
这样统一了编程模型,彻底杜绝了事务漏加、懒加载异常等问题。
八、实战案例:从简单到复杂
8.1 案例一:用户注册(基础事务)
@Service
@Slf4j
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private AccountRepository accountRepository;
@Autowired
private CouponRepository couponRepository;
@Transactional(rollbackFor = Exception.class)
public void register(RegisterRequest request) {
// 1. 保存用户基本信息
User user = User.builder()
.username(request.getUsername())
.phone(request.getPhone())
.email(request.getEmail())
.build();
userRepository.save(user);
log.info("用户创建成功, id: {}", user.getId());
// 2. 创建账户
Account account = Account.builder()
.userId(user.getId())
.balance(BigDecimal.ZERO)
.build();
accountRepository.save(account);
// 3. 发放注册优惠券
Coupon coupon = Coupon.builder()
.userId(user.getId())
.amount(new BigDecimal("10"))
.expireTime(LocalDateTime.now().plusDays(7))
.build();
couponRepository.save(coupon);
// 如果任意一步失败,所有操作都会回滚
}
}
8.2 案例二:订单创建 + 扣库存 + 日志记录(传播行为)
@Service
@Slf4j
public class OrderService {
@Autowired
private InventoryService inventoryService;
@Autowired
private LogService logService;
@Autowired
private OrderRepository orderRepository;
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderRequest request) {
// 1. 保存订单
Order order = Order.builder()
.userId(request.getUserId())
.productId(request.getProductId())
.quantity(request.getQuantity())
.status(OrderStatus.PENDING)
.build();
orderRepository.save(order);
log.info("订单创建成功, orderId: {}", order.getId());
// 2. 扣减库存(独立事务)
// REQUIRES_NEW 保证即使库存扣减失败,日志仍可记录
inventoryService.deductStock(request.getProductId(), request.getQuantity());
// 3. 记录操作日志(独立事务,必须成功)
// 使用 REQUIRES_NEW 确保即使主业务失败,日志也保存成功
logService.saveLog("用户 " + request.getUserId() + " 创建订单: " + order.getId());
}
}
@Service
@Slf4j
public class InventoryService {
@Autowired
private InventoryRepository inventoryRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void deductStock(Long productId, Integer quantity) {
Inventory inventory = inventoryRepository.findByProductId(productId)
.orElseThrow(() -> new BusinessException("商品不存在"));
if (inventory.getStock() < quantity) {
throw new BusinessException("库存不足");
}
inventory.setStock(inventory.getStock() - quantity);
inventoryRepository.save(inventory);
log.info("库存扣减成功, productId: {}, 剩余库存: {}", productId, inventory.getStock());
}
}
@Service
@Slf4j
public class LogService {
@Autowired
private LogRepository logRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void saveLog(String message) {
OperationLog log = OperationLog.builder()
.message(message)
.createTime(LocalDateTime.now())
.build();
logRepository.save(log);
// 即使这里抛出异常,也只回滚日志记录本身,不影响主业务
}
}
8.3 案例三:批量数据处理(嵌套事务)
@Service
@Slf4j
public class BatchService {
@Autowired
private DataRepository dataRepository;
@Transactional(rollbackFor = Exception.class)
public BatchResult batchImport(List<Data> dataList) {
int successCount = 0;
int failCount = 0;
List<String> errorMessages = new ArrayList<>();
for (Data data : dataList) {
try {
// 使用 NESTED 传播行为,单条失败只回滚当前条
processSingle(data);
successCount++;
} catch (Exception e) {
failCount++;
errorMessages.add("数据 " + data.getId() + " 导入失败: " + e.getMessage());
// 继续处理下一条,不影响其他数据
}
}
return BatchResult.builder()
.total(dataList.size())
.success(successCount)
.fail(failCount)
.errors(errorMessages)
.build();
}
@Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class)
public void processSingle(Data data) {
// 单条数据处理
dataRepository.save(data);
// 这里抛出异常,只会回滚到当前条目的保存点
// 不会影响外层事务中已提交的其他条目
}
}
九、10种事务失效场景完整盘点
这是生产环境最常踩的坑,我将逐一详细解析。
场景1:方法不是 public 的
问题代码:
@Service
public class UserService {
@Transactional // ❌ 事务不会生效
private void updateUser(User user) {
userRepository.save(user);
}
}
源码分析:
在 AbstractFallbackTransactionAttributeSource 中:
protected TransactionAttribute computeTransactionAttribute(Method method, Class<?> targetClass) {
// ...
if (method.getModifiers() != Modifier.PUBLIC) {
return null; // 非public方法直接返回null,事务不生效!
}
// ...
}
解决方案:确保所有事务方法都是 public 的。
场景2:同类方法自调用(最常见的坑)
问题代码:
@Service
public class OrderService {
public void placeOrder(Order order) {
// ❌ 直接调用 this.deductInventory(),走的是原生对象,代理失效!
this.deductInventory(order);
orderRepository.save(order);
}
@Transactional
public void deductInventory(Order order) {
inventoryRepository.reduceStock(order.getProductId(), order.getQuantity());
}
}
原因图解:
外部调用 → 代理对象 → 事务拦截器(开启事务) → 目标对象.placeOrder()
↓
this.deductInventory() ← 直接调用原生对象
(事务拦截器不触发!)
三种解决方案:
方案一:拆分到不同Service类(最推荐)
@Service
public class OrderService {
@Autowired
private InventoryService inventoryService;
public void placeOrder(Order order) {
inventoryService.deductInventory(order); // ✅ 通过代理调用
orderRepository.save(order);
}
}
@Service
public class InventoryService {
@Transactional
public void deductInventory(Order order) {
inventoryRepository.reduceStock(order.getProductId(), order.getQuantity());
}
}
方案二:使用 AopContext.currentProxy()
@EnableAspectJAutoProxy(exposeProxy = true) // 启动类上开启
@SpringBootApplication
public class Application { ... }
@Service
public class OrderService {
public void placeOrder(Order order) {
// ✅ 获取当前代理对象
((OrderService) AopContext.currentProxy()).deductInventory(order);
orderRepository.save(order);
}
@Transactional
public void deductInventory(Order order) {
inventoryRepository.reduceStock(order.getProductId(), order.getQuantity());
}
}
方案三:注入自身
@Service
public class OrderService {
@Autowired
private OrderService self; // 注入自身代理对象
public void placeOrder(Order order) {
self.deductInventory(order); // ✅ 通过代理调用
orderRepository.save(order);
}
@Transactional
public void deductInventory(Order order) {
inventoryRepository.reduceStock(order.getProductId(), order.getQuantity());
}
}
场景3:异常被 try-catch 捕获且未重新抛出
问题代码:
@Transactional
public void deleteUser(Long id) {
try {
userRepository.deleteById(id);
// 模拟异常
int x = 1 / 0;
} catch (Exception e) {
log.error("发生异常", e);
// ❌ 异常被捕获且未重新抛出,Spring事务拦截器感知不到异常,事务照常提交!
}
}
解决方案:
@Transactional(rollbackFor = Exception.class)
public void deleteUser(Long id) {
try {
userRepository.deleteById(id);
int x = 1 / 0;
} catch (Exception e) {
log.error("发生异常", e);
throw new RuntimeException("手动抛出异常,触发回滚", e); // ✅ 重新抛出
}
}
场景4:rollbackFor 未指定检查异常
问题代码:
@Transactional // ❌ IOException(检查异常)不会触发回滚
public void deleteDept(Long id) throws IOException {
deptMapper.delete(id);
if (someCondition) {
throw new IOException("模拟检查异常");
}
}
解决方案:
@Transactional(rollbackFor = Exception.class) // ✅ 显式指定所有异常都回滚
public void deleteDept(Long id) throws IOException {
deptMapper.delete(id);
if (someCondition) {
throw new IOException("现在会回滚了");
}
}
场景5:数据库引擎不支持事务
问题:MySQL的 MyISAM 引擎不支持事务,只有 InnoDB 才支持。
-- 检查表引擎
SHOW TABLE STATUS LIKE 'your_table';
-- 修改为 InnoDB
ALTER TABLE your_table ENGINE = InnoDB;
场景6:未配置事务管理器
在Spring Boot中,引入 spring-boot-starter-data-jdbc 或 spring-boot-starter-data-jpa 后会自动配置事务管理器。
但在传统Spring XML或纯Java配置项目中,需要手动配置:
@Configuration
public class TransactionConfig {
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
// 如果使用JPA
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}
场景7:方法被 final 修饰
被 final 修饰的方法无法被CGLIB代理重写,事务不会生效:
@Transactional // ❌ final方法无法被代理
public final void updateOrder(Order order) {
orderRepository.save(order);
}
场景8:多线程调用
问题代码:
@Transactional
public void process() {
// 主线程持有事务连接
new Thread(() -> {
// ❌ 子线程拿不到父线程的ThreadLocal连接
// 这个操作在独立连接中执行,事务不传播!
userRepository.save(new User());
}).start();
// 主线程的事务提交不会影响子线程的操作
}
根本原因:TransactionSynchronizationManager 中的 resources 是 ThreadLocal 存储,对子线程不可见。
解决方案:避免在事务方法中创建新线程。如需异步处理,使用 @Async + 独立事务。
场景9:错误的事务传播行为
@Transactional(propagation = Propagation.NOT_SUPPORTED) // ❌ 显式禁用事务
public void updateOrder(Order order) {
orderRepository.save(order); // 事务不生效
}
@Transactional(propagation = Propagation.NEVER) // ❌ 抛出异常:已存在事务时拒绝执行
public void updateOrder(Order order) {
orderRepository.save(order);
}
场景10:类未被Spring容器管理
// ❌ 缺少 @Service 或 @Component,未被Spring扫描
public class UserServiceImpl implements UserService {
@Transactional // 完全无效
public void save(User user) {
userRepository.save(user);
}
}
十、最佳实践:让事务成为系统"守护神"而非"绊脚石"
10.1 代码规范
-
统一管理:建议所有Service层方法都加上
@Transactional,查询用readOnly=true,修改用rollbackFor=Exception.class。 -
明确标注:在接口文档或方法注释中明确说明事务边界。
-
避免自调用:将事务方法拆分到不同的Service类中,让Spring代理链始终生效。
10.2 性能优化
-
事务要"短、平、快":尽量将事务范围控制在最小粒度,减少锁定资源的时间。
-
不在事务中调用远程RPC:网络超时会无限拉长事务时间,导致数据库连接池耗尽。
-
大数据量操作分批处理:单次事务处理的数据量不宜过大,避免Redo Log堆积。
-
善用只读事务:所有纯查询方法加上
readOnly=true。
10.3 监控告警
- 监控长事务:在数据库中定期查询执行时间过长的事务:
-- MySQL 查询长时间运行的事务
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) as running_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.INNODB_TRX
WHERE trx_state = 'RUNNING'
AND TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
-
设置事务超时:在
@Transactional中配置timeout参数,防止异常情况下的无限等待。 -
开启慢查询日志:监控哪些事务方法执行时间过长,及时优化。
十一、总结
@Transactional 是一个典型的"易学难精"的注解。它的魔法建立在:
- AOP动态代理机制——只有通过代理对象调用,事务拦截器才能生效
- ThreadLocal事务同步——同一线程共享同一数据库连接
- PlatformTransactionManager抽象——统一了不同数据源的事务管理接口
而MySQL InnoDB在收到事务开启指令时,内部进行的是:
- Server层线程上下文标记
- InnoDB内存结构(
trx_t)初始化 - Undo Slot预留(但不立即写入)
- Redo Log Buffer标记(但不立即刷盘)
- Read View延迟生成(RR级别下)
所有的磁盘IO,都发生在执行具体SQL时。理解这一点,你就能明白为什么长事务是性能杀手——它不写磁盘,但长期占用MVCC版本链,导致Undo表空间膨胀、Redo Checkpoint无法推进。
最后,记住事务失效的10个场景:
| 序号 | 失效场景 | 核心解决方案 |
|---|---|---|
| 1 | 方法非public | 改为public |
| 2 | 同类自调用 | 拆分到不同类 / 注入自身 / AopContext |
| 3 | try-catch吃掉异常 | 重新抛出异常 |
| 4 | rollbackFor未指定检查异常 | 设置 rollbackFor=Exception.class |
| 5 | 数据库引擎不支持事务 | 使用InnoDB引擎 |
| 6 | 未配置事务管理器 | 配置 PlatformTransactionManager |
| 7 | 方法被final修饰 | 移除final |
| 8 | 多线程调用 | 避免在事务中创建新线程 |
| 9 | 错误传播行为 | 使用 REQUIRED 或 REQUIRES_NEW |
| 10 | 类未被Spring管理 | 添加 @Service / @Component |
掌握了这些,你不仅能写出健壮的代码,更能在排查生产问题时,一眼看穿那些让同事抓狂的"幽灵事务"。愿你的每一次事务提交,都如你所愿!