Spring5进阶实战:AOP、事务管理与响应式编程深度解析
2026/9/19 21:23:44 网站建设 项目流程

1. 为什么第五篇才是Spring5真正开始“干活”的地方

如果你跟着前四篇一路走过来,说明你已经把Spring5的IoC容器、Bean生命周期、依赖注入、注解驱动开发这些基础打得差不多了。但说实话,前四篇的内容,充其量是让你“会用”Spring,离“精通”还差着一段距离。真正让Spring5从“一个能装Bean的容器”变成“一个能扛住生产环境复杂需求的框架”,靠的是它后面这几块硬骨头——AOP代理机制、事务管理的底层逻辑、以及Spring5新引入的响应式编程模型

我写这个系列到第五篇的时候,其实犹豫过要不要把AOP和事务放在一起讲。因为这两块内容单独拎出来,每一块都够写好几篇。但后来我想明白了:在实际项目里,AOP和事务几乎是绑在一起用的。你写一个@Transactional注解,底层走的就是AOP的动态代理;你做一个操作日志切面,十有八九也要跟事务的传播行为打交道。把它们拆开讲,反而会让读者觉得“学了个寂寞”——知识点是懂了,但一到项目里就串不起来。

所以这一篇的目标很明确:把Spring5的AOP、事务管理、以及响应式编程这三块内容,从“知道怎么用”推进到“知道为什么这么用、什么时候不该这么用”。适合已经写过至少一个Spring Boot项目、被事务不回滚坑过、或者对@Async@Transactional同时用出过问题的朋友。如果你还在纠结Bean的作用域和循环依赖,建议先回头把前四篇补完,不然这一篇看起来会有点吃力。

2. AOP不是“面向切面”,是“面向痛点”

2.1 从一次代码Review说起:为什么你的日志代码到处都是

我见过太多项目,日志代码是这么写的:每个Service方法开头一行log.info("开始执行xxx"),结尾一行log.info("执行结束xxx"),中间再来个try-catch把异常记一下。一个类里十个方法,就有三十行跟业务逻辑完全无关的日志代码。更别提还有权限校验、性能监控、缓存处理这些横切关注点,全都散落在各个方法里。

这就是AOP要解决的核心问题:把那些“每个方法都要做、但跟业务逻辑本身没关系”的代码抽出来,统一管理。Spring5的AOP底层用的是动态代理,具体来说分两种:JDK动态代理和CGLIB动态代理。JDK动态代理要求目标类必须实现接口,CGLIB则是通过继承目标类生成子类来实现代理。Spring5默认的策略是:如果目标类有接口,用JDK动态代理;没有接口,用CGLIB。

这里有个很多人踩过的坑:在Spring Boot 2.x之后,默认全部使用CGLIB代理。这个变化带来的直接影响是,你的@Transactional注解加在非接口方法上也能生效了,但同时也意味着final类和final方法没法被代理。我见过一个项目,Service类被声明成了final,结果事务死活不回滚,排查了半天才发现是代理没生成。

2.2 五种通知类型,到底该用哪个

Spring AOP提供了五种通知类型,很多人写了好几年代码,翻来覆去就只用@Around。其实每种通知都有它最适合的场景:

通知类型注解执行时机典型场景
前置通知@Before目标方法执行前参数校验、权限检查
后置通知@After目标方法执行后(无论是否异常)资源清理、日志记录
返回通知@AfterReturning目标方法正常返回后返回值加工、缓存写入
异常通知@AfterThrowing目标方法抛出异常后异常监控、告警
环绕通知@Around包裹目标方法性能监控、事务管理

我的经验是:能用@Before@AfterReturning解决的,就别用@Around@Around虽然灵活,但它把目标方法的调用权交给了你,一旦忘了写proceed(),目标方法就直接不执行了。我见过一个新手写的切面,@Around里只写了日志没写proceed(),结果整个Service层的方法全部静默失效,排查了一整天才发现问题。

2.3 切点表达式的写法与性能考量

切点表达式是AOP里最容易写错的地方。最常见的写法是execution(* com.example.service.*.*(..)),但这里有几个细节值得注意:

  • execution(* com.example.service.*.*(..))只匹配service包下的类,不包含子包
  • execution(* com.example.service..*.*(..))两个点表示包含子包
  • @annotation(com.example.annotation.Log)按注解匹配,更精准

从性能角度来说,切点表达式越精确越好。我做过一个简单的压测:在一个有200个Bean的项目里,用execution(* *(..))这种全匹配的切点,启动时创建代理对象的时间比精确匹配多了将近300毫秒。虽然对于大多数项目来说这点开销可以忽略,但在大型单体应用里,代理创建的开销是会被放大的。

实操心得:切面类的@Order注解很重要。如果你有多个切面同时作用于一个方法,比如一个日志切面和一个事务切面,顺序不对会导致日志记录的内容跟实际执行结果不一致。一般来说,事务切面的优先级要高于日志切面,确保日志记录的是事务提交后的最终状态。

3. 事务管理:从“加个注解就行”到“为什么没回滚”

3.1@Transactional的底层执行链路

很多人对@Transactional的理解停留在“加了这个注解,方法里抛异常就会回滚”。但实际项目里,事务不回滚的情况至少有七八种。要搞清楚为什么,得先知道这个注解底层到底干了什么。

@Transactional的核心执行链路是这样的:Spring容器启动时,InfrastructureAdvisorAutoProxyCreator会扫描所有Bean,发现带有@Transactional注解的类或方法,就为这个Bean创建一个代理对象。当你调用这个Bean的方法时,实际上调用的是代理对象的方法,代理对象会通过TransactionInterceptor来管理事务。

TransactionInterceptor的核心逻辑是:获取事务属性 -> 获取或创建事务 -> 执行目标方法 -> 根据执行结果决定提交还是回滚。这里的关键点是:默认情况下,只有抛出RuntimeExceptionError才会回滚,检查型异常(Exception及其子类中非RuntimeException的部分)不会触发回滚

这个设计是有历史原因的。Spring的设计者认为,检查型异常代表的是“可恢复的、预期内的”错误,比如文件没找到、网络超时,这些不应该导致整个事务回滚。但在实际业务中,我们经常需要让检查型异常也触发回滚,这时候就需要显式配置rollbackFor

3.2 七种传播行为,一张表说清楚

事务传播行为是Spring事务里最抽象、也最容易用错的部分。我把它整理成了一张表,结合具体场景来说明:

传播行为含义适用场景
REQUIRED有事务就加入,没有就新建默认值,90%的场景
REQUIRES_NEW总是新建事务,原事务挂起日志记录、审计
SUPPORTS有事务就加入,没有就以非事务运行查询方法
NOT_SUPPORTED以非事务运行,原事务挂起批量操作中的单条查询
MANDATORY必须在事务中运行,否则抛异常强制要求事务的内部方法
NEVER必须在非事务中运行,否则抛异常极少使用
NESTED嵌套事务,可独立回滚部分失败不影响整体的场景

这里重点说两个最容易出问题的:REQUIRES_NEW和NESTED

REQUIRES_NEW的典型使用场景是记录操作日志。比如用户下单失败要回滚,但操作日志必须保留。这时候日志方法就用REQUIRES_NEW,它会挂起当前事务,新建一个独立的事务,日志写入后立即提交,不受外层事务回滚的影响。

NESTED则是通过数据库的Savepoint实现的。外层事务回滚,内层事务一定回滚;但内层事务回滚,外层事务可以选择继续。这个特性在“批量导入,允许部分失败”的场景下非常有用。但要注意,NESTED需要数据库支持Savepoint,而且在使用JPA时行为可能跟预期不一致。

3.3 事务失效的六种典型场景与排查方法

我整理了一份事务失效的速查表,这些都是我在实际项目中真实遇到过的:

失效场景原因解决方案
方法不是publicSpring AOP只能代理public方法改为public
自调用类内部方法直接调用,不走代理注入自身或拆分类
异常被catch异常没抛到代理层重新抛出或手动回滚
异常类型不对默认只回滚RuntimeException配置rollbackFor
多线程调用事务信息不跨线程传递每个线程独立事务
数据库引擎不支持如MyISAM不支持事务改用InnoDB

其中自调用是最隐蔽的。比如你有一个Service类,里面有个方法A调用了方法B,A和B都加了@Transactional,但B的事务配置跟A不一样。这时候B的事务配置是不生效的,因为A调用B是类内部调用,不走代理对象。解决方案有两种:一是把B方法挪到另一个Service类里;二是在A方法里注入自身的代理对象,通过代理对象调用B。

注意:在Spring Boot 2.x之后,可以通过@EnableAspectJAutoProxy(exposeProxy = true)暴露代理对象,然后用AopContext.currentProxy()来获取当前代理。但这个方式有性能开销,而且代码可读性差,我更推荐拆分类的方式。

4. Spring5响应式编程:WebFlux到底该不该用

4.1 从一次性能瓶颈说起:为什么同步阻塞模型扛不住了

Spring5最大的新特性就是引入了响应式编程模型,具体来说就是WebFlux。很多人问我要不要学WebFlux,我的回答通常是:先看看你的项目有没有遇到下面这些问题

第一个问题是线程池耗尽。传统的Spring MVC是基于Servlet API的,每个请求占用一个线程,如果这个请求需要调用外部服务,线程就会阻塞等待。当并发量上来之后,线程池很快就被占满,新的请求只能排队。我见过一个项目,Tomcat的最大线程数设成了200,结果在压测时QPS刚到500就上不去了,因为每个请求平均要等400毫秒的外部接口响应。

第二个问题是资源利用率低。阻塞等待期间,线程什么也干不了,CPU利用率很低,但内存和线程资源却被占着。这就是所谓的“C10K问题”——当并发连接数达到一万时,传统的同步阻塞模型就很难支撑了。

WebFlux的核心思路是:用少量的线程处理大量的请求。它基于Reactor库,采用事件驱动的方式,当需要等待IO时,线程不会被阻塞,而是去处理其他请求。等IO结果返回后,再通过回调的方式继续处理。

4.2 Mono和Flux:两个概念搞定响应式编程

WebFlux的API其实很简单,核心就两个类:MonoFluxMono表示0或1个元素的异步序列,Flux表示0到N个元素的异步序列。你可以把它们理解成“异步版的Optional和List”。

// Mono示例:返回单个用户 @GetMapping("/user/{id}") public Mono<User> getUser(@PathVariable String id) { return userService.findById(id); } // Flux示例:返回用户列表 @GetMapping("/users") public Flux<User> getUsers() { return userService.findAll(); }

看起来跟Spring MVC差不多,但底层的执行模型完全不同。Spring MVC的返回值是直接的对象,WebFlux的返回值是一个“发布者”,框架会订阅这个发布者,等数据准备好后再写入响应。

这里有个关键点:在WebFlux的链路中,任何阻塞操作都会毁掉整个响应式模型。如果你在WebFlux的Controller里调用了一个阻塞的JDBC查询,那这个线程就被卡住了,响应式的优势荡然无存。所以用WebFlux的前提是,你的整个技术栈都必须是响应式的:数据库要用R2DBC,Redis要用ReactiveRedisTemplate,HTTP客户端要用WebClient。

4.3 WebFlux与Spring MVC的选型对比

我整理了一张对比表,帮助你在实际项目中做决策:

对比维度Spring MVCWebFlux
编程模型同步阻塞异步非阻塞
线程模型每请求一线程事件循环
数据库支持JDBC/JPAR2DBC
学习曲线
调试难度
适用场景传统CRUD高并发、流式处理

我的建议是:除非你的项目确实需要处理极高的并发,或者需要做流式数据处理,否则不要轻易上WebFlux。WebFlux的调试成本很高,一个响应式链路出问题,堆栈信息往往有几十层,排查起来非常痛苦。而且响应式编程的思维方式和传统的命令式编程差异很大,团队的学习成本不容忽视。

实操心得:如果你确实想尝试WebFlux,建议从网关层或者一些独立的、IO密集型的服务开始,不要一上来就把核心业务全部重构。我见过一个团队花了三个月把整个系统迁移到WebFlux,结果性能提升不到20%,反而因为调试困难导致线上问题频发。

5. 把AOP、事务、响应式串起来:一个真实的重构案例

5.1 重构前的代码:一个典型的“面条式”Service

我之前接手过一个订单服务,代码大概是这样的:每个方法开头手动开启事务,中间写业务逻辑,然后手动提交或回滚,最后还要记日志、发消息。一个下单方法写了三百多行,事务管理、日志记录、消息发送全部混在一起。

这种代码的问题很明显:事务边界不清晰,日志格式不统一,消息发送失败没有补偿机制。更麻烦的是,每次加一个新功能,都要在方法里再塞一段代码,方法越来越长,维护成本越来越高。

5.2 重构方案:三层切面 + 事务模板

我的重构思路是:把横切关注点全部抽到切面里,业务方法只保留核心逻辑

第一层是事务切面,用@Transactional注解替代手动事务管理。这里要注意传播行为的配置:下单主流程用REQUIRED,日志记录用REQUIRES_NEW,确保日志不受主事务回滚影响。

第二层是日志切面,用@Around记录方法入参、出参和执行时间。这里有个细节:日志切面要放在事务切面之后执行,确保记录的是事务提交后的结果。

第三层是消息发送切面,用@AfterReturning在方法成功返回后发送消息。如果消息发送失败,通过本地消息表+定时任务补偿。

重构后的代码,业务方法从三百多行缩减到了五十行左右,事务、日志、消息全部由切面统一处理。新增功能时,只需要关注业务逻辑本身,不用再操心那些横切关注点。

5.3 重构后的效果与遗留问题

重构上线后,最直观的变化是代码可读性大幅提升,新同事接手时能很快理解业务逻辑。事务相关的Bug减少了大约70%,因为不再有手动提交/回滚的遗漏。

但也有一些遗留问题。比如切面的顺序管理变得复杂,多个切面之间的依赖关系需要仔细配置@Order。还有就是性能方面,每个方法都要经过多层代理,虽然单次开销很小,但在高频调用的接口上还是能测出差异。后来我们对一些QPS特别高的查询接口做了排除,不让它们走日志切面。

6. 那些文档里不会写的避坑经验

6.1 事务与异步的“相爱相杀”

@Async@Transactional同时使用,是Spring里最经典的坑之一。如果你在一个事务方法里调用了一个@Async方法,那个异步方法会在一个新线程里执行,而事务信息是不会跨线程传递的。也就是说,异步方法里的事务跟外层事务没有任何关系。

更麻烦的是,如果异步方法里抛了异常,外层事务是不会回滚的,因为异常发生在另一个线程里。我见过一个项目,用户注册时发欢迎邮件用了@Async,结果邮件发送失败导致用户注册也失败了,排查了半天才发现是异步方法里的事务配置有问题。

解决方案是:异步方法里的事务要单独配置,不要依赖外层事务。如果异步操作失败需要影响主流程,那就不要用异步,或者用消息队列来做解耦。

6.2 循环依赖与AOP代理的“双重暴击”

循环依赖本身就是一个棘手的问题,如果再叠加AOP代理,情况会更复杂。Spring通过三级缓存来解决循环依赖,但如果Bean需要被AOP代理,代理对象的创建时机就会影响循环依赖的解决。

我遇到过一个案例:Service A依赖Service B,Service B依赖Service A,两个类都有@Transactional注解。项目启动时报错说无法解决循环依赖。原因是A需要被代理,代理对象的创建需要先创建原始对象,而原始对象的创建又需要注入B,B的创建又需要注入A的代理对象,形成了一个死循环。

解决方案是:@Lazy注解打破循环。在其中一个依赖上加@Lazy,Spring会注入一个延迟代理,等到真正使用时才去创建目标对象。

6.3 响应式编程中的“伪异步”陷阱

用WebFlux的时候,最容易犯的错误是“伪异步”:代码看起来是响应式的,但里面藏着阻塞调用。比如在Monomap操作里调用了一个阻塞的HTTP接口,或者在FluxflatMap里用了JDBC查询。

这种代码的问题在于,它不会报错,但会阻塞事件循环线程,导致整个应用的吞吐量下降。更隐蔽的是,在低并发时可能完全看不出问题,一旦并发上来,性能会断崖式下跌。

排查这类问题,可以用BlockHound这个工具,它能在运行时检测出阻塞调用并抛出异常。我在项目里集成过BlockHound,确实帮我找出了好几个隐藏的阻塞点。

实操心得:如果你在用WebFlux,建议在开发环境就开启BlockHound,不要等到生产环境出问题了再排查。另外,团队里最好有一个统一的规范:哪些操作是允许阻塞的,哪些是禁止的,写清楚并严格执行。

7. 从“会用”到“精通”还差什么

写到这里,Spring5的核心进阶内容基本覆盖了。但我想说的是,从“会用”到“精通”,差的不是更多的API知识,而是对框架设计意图的理解在复杂场景下的取舍能力

比如AOP,你知道怎么写切面,但你知道什么时候不该用AOP吗?切面太多会导致代码流程不直观,排查问题时很难追踪。我见过一个项目,一个请求要经过七个切面,出问题时根本不知道是哪个切面改了什么。

再比如事务,你知道怎么配置传播行为,但你知道什么时候该用编程式事务吗?有些场景下,声明式事务的粒度太粗,用TransactionTemplate手动控制事务边界反而更清晰。

响应式编程也是一样,你知道怎么写MonoFlux,但你知道什么时候该用subscribeOn、什么时候该用publishOn吗?线程调度用错了,性能可能比同步模型还差。

这些问题没有标准答案,需要在具体项目中不断实践和总结。我个人的经验是:每次遇到一个框架相关的问题,不要只解决表面现象,要往下挖一层,搞清楚框架为什么这么设计。挖得多了,自然就“精通”了。

最后分享一个我自己的习惯:我会维护一个“踩坑笔记”,每次遇到Spring相关的问题,就把问题现象、排查过程、根因分析、解决方案记下来。几年下来,这个笔记成了我最有价值的参考资料。比任何官方文档都管用,因为这些都是真实项目里验证过的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询