Spring(四):声明式事务与 @Transactional
Spring(四):声明式事务与 @Transactional
导语:本篇把「声明式事务」讲到能落地:
@Transactional的底层实现(AOP + 事务管理器 + ThreadLocal 连接绑定)、七种传播行为与最容易混淆的REQUIRES_NEWvsNESTED、回滚规则与隔离级别,然后集中解决生产事故高发区——八种事务失效场景、自调用、@Async冲突与长事务治理,共 14 题。
一、事务模型与实现原理
1. Spring 事务有哪两种管理方式?
答:
| 方式 | 载体 | 特点 |
|---|---|---|
| 编程式事务 | TransactionTemplate、PlatformTransactionManager | 事务边界清晰可控、可精细控制(局部回滚、指定提交点),但有侵入、代码啰嗦 |
| 声明式事务 | @Transactional 注解 / XML <tx:advice> | 基于 AOP,无侵入、最常用;缺点是边界是"方法级"、容易失效(见后文) |
选择:绝大多数业务用声明式;只有在"一个方法内需要多次提交/局部回滚/控制事务边界"时才用编程式——
TransactionTemplate比直接操作PlatformTransactionManager更简洁。
2. @Transactional 底层是如何实现的?
答: 三层协作:AOP 拦截 + 事务管理器 + 连接绑定。
调用方 → 代理对象(TransactionInterceptor)
① TransactionInterceptor.invoke():本质是一个环绕通知(Advice)
② 读取方法/类上的 @Transactional 属性(TransactionAttributeSource)
③ PlatformTransactionManager.getTransaction() 开启事务
· DataSourceTransactionManager → 从 DataSource 取 Connection
· 关闭 autoCommit、设置隔离级别、绑定到当前线程
④ 执行业务方法(proceed())
⑤ 正常返回 → commit();抛异常 → 按 rollbackFor 规则 rollback()
⑥ 清理:恢复 autoCommit、解绑 ThreadLocal、释放连接两个关键机制:
TransactionSynchronizationManager:用ThreadLocal把Connection(ConnectionHolder)与TransactionStatus绑定到当前线程——所以同一线程内多次 DAO 调用复用同一个连接、同一个事务;DataSourceUtils.getConnection()就是从这里取连接。- 代理是前提:
@Transactional由TransactionInterceptor(一个Advisor)生效,必须经过代理对象调用(见失效场景)。
加分点:能说出"事务的本质是把连接绑定到线程",就能自然解释"为什么跨线程(
@Async、线程池、CompletableFuture)事务会丢"——因为新线程的 ThreadLocal 里没有那个连接。
3. Spring 事务的七种传播行为分别是什么?
答:
| 传播行为 | 含义 | 典型用途 |
|---|---|---|
| REQUIRED(默认) | 有事务就加入,没有就新建 | 绝大多数业务 |
| SUPPORTS | 有事务就加入,没有就以非事务方式执行 | 只读查询(不强制事务) |
| MANDATORY | 必须已有事务,否则抛异常 | 强制"必须在事务内调用"的内部方法 |
| REQUIRES_NEW | 挂起当前事务、新建独立事务,两者互不影响 | 日志记录(主事务回滚也要留存日志) |
| NOT_SUPPORTED | 挂起当前事务,以非事务方式执行 | 大批量操作、不希望占事务 |
| NEVER | 必须无事务,当前有事务则抛异常 | 强行禁止事务的接口 |
| NESTED | 在当前事务内开嵌套事务(保存点 Savepoint),内层回滚不影响外层 | 部分回滚场景(需 JDBC 保存点支持) |
易错点:
REQUIRES_NEW与NESTED都"看起来像新建了一个事务",但本质不同(下一题);NESTED只对支持 Savepoint 的数据源/事务管理器有效(DataSourceTransactionManager在 JDBC 3.0+ 支持)。
4. REQUIRES_NEW 和 NESTED 有什么区别?
答: 这是传播行为里最高频的追问:
| 维度 | REQUIRES_NEW | NESTED |
|---|---|---|
| 事务数量 | 两个完全独立的事务(两个连接,外层被挂起) | 同一个物理事务,内层是"保存点" |
| 底层实现 | suspend() 外层事务 + 新建事务 + 恢复外层 | Connection.setSavepoint() |
| 内层提交 | 真正提交(对外可见) | 只是释放保存点,要等外层提交才真正生效 |
| 外层回滚 | 不影响已提交的内层(内层数据保留) | 会带着内层一起回滚 |
| 内层回滚 | 不影响外层 | 只回滚到保存点,外层可继续 |
| 连接占用 | 需要额外连接(高并发下连接池压力大) | 复用同一连接 |
| 锁 | 内层独立持锁/释放锁 | 与外层共享同一事务的锁 |
// A(REQUIRED) 调 B(REQUIRES_NEW):B 提交后 A 抛异常回滚 → A 的数据没了,B 的数据还在
// A(REQUIRED) 调 B(NESTED) :B 回滚到保存点,A 继续;A 回滚则 B 也一起没了答案口诀:"独立提交用 REQUIRES_NEW,部分回滚用 NESTED"。注意
REQUIRES_NEW在"记录操作日志"场景很常用,但要警惕连接池被占满(每个嵌套调用多占一个连接)。
5. Spring 事务的隔离级别有哪些?默认是什么?
答: 与数据库标准一致,另加一个 DEFAULT:
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
READ_UNCOMMITTED | ✅ 可能 | ✅ 可能 | ✅ 可能 |
READ_COMMITTED | ❌ | ✅ 可能 | ✅ 可能 |
REPEATABLE_READ | ❌ | ❌ | ✅ 可能(InnoDB 用间隙锁基本避免) |
SERIALIZABLE | ❌ | ❌ | ❌ |
DEFAULT(Spring 默认):不做设置,跟随底层数据库——MySQL InnoDB 默认 REPEATABLE_READ,Oracle/PostgreSQL 默认 READ_COMMITTED。- 重要认知:Spring 的
isolation只是把隔离级别"传递"给数据库连接,真正生效与实现(MVCC / 锁)由数据库决定;同一个事务内切换隔离级别是无效的(连接创建时已设定)。
关联阅读:《MySQL(三)》讲锁与 MVCC、《MySQL(四)》讲 RC 为什么禁用间隙锁。
6. Spring 事务默认对哪些异常回滚?怎么改?
答: 默认只在抛出 RuntimeException 及其子类、Error 时回滚;受检异常(Checked Exception,如 IOException、自定义 Exception)默认不回滚(提交)。
@Transactional(rollbackFor = Exception.class) // 最常用:所有异常都回滚
@Transactional(noRollbackFor = BizIgnoreException.class) // 指定不回滚两个易错点:
- 异常被
try-catch吞掉 → 拦截器感知不到 → 不回滚(这是"事务失效"里最常见的一种,见第 10 题)。 rollbackFor要写在"发起事务的那个方法"上,内层方法的rollbackFor未必能被外层感知;实践中建议全局统一rollbackFor = Exception.class。
补充:
@Transactional的rollbackFor匹配的是"传播到代理边界的异常类型"——若异常在事务方法内部被包装成其他类型抛出,按包装后的类型判断。
二、失效场景与生产陷阱
7. @Transactional 标注在类上和标注在方法上有什么区别?
答:
- 类上:对该类所有方法(严格说是所有可被代理匹配的 public 方法)生效;
- 方法上:仅对该方法生效,且方法级优先级高于类级(同名属性以方法级为准,未指定的回落到类级);
- 接口 vs 实现类:注解写在接口方法上时,JDK 代理可继承接口注解、CGLIB 类代理读不到接口注解,因此在 Boot(默认 CGLIB)下可能不生效——务必标注在实现类或实现类方法上。
一句话:@Transactional 的注解解析顺序是"实现类方法 > 实现类 > 接口方法 > 接口",最稳妥的写法是标注在实现类的 public 方法上。
8. 只读事务(readOnly = true)有什么作用?能优化什么?
答:
- 语义声明:告诉事务管理器与数据库"本事务只读",可阻止误写(部分数据库/连接池会拒绝写操作);
- 优化空间:某些数据库/ORM 可据此减少不必要的工作(如 Hibernate 会跳过脏检查
flush),MySQL 会把连接设为只读会话; - 注意:
- 只读事务仍然占用连接,不能替代查询优化;
- 它不是安全边界,不能指望它防住所有写入;
- 不要与"读写混用"的方法共用,否则会引入不确定行为。
实践建议:查询方法加
@Transactional(readOnly = true)(配合SUPPORTS传播更省资源),但要评估"读方法内是否可能写"(如更新缓存表、埋点)。
9. @Transactional 在哪些常见场景下会"失效"?(八种)
答: 这是生产事故最高频的一题,建议按"代理链路"分类记忆:
| # | 场景 | 原因 |
|---|---|---|
| 1 | 同类内部自调用 | 走 this 而非代理,拦截器不介入(见下题) |
| 2 | 方法非 public | 事务属性解析对非 public 方法直接返回 null(Spring 明确不生效) |
| 3 | 异常被 catch 吞掉 | 代理感知不到异常 → 不回滚 |
| 4 | 抛受检异常且未配 rollbackFor | 默认只回滚 RuntimeException / Error |
| 5 | 类未被 Spring 管理(自己 new) | 没有代理对象 |
| 6 | final / static 方法(CGLIB 无法重写) | 代理失效 |
| 7 | 数据库/存储引擎不支持事务(MyISAM) | 底层无事务能力 |
| 8 | 多数据源未指定事务管理器 / 使用了不同连接 | 操作不在被管理的事务连接上,事务"管不到" |
补充两种"隐形失效":
@Async与@Transactional同时用:方法被异步代理丢到新线程执行,新线程的 ThreadLocal 里没有事务连接 → 事务不生效(要保证"先事务、后异步",见第 13 题);@Transactional方法内启动新线程 /CompletableFuture:新线程不在事务上下文内。
10. 为什么自调用会让 @Transactional 失效?怎么解决?
答: 事务由代理对象在方法入口开启,自调用走的是目标对象自身的 this,代理逻辑被完全跳过(详见《Spring(三)》第 10 题)。
public void a() { this.b(); } // b() 上的 @Transactional 不生效
@Transactional public void b() { ... }四种解决方式:
- 拆分到另一个 Bean(首选):符合职责划分,无框架耦合;
AopContext.currentProxy():需@EnableAspectJAutoProxy(exposeProxy = true);- 自注入:
@Autowired @Lazy private XxxService self;再self.b(); - 编程式事务:
TransactionTemplate手动控制边界。
架构视角:自调用导致事务失效,本质是"用方法边界充当事务边界"这一设计假设被打破——如果事务边界要跨越多个内部方法,说明应该把它们抽成一个独立的事务单元(新 Bean / 公开方法)。
11. 嵌套事务中,外层异常回滚,内层(REQUIRES_NEW)会回滚吗?
答: 不会。REQUIRES_NEW 会挂起外层事务、新建独立事务并在内层方法返回时立即提交;外层之后抛异常只回滚外层,已提交的内层数据不受影响。
这正是它与 NESTED 最关键的差异(见第 4 题)。实践提醒:
- 用
REQUIRES_NEW记录"操作日志/审计"很常见——正因为"主业务回滚了日志也不能丢"; - 但它需要额外数据库连接,高并发/递归嵌套时容易把连接池打满;
- 若内层失败抛异常向外传播,可能会把外层也"标记为回滚"(
UnexpectedRollbackException)——此时需在调用处catch掉内层异常。
12. 事务与 AOP 通知的执行顺序是什么?
答: 事务是一层环绕通知,它的"位置"由 @Order 决定,顺序会直接影响语义:
@Order(0) 限流/监控 ← 最外层
@Order(10) 日志/追踪
@Order(20) 事务(TransactionInterceptor)
业务方法两个典型影响:
- 日志切面在事务外层:日志会记录"方法调用成功/失败",但事务可能在日志之后才回滚——若日志写在事务内层并记录"提交结果",语义更准;
- 缓存/重试切面在事务外层:
@Retryable在事务外层会导致每次重试都新开事务(可能产生重复提交),需要谨慎设计; @TransactionalEventListener(phase = AFTER_COMMIT):利用TransactionSynchronizationManager把"事件发布"绑定到事务阶段——只有事务提交后才发消息,这是"业务与消息一致"的标准解法(见《高性能(二)》第 5 题)。
13. @Transactional 和 @Async 一起用会有什么问题?
答: 二者都靠代理,且作用在完全不同的维度,组合时极易出错:
| 组合 | 结果 |
|---|---|
@Async 在外层、@Transactional 在内层(被异步调用的方法自己带事务) | ✅ 正确:新线程内重新开事务 |
@Transactional 在外层、内部方法加 @Async 调远程/写日志 | ✅ 正确:事务提交前发起异步(但要注意异步任务可能先于事务提交读到旧数据) |
同一方法上同时标 @Async + @Transactional | ⚠️ 需谨慎:方法被丢到新线程执行,事务在新线程开启(若 @Transactional 也跟着生效);但如果外层以为"当前线程有事务"就是错的 |
| 在事务未提交时异步读同一份数据 | ❌ 读到旧数据/幻读——因为异步线程可能在主事务 commit 之前就查询了 |
实践建议:
- 不要在同一方法上叠加
@Async与@Transactional,把二者拆到不同方法/不同 Bean; - 需要"提交后再异步"时,用
@TransactionalEventListener(AFTER_COMMIT)或TransactionSynchronization#afterCommit; - 跨线程需要事务时,考虑
TransactionTemplate显式控制(不要指望 ThreadLocal 自动传递)。
14. 生产上如何治理"长事务"?
答: 长事务是连接占用、锁等待、主从延迟、回滚日志膨胀的共同根源,架构上要主动治理:
| 手段 | 说明 |
|---|---|
| 事务内禁止远程调用/IO 等待 | RPC、HTTP、MQ 发送、大文件处理都可能耗时数百毫秒~秒级,必须移到事务外(最高优先级) |
| 缩小事务粒度 | 只把"必须原子"的写操作放进事务;查询、组装、转换放事务外 |
| 拆分大事务 | 批量操作分批提交(如每 500 条一提交),避免一次持有大量行锁与 undo |
| 用编程式事务兜底 | 复杂场景用 TransactionTemplate 明确边界,比宽泛的 @Transactional 更可控 |
| 设置事务超时 | @Transactional(timeout = 3),避免异常时无限持锁(注意它只是提示,依赖底层 JDBC/数据库支持) |
| 监控告警 | 采集事务耗时(TransactionSynchronization 埋点 / p6spy / APM),对超阈值告警 |
| 锁等待排查 | 结合 information_schema.innodb_trx、SHOW ENGINE INNODB STATUS 定位持锁行 |
一句话:事务要"短、内、无外部等待"——把非数据库操作赶出事务、把大事务拆小,是从架构上消灭大多数"锁等待/连接池耗尽/主从延迟"事故的方法。
本章小结:
@Transactional的完整认知 = AOP 环绕通知 + ThreadLocal 连接绑定 + 传播行为 + 回滚规则;它的所有"失效"都源于 "没走到代理" 或 "异常没传到代理"。答题时先给这条主线,再逐一展开八种失效场景,条理最清晰。
