Spring @Transactional 终极指南:从源码原理到数据库底层

189 阅读28分钟

Spring @Transactional 终极指南:从源码原理到数据库底层

事务管理是每一个Java后端开发者的必修课。@Transactional作为Spring框架最核心的注解之一,用好了是利器,用错了是灾难。本文将从最基础的用法讲起,深入到Spring AOP代理机制、MySQL InnoDB存储引擎的内部反应,再到10种事务失效场景的完整避坑指南,为你呈现一份真正"保姆级"的技术手册。

一、引言:一个价值百万的生产事故

先讲一个真实发生的案例。

某互联网金融公司的用户注册接口,业务逻辑是这样的:

  1. 插入用户基本信息到 user 表
  2. 为用户创建一条账户记录到 account 表
  3. 发送一条注册成功的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事务抽象层由三个核心组件构成:

组件类名职责
事务拦截器TransactionInterceptorAOP增强的核心实现,所有事务方法的入口
事务管理器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也不会自动生成 UPDATE SQL去同步数据库。
@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 的方法时,事务管理器会:

  1. 将当前 ThreadLocal 中绑定的 Connection 取出并保存(挂起)
  2. 清空 ThreadLocal
  3. 创建一个全新的 Connection 绑定进去
  4. 新方法执行完毕后,提交新事务
  5. 将之前保存的旧 Connection 重新绑定回 ThreadLocal(恢复)

NESTED 的底层实现:

嵌套事务依赖数据库的 Savepoint(保存点) 机制:

  1. 外层事务开启,建立连接
  2. 进入嵌套方法时,创建保存点(SAVEPOINT sp1)
  3. 内层操作失败时,回滚到保存点(ROLLBACK TO SAVEPOINT sp1)
  4. 外层事务最终统一提交或回滚

注意: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 代码规范

  1. 统一管理:建议所有Service层方法都加上 @Transactional,查询用 readOnly=true,修改用 rollbackFor=Exception.class。

  2. 明确标注:在接口文档或方法注释中明确说明事务边界。

  3. 避免自调用:将事务方法拆分到不同的Service类中,让Spring代理链始终生效。

10.2 性能优化

  1. 事务要"短、平、快":尽量将事务范围控制在最小粒度,减少锁定资源的时间。

  2. 不在事务中调用远程RPC:网络超时会无限拉长事务时间,导致数据库连接池耗尽。

  3. 大数据量操作分批处理:单次事务处理的数据量不宜过大,避免Redo Log堆积。

  4. 善用只读事务:所有纯查询方法加上 readOnly=true。

10.3 监控告警

  1. 监控长事务:在数据库中定期查询执行时间过长的事务:
-- 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;
  1. 设置事务超时:在 @Transactional 中配置 timeout 参数,防止异常情况下的无限等待。

  2. 开启慢查询日志:监控哪些事务方法执行时间过长,及时优化。

十一、总结

@Transactional 是一个典型的"易学难精"的注解。它的魔法建立在:

  1. AOP动态代理机制——只有通过代理对象调用,事务拦截器才能生效
  2. ThreadLocal事务同步——同一线程共享同一数据库连接
  3. 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
3try-catch吃掉异常重新抛出异常
4rollbackFor未指定检查异常设置 rollbackFor=Exception.class
5数据库引擎不支持事务使用InnoDB引擎
6未配置事务管理器配置 PlatformTransactionManager
7方法被final修饰移除final
8多线程调用避免在事务中创建新线程
9错误传播行为使用 REQUIRED 或 REQUIRES_NEW
10类未被Spring管理添加 @Service / @Component

掌握了这些,你不仅能写出健壮的代码,更能在排查生产问题时,一眼看穿那些让同事抓狂的"幽灵事务"。愿你的每一次事务提交,都如你所愿!