SSM与Spring关系详解:父子容器、事务失效与Mapper排障

发布时间:2026/10/31 13:00:42
SSM与Spring关系详解:父子容器、事务失效与Mapper排障 上个月帮某开发者梳理一个跑了快七年的老系统打开工程目录的一瞬间熟悉的味道就来了spring-dao.xml、spring-service.xml、springmvc.xml、mybatis-config.xml四份配置文件整整齐齐躺在那儿web.xml里挂着ContextLoaderListener和DispatcherServlet。这套东西就是通常说的SSM而它中间那个S指的正是Spring。很多人把 SSM 和 Spring 当成两个平行的东西在讨论甚至在面试里被问你们项目用 SSM 还是 Spring时会愣一下——这个问法本身就把关系搞混了。SSM 不是一个框架它是 Spring、SpringMVC、MyBatis 三个组件的组装方案Spring 也不是 SSM 的替代品它是这套方案的底座。这篇文章想把这层关系彻底讲透顺带把 SSM 从搭建到排障的完整链路拆一遍包括容器父子关系、事务失效、Mapper 绑定失败这些年年有人踩的坑。不管你是刚接触这套技术栈、正在照着教程搭第一个 Demo还是维护着一个不便大改的存量系统下面这些内容应该都能直接用上。1. 先分清两个概念SSM 是组合拳Spring 是底座1.1 SSM 这个名字背后到底是什么SSM 是三个组件的缩写拼在一起的产物Spring 负责对象的创建、依赖装配和事务控制SpringMVC 负责把 HTTP 请求接进来、分发到具体方法、再把结果写回去MyBatis 负责把 Java 对象和数据库表之间的转换做掉。这三个东西各自独立谁也不是谁的子模块只是经常被放在同一个工程里配合使用久而久之就有了 SSM 这个说法。理解这一点很关键因为它决定了很多实际问题该怎么定位。比如事务不生效问题在 Spring 的代理机制不在 SpringMVC 也不在 MyBatis比如接口返回 404问题在 SpringMVC 的映射配置跟 Spring 容器没关系比如Invalid bound statement报错问题在 MyBatis 的映射文件扫描跟前两者都没关系。把三者当成一个整体去看排障时就会像无头苍蝇一样到处乱改。我一般习惯在脑子里给这三者画一条清晰的边界线组件核心职责关键入口出问题时的典型症状SpringBean 生命周期、依赖注入、AOP、事务ApplicationContext注入为 null、事务不回滚、启动报循环依赖SpringMVC请求路由、参数绑定、视图渲染DispatcherServlet404、400、415、返回乱码MyBatisSQL 映射、结果集转换、连接管理SqlSessionFactory绑定异常、字段为 null、连接泄漏这张表看着简单但它是我处理线上问题时的第一道分流器。看到报错先判断落在哪一栏再往下钻效率比翻整份堆栈高得多。1.2 Spring 真正提供的能力清单抛开那些营销词汇Spring 在 SSM 里实际干了四件事每一件都有明确的落点。第一件是对象工厂。你在 XML 里写一个bean或者在类上打一个ServiceSpring 就会在启动时把这个对象 new 出来放进容器别处需要的时候按类型或名字取。这件事听起来平平无奇但它的价值在于把谁创建谁这个决定从代码里搬到了配置里改实现类不用改调用方。第二件是依赖装配。Autowired背后是一套按类型查找、按名称兜底、配合Qualifier精确指定的解析逻辑。它解决的是手工 new 对象带来的层层耦合——A 依赖 BB 依赖 C手工装配就得在 A 里 new B在 B 里 new C任何一层换实现都要改一串代码。第三件是代理增强。AOP 让你在不改动业务代码的前提下在方法前后插入逻辑声明式事务就是最典型的应用。你在 Service 方法上打个TransactionalSpring 就在容器里放一个代理对象调用真正进方法之前先开事务抛出符合规则的异常就回滚。第四件是资源与生命周期管理。数据库连接、事务同步、线程上下文这些横切资源由 Spring 统一托管业务代码只管拿现成的。SSM 里那套一次请求一个事务、一个事务一个连接的语义就是靠 Spring 的TransactionSynchronizationManager把连接绑到线程上实现的。1.3 为什么很多人把它们当成同一件事混淆的根源在于教程的叙事方式。绝大多数入门材料都是直接甩出一个完整的 SSM 工程从pom.xml到web.xml一次性配好读者跑通之后形成的心智模型就是这一坨东西 SSM至于哪部分是 Spring 的、哪部分是 SpringMVC 的从来没被切开过。第二个原因是命名。SpringMVC 名字里带 Springspring-webmvc这个依赖又确实属于 Spring 家族于是很多人自然认为它是 Spring 的一部分。严格说它确实是 Spring 框架的一个模块但它有独立的容器实例、独立的配置、独立的生命周期和核心容器不是一个东西。第三个原因是 Spring Boot 带来的视角错位。Spring Boot 把三者打包成一个 starter一个spring-boot-starter-web就把 MVC 拉进来了一个SpringBootApplication就把所有配置吞掉了。用惯了 Boot 的人回头看 SSM会觉得这是一堆冗余配置而一直写 SSM 的人看 Boot会觉得这不就是把我的 XML 换成了注解。这两种视角都不算错但都缺了中间那层理解。提醒如果你正在准备面试或者要接手一个 SSM 项目务必先把谁管什么这条边界记牢。后面所有排障技巧都建立在这条边界上。2. 选型背后的取舍当年为什么选这三件套2.1 三层结构为什么这样切Web 层、业务层、持久层这个切法不是 SSM 发明的但 SSM 把它固化得最彻底。Controller 只做三件事接参数、调 Service、封返回。Service 承担业务编排和事务边界。Mapper 只管一件事把方法调用翻译成 SQL。这样切的直接好处是事务边界清晰。事务应该开在业务方法上而不是开在 Controller 上那样会把参数校验、日志这些无关操作也圈进事务拉长事务时间也不该开在 Mapper 上那样一个业务操作涉及多张表时就会有多个独立事务无法保证一致性。把Transactional打在 Service 层是这套结构里最自然的落点。另一个好处是可测试性。Mapper 是接口可以轻易 mockService 依赖 Mapper 接口注入一个假实现就能单测Controller 依赖 Service 接口同理。整条链路上唯一的硬依赖是数据库连接而它被推迟到了运行期。我自己踩过一个反例早期为了省事在一个 Controller 方法里直接注入了 Mapper 并调用了三次写操作指望它们在一个事务里。结果当然是各自提交中间失败就留下了脏数据。那次之后我给自己定了个死规矩——Mapper 只在 Service 层出现Controller 里出现 Mapper 一律视为设计事故。2.2 持久层为什么选 MyBatis 而不是全自动 ORM这是 SSM 组合里争议最大的一环。全自动 ORM 的卖点是不用写 SQLMyBatis 的卖点是SQL 由你写。听起来后者是倒退实际项目里恰恰相反。原因在于复杂查询的可控性。业务报表、多表关联、动态条件组合这些场景下全自动 ORM 生成的 SQL 往往不是最优的甚至不可预测。你得学会看它生成的执行计划学会用它的查询 API 去引导它生成你想要的样子学习成本并没有降低只是从学 SQL变成了学框架的 SQL 生成规则。MyBatis 的取舍是把 SQL 摊开写在你面前你完全掌控它长什么样同时用映射配置把结果集到对象这段重复劳动自动化掉。这样既保留了性能调优的空间又避免了手写ResultSet的痛苦。还有一点是动态 SQL。if、where、foreach这几个标签在处理条件可选的列表查询时特别顺手。比如一个带五个可选筛选条件的查询用纯 Java 拼字符串会写得非常难看用标签几行就描述清楚了。代价也很明确SQL 写在 XML 里和 Java 代码分离重构时 IDE 帮不上忙字段改名要同步改 XML容易漏。这个代价在中小型项目里可以接受在超大型项目里就有人开始抱怨了。所以后来才有 MyBatis-Plus 这类增强工具出现本质是在手写 SQL和自动生成之间再找一个平衡点。2.3 SpringMVC 与 Spring 容器的父子关系设计这是 SSM 里最容易被忽略、但影响最大的一处设计。用web.xml部署时实际会创建两个容器由ContextLoaderListener创建的根容器通常加载spring-dao.xml和spring-service.xml里面放数据源、事务管理器、Service、Mapper由DispatcherServlet创建的子容器通常加载springmvc.xml里面放 Controller、视图解析器、拦截器、消息转换器。两者之间的关系是单向可见子容器能拿到父容器的 Bean父容器看不到子容器。所以 Controller 里注入 Service 是可行的Service 里注入 Controller 会失败——这个方向不能反。设计成父子结构而不是一个大容器原因是隔离。SpringMVC 相关的 Bean尤其是那些带请求作用域的、绑定到 Servlet API 的不应该被 Service 层感知反过来Service 层的启动也不该依赖 Web 环境。分开之后Service 层可以在非 Web 环境里单独跑起来做集成测试。这里有个非常经典的坑要提前说如果springmvc.xml里的context:component-scan把 base-package 写成了整个项目根包而不是精确到controller包那么 Service 会被子容器再扫一遍。结果是根容器里有一个带事务代理的 Service子容器里有一个没走代理的 Service而 Controller 引用的是子容器里那个。表现出来就是Service 里明明写了Transactional但插入失败后数据就是没回滚。这个问题的排查难度极高因为代码看起来完全正确配置也看起来没问题。正确的写法是把扫描范围切干净!-- 根容器只扫业务和持久层排除 Controller -- context:component-scan base-packagecom.example.demo context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan !-- 子容器只扫 Controller -- context:component-scan base-packagecom.example.demo.controller use-default-filtersfalse context:include-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan2.4 配置形态的演进路线SSM 的配置写法经历过三个阶段理解这条演进线有助于看懂各种老项目里的混合风格。第一阶段是纯 XML。所有 Bean 在 XML 里声明property namexxx refyyy/手工装配。好处是配置集中、一目了然坏处是文件动辄上千行改一个类名要动两个文件。第二阶段是 XML 注解混合。XML 只留三类东西数据源、事务管理器、MyBatis 的SqlSessionFactory——这些是从第三方类库来的对象改不了源码只能外部声明。业务组件全部用Service、Repository、Controller注册用Autowired装配。第三个阶段是纯 Java 配置。用ConfigurationBean替代 XMLSSM 里对应的就是MapperScan、DataSourceTransactionManager的Bean声明。这一步再往前走一小截加上自动配置和起步依赖基本就是 Spring Boot 的样子了。我在维护老项目时不会强行把 XML 迁移成 Java 配置——除非要动那块逻辑。纯粹为了现代化去改配置风险远大于收益尤其是事务和数据源部分改错一个属性可能引发线上问题。3. 核心机制拆解装配、代理与请求链路3.1 Bean 的生命周期在 SSM 里落到哪个环节Spring 创建一个 Bean 大致要走过这些步骤实例化调构造方法、属性填充处理Autowired、Aware接口回调、BeanPostProcessor前置处理、初始化方法PostConstruct/InitializingBean/init-method、BeanPostProcessor后置处理、放入单例池。SSM 里真正有感知的是两个节点。第一个是属性填充MyBatis 的MapperFactoryBean就是在这一步把SqlSessionTemplate塞进 Mapper 代理对象的。第二个是后置处理AOP 代理就是在postProcessAfterInitialization里织入的——这也是为什么自调用导致事务失效这个说法成立代理对象在外层this调用绕过了外层。还有一处容易忽略BeanPostProcessor本身是提前实例化的比普通 Bean 早。这意味着你在BeanPostProcessor里注入一个普通 Service可能会拿到还没初始化完的对象。这个坑在写自定义注解处理器时尤其常见。3.2 AOP 代理与事务传播的真实行为Transactional生效的前提是调用方持有的是代理对象。Spring 默认的代理策略是目标类实现了接口就用 JDK 动态代理没实现接口就用 CGLIB。这个默认值在 Spring Boot 2.x 之后被改成了全部用 CGLIBproxyTargetClasstrue所以从 SSM 迁到 Boot 的人偶尔会发现行为变了——比如原本注入接口能成功的地方现在注入实现类也能成功反过来如果代码里强制要求接口类型可能会有细微差异。事务传播行为是另一个重灾区。默认的REQUIRED语义是有事务就加入没有就新建。这意味着如果外层方法已经开了事务内层方法即使写了REQUIRES_NEW之外的值也不会真的新建——除非显式指定REQUIRES_NEW。而REQUIRES_NEW会挂起外层事务、占用一个新的数据库连接嵌套两层就有两个连接同时在跑连接池不够时直接死锁。我把几个高频传播行为的实际效果整理成表比翻文档快传播行为外层有事务时外层无事务时典型用途REQUIRED默认加入外层新建绝大多数业务方法REQUIRES_NEW挂起外层新建新建日志记录、独立提交的流水NESTED创建保存点新建局部失败可回滚到保存点SUPPORTS加入外层不用事务只读查询NOT_SUPPORTED挂起外层不用事务耗时统计上报回滚规则这里必须强调一遍rollbackFor默认只对RuntimeException和Error生效受检异常不会触发回滚。如果你在 Service 里抛了一个自定义的Exception子类受检事务会正常提交。我见过太多项目在这上面栽跟头表现是代码明明抛异常了数据还是进去了。稳妥的做法是在团队规范里统一写成Transactional(rollbackFor Exception.class)。3.3 一次 HTTP 请求在 SpringMVC 里的完整旅程请求进到DispatcherServlet之后会依次走过getHandler找处理器由RequestMappingHandlerMapping根据路径、方法、参数条件匹配、getHandlerAdapter找适配器、执行拦截器的preHandle、反射调用目标方法、处理返回值HandlerMethodReturnValueHandlerResponseBody时走消息转换器写 JSON、执行postHandle、渲染视图、执行afterCompletion。中途任何一步抛异常都会走到processHandlerException由HandlerExceptionResolver链条处理。这条链路上最值得关注的是参数绑定。RequestParam、PathVariable、RequestBody分别对应查询串、路径片段、请求体。绑定失败会抛BindException或HttpMessageNotReadableException最终表现为 400。而请求体是 JSON 但没写RequestBody参数会全部为 null不会报错——这个静默失败非常坑。返回阶段也有一个隐蔽点ResponseBody写 JSON 时Accept头决定了用哪个HttpMessageConverter。如果前端发的是Accept: text/html浏览器直接访问接口时就是这样而配置里没有合适的转换器会返回 406。这也是为什么用浏览器调试接口和用 Postman 调试结果可能完全不同。3.4 MyBatis 被 Spring 接管的关键三个类MyBatis 原本是自带SqlSession生命周期的跟 Spring 整合的核心就是把这套生命周期交给 Spring 管。干这件事的是三个类。SqlSessionFactoryBean负责构建SqlSessionFactory。它接过来一个DataSource把数据源、别名包、映射文件位置、全局配置文件路径统统塞进Configuration最后build出工厂。这一步的关键是数据源由 Spring 提供MyBatis 自己不再管连接池。MapperScannerConfigurer负责扫描 Mapper 接口。它拿到包名后为每个接口注册一个MapperFactoryBean后者在被注入时生成 JDK 动态代理。这就解释了为什么 Mapper 是个没有实现类的接口却能直接被注入——它本质是个工厂产物不是普通 Bean。SqlSessionTemplate是线程安全的SqlSession代理。它的作用是每次调用时向 Spring 要当前线程绑定的连接如果有就复用没有就新开用完解绑。这就是一次事务一个连接的实现所在也是为什么事务和 MyBatis 能配合上的根本原因。理清这三个类之后很多报错就能直接对号入座。比如Invalid bound statement (not found)是MapperScannerConfigurer扫到了接口但Configuration里没有对应语句通常意味着 XML 没被打包进target/classesMaven 默认只打包src/main/resources下的资源XML 放在src/main/java下就必须在pom.xml里配resources白名单。4. 从零搭一个能跑的 SSM 骨架4.1 依赖与版本对齐SSM 报错里有一大半是版本不匹配造成的。Spring 5.x 配mybatis-spring1.3.x 会出现NoSuchMethodError因为SqlSessionTemplate的构造签名变了这是最典型的一个。下面的组合是经过验证能跑通的组件建议版本需注意的点JDK8 或 11Spring 5.x 最低要求 8Spring5.3.x5.x 主线最后一支稳定MyBatis3.5.x与 3.4 兼容性良好mybatis-spring2.0.x必须与 Spring 5 配对Servlet API3.1 或 4.0scope 设为 provided连接池HikariCP 或 Druid二者择一即可日志slf4j logbackMyBatis 日志依赖它输出 SQLpom.xml里最容易被忘记的是资源白名单。Mapper XML 如果和接口放在一起src/main/java下Maven 编译时不会把它复制到target/classes运行时就会找不到build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource resource directorysrc/main/resources/directory includes include**/*.xml/include include**/*.properties/include /includes /resource /resources /build提示如果本地 IDE 里跑得好好的打包部署就报绑定异常八成就是这段没配。IDE 直接读源码目录Maven 打包只看target两者行为不一致。4.2 数据源、事务与 MyBatis 的容器配置根容器配置的目标只有一个把数据源、事务管理器、SqlSessionFactory这三样东西装好然后把 Service 和 Mapper 扫进来。下面是我常用的骨架!-- spring-dao.xml -- bean iddataSource classcom.zaxxer.hikari.HikariDataSource destroy-methodclose property namedriverClassName value${jdbc.driver}/ property namejdbcUrl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property namemaximumPoolSize value20/ property nameminimumIdle value5/ property nameconnectionTimeout value3000/ property nameidleTimeout value600000/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nametypeAliasesPackage valuecom.example.demo.entity/ property namemapperLocations valueclasspath*:mapper/*.xml/ property nameconfiguration bean classorg.apache.ibatis.session.Configuration property namemapUnderscoreToCamelCase valuetrue/ property namecacheEnabled valuefalse/ /bean /property /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.demo.mapper/ property namesqlSessionFactoryBeanName valuesqlSessionFactory/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/这里有几个参数选择值得说明。mapUnderscoreToCamelCasetrue让create_time自动映射到createTime省掉大量resultMap。cacheEnabled我默认关掉因为二级缓存的作用域是SqlSessionFactory多实例部署时会不一致收益小风险大。MapperScannerConfigurer里用sqlSessionFactoryBeanName而不是sqlSessionFactoryref是为了避免 Bean 提前初始化导致数据源读不到占位符——这是个很隐蔽的启动期问题。4.3 web.xml 与 SpringMVC 配置web.xml在 Servlet 3.0 之后可以用WebApplicationInitializer替代但存量项目里还是 XML 居多。核心是三段编码过滤器、根容器监听器、DispatcherServlet映射。filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-dao.xml,classpath:spring-service.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:springmvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mappingurl-pattern//url-pattern和/*的区别是个经典问题。/是默认 Servlet 映射会覆盖容器自带的静态资源处理所以需要在springmvc.xml里配mvc:default-servlet-handler/或mvc:resources否则 js、css 会 404。/*则会连.jsp也拦截导致视图无法渲染。这个坑几乎是每个 SSM 新手都要踩一次的。load-on-startup设为 1 让DispatcherServlet随容器启动而初始化好处是配置错误在启动阶段就暴露不用等到第一个请求。我在所有环境都设成 1宁可启动慢几百毫秒。4.4 一条完整链路Controller 到 Mapper配置齐了之后一条完整的写操作链路是这样的RestController RequestMapping(/order) public class OrderController { Autowired private OrderService orderService; PostMapping(/create) public ResultLong create(RequestBody Valid OrderCreateReq req) { return Result.ok(orderService.create(req)); } } Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private StockMapper stockMapper; Transactional(rollbackFor Exception.class) public Long create(OrderCreateReq req) { Long id orderMapper.insert(req.toEntity()); int affected stockMapper.deduct(req.getSkuId(), req.getCount()); if (affected 0) { throw new IllegalStateException(库存不足); } return id; } } Mapper public interface StockMapper { Update(update stock set qty qty - #{count} where sku_id #{skuId} and qty #{count}) int deduct(Param(skuId) Long skuId, Param(count) Integer count); }这段代码里最值得看的是deduct的 SQL 写法把库存判断放到where条件里用受影响行数判断是否成功而不是先查再更新。前者是一条语句、原子操作后者是两条语句中间有窗口期高并发下会超卖。这是我在实际项目里反复强调的一点——能用一条 SQL 表达的业务判断就不要拆成两条。另一个细节是Valid配合RequestBody校验失败会抛MethodArgumentNotValidException需要在全局异常处理器里接住否则前端只能看到 400 和一坨堆栈。这个处理器的写法是从RestControllerAdvice加ExceptionHandler起步的写起来比想象中简单。4.5 几个必须算清楚的参数连接池大小不是拍脑袋定的。业内常用的经验公式是连接数 ≈ CPU 核数 × 2 磁盘数对于 SSD 环境可以简化成核数 × 2 1。一台 4 核机器池大小给 10 左右就够给到 50 反而更慢因为数据库侧的上下文切换成本上来了。这个结论我实测验证过某个查询接口在池大小 10 时 QPS 是 1200调到 50 之后掉到了 900。原因是数据库连接的建立和上下文切换都有开销连接数超过数据库的服务能力之后多出来的连接只是在排队。事务超时的设置也需要算。它的合理值应该同时小于三个量HTTP 客户端超时、数据库锁等待超时、上游 RPC 超时。假设前端超时是 10 秒数据库innodb_lock_wait_timeout是 50 秒那事务超时设在 8 秒比较合适——保证在客户端放弃之前事务先自己结束避免连接被长时间占用。connectionTimeout我一般设 3000 毫秒含义是从池里拿连接的等待时间。这个值不该太大因为拿不到连接通常意味着有慢查询或连接泄漏早点暴露比慢慢排队更好。5. 排障实录SSM 项目里最常见的那批坑5.1 事务不生效的六种典型原因这是被问得最多的一类问题。按出现频率排一下第一自调用。同一个类里 A 方法调 B 方法B 上有Transactional。因为调用走的是this没经过代理注解完全无效。解决办法是把 B 挪到另一个 Bean或者注入自己的代理对象。第二方法非 public。Spring 的代理只能拦截 public 方法private、protected、包级方法上的注解会被静默忽略。这个特性在 Spring 5 之前不明显之后有了明确日志提示但还是容易忽略。第三异常被吞。try-catch把异常抓住没往外抛事务管理器根本感知不到失败自然正常提交。这类代码通常是为了不让接口报错加上去的结果更糟。第四受检异常没配 rollbackFor。前面说过默认只回滚运行时异常。给自定义业务异常加个rollbackFor Exception.class就能解决。第五类没被 Spring 管理。自己new出来的对象不带代理注解写了也白写。常见于工具类里手动 new Service 的场景。第六多数据源没指定事务管理器。配了两个DataSourceTransactional没写transactionManager属性就会用默认的那个操作另一个库时事务当然是失效的。排查顺序我一般这样走先确认类是不是 Bean看启动日志有没有注册再确认方法是不是 public、有没有自调用最后确认异常类型。这三步能覆盖九成情况。5.2 启动即失败与容器相关的报错启动阶段报错通常更友好因为堆栈直接指向配置文件。但有几个是例外。BeanCurrentlyInCreationException是循环依赖。单例模式下Spring 通过三级缓存能解决大部分字段注入的循环依赖但构造器注入的循环依赖解不了会直接抛错。遇到这个优先考虑是不是设计问题——两个 Service 互相依赖往往意味着一部分职责该抽到第三个类里。NoSuchBeanDefinitionException报的是找不到 Bean。先看包扫描范围对不对再看类上有没有加注解最后看是不是被Profile或条件注解排除了。这个报错信息里通常会打印期望至少有一个 Bean 符合条件顺着类型找就行。BeanCreationException是个大类得看caused by。如果是Could not resolve placeholder说明配置文件没加载如果是Failed to determine a suitable driver class说明数据源 URL 写错了如果是Invalid bound statement说明 Mapper 扫描或 XML 位置有问题。还有一种启动不报错但运行出错的情况contextConfigLocation里配了两个文件中间用了中文逗号。这个错误极难发现因为 XML 解析器不会报错只是把整个字符串当成一个文件名去找找不到就用默认规则于是加载了一堆你没预期的配置。5.3 请求层报错定位表接口调不通的时候状态码是最好的线索。我整理了一张对照表放在团队文档里新人上手快很多状态码最常见原因排查动作404路径不匹配、类没被扫描、RequestMapping拼错启动日志搜Mapped {看实际注册的路径400参数类型不匹配、JSON 格式错、缺少RequestBody打开 debug 日志看BindException详情405HTTP 方法不对用了PostMapping却发 GET检查注解和请求方法406返回类型没有匹配的消息转换器检查Accept头和jackson依赖415Content-Type不是application/json检查请求头500业务异常未捕获看HandlerExceptionResolver是否有对应处理404 那一行值得多说一句。SpringMVC 启动时会打印所有注册的映射格式是Mapped {[/order/create],methods[POST]}。找不到接口的时候去日志里搜这个关键字能立刻分辨是路径写错还是根本没注册。如果是后者问题就在扫描范围。另外路径匹配有个细节RequestMapping(/user)加GetMapping(/list)最终路径是/user/list但如果类上的注解写成了/user/某些版本下会拼出/user//list虽然一般能容错但最好别留尾斜杠。5.4 持久层绑定与映射异常Invalid bound statement (not found)是 MyBatis 的头号报错。它的字面意思是接口方法找到了但Configuration里没有对应的 SQL 语句。原因通常有三种——XML 没被打包前面说的资源白名单、XML 的namespace和接口全限定名不一致、方法名和 XML 里的id对不上。排查方法很直接启动时断点看Configuration.mappedStatements的大小或者直接搜target/classes下有没有对应的 XML。我一般用后者一条命令的事。TooManyResultsException是查询返回了多行但方法声明成单个对象。这类问题通常出现在按理说应该唯一的字段上——比如按手机号查用户结果库里有两个重复手机号。这暴露的是数据层面的约束缺失加唯一索引比改代码更根本。ReflectionException: There is no getter for property named xxx是参数名对不上。用Param显式命名能避免或者开-parameters编译参数。单参数时 MyBatis 会做特殊处理直接取值多参数时不加Param就会用param1、arg0这种默认名很容易踩。还有一个不报错但结果不对的情况resultType用了类名简写但没配typeAliasesPackage。这时 MyBatis 会把结果映射成一个Map或者直接返回 null 而不报错字段全是空。看到查询有结果但对象属性全 null先检查类型别名和驼峰映射开关。5.5 一张速查表把这些高频问题压成一页方便对照现象最可能的原因快速验证方式注入对象为 null类未被扫描、子容器未加载打印容器中 Bean 名称列表事务不回滚自调用、非 public、受检异常断点看调用栈是否经过代理静态资源 404DispatcherServlet映射为/检查mvc:resources配置中文乱码过滤器顺序或forceEncoding未开抓包看请求头编码SQL 日志不打印日志级别没开设com.example.demo.mapper为 DEBUG连接池耗尽慢查询、连接未释放看活跃连接数和等待队列6. 站在今天看 SSM哪些东西值得留哪些该换6.1 保留价值最高的部分即便现在新项目基本不这么搭了SSM 里沉淀下来的几样东西依然有长期价值。一是分层的心智模型。Controller 薄、Service 厚、Mapper 纯这条边界放在任何框架里都成立。用不用 Spring 都无所谓切分逻辑是一样的。二是显式 SQL 的可控性。MyBatis 那种SQL 摊开写的做法在排查性能问题时的优势无可替代。你用全自动方案出了慢查询还得去扒生成的 SQL用 MyBatis慢查询日志里那条语句就是你写的那条直接看执行计划就行。三是对容器的理解。搞清楚了 Bean 什么时候创建、代理什么时候织入、事务怎么和线程绑定这些认知在换任何框架之后都不会过时。框架会变这些机制不变。6.2 迁移时最容易翻车的点如果要把存量 SSM 往新形态迁有几处必须格外小心。配置方式变了之后扫描范围的问题会以另一种形式出现。原来靠两个 XML 隔离父子容器现在只有一个容器如果包结构没有相应调整Transactional失效的老问题会以新面貌重现。事务管理器的选择也要重新确认。多数据源场景下自动配置只会给你一个默认的另一个库的事务得手动声明Bean并指定。还有就是拦截器和过滤器的注册顺序。XML 里靠filter-mapping的书写顺序注解式配置靠Order两个顺序不一致会导致编码过滤器、鉴权过滤器的执行次序变化表现出来就是迁完之后某个接口突然 401 了。6.3 我的取舍建议如果是接手存量系统我的建议是别动骨架。SSM 那套配置虽然啰嗦但它稳定、可预期、团队里所有人都看得懂。为了技术先进去重构一个跑得好好的系统收益极低而风险极高。如果是新起项目那确实没必要从裸 SSM 开始直接用带自动配置的形态把精力放在业务上。但有一点要提醒用自动配置不等于不用理解底层。当出现事务为什么不生效为什么这个 Bean 注入不进来这类问题时能救你的还是对容器、代理、扫描范围的理解而不是对自动配置类的熟悉程度。我在两个不同类型的项目里都验证过这一点一个项目从零用现代方式搭出问题时依然要回到代理机制去找原因另一个项目维护了六年的 SSM靠的也是同一套知识。工具换了判断问题的路径没换。最后分享一个我这些年养成的小习惯任何一次事务相关的改动改完之后不要只看代码一定跑一遍中途抛异常的用例确认数据库里的数据真的回滚了。这件事花不了两分钟但能挡掉绝大多数线上脏数据事故——我见过太多代码看起来完全正确的事务配置实际跑起来根本没生效。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询