在Spring项目里写MyBatis大家最熟悉的一行代码大概就是启动类上那个MapperScan(com.xxx.mapper)。加了这个注解Mapper接口就能直接注入Service里用不用写实现类不用手动new仿佛一切都被安排得明明白白。用得越顺手就越容易忽略它背后到底是怎么工作的。如果你只是停留在会用层面这一篇可能没必要看但如果你想把MyBatis和Spring两套框架的协作机制弄明白MapperScan就是最值得拆的那个入口。这篇文章会沿着一条完整链路讲清楚先回顾不用Spring时MyBatis怎么用再讲Spring集成解决了哪些痛然后一行一行拆MapperScan的源码从Import到ImportBeanDefinitionRegistrar从ClassPathMapperScanner到MapperFactoryBean最后落到MapperProxy动态代理把接口没有实现类却能注入、能执行SQL这件事彻底说透。文章后面还会整理几类实际开发中非常常见的踩坑场景比如扫描不到Mapper、多数据源冲突、分页插件失效等。适合对MyBatis和Spring IoC有一定使用经验、想了解底层原理的Java后端开发者。1. 先说结论MapperScan到底替你做了什么先给一个整体印象MapperScan的本质是扫描指定包下的Mapper接口把每个接口都注册成Spring容器里的一个Bean但这个Bean的实际形态是一个代理对象由MapperFactoryBean负责创建最终通过JDK动态代理拦截方法调用路由到SqlSession执行SQL。这三步拆开看扫描ClassPathMapperScanner在指定包下面找接口过滤出符合条件的Mapper接口。注册把每个接口的BeanDefinition改造成MapperFactoryBean的BeanDefinitionSpring在创建Bean时调用MapperFactoryBean.getObject()。代理getObject()返回的是MapperProxy的JDK代理调用Mapper接口方法时代理会解析方法对应的SQL交给SqlSession执行。用一句话说清楚MapperScan不是把接口直接放进容器而是给Spring递了一张接口名片让Spring在需要用的时候通过MapperFactoryBean这个工厂生成一个代理来完成实际工作。我习惯把这条链路类比成公司招人Mapper接口只是候选人的简历ClassPathMapperScanner是猎头负责筛简历MapperFactoryBean是HR负责安排面试、办理入职MapperProxy就是正式上岗的员工真正干活的是它背后的SqlSession。Spring容器里自始至终没有实现类这个东西容器知道的只是你注入UserMapper时我给你一个代理对象这个代理能完成你要的数据库操作。这个机制理解起来其实不复杂但它依赖Spring的几个关键扩展点Import、ImportBeanDefinitionRegistrar、FactoryBean、BeanDefinitionRegistryPostProcessor。这些概念单独看都容易懂合在一起就成了很多人眼中的魔法。所以这篇文章后面的几个章节我会把这条链路上的每个环节都用实际代码摆出来一步步拆。2. 从原生MyBatis到Spring集成一条不得不走的路2.1 原生MyBatis的使用方式长什么样在没有Spring的年代用MyBatis写一个查询流程大致是固定的String resource mybatis-config.xml; InputStream inputStream Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory new SqlSessionFactoryBuilder().build(inputStream); try (SqlSession session sqlSessionFactory.openSession()) { UserMapper mapper session.getMapper(UserMapper.class); User user mapper.findById(1L); System.out.println(user); }这段代码暴露了三个问题每次都要重复构建流程即使SqlSessionFactory只需要创建一次也必须自己管理它的生命周期。SqlSession不是线程安全的它不能被多个线程共享所以每个线程都得拿一个独立的SqlSession用完还得关闭。业务代码和会话管理耦合服务层每个方法都要写开session、close的样板代码代码重复度高还容易漏关连接。这就像你去饭店吃饭每让服务员上菜一次都得亲自跑一趟后厨拿菜、端菜、洗盘子。开发效率自然上不去。2.2 Spring集成要解决的核心问题MyBatis和Spring集成后主要解决了三件事SqlSessionFactory单例化交给Spring容器管理整个应用只维护一个SqlSessionFactory。会话线程安全用SqlSessionTemplate包装SqlSession它是线程安全的可以在Service层注入使用内部自己管理session获取和关闭。Mapper接口Bean化通过动态代理把Mapper接口直接注册成Spring BeanService里Autowired进来直接用。所以Spring集成MyBatis后上面的原生代码会变成Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper userMapper; } public User getUserById(Long id) { return userMapper.findById(id); } }没有SqlSession管理没有close()也没有手动拿Mapper。这些脏活累活都被框架吞掉了。2.3 传统Spring XML配置和注解方式的区别在Spring纯XML时代集成MyBatis需要在XML里注册两个关键Beanbean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.demo.mapper/ /beanSqlSessionFactoryBean负责生产SqlSessionFactoryMapperScannerConfigurer负责扫描Mapper接口并把它们注册成Bean。注意MapperScannerConfigurer实现了BeanDefinitionRegistryPostProcessor它会在Spring的Bean定义加载阶段主动往容器里塞Mapper定义。MapperScan正是把这个XML配置的语义浓缩成了一个注解。两者做的事情本质相同只是从XML配置换成了注解加Java配置。这也是为什么后来Spring Boot项目中很少再见到MapperScannerConfigurer的身影因为MapperScan一行就搞定了。2.4 Mapper和MapperScan的关系与选择提到MapperScan经常有人把Mapper和它搞混。Mapper是MyBatis提供的注解标注在单个Mapper接口上告诉扫描器我也是候选接口。MapperScan是让Spring主动扫描某个包下的所有接口批量注册。如果你不用MapperScan就得在每个Mapper接口上加Mapper。但加Mapper只是标记接口实际触发扫描的还是Spring Boot的自动配置或MapperScannerConfigurer。换句话说Mapper是给扫描器看的筛选条件MapperScan是发起扫描的动作。实际开发中我通常只选一种方式推荐统一用MapperScan加在配置类或启动类上。这样包结构一目了然新加Mapper接口不用再重复标注某个特定注解少了一些心智负担。3. MapperScan源码拆解从注解到代理的完整链路3.1 注解入口与关键参数先看MapperScan注解本身的定义Import(MapperScannerRegistrar.class) public interface MapperScan { String[] value() default {}; String[] basePackages() default {}; Class?[] basePackageClasses() default {}; Class? extends Annotation annotationClass() default Annotation.class; Class? markerInterface() default Class.class; String sqlSessionTemplateRef() default ; String sqlSessionFactoryRef() default ; Class? extends BeanNameGenerator nameGenerator() default BeanNameGenerator.class; }几个关键参数value/basePackages指定扫描包路径不需要全量包名父包即可扫描会包含子包。annotationClass给定一个注解类型只扫描标了这个注解的接口。markerInterface给定一个标记接口类型只扫描实现了它的接口。sqlSessionTemplateRef/sqlSessionFactoryRef多数据源或多SqlSessionFactory时指定使用哪个实例。重点是开头的Import(MapperScannerRegistrar.class)。这个Import就是整条链路的第一块多米诺骨牌。3.2 Import与ImportBeanDefinitionRegistrar的工作方式Import在Spring里的作用常规理解是用来引入一个配置类或普通组件类。但比起同类Spring还给了它一个更强大的能力导入一个实现了ImportBeanDefinitionRegistrar的类时可以在Spring容器加载BeanDefinition阶段手动向BeanDefinitionRegistry注册任意BeanDefinition。MapperScannerRegistrar正是这么做的。看核心代码public class MapperScannerRegistrar implements ImportBeanDefinitionRegistrar, ResourceLoaderAware { Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { MapperScannerConfigurer configurer new MapperScannerConfigurer(); // 复制注解中的属性到configurer中 configurer.setBasePackage(...); configurer.registerBeanDefinition(registry); } }不同版本实现略有差异但整体思路一致。registerBeanDefinitions有两个关键入参importingClassMetadata能拿到MapperScan注解上的所有属性值registry则提供了往容器注册BeanDefinition的入口。这里需要理解一个重要时序MapperScan标注的配置类Spring在处理配置类时发现Import然后创建MapperScannerRegistrar实例通过ConfigurationClassPostProcessor这个BeanDefinitionRegistryPostProcessor在标准Bean注册流程之前完成Mapper接口的注册。所以Mapper相关的BeanDefinition会早于普通用户Bean被注册这也是它能提前准备代理Bean的前提。3.3 核心扫描器ClassPathMapperScannerMapperScannerRegistrar实际注册的是MapperScannerConfigurer而MapperScannerConfigurer内部真正干活的是一个继承自ClassPathBeanDefinitionScanner的ClassPathMapperScanner。ClassPathBeanDefinitionScanner是Spring提供的通用类路径扫描器正常扫描时是找出类路径下符合条件的候选组件比如Component标注的类。ClassPathMapperScanner重写了关键方法让它专门适配Mapper接口的扫描需求。重写的地方主要是isCandidateComponentOverride protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { AnnotationMetadata metadata beanDefinition.getMetadata(); return metadata.isInterface() metadata.isIndependent(); }注意这段逻辑metadata.isInterface()意味着只有接口才算候选。普通类哪怕加了Component也不会被当作Mapper处理这符合Mapper设计初衷Mapper必须是接口不能是类。同时还需要看setAnnotationClass和setMarkerInterface。如果你在MapperScan里指定了annotationClass扫描器会要求接口上有对应注解如果指定了markerInterface则要求接口实现这个标记接口。默认情况下两个都不指定扫描器会把包下所有接口都当作候选。3.4 processBeanDefinitions把接口变成工厂BeandoScan从类路径下找到候选接口后会生成一组BeanDefinition然后进入processBeanDefinitions方法。这里发生了最关键的偷梁换柱protected void processBeanDefinitions(SetBeanDefinitionHolder beanDefinitions) { for (BeanDefinitionHolder holder : beanDefinitions) { GenericBeanDefinition definition (GenericBeanDefinition) holder.getBeanDefinition(); definition.setBeanClass(this.mapperFactoryBeanClass); definition.getConstructorArgumentValues().addGenericArgumentValue(holder.getBeanName()); definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE); } }这段代码的含义是一个原本描述接口类型的BeanDefinition现在被改造成了工厂类型的BeanDefinition。definition.setBeanClass(MapperFactoryBean.class)表示Spring将来要实例化的对象不是接口而是MapperFactoryBean。同时原接口的名字被作为构造器参数传给MapperFactoryBean。可以把这一步理解成仓库里的货架标签被换掉了原来标签写的是这里放着一袋大米现在改成这里放着一个碾米机里面能出来一袋大米。Spring创建Bean时看到标签是MapperFactoryBean就会调用它的构造器并生成实际对象。还要注意AUTOWIRE_BY_TYPE。这个自动装配模式意味着MapperFactoryBean里声明的依赖属性尤其是SqlSessionFactory或SqlSessionTemplate会根据类型自动注入这也是为什么你在配置里只需要提供数据源和SqlSessionFactory它就能自动配上。3.5 MapperFactoryBean到底创建了什么MapperFactoryBean是SqlSessionDaoSupport的子类同时实现了FactoryBeanT接口。FactoryBean是Spring里专门用来生产Bean的BeanSpring容器会先创建MapperFactoryBean然后调用它的getObject()方法把返回值作为真正的Bean放到容器里。public class MapperFactoryBeanT extends SqlSessionDaoSupport implements FactoryBeanT { private ClassT mapperInterface; public MapperFactoryBean(ClassT mapperInterface) { this.mapperInterface mapperInterface; } Override public T getObject() throws Exception { return getSqlSessionTemplate().getMapper(mapperInterface); } Override public ClassT getObjectType() { return this.mapperInterface; } }看到getObject()调用了getSqlSessionTemplate().getMapper(mapperInterface)这个getMapper方法就指向了MyBatis的动态代理创建逻辑。SqlSessionTemplate.getMapper()内部的实现类似Override public T T getMapper(ClassT type) { return getConfiguration().getMapper(type, this); }Configuration.getMapper()最终调用MapperRegistry.getMapper()通过ProxyFactory创建JDK代理对象public T T getMapper(ClassT type, SqlSession sqlSession) { MapperProxyFactoryT mapperProxyFactory knownMappers.get(type); return mapperProxyFactory.newInstance(sqlSession); }这里产出的就是带InvocationHandler的JDK动态代理对象。也就是说Spring容器里保存的UserMapper本质上是一个代理类实例instanceof UserMapper会返回true但它的内部逻辑全部交给了MapperProxy。3.6 最后一棒MapperProxy方法拦截MapperProxy实现了InvocationHandler代理对象每次方法调用都会进入invoke方法Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { // toString、equals、hashCode等Object方法直接调用不走SQL return method.invoke(this, args); } // 根据Method拿到对应的MapperMethod MapperMethod mapperMethod cachedMapperMethod(method); return mapperMethod.execute(sqlSession, args); }MapperMethod中封装了两个关键对象SqlCommand和MethodSignature。SqlCommand里记录着这条SQL的id和类型SELECT、INSERT、UPDATE、DELETEMethodSignature里记录着参数映射关系和返回类型。在执行阶段MapperMethod.execute()根据命令类型分发到SqlSession的对应方法调用链最终落到JDBC执行器。所以完整的调用路径是这样的Service - UserMapper代理 (MapperProxy) - MapperMethod.execute - SqlSessionTemplate - MyBatis执行器 (Executor) - JDBC - 数据库到这一步MapperScan的整条链路就通了扫描接口、注册工厂Bean、工厂生成代理、代理拦截方法并把请求路由到SqlSession执行SQL。4. 手把手实操Spring Boot MyBatis集成全流程原理说得再多不如亲手搭一个项目看看效果。这一节我会从零配置一个Spring Boot MyBatis项目全程带代码跑通之后再做几个针对性实验来验证前面讲的原理。4.1 依赖准备用Maven做构建创建项目后在pom.xml加入这些依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency /dependencies我用H2内存数据库做演示省去搭建MySQL环境的步骤。实际项目替换成MySQL驱动坐标即可。4.2 配置文件application.yml里配置数据源和Mapper相关参数spring: datasource: driver-class-name: org.h2.Driver url: jdbc:h2:mem:testdb username: sa password: mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: truemapper-locations告诉框架去哪里找XML文件type-aliases-package简化实体类别名map-underscore-to-camel-case能把数据库里的下划线字段映射成驼峰属性。这些都属于基础配置实际项目中几乎每次都会用。4.3 启动类加上MapperScan主启动类加一行注解SpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这行注解的作用范围是com.example.demo.mapper包及其子包扫描到的所有接口都会注册成对应的代理Bean。4.4 Mapper接口和XML映射文件先定义一个实体类public class User { private Long id; private String name; private Integer age; // getter/setter省略 }再写Mapper接口public interface UserMapper { User findById(Param(id) Long id); int insert(Param(user) User user); }对应的XML文件放在classpath:mapper/下?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper select idfindById resultTypecom.example.demo.entity.User SELECT id, name, age FROM user WHERE id #{id} /select insert idinsert useGeneratedKeystrue keyPropertyid INSERT INTO user(name, age) VALUES(#{user.name}, #{user.age}) /insert /mapperXML的namespace必须是Mapper接口的全限定名这个namespace和id组合决定了MapperMethod.SqlCommand能正确找到SQL语句。4.5 Service注入使用Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper userMapper; } Transactional public User insertAndQuery(User user) { userMapper.insert(user); return userMapper.findById(user.getId()); } }这里userMapper字段是构造器注入的。注意实际注入的是代理对象但类型是UserMapper所以编译和运行都没问题。4.6 启动项目观察现象启动项目后留意日志中关于Mapper的注册过程。然后在Controller或启动逻辑里调用一次查询控制台如果出现SQL日志说明整条链路已经跑通。如果去掉MapperScan启动大概率会看到类似这样的报错Description: Field userMapper in com.example.demo.service.UserService required a bean of type com.example.demo.mapper.UserMapper that could not be found. Action: Consider defining a bean of type com.example.demo.mapper.UserMapper in your configuration.这个报错说明Spring容器里没有UserMapper类型的Bean。因为Spring默认不会扫描Mapper接口更不会主动为接口创建代理所以必须通过MapperScan或Mapper触发扫描注册。4.7 验证代理对象的形态写一个小工具类在启动后输出Bean的类型Component public class MapperInspector implements ApplicationRunner { Override public void run(ApplicationArguments args) { UserMapper userMapper applicationContext.getBean(UserMapper.class); System.out.println(userMapper.getClass()); System.out.println(userMapper.getClass().getSuperclass()); } }输出结果通常是这样class com.sun.proxy.$Proxy60说明容器里的UserMapper确实是JDK动态代理对象。如果项目里做了AOP还有其他切面逻辑输出可能会变成CGLIB包装后的代理但核心仍然是JDK动态代理链。5. 高频问题与排查技巧实录5.1 扫描不到Mapper现象项目启动正常但注入Mapper时报错找不到Bean或者应用启动时报Invalid bound statement (not found)。常见原因MapperScan的basePackages路径写错扫不到目标包。接口定义在某个jar包中但当前模块没有依赖引入该jarclasspath下压根没有这个接口。配置了annotationClass或markerInterface但接口不满足过滤条件。Spring Boot自动配置时扫描根路径和MapperScan的包路径不在同一个体系里看起来扫描逻辑正确实际包名写错一个字符。排查思路首先确认包路径是否真实存在看编译产物里是否有对应class文件。在启动类上加MapperScan的包路径写大一点也没关系比如直接写com.example看能否覆盖到目标mapper包。检查BeanDefinition注册情况启动时加--debug参数Spring Boot会输出自动配置报告和Bean注册列表从中确认有没有UserMapper相关的Bean。如果使用多个模块优先确认mapper接口所在模块是否已经被当前应用依赖引入。我遇到过一次比较隐蔽的情况项目是Maven多模块mapper模块的jar包在本地仓库里是旧版本新的接口发布后没有重新install导致新接口一直扫描不到。这类问题不是代码逻辑错误而是构建管理问题排查时容易忽略。5.2 多个数据源时的MapperScan冲突现象配置了两个数据源两个MapperScan分别扫不同包但某个Mapper执行时用错了连接或者启动时报No SqlSessionFactoryBean found。原因分析每个数据源通常要对应一个独立的SqlSessionFactory。如果一个Mapper在注册时没有指定使用哪个SqlSessionFactory默认会找容器里唯一的那个如果容器里有多个SqlSessionFactory且没有明确指定MapperFactoryBean在注入SqlSessionFactory时就会出问题。正确做法是给每个MapperScan指定对应的工厂引用Configuration MapperScan(basePackages com.example.mapper.primary, sqlSessionFactoryRef primarySqlSessionFactory) public class PrimaryDataSourceConfig { } Configuration MapperScan(basePackages com.example.mapper.secondary, sqlSessionFactoryRef secondarySqlSessionFactory) public class SecondaryDataSourceConfig { }同样多数据源场景下SqlSessionTemplate也要分别构建并配置好事务管理器。这类问题的本质是一个SqlSessionFactory只能指向一个数据源而Mapper必须明确自己归属于哪个工厂。5.3 Mapper方法重载与namespace冲突现象XML里有多个同名SQL idMyBatis启动时直接报错或者执行时调到了错误的SQL。MyBatis的SQL定位机制是namespace id唯一确定一个MappedStatement。Java里方法可以重载但SQL id如果重复直接就是冲突。Mapper接口里写两个同名方法public interface UserMapper { User findById(Param(id) Long id); User findById(Param(id) Long id, Param(name) String name); }XML里如果只有一条idfindById第二个方法运行时会报Invalid bound statement (not found)因为MyBatis认为找不到对应的MappedStatement。如果XML里写了两条同id的SQLMyBatis初始化就会因为重复注册报错。建议Mapper接口方法名保持全局唯一不做重载。这不仅是MyBatis的限制实际从阅读和维护角度看重载也会让SQL映射关系变得混乱。5.4 分页插件失效或SQL执行异常现象引入分页插件后count查询生成失败或者分页参数没有生效。原因分析分页插件本质上是MyBatis的Interceptor它必须被注册到执行器链路中。在MyBatis Spring集成时插件要添加到SqlSessionFactoryBean的插件列表里Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setPlugins(new Interceptor[]{ interceptor }); return factoryBean.getObject(); }多数据源场景下每个SqlSessionFactory都要单独配置插件只配一个会出现另一个数据源分页不生效的情况。还有一类坑是插件版本与MyBatis版本不匹配升级版本后没同时更新插件坐标报错往往非常隐蔽编译期不报错运行期才出诡异SQL。还有一点分页插件对写法有要求count查询会重写SQL如果Mapper XML里的SQL写得太复杂比如多了自定义JOIN或子查询分页插件解析时容易出问题。遇到这类问题优先查看插件生成的count SQL日志对比原SQL和改写后的SQL确认哪一步被转换错了。6. 一些实用心得与扩展思路实际使用中我对MapperScan最大的感受是注释越短背后承担的职责越重。很多人写代码时只觉得这一行是启动MyBatis开关但项目中一旦遇到扫描不到、多数据源路由出错、分页插件失效这类问题如果脑子里没有完整的链路模型排查起来会非常被动。我自己的排查习惯是分三步走先看类型容器里到底注入了什么东西是JdkDynamicAopProxy还是原始代理用getClass()直接看。再看注册源Bean名字叫什么是从哪个BeanDefinition创建的用BeanFactory的getBeanDefinition反查。最后看SQL绑定Mapper方法能不能找到对应的MappedStatement有问题就检查XML路径、namespace和id三者是否一一对应。这套思路几乎覆盖了我遇到过的所有MyBatis集成问题。另外学完MapperScan的机制后可以横向迁移到其他框架上。比如MyBatis-Plus的MapperScan、Spring Data JPA的EnableJpaRepositories、甚至一些自研starter里用EnableXxx做的自动装配本质上都是同一条套路用Import引入一个ImportBeanDefinitionRegistrar或BeanDefinitionRegistryPostProcessor在Bean定义阶段注册一批接口的代理Bean。理解了这一个模式再去看任何同类框架的启动注解都不会觉得神秘了。最后分享一个经验代码里用MapperScan时包路径不要写得太具体。扫一个宽泛的父包以后新增子模块、新增mapper接口时不用频繁改这个注解也更不容易因为包结构调整导致扫描遗漏。代价是启动时稍微多做一点类路径扫描但这个开销在绝大多数业务系统里完全可以忽略。比起启动多花几毫秒业务上线后因为漏扫导致的找不到Bean问题显然更让人头疼。