Spring 面经系列 · 第 5 篇:事务这条线,从 ACID 到失效与多线程

💡 面试里问「说一下事务」,十个人有九个上来就背 ACID、背四种隔离级别,像在答数据库的题。可面试官真正想确认的是:Spring 在这条链路里到底干了什么、没干什么。
说穿了,Spring 自己既不写 redo log,也不加行锁,它只是把「事务边界」这件事做成了一层统一抽象,再靠动态代理 + ThreadLocal 复用同一个数据库连接,把 commit / rollback 翻译给底层。一旦你想让它跨线程、跨库去保证一致性,它立刻两手一摊——这不是它管的范围。
这篇文章按视频里的顺序把这条线捋一遍:ACID 归位 → 管理类型与实现方式 → 七种传播行为 → 隔离级别与数据库的对应 → 声明式事务原理 → 传播行为在源码里怎么落地 → 失效场景与多线程事务。凡是我没有对照源码逐行确认的具体类名、方法名、默认值,都会标出来,不会替 Spring 打包票。
1. ACID:Spring 只兜住「边界」,不实现「特性」
视频里先过了事务的四大特性,并且强调这不是 Spring 独有的——凡是碰数据库、碰事务的地方都得遵守它。这段本身没错,但如果面试时只把 ACID 念一遍,等于答成了数据库教科书。这里真正要说清的是每一条特性分别由谁负责。
- 原子性(A):一组操作要么全成功要么全失败。数据库靠 undo log 回滚来兑现「全失败」,Spring 只负责把「哪几条语句算一组」这个边界圈出来,并在异常时把 rollback 指令发给底层。
- 一致性(C):视频用的是转账例子——张三和李四加起来一共 3000 块,转一万次总额还是 3000。这种「数据前后守恒」本质上是业务规则 + 数据库约束(唯一键、外键、check)保证的,Spring 并不校验你的业务金额对不对。
- 隔离性(I):并发事务之间互不干扰,防止脏读、不可重复读、幻影读。这是数据库引擎用锁 / MVCC 实现的,Spring 只是把隔离级别作为参数透传下去。
- 持久性(D):一旦提交就永久保存,哪怕中途宕机也不能丢。这是数据库的 redo log / WAL 落盘在兜底,Spring 完全不管。
一张表看清职责边界:
| 特性 | 谁真正兑现 | Spring 负责的那部分 |
|---|---|---|
| 原子性 A | 数据库(undo log 回滚) | 定义事务边界 + 异常时下发 rollback |
| 一致性 C | 业务逻辑 + 数据库约束 | 不负责,只保证「要么都提交要么都回滚」 |
| 隔离性 I | 数据库(锁 / MVCC) | 把 isolation 参数透传给底层连接 |
| 持久性 D | 数据库(redo log / WAL) | 不负责,只管把 commit 发出去 |
一句话理解:Spring 是「事务的编排者」,不是「事务的实现者」。它把 commit / rollback / 隔离级别这些动作统一成一套 API,底下换成 JDBC、JPA、Hibernate 还是 JTA,你写的代码不变;但 ACID 这四个字母,绝大多数是数据库引擎在替你扛。
所以视频里那句「本地事务也好、分布式事务也好,都应尽量遵循这四大特性,可能做不到百分之百,但要尽量遵循」——这句话的落点其实在分布式场景:单机里数据库帮你把 A、I、D 兜住了,一旦跨库跨服务,这些保证就散了,才需要你自己想办法。这点在第 7 节的多线程 / 分布式里会回扣。
2. 事务管理类型与实现方式:两种类型,三条实现路径
视频把这道题拆成两个小问:一是「管理类型」,二是「实现方式」。
管理类型只有两种:编程式和声明式。所谓编程式,就是你自己手动控制开启、提交、回滚。最原始的样子就是早期直接用 JDBC:拿一个 Connection,setAutoCommit(false) 相当于开启事务,操作做完手动 commit(),出异常就 rollback()。Spring 在之上也提供了编程式入口——注入一个 TransactionTemplate,把数据库操作丢进它的 execute() 里,一旦捕获到异常,就把事务状态设成 rollback-only(只回滚),否则让它正常提交。它的好处是灵活,某些声明式满足不了、想手动控制提交时机的场景用得上。
声明式就是大家最熟的 @Transactional:不用自己写提交回滚,交给 Spring。它又分两种配法——注解,或者在 XML 里用 <tx:advice> 这种通知的方式声明「哪些方法走什么事务」。
| 维度 | 编程式事务 | 声明式事务 |
|---|---|---|
| 边界控制 | 自己写代码控制 | 注解 / XML 元数据声明 |
| 代表 API | TransactionTemplate.execute | @Transactional / tx:advice |
| 侵入性 | 有,业务代码里混事务逻辑 | 无,靠代理织入 |
| 灵活度 | 高,可随时手动提交 | 受注解语义限制 |
| 使用频率 | 少数特殊场景 | 绝大多数场景 |
至于实现方式,视频说得很直接:Spring 声明式事务本身就是基于 AOP 的,所以它的实现路径跟 AOP 那套几乎一模一样,分基于接口、基于 XML、基于注解三种(AspectJ 算第四种,但视频强调严格来说只讲三种)。基于接口的方式现在不建议用,但它对应的是早期的 TransactionProxyFactoryBean 一类 API——你得自己声明通知、自己创建动态代理。视频作者特意说,讲源码之前先普及一遍接口用法,反而更容易看懂底层。这里我把它归类为「视频里给的说法」,具体类名笔者未对照源码核实。
这里补一个视频没展开、但面试常追问的点:声明式事务真正落地时靠的是 TransactionManager 家族。Spring 定义了一个统一的 PlatformTransactionManager 接口,按你底层用什么数据访问技术,换不同实现:
| 实现类 | 管的是什么 | 典型场景 |
|---|---|---|
DataSourceTransactionManager | 一个 JDBC Connection | 单库、MyBatis / 原生 JDBC |
JpaTransactionManager | JPA 的 EntityManager | Spring Data JPA |
HibernateTransactionManager | Hibernate Session | 老项目直接用 Hibernate |
JtaTransactionManager | 应用服务器的全局事务 | 跨多个数据源的分布式事务 |
PlatformTransactionManager 这层抽象,正是第 1 节「Spring 只当编排者」的技术落点:无论哪个实现,它做的都是同一件事——开启、提交、回滚、(必要时)挂起。至于 NESTED 传播、事务挂起这些高级动作,并不是每个实现都支持,这点第 3、6 节会再提。(本表为笔者依据官方文档补充,视频未点名这几个类。)
Q&A 先摆一个这里容易踩的:
Q1:Spring 是不是自己实现了原子性 / 持久性?
不是。Spring 拿不到 undo log、也写不了 redo log,它做的事是把「一组操作」绑在同一个连接上,出异常时补发一条 rollback。真正的「要么全做要么全不做」「提交后不丢」,是数据库引擎在磁盘和日志层面完成的。换句话说,你把 @Transactional 加在一个不支持事务的存储引擎上(比如 MySQL 的 MyISAM 表),Spring 发再多 rollback 也没用——因为它根本没有可回滚的东西。(前半句是公开事实,后半句「MyISAM 失效」是 MySQL 常识,笔者未逐条对照文档,列为补充。)
3. 七种传播行为:语义、用途与坑,全在一张表里
视频里先给了一个定义上的提醒:面试题目往往只说「说一下事务传播行为」,不会帮你把「什么是传播行为」问出来,所以你得自己先交代——传播行为只在「两个事务方法嵌套调用」时才有意义,也就是外层一个事务方法里又调了内层一个事务方法,内层到底是「融入外层」「另开一个」「还是报错」,这个规则就是传播行为。
Spring 一共定义了七种。视频反复强调前三种(REQUIRED / SUPPORTS / REQUIRES_NEW)是必须能脱口而出的,其余能记多少说多少。我把它做成一张对照表,右边两列专门写「典型用途」和「容易踩的坑」:
| 传播行为 | 外层有事务时 | 外层无事务时 | 典型用途 | 常见坑 |
|---|---|---|---|---|
| REQUIRED(默认) | 融入外层,共用一个事务 | 自己开一个新事务 | 增删改,要保证一起成功失败 | 以为「开了注解就一定是新事务」,其实它只是融入 |
| SUPPORTS | 融入外层 | 不开事务,裸跑 | 查询方法,单独调没必要开事务 | 单独调用时无事务,隔离级别设了也白设 |
| REQUIRES_NEW | 挂起外层,另开独立新事务 | 自己开一个新事务 | 记日志:外层成不成都要求日志落库 | 内外是两个事务,外层回滚带不回内层已提交的 |
| NOT_SUPPORTED | 挂起外层,以非事务方式跑 | 非事务方式跑 | 不需要事务、且想脱离外层事务的操作 | 别指望它有原子性,出异常不会回滚 |
| MANDATORY | 融入外层 | 直接抛异常(要求外层必须有事务) | 强制「必须在事务里被调用」的方法 | 单独调用它就炸,常被误用 |
| NEVER | 直接抛异常(外层不能有事务) | 不开事务,正常跑 | 明确禁止在事务上下文里执行的方法 | 一旦它出现在事务链上就报错 |
| NESTED | 在外层里开嵌套事务(savepoint) | 相当于 REQUIRED | 部分回滚:子回滚不影响父提交 | 只有特定事务管理器支持,默认多为 DataSourceTransactionManager |
视频里对三个高频的解释很实在:REQUIRED 适用增删改;SUPPORTS 适用查询,因为单独调用查询没必要开事务,被事务方法调用时才融入;REQUIRES_NEW 适用「内外没有业务关联」的场景,最典型就是记日志——不管主业务成功失败都想把这条日志留下,所以自己另开一个事务,不受外层影响。这两个「独立事务」的语义,直接引出了源码里的「挂起 / 恢复」机制,放到第 6 节讲。
Q2:NESTED 和 REQUIRES_NEW 都能「自己搞一个」,区别在哪?
区别在它们和外层事务的关系。REQUIRES_NEW 是彻底独立的两个事务:外层事务被挂起,内层自己提交,内层回滚不回滚外层、外层回滚也不影响已经提交的内层。NESTED 用的是 savepoint(保存点),内层是外层的一个「子集」——内层回滚只回到 savepoint,外层还在;但外层一旦回滚,内层跟着一起没了。视频里提到 NESTED「比较复杂,可以自己控制哪些操作需要事务、哪些不需要」,本质就是 savepoint 的部分回滚能力。要注意它的适用范围很窄。
(七种常量名与语义、NESTED 仅 DataSourceTransactionManager 开箱支持、REQUIRES_NEW / NOT_SUPPORTED 的挂起并非所有管理器都支持——均已对照官方 Propagation 文档核实。视频里「MANDATORY 就是外部一定要有事务」「NEVER 就是我看到外部开事务直接不干了」是口语版,对应官方语义一致。)
4. 隔离级别:Spring 的五档 ↔ 数据库的三种并发问题
视频对这块的态度很有意思:作者说自己不太想讲,因为不想把基础概念再念一遍,但面试会问,所以只做「总结 + 答题技巧」。隔离性对应的是并发事务下的三类读问题——脏读、不可重复读、幻影读。Spring 的隔离级别本质是对 JDBC 隔离级别的封装,一共五档(含 DEFAULT)。
先把三种并发问题用一个「账户转账」的例子讲清楚(视频用的就是转账 / 存取款的场景):
- 脏读:事务二把 1000 改成 800 但还没提交,事务一进来查到了 800。读到了未提交的「脏」数据,一旦二回滚,一拿到的就是错的。
- 不可重复读:事务一先读到 1000,事务二改成 800 并提交了,事务一在同一事务里再读变成 800。同一个事务两次读同一行结果不一样。
- 幻影读:事务一按条件查总额度读到 3000,事务二插入了一条新记录并提交,事务一再查总条数 / 总额对不上。它针对的是「整张表里符合条件的行集合」,而不是某一行。
这里就是最容易搞混的地方,视频专门点了一句:不可重复读针对「一行数据」被改,幻影读针对「整张表」被插入新行。所以解法也不同——不可重复读锁到行级别就够了,幻影读得把范围 / 表锁住。
隔离级别与三类问题的对应关系:
| Spring 常量 | 脏读 | 不可重复读 | 幻影读 | 数据库大致怎么兜底 |
|---|---|---|---|---|
READ_UNCOMMITTED | 可能 | 可能 | 可能 | 基本不加读锁,并发问题全开 |
READ_COMMITTED | 阻止 | 可能 | 可能 | 只读已提交数据,解决脏读 |
REPEATABLE_READ | 阻止 | 阻止 | 可能 | 视频说核心是「加锁」,锁住读过的一行 |
SERIALIZABLE | 阻止 | 阻止 | 阻止 | 视频直白说「说白了就是表锁」 |
DEFAULT | 取决于数据库 | — | — | 用底层数据库自己的默认级别 |
关键一条:Spring 的隔离级别默认是 DEFAULT,也就是跟随数据库。视频给了两个具体值——MySQL 默认是「可重复读」,Oracle 默认是「读已提交」。这属于数据库公开事实,也是面试最爱顺带问的一句。
Q3:给一个 SUPPORTS 的方法设 isolation,会生效吗?
不会。这是我在官方文档里核到的一条硬性约束:隔离级别只对「新开的事务」有意义。SUPPORTS 单独调用时根本不开事务,NOT_SUPPORTED 更是以非事务方式跑,你给它设 READ_COMMITTED 也是白设。视频没点破这条,但和第 3 节 SUPPORTS 单独调时无事务的说法是自洽的。(此条已对照 @Transactional 官方文档核实:isolation 仅适用于新开事务,实际只对 REQUIRED / REQUIRES_NEW 这类会创建事务的行为有意义。)
补充一点边界:视频用的是标准 SQL 模型(幻影读只有 SERIALIZABLE 挡得住),而 MySQL InnoDB 在其默认的 REPEATABLE_READ 下靠 next-key / gap lock 其实也能挡大部分幻影读。这属于 MySQL 引擎的实现细节,笔者未对照 MySQL 文档逐条核实,仅作提示,面试时按「标准模型」答最稳。
5. 声明式事务原理:动态代理 + ThreadLocal 复用同一条连接
这是全套题里难度拉满的一块。视频把它归到「你得看过源码才答得出」的档次,但主线其实只有一句话:Spring 声明式事务整套机制就是套在 AOP 上跑的,而事务的本质,是「同一线程复用同一条数据库连接」。
先看怎么用:@Configuration 配置类上要先加 @EnableTransactionManagement,才能在各方法上用 @Transactional。视频强调——不加这个开关,注解是不生效的。
原理分三大步,和 AOP 完全对齐:解析 → 创建动态代理 → 调用增强。
第一步解析。AOP 里解析的是 advisor(切面 = pointcut 切点 + advice 通知),发生在 Bean 创建前的第一个后置处理器里。但事务「没有切面、也没有前后置通知」,它解析的是什么?视频给的答案是:@EnableTransactionManagement 会导入一个配置类,这个配置类帮我们注册了一个针对事务的 advisor。所以事务的 advisor 不是你用 @Around 之类注解写出来的,而是它内置提供的。这个 advisor 也有 pointcut,只不过切点不是切点表达式,而是「你方法 / 类 / 接口上有没有 @Transactional」。
第二步创建代理。在 Bean 初始化后、由 Bean 后置处理器创建动态代理,规则和 AOP 一样:有接口走 JDK 动态代理,没接口走 CGLIB。创建前拿 advisor 的 pointcut 去匹配 @Transactional,匹配方式很宽——方法上有、类上有、接口 / 抽象类 / 父类上有,满足任意一个就给你建代理。
第三步调用增强,这才是事务真正发生的地方。代理把目标方法包了一层 try / catch:进来先在增强里拿一个 Connection、把它 setAutoCommit(false)(开启事务),执行目标方法里的 SQL,正常结束就 commit,抛异常就 rollback。视频那句「说白了它就是在动态代理的增强类里,为你包了一层事务」非常精准。
调用 @Transactional 方法
→ 代理拦截:取连接 + setAutoCommit(false) 开事务
→ 把 Connection 存进 ThreadLocal(以 DataSource 为 key)
→ try { 执行目标方法 / SQL } → 正常 commit,异常 rollback
为什么是 ThreadLocal? 视频里 P03 和 P38 反复落到这个点:外层方法把连接存进 ThreadLocal,就是为了嵌套调用的内层方法能从同一个 ThreadLocal 里取到同一个 Connection——只有用同一条连接,才能保证是同一个事务。同时内层如果发现 ThreadLocal 里已经有值,就不会自己去提交,而是把提交权统一交给最外层。这套「按线程绑定资源」的机制,官方实现就是 TransactionSynchronizationManager(下面第 6 节源码会点名它)。
这一层我核到了官方描述:TransactionSynchronizationManager 以「每线程(per-thread)」方式把资源绑到当前线程,绑定用的 key 通常就是 DataSource,value 是连接持有对象;同线程同事务里后续再取,拿到的就是同一个连接;事务结束时由事务管理器 clear 掉、解绑。这与视频讲的「存进 ThreadLocal、以 DataSource 为 key、取出复用」完全对得上。
6. 传播行为的源码实现:挂起、恢复、新建事务的分支
第 5 节讲清楚了「融入」,但视频明确指出:传播行为是在「调用动态代理」那一刻处理的,跟解析切面、创建代理没关系——所以本节只聚焦调用阶段。源码虽然复杂,但七种传播归纳起来就两种走向:要么融入外部事务,要么新建一个事务。视频就讲这两种。
要讲清这两个走向,先记住两个贯穿始终的关键点(视频反复强调「看过源码就记这两个」):
- 开启事务后会返回一个事务状态对象,源码里叫
TransactionInfo(视频原话),里面有个关键布尔属性newTransaction——它是不是一个新事务。外层自己创建连接开事务时它为true;内层只是融入时它是false。 - 提交动作永远只提交「当前
ThreadLocal里那条连接」。所以谁把连接放进 / 拿出ThreadLocal,谁就决定了这次事务到底提不提交。
走向 A:内嵌融入外层。 外层创建 Connection、setAutoCommit(false)、存入 ThreadLocal、newTransaction=true,然后执行目标方法;目标方法里调了内嵌事务方法,又走一遍内嵌的动态代理增强。内嵌增强里先判断 ThreadLocal 上是不是已经有连接——有,说明外面已经有事务了;再判断当前传播行为是「融入」这一类,于是不创建新连接,newTransaction=false,直接用外层连接执行 SQL。内嵌方法执行完,到提交这一步会判 newTransaction:是 false → 不提交,方法结束、控制权回到外层。外层继续往下走,最后判 newTransaction=true,从 ThreadLocal 拿连接提交。整个过程就是一次提交,落在最外层。
走向 B:内嵌新建事务(对应 REQUIRES_NEW / NOT_SUPPORTED 的「挂起」)。 前面几步一样:外层建连接、开事务、newTransaction=true、目标方法调用内嵌。区别在内嵌判断传播行为是「新建事务」这一类时,它做了一件关键的事:把外层事务的相关信息(连接、隔离级别、是否只读等)先「暂存」起来,也就是把外层 ThreadLocal 里存的事务信息置空,然后自己新建一条 Connection、放进 ThreadLocal——因为第 5 节说过,一个线程的 ThreadLocal 只保证有一条连接 / 一个事务。这个「暂存 + 置空 + 自建」的动作,就是官方所谓的挂起(suspend)。内嵌 newTransaction=true,执行自己的 SQL,到提交时判为 true → 提交自己;提交完,它再把「之前暂存的外层事务信息」回归回 ThreadLocal——这就是恢复(resume),让外层重新占回当前线程。控制权回到外层后,外层同样判 newTransaction=true,拿回自己的连接提交。
融入: 外层存连接 → 内嵌查到已有连接(newTransaction=false) → 不提交 → 外层统一提交
新建/挂起:外层存连接 → 内嵌「暂存外层信息 + 置空 + 自建新连接」→ 内嵌提交自己 → 「恢复外层信息」→ 外层再提交
视频也坦诚:这块「没看过源码听着会晕」。所以面试时不用背全,抓住三句话就能立住——连接存 ThreadLocal、提交只提交 ThreadLocal 里的连接、newTransaction 决定这次提不提交;新建事务比融入多了个「暂存 / 回归(挂起 / 恢复)」的概念。至于「外部存在事务却报异常」那类(NEVER / MANDATORY),视频直接说不是重点,略过。
这里回扣第 3 节那句「挂起不是所有管理器都支持」:官方 Propagation 文档明确写了 REQUIRES_NEW / NOT_SUPPORTED 的事务挂起在部分管理器上开箱不支持,尤其 JtaTransactionManager 要看具体是否配置了相应的 TransactionSynchronization。也就是说上面这套「暂存 / 置空 / 恢复」的漂亮逻辑,落到 JtaTransactionManager 上不一定走得通。
7. 失效场景与多线程事务:Spring 兜不住的那些坑
前面全是「怎么生效」,这一节讲「什么时候不生效」,这也是最容易在面试里被追问、被要求「说一个你踩过的坑」的地方。视频作者给答题定调:把失效原因答全,说明你真在项目里踩过。
失效场景清单。 视频把 P40 和 AOP 的失效思路绑定:因为事务本来就基于 AOP,所以凡是 AOP 失效的原因,事务同样失效。我做成了可勾选的清单,并补上了视频没明说、但面试常凑齐的几条:
| 失效场景 | 根因 | 解决办法 |
|---|---|---|
方法是 private / 非 public | 代理增强默认拦不到非公开方法,事务织不进去 | 改成 public |
| 目标类没交给 Spring 管理 | 不是 Bean,根本没为其创建代理 | 加上 @Service / @Component 等 |
| 同类自调用(内部调用) | 没重新走代理,增强不触发,传播行为也失效(视频列为最关键) | 注入自身走代理,或开 exposeProxy 用 AopContext.currentProxy() |
| 异常被自己 catch 后没抛出 | 代理增强没感知到异常,不会触发 rollback | 别吞异常,或手动 setRollbackOnly |
| 抛的是 checked 异常 | 默认只对 RuntimeException 和 Error 回滚,checked 异常不回滚 | @Transactional(rollbackFor = Exception.class) |
@Transactional 标在接口方法上、却用 CGLIB 代理 | CGLIB 基于类,接口上的注解匹配不到 | 注解标到实现类 / 实现方法上 |
| 传播行为「吞掉」了预期事务 | 如误用 SUPPORTS/NEVER/NOT_SUPPORTED,以为开了其实没开 | 核对第 3 节表格语义 |
| 存储引擎不支持事务 | 底层无可回滚的东西(如 MyISAM 表) | 换成支持事务的引擎(InnoDB) |
| 多线程执行 | 事务信息绑在 ThreadLocal,子线程拿不到(下详) | 见第 7 节后半 / 分布式方案 |
自调用这一条值得多说两句,因为它是「内部调用导致传播失效」的根源。视频的例子是:本类事务方法 A 里直接调本类的 B,而你把 B 的传播行为设成「重新开一个」(REQUIRES_NEW),结果 B 根本没另开事务。原因不是传播规则坏了,而是你走的是 this.B() 这种本类直接调用,没经过代理对象,那段「新建事务 / 挂起」的增强代码压根没执行。两种解法视频都给了:一是把自身再注入一遍(@Resource private XxxService self;),用 self.B() 走代理;二是在配置里启用 AOP 并「暴露代理到当前线程」,调用时通过 AopContext.currentProxy() 拿代理对象再调。(exposeProxy / AopContext 的精确 API 名笔者未逐字对照源码,列为视频原话。)
再看 checked 异常这条——它是被问得最多、又最反直觉的:默认情况下你 try 里抛了个 checked 异常,事务不回滚。官方文档给的回滚默认规则很明确:不配自定义规则时,只对 RuntimeException 和 Error 回滚,checked 异常不回滚;想让 checked 也回滚,用 rollbackFor(或 rollbackForClassName)。所以视频说「把异常抛出去」还不够,抛对了类型 + 配对了 rollbackFor 才行。
Q4:Spring 用多线程跑事务,能保证一致性吗?
不能——视频里这题是作者一个学员去面试某大厂时碰到的原题,答案斩钉截铁:保证不了一致性,也保证不了原子性。 原理就是第 5 节那句话:事务信息全在 ThreadLocal 里,一个线程只绑一条连接、只有一个事务。你在 A 里 new Thread 调 B,B 在自己的线程上,查不到 A 那个 ThreadLocal 里的连接,于是它自己另开一条连接、另起一个事务,两人各干各的。结果就是 B 抛异常只回滚 B,A 的数据照样提交——A 插进去了、B 回滚了,数据库里只剩一半。官方也写得很直白:@Transactional 不会传播到方法内部新起的线程。
视频作者给的出路有两条(P39):一是自己用编程式事务手动控制提交回滚;二是走分布式事务的思路,用二阶段提交那套。这两条都指向同一个事实:多线程一致性不在 Spring 声明式事务的能力范围内。
「2 句代码」的解法(P03)——以及它到底没解决什么。 P03 给了一个看起来像银弹的技巧,作者说「只要两句代码就能解决多线程事务失效」。它的本质是把外层事务的连接手动搬进子线程的 ThreadLocal:
@Resource
private DataSource dataSource;
@Transactional
public void methodA() throws InterruptedException {
// 第 1 句:取出外层事务已绑定到本线程的连接持有对象
Object holder = TransactionSynchronizationManager.getResource(dataSource);
Thread t = new Thread(() -> {
// 第 2 句:把它手动绑进子线程自己的 ThreadLocal
TransactionSynchronizationManager.bindResource(dataSource, holder);
methodB(); // 走 B 的代理,读到同一个 connection
});
t.start();
t.join(); // 关键:父线程必须等子线程跑完,再统一提交
}
视频里的演示效果确实对了:B 抛异常后,A 的那条也不提交了,数据库刷新成一致(要么都有要么都无)。它「解决」的是——让子线程复用外层那一条 Connection,于是内外变成同一个事务,提交权统一收回到最外层。
但这里必须把话说透,它没解决的部分恰恰才是重点:
- 它没有做多线程并行事务。 你复用的是同一条 JDBC 连接,而一条
Connection不是线程安全的。要让 A 线程和 B 线程同时往同一条连接上写 SQL,结果是不可预料的。所以你看代码里那句t.join()——父线程必须串行等待子线程跑完再提交。说白了这不是「并发事务」,而是「把一个本该串行的活儿挪到别的线程去跑,再拼回同一个连接」。子线程一多、或者两个线程真并发跑,方案立刻崩。 - 它只管「事务边界」,不管分布式一致性。 库换了、连接换了、跨服务了,这套
bindResource就搬不过去了。视频作者自己也留了一句「解决方案其实还有很多种」,言下之意这两句是入门技巧不是终局。 - 它绕过了 Spring 的正常资源管理。 手动
bindResource若不配对unbindResource,线程复用时(比如线程池)连接会残留、脏读上一个任务的上下文。生产环境里至少要补三件事:子线程用完解绑、保证串行、评估是否根本不该用多线程。
一句话理解:这两句代码不是「让 Spring 支持了多线程事务」,而是「你自己手动把子线程塞进外层那条连接的上下文里」——它把并发问题偷偷变成了串行。真正的多线程 / 跨服务一致性,还得回到「编程式手动编排」或「分布式事务(二阶段提交 / Seata 那一类)」上去解。
最后总结
- ACID 归位:A / I / D 主要靠数据库引擎,Spring 只做「事务边界的编排者」,负责圈范围、发 commit / rollback、透传隔离级别。
- 两种管理类型:编程式(
TransactionTemplate/ 手动 JDBC)与声明式(注解 / XML);实现上分接口 / XML / 注解三条路,底层统一走PlatformTransactionManager家族。 - 七种传播行为:REQUIRED / SUPPORTS / REQUIRES_NEW 是必背的三件套,REQUIRES_NEW 是「独立事务」,NESTED 是「savepoint 部分回滚」,MANDATORY / NEVER 是「条件不满足就抛异常」。
- 隔离级别五档跟数据库走:
DEFAULT跟随数据库(MySQL 默认 RR、Oracle 默认 RC),且隔离级别只对新开事务有效。 - 原理主线:AOP 代理 +
ThreadLocal持连接,事务本质 = 同一线程复用同一条连接;TransactionSynchronizationManager是这套绑定的官方实现。 - 传播的源码:记住
newTransaction决定提不提交、提交只提交ThreadLocal里的连接;新建事务比融入多了「挂起 / 恢复」。 - 失效与多线程:失效原因基本 = AOP 失效原因(非 public、自调用、吞异常、checked 异常、接口注解遇 CGLIB、Bean 没管理、引擎不支持);多线程 Spring 保证不了一致性,「2 句代码」是把并发偷偷掰成串行复用同一连接的技巧,不是银弹。
我个人在面试里最看重的一条判断标准是:候选人能不能把「事务 = 同一线程复用同一连接」这句因果链讲顺。能讲顺的人,传播行为、失效场景、多线程问题基本都不会答错;讲不顺的,背再多七种传播也经不起追问。
事实说明
- 视频里给的事实:ACID 四条与转账 / 存取款例子;管理类型 = 编程式 + 声明式、实现方式 = 接口 / XML / 注解;七种传播行为语义及 REQUIRED / SUPPORTS / REQUIRES_NEW 三件套用途;三种并发读问题与「不可重复读针对行、幻影读针对表」的区分、MySQL 默认 RR / Oracle 默认 RC、SERIALIZABLE「就是表锁」、REPEATABLE_READ「核心是加锁」;声明式事务 = 套 AOP 三步(解析 advisor、创建动态代理、调用增强)、
@EnableTransactionManagement导入配置类注册事务 advisor、连接存ThreadLocal以 DataSource 为 key;传播行为在调用动态代理时处理、TransactionInfo.newTransaction决定是否提交、新建事务的「暂存 / 置空 / 恢复」;失效原因(非 public、类未交 Spring 管理、吞异常、自调用走 this 不过代理、接口注解遇 CGLIB、rollbackFor未配、多线程);P39 结论 = 多线程保证不了一致性、可用编程式或二阶段提交;P03 的getResource+bindResource两句代码演示与效果。 - 官方文档核实过的事实(Spring Framework 7.0.9 javadoc / reference):Propagation 七枚举常量语义与 REQUIRED 为默认、NESTED 仅
DataSourceTransactionManager开箱支持、REQUIRES_NEW/NOT_SUPPORTED挂起并非所有管理器支持;Isolation 五常量语义与 DEFAULT = 跟随数据库;默认回滚只对RuntimeException+Error、checked 不回滚、可用rollbackFor/noRollbackFor覆盖;isolation 仅对新开事务有效;@Transactional不传播到方法内新建的线程;自调用绕过代理不触发事务;TransactionSynchronizationManager按线程绑定资源、key = DataSource、getResource/bindResource/unbindResource;Spring 提供跨 JDBC / JPA / Hibernate / JTA 的一致抽象。 - 笔者判断 / 未核实:
TransactionTemplate、TransactionProxyFactoryBean、exposeProxy/AopContext.currentProxy()的精确类名方法名为视频原话、笔者未逐字对照源码;「MyISAM 不支持事务导致失效」是 MySQL 常识、未对照 MySQL 文档;「InnoDB 在 RR 下靠 gap lock 挡幻影读」是引擎实现细节、未核实,仅作提示;PlatformTransactionManager实现类家族表为笔者依官方文档补充、视频未点名;P03「2 句代码」代码里的t.join()、finally解绑、以及对「Connection 非线程安全 / 实为串行」的分析为笔者补充判断,视频演示未展开。
参考资料 & 致谢
- [1] Spring面经-哔哩哔哩(本系列素材来源,分 P:P03 / P33 / P34 / P35 / P36 / P37 / P38 / P39 / P40)
- [2] Spring Framework 官方文档 · Transactions
- [3] Spring Propagation 枚举 javadoc
- [4] Spring Isolation 枚举 javadoc
- [5] Spring @Transactional javadoc
- [6] Spring TransactionSynchronizationManager javadoc
- [7] Spring 项目主页
- [8] MySQL 官网 ・ Oracle Database 官网 ・ Hibernate 官网 ・ Seata 官网
本文覆盖分 P
| 分 P 编号 | 视频原始标题 | 本文对应小节 |
|---|---|---|
| P33 | 事务四大特性 | 第 1 节 ACID |
| P34 | Spring 支持的事务管理类型和实现方式 | 第 2 节 管理类型与实现方式 |
| P35 | 说一下 Spring 的事务传播行为 | 第 3 节 七种传播行为 |
| P36 | 说一下 spring 的事务隔离 | 第 4 节 隔离级别 |
| P37 | Spring 事务实现基本原理 | 第 5 节 声明式事务原理 |
| P38 | Spring 事务传播行为实现原理 | 第 6 节 传播行为源码实现 |
| P40 | Spring 事务的失效原因? | 第 7 节 失效场景清单 |
| P39 | Spring 多线程事务能否保证事务的一致性 | 第 7 节 Q4 多线程一致性 |
| P03 | 2 句代码即可解决 Spring 多线程事务失效问题 | 第 7 节 「2 句代码」解法 |
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/YangCheney/article/details/167178804




