记一次扫不到其他模块mapper的问题排查先说结论这一次的坑坑在“包扫描路径”上但根子却在“模块依赖”和“配置文件加载顺序”这两件看起来毫不相干的事情上。事情是这样的一个多模块的Spring Boot工程结构大致是parent下面挂着common、business、web三个子模块。business模块里放着业务逻辑和Mapper接口web模块负责启动和接口暴露。之前一直跑得好好的某天新加了一个report模块把一部分报表相关的Mapper放到了report模块里顺手在web模块的启动类上增加了MapperScan的扫描路径结果一启动就发现report模块下的Mapper一个都扫不到。报错信息很经典Invalid bound statement (not found)或者直接No qualifying bean of type ...。看一眼就头大因为这种问题往往是“配置看起来没错代码看起来也没错但就是跑不起来”。1. 扫不到Mapper的第一个怀疑方向路径配错遇到扫不到Mapper大多数人第一反应都是检查MapperScan的包路径。我这次也不例外打开启动类反复看了好几遍basePackages写得和report模块的包名一模一样甚至把路径从com.xxx.report.mapper改成com.xxx.report.**.mapper再试一次还是扫不到。这里先说一个容易被忽略的点MyBatis的MapperScan扫描逻辑实际上注册的是MapperFactoryBean。它会在指定包路径下找接口类然后给每个接口生成代理对象最终注册成Spring容器里的Bean。这个过程中有一个关键机制——扫描依赖的是ClassPathMapperScanner它调用的是Spring底层的ClassPathScanningCandidateComponentProvider。后者扫描的是“类路径资源”也就是说它只能扫到当前运行时classpath里真实存在的类。所以路径字符串本身没问题不代表扫描就一定能成功。路径写得再对如果对应的类不在classpath里那也是白搭。我后来专门去web模块的构建产物里翻了一下target/classes下面根本没有report/mapper目录的影子。到这一步问题才真正开始清楚不是扫描配置错了是这个模块压根没被依赖进来它的类根本没进到最终的classpath。1.1 模块依赖缺失的隐蔽性多模块项目里模块A引用模块B最常见的方式当然是在A的pom.xml里加一个dependency。但问题是很多人加了依赖之后因为mvn compile成功、启动也不报“类不存在”的错误就会忽略一个问题——依赖的scope对不对、是不是optional、有没有被传递依赖给“吞掉”。比如这次web模块确实加了report模块的依赖但是用的是optionaltrue。在这个项目里optional意味着“这个依赖不会被传递”。平时开发调试没什么问题但一旦涉及到某些打包插件或者最终启动时的classpath组装optional依赖可能并不会出现在真正需要它的地方。更隐蔽的是web模块是通过business模块间接依赖report的当时为了图省事没有直接加依赖结果某个环境构建时传递依赖断链了类就“消失”了。排查这种问题别靠猜。最快的办法是直接在IDE里打开依赖图或者在启动类旁边写一段临时代码打印classpath再或者用mvn dependency:tree看依赖关系。手头没有IDE环境时直接看构建产物更实在——web模块的target/classes里有没有report的Mapper接口一看便知。2. 真正的问题往往不在扫描器而在Bean定义确认类确实不在classpath后我就去补了依赖。补完重新构建再次启动结果还是扫不到。这就有点意思了。然后我开始重点怀疑MapperScan是不是“只扫到了部分接口”。用MyBatis-Spring这套组合的人应该都知道MapperScan的底层其实会注册一个MapperScannerConfigurer的Bean定义而且它注册的时机非常早在Spring容器的BeanDefinitionRegistryPostProcessor阶段就会执行。这个阶段有个坑如果你在启动类上同时用了MapperScan和Configuration但某些配置类里又定义了一些和数据源、事务相关的Bean这些Bean的初始化顺序有时候会和扫描过程产生微妙的关系。不过这次真正让我卡住的是report模块的Mapper接口上加了Repository注解。这本来是好习惯让Spring在组件扫描的时候也能把它们当作Bean处理。坏就坏在这个模块的Mapper接口位于包com.xxx.report.mapper下而web模块的启动类SpringBootApplication里自带的ComponentScan扫描范围是com.xxx.web。如果只靠MapperScan去处理Mapper的Bean注册那业务上没问题但如果某处配置把MapperScan的路径覆盖了或者把全局的mapper-locations配置指向了别的位置Spring就会产生歧义。具体表现是report模块的Mapper接口一方面被MapperScan尝试注册另一方面因为Repository注解又被组件扫描器扫到。这时候如果两个扫描器都生效有概率产生“同一个接口被注册了两次”的兼容性问题虽然不一定报错但是另一部分Mapper比如business模块的会被漏掉。我把report模块Mapper上的Repository去掉只保留MapperScan统一管理问题竟然就解决了。这是个比较冷门但容易踩的点建议所有用MyBatis的人注意不要重复使用多种Mapper注册机制同类冲突有时候不会立刻报错它只会悄悄让你“扫不到”。2.1 关于工厂Bean与Mapper接口代理的细节再往深挖一层。Mapper接口能被注入到Service里靠的是MapperFactoryBean给每个接口生成代理。MapperFactoryBean有一个checkDaoConfig方法启动时会校验Mapper接口对应的XML命名空间是否存在。如果你的Mapper是“纯注解模式”接口上直接用Select、Insert等没有XML那还可以绕过去。但如果是“XML模式”必须保证mapper-locations里配置的路径和实际XML文件位置一致并且XML的namespace必须和接口全限定名一致。这次我虽然补了模块依赖、去了重复注解但还发现自己踩了一个更常见的问题report模块的XML文件放在src/main/resources/mapper/report/目录下而全局配置里写的是classpath:mapper/**/*.xml。按理说能匹配到但因为report模块在最终打包时resource目录下的Mapper XML没有被包含进去。原因很搞笑——report模块的打包配置里没有配置resources段默认只打包src/main/resources下所有资源但项目里之前有人改过父工程的resources规则设了include排除规则把XML给排掉了。这个问题不实际看一下构建产物很难发现。所以排查顺序很关键先看接口类有没有进classpath再看XML有没有进classpath最后才看扫描配置。顺序反了容易被“配置看起来没问题”给带偏。3. 从“扫不到”到“定位根因”的完整排查路径说了这么多我把这次完整的排查路径整理一下。基本上多模块项目里遇到扫不到Mapper按下面这个顺序走一遍绝大多数问题都能暴露。第一确认报错形态。是Invalid bound statement还是No qualifying bean还是启动时MapperScan警告不同报错对应不同方向。No qualifying bean偏向Bean没有注册成功而Invalid bound statement则是Bean有了但Mapper方法对应的SQL找不到也就是XML或者注解没有绑定上。第二拉构建产物。这一步最简单直接去最终启动模块的target/classes目录下用find命令或者直接在IDE里右键打开看两个东西Mapper接口的.class文件在不在Mapper XML文件在不在。如果不在一会儿就建索引项目多的时候很乱。我个人的习惯是直接在项目根目录跑一句mvn clean package -DskipTests然后用jar tf去看最终打的jar包里都有什么。比如jar tf web.jar | grep report结果里如果有com/xxx/report/mapper/ReportMapper.class说明类进去了没有就回头看依赖。同理XML用类似方式检查jar tf web.jar | grep report.*xml这一步能过滤掉一半的假“扫不到”问题。第三查依赖关系。确认了类没进classpath之后就去查依赖树。多模块项目建议直接看web模块的依赖图重点看三处report模块有没有被直接依赖、依赖的scope是什么、有没有被optional标记。另外注意检查是不是被某个聚合模块给剔除了。用dependency:tree时配合-Dincludes参数可以快速过滤mvn dependency:tree -Dincludescom.xxx:report第四验证扫描配置。如果类都在、XML也都在剩下的就是MapperScan或Mapper相关配置的问题。这里建议把“扫描器”统一成一个入口。最推荐的做法是启动类上只用MapperScanMapper接口上一律不加Repository、不加Component、不加Mapper。避免重复注册。MapperScan本身足够完成所有工作多写反而引入不确定性。第五检查MyBatis全局配置。这里要特别留意mapper-locations和type-aliases-package。mapper-locations如果配的是classpath:mapper/*.xml那只匹配根目录下的一层如果XML在嵌套目录里要写成classpath:mapper/**/*.xml。type-aliases-package同理多模块下建议配成父级包名比如com.xxx而不是某个具体的模块包名否则别名解析可能漏掉其它模块。3.1 配置踩坑速查表下面这个表是我整理的多模块场景下常见配置坑按发生频率排序现象可能原因快速验证方式启动报No qualifying beanMapperScan路径没覆盖到对应包打印applicationContext.getBeansOfType(MapperFactoryBean.class)启动报Invalid bound statementXML缺失或namespace不匹配检查jar tf中XML文件再核对namespace和接口全限定名部分Mapper是Bean部分不是同时用了Mapper和MapperScan统一去掉接口上的Mapper注解新模块Mapper扫不到模块依赖缺失或optionaltruemvn dependency:tree查依赖XML找不到/SQL异常resource过滤/打包插件排除jar tf查产物检查父工程resources配置服务启动成功但调用时报空指针/代理异常出现了同包名同接口的重复定义IDE依赖图查看是否有两个版本模块同时存在表格里最后一条也值得多说一句多模块项目里如果A模块和B模块都定义了包名相同的类而且两个模块同时被引用最终classpath里只保留一个取决于构建顺序很容易出现“本地是对的服务器上是错的”这类诡异现象。这种问题靠配置解决不了只能消歧。4. MyBatis扫描机制的底层逻辑顺便讲透很多人对MapperScan的理解停留在“扫一下包路径就行”的层面实际它内部做的事比较复杂。整个流程大概是Spring容器启动时MapperScannerConfigurer作为一个BeanDefinitionRegistryPostProcessor会在标准Bean扫描之前注册一批候选的Mapper接口。它把每个Mapper接口包装成一个BeanDefinition然后把beanClass替换为MapperFactoryBean并设置构造参数为Mapper接口类型。之后容器创建Bean时MapperFactoryBean就会用MyBatis的SqlSessionTemplate给接口生成代理。理解这个机制后很多问题都能从理论上反推。比如MapperScan和Mapper混用实际是两波扫描器在抢着注册同一批接口虽然Spring有去重机制但不同扫描器的BeanDefinition元信息不同有些场景下就会导致后注册的覆盖先注册的而覆盖后可能丢失了某一部分参数比如sqlSessionTemplateRef。这也是为什么我会强烈建议“一个项目只保留一种Mapper扫描方式”。另外一个和“扫不到”强相关的点MapperScan支持多个路径写在一个数组里比如MapperScan(basePackages {com.xxx.business.mapper, com.xxx.report.mapper})但如果你写的是MapperScan(basePackages com.xxx.*.mapper)那是无效的。ClassPathMapperScanner不支持这样通配多个包。它支持的是Spring的basePackages多个字符串数组以及basePackageClasses就是指定一个类取这个类所在的包作为扫描起点不支持*号通配多级。很多人在这里栽跟头花了不少时间。4.1 使用basePackageClasses替代字符串路径有一个比较能规避这类问题的技巧就是用basePackageClasses。比如在report模块里定义一个空的标记接口或者标记类然后在启动类的MapperScan里指定MapperScan(basePackageClasses ReportMapperMarker.class)这样做的好处是不依赖字符串拼写包路径在编译期就能校验而且如果以后重构包名编译器会直接提示不会出现路径写错扫不到的问题。坏处是每个模块得多建一个标记类稍微啰嗦但换来的是可靠性我认为值得。5. 如何彻底避免“扫不到Mapper”问题排查这次问题之后我给自己定了几条规矩以后凡是多模块项目都按这个标准来。第一模块依赖只保留一种方向外层模块直接依赖所需模块不指望传递依赖。Maven的传递依赖虽然有但在多模块复杂项目里传递依赖经常被optional、provided、exclusion影响指望它是不可靠的。启动模块需要什么就直接声明什么。第二Mapper接口的注册统一走MapperScan接口上任何其它Spring注解都不加。MapperScan是专门为MyBatis设计的扫描器它处理了MapperFactoryBean、SqlSessionTemplate注入等逻辑比ComponentScan配合Mapper注解的方式更可控。第三全局配置里mapper-locations默认写classpath*:mapper/**/*.xml。注意这里我写的是classpath*:而不是classpath:区别在于classpath:只能匹配第一个classpath目录classpath*:会扫描所有依赖JAR包里的匹配资源。多模块场景下Mapper XML可能散落在不同模块的JAR里用classpath*:才能一网打尽。第四凡是新加模块第一时间检查最终启动模块的构建产物。不要等到启动报错再来查直接在target/classes或者jar包里看类在不在、XML在不在三十秒就能确认的事不要让它变成一小时的事故。第五如果项目里配置了MapperScan就不要再用mybatis-plus之类的框架时同时开它的MapperScan或者Mapper同类框架的注解机制不同扫描器也不同重复注册时的行为差异更难预判。6. 几个特别值得留意的边界场景排查过程里还发现了一些容易忽略的边界情况这里单独列一下。第一种是Spring Boot的配置中心场景。有些项目会把mapper-locations放到nacos或apollo里启动时配置中心还没完全加载MyBatis的配置就已经被初始化了。这种时序问题导致的“扫不到”往往换个本地配置就好但一接配置中心就挂。解决办法是确保MyBatis配置依赖于配置中心的ConfigurationProperties而不是在application.yml里用${}占位符直接引用。如果引用了占位符请确保配置中心的加载优先于MyBatis自动配置。第二种是单元测试里扫不到Mapper。很多人只在启动类上配置了MapperScan但单元测试用的是Test上下文它不会加载启动类。这时候如果MybatisTest或者SpringBootTest没有显式指定MapperScan测试里就会报扫不到。推荐在测试类上加上Import或者写上MapperScan或者直接让测试类继承主启动类的配置。第三种是切面AOP场景。最近网上也常有人问“怎么用面向切面的方式只在mapper层改数据”这个和扫不到其实有关联如果你要对Mapper的方法做切面前提是这个Mapper Bean必须存在而且切面要能代理到MapperFactoryBean生成的代理对象上。切面上的pointcut如果写的是execution(* com.xxx.report.mapper.*.*(..))一旦Mapper本身没扫进容器切面再怎么对也不会有任何效果。所以先解决“扫得到”的问题再谈“切得到”。如果切面本身没有生效优先确认两件事EnableAspectJAutoProxy是否开启Spring Boot默认开启但可被覆盖以及切面类是否在ComponentScan覆盖的包路径下。第四种是动态数据源场景。使用了DS注解或者手动切数据源的路由多个SqlSessionTemplate并存时MapperScan上如果有sqlSessionTemplateRef指定了名字但实际配置里另一个数据源的SqlSessionTemplate名字写错了也会导致Mapper虽然扫到了但初始化时报错。这种情况下报错信息通常是Property sqlSessionFactory or sqlSessionTemplate are required。7. 排查工具的推荐与实战用法排查这类问题有几个工具用好了效率会翻倍。第一个是Spring Boot的/actuator/beans端点。如果你的应用已经起了但功能异常直接GET这个端点看看reportMapper这个Bean在不在beanType是什么依赖了哪些其它Bean。如果Bean都没有直接说明扫描注册阶段就失败了。第二个是IDE里的Diagrams依赖图。IntelliJ IDEA在pom.xml右键选Diagrams-Show Dependencies可以直观看到模块间依赖关系。遇到“感觉依赖了实际没有”的情况这张图比任何文档都清楚。第三个是mvn dependency:tree配合grep前面提过不再重复。第四个是arthas的sc命令。如果应用已经跑起来用sc com.xxx.report.mapper.ReportMapper可以查看这个类是否被加载以及由哪个ClassLoader加载的。如果输出结果是Affect(row-cnt:0)说明类根本没进入JVM直接往前端模块依赖查即可。这一轮排查下来我的最终结论是绝大多数“扫不到Mapper”都不是扫描器的问题而是“这个类在不在classpath里”的问题。先把产物、依赖、资源这三件事查清楚再去调MapperScan、调包路径、调配置顺序不能反。顺序一错不仅浪费时间还会把自己绕进“配置玄学”里出不来。后来我把项目中report模块的包装DB配置重新梳理了一遍全部统一为所有Mapper接口集中在各模块的xxx.mapper包下由启动模块统一声明MapperScanXML放在各模块src/main/resources/mapper/目录下配置用classpath*:mapper/**/*.xml。从那次之后这个项目再没出现过“扫不到Mapper”的问题。如果哪天你也被这个问题折磨不妨按我上面的思路一层层剥。你可能会发现问题并不复杂只是坑点比较隐蔽罢了。