001.你不知道的事务异常处理
约 2970 字大约 10 分钟
2025-08-23
在日常开发中,Spring事务处理看似简单,实则隐藏着不少容易忽略的细节。 尤其是异常处理,关系到事务是否真正回滚! 本篇笔记带你系统掌握事务异常处理的正确打开方式,避免踩坑!
1. Spring事务依赖异常传播
Spring事务(@Transactional)是通过AOP代理实现的,它的工作机制很简单:
| 场景 | Spring事务行为 |
|---|---|
| 方法正常返回 | 提交事务 |
| 方法抛出异常(满足规则) | 回滚事务 |
✅ 重点: 事务回滚的判断依据是——方法执行时有没有真正抛出异常!
2. 为什么不能直接catch异常?
如果你在事务方法内部catch了异常但没有再抛出去,就会出现这种情况:
- Spring感知不到异常
- 以为一切正常
- ✅ 正常提交事务(哪怕业务逻辑早已出错)
示例:错误示范
@Transactional
public void save(Emp emp) {
try {
empMapper.insert(emp);
int a = 1 / 0; // 这里模拟出问题了,你是不想要继续执行后面的逻辑的!!!
// 其他操作,比如修改逻辑
} catch (Exception e) {
log.error("出异常了", e);
// 什么都不做
}
}⛔ 结果:事务不会回滚,数据库产生脏数据!
3. rollbackFor = Exception.class能解决吗?
有些人以为可以这么写来兜底:
@Transactional(rollbackFor = Exception.class)但是!!! rollbackFor的本质是:告诉Spring,如果遇到"异常冒出来",并且异常类型是Exception,也回滚。
⚠️ 如果catch住异常并吃掉,异常根本没冒出去,rollbackFor压根不会触发。
✅ 小总结:
| 问题 | 是否解决? | 说明 |
|---|---|---|
加 rollbackFor 后 catch 住异常不抛出 | ❌ | 异常感知不到,事务提交 |
不catch,直接抛出异常 | ✅ | 异常冒泡,事务回滚 |
catch后重新抛出 RuntimeException | ✅ | 异常冒泡,事务回滚 |
4. 为什么不直接抛出 Exception,而是推荐抛 RuntimeException?
如果你catch了异常后想重新抛,可以直接抛Exception吗? 理论上可以,但是从工程实践来看,这么做非常不推荐,原因有三:
4.1 污染方法签名
- 抛出受检异常(
Exception)意味着你的方法必须显式声明throws Exception。 - 连锁反应:调用者也要加
throws Exception,一层一层传递,方法签名越来越脏。
4.2 破坏调用方逻辑
- 调用方必须处理异常,不然编译器不放过。
- 明明只是内部服务层异常,结果搞得上层代码到处try-catch,非常恶心。
4.3 Spring默认只回滚RuntimeException
- Spring事务默认遇到RuntimeException才回滚。
- 抛Exception,即使冒泡出来,如果没有指定
rollbackFor,事务也不会回滚! - 写错一个地方,悲剧!
✅ 最佳实践是:
- catch住异常
- log下来
- 抛出自定义RuntimeException
5. 最优雅的事务异常处理范式
5.1 自定义业务异常类
public class ServiceException extends RuntimeException {
public ServiceException(String message) {
super(message);
}
public ServiceException(String message, Throwable cause) {
super(message, cause);
}
}5.2 Service层写法
@Transactional
public void save(Emp emp) {
try {
empMapper.insert(emp);
int a = 1 / 0;
} catch (Exception e) {
log.error("保存员工失败", e);
throw new ServiceException("保存员工出错", e);
}
}✅ catch异常是为了记录日志, ✅ 抛出自定义RuntimeException是为了让事务回滚。
6. 实战演练小案例
模拟几个常见的事务处理场景,展示实际运行时的行为,并通过日志来验证事务回滚或提交的结果。
案例1:异常未抛出,事务提交(错误示范)
场景描述:
- 模拟方法中,发生了一个异常,但被catch住了。
- 事务依然提交,并没有回滚。
代码实现:
@Transactional
public void saveEmployee(Emp emp) {
try {
empMapper.insert(emp); // 正常插入
int a = 1 / 0; // 故意触发异常
} catch (Exception e) {
log.error("发生异常,但没有重新抛出,事务不会回滚", e);
// 异常被catch住了,什么也不做,事务提交
}
}预期结果:
- 日志会输出异常信息,但事务不会回滚。
- 你会看到数据表里可能已经插入了数据(假设
empMapper.insert(emp)成功)。
验证步骤:
- 运行代码。
- 查看数据库,发现
emp表的记录已经被插入。 - 通过日志可以确认异常被捕获,但事务没有回滚。
案例2:抛出 RuntimeException,事务回滚(正确示范)
场景描述:
- 当发生异常时,我们直接抛出一个 RuntimeException。
- Spring会感知到这个异常,并回滚事务。
代码实现:
@Transactional
public void saveEmployee(Emp emp) {
try {
empMapper.insert(emp); // 正常插入
int a = 1 / 0; // 故意触发异常
} catch (Exception e) {
log.error("发生异常,抛出RuntimeException,事务会回滚", e);
throw new RuntimeException("保存员工失败,事务回滚");
}
}预期结果:
- 异常会被捕获并且重新抛出
RuntimeException。 - 事务回滚,
emp表的记录不会被插入。
验证步骤:
- 运行代码。
- 查看数据库,确认
emp表中的记录没有插入。 - 通过日志可以确认异常被捕获并重新抛出
RuntimeException,事务已经回滚。
案例3:指定 rollbackFor = Exception.class,事务回滚(指定回滚策略)
场景描述:
- 使用
@Transactional(rollbackFor = Exception.class),让Spring回滚所有Exception,包括普通的受检异常(Checked Exception)。 - 即使我们抛出了
Exception,事务也能回滚。
代码实现:
@Transactional(rollbackFor = Exception.class)
public void saveEmployee(Emp emp) throws Exception {
try {
empMapper.insert(emp); // 正常插入
int a = 1 / 0; // 故意触发异常
} catch (Exception e) {
log.error("发生异常,抛出Exception,事务会回滚", e);
throw new Exception("保存员工失败,事务回滚");
}
}预期结果:
Exception被抛出,事务会回滚。emp表中的记录不会被插入。
验证步骤:
- 运行代码。
- 查看数据库,确认
emp表中的记录没有插入。 - 通过日志可以确认异常被捕获并重新抛出
Exception,事务已经回滚。
案例4:catch异常后重新抛出自定义RuntimeException,事务回滚(优雅方案)
场景描述:
- 使用
RuntimeException自定义异常来重新抛出异常。 - Spring感知到异常后,事务会回滚。
代码实现:
@Transactional
public void saveEmployee(Emp emp) {
try {
empMapper.insert(emp); // 正常插入
int a = 1 / 0; // 故意触发异常
} catch (Exception e) {
log.error("发生异常,抛出自定义的RuntimeException,事务会回滚", e);
throw new ServiceException("保存员工失败,事务回滚", e); // 自定义异常
}
}ServiceException是自定义的RuntimeException,如下:
public class ServiceException extends RuntimeException {
public ServiceException(String message, Throwable cause) {
super(message, cause);
}
}预期结果:
- 异常被捕获并重新抛出自定义的
RuntimeException。 - 事务回滚,
emp表中的记录不会被插入。
验证步骤:
- 运行代码。
- 查看数据库,确认
emp表中的记录没有插入。 - 通过日志可以确认异常被捕获并重新抛出
ServiceException,事务已经回滚。
总结
这些案例可以帮助你理解不同情况下事务的行为:
- 未抛出异常时,事务提交。
- 抛出
RuntimeException或Exception时,事务回滚(Exception需指定rollbackFor)。 - 捕获异常后重新抛出,事务会回滚。
通过这些实战演练,你可以清晰地看到Spring事务的回滚机制和如何优雅地控制事务回滚。 这样,你就能在实际开发中灵活应用,避免事务处理中的常见问题!
7. 一句话记住
事务是否回滚,不是看你catch了什么异常,是看异常最后有没有"活着冒出去"!
| 场景 | Spring行为 | 推荐做法 |
|---|---|---|
| 抛出 RuntimeException | 事务会回滚 | ✅ 推荐做法:抛出 RuntimeException(或其子类),事务会回滚。这是默认行为。 |
| 抛出 Checked Exception(普通 Exception) | 默认不回滚(除非指定 rollbackFor) | ❌ 不推荐:Checked Exception 默认不会回滚,除非使用 @Transactional(rollbackFor = Exception.class) 明确指定回滚。 |
| catch 异常但不重新抛出 | 事务会提交(错误行为) | ❌ 严禁:如果你在 catch 块中捕获异常并且没有重新抛出,事务会被认为正常结束并提交,导致数据不一致。 |
catch 异常并重新抛出 RuntimeException | 事务会回滚 | ✅ 最优雅做法:捕获异常后,重新抛出一个 RuntimeException(或者自定义的继承自 RuntimeException 的异常),Spring 会感知到这个异常并回滚事务。 |
附:事务异常处理决策流程图
业务代码
↓
发生异常?
↓
是
↓
catch异常了?
↓
是 ➔ 再抛出 RuntimeException?
↓
是 ➔ Spring感知异常,回滚事务
否 ➔ Spring以为正常,提交事务 ❌
否
直接冒泡 ➔ Spring感知异常,回滚事务🧠 到这里,你就掌握了事务异常处理的完整套路! 以后不管是简单CRUD,还是复杂业务链路,都可以写出又优雅又可靠的事务代码啦~🚀
拓展:事务传播行为
能不用传播就不用,事务设计越简单越好
90%业务:默认 REQUIRED
- 正常的增删改操作都是默认的。
- 有事务复用,没有事务新建。
特殊场景:用 REQUIRES_NEW
- 需要保证独立提交,不受外部事务回滚影响。
- 最典型的就是「记录审计日志」。
比如:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(AuditLog log) { ... }✅ 主业务失败也要记录日志。 ✅ 事务挂起,单独提交,互不干扰。
✨ 关于「关键业务操作记录日志」是不是用 REQUIRES_NEW 的解决方案?
答案是:通常是,但也要分场景。
✅ 为什么「关键日志」用 REQUIRES_NEW 是合适的?
因为日志(比如操作日志、审计日志、安全日志等)本身的重要性独立于业务主流程,通常我们希望:
- 即使业务失败回滚(比如插入员工失败了),
- 日志仍然必须保存下来(比如记录是谁操作了,发生了什么异常)。
而事务默认是嵌套的,如果不特别声明,父事务回滚会把子事务(日志保存)也一起回滚。 所以,为了确保日志一定成功,我们就让它自己起个新的事务,跟父事务断开关系:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(OperationLog log) {
operationLogMapper.insert(log);
}这样就算主事务 saveEmployee 出问题,日志事务也能单独提交 ✅。
📌 但不是所有日志都必须 REQUIRES_NEW
要分情况:
| 日志类型 | 事务要求 | 是否用 REQUIRES_NEW |
|---|---|---|
| 审计日志/操作日志 | 无论主业务成功还是失败都要记录 | 用 ✅ |
| 调试日志/普通系统日志 | 仅供排查用,不要求写数据库或强一致性 | 不用 ❌ |
| 关联业务数据的日志 | 如果主业务失败,这条日志也应该回滚 | 不用 ❌(和主事务同生共死) |
总结一句话: 🔹 需要独立保存,不受主业务失败影响的日志 ➔ 用 REQUIRES_NEW。 🔹 跟主业务成败强关联的数据 ➔ 跟随主事务,不需要新事务。
🔥 再用你之前「保存员工」的案例举个对比:
1. 审计日志(需要独立保存,记录谁操作了员工保存)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(Emp emp) {
AuditLog log = new AuditLog();
log.setAction("Create employee");
log.setEmployeeId(emp.getId());
log.setOperator("admin");
log.setTimestamp(LocalDateTime.now());
auditLogMapper.insert(log);
}✔️ 即使 saveEmployee 失败,saveAuditLog 也会单独成功提交。
2. 业务日志(比如记录员工入职信息,入职失败了当然不能留记录)
@Transactional
public void saveBusinessLog(Emp emp) {
BusinessLog log = new BusinessLog();
log.setContent("Employee joined on " + emp.getJoinDate());
log.setEmployeeId(emp.getId());
businessLogMapper.insert(log);
}✔️ 这里跟着主事务走,如果员工保存失败,日志也应该一起回滚。
🛠 所以你问的核心点可以总结为:
- 关键审计日志(尤其是安全、合规相关的)➡️ 推荐使用
REQUIRES_NEW。 - 普通业务相关日志(跟主数据状态强绑定)➡️ 不要单独事务,跟主事务绑定。
这样用才真正合理、优雅、专业!
