@Transactional 的底层实现原理是什么?事务隔离级别有哪些?
简化版
@Transactional 的底层是 AOP 动态代理 + 数据库事务:Spring 给带 @Transactional 的类生成代理,方法调用时代理在前面开启事务(拿一个数据库连接、关闭自动提交)、方法正常返回就 commit、抛异常就 rollback。事务用到的数据库连接通过 ThreadLocal 绑定到当前线程,保证同一线程内的多次数据库操作用的是同一个连接、同一个事务。事务隔离级别有四种(数据库标准):读未提交、读已提交、可重复读、串行化,隔离性从低到高,解决的并发问题(脏读、不可重复读、幻读)依次增多,但性能依次降低。@Transactional 的 isolation 属性可指定,默认用数据库的默认级别。
详细版
实现原理三要素:
① AOP 代理:Spring 为 @Transactional 的类生成代理(JDK 或 CGLIB)
② 事务拦截器:代理调用时,由 TransactionInterceptor 织入事务逻辑
③ ThreadLocal 绑定连接:TransactionSynchronizationManager 用 ThreadLocal
把数据库连接绑到当前线程,保证同一事务内所有操作用同一个连接
一次 @Transactional 方法调用的流程:
@Transactional
public void transfer() {
accountDao.debit(); // 扣款
accountDao.credit(); // 加款
}
// 代理拦截后的实际执行:
// 1. 获取数据库连接,setAutoCommit(false),绑定到 ThreadLocal
// 2. 执行方法体(debit、credit 都从 ThreadLocal 拿同一个连接,同一事务)
// 3. 方法正常返回 → connection.commit()
// 方法抛 RuntimeException/Error → connection.rollback()
// 4. 释放连接,清理 ThreadLocal
四种隔离级别与并发问题:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ_UNCOMMITTED(读未提交) | ✗ 可能 | ✗ 可能 | ✗ 可能 | 能读到别人未提交的数据,几乎不用 |
| READ_COMMITTED(读已提交) | ✓ 避免 | ✗ 可能 | ✗ 可能 | 只能读已提交数据,Oracle/PG 默认 |
| REPEATABLE_READ(可重复读) | ✓ 避免 | ✓ 避免 | ✗ 可能* | 同一事务内多次读一致,MySQL 默认 |
| SERIALIZABLE(串行化) | ✓ 避免 | ✓ 避免 | ✓ 避免 | 完全串行,性能最差 |
- 脏读:读到别的事务未提交的数据(对方回滚后你读的就是脏数据)。
- 不可重复读:同一事务内两次读同一行,值不同(别人更新并提交了)。
- 幻读:同一事务内两次查询,行数不同(别人插入/删除并提交了)。
⚠️ MySQL 的 InnoDB 在 REPEATABLE_READ(默认级别)下,通过 MVCC + 间隙锁(Next-Key Lock) 很大程度上避免了幻读,这和 SQL 标准「RR 不能避免幻读」不同——所以「MySQL 默认级别会不会幻读」要看具体场景(快照读靠 MVCC 避免,当前读靠间隙锁避免)。
完整版教学
一、@Transactional 的本质:AOP 织入事务逻辑
@Transactional 看起来很神奇——加个注解方法就有事务了。但它没有魔法,本质是 AOP:Spring 给带 @Transactional 的类生成一个代理对象,把「开启事务、提交、回滚」这些逻辑,作为一个环绕通知(Around Advice) 织入到方法调用的前后。
你写的:
@Transactional
void transfer() { debit(); credit(); }
代理帮你做的(伪代码):
void transfer() {
开启事务(); // 前置:拿连接、关自动提交
try {
原始 transfer(); // 你的方法体
提交事务(); // 正常返回 → commit
} catch (RuntimeException e) {
回滚事务(); // 抛异常 → rollback
throw e;
}
}
理解「@Transactional = AOP 代理 + try-catch 包裹事务操作」,就理解了它的一切行为——包括为什么自调用会失效(自调用不走代理,就没有这层包裹)、为什么默认只对 RuntimeException 回滚(catch 的是 RuntimeException)。这些「失效场景」的根都在「它是 AOP 代理」这一点上。
二、ThreadLocal 绑定连接:保证同一事务用同一连接
一个事务里有多次数据库操作(debit、credit),它们必须用同一个数据库连接才能在同一个事务里——如果各用各的连接,就是各自独立的事务,一个提交一个回滚就乱了。Spring 怎么保证「同一线程内的多次操作用同一连接」?靠 ThreadLocal:
TransactionSynchronizationManager 内部有个 ThreadLocal<Map<DataSource, Connection>>
开启事务时:
从连接池拿一个连接 conn → 存进当前线程的 ThreadLocal
方法体里 debit()、credit():
它们通过 Spring 的 DataSourceUtils.getConnection() 拿连接
→ 先查 ThreadLocal,发现当前线程已有绑定的 conn → 用这个 conn(同一事务)
提交/回滚后:
从 ThreadLocal 移除连接,归还连接池
关键:ThreadLocal 让「当前事务的连接」在整个方法调用链里可见且唯一,不用把连接一层层传参。这也解释了为什么 @Transactional 是线程绑定的——事务信息存在 ThreadLocal 里,跨线程(如方法内 new Thread() 或 @Async)就拿不到这个连接,事务不生效。这是「事务在异步/多线程下失效」的原理。
三、四种隔离级别与三种并发问题
数据库事务的隔离级别,本质是「在并发下,一个事务能看到其他事务的哪些改动」的松紧程度。它要权衡三个并发问题:
脏读(Dirty Read):读到别人"未提交"的数据
事务A改了余额但没提交 → 事务B读到了新余额 → A 回滚 → B 读的是根本不存在的脏数据
不可重复读(Non-repeatable Read):同一行两次读值不同
事务B第一次读余额=100 → 事务A更新余额=200并提交 → B 再读=200 → 同一事务内值变了
幻读(Phantom Read):同一查询两次行数不同
事务B查"余额>50的账户"得3行 → 事务A插入一个新账户并提交 → B 再查得4行 → 多了"幻影"行
隔离级别从低到高,逐个把这些问题堵上:
READ_UNCOMMITTED:什么都不防(能读未提交)→ 脏读、不可重复读、幻读都可能
READ_COMMITTED: 只读已提交 → 防脏读;但不可重复读、幻读仍可能
REPEATABLE_READ: 同一事务内读一致 → 防脏读、不可重复读;幻读仍可能(SQL标准)
SERIALIZABLE: 完全串行执行 → 全防,但性能最差
规律:隔离级别越高,防的并发问题越多,但并发性能越差(要加更多锁或用更严格的机制)。所以选隔离级别是「一致性 vs 性能」的权衡——不是越高越好。
四、MySQL 的特殊性:RR 下的幻读
一个高频深挖点:SQL 标准说「REPEATABLE_READ 不能避免幻读」,但 MySQL InnoDB 在默认的 RR 级别下,很大程度上避免了幻读,这是怎么回事?
MySQL InnoDB 在 RR 下用两套机制:
① 快照读(普通 SELECT):靠 MVCC(多版本并发控制)
读的是事务开始时的一致性快照,别人插入的新行在快照里看不到 → 天然无幻读
② 当前读(SELECT...FOR UPDATE、UPDATE、DELETE):靠 间隙锁(Gap Lock / Next-Key Lock)
锁住的不只是行,还有行之间的"间隙",别人无法在间隙插入 → 防幻读
所以「MySQL 默认级别会不会幻读」的准确回答是:普通快照读靠 MVCC 避免、当前读靠间隙锁避免,绝大多数场景下 MySQL 的 RR 已经解决了幻读,比 SQL 标准更严格。这也是为什么很多人以为「RR 能防幻读」——因为他们用的 MySQL 确实防住了。理解 MVCC 和间隙锁两条线,才能答准这个问题。
五、@Transactional 的关键属性
@Transactional 除了 isolation(隔离级别),还有几个常考属性:
| 属性 | 作用 |
|---|---|
propagation | 传播行为(REQUIRED/REQUIRES_NEW/NESTED…),控制事务如何嵌套 |
isolation | 隔离级别,默认 DEFAULT(用数据库默认级别) |
rollbackFor | 指定哪些异常回滚,默认只回滚 RuntimeException 和 Error |
readOnly | 只读事务,可让数据库/连接池做优化 |
timeout | 超时时间,超时自动回滚 |
最容易踩的是 rollbackFor:默认只对 RuntimeException 和 Error 回滚,检查异常(如 IOException、自定义的 checked Exception)不会触发回滚!所以如果方法抛的是受检异常,事务不会回滚,数据已改的部分会提交。解决:@Transactional(rollbackFor = Exception.class) 让所有异常都回滚。这是事务「没回滚」的高频原因。
六、隔离级别与传播行为的配合选型
实战中如何选隔离级别?绝大多数场景不动 isolation,用数据库默认:
MySQL 默认 REPEATABLE_READ:已能防脏读、不可重复读、幻读,够用
Oracle/PostgreSQL 默认 READ_COMMITTED:防脏读,允许不可重复读
什么时候要调:
- 有强一致要求、且不可重复读会出问题 → 调高到 REPEATABLE_READ
- 极端一致性(如对账、余额) → SERIALIZABLE,但要接受性能代价
- 报表统计、能容忍轻微不一致 → 可用 READ_COMMITTED 提高并发
原则:默认级别能满足就别动,隔离级别调高是有并发代价的(更多锁、更低吞吐)。真正常调的是传播行为(propagation)而非隔离级别——比如日志记录用 REQUIRES_NEW 保证独立提交。隔离级别是「防并发读问题」,传播行为是「控制事务嵌套」,两者解决不同问题,别混淆。
记忆钩子:「@Transactional = AOP 代理(自调用失效根源)+ ThreadLocal 绑连接(异步失效根源)+ 正常 commit 异常 rollback(默认只回滚 RuntimeException);四隔离级别:读未提交/读已提交/可重复读/串行化,防的脏读→不可重复读→幻读依次增多、性能依次降;MySQL RR 靠 MVCC+间隙锁额外防了幻读」。
七、常见误区与追问
- 误区:@Transactional 是数据库层面的魔法。 它是 Spring 的 AOP 代理 + try-catch 包裹的事务操作 + ThreadLocal 绑定连接;理解这点就能解释所有失效场景。
- 误区:@Transactional 默认所有异常都回滚。 默认只回滚 RuntimeException 和 Error,检查异常(如 IOException)不回滚;要用
rollbackFor = Exception.class才全回滚。 - 误区:隔离级别越高越好。 越高防的并发问题越多,但锁更多、并发性能越差;应按业务权衡,默认级别够用就别调高。
- 误区:MySQL 的 RR 级别不能防幻读。 MySQL InnoDB 在 RR 下用 MVCC(快照读)+ 间隙锁(当前读)额外避免了幻读,比 SQL 标准更严格。
- 追问:@Transactional 为什么自调用会失效? 事务靠代理织入,自调用(this.method())不经过代理,直接调原始方法,没有事务包裹,所以失效;解决是注入自己的代理或拆到别的 Bean。
- 追问:@Transactional 为什么在 @Async 或 new Thread 里失效? 事务连接绑在 ThreadLocal 上,新线程拿不到原线程的连接,用的是新连接(新事务或无事务),所以原事务对它不生效。
- 追问:脏读、不可重复读、幻读有什么区别? 脏读=读到未提交数据;不可重复读=同一行两次读值不同(别人 update);幻读=同一查询两次行数不同(别人 insert/delete)。
八、加强记忆
@Transactional 没有魔法,本质是「AOP 代理 + 数据库事务 + ThreadLocal」:Spring 给带注解的类生成代理,方法调用时代理在前面开事务(拿连接、关自动提交)、正常返回 commit、抛异常 rollback;事务的数据库连接通过 ThreadLocal 绑定到当前线程,保证同一事务内多次操作用同一连接。理解这三点就能解释所有失效场景——自调用失效(不走代理)、异步/多线程失效(新线程拿不到 ThreadLocal 的连接)、默认只回滚 RuntimeException(catch 的是它,检查异常要配 rollbackFor=Exception.class)。四种隔离级别(读未提交→读已提交→可重复读→串行化)隔离性递增,防的并发问题(脏读→不可重复读→幻读)依次增多、但性能依次降低:脏读=读未提交数据、不可重复读=同行两次读值不同、幻读=同查询两次行数不同。特别地,MySQL InnoDB 的 RR 默认级别靠 MVCC(快照读)+ 间隙锁(当前读)额外避免了幻读,比 SQL 标准更严。选型上默认级别够用就别调高(隔离越高性能越差)。一句话「AOP+ThreadLocal 实现事务、默认只回滚运行时异常、四级隔离防脏读不可重复读幻读、MySQL RR 额外防幻读」。