主题
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 |
SqlSessionTemplate | SqlSession 的线程安全实现,每次调用从事务上下文取或新建 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: truejava
// 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: truesql
-- 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就不会自动装配,需要手动配齐工厂、模板、事务管理器和扫描引用。