纯 Spring 项目里把 MyBatis 接进来,这件事我做过不止一次。每次带项目,我都建议团队新人不要一上来就 Spring Boot + mybatis-starter,而是先用最原始的方式手动整合一遍。原因很简单:只有亲手把 SqlSessionFactory 交给 Spring、把 Mapper 接口变成可注入的 Bean、把事务边界从 MyBatis 手里移交到 Spring 手里,你才能真正理解那些 starter 到底替你做了什么。这篇文章就把这条手动整合的路完整走一遍,从版本选型、依赖引入、配置拆解,到 CRUD 跑通、常见报错排查,全部给你。
1. 整合本质:Spring 容器和 MyBatis 之间到底交换了什么
1.1 很多人对整合“会配但不懂”的根源
早些年带团队的时候,我见过不少这样的场景:用 Spring Boot 的人顺手就加上了 mybatis-spring-boot-starter,写一个接口、写一个 XML,项目就活了。但一旦脱离 Spring Boot,回到传统 SSM 项目,或者公司要求手动管理依赖,很多人就懵了——不知道 SqlSessionFactory 是怎么进 Spring 的,不知道 Mapper 接口为什么能直接注入,更不知道事务是怎么串起来的。
这个问题的根源,是 Spring Boot 的自动配置把整合细节全藏了。你看到的只是“加依赖、写接口”,底层发生了什么一概不知。理解 Spring 整合 MyBatis,本质上是搞清楚三件事:
- SqlSessionFactory 如何被 Spring 创建和管理;
- Mapper 接口如何变成可注入的 Bean;
- 数据库事务如何从 MyBatis 的 SqlSession 转移到 Spring 的事务管理器上。
把这三条线理清,任何 starter 版本你都能读懂它在干嘛,出了问题也能顺着链路去定位。
1.2 为什么不能直接在业务代码里 new SqlSessionFactory
很多初学者第一反应是:MyBatis 自己不是有 SqlSessionFactoryBuilder 吗?我写个工具类 new 一个工厂,再拿 SqlSession 去执行,行不行?
技术上完全可行,工程上非常不推荐。原因是 SqlSessionFactory 本身是重量级对象,一个数据库对应一个就够了,而且 SqlSession 不是线程安全的。如果每个线程都自己 new SqlSession,连接管理、事务边界、资源释放全乱套。Spring 整合后,这些事交给了容器:
- SqlSessionFactory 以单例 Bean 的形式存在,生命周期由容器管理;
- 每次数据库操作通过 SqlSessionTemplate,由它按需获取和释放 SqlSession;
- 事务边界由 Spring 的 PlatformTransactionManager 统一控制,MyBatis 的 SqlSession 会感知当前是否存在 Spring 事务,决定是新建会话还是复用事务内会话。
这个过程的载体就是 mybatis-spring 这个桥接包。它提供的 SqlSessionFactoryBean、MapperFactoryBean、SqlSessionTemplate 三样东西,分别对上了上面三条线。
1.3 整合后的整体链路:记住这张图
为了后续配置不迷路,先给出一张我脑子里的对象链路图(文字版):
- Spring 容器持有 DataSource,它是整个数据库连接的源头;
- SqlSessionFactoryBean 接收 DataSource,生产 MyBatis 的 SqlSessionFactory;
- MapperScannerConfigurer 扫描指定包下的接口,为每个接口注册 MapperFactoryBean;
- MapperFactoryBean 依赖 SqlSessionFactory,创建出 Mapper 接口的 JDK 动态代理;
- DataSourceTransactionManager 也接收同一个 DataSource,负责开启、提交、回滚事务;
- 业务代码注入 Mapper 接口,实际拿到的是代理对象,代理内部通过 SqlSessionTemplate 执行 SQL。
记住这张图,后面每个配置项你都知道它挂在哪条线上。这就是我建议任何人都至少手动整合一次的原因:自动配置确实爽,但出了问题,你得知道去哪里定位。
2. 环境与依赖:版本矩阵是最容易被低估的坑
2.1 技术选型与版本组合
整合之前先把环境定下来。我用的组合如下,也是目前兼容性最好、踩坑最少的经典组合:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 8 或 11 | Spring 5.3 最低要求 JDK8,JDK11 也没问题 |
| Spring Framework | 5.3.x | 原生 Spring,非 Spring Boot |
| MyBatis | 3.5.x | 当前主流 3.5.16 |
| mybatis-spring | 2.1.x | 与 MyBatis 3.5 配套 |
| MySQL | 8.0 | 使用官方驱动 mysql-connector-j |
| 连接池 | Druid 1.2.x | 也可以换 HikariCP,思路一致 |
注意:如果你准备用 Spring 6,JDK 必须是 17 以上,而且旧代码里的 javax.* 命名空间要全部迁移到 jakarta.*,工程量大。老项目升级前一定要评估影响面,不能只看依赖版本号。
2.2 Maven 依赖清单
因为项目是原生 Spring + MyBatis,没有 Boot 的依赖管理,建议直接把以下依赖加到 pom.xml。这里有两个点特别容易踩,我会重点说明:
<properties> <spring.version>5.3.39</spring.version> <mybatis.version>3.5.16</mybatis.version> <mybatis.spring.version>2.1.2</mybatis.spring.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>${mybatis.spring.version}</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> </dependencies>第一点,mybatis-spring 2.1.x 依赖 MyBatis 3.5+。如果你接手的是老项目,MyBatis 还是 3.4.x,请把 mybatis-spring 换成 1.3.x,否则启动时会直接抛 NoSuchMethodError。这类报错通常不是代码问题,而是版本错位。
第二点,spring-jdbc 必须显式引入。它提供了 DataSourceTransactionManager、JdbcTemplate 等关键类,spring-context 不会自动带上它。之前的项目就有同事漏引入,运行期报 ClassNotFoundException: org.springframework.jdbc.datasource.DataSourceTransactionManager,排查了半天才发现是依赖缺失。
2.3 数据库准备
整合过程需要一个能连的库。我用一张最小化的表,后面所有演示都围绕它:
CREATE DATABASE IF NOT EXISTS demo DEFAULT CHARACTER SET utf8mb4; USE demo; CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(50) NOT NULL COMMENT '用户名', email VARCHAR(100), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO user(user_name, email) VALUES ('zhangsan', 'zhangsan@example.com'); INSERT INTO user(user_name, email) VALUES ('lisi', 'lisi@example.com');注意表引擎用了 InnoDB,这关系到后面第 5 节事务能否生效。MyISAM 不支持事务,很多人辛辛苦苦配好了 @Transactional,结果回调滚没用,最后发现是表引擎的问题。
3. 配置逐项拆解:数据源、工厂与扫描器三个关键角色
3.1 数据源配置:连接是整合的第一条命
第一个 Bean 是 DataSource。我用 Druid 举例,换成 HikariCP 思路完全一致,只改类名即可。在 JavaConfig 里这样写:
@Bean public DataSource dataSource() { DruidDataSource ds = new DruidDataSource(); ds.setDriverClassName("com.mysql.cj.jdbc.Driver"); ds.setUrl("jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"); ds.setUsername("root"); ds.setPassword("你的密码"); ds.setInitialSize(5); ds.setMaxActive(20); return ds; }URL 里的三个参数单独说一下:
- useUnicode=true&characterEncoding=utf8,解决中文乱码问题,存进 MySQL 的中文能不能正确读写,主要靠它;
- serverTimezone=Asia/Shanghai,MySQL 8 驱动强制要求时区参数,不写会报 “The server time zone value” 错误;
- 如果只在本机测试,useSSL=false 也建议加上,避免 SSL 握手带来的噪音日志和额外耗时。
这些参数看似小事,但几乎每个新人第一次跑整合都会栽在时区或乱码上。
3.2 SqlSessionFactoryBean:把 MyBatis 原生配置收编进 Spring
第二个 Bean 是关键中的关键。SqlSessionFactoryBean 是 FactoryBean 的一种:Spring 容器里声明的是这个 Bean,但通过 getBean("sqlSessionFactory") 拿到的其实是它内部生产的 SqlSessionFactory 对象。这是 Spring 经典的 FactoryBean 机制,很多同学第一次看到 Bean 名和实际类型对不上,容易绕晕。
@Bean public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) throws IOException { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setTypeAliasesPackage("com.demo.entity"); PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); factoryBean.setMapperLocations(resolver.getResources("classpath:mapper/*.xml")); org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); configuration.setLogImpl(org.apache.ibatis.logging.stdout.StdOutImpl.class); factoryBean.setConfiguration(configuration); return factoryBean; }逐项解释:
- setDataSource 必须有,SqlSessionFactory 从它拿到连接;
- setTypeAliasesPackage 指定实体类包名,XML 里写 resultType="User" 就能自动映射到 com.demo.entity.User,不用写全限定名;
- setMapperLocations 指定 XML 文件位置,classpath:mapper/*.xml 表示 classpath 下 mapper 目录所有 XML。要注意,Maven 默认只把 src/main/resources 下的文件当资源打包,XML 放错位置会导致运行期找不到;
- mapUnderscoreToCamelCase 开启后,数据库字段 user_name 能自动映射到属性 userName,省掉大量手写 resultMap 的重复劳动;
- StdOutImpl 是控制台打印 SQL,调试期强烈建议开,上线前要换掉。
这里还有个很容易犯的错:同时用 setConfiguration 和 setConfigLocation。如果你有自己的 mybatis-config.xml,这两个概念别混:
- setConfigLocation 指向外部 mybatis-config.xml;
- setConfiguration 直接塞一个 MyBatis Configuration 对象。
两者同时设置时会互相覆盖,结果不可预期。我的习惯是:新项目完全用 setConfiguration,不再保留外置 mybatis-config.xml;老项目迁移才用 setConfigLocation,保持原来 XML 不动,平滑过渡。
3.3 纯注解配置和 XML 配置怎么选
为了照顾不同习惯的读者,我把 XML 等价的配置也贴出来。本质上,JavaConfig 和 XML 画的是同一张对象图:
<context:component-scan base-package="com.demo"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="typeAliasesPackage" value="com.demo.entity"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.demo.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>从零搭建的话,我更推荐 JavaConfig。理由很简单:Bean 之间的依赖在编译期部分可查,重构时 IDE 能帮你定位引用;XML 字符串配置只能等运行期报错再去排查。但如果你接的是老项目,XML 是主流,完全可以直接用。
4. Mapper 注册的三种方式与底层代理逻辑
4.1 最小可用方案:逐个注册 MapperFactoryBean
先说最朴素的方式。如果项目里只有一个两个 Mapper,可以直接注册 MapperFactoryBean,这也是理解 Mapper 注入机制的起点:
@Bean public MapperFactoryBean<UserMapper> userMapper(SqlSessionFactory sqlSessionFactory) { MapperFactoryBean<UserMapper> factoryBean = new MapperFactoryBean<>(); factoryBean.setMapperInterface(UserMapper.class); factoryBean.setSqlSessionFactory(sqlSessionFactory); return factoryBean; }MapperFactoryBean 实现的是 FactoryBean 接口,它 getObject() 返回的不是工厂本身,而是 UserMapper 的代理对象。代理的生成逻辑在 MapperProxy 中:每次调用接口方法,都会通过底层的 SqlSessionTemplate 去执行对应 namespace 下的 SQL,namespace 默认取接口的全限定名。
这种方式的好处是显式、直观;坏处也很明显——Mapper 一多,Bean 定义多到没法看。所以它只适合教学和理解,实际项目不会这么写。
4.2 自动扫描:注解和扫描器二选一
项目里实际用的一定是自动扫描。XML 配置我上面已经写了 MapperScannerConfigurer,JavaConfig 更推荐直接用注解,一行搞定:
@Configuration @MapperScan("com.demo.mapper") public class AppConfig { ... }注意 @MapperScan 在 mybatis-spring 包里,路径是 org.mybatis.spring.annotation.MapperScan。它在底层做的事和 XML 里的 MapperScannerConfigurer 一样,都是给扫描到的接口注册一批 MapperFactoryBean 的 BeanDefinition。
用扫描方式时,basePackage 的粒度有讲究。我习惯把 Mapper 接口单独放在一个包,比如 com.demo.mapper,不要和 service、entity 混在一起。原因有二:扫描路径越精确越不容易误扫;线上问题排查时一眼能定位到数据访问层。如果你想把接口散落在不同包,也可以用逗号分隔写多个包。
这里还有个隐藏规则:@MapperScan 和手动声明 MapperScannerConfigurer 不要同时用。两者功能一样,同时配置可能造成重复扫描,出现 Bean 定义冲突。
4.3 接口与 XML 的绑定硬规则
这里说三个绑定规则,违反任何一个都会在不经意间翻车:
- namespace 必须等于接口的全限定名,比如 com.demo.mapper.UserMapper;
- SQL 标签的 id 必须等于接口方法名,且方法不能重载。MyBatis 不区分重载,同一个接口里出现两个同名方法,哪怕参数不同,也会直接冲突;
- 方法返回值和参数类型要和 select 标签的 resultType/resultMap、parameterType 匹配。
除了 XML,MyBatis 3.5 也支持用注解写 SQL:
public interface UserMapper { @Select("select * from user where id = #{id}") User findById(Long id); }我的实际经验是:单表两三个字段的简单查询,注解写起来很舒服;一旦涉及多表 join、动态 where、批量更新,XML 的可读性和维护性远超注解字符串。很多团队定规矩“一条 SQL 超过 10 行就必须进 XML”,我深表赞同,你可以参考。
5. 事务衔接:DataSourceTransactionManager 与 SqlSessionTemplate 的配合
5.1 事务管理器的配置与同一个 DataSource 原则
事务是整合里的重头戏,也是最容易“配了但没生效”的部分。JavaConfig 里需要加:
@EnableTransactionManagement public class AppConfig { @Bean public DataSourceTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }这里有一个强制要求:DataSourceTransactionManager 注入的 DataSource,必须和 SqlSessionFactoryBean 用的是同一个 DataSource 实例。如果配置了两份不同的连接池,事务管理器控制的那份连接和 MyBatis 用的那份连接不是一回事,事务就形同虚设。一旦出现“明明配了 @Transactional 但数据还是写进去了”的诡异问题,第一嫌疑就是这里。
5.2 SqlSessionTemplate 的线程安全与事务跟随机制
早期有人自己封装 SqlSession 工具类,每个线程 new 一个 SqlSession,一个方法里连续查两次,第二次可能因为第一次的 SqlSession 已关闭而失效。mybatis-spring 给出的解决方案是 SqlSessionTemplate。
SqlSessionTemplate 内部维护了一个 SqlSessionProxy,通过 JDK 动态代理拦截所有方法调用。每次调用它都会先判断当前线程有没有被 Spring 事务管理器同步的 SqlSession:
- 有,直接复用事务内那个 SqlSession;
- 没有,新开一个 SqlSession,执行完立即关闭。
这个“跟着事务走”的机制,让 MyBatis 的 SqlSession 生命周期与 Spring 事务边界完全对齐。你所注入的 Mapper 接口代理,最终也是落到这个逻辑上。
顺带解释一个很多人困惑的现象:非事务环境下,同一个方法里两次调用 mapper.select,MyBatis 的一级缓存基本不起作用。原因就是每次调用都新建和销毁 SqlSession,而一级缓存是 SqlSession 级别的。想要缓存命中,就得在同一个事务里连续查询。这不是 Bug,是 Spring 整合后的正常行为。
5.3 事务配置检查清单
把容易踩的点列成清单,建议逐条对照检查:
- 数据源只有一份,DataSourceTransactionManager 与 SqlSessionFactoryBean 复用同一个;
- @EnableTransactionManagement 或 tx:annotation-driven 必须存在于容器;
- 事务方法必须是 public,并且不能通过对象内部自调用,否则代理不生效;
- 数据库表引擎是 InnoDB,不是 MyISAM;
- 异常默认只在 RuntimeException 和 Error 时回滚,受检异常默认不回滚,需要时用 @Transactional(rollbackFor = Exception.class);
- 如果你在 Spring 容器外还配置了 MyBatis 自己的 transactionManager,整合后建议删掉,交给 Spring 统一管理。
自调用问题值得单独强调:A 类的 methodA() 调用同类 methodB(),methodB 上有 @Transactional,此时事务不会生效,因为调用发生在对象内部,没经过 Spring 代理。解决办法是把 methodB 放到另一个 Bean 里。这个坑几乎每个团队都踩过。
6. 从空项目到跑通 CRUD 的完整落地流程
6.1 项目结构与完整配置
下面是完整可运行的骨架,照着建就行:
src/main/java/com/demo ├── App.java ├── config/AppConfig.java ├── entity/User.java ├── mapper/UserMapper.java └── service/UserService.java src/main/resources └── mapper/UserMapper.xmlAppConfig 就是把前面 3.1、3.2 和 5.1 节里的 @Bean 方法全部放进一个类,再加上 @ComponentScan 和 @MapperScan。为了避免你还要自己拼装,我贴出完整类:
package com.demo.config; import com.alibaba.druid.pool.DruidDataSource; import org.apache.ibatis.session.Configuration; import org.mybatis.spring.SqlSessionFactoryBean; import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import javax.sql.DataSource; @Configuration @ComponentScan("com.demo.service") @MapperScan("com.demo.mapper") @EnableTransactionManagement public class AppConfig { @Bean public DataSource dataSource() { DruidDataSource ds = new DruidDataSource(); ds.setDriverClassName("com.mysql.cj.jdbc.Driver"); ds.setUrl("jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"); ds.setUsername("root"); ds.setPassword("你的密码"); return ds; } @Bean public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setTypeAliasesPackage("com.demo.entity"); PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); factoryBean.setMapperLocations(resolver.getResources("classpath:mapper/*.xml")); Configuration configuration = new Configuration(); configuration.setMapUnderscoreToCamelCase(true); configuration.setLogImpl(org.apache.ibatis.logging.stdout.StdOutImpl.class); factoryBean.setConfiguration(configuration); return factoryBean; } @Bean public DataSourceTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }实体类就不过度展开了,注意必须补全 getter/setter,并加上无参构造:
package com.demo.entity; public class User { private Long id; private String userName; private String email; public User() {} public User(String userName, String email) { this.userName = userName; this.email = email; } // 省略 getter/setter,请自行补全 }Mapper 接口:
package com.demo.mapper; import com.demo.entity.User; import java.util.List; public interface UserMapper { User findById(Long id); List<User> findAll(); int insert(User user); int update(User user); int deleteById(Long id); }Mapper XML,namespace 必须和接口全限定名完全一致:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.demo.mapper.UserMapper"> <insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id"> insert into user(user_name, email) values (#{userName}, #{email}) </insert> <update id="update" parameterType="User"> update user set user_name = #{userName}, email = #{email} where id = #{id} </update> <delete id="deleteById" parameterType="long"> delete from user where id = #{id} </delete> <select id="findById" parameterType="long" resultType="User"> select id, user_name, email from user where id = #{id} </select> <select id="findAll" resultType="User"> select id, user_name, email from user </select> </mapper>这里特意写了 useGeneratedKeys="true" keyProperty="id",插入后主键会回填到 User 对象的 id 字段。这个功能日常开发使用频率极高,但不看文档的新人基本想不到。
Service 层加事务演示:
package com.demo.service; import com.demo.entity.User; import com.demo.mapper.UserMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper = userMapper; } public List<User> listAll() { return userMapper.findAll(); } public User getById(Long id) { return userMapper.findById(id); } @Transactional public User createUser(String userName, String email) { User u = new User(userName, email); userMapper.insert(u); return u; } @Transactional public void updateUser(Long id, String userName, String email) { User u = new User(userName, email); u.setId(id); userMapper.update(u); } @Transactional public void deleteUser(Long id) { userMapper.deleteById(id); } @Transactional(rollbackFor = Exception.class) public void createWithRollback(String userName, String email) { User u = new User(userName, email); userMapper.insert(u); throw new RuntimeException("模拟异常,触发回滚"); } }6.2 用 main 方法启动容器(非 Spring Boot 启动姿势)
没有 Spring Boot,启动入口只能是手动创建容器。如果你习惯写单元测试,用 @RunWith(SpringJUnit4ClassRunner.class) 搭配 @ContextConfiguration(classes = AppConfig.class) 也可以,原理一样。这里用 main 方法演示,更直观:
package com.demo; import com.demo.config.AppConfig; import com.demo.entity.User; import com.demo.service.UserService; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import java.util.List; public class App { public static void main(String[] args) { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class); UserService userService = ctx.getBean(UserService.class); System.out.println("查询全部:" + userService.listAll()); User newUser = userService.createUser("wangwu", "wangwu@example.com"); System.out.println("新增用户,回填主键:" + newUser.getId()); System.out.println("新增后查询:" + userService.getById(newUser.getId())); userService.updateUser(newUser.getId(), "wangwu_updated", "new@example.com"); System.out.println("更新后查询:" + userService.getById(newUser.getId())); userService.deleteUser(newUser.getId()); System.out.println("删除后查询:" + userService.getById(newUser.getId())); try { userService.createWithRollback("rollback_case", "rollback@example.com"); } catch (RuntimeException e) { System.out.println("捕获异常:" + e.getMessage()); } System.out.println("回滚验证,rollback_case 不应出现在下面列表中:"); List<User> users = userService.listAll(); users.forEach(u -> System.out.println(u.getId() + " - " + u.getUserName())); ctx.close(); } }如果配置全部正确,控制台会打印 SQL 日志;新增的 wangwu 能查到、更新能生效、删除后查不到、回滚用例不会出现在最终列表里。这几个现象同时出现,就说明 Mapper 扫描、SQL 绑定、事务控制三件事全部生效了。
6.3 验证逻辑:每一行日志分别证明什么
第一次跑通的时刻值得细看。开着 StdOutImpl,控制台会出现这些关键输出:
- Preparing: select id, user_name, email from user where id = ?
- Parameters: 3(String),说明参数绑定正常;
- <== Total: 1,说明查询有结果返回;
- 回滚验证时最终列表里没有 rollback_case,说明事务回滚生效。
如果哪一步输出缺失,顺着这条证据链去定位,比自己瞎猜快得多。这也是我特别推荐调试期开 SQL 日志的原因——每个功能点都有明确的可见信号,而不是“感觉它工作了”。
7. 整合后常见报错与我的排查清单
7.1 Invalid bound statement (not found):最高频的运行错误
这个错误出现时,第一反应不是改代码,而是按下面顺序排查:
- 打开 target/classes/mapper 目录,确认 UserMapper.xml 是否真的打包进去了。Maven 默认把 src/main/resources 当资源目录,如果 XML 误放在 src/main/java 下,默认不会被打包(除非额外配置)。
- 确认 mapperLocations 的路径和你实际放的路径一致。classpath:mapper/*.xml 对应的是 resources/mapper 下的 XML。
- 打开 XML 看 namespace,必须等于接口全限定名。
- 看标签 id,必须和接口方法名完全一致。
- 看 basePackage / @MapperScan 是否扫到了这个接口。判断方法:启动时打开 DEBUG 日志,观察 org.mybatis.spring.mapper.ClassPathMapperScanner 的输出,正常会出现 Registering mapper interface com.demo.mapper.UserMapper 之类的日志,没有就说明接口没被扫描到。
还有一种情况是接口方法重载。前面说过,MyBatis 不允许同一个接口里有同名方法,即使参数不同也不可以。遇到这种需求,把其中一个方法改名或者合并参数。
7.2 事务不生效,数据照样写库
这个坑我在 5.3 节给了完整清单,这里补充一个真实场景。之前一个同事的项目里新增数据总是回滚不掉,我查到最后发现,他的数据源在 XML 里配了一个,事务管理器的 dataSource 又手动指定了另一个连接池 Bean。两者虽然指向同一个库,但不是同一个实例,MyBatis 用的连接和事务管理器控制的连接根本不是同一份,事务自然无效。
排查顺序建议:
- 在加了 @Transactional 的方法里打断点,看 TransactionSynchronizationManager.isActualTransactionActive() 是否返回 true,false 说明事务压根没开启;
- 确认事务管理器用的 DataSource 和 sqlSessionFactory 用的是不是同一个 Bean;
- 再看异常类型是不是 RuntimeException。如果业务抛的是 IOException 这类受检异常,默认不会触发回滚,必须显式 rollbackFor。
7.3 其它高频问题汇总
最后把另外几个常见问题集中说明:
- Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required:通常是 MapperFactoryBean 在缺少 SqlSessionFactory 的情况下被创建。检查是否同时用了 @MapperScan 和手动注册 MapperFactoryBean,两边重复会冲突,只保留一种。
- 启动报 The server time zone value is unrecognized:MySQL 8 驱动要求 URL 带 serverTimezone,补上即可。
- 中文乱码:URL 补 characterEncoding=utf8,且建库建表统一用 utf8mb4。
- 启动后出现大量 WARN: More than one configuration:同时设置了 configLocation 和 configuration,二选一。
- MyBatis 3.4.x 配 mybatis-spring 2.x 直接 NoSuchMethodError:版本错位,回到第 2 节表格对照修正。
整合这种基础工程,最有价值的往往不是“怎么配置对”,而是“配置错了怎么快速定位”。我习惯把错误信息原文先复制下来,判断它属于扫描类、绑定类、连接类还是事务类,再按对应清单逐项排除。这比从头到尾看一遍配置高效得多。
这是我多年来反复手动整合 Spring 和 MyBatis 沉淀下来的流程和避坑经验。如果你之前一直用 Spring Boot starter,强烈建议照这篇文章自己搭一遍纯 Spring 版。跑通一次之后,再看任何自动配置的源码,都会有豁然开朗的感觉。