Spring5进阶实战:AOP、事务管理与响应式编程深度解析
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.1Transactional的底层执行链路很多人对Transactional的理解停留在“加了这个注解方法里抛异常就会回滚”。但实际项目里事务不回滚的情况至少有七八种。要搞清楚为什么得先知道这个注解底层到底干了什么。Transactional的核心执行链路是这样的Spring容器启动时InfrastructureAdvisorAutoProxyCreator会扫描所有Bean发现带有Transactional注解的类或方法就为这个Bean创建一个代理对象。当你调用这个Bean的方法时实际上调用的是代理对象的方法代理对象会通过TransactionInterceptor来管理事务。TransactionInterceptor的核心逻辑是获取事务属性 - 获取或创建事务 - 执行目标方法 - 根据执行结果决定提交还是回滚。这里的关键点是默认情况下只有抛出RuntimeException和Error才会回滚检查型异常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调用了方法BA和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其实很简单核心就两个类Mono和Flux。Mono表示0或1个元素的异步序列Flux表示0到N个元素的异步序列。你可以把它们理解成“异步版的Optional和List”。// Mono示例返回单个用户 GetMapping(/user/{id}) public MonoUser getUser(PathVariable String id) { return userService.findById(id); } // Flux示例返回用户列表 GetMapping(/users) public FluxUser getUsers() { return userService.findAll(); }看起来跟Spring MVC差不多但底层的执行模型完全不同。Spring MVC的返回值是直接的对象WebFlux的返回值是一个“发布者”框架会订阅这个发布者等数据准备好后再写入响应。这里有个关键点在WebFlux的链路中任何阻塞操作都会毁掉整个响应式模型。如果你在WebFlux的Controller里调用了一个阻塞的JDBC查询那这个线程就被卡住了响应式的优势荡然无存。所以用WebFlux的前提是你的整个技术栈都必须是响应式的数据库要用R2DBCRedis要用ReactiveRedisTemplateHTTP客户端要用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 BService B依赖Service A两个类都有Transactional注解。项目启动时报错说无法解决循环依赖。原因是A需要被代理代理对象的创建需要先创建原始对象而原始对象的创建又需要注入BB的创建又需要注入A的代理对象形成了一个死循环。解决方案是用Lazy注解打破循环。在其中一个依赖上加LazySpring会注入一个延迟代理等到真正使用时才去创建目标对象。6.3 响应式编程中的“伪异步”陷阱用WebFlux的时候最容易犯的错误是“伪异步”代码看起来是响应式的但里面藏着阻塞调用。比如在Mono的map操作里调用了一个阻塞的HTTP接口或者在Flux的flatMap里用了JDBC查询。这种代码的问题在于它不会报错但会阻塞事件循环线程导致整个应用的吞吐量下降。更隐蔽的是在低并发时可能完全看不出问题一旦并发上来性能会断崖式下跌。排查这类问题可以用BlockHound这个工具它能在运行时检测出阻塞调用并抛出异常。我在项目里集成过BlockHound确实帮我找出了好几个隐藏的阻塞点。实操心得如果你在用WebFlux建议在开发环境就开启BlockHound不要等到生产环境出问题了再排查。另外团队里最好有一个统一的规范哪些操作是允许阻塞的哪些是禁止的写清楚并严格执行。7. 从“会用”到“精通”还差什么写到这里Spring5的核心进阶内容基本覆盖了。但我想说的是从“会用”到“精通”差的不是更多的API知识而是对框架设计意图的理解和在复杂场景下的取舍能力。比如AOP你知道怎么写切面但你知道什么时候不该用AOP吗切面太多会导致代码流程不直观排查问题时很难追踪。我见过一个项目一个请求要经过七个切面出问题时根本不知道是哪个切面改了什么。再比如事务你知道怎么配置传播行为但你知道什么时候该用编程式事务吗有些场景下声明式事务的粒度太粗用TransactionTemplate手动控制事务边界反而更清晰。响应式编程也是一样你知道怎么写Mono和Flux但你知道什么时候该用subscribeOn、什么时候该用publishOn吗线程调度用错了性能可能比同步模型还差。这些问题没有标准答案需要在具体项目中不断实践和总结。我个人的经验是每次遇到一个框架相关的问题不要只解决表面现象要往下挖一层搞清楚框架为什么这么设计。挖得多了自然就“精通”了。最后分享一个我自己的习惯我会维护一个“踩坑笔记”每次遇到Spring相关的问题就把问题现象、排查过程、根因分析、解决方案记下来。几年下来这个笔记成了我最有价值的参考资料。比任何官方文档都管用因为这些都是真实项目里验证过的经验。