第30章 Web 层与事务异常:参数校验、@Transactional 失效场景全集
30.1 参数校验异常速查
| 异常 | 触发场景 |
|---|---|
MethodArgumentNotValidException |
@RequestBody 配合 @Valid/@Validated 校验 JSON 请求体失败 |
BindException |
@ModelAttribute 表单参数绑定+校验失败(MethodArgumentNotValidException 实际上是 BindException 的子类,用于 @RequestBody 场景) |
ConstraintViolationException |
方法级 @Validated(类上标注)校验 @PathVariable/@RequestParam 时失败,属于 Bean Validation 规范本身抛出的异常,不经过 Spring MVC 的参数绑定流程 |
HttpMessageNotReadableException |
请求体不是合法 JSON,或字段类型与 DTO 定义不匹配导致反序列化失败 |
MissingServletRequestParameterException |
必填的 @RequestParam 参数未传值 |
MethodArgumentTypeMismatchException |
参数类型转换失败(如 @RequestParam Integer id 但传参是非数字字符串) |
30.2 事务失效场景全集(Spring 面试和线上排查的高频重灾区)
Spring 的声明式事务基于 AOP 代理实现,凡是"代理机制无法生效"或"业务代码违反了事务传播/异常处理约定"的场景,都会导致事务静默失效(不报错但事务没生效,比参数校验异常更隐蔽,因为没有任何异常提示,只有在数据不一致发生后才会被发现):
场景1:方法内部自调用
java
@Service
public class OrderService {
public void outer() {
this.inner(); // 内部调用不经过代理对象,事务注解直接失效
}
@Transactional
public void inner() { ... }
}
原因 :@Transactional 的实现依赖 Spring AOP 生成的代理对象拦截方法调用,this.inner() 这种调用方式是直接在原始对象上调用方法,压根没有经过代理,自然不会触发事务拦截逻辑。
解决方案:
java
// 方案1:注入自身的代理对象(需要开启 exposeProxy 或走 ApplicationContext 获取)
@Service
public class OrderService {
@Autowired
@Lazy
private OrderService self; // 注入代理后的自身引用
public void outer() {
self.inner(); // 通过代理调用,事务生效
}
@Transactional
public void inner() { ... }
}
// 方案2(更推荐,架构更清晰):把需要事务的方法拆分到另一个 Bean 里
@Service
public class OrderService {
@Autowired
private OrderTransactionalService txService;
public void outer() {
txService.inner(); // 跨 Bean 调用,天然经过代理
}
}
场景2:方法访问修饰符不是 public
java
@Transactional
private void save() { ... } // Spring AOP(基于 JDK 动态代理或 CGLIB)默认只能拦截 public 方法,此处注解被静默忽略
Spring 的 AOP 代理机制(无论是接口代理还是 CGLIB 子类代理)在处理非 public 方法时都不会应用事务拦截逻辑,且不会有任何警告或报错,这是最容易被忽视的一类事务失效原因。
场景3:异常被自己 catch 掉,没有重新抛出
java
@Transactional
public void save() {
try {
doSomething();
} catch (Exception e) {
log.error("出错了", e); // 异常被吞掉,事务管理器感知不到有异常发生,事务照常提交
}
}
原理 :Spring 事务的回滚判断逻辑,本质是在代理方法的 AOP 环绕通知里 catch (Throwable ex),判断这个异常是否满足回滚条件,如果满足则调用 TransactionManager.rollback()。如果业务代码自己把异常吞掉了,压根不会有异常抛到代理层,代理感知不到任何异常,事务会被正常提交------即使数据库操作实际上因为业务异常导致数据不完整。
场景4:默认只回滚 RuntimeException 和 Error,不回滚受检异常
java
@Transactional // 默认 rollbackFor 只包含 RuntimeException 和 Error
public void save() throws IOException {
doSomething();
throw new IOException("网络异常"); // 受检异常,默认不会触发回滚!
}
原理:这是 Spring 框架早期设计的一个延续至今的默认行为------设计者认为受检异常代表"可预期的业务异常",调用方应该能够处理并决定是否需要回滚,因此默认不将其纳入自动回滚范围。这个默认行为经常和大多数开发者的直觉不符(直觉上"抛异常了就该回滚"),必须显式声明:
java
@Transactional(rollbackFor = Exception.class) // 显式声明所有异常(含受检异常)都触发回滚
public void save() throws IOException { ... }
场景5:事务传播行为配置不当
java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void innerMethod() {
// 独立的新事务,即使外层事务回滚,这里已提交的内容不受影响
}
常见误区 :以为 REQUIRES_NEW 能保证"内层失败不影响外层",但反过来的场景经常被忽略------外层事务如果最终回滚,不会撤销 REQUIRES_NEW 内层事务已经独立提交的内容,这在需要"部分操作必须持久化留痕(如审计日志),即使主流程失败也不能丢"的场景是合理设计,但如果开发者误用在了本该保持强一致性的场景(比如库存扣减和订单创建),会导致明明主流程失败了,某些子操作却已经不可撤销地生效,造成数据不一致。
场景6:多线程环境下事务不会跨线程传播
java
@Transactional
public void process() {
save(); // 在当前线程的事务中
new Thread(() -> {
saveMore(); // 新线程,不在原事务的上下文中,即使原事务回滚,这里的操作也不会被回滚
}).start();
}
原理 :Spring 事务的上下文(TransactionSynchronizationManager 内部用 ThreadLocal 存储当前事务状态)是绑定在当前线程 上的,另起线程执行的代码天然拿不到父线程的事务上下文,等同于运行在一个全新的、没有事务包裹的环境里(如果 saveMore() 内部方法本身也标注了 @Transactional,会开启一个和主流程完全独立的新事务,而不是加入主流程的事务)。
场景7:数据库引擎本身不支持事务
sql
-- MySQL 的 MyISAM 存储引擎不支持事务,即使 Spring 层面配置了 @Transactional,
-- 底层数据库操作也不会有任何回滚能力,这是最容易被忽视、且完全在应用代码层面无法察觉的一类"事务失效"
排查这类问题时,SHOW TABLE STATUS 或 information_schema.TABLES 里查看具体表的 ENGINE 字段,如果不是 InnoDB,无论应用层代码写得多规范都无济于事。
30.3 事务失效的排查方法论
java
// 开启 Spring 事务相关的 debug 日志,能清楚看到每次事务的开启/提交/回滚时机
logging:
level:
org.springframework.transaction: debug
org.springframework.orm.jpa: debug
csharp
Creating new transaction with name [OrderService.save]: ...
Initiating transaction commit
Committing JPA transaction on EntityManager ...
如果日志里显示事务"Creating new transaction",说明 AOP 代理和事务管理器本身是生效的,问题更可能出在回滚条件 (异常类型是否满足 rollbackFor)上;如果压根看不到这行日志,说明代理层面就没有拦截到这次调用,应该按场景1/2 的方向排查(自调用/非 public 方法)。