【Spring事务】Spring事务注解 @Transactional 完整体系:从 MySQL 隔离级别到 MyBatis 原理详解

作者:大家好,我是 CodeStats。 一个在底层技术上"考古"了四年的硬核爱好者,也是 WWAIC(全周项目AI编程)范式的提出者和实践者。我曾手写过一个完整的 Java Web 框架(从 IoC 容器到嵌入式 Tomcat,代码全开源),也喜欢用通俗的语言拆解 CPU、JVM、操作系统的运行本质。

我的技术信条:所有高深的技术,最后都能用大白话讲清楚。如果讲不清楚,说明还没真正理解。

一、本文你将获得什么

通过本文,我将用"层层递进"的方式,带你系统掌握 Spring 声明式事务的底层全貌:

  1. MySQL事务隔离级别:四种隔离级别的区别与底层实现(MVCC 与锁)

  2. @Transactional 九个参数:每个参数的作用、默认值与生产环境建议

  3. 超时时间(timeout):到底什么时候抛异常?应用层还是数据库层?

  4. "加入"与"挂起":深入线程上下文,彻底理解事务传播的本质

  5. MyBatis 两阶段注册 Bean:SqlSessionFactory 和 MapperFactoryBean 的类结构体与职责

  6. Service 层控制 MyBatis 事务:注解在 Service,代理在 Mapper,中间只隔着一个 ThreadLocal


二、正文(层层递进)

问题一:MySQL事务四种隔离级别区别是什么?

本章核心总结:隔离级别就是数据库在"性能"和"数据一致性"之间做权衡的四档开关。

2.1.1 为什么要隔离?

多个事务并发执行时,如果不加任何控制,会出现三类数据异常:

异常类型 表现 本质
脏读 读到其他事务未提交的数据 信任了不该信任的中间状态
不可重复读 同一事务两次读同一行,结果不同 数据被其他已提交事务修改了
幻读 同一事务两次范围查询,记录数不同 其他事务插入了新数据
2.1.2 MySQL 四种隔离级别
隔离级别 脏读 不可重复读 幻读 MySQL 实现方式
READ UNCOMMITTED ✅ 可能 ✅ 可能 ✅ 可能 不加锁,直接读最新版本
READ COMMITTED ✅ 可能 ✅ 可能 MVCC(每次读生成新视图)
REPEATABLE READ(默认) ❌ 理论上可能,MySQL 通过 MVCC 解决 MVCC(事务开始生成视图)+ 间隙锁
SERIALIZABLE 读加共享锁(S锁),写加排他锁(X锁)

面试追问:MySQL 默认 REPEATABLE READ,为什么 Oracle 默认 READ COMMITTED?因为 MySQL 通过 MVCC + 间隙锁在 RR 下解决了幻读,但代价是更高的锁开销;Oracle 的 RR 不解决幻读(通过序列号快照),本质上更接近 MySQL 的 RC。

2.1.3 Spring 隔离级别枚举

java

复制代码
public enum Isolation {
    DEFAULT,          // 随数据库,MySQL=REPEATABLE_READ,Oracle=READ_COMMITTED
    READ_UNCOMMITTED,
    READ_COMMITTED,   // 生产最推荐,避免脏读且性能优秀
    REPEATABLE_READ,
    SERIALIZABLE
}

问题二:Spring的 @Transactional 注解九个参数分别什么作用?

本章核心总结:九大参数本质是开发者在方法上贴的一张"事务执行命令清单",告诉 Spring"怎么开、怎么关、什么情况算失败"。

2.2.1 参数全览
参数 类型 默认值 一句话作用
value / transactionManager String "" 多数据源时指定用哪个事务管理器
propagation Propagation REQUIRED 有事务就加入,没事务就新建
isolation Isolation DEFAULT 使用数据库默认隔离级别
timeout int(秒) -1(无限) 事务最多跑多久,超时就抛异常
timeoutString String - timeout 的字符串版
readOnly boolean false 是否为只读事务(优化 flush)
rollbackFor Class\[\] {} 哪些异常必须回滚
rollbackForClassName String\[\] {} 同上,用类名字符串
noRollbackFor Class\[\] {} 哪些异常不回滚
2.2.2 核心参数详解
(1)propagation ------ 7种传播行为(面试高频)
传播行为 有事务时的行为 无事务时的行为 经典场景
REQUIRED(默认) 加入 新建 增删改业务
SUPPORTS 加入 非事务执行 查询操作,有也行,没有更快
MANDATORY 加入 抛异常 强制要求在事务中执行
REQUIRES_NEW 挂起外部,新建独立事务 新建 操作日志,必须独立提交
NOT_SUPPORTED 挂起外部,非事务执行 非事务执行 执行不重要的清理脚本
NEVER 抛异常 非事务执行 绝对禁止事务的场景
NESTED 在当前事务中创建保存点 新建(同REQUIRED) 局部回滚,外部统一提交
(2)rollbackFor ------ 最关键的生产配置

java

复制代码
// 生产环境推荐:只要抛异常就回滚,不管是不是RuntimeException
@Transactional(rollbackFor = Exception.class)
public void transfer() { ... }

原因 :Spring 默认只对 RuntimeExceptionError 回滚,对 SQLExceptionIOExceptionchecked 异常不回滚 。不写 rollbackFor = Exception.class,数据库报错可能就悄悄吞掉了。

问题三:@Transactional 超时时间是到时间就抛异常,还是执行时检查?是应用层还是数据库超时?

本章核心总结:@Transactional 的超时是"执行时检查"而非"定时炸弹",它只在数据库操作前看一眼秒表,超时就报错。

2.3.1 检查机制:执行时检查,非后台轮询

答案是:执行时检查。

Spring 不会启动一个后台线程每秒钟去扫描超时事务,而是在数据库操作执行前进行"按需检查"。

检查点有三个:

  1. 每次获取数据库连接时 (最核心):DataSourceUtils.getConnection() 内部会检查 ConnectionHolder.isTimeout()

  2. 事务提交/回滚时:兜底检查,防止纯逻辑执行太久。

  3. 事务开始时启动一个定时任务:仅用于标记超时状态,配合检查点使用。

2.3.2 超时计时范围

Spring 事务超时 = 事务开始时间 到 所有数据库操作执行完成的时间

致命陷阱 :如果方法里有 Thread.sleep(15000)(纯 Java 逻辑)且 timeout=10,不会触发超时 。因为 Spring 只有在执行 SQL 时才去检查秒表,sleep 期间没有数据库操作,不触发检查。

2.3.3 应用层超时 vs 数据库超时
层级 实现类 作用范围 抛出异常
应用层(Spring) ConnectionHolder.timeout 整个事务方法内的所有SQL TransactionTimedOutException
数据库层(JDBC) Statement.setQueryTimeout() 单条 SQL 语句的执行时间 SQLTimeoutException

两者是独立的:

  • Spring 超时到时间了,会在下一次数据库操作前抛 TransactionTimedOutException

  • 数据库超时是单条 SQL 执行太久被数据库 kill 掉。

问题四:@Transactional"加入"和"挂起"是什么意思?

本章核心总结:"加入"就是共享同一根数据库连接,"挂起"就是先把旧连接寄存起来,腾出手来干自己的事。

2.4.1 "加入"(Join)------ 共享同一连接

当前线程的 ThreadLocal(实际是 TransactionSynchronizationManager)里已经有绑定的连接时,新方法直接取出来用,不新建。

java

复制代码
// 加入的本质:直接复用
Connection existing = TransactionSynchronizationManager.getResource(dataSource);
if (existing != null) {
    // 这就是"加入":直接使用现有连接,共用一个事务上下文
    return existing;
}

后果 :所有加入的方法共享同一个物理连接,最终一起 commit 或一起 rollback

2.4.2 "挂起"(Suspend)------ 寄存旧连接,换新连接

当传播行为是 REQUIRES_NEWNOT_SUPPORTED 时,Spring 执行挂起:

java

复制代码
// 挂起的本质:存旧换新
Connection old = TransactionSynchronizationManager.unbindResource(dataSource); // 取出并解绑
suspendedResources = old; // 存到栈里
Connection newConn = dataSource.getConnection(); // 建立新连接
bindResource(dataSource, newConn); // 绑定新连接
// ... 执行内部方法 ...
// 内部方法结束后,恢复旧连接
bindResource(dataSource, old);

后果:内外两个事务完全独立,内部提交/回滚不影响外部。

2.4.3 REQUIRES_NEW vs NESTED(终极对比)
维度 REQUIRES_NEW NESTED
连接数 2个(挂起旧,新建新) 1个(复用旧)
内部回滚 内部回滚后立即提交(如果成功) 回滚到 Savepoint,不提交
外部回滚 不影响内部(已独立提交) 内部一起被回滚
本质 两个独立物理事务 一个物理事务 + 一个逻辑保存点

问题五:MyBatis 两阶段注册 Bean 完整类结构体------SqlSessionFactory 和 MapperFactoryBean 作用

本章核心总结:MyBatis 启动阶段就是两件事------把 SQL 编译成"蓝图"(MappedStatement),把 Mapper 接口包装成"工厂"(MapperFactoryBean)。

2.5.1 第一阶段:启动注册(IoC 容器构建)

Spring Boot 启动时,MybatisAutoConfiguration 负责向容器注册两个核心 Bean:

text

复制代码
【阶段一:BeanDefinition 注册】
1. MybatisAutoConfiguration 被 @Conditional 条件触发
2. 创建 SqlSessionFactoryBean(这是一个 FactoryBean)
   └── 解析 mybatis-config.xml 和所有 Mapper.xml
   └── 生成 Configuration 对象(内含所有 MappedStatement)
3. 通过 @MapperScan 扫描所有 @Mapper 接口
   └── 为每个 Mapper 接口生成 MapperFactoryBean 的 BeanDefinition

核心类结构体

java

复制代码
// 1. SqlSessionFactory(实际实现是 DefaultSqlSessionFactory)
public class DefaultSqlSessionFactory implements SqlSessionFactory {
    // 持有核心配置对象
    private final Configuration configuration;
    
    @Override
    public SqlSession openSession() {
        // 从 configuration 中取 Environment,获取 DataSource
        // 创建 Executor 和 DefaultSqlSession
    }
}

// Configuration 内部结构(第一阶段解析成果)
public class Configuration {
    // 所有 SQL 的"蓝图"
    protected final Map<String, MappedStatement> mappedStatements;
    // 所有注册的 Mapper 接口
    protected final Map<Class<?>, MapperRegistry> mapperRegistry;
    // 数据源、事务工厂等
    protected Environment environment;
}

java

复制代码
// 2. MapperFactoryBean(为每个 Mapper 接口生成代理)
public class MapperFactoryBean<T> extends SqlSessionDaoSupport implements FactoryBean<T> {
    private Class<T> mapperInterface;
    
    @Override
    public T getObject() throws Exception {
        // 返回动态代理对象(不是 Mapper 实现类)
        return getSqlSession().getMapper(this.mapperInterface);
    }
}
2.5.2 第二阶段:运行时调用(执行 SQL)

text

复制代码
【阶段二:运行时调用链】
Service 调用 mapper.selectById()
    ↓
MapperProxy(JDK动态代理)触发 invoke()
    ↓
MapperMethod.execute() 根据 SQL 类型路由
    ↓
SqlSessionTemplate(Spring包装的 SqlSession)
    ↓
从 Configuration 中取出 MappedStatement(蓝图)
    ↓
Executor 执行(Simple / Reuse / Batch)
    ↓
StatementHandler 编译 SQL + 设置参数
    ↓
ResultSetHandler 映射结果

问题六:Service 层的 @Transactional 如何控制 MyBatis 的事务?

本章核心总结:Service 层通过 ThreadLocal 这个"线程公共储物柜"把连接交给 Mapper,实现事务的隔空控制。

2.6.1 核心答案:隔空传物(ThreadLocal)

Service 和 Mapper 之间没有任何直接引用关系 。Service 的代理把连接放进当前线程的 ThreadLocal 仓库,Mapper 执行时主动去这个仓库里拿。

2.6.2 完整调用链路(含源码级原理)
步骤 执行主体 具体动作 对 ThreadLocal 做了什么
TransactionInterceptor(AOP代理) 进入 Service 方法前,从 DataSource 获取物理连接,执行 setAutoCommit(false) 存入TransactionSynchronizationManager.bindResource(dataSource, connection)
UserService(真实业务对象) 执行 orderDao.insert() 无感,不关心连接
MapperProxy(JDK代理) 触发 invoke(),委托给 SqlSessionTemplate 无感
SqlSessionTemplate 内部拦截器 调用 DataSourceUtils.getConnection(dataSource) 取出 :从 TransactionSynchronizationManager.getResource(dataSource) 取出第①步放入的连接
Executor 用取出的连接执行 JDBC 操作 无感
TransactionInterceptor(后置) 方法返回后,根据是否抛异常执行 commit()rollback() 移除TransactionSynchronizationManager.unbindResource(dataSource)
2.6.3 为什么必须把 @Transactional 加在 Service 层?

如果加在 Mapper 层:

  • 每次调用 userMapper.insert() 就开启一个独立事务,执行完立即提交。

  • Service 里三个 Mapper 操作(下单、扣库存、扣余额)就是三个独立事务。

  • 第三个报错时,前两个已经提交了,无法回滚,数据产生不一致。

如果加在 Service 层:

  • 整个 Service 方法是一个事务边界。

  • 所有 Mapper 操作共享同一个连接,一起提交或一起回滚。

  • 保证业务原子性

2.6.4 Spring + MyBatis 整合的连接获取源码级逻辑

java

复制代码
// SqlSessionTemplate 内部获取连接的核心逻辑(简化)
public Connection getConnection(DataSource dataSource) {
    // 1. 先去当前线程的 ThreadLocal 里找(事务管理器放连接的地方)
    ConnectionHolder holder = TransactionSynchronizationManager.getResource(dataSource);
    
    if (holder != null && holder.hasConnection()) {
        // 2. 找到了!说明当前方法被 @Transactional 包裹
        //    直接返回这个已经开启事务的连接(这就是“加入”当前事务)
        return holder.getConnection();
    } else {
        // 3. 没找到!说明没有事务
        //    直接从数据源 new 一个物理连接(非事务场景,用完立即关闭)
        return dataSource.getConnection();
    }
}

三、全文内容总结(层层递进的逻辑链条)

我们把六个问题串起来,形成一条完整的技术认知链:

层级 核心问题 核心结论
数据库层(问题一) MySQL 隔离级别怎么选? 隔离级别是"性能"与"一致性"的四档开关,READ_COMMITTED 是生产最佳平衡点。
Spring API 层(问题二) 九个参数怎么配? 九大参数是贴在方法上的"事务执行命令清单",rollbackFor=Exception.class 是必加项。
Spring 内部机制(问题三、四) 超时和传播怎么实现? 超时是"执行时检查"而非定时炸弹;加入/挂起本质是 ThreadLocal 中连接的"共享"与"寄存"。
MyBatis 整合层(问题五) MyBatis 怎么注册到 Spring? 启动阶段把 SQL 编译成 MappedStatement(蓝图),把 Mapper 包装成 MapperFactoryBean(工厂)。
运行时线程层(问题六) Service 怎么控制 Mapper? 通过 TransactionSynchronizationManager(ThreadLocal 仓库)完成连接的隔空传递与边界控制。

终极结论 :Spring 事务的本质,从来不是代码块嵌套,而是 数据库连接(Connection) 在不同层级之间有序流转与边界控制。理解了 ThreadLocal 这个"线程公共储物柜",你就理解了 Spring 事务、MyBatis 整合、乃至整个 Java Web 声明式治理的底层灵魂。

四、最后

如果这篇从数据库隔离级别一路"考古"到 ThreadLocal 底层流转的文章对你有帮助,欢迎:

  • 点赞 👍 ------ 让更多朋友看到硬核内容

  • 收藏 ⭐ ------ 随时回顾 Spring + MyBatis 事务全链路

  • 关注 👀 ------ 下次我讲"手写 Spring 事务管理器实现"时第一时间收到

有任何疑问,欢迎评论区交流,我会从源码层面一一回复。

相关推荐
我命由我123452 小时前
Android 开发问题:为 PDFView 设置一个带有黑色边框的背景 drawable,但边框没有生效
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
都叫我大帅哥4 小时前
从Python到Java:为什么企业级Agent最终会选择Java?
java·ai编程
wanderist.4 小时前
Lambda表达式在算法竞赛中的应用
java·开发语言·算法
腻害兔5 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:IM 即时通讯模块,一个被低估的「全功能聊天系统」
java·前端·vue.js·产品经理·ai编程
心念枕惊6 小时前
.NET CORE 授权进阶-角色、策略与动态权限实现
java·前端·.netcore
景同学7 小时前
把 AI 用到线上运维:可行、有效,前提是喂足信息——一次 Full GC 排障实录
java·人工智能·后端
C++、Java和Python的菜鸟8 小时前
第7章 后端Web实战(Tlias系统)
java
憧憬成为java架构高手的小白8 小时前
黑马八股--spring框架学习
java·学习·spring
唐青枫9 小时前
Java Picocli 实战详解:用注解写出好用的命令行工具
java