作者:大家好,我是 CodeStats。 一个在底层技术上"考古"了四年的硬核爱好者,也是 WWAIC(全周项目AI编程)范式的提出者和实践者。我曾手写过一个完整的 Java Web 框架(从 IoC 容器到嵌入式 Tomcat,代码全开源),也喜欢用通俗的语言拆解 CPU、JVM、操作系统的运行本质。
我的技术信条:所有高深的技术,最后都能用大白话讲清楚。如果讲不清楚,说明还没真正理解。
一、本文你将获得什么
通过本文,我将用"层层递进"的方式,带你系统掌握 Spring 声明式事务的底层全貌:
-
MySQL事务隔离级别:四种隔离级别的区别与底层实现(MVCC 与锁)
-
@Transactional 九个参数:每个参数的作用、默认值与生产环境建议
-
超时时间(timeout):到底什么时候抛异常?应用层还是数据库层?
-
"加入"与"挂起":深入线程上下文,彻底理解事务传播的本质
-
MyBatis 两阶段注册 Bean:SqlSessionFactory 和 MapperFactoryBean 的类结构体与职责
-
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 默认只对 RuntimeException 和 Error 回滚,对 SQLException、IOException 等 checked 异常不回滚 。不写 rollbackFor = Exception.class,数据库报错可能就悄悄吞掉了。
问题三:@Transactional 超时时间是到时间就抛异常,还是执行时检查?是应用层还是数据库超时?
本章核心总结:@Transactional 的超时是"执行时检查"而非"定时炸弹",它只在数据库操作前看一眼秒表,超时就报错。
2.3.1 检查机制:执行时检查,非后台轮询
答案是:执行时检查。
Spring 不会启动一个后台线程每秒钟去扫描超时事务,而是在数据库操作执行前进行"按需检查"。
检查点有三个:
-
每次获取数据库连接时 (最核心):
DataSourceUtils.getConnection()内部会检查ConnectionHolder.isTimeout()。 -
事务提交/回滚时:兜底检查,防止纯逻辑执行太久。
-
事务开始时启动一个定时任务:仅用于标记超时状态,配合检查点使用。
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_NEW 或 NOT_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 事务管理器实现"时第一时间收到
有任何疑问,欢迎评论区交流,我会从源码层面一一回复。