跳到正文

MyBatis 与 Spring 整合 ​

一句话:Spring 接管 SqlSession 的生命周期和 Mapper 的注册,把“手动 open/close”换成“注入接口 + 事务上下文复用”;换来线程安全和声明式事务,代价是代理、扫描和多数据源配置更容易错。

适用版本:MyBatis 3.5.x + mybatis-spring 3.0.x / mybatis-spring-boot-starter 3.0.x,对应 Spring Boot 3.x。Boot 2.x 用 2.3.x,不要混用。

为什么需要 ​

原生 MyBatis 的用法是:

java
try (SqlSession session = factory.openSession()) {
    UserMapper mapper = session.getMapper(UserMapper.class);
    mapper.insert(user);
    session.commit();
}

问题在于:每个 DAO 都要管 session、提交、回滚和关闭;Spring 事务无法和它自然衔接;Mapper 代理也不能直接作为单例 Bean 注入。

整合后,业务代码只注入 Mapper 接口,事务由 @Transactional 声明,连接和 session 由 Spring 绑定到当前事务。

核心机制 ​

三个核心对象 ​

对象作用
SqlSessionFactoryBean创建 SqlSessionFactory,把 DataSource、Mapper 位置、配置传给 MyBatis
SqlSessionTemplateSqlSession 的线程安全实现,每次调用从事务上下文取或新建 session
MapperFactoryBean为每个 Mapper 接口创建代理,getObject() 返回 MapperProxy

SqlSessionTemplate 内部用 JDK 代理拦截 SqlSession 的每个方法:有 Spring 事务时,复用事务绑定的 SqlSession(一级缓存生效);没有事务时,每次调用取新 session,用完关闭。因此 Mapper 代理可以安全地作为单例注入。

@MapperScan 与 MapperScannerConfigurer ​

扫描发生在 Bean 定义阶段,不是运行期;所以 @MapperScan 的包路径写错,报错表现为「找不到 Bean」而不是「找不到 Mapper」。

关系要说清:

  • @MapperScan 是注解层的入口,负责把包路径、sqlSessionTemplateRef 等属性传下去。
  • MapperScannerConfigurer 是真正执行扫描的 BeanDefinitionRegistryPostProcessor;XML 里可以直接配它,效果等价。
  • MapperScannerRegistrar 是把注解属性翻译成 MapperScannerConfigurer 定义的适配器,不是扫描器本身。
  • 只写 @Mapper 不写 @MapperScan 时,mybatis-spring-boot-starter 会在没有现成 MapperFactoryBean/MapperScannerConfigurer 的情况下,自动扫描启动类所在包及子包中的 @Mapper 接口;包外接口仍要显式 @MapperScan。

自动配置的条件 ​

MybatisAutoConfiguration 的关键条件包括:

java
@ConditionalOnClass({SqlSessionFactory.class, SqlSessionFactoryBean.class})
@ConditionalOnSingleCandidate(DataSource.class)
@EnableConfigurationProperties(MybatisProperties.class)

@ConditionalOnSingleCandidate(DataSource.class) 的准确语义是:容器中该类型只有一个候选 Bean,或者有多个但其中一个标了 @Primary。它不是“必须恰好一个 DataSource”的硬性判断;多数据源时如果没有任何 @Primary,这个条件不满足,自动配置不会生效,需要手动配置。

事务整合 ​

  • Spring + MyBatis 通常用 DataSourceTransactionManager(JDBC 事务管理器);JPA 项目可能用 JpaTransactionManager。
  • SqlSessionFactoryBean 默认使用 SpringManagedTransactionFactory,让 MyBatis 的连接从事务上下文取,而不是自己 commit。
  • 同一 @Transactional 内的多次 Mapper 调用复用同一个 SqlSession,一级缓存生效;REQUIRES_NEW 会挂起外层、用新 session 和新连接。
  • 多数据源要分别配置 SqlSessionFactory、SqlSessionTemplate 和事务管理器,并在 @MapperScan/@Transactional 上显式指定 sqlSessionTemplateRef/transactionManager。

实战场景 ​

一、NoSuchBeanDefinitionException: UserMapper:扫描阶段就错了 ​

现象:新拆出的 promotion-module 里 Mapper 注入失败,启动日志报 No qualifying bean of type 'com.example.promotion.mapper.PromotionMapper'。同一工程里的 order-module 正常。报错发生在启动期,不是查询期。

排查:扫描发生在 Bean 定义阶段,所以定位要回到定义。

定位层取证据判定
扫描范围@MapperScan 的 basePackages 与接口实际包名启动类包外的接口,starter 的自动扫描不覆盖
是否注册了定义启动日志搜 scanned ... Mapper 或 dump getBeanDefinitionNames() 过滤 MapperFactoryBean没有定义 → 扫描没命中,不是代理问题
是否被排除@Conditional、profile、模块是否进了构建产物类不在 classpath 也表现为找不到 Bean
多个模板冲突容器里 SqlSessionTemplate 数量 > 1 且未写 sqlSessionTemplateRef注入时无法选择,报错仍是找不到 Bean
接口形态是否被 final/内部类/非接口类型包住ClassPathMapperScanner 只登记接口

本例命中的是范围问题:模块包在启动类包外,工程只在启动类上写了 @MapperScan("com.example.order.mapper")。第二个模块复用了同一段配置,因此从未被扫描。

对症做法:

java
// 1. 每个模块显式声明自己的扫描范围,不依赖 starter 的“启动类及子包”
@MapperScan(basePackages = "com.example.promotion.mapper",
            sqlSessionTemplateRef = "promotionSqlSessionTemplate")

// 2. 用模式常量而不是散落字符串,避免改名后静默漏扫
@MapperScan(basePackages = PromotionScan.MAPPER_PACKAGE)

// 3. 启动期自检:模块启动时断言关键 Mapper 已注册
@PostConstruct void assertMappersReady() {
    applicationContext.getBean(PromotionMapper.class);   // 失败就让启动直接失败
}

@MapperScan 范围要按模块限定,不要扫整个公司包:扫描会把非 Mapper 接口也注册成 MapperFactoryBean,结果是启动报错或注入到空代理。

复盘要点:新模块接入的验收标准应该是“能启动”,不是“能编译”。把上面那条 getBean 断言放进每个模块的启动检查,缺 Bean 的问题就会在流水线暴露;相关做法见 优雅上下线与启动检查。

二、Invalid bound statement (not found):XML 在打包时丢了 ​

现象:CouponMapper 的方法在 IDE 里正常,打成 jar 后线上报 BindingException: Invalid bound statement (not found)。报错只在 XML 写法的 Mapper 上出现,注解写法的都正常。

排查:MyBatis 找不到 statement,只有三种可能。

定位层取证据判定
XML 是否进包unzip -l app.jar | grep CouponMapper.xml不在 → 构建阶段被过滤
是否被配置发现mybatis.mapper-locations 与实际路径前缀是否匹配classpath: 只取一份,多模块要用 classpath*:
绑定键是否一致XML namespace 对接口全限定名;id 对方法名一个字母不同就报这个错,不是启动失败
大小写与后缀文件名、包名在 Linux 与 Windows 下是否一致本地正常、线上失败的经典原因

根因是 Maven 的 <resources> 只包含 **/*.properties,自定义过滤规则把 src/main/java 下的 XML 排除了;本地用 IDE 直接跑 target/classes 时 XML 被复制过,所以从未复现。

对症做法:

xml
<!-- 1. 让 java 目录下的 XML 进入产物(标准修法) -->
<build>
  <resources>
    <resource><directory>src/main/resources</directory></resource>
    <resource>
      <directory>src/main/java</directory>
      <includes><include>**/*.xml</include></includes>
    </resource>
  </resources>
</build>
yaml
# 2. 多模块依赖里的 XML 要用 classpath*,且路径不写死在 Java 包下
mybatis:
  mapper-locations: classpath*:mapper/**/*.xml
  configuration:
    map-underscore-to-camel-case: true
java
// 3. 把“XML 都绑定上了”变成构建期检查,而不是等运行报错
@Test void everyMapperStatementIsBound() {
    Configuration c = sqlSessionFactory.getConfiguration();
    for (Class<?> m : List.of(CouponMapper.class)) {
        for (Method method : m.getDeclaredMethods()) {
            assertThat(c.hasStatement(m.getName() + "." + method.getName())).isTrue();
        }
    }
}

注解与 XML 混用时,statement id 冲突会被 MyBatis 忽略后加载的那一份覆盖;同一方法两边都写要显式选一种。

复盘要点:这条用例(遍历所有 Mapper 接口方法断言 hasStatement)能在 10 秒内挡住整类问题,值得放进每个使用 MyBatis 的模块。另外把“XML 放在 src/main/java”改成统一放 resources/mapper,让打包规则只有一条。

三、多数据源:写到 A 库、事务管 B 库 ​

现象:报表模块从 BI 库读、从主库写。上线后发现写入操作不受 @Transactional 控制(异常了不回滚),偶尔还出现报表 SQL 打到主库、把主库连接占满。两个数据源用 AbstractRoutingDataSource 实现。

排查:多数据源成套配置有四个部件,缺一个都会错。

部件取证据判定
SqlSessionFactory每个数据源是否各一套,dataSource 注入的是谁共用一个工厂 → 连接永远来自同一个库
SqlSessionTemplate@MapperScan(sqlSessionTemplateRef=...) 是否指向正确模板未指定 → 随机命中一个,表现为“偶尔打错库”
事务管理器@Transactional(transactionManager=...) 与 Mapper 用的模板是否同一个数据源不一致 → 事务管的是 B 库连接,A 库自动提交,回滚无效
@ConditionalOnSingleCandidate是否配了 @Primary多个且无 @Primary 时自动配置直接不生效

判定“事务管的是哪条连接”有直接证据:方法内打印 TransactionSynchronizationManager.getResource(dataSourceBean),或在 SQL 日志里比对 JDBC URL。

java
// 命中本例的错误写法:事务管理器绑主库,Mapper 走 BI 模板
@Transactional                                // 默认 txManager = 主库
public void writeReport() { biMapper.select(...); mainMapper.insert(...); }

对症做法:

java
// 1. 成套声明:一个数据源 = 工厂 + 模板 + 事务管理器 + 扫描范围
@MapperScan(basePackages = "com.example.report.bi",   sqlSessionTemplateRef = "biTemplate",
            annotationClass = BiMapper.class)
@MapperScan(basePackages = "com.example.report.main", sqlSessionTemplateRef = "mainTemplate",
            annotationClass = MainMapper.class)

// 2. 事务显式指定管理器,跨库不假装是一个事务
@Transactional(transactionManager = "mainTxManager", rollbackFor = Exception.class)

// 3. 用标记注解把 Mapper 归到数据源,避免包路径改名后错配
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface BiMapper {}

跨库写一致性不靠“把两个事务拼起来”:@Transactional 只管当前数据源,跨库要靠本地消息表或补偿,见 分布式事务。AbstractRoutingDataSource 的路由键在事务开启时确定,事务内切换数据源无效——这是它最常见的误用。

复盘要点:多数据源的正确性可以静态检查:包路径(或标记注解)→ 模板 → 事务管理器三者一一对应,用一条架构测试断言“@BiMapper 接口不出现在带 mainTxManager 的事务方法里”。报表查询压垮主库这一条,还要配上连接池隔离(每个库独立池、独立上限)与只读路由,而不是靠开发记住指定模板。

最小上手 ​

Spring Boot 3.x + H2 + MyBatis starter 的最小整合:

yaml
# application.yml
spring:
  datasource:
    url: jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
    username: sa
    password:
  sql:
    init:
      mode: always
mybatis:
  configuration:
    map-underscore-to-camel-case: true
sql
-- schema.sql
CREATE TABLE users (id BIGINT PRIMARY KEY, user_name VARCHAR(20));
INSERT INTO users(id, user_name) VALUES (1, 'Ada');
java
// 假设已有 User 实体(含 getUserName()),Mapper 与启动类同在 demo 包
@Mapper
interface UserMapper {
    @Select("select id, user_name from users where id = #{id}")
    User find(long id);
}

@SpringBootApplication
@MapperScan("demo")
public class App {
    public static void main(String[] args) { SpringApplication.run(App.class, args); }

    @Bean
    CommandLineRunner run(UserMapper mapper) {
        return args -> System.out.println(mapper.find(1L).getUserName());  // Ada
    }
}

启动后输出 Ada,说明扫描、SqlSessionFactory、SqlSessionTemplate 和 Mapper 代理都已就绪。把 @MapperScan 去掉、接口移到启动类包外,再启动会报找不到 Mapper Bean,这就是扫描范围的作用。

生产动作 ​

  • mybatis.mapper-locations=classpath*:mapper/**/*.xml(无默认值,starter 会自动找 classpath*:/mapper/**/*.xml 等位置,以版本文档为准):XML 不在默认位置时必须显式配置,否则报 Invalid bound statement。
  • mybatis.configuration.map-underscore-to-camel-case=false(默认 false):需要驼峰映射时显式开启;也可以在每个 resultMap 里写别名。
  • 多数据源:每个数据源一套 SqlSessionFactory + SqlSessionTemplate + DataSourceTransactionManager;@MapperScan(sqlSessionTemplateRef = "...") 和 @Transactional(transactionManager = "...") 必须成对指定。
  • 排障路径:NoSuchBeanDefinitionException: UserMapper → 查 @MapperScan 包路径、接口是否在扫描范围、是否被条件排除 → 查是否多个 SqlSessionTemplate 但没指定 sqlSessionTemplateRef。
  • 排障路径:Invalid bound statement (not found) → 查 XML 的 namespace 与接口全限定名、方法名与 statement id、mapper-locations 是否匹配。
  • 排障路径:事务不回滚或连接不释放 → 查是否用了 DataSourceTransactionManager、Mapper 调用是否在同一个 @Transactional 内、是否被自调用绕过代理。
  • 排障路径:多数据源下写错库 → 查 @MapperScan 的 sqlSessionTemplateRef 与 @Transactional 的 transactionManager 是否指向同一个数据源。

边界 ​

  • @MapperScan 不是越宽越好:扫描整个公司包会把非 Mapper 接口也注册成 MapperFactoryBean,启动时报错或产生空代理;按模块限定包路径。
  • Mapper 接口不要继承业务接口:MyBatis 会把接口方法都当成 statement 查找,继承来的方法容易触发 Invalid bound statement。
  • SqlSessionTemplate 不是“万能线程安全”:它保证每次调用取到正确的 session,但一级缓存仍属于事务范围;跨线程、异步任务不会自动共享事务。
  • 多数据源不是加一个 @Bean 就完事:事务管理器、模板、扫描必须成套配置,否则会出现“写到 A 库、事务管 B 库”的问题。
  • Boot starter 不是必须:手动 SqlSessionFactoryBean + @MapperScan 等价;starter 只是把常用默认值装配好,排查时仍要回到这三个核心对象。

讲清楚 ​

Spring 整合 MyBatis 的关键是接管两件事:SqlSession 的生命周期和 Mapper 的注册。SqlSessionTemplate 用 JDK 代理拦截每个 session 方法,有事务时复用事务绑定的 session,没有事务时每次新建并关闭;所以 Mapper 代理可以做成单例注入。MapperFactoryBean 负责在启动时调用 getObject() 生成代理。

@MapperScan 和 MapperScannerConfigurer 不是两个平行的东西:前者是注解入口,通过 MapperScannerRegistrar 注册后者;后者才是 BeanDefinitionRegistryPostProcessor,真正扫描接口并注册 MapperFactoryBean。

自动配置里的 @ConditionalOnSingleCandidate(DataSource.class) 表示“只有一个候选,或有一个 @Primary”,不是“恰好一个”。多数据源时没有 @Primary 就不会自动装配,需要手动配齐工厂、模板、事务管理器和扫描引用。

把一门技术讲透到既懂也会用