SSM整合:父子容器与事务AOP失效排查

发布时间:2026/10/31 9:08:22
SSM整合:父子容器与事务AOP失效排查

SSM 这三个字母,第一次让我犯迷糊是在接手一个老后台系统的时候。需求文档上写着“基于 SSM 与 Spring 的单体应用”,我当时的第一反应是:SSM 里的第一个 S 不就是 Spring 吗,这两者放在一起,是不是把同一个东西写了两遍?后来真正把项目跑通、把配置文件翻了个底朝天,才明白这句话其实是在问一个更实际的问题——Spring 作为底座,是怎么跟 SpringMVC 和 MyBatis 这两个零件装到一起的,装的过程中又有哪些地方注定会出问题。这篇文章就把这套装配逻辑从头拆一遍:SSM 到底是什么、Spring 在里面负责哪几件事、父子容器为什么是最大的问题来源、事务和 AOP 为什么老是“配了但没生效”。适合正在维护老项目的人、准备从 Spring Boot 回头补原理的人,以及需要在一个资源有限的内部系统里快速搭出可用骨架的人。

1. 先把名字捋顺:SSM 里的 S 就是 Spring

1.1 三层职责拆开看,名字就不绕了

SSM 是三个框架首字母的拼接,S 是 Spring,第二个 S 是 SpringMVC,M 是 MyBatis。之所以会出现“SSM 与 Spring”这种看起来重复的说法,是因为这两个词的指向范围不一样:SSM 指的是一整套技术组合和装配方式,Spring 指的是其中那个负责“把所有东西串起来”的容器。打个比方,SSM 像一辆整车的型号,Spring 是底盘和线束,SpringMVC 是方向盘和仪表盘,MyBatis 是发动机到轮子的传动。你单独说“底盘”,和说“这辆车”,说的不是一个层面的东西,但确实是同一台车上的部件。

真正把这三者粘在一起的,不是某种官方标准,而是 Spring 的 IoC 容器和它提供的一批整合类。SpringMVC 本身是 Spring 的一个 Web 模块,天然跑在 Spring 的容器体系里;MyBatis 则是一个独立的持久层框架,它必须通过mybatis-spring这个中间层,才能把自己的SqlSessionFactory、Mapper接口交给 Spring 托管。所以“SSM 整合”这个动作,百分之八十的工作量其实是两件事:把 MyBatis 塞进 Spring 容器,把 SpringMVC 的子容器和 Spring 的父容器对齐。剩下的注解、配置、事务,都是这两件事的延伸。

我见过不少人对 SSM 的理解停留在“三个框架各写各的配置”,结果项目跑起来之后,Controller 里注入 Service 是 null,Service 里注入 Mapper 又正常。问题就出在他们把三个框架当成了三个平行的东西,而没有意识到其中两个容器有明确的父子关系。

1.2 Spring 在 SSM 里真正干的四件事

把 Spring 在 SSM 中的职责列清楚,后面所有问题都能对得上号。它主要做四件事,每一件都对应着一类典型的故障。

第一件是 Bean 的生命周期管理。所有标注了@Service、@Repository、@Component的类,加上配置文件里<bean>声明的对象,都由容器负责创建、注入、初始化、销毁。这一步的坑在于:扫描范围没配对,Bean 根本没被扫到,运行时才报NoSuchBeanDefinitionException。

第二件是事务管理。Spring 的声明式事务是一层 AOP 代理,它接管了DataSource的Connection,在方法前后做setAutoCommit(false)和commit/rollback。这一层的坑最多,因为代理生效需要满足一堆前提条件,任何一个不满足,注解就变成了装饰品。

第三件是 AOP 增强。日志、权限校验、接口耗时统计、缓存,这些横切逻辑都挂在这一层。坑在于代理方式和切面顺序,顺序错了会导致事务读到未提交的数据,或者日志记录的是被包装过的返回值。

第四件是资源整合。DataSource、SqlSessionFactory、MapperScannerConfigurer、TransactionManager,这些对象之间的依赖关系全靠 Spring 来编排。这一层的坑在于初始化的先后顺序,某些 Bean 会在占位符还没解析完的时候就被创建。

提示:判断一个问题该往哪个方向排查,先问自己“这是容器的问题、代理的问题,还是资源装配的问题”。三者混在一起查,效率会低很多。

1.3 什么场景下还值得用 SSM,什么场景下别硬上

先说结论:新项目基本不需要从零搭 SSM,但维护存量项目、做教学演示、写需要极简依赖的内部工具,SSM 依然有它的位置。

存量项目是最现实的理由。大量运行了七八年的后台系统,用的是 Spring 4.x 或 5.x 加 XML 配置,JDK 还是 8。这种项目不可能为了“技术先进”整体重写,你要做的是在现有骨架上加功能、修 Bug、做性能优化。这时候对 SSM 装配逻辑的理解程度,直接决定了你改一个接口要花两小时还是两天。

教学和原理学习是第二个理由。Spring Boot 把大量配置藏进了自动装配,新手用起来很爽,但一旦出问题就无从下手。从 SSM 手动装配一遍,你会清楚地知道DataSource是怎么来的、事务管理器是怎么挂上去的、DispatcherServlet和ContextLoaderListener各自初始化了什么。这些东西在 Boot 里依然存在,只是被默认值覆盖了。

内部小工具是第三个理由。一个只有几个页面的数据录入系统,扔一个 Spring Boot 包进去,启动要几秒,内存占几百兆,有时候反而累赘。用 SSM 打一个 war 丢进现成的容器里,跟其他系统共用端口和线程池,运维上更省事。

但如果你要做的是对外服务、需要快速迭代、团队里新人多,那就别绕这个弯。SSM 的配置成本在多人协作下会被放大,每加一个数据源、每换一个中间件,都要动 XML,合并冲突能让人崩溃。

2. 父子容器:SSM 启动期最容易出问题的地方

2.1 两个容器是怎么起来的

传统 SSM 项目打成 war 包丢进 Servlet 容器,启动时会创建两个 Spring 容器,这一点很多人配置写了一年都没意识到。

第一个容器由ContextLoaderListener创建,读取contextConfigLocation参数指定的配置文件,通常叫applicationContext.xml。它是父容器,里面装的是 Service、DAO、数据源、事务管理器、SqlSessionFactory这些“业务和基础设施”对象。

第二个容器由DispatcherServlet创建,读取它自己的contextConfigLocation参数,通常叫spring-mvc.xml。它是子容器,里面装的是 Controller、视图解析器、消息转换器、拦截器这些“Web 层”对象。

<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>

这段配置里有两个contextConfigLocation,一个在<context-param>里,一个在<servlet>的<init-param>里。它们的作用域完全不同:前者是 ServletContext 级别的,后者是 Servlet 级别的。有人图省事把两份配置写成同一个路径,结果所有 Bean 被创建了两遍,@Transactional的代理套了两层,问题排查起来极其痛苦。

2.2 可见性规则决定了谁能注入谁

父子容器之间是单向可见的:子容器能拿到父容器的 Bean,父容器看不到子容器的 Bean。

这条规则解释了一大堆看起来莫名其妙的现象。Controller 在子容器里,Service 在父容器里,Controller 注入 Service 没问题,因为子容器可以往上找。反过来,如果某个ApplicationListener或者定时任务跑在父容器里,想注入一个 Controller,必然拿到 null。

更隐蔽的一种情况:有人把@Service也写在了spring-mvc.xml的扫描范围里。这时候 Service 会在子容器里被创建一份,父容器里也有一份,Controller 注入的是子容器那份,事务代理挂在子容器那份上。如果父容器里还有别的地方引用 Service,用的是另一份实例,两者的@Transactional行为、内部状态、缓存全都不一致。这类问题不会报错,只会表现为“有时候事务不生效”“有时候缓存对不上”。

还有一个典型现象:@Transactional在 Service 上不起作用,但把注解放到 Controller 上就好了。原因就是 Service 被扫进了子容器,而tx:annotation-driven和transactionManager配置在父容器里,子容器里的 Service 找不到事务管理器,代理根本没生成。

正确的做法是严格分工:父容器配置只扫描 Service 和 Repository 层,子容器配置只扫描 Controller 层。用context:component-scan的use-default-filters="false"配合include-filter,或者干脆把包路径按层拆开写。

<!-- applicationContext.xml --> <context:component-scan base-package="com.example.demo"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> <context:exclude-filter type="annotation" expression="org.springframework.web.bind.annotation.ControllerAdvice"/> </context:component-scan>
<!-- spring-mvc.xml --> <context:component-scan base-package="com.example.demo" use-default-filters="false"> <context:include-filter type="annotation" expression="org.springframework.stereotype.Controller"/> <context:include-filter type="annotation" expression="org.springframework.web.bind.annotation.ControllerAdvice"/> </context:component-scan>

排除ControllerAdvice这一步很多人会漏。全局异常处理器如果被父容器扫到,它引用的那些 Web 层对象在父容器里不存在,启动直接失败,报错信息还指向一个跟异常处理八竿子打不着的类。

2.3 三类典型故障和定位手法

第一类是启动报NoSuchBeanDefinitionException,指向某个 Service 或者 Mapper。先确认这个类所在的包在不在扫描路径里,再确认它的注解是@Service还是@Component,最后确认它有没有被exclude-filter误伤。顺序别乱,从最外层往里查。

第二类是启动没报错,但访问接口返回 500,日志里是NullPointerException,位置在 Controller 里对 Service 的调用上。这就是典型的父子容器问题。定位方法很简单:在 Controller 的构造方法里打印一下this.getClass().getClassLoader()和注入对象的类加载器,或者直接applicationContext.getParent()看一下父容器里有没有这个 Bean。更快的方式是在 Service 的构造函数里打一行日志,看它被创建了几次——如果打了两次,基本可以确定被两个容器都扫了。

第三类是运行期事务失效,但代码看起来完全正常。这时候要检查的是transactionManager到底被哪个容器持有,以及 Service 实例是属于哪个容器的。这个排查过程比较绕,我的习惯是在启动日志里搜Root WebApplicationContext和WebApplicationContext for namespace,看两个容器的初始化顺序和加载的配置文件路径对不对。

注意:不要用“把所有包都扫进父容器”的方式绕开这个问题。那样虽然表面上不报错了,但 Controller 会被创建在父容器里,拦截器、参数解析器、消息转换器全部失效,问题只是换了个形式出现。

3. 从零搭一套能跑起来的 SSM 骨架

3.1 版本组合比单个版本更重要

SSM 的依赖兼容性是个雷区,版本选错了会出现各种NoSuchMethodError、ClassNotFoundException。我比较推荐的稳定组合是:Spring 5.3.x、MyBatis 3.5.x、mybatis-spring 2.0.x、JDK 8 或 11、Servlet 3.1 以上。这套组合经过了大量项目验证,社区资料也最全。

如果非要上 Spring 6,得注意两件事:一是 JDK 最低 17,二是javax.*全部换成了jakarta.*,所有HttpServletRequest、@Resource的导入包都要改,web.xml的 schema 版本也要跟着升。这不是改个版本号能解决的,老项目升级基本等于重写 Web 层。

<properties> <spring.version>5.3.39</spring.version> <mybatis.version>3.5.16</mybatis.version> <mybatis.spring.version>2.1.2</mybatis.spring.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-aspects</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>${mybatis.spring.version}</version> </dependency> </dependencies>

spring-aspects容易被漏掉,没有它,@Aspect注解的切面类在启动时会报找不到AspectJ相关的类。spring-jdbc也不能省,DataSourceTransactionManager在这个包里面。

3.2 父容器配置:数据源、会话工厂、Mapper 扫描

父容器的配置是整个项目的基础,顺序上要先数据源,再SqlSessionFactory,最后 Mapper 扫描。

<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="jdbcUrl" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="maximumPoolSize" value="20"/> <property name="minimumIdle" value="5"/> <property name="connectionTimeout" value="3000"/> <property name="idleTimeout" value="600000"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <property name="mapperLocations" value="classpath*:mapper/**/*.xml"/> <property name="typeAliasesPackage" value="com.example.demo.entity"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.demo.mapper"/> <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/> </bean>

这里有个非常经典的坑:MapperScannerConfigurer的属性必须用sqlSessionFactoryBeanName,不能写成sqlSessionFactory。原因是它是BeanDefinitionRegistryPostProcessor,执行时机比PropertySourcesPlaceholderConfigurer还早,如果这时候用ref去引用sqlSessionFactory,会强制提前初始化这个 Bean,而sqlSessionFactory依赖的${jdbc.url}还没被解析,启动直接抛Could not resolve placeholder。

用sqlSessionFactoryBeanName写字符串就避开了这个问题,因为它只是记下名字,等到真正需要实例的时候才去容器里取,那时占位符早就解析完了。同理,basePackage里也不要写${mapper.package}这种占位符,一样会解析失败。真要动态化,得换成@MapperScan注解,或者用PropertyPlaceholderConfigurer的ignoreUnresolvablePlaceholders配合特殊处理,不推荐。

mapperLocations里的classpath*:前缀也有讲究。classpath:只会在第一个匹配到的 classpath 路径下找,如果 Mapper XML 分散在多个 jar 里,后面的就找不到了。classpath*:会遍历所有 classpath 路径,多模块项目必须用它。

3.3 子容器配置:注解驱动、静态资源、消息转换

<context:component-scan base-package="com.example.demo" use-default-filters="false"> <context:include-filter type="annotation" expression="org.springframework.stereotype.Controller"/> <context:include-filter type="annotation" expression="org.springframework.web.bind.annotation.ControllerAdvice"/> </context:component-scan> <mvc:annotation-driven/> <mvc:resources mapping="/static/**" location="/static/"/> <mvc:resources mapping="/upload/**" location="file:/data/upload/"/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>

<mvc:annotation-driven/>这一行做了很多事:注册RequestMappingHandlerMapping、RequestMappingHandlerAdapter、各种HttpMessageConverter、数据校验器、类型转换器。没有它,@ResponseBody不会生效,日期格式转换也会出问题。

静态资源那两个<mvc:resources>是用来把请求从DispatcherServlet手里甩出去的。默认配置下DispatcherServlet映射的是/,会拦下所有请求,包括 CSS、JS、图片,导致页面样式全丢、404 一片。手工在 Controller 里写资源映射也能解决,但没必要,用标准配置更省事。

消息转换器这块,如果项目里需要返回 JSON,得确保 classpath 下有 Jackson 的实现包,<mvc:annotation-driven/>会自动检测并注册MappingJackson2HttpMessageConverter。如果前后端约定用 Fastjson,就要手动配置,注意新版 Fastjson 的安全配置项,别用默认设置直接对外暴露接口。

3.4 事务就该写在这三行里,别再加戏

<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

配置本身的复杂度不高,难点在于理解DataSourceTransactionManager的工作方式。它会在方法开始时从DataSource拿一个Connection,关掉自动提交,通过TransactionSynchronizationManager把连接绑定到当前线程,然后 MyBatis 的SqlSessionTemplate在执行 SQL 时会去这个线程变量里找连接,做到“同一个事务里用同一个连接”。方法正常结束时提交,抛异常时回滚,最后解绑、还连接。

这个机制决定了三件事:事务是线程绑定的,所以跨线程不生效;连接是从池里借的,所以池子耗尽可能导致整个应用卡死;连接释放的时机在事务边界,事务太长会长时间占着连接。

3.5 连接池大小和事务超时,给个能算的数

很多项目连接池直接写死 20、50、100,从来没算过。给个粗糙但管用的估算方法。

先算平均并发连接数:并发数 ≈ QPS × 事务耗时(秒)。假设峰值 QPS 是 300,一个请求从开启事务到提交平均耗时 60 毫秒,那么300 × 0.06 = 18,也就是说平均需要 18 个连接同时在用。考虑到波动,乘 1.5 到 2 倍的冗余,maximumPoolSize定在 30 到 40 比较合理。

然后检查数据库侧的限制:如果数据库实例的max_connections是 500,应用集群有 10 个节点,每个节点 40 个连接,总共 400,留 100 给运维和监控,勉强够。如果节点数更多,就得往下压,或者上中间件做连接复用。

事务超时用@Transactional(timeout = 5)设置,单位是秒。这个值的参考点是接口的 P99 耗时。P99 是 800 毫秒,设 3 到 5 秒比较合适,既能容忍偶发慢查询,又不会让一个卡死的请求把连接占太久。设成 30 秒等于没设。

4. 事务这部分,坑永远比配置多

4.1 声明式事务生效的六个前提

@Transactional能起作用,需要同时满足六个条件,缺一个就变成摆设。

第一,类必须被 Spring 管理。你自己new出来的对象,注解写得再标准也没人管。第二,方法必须是public。private、protected、包级可见的方法,代理拦截不到。第三,方法不能是final或static,CGLIB 没法重写final方法。第四,调用必须经过代理对象,同类内部用this调用不行。第五,异常要能抛出去,被catch吞掉就等于正常返回,事务照样提交。第六,抛出的异常类型要能被回滚规则匹配,默认只回滚RuntimeException和Error。

这六条里,第四和第五条引发的线上问题最多。

4.2 自调用为什么会绕过代理

Spring 的 AOP 用的是动态代理,容器里放的其实是一个代理对象,它包着你的真实对象。Controller 注入 Service 时拿到的是代理,调用方法时先经过代理,代理再做事务增强,最后才调真实方法。

但如果你在 Service 的一个方法里调另一个方法,用的是this.otherMethod(),这个this指向的是真实对象,不是代理对象。调用压根没经过代理,事务注解自然不生效。

@Service public class OrderService { @Autowired private OrderMapper orderMapper; public void createOrder(Order order) { // 这里 this 是真实对象,saveDetail 上的事务注解不生效 this.saveDetail(order); } @Transactional(rollbackFor = Exception.class) public void saveDetail(Order order) { orderMapper.insertDetail(order); } }

解决办法有三种。第一种是把saveDetail挪到另一个 Bean 里,通过注入调用。第二种是在本类里注入自己,@Autowired private OrderService self;,然后self.saveDetail(order),注意循环依赖问题,Spring Boot 2.6 以后默认禁止,需要额外配置。第三种是用AopContext.currentProxy(),前提是开启exposeProxy = true。

@EnableAspectJAutoProxy(exposeProxy = true)
public void createOrder(Order order) { ((OrderService) AopContext.currentProxy()).saveDetail(order); }

我更倾向第一种,把职责拆开,代码也更清楚。第三种虽然省事,但把 AOP 的实现细节泄漏到了业务代码里,后面接手的人看到这一行会愣一下。

4.3 异常被吞掉,是比自调用更隐蔽的问题

@Transactional public void transfer(Long from, Long to, BigDecimal amount) { try { accountMapper.decrease(from, amount); accountMapper.increase(to, amount); } catch (Exception e) { log.error("转账失败", e); } }

这段代码的问题不在于事务配置,而在于catch之后没有继续抛出。方法正常返回,Spring 认为一切顺利,直接提交。结果就是扣了钱没加钱,或者两个都没动,看执行到哪一步。

正确的写法是要么不捕获,让异常往上抛;要么捕获后处理完再抛一个RuntimeException;要么在catch里手动调TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

还有一种情况是抛了受检异常。比如throws IOException,Spring 默认不回滚。要么在注解上写rollbackFor = Exception.class,要么把异常包装成运行时异常。我习惯在每个@Transactional上都显式写rollbackFor = Exception.class,多敲几个字,省掉一类事故。

4.4 传播行为怎么选,给几个实际场景

传播行为有七种,日常用到的其实就三个。

REQUIRED是默认值,有事务就加入,没有就新建。绝大多数业务方法用这个。

REQUIRES_NEW是挂起当前事务,新开一个独立事务。典型场景是记录操作日志:主业务失败了要回滚,但日志必须留下来,否则出了问题连痕迹都没有。用这个传播行为,日志方法要放在独立的 Bean 里,否则自调用一样不生效。

NESTED是嵌套事务,基于数据库的保存点实现。外层回滚,内层跟着回滚;内层回滚,外层可以捕获异常继续走。适合“批量导入,某一条失败不影响其他条”的场景。但它依赖保存点,某些数据库或驱动支持不好,用之前要测。

NOT_SUPPORTED用于明确不需要事务的操作,比如查询外部接口、生成报表文件。挂起事务能减少连接占用时间。

MANDATORY要求必须存在外层事务,否则抛异常。这个用得少,但在一些强约束的底层方法上加这个,能防止调用方忘记开事务。

4.5 只读事务不只是个标记

@Transactional(readOnly = true)这一行,很多人以为只是给数据库看的提示,实际上它做了两件事:一是告诉数据库这是只读操作,某些数据库会因此选择更优的执行计划、跳过一些锁;二是告诉 Hibernate 这类 ORM 跳过脏检查,减少内存开销。

在 MyBatis 里,它主要体现在连接层面。如果用的是主从分离的架构,只读事务还能作为路由到从库的依据。所以查询方法上加这个注解,不只是规范问题,是真的能省资源。

但要注意,只读事务里如果执行了UPDATE,有的数据库直接报错,有的静默忽略,行为不一致。别在这种事务里做写操作。

5. AOP 与代理:切面写了为什么没反应

5.1 JDK 代理和 CGLIB 的切换时机

Spring AOP 有两种代理实现。目标类实现了接口,默认用 JDK 动态代理,生成的代理类和目标是兄弟关系,都实现同一个接口。目标类没有接口,用 CGLIB,生成目标类的子类。

这个差异带来的直接后果是:JDK 代理只能调用接口里声明的方法。如果你注入的时候用的是实现类类型,容器会报找不到 Bean,因为代理对象不是实现类的实例,只是接口的实现。解决办法是要么注入接口类型,要么强制用 CGLIB。

<aop:aspectj-autoproxy proxy-target-class="true"/>

CGLIB 也不是万能的。final类没法继承,final方法没法重写,private方法不可见。而且 CGLIB 在启动时要做字节码生成,类多的时候会拖慢启动速度,不过 Spring 5 之后用了更高效的实现,影响已经不大。

还有个容易忽略的点:代理对象的equals、hashCode、toString行为可能和原对象不一致,别在集合里用它做去重键。

5.2 切面顺序错了,事务读到脏数据

多个切面同时作用在一个方法上时,执行顺序由@Order或者Ordered接口决定,值越小越靠外层。这个“外层”的概念很关键:值小的切面先进后出,像洋葱一样包在外面。

假设有一个日志切面和一个事务切面,日志切面@Order(1),事务切面默认是Ordered.LOWEST_PRECEDENCE。执行顺序是:日志开始 → 事务开启 → 业务方法 → 事务提交 → 日志结束。这个顺序是对的。

如果反过来,事务在外层,日志在内层,那么日志打印的时候事务还没提交,如果日志里查了数据库,可能读到未提交的数据,或者因为隔离级别读不到。更麻烦的是权限校验切面,如果它排在事务里面,一个没有权限的请求会先开启事务再被拒绝,白白占一次连接。

我一般把切面按这个顺序排:权限校验(最外层)→ 参数校验 → 日志 → 事务 → 业务。缓存切面要看情况,读缓存应该在最外层,写缓存要在事务提交之后,用@TransactionalEventListener或者TransactionSynchronization更稳妥。

5.3 切点表达式写宽了,性能会掉

@Pointcut("execution(* com.example.demo.service..*.*(..))") public void servicePointcut() {}

这种写法匹配了 service 包及其子包下所有类的所有方法。如果这个切面里做了比较重的操作,比如序列化参数、查数据库,那所有 Service 方法都要背这个开销。

优化思路有三个。一是收窄范围,只匹配需要的注解,比如@annotation(com.example.demo.annotation.AuditLog),用自定义注解精确控制。二是控制切面逻辑,日志切面里只打方法名和耗时,不要无条件序列化参数,大对象序列化一次可能几十毫秒。三是用条件判断,比如只有特定环境的配置打开时才执行完整逻辑。

还有一种性能陷阱是切点匹配本身。execution表达式的匹配在启动时完成,成本主要在启动阶段;但@annotation这种动态匹配在每次调用时都要做判断,类多的时候会影响运行性能。这个差异在高并发下能测出来。

6. 排查实录:那些报错背后的真实原因

6.1 启动阶段的高频报错

java.lang.IllegalStateException: No WebApplicationContext found这个错误,通常出现在过滤器或者监听器里想用WebApplicationContextUtils拿容器,但拿不到。原因是ContextLoaderListener还没执行完,或者web.xml里 listener 的顺序不对,或者根本就没配这个 listener。

BeanDefinitionStoreException: IOException parsing XML document一般是配置文件路径写错了,或者 XML 里有非法字符。注意classpath:后面不能有空格,文件编码要和声明的一致,中文注释在某些老版本解析器上会出问题。

ClassNotFoundException: javax.servlet.ServletException这种,是 Servlet API 的依赖没加,或者加了provided但 IDE 的运行配置里没包含。用 Maven 的话确认scope是provided,然后用 Tomcat 插件跑,别用java -jar。

NoClassDefFoundError: org/aspectj/weaver/reflect/ReflectionWorld是缺aspectjweaver包。只加spring-aspects有时候不够,得显式加一个aspectjweaver依赖。

6.2 运行期的诡异现象

现象一:接口第一次访问正常,第二次开始返回的数据不对。大概率是 MyBatis 一级缓存在作怪。同一个SqlSession里,相同 SQL 加相同参数,第二次直接走缓存。在事务里,整个事务共用一个SqlSession,所以事务内多次查询同一条件,后面几次拿的是第一次的结果。如果中间有别的操作改了数据,你读到的是旧值。想关掉可以配localCacheScope=STATEMENT,但会牺牲性能,得权衡。

现象二:保存中文变成问号。三个地方要检查:数据库连接 URL 有没有characterEncoding=utf8,表和字段的字符集对不对,以及CharacterEncodingFilter有没有配、是不是放在 filter 链最前面。最后这一条最容易被忽略,forceEncoding=true要加上,否则请求编码不会强制覆盖。

<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

现象三:@ResponseBody返回的对象里日期是一串数字。这是 Jackson 默认把Date序列化成时间戳。要么在字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),要么全局配置ObjectMapper。注意时区,不写的话用的是 UTC,会比北京时间少八小时。

现象四:加了拦截器之后,静态资源 404。拦截器的addPathPatterns("/**")把静态资源也拦了,而拦截器里判断登录的逻辑对静态请求返回了重定向。要么把静态资源路径排除掉,要么在拦截器里先判断请求是不是资源请求。

6.3 一张速查表,按症状找原因

症状最可能的原因优先检查点
Controller 注入 Service 为 null父子容器扫描范围重叠或错位两个配置文件的 component-scan 过滤规则
@Transactional不生效自调用、非 public、异常被吞调用方式、方法修饰符、catch 块
启动报占位符解析失败扫描器提前初始化了依赖 Bean是否用了sqlSessionFactoryBeanName
Mapper 注入失败扫描包路径错、XML 位置错basePackage和mapperLocations
事务提交了但数据没变数据库引擎不支持事务表是否是 InnoDB
切面没执行切点表达式不匹配、类没被代理是否final、是否走代理调用
静态资源 404DispatcherServlet 拦截了全部请求<mvc:resources>配置
中文乱码编码过滤器缺失或顺序错filter 顺序、连接串参数
循环依赖启动失败构造器注入、Boot 2.6+ 默认禁止改成 setter 注入或拆解依赖
事务内查询拿到旧数据一级缓存、隔离级别localCacheScope配置

关于循环依赖多说一句。Spring 通过三级缓存解决单例 Bean 的 setter 注入循环依赖,但构造器注入的循环依赖解决不了,会直接抛BeanCurrentlyInCreationException。Spring Boot 2.6 之后默认禁止循环依赖,报错信息更明确,但也让一批老项目升级时集体卡壳。真要临时放开,可以设spring.main.allow-circular-references=true,但这是治标,根子上还是要把依赖关系理清楚,A 依赖 B、B 又依赖 A,说明这两个类的职责划分有问题。

7. 我个人在处理这类项目时的几个习惯

接手一个 SSM 项目,我的第一件事不是看代码,是看配置文件。把web.xml、applicationContext.xml、spring-mvc.xml三份文件并排打开,画出 Bean 的分布图,标清楚哪个对象在哪个容器里。这一步花半小时,后面能省好几天。

第二件事是把启动日志完整跑一遍,搜几个关键词:Root WebApplicationContext、initializing、Mapped、ERROR、WARN。启动日志里藏了大量信息,Bean 创建了几次、扫描了哪些包、映射了哪些 URL,全在里面。很多人直接跳过这一段,结果出了问题只能猜。

第三件事是给关键路径加事务日志。不是打日志到文件,是在事务方法里临时加一行,打印当前事务的名字和是否为新事务,配合TransactionSynchronizationManager.isActualTransactionActive()一起看。跑一遍正常流程,确认事务真的开了,再开始改业务代码。

还有一个习惯是尽量不用“约定俗成”的配置。component-scan一定写清楚包含和排除,transaction-manager一定显式指定名字,proxy-target-class根据实际情况明确写出来。默认值能省事,但出问题时也最难查,因为没人知道到底生效的是哪一套规则。

最后一点,别迷信“配置越少越好”。SSM 的配置文件确实冗长,但每一行都有它的作用。见过有人把事务配置全删了,靠@EnableTransactionManagement一个注解撑着,结果因为数据源是多数据源的,默认管理器选错了,事务跑到了另一个库上。配置多一点,但看得见,比藏在默认值里强。