楼层: 首页/ 软件技术/ Spring 核心框架/ 事务管理:银行转账不能转一半
5

事务管理:银行转账不能转一半

Transaction Management

转账这个动作,本质是两条 SQL:A 扣款、B 加钱。如果 A 扣完钱、B 还没加钱时服务器宕机,钱就凭空消失了。事务的意义就是:这两步要么都成功,要么都回滚。Spring 把事务分成两种玩法——编程式(手写代码控制)和声明式(贴个 @Transactional 注解),后者是日常主力。

论编程式 vs 声明式

编程式事务:用 TransactionTemplate 或 PlatformTransactionManager 手动写 begin/commit/rollback。控制力强,但代码侵入业务。

声明式事务:在方法上贴一个 @Transactional,Spring 用 AOP 自动包裹事务。业务代码干净,这是 99% 场景的选择。

底层:不管哪种写法,最终都通过 PlatformTransactionManager 接口跟数据库(或消息中间件)打交道。换数据库实现,接口不变。

@Transactional 的六个关键参数

参数含义常用值
propagation传播行为:方法被调用时,事务该怎么和已有事务共存。REQUIRED(默认)、REQUIRES_NEW、NESTED 等 7 种。
isolation隔离级别:并发事务之间能看见多少对方的中间状态。DEFAULT、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE。
readOnly是否只读。只读事务可以让数据库优化执行计划。查询方法标 true。
timeout事务超时时间(秒),超时自动回滚。默认 -1(不超时)。
rollbackFor遇到哪些异常时回滚。默认只对 RuntimeException 和 Error 回滚;checked 异常要写 rollbackFor = Exception.class。
noRollbackFor遇到哪些异常不回滚。极少数业务需要。

七种传播行为,背这四个就够

传播行为大白话
REQUIRED(默认)有事务就加入,没事务就新建。绝大多数方法用这个。
REQUIRES_NEW不管有没有事务,都新开一个独立事务,挂起当前事务。比如主业务要记日志,日志失败不影响主业务——日志方法标 REQUIRES_NEW。
NESTED嵌套事务:在当前事务里开一个"保存点",内层失败只回滚到保存点,不影响外层。
SUPPORTS有事务就用,没事务就非事务运行。适合查询方法。

声明式事务:贴个注解就完事

import org.springframework.transaction.annotation.Transactional; @Service public class TransferService { private final AccountDao accountDao; public TransferService(AccountDao accountDao) { this.accountDao = accountDao; } // 整个方法在一个事务里:扣钱和加钱要么都成功,要么都回滚 // rollbackFor = Exception.class:哪怕抛 checked 异常也回滚 @Transactional(rollbackFor = Exception.class) public void transfer(long fromId, long toId, double amount) { accountDao.deduct(fromId, amount); // 扣 A 的钱 if (amount > 10000) { // 抛异常 → 事务回滚,刚才扣的钱会被恢复 throw new IllegalArgumentException("单笔转账不能超过 1 万"); } accountDao.credit(toId, amount); // 加 B 的钱 } }

REQUIRES_NEW 实战:主业务失败不影响审计日志

@Service public class OrderService { private final AuditLogService auditLogService; @Transactional public void createOrder() { // 主业务:创建订单、扣库存... // 即使主业务失败,审计日志也应该保留下来,方便排查 auditLogService.log("订单创建开始"); if (1 == 1) { throw new RuntimeException("库存不足"); // 主业务回滚 } } } @Service public class AuditLogService { // REQUIRES_NEW:不管外层有没有事务,都新开一个独立事务 // 外层回滚不影响这里已经提交的日志 @Transactional(propagation = Propagation.REQUIRES_NEW) public void log(String msg) { // 写审计日志,独立提交 } }

编程式事务:TransactionTemplate 手动控制(需要精细控制时用)

@Service public class ReportService { private final TransactionTemplate txTemplate; public ReportService(TransactionTemplate txTemplate) { this.txTemplate = txTemplate; } public Report generate() { // 只把"写报表"这一小段包进事务,其余查询不在事务里 return txTemplate.execute(status -> { Report r = buildReport(); reportDao.save(r); return r; // 正常返回 → commit;抛异常 → rollback }); } }

事务为什么会失效:六个高频翻车现场

@Transactional 贴了却不回滚,是线上最经典的事故之一。原因几乎都和"代理没生效"有关。对照自查:

失效场景原因 / 解法
1. 方法不是 publicSpring 代理只拦 public 方法,protected/private 上的 @Transactional 直接被忽略。
2. 同类内部调用this.method() 不走代理对象,@Transactional 不生效。要让"被调方法"从另一个 Bean 进来,或注入自己。
3. 异常被自己 catch 了try-catch 吞了异常没抛出去,Spring 不知道出错,不回滚。要么重新抛,要么手动 setRollbackOnly()。
4. 抛的是 checked 异常默认只对 RuntimeException/Error 回滚。抛 IOException 不回滚,要配 rollbackFor=Exception.class。
5. 类没被 Spring 管理自己 new 的对象不走代理,@Transactional 当然无效。必须交给容器注入。
6. 数据库引擎不支持事务MySQL 用 MyISAM(不支持事务)而不是 InnoDB,贴啥注解都没用。

完整可运行案例:银行转账全链路(含隔离级别与回滚规则)

把上面的零件拼成一个完整工程:一个 transfer 方法同时演示 传播行为 REQUIRED、隔离级别、rollbackFor 回滚规则。重点看转账到一半抛异常时,数据库里的钱是怎么被"退回去"的。

转账服务:传播行为 + 隔离级别 + 回滚规则一口气配齐

package com.example.tx; import org.springframework.transaction.annotation.*; @Service public class BankService { private final AccountDao accountDao; public BankService(AccountDao accountDao) { this.accountDao = accountDao; } // propagation:有事务就加入,没有就新建(REQUIRED 是默认值,写出来为了强调) // isolation:读已提交,避免脏读;高并发对账场景才考虑 SERIALIZABLE // rollbackFor:默认只回滚 RuntimeException;这里显式声明 Exception,checked 异常也回滚 @Transactional( propagation = Propagation.REQUIRED, isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class ) public void transfer(long fromId, long toId, double amount) throws Exception { // ① 扣钱。失败(比如余额不足)直接抛异常 accountDao.deduct(fromId, amount); // ② 故意模拟:加钱这一步数据库抖动,抛 checked 异常 if (amount > 500) { throw new java.io.IOException("数据库连接闪断"); // checked 异常 } // ③ 加钱 accountDao.credit(toId, amount); } }

DAO 层(JdbcTemplate 操作 H2 内存库)

@Repository public class AccountDao { private final JdbcTemplate jdbc; public AccountDao(JdbcTemplate jdbc) { this.jdbc = jdbc; } public void deduct(long id, double amt) { jdbc.update("UPDATE account SET balance = balance - ? WHERE id = ?", amt, id); } public void credit(long id, double amt) { jdbc.update("UPDATE account SET balance = balance + ? WHERE id = ?", amt, id); } public double balanceOf(long id) { return jdbc.queryForObject( "SELECT balance FROM account WHERE id=?", Double.class, id); } }
转账前:A=1000,B=0。执行 transfer(1, 2, 800): 第一步 deduct(1,800) → A 临时变成 200 第二步 抛 IOException → 事务回滚! 结果:A 恢复成 1000,B 还是 0(钱没转丢)。 # 对比实验:把 rollbackFor = Exception.class 删掉再跑: 默认只回滚 RuntimeException,IOException 是 checked → 不回滚! 结果:A 被扣成 200,B 还是 0——钱凭空消失了。 # 这就是为什么生产代码一定要写 rollbackFor = Exception.class。
@Transactional 失效的五个经典场景

① 同类内部调用:this.transfer() 不经过代理,注解直接被忽略。把调用方拆到另一个 Bean。

② 方法不是 public:private/protected 方法上贴 @Transactional,不生效。

③ 异常被你自己 catch 了:方法内部 try-catch 把异常吞掉,Spring 根本不知道出错了,当然不会回滚。想回滚就得在 catch 里抛出去,或手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

④ 异常类型不对:默认只对 RuntimeException 回滚。你抛了个 IOException(checked),事务不回滚。记得写 rollbackFor = Exception.class。

⑤ 类没被 Spring 管理:自己 new 出来的对象,事务注解等于空气。

本章面试题 · 第 5 章(事务)

1.(高频题)@Transactional 的 propagation 里,REQUIRED 和 REQUIRES_NEW 有什么区别?分别什么时候用?

查看答案

答案:REQUIRED(默认):有事务就加入,没事务就新建,多个方法共用一个事务。REQUIRES_NEW:不管外层有没有事务,都挂起外层、新开独立事务,内层提交不影响外层、外层回滚也不影响内层。解析:典型场景:主业务写订单,旁边要记一条"审计日志"——日志方法标 REQUIRES_NEW,主业务挂了日志也得留着。

2.(排错题)方法上贴了 @Transactional,里面抛了 IOException,结果事务没回滚。为什么?怎么修?

查看答案

答案:@Transactional 默认只对 RuntimeException 和 Error 回滚。IOException 是 checked 异常,默认不回滚。解析:修法:写 @Transactional(rollbackFor = Exception.class),把回滚范围扩大到所有异常。这是生产代码的标配写法。

3.(理解题)数据库事务的四个隔离级别,分别解决什么并发问题?

查看答案

答案:读未提交(会脏读)→ 读已提交(防脏读,Oracle/PG 默认)→ 可重复读(防脏读+不可重复读,MySQL InnoDB 默认)→ 串行化(全防,但性能最差)。解析:脏读=读到别人没提交的数据;不可重复读=同一事务内两次读同一行结果不一样;幻读=两次范围查询行数对不上。

4.(陷阱题)@Transactional 写在 private 方法上,为什么不生效?

查看答案

答案:Spring 事务靠 AOP 动态代理实现,而 JDK 动态代理基于接口、CGLIB 基于子类覆写 public 方法,private 方法根本无法被代理覆盖。解析:事务注解只能贴在 public 方法上。这是 AOP 体系的通病,@Transactional 和 @Async 都一样。