最近在维护一个Spring Boot多模块工程时,碰上了一个非常典型的启动失败问题:服务一启动就报错,提示找不到某个Mapper。按理说编译都过了,配置也写了,接口上甚至加了@Mapper注解,XML文件也在resources目录里,到底为什么扫不到?这个问题我在本地和CI流程里反反复复试了几轮,最后才把根因彻底定位。这篇记录就围绕这个扫不到其他模块mapper的问题排查展开,从现象、机制到最终落地,把我实际踩过的坑和排查思路原原本本写出来,希望给同样掉进这个坑里的朋友省点时间。
先说结论核心:Spring Boot启动类默认只会扫描自己所在包及子包,多模块工程里Mapper接口如果落在默认扫描范围之外,光靠接口上的@Mapper注解是救不了的,必须依靠@MapperScan或者合理调整包结构来兜底。看起来像个配置问题,实际上牵扯到了Spring的自动配置机制和MyBatis的后置处理器执行逻辑。
1. 项目结构还原与问题现象描述
1.1 典型的多模块工程长什么样
这次的工程是一个非常标准的Maven多模块项目,通常拆成三类模块:
demo-common:存放工具类、通用DTO、常量定义。demo-dao:存放MyBatis相关的东西,比如Mapper接口、XML映射文件、Entity实体。demo-service:存放启动类、Controller、Service实现,是最后打包运行的模块。
模块之间依赖关系很明确:demo-service依赖demo-dao,demo-dao可能依赖demo-common。每个模块在Maven里都有自己独立的坐标和生命周期,编译时确实没问题,因为编译只看依赖是否引进来了,代码里的引用能不能解析;而Mapper接口编译后就是一个普通接口,根本不涉及容器装配。
所以你会发现一件很迷惑的事情:mvn clean package一路绿灯,一运行java -jar或者从IDE点启动,立刻崩给你看。报错信息大致是这一串:
Parameter 0 of method setDemoMapper in com.demo.service.impl.DemoServiceImpl required a bean of type 'com.demo.dao.mapper.DemoMapper' that could not be found.或者换成MyBatis风格:
Invalid bound statement (not found): com.demo.dao.mapper.DemoMapper.selectById前一种说明连Mapper代理Bean都没注册进去,后一种说明Mapper接口注册了,但XML映射文件和接口没有正确绑定。这次的场景属于前一种,问题就出在“接口根本没有被Spring容器感知”。
1.2 为什么说影响范围不止一次启动失败
可能有人觉得,启动失败重启一下、改改配置就好了,不值得上纲上线。但在实际工程里,这种问题的影响面远比表面看到的要广。
首先是本地开发效率。配置类、扫描路径、启动类结构稍微不一致,就会浪费大段时间去排查。尤其是多人协作的项目,每个人本地的IDE设置、Maven仓库状态都不一样,问题可能只在一部分人机器上复现,搞得大家互相怀疑谁的代码有问题。
其次是CI/CD流水线。打包过程不报错,但部署后应用无法启动,或者启动后接口调用立即失败,这种问题往往在自动化测试阶段才暴露。一旦流水线里的冒烟测试覆盖不到Mapper调用,问题就会一路带到生产环境。生产环境应用启动失败,影响的就是所有依赖这个服务的调用方。
另外,这类问题还容易和Spring Boot版本、MyBatis Starter版本产生联动。新版本里自动配置类的包名变了、扫描注册器的行为变了,再加上多模块的包路径设计不合理,问题会变得非常隐蔽。这也是为什么我建议遇到启动报找不到Mapper的情况,不要急着补注解,而是先把扫描机制理解透。
1.3 初步判断:代码和配置看起来都没问题
我先复述一下这次排查前的“案发现场”。启动类写在demo-service模块里,包名是com.demo.service,代码长这样:
package com.demo.service; @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }Mapper接口在demo-dao模块里,包名是com.demo.dao.mapper:
package com.demo.dao.mapper; public interface DemoMapper { DemoEntity selectById(@Param("id") Long id); }为了让MyBatis认识这个接口,我还在接口上加过@Mapper注解,也在启动类上试过@MapperScan("com.demo.dao.mapper"),但同样没能解决问题。这就很反常了,理论上这两种方式都能把Mapper注册进容器,怎么都不生效?
排查了依赖,demo-service/pom.xml里确实引入了demo-dao的依赖,代码里也能import到Maper接口,说明编译阶段没问题。XML文件的位置也检查过,在demo-dao/src/main/resources/mapper/下,文件内容、namespace也对得上。一切看起来都正常,但启动就是报找不到Bean。
然后我意识到了一个很关键的点:依赖引入了、代码编译过了,不代表Spring在运行时会真的去扫描demo-dao这个模块下的包。Spring的扫描范围和Maven的依赖范围是两套逻辑,前者靠注解和包名约定,后者靠classpath和jar包。很多人在这一步就开始原地打转,问题其实是出在“扫描范围”上。
2. 排查过程:从怀疑配置到理解扫描机制
2.1 第一步:先从依赖入手,而不是急着改代码
有段时间我一遇到扫不到Mapper的问题,第一反应就是给启动类加各种注解。后来发现这样做经常是在碰运气,正确做法是先把依赖链路理清楚。
我先在demo-service模块下执行了依赖树命令:
mvn dependency:tree -Dincludes=com.demo:demo-dao输出里确认了demo-dao确实以依赖形式进入了运行classpath。这一步很关键,如果依赖都没进来,后面的一切排查都是徒劳的。
接着检查了demo-dao模块打包出来的jar包内容:
jar tf target/demo-dao-1.0.0.jar确认com/demo/dao/mapper/DemoMapper.class和mapper/DemoMapper.xml都在。这一步排除了“代码没打进去”的嫌疑。很多时候多模块开发只执行了mvn clean compile,没有执行mvn clean install,导致本地仓库里的demo-dao还是旧版本的jar包,代码改了但打包出来的东西没变,也会引发类似问题。
如果依赖和jar包都正常,那就说明问题大概率出在Spring的组件扫描机制上。
2.2 第二步:我把Spring Boot的扫描逻辑重新捋了一遍
必须承认,很多人包括我自己,对@SpringBootApplication的理解停留在“加了这个注解就能启动”的层面,没有仔细想过它背后干了什么。实际上这个注解是一个组合注解,核心是@EnableAutoConfiguration、@ComponentScan和@Configuration。
最关键的就是@ComponentScan。它的默认规则是:以被标注类所在的包作为基准包,扫描这个包及其所有子包下的组件。也就是说,启动类在com.demo.service,Spring默认会去扫描com.demo.service层级下的所有类,把它交给容器管理。
那么问题就来了:DemoMapper接口在com.demo.dao.mapper包下,跟com.demo.service不是一个层级,甚至不在同一个模块里。Spring默认扫描根本不会走到那里去,所以Mapper接口无论加不加@Mapper注解,都不会被容器发现。
有人可能会反驳:@Mapper注解不是用来标记MyBatis接口的吗?加了它不就应该被处理吗?
这就涉及MyBatis Starter的运作细节了。mybatis-spring-boot-starter里有一个自动配置类MybatisAutoConfiguration,它通过@Import(AutoConfiguredMapperScannerRegistrar.class)来扫描Mapper接口。重点在于,这个扫描器并不是扫描整个classpath,而是使用AutoConfigurationPackages.get(beanFactory)获取到的包集合。这个包集合来源于注册到容器中的、被@SpringBootApplication修饰的启动类所在包。
换句话说,自动扫描器扫描的范围,还是跟随启动类所在包走的。DemoMapper在默认范围之外,@Mapper注解就不会被处理。只有手动的@MapperScan可以指定额外的扫描路径,强制把com.demo.dao.mapper纳入扫描列表。
2.3 第三步:根因定位靠的是逐个场景验证
为了确认是不是扫描路径的问题,我做了几个小实验。
第一个实验:把启动类上的@SpringBootApplication换成显式的@ComponentScan,指定包含com.demo.dao的完整包路径。启动后问题依旧,但仔细一看,原来我写成了这样:
@ComponentScan(basePackages = {"com.demo.service"})这等于把默认扫描范围重新限定回了com.demo.service,反而削弱了扫描范围。所以这里有一个很重要的细节:一旦显式声明了@ComponentScan,默认的“启动类包及其子包”规则就会失效,必须把所有需要扫描的包全部写全。写少了,原本能扫到的Service、Controller也可能跟着消失。
第二个实验:在启动类上同时加上@MapperScan("com.demo.dao.mapper")。这一次启动成功了,Mapper Bean能正常注入了。这说明问题就是Mapper接口没被手动扫描注册,和XML配置、namespace都没关系。
第三个实验,也是让我印象最深的:我把Mapper接口上的@Mapper注解删掉,只保留@MapperScan,启动依然正常。反过来,取消@MapperScan但保留@Mapper注解,启动直接失败。这个对比把机制解释得很清楚了:在多模块工程里,想靠@Mapper注解让MyBatis找到接口,前提是接口本身得处在Spring的默认扫描范围内;一旦不在,@Mapper根本不会被MyBatis的自动配置扫描器看见。真正可靠的做法,还是用@MapperScan手动指定包路径。
2.4 顺手看了一眼自动配置报告
排查过程中我还用了Spring Boot提供的AutoConfiguration Report,这个工具对定位类似问题极其有用。
在启动参数里加上--debug,或者直接配置debug=true,控制台就会输出一份条件评估报告,里面有Positive matches和Negative matches。重点看Negative matches里和MyBatis相关的部分,比如:
AutoConfiguredMapperScannerRegistrar Did not match: - @ConditionalOnBean did not find any beans of type org.mybatis.spring.mapper.MapperFactoryBean这段日志说明MyBatis自动配置尝试去扫描Mapper,但因为找不到基准包或者没有额外指定的扫描包,最终跳过了自动扫描逻辑。看到这个基本就能锁定方向,不用再猜来猜去了。
3. 解决方案:三种可落地的处理方式
3.1 方案一:启动类上指定@MapperScan(最直接有效)
这是最推荐的做法,改动量小,效果立竿见影。在启动类上增加注解,明确告诉MyBatis要去哪些包下面找Mapper接口:
@SpringBootApplication @MapperScan("com.demo.dao.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }如果Mapper散布在多个包下面,可以写成:
@MapperScan({"com.demo.dao.mapper", "com.demo.module1.mapper", "com.demo.module2.mapper"})这里有几个细节需要注意:
@MapperScan扫描的是接口,不是XML文件,XML文件的位置单独靠mybatis.mapper-locations配置控制。@MapperScan和接口上的@Mapper注解可以同时存在,不会冲突,重复扫描同一个接口也不会有问题,MyBatis内部会做去重处理。@MapperScan除了指定包路径,还可以指定sqlSessionFactoryRef、sqlSessionTemplateRef等参数。当工程里配置了多个数据源时,每个数据源对应一个SqlSessionFactory,这时候@MapperScan必须绑定对应的SqlSessionTemplate,否则Mapper会找准了包但用错了数据源。
我当时就是用了这个方案,加上之后启动一次通过。
3.2 方案二:统一包结构,让默认扫描就能覆盖到
如果不想到处加注解,可以考虑从包结构上根治问题。核心思路是:把启动类所在包作为所有子模块包的共同祖先。
比如启动类放在com.company.project包下,那所有模块的包名都从这个祖先包往下分:
com.company.project.dao (demo-dao模块) com.company.project.service (demo-service模块) com.company.project.common (demo-common模块)这样Spring Boot默认扫描com.company.project时,天然会覆盖到每个子包,即使不写@MapperScan,Mapper接口只要在com.company.project.dao包下,加上@Mapper注解就能被自动配置扫描器发现。
但这个方法有个前提:模块之间的包名设计必须从项目的第一天就统一规划。如果项目已经跑了很多年,各模块包名各自为政,想靠改包结构来解决问题,迁移成本会非常大,而且很容易改出新的问题。这种方案更适合新项目启动时制定规范,或者小范围内的结构调整。
3.3 方案三:模块内显式配置与依赖管理双管齐下
还有一种场景,就是启动类不愿意写太多的@MapperScan,或者不想把所有Mapper包都暴露出来。这时候可以在demo-dao模块内部自己定义一个配置类,把扫描工作收敛到数据访问层:
package com.demo.dao.config; @Configuration @MapperScan("com.demo.dao.mapper") public class DaoAutoConfiguration { }然后在demo-service模块的启动类上,把这个配置类导入进来:
@Import(DaoAutoConfiguration.class) @SpringBootApplication public class DemoApplication { // ... }这样做的好处是:扫描逻辑跟着数据访问模块走,业务模块只需要依赖demo-dao并引用它提供的配置即可。如果以后Mapper迁移到别的包,只需改DaoAutoConfiguration里的@MapperScan路径,不需要去改启动类。
同时要检查demo-dao模块的pom.xml,确保它被正确安装到本地仓库并由其他模块引用:
<dependency> <groupId>com.demo</groupId> <artifactId>demo-dao</artifactId> <version>1.0.0</version> </dependency>如果demo-dao是私有模块,最好把版本号统一托管在父POM的<dependencyManagement>里,避免各模块引用时版本漂移。
3.4 方案选型对比:不是每一种都适合所有项目
我用一张表把三种方案的核心差异列出来,方便参考:
| 方案 | 改动位置 | 适用场景 | 推荐度 |
|---|---|---|---|
| 启动类加@MapperScan | 启动类 | 已有项目快速修复,Mapper包数量少 | 高 |
| 统一包结构 | 全模块包名 | 新项目规划阶段,或小范围重构 | 中 |
| 模块内配置类+@Import | dao模块新增配置类 | 模块边界清晰,希望收敛扫描逻辑 | 高 |
实际操作中,我建议优先使用方案一,理由很朴素:改动最小,风险最低。方案二需要协调所有模块的包名调整,在团队协作中很容易引发冲突。方案三适合那些架构上已经做了模块化治理的团队,看起来干净,但对项目规范要求较高。
注意:不论采用哪种方案,都需要保持XML文件和接口之间的映射关系正确。
mybatis.mapper-locations配置决定了XML加载路径,我习惯统一设置成classpath*:mapper/*.xml,注意前面的classpath*:必须带星号,表示从所有依赖jar包中搜索mapper目录下的XML文件。漏掉这个星号,多模块下很容易出现“接口找得到,XML找不到”的问题。
4. 常见问题速查与进阶实战建议
4.1 类似问题的排查速查表
这次排查之后,我整理了一份速查清单,按优先级排序,遇到同类问题直接照着过一遍:
| 现象 | 可能原因 | 排查要点 |
|---|---|---|
| 启动报NoSuchBeanDefinitionException | Mapper接口未被扫描注册 | 检查@MapperScan路径是否覆盖接口包;检查接口包是否在启动类默认扫描范围内 |
| 启动报Invalid bound statement | 接口与XML未配对 | 检查XML文件名、namespace、statement id是否与接口一致;检查mapper-locations配置 |
| 编译报错找不到Mapper接口 | 依赖未引入 | 检查pom.xml依赖坐标;执行mvn dependency:tree查看依赖树 |
| 代码改为依赖未更新 | 本地仓库还是旧jar | 执行mvn clean install重新安装依赖模块 |
| 接口能找到但方法无法调用 | XML与接口方法签名不匹配 | 对比方法名、参数类型、返回类型;重新生成XML或用IDE的MyBatis插件校验 |
| 多数据源场景下Mapper串了 | SqlSessionFactory绑定错误 | 在@MapperScan中指定sqlSessionTemplateRef隔离数据源 |
这个表里包含了最常见的问题分布,基本覆盖了我这些年遇到过的90%的Mapper相关启动故障。
4.2 几个容易踩的变种坑
除了上面那种“其他模块扫不到Mapper”的基础情况,我还遇到过几个变种坑,值得单独拎出来说。
第一个是拆分模块后,不同模块里出现了同名的Mapper接口。比如order模块和user模块都有UserMapper,包名还不一样,@MapperScan把两个包都扫进来之后,Spring容器里会存在两个类型具备相同的短类名。如果Service里按接口类型注入,并且两个接口恰好全限定名不同但短类名相同,某些情况下会让人误以为是扫描漏了,实际上是装配歧义。解决办法是避免跨模块短类名重复,或者在注入时使用@Qualifier明确指定Bean名称。
第二个是Spring Boot版本和MyBatis Starter版本组合问题。不同版本的mybatis-spring-boot-starter,包名和自动配置类的位置有过调整。如果你用的版本比较新,但代码里还按旧教程写@MapperScan的包路径,或者引错了MapperScan类,也会出现注解没生效的情况。遇到这种问题,最稳妥的办法是查看当前版本的官方文档,或者直接在IDE里用Shift按两次查类,确认org.mybatis.spring.annotation.MapperScan确实存在于当前依赖的jar包里。
第三个是模块依赖没有传递。比如demo-service模块依赖了demo-dao,而demo-dao依赖了demo-common。如果demo-dao的POM里把demo-common声明成了<scope>provided</scope>,运行时就可能缺失某些类,导致启动过程中MyBatis实例化Mapper失败,报错信息甚至不会直接提到Mapper。这种问题要靠完整堆栈和依赖树一起分析,单独看启动日志很容易误判。
第四个是Mapper接口所在的jar包被构建工具过滤掉了。有些团队配置了maven-jar-plugin,只打包特定包路径下的类。如果过滤列表写的是**/service/**,恰好Mapper接口不在这个范围内,就会导致打进jar包的内容不完整,启动后同样找不到Mapper。检查方法还是回到jar tf,确认jar包里有没有Mapper的class文件。
4.3 进阶:从“扫不到”到“统一处理”的思考
把扫不到的问题解决之后,我又顺手想了一件事:既然Mapper层的扫描和装配已经理清楚了,那能不能在Mapper这一层做统一的横切逻辑?这也是我在实际项目里经常被问到的问题——怎么用面向切面的方式只在mapper层改数据。
比如你有这样一个需求:所有查询Mapper执行前,自动注入租户ID、数据权限条件;或者所有更新Mapper执行后,记录操作日志。这类需求如果都去改每个Mapper方法,太容易漏了,而且还很难维护。
最直接的做法是写一个AOP切面,把切入点限定在Mapper接口层:
@Aspect @Component public class MapperAspect { @Around("execution(* com.demo.dao.mapper.*.*(..))") public Object aroundMapper(ProceedingJoinPoint joinPoint) throws Throwable { // 方法执行前统一处理参数、权限上下文等 Object result = joinPoint.proceed(); // 方法执行后统一处理结果 return result; } }这个切面生效的前提,依然是Mapper接口必须已经注册成Spring容器里的Bean,否则AOP代理根本找不到目标对象。所以前面排查扫描问题的结论,在这里也有意义:只有保证了Mapper能被正常扫描和装配,后续的AOP增强、拦截器、数据权限过滤才有施展的空间。
如果你的需求更偏向底层SQL层面,比如拦截某些查询自动追加过滤条件,那么比起AOP,更合适的方案是实现MyBatis的Interceptor接口,在Executor层面统一处理。这个原理上比AOP更贴近SQL强账单,但这篇文章就不展开了,知道有这个路径就行。
4.4 最后说点实在的经验
踩过几次这种“扫不到Mapper”的坑之后,我总结出几条受益很深的经验,供你参考。
第一,多模块工程的组件扫描规则应该被当成架构规范写下来,而不是当作一个人人靠猜的记忆。新模块加入时,先看新模块的包路径和启动类所在包的上下级关系,再决定是在启动类里写扫描注解,还是把扫描逻辑收在模块内部。架构评审阶段多花几分钟,能省下后面一大片排查时间。
第二,MyBatis相关配置尽量收敛,不要散落到多个配置类里。我见过有些项目启动类上挂着一堆扫描注解,Controller包也扫一遍、Service包也扫一遍、Mapper包也扫一遍,导致后续的人根本不敢动启动类。可以的话,把Mapper的扫描配置单独拎到一个配置类里,保持启动类干净。
第三,依赖版本统一交给父POM管理。把demo-dao、demo-common、mybatis-spring-boot-starter的版本都放在<dependencyManagement>中,子模块只声明依赖坐标,不写版本号,能从根本上避免模块间的版本冲突。
第四,遇到类似问题不要急着改配置,先把mvn dependency:tree、jar tf、自动配置报告这几个工具跑一遍。正确率比直觉高得多。有时候问题不在扫描注解,而在底层依赖根本没有进入运行环境。
最后再分享一条小技巧:启动类所在包尽量设计成所有模块包路径的顶层父包,哪怕后续模块再多,Spring Boot的默认扫描总能覆盖到,很多看似玄学的Bean找不到问题都会自动消失。这条规则,我和团队实测下来,对减少多模块工程的同类故障非常有帮助。