其实Spring和MyBatis这套组合,几乎是国内Java后端开发的“国民级配置”。凡是做业务系统、后台管理的朋友,大概率都跟它打过交道。为什么这样说?因为Spring负责把对象之间的依赖关系管起来,MyBatis负责把你跟数据库打交道这件事做简单——你不需要像JDBC那样写一堆try-catch和资源释放代码,也不需要像Hibernate那样去记复杂的HQL和关联映射,SQL怎么写就怎么写,灵活、直接、可控。
这套组合能解决的问题很明确:第一,让业务代码里的数据库操作变得整洁,SQL语句集中管理或写在Mapper接口的注解里,不再散落各处;第二,让事务、连接池、Mapper代理这些底层逻辑交给框架,开发者只关注业务本身;第三,对于团队里熟悉SQL而未必熟悉ORM的同事非常友好,SQL调优就是写SQL的问题,不用绕弯。
适合谁来学习呢?如果你是刚入行的Java开发、准备上手Spring Boot项目的实习生,或者是从其他技术栈转过来想快速了解主流Java Web数据访问方案的朋友,这篇文章都能提供一个完整的视角。我会尽量把集成步骤、底层原理、实战案例和排查经验揉在一起讲,因为只讲用法不讲原理,遇到问题还是会抓瞎;只讲原理不讲用法,看完也上不了手。
1. 为什么Spring要配上MyBatis
1.1 项目里的真实痛点
我先问你一个问题:你现在做项目,写数据访问层的时候最怕什么?我自己的经验是,最怕的不是SQL写错,而是下面这几个问题。
第一,连接管理。每次操作数据库都要打开连接、用完再关,一旦某个分支忘了关,连接池就慢慢耗尽,线上偶发的Connection timeout基本都是这么来的。
第二,参数与结果的转换。JDBC里面给占位符赋值、从ResultSet里取字段,都是手工一行一行地setString、getInt。表一多,字段一多,这些代码又臭又长,还特别容易下标写错。
第三,SQL与代码的割裂。业务逻辑写在Service层,SQL散落在DAO类里,改一个字段要全项目搜索,命名还不统一。
而MyBatis恰好把这些痛点都堵上了。它帮你把连接创建、参数绑定、结果映射全部封装掉,你只需要定义Mapper接口,再写SQL语句,剩下的映射和参数处理都让框架来做。Spring再补上事务管理和数据源注入,整个数据访问层就非常清爽了。
1.2 Spring与MyBatis各自擅长什么
很多人把Spring和MyBatis放在一起说,但其实它们干的事情完全不同。Spring的核心是IoC容器和AOP,它管理的是“对象之间怎么协作”;MyBatis的核心是SQL执行引擎,它管理的是“Java对象与数据库之间怎么转换”。
打个比方:Spring是一个公司的行政部门,负责人员调度、资源分配和制度执行;MyBatis是具体干活的技术小组,负责把需求(SQL)落地成产出(数据)。没有Spring,MyBatis也能单独运行,但你在每个业务调用里都得自己去创建SqlSession、控制事务、管理连接;没有MyBatis,Spring连数据库就只剩JdbcTemplate或者JPA,要么你写更多模板代码,要么你把SQL的灵活性交出去。
所以Spring和MyBatis不是竞争关系,而是互补关系。Spring Boot出现之后,两者集成更加顺理成章:spring-boot-starter-jdbc负责数据源和事务,mybatis-spring-boot-starter负责把MyBatis的生命周期托管给Spring。
2. 环境准备与依赖引入
2.1 Maven坐标怎么配
先上结论,一个基于Spring Boot 2.7的项目,pom.xml里要加这么几样东西:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>这里我特意没有加spring-boot-starter-jdbc,因为mybatis-spring-boot-starter已经传递依赖了spring-boot-starter-jdbc,DataSource、JdbcTemplate、事务管理器这些都已经准备好了。
版本选择上我多说一句:如果你是Spring Boot 2.x,MyBatis starter用2.3.x;如果是Spring Boot 3.x,starter要用3.0.x及以上,同时JDK也得升到17以上。这个版本对应关系是踩坑高发区,别问,问就是我当时因为版本不匹配折腾了一下午。
2.2 配置文件详解
依赖加好之后,application.yml里最基本的配置长这样:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/test_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl每个配置项我都解释一下,因为这个看似简单的配置背后藏着很多细节。
driver-class-name如果你用的是MySQL 8.x,必须写成com.mysql.cj.jdbc.Driver,老版本的com.mysql.jdbc.Driver已经废弃了。url里面的serverTimezone参数也必须有,不写的话,高版本的MySQL驱动会抛出一个时区相关的异常,说是SQLException,其实就是驱动检测不到默认时区。
map-underscore-to-camel-case这个配置是我强烈建议打开的。因为很多表设计是user_name这种下划线命名,而Java实体类约定是userName这种驼峰命名,打开这个开关,自动映射就能完成下划线到驼峰的转换,不用你每个字段都写resultMap。但注意,它只对自动映射生效,如果你用了resultMap,就得在resultMap的column和property里显式指定。
log-impl配合StdOutImpl,可以在控制台直接看到每次执行的SQL语句、参数和返回结果,对于开发调试来说非常有用。生产环境建议关掉或者在日志框架里按级别过滤,避免敏感SQL全部打到日志文件里。
2.3 日志与SQL打印
说到日志,很多人有个误区:以为配置了log-impl就能打印SQL了。其实MyBatis打印SQL需要日志框架在DEBUG级别输出对应的logger。如果你只用StdOutImpl,它是直接System.out输出,日志级别不受log4j2控制。我更推荐用slf4j的方式:在logback.xml里加上
<logger name="com.example.demo.mapper" level="debug"/>这样就能按包名精确控制哪些Mapper的SQL需要打印,线上出了问题再开某个包,不用把整个应用的SQL都暴露出来。这个经验是我在生产环境排查慢SQL时总结出来的,好几次线上问题都靠这个定位。
3. 核心配置与初始化原理
3.1 SqlSessionFactoryBean与MyBatis的初始化流程
配置搞定了,接下来聊聊它是怎么跑起来的。很多人知道Spring集成MyBatis是个可用的黑盒,但对于里面到底发生了什么不清楚。其实整个过程就三步。
第一步,Spring容器启动时,mybatis-spring-boot-starter里的MybatisAutoConfiguration会自动生效。它读取你yml里mybatis.*开头的配置,生成一个SqlSessionFactoryBean。
第二步,SqlSessionFactoryBean实现了Spring的FactoryBean接口和InitializingBean接口。在afterPropertiesSet方法里,它会调用MyBatis本身的XMLConfigBuilder和XMLMapperBuilder,把配置文件解析成Configuration对象,把每个Mapper XML文件解析成MappedStatement集合。
第三步,Configuration对象构建出SqlSessionFactory,这个工厂再产生SqlSession,也就是MyBatis对一次数据库会话的封装。Spring为你做了一层包装:SqlSessionTemplate,它实现了SqlSession接口,并且把每一次方法调用都委托给线程绑定的SqlSession。
所以你会发现,你在业务代码里从没直接new过SqlSessionFactory,都是注入Mapper接口,然后框架在背后完成了一切。这里有一个很关键的细节:Spring容器里真正放的是SqlSessionTemplate,你注入的SqlSessionFactory其实是它内部的成员。
3.2 Mapper扫描机制
还有一个你需要知道的关键点:Mapper接口为什么注入进来就能用?
Spring Boot集成MyBatis时,默认会扫描启动类所在包以及子包下的所有Mapper接口。你只需要在启动类上加@MapperScan("com.example.demo.mapper"),或者给每个Mapper接口加@Mapper注解。两者区别在于:@MapperScan是批量扫描,建议在启动类上加一次;@Mapper是单个标记,适合接口不多的时候,而且每个都要加比较啰嗦。
扫描到Mapper接口之后,MyBatis会为每个接口生成一个动态代理对象。这个代理对象在调用方法时,会根据方法名找到MappedStatement,把参数通过TypeHandler转成数据库需要的类型,交给Executor执行,最后再把ResultSet映射回Java对象。这就是为什么你定义接口、写SQL就能直接用,因为代理帮你做了所有事情。
3.3 XML配置的工作流程与XMLConfigBuilder
很多面试题里会问你MyBatis的初始化工作流程,我这里把基于XML的场景完整串一遍。
MyBatis在Spring环境下启动时,首先通过XMLConfigBuilder解析mybatis-config.xml(如果没有,Spring Boot会自动生成一个带所有必要配置的Configuration对象)。解析顺序是:properties、settings、typeAliases、typeHandlers、objectFactory、objectWrapperFactory、plugins、environments、databaseIdProvider、mappers。
其中mappers节点,或者Spring Boot环境下的Mapper XML location配置,会触发XMLMapperBuilder解析每个Mapper XML。XMLMapperBuilder把
等你执行Mapper方法的时候,Executor会根据MappedStatement的id(通常就是Mapper接口的全限定类名加方法名)找到对应的SQL,调用JDBC执行。
这个过程听起来不复杂,但你真的理解了这套流程,后面排查各种“为什么我改了XML不生效”“为什么Mapper扫描不到”的问题,就会非常从容,因为你已经知道去哪看发生了什么。
4. 实战:从Mapper接口到CRUD
4.1 一个完整的注册功能
纸上谈兵没意思,直接来一个用户注册功能的实操,这次代码尽量完整,因为我知道很多人要的就是这种能直接抄的。
表结构很简单:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), created_time DATETIME DEFAULT CURRENT_TIMESTAMP );实体类:
public class SysUser { private Long id; private String username; private String password; private String nickname; private LocalDateTime createdTime; // getters/setters 省略 }Mapper接口:
public interface SysUserMapper { int insert(SysUser user); }Mapper XML:
<insert id="insert" parameterType="com.example.demo.entity.SysUser"> INSERT INTO sys_user (username, password, nickname) VALUES (#{username}, #{password}, #{nickname}) </insert>Service层:
@Service public class UserServiceImpl implements UserService { @Autowired private SysUserMapper sysUserMapper; @Transactional public Long register(SysUser user) { sysUserMapper.insert(user); return user.getId(); } }这里有个细节我要特别提醒:主键回填。MySQL的AUTO_INCREMENT字段如果不在INSERT语句里写,数据库会自动生成,但Java对象里的id依然是null。解决办法有两种:
第一种,在insert标签里加useGeneratedKeys和keyProperty:
<insert id="insert" useGeneratedKeys="true" keyProperty="id">这样MyBatis执行完JDBC的Statement.getGeneratedKeys(),把数据库自增的id填回user对象的id字段。第二种是手动在insert之前生成一个分布式ID(比如雪花算法),然后显式插入id字段。业务系统里这种情况更常见一点。
4.2 参数传递的坑
参数传递是我见过翻车最多的地方。新手最容易遇到的是:接口方法有两个参数,比如
List<SysUser> selectByUsernameAndStatus(String username, Integer status);SQL里如果直接写#{username},大概率会报错,告诉你找不到参数。原因很直接:Java编译后,参数名默认是arg0、arg1,不会保留源码里的名字。MyBatis在解析单个参数时,可以直接用任意名字取值;但一旦有两个或以上参数,就必须通过@Param注解显式指定名字:
List<SysUser> selectByUsernameAndStatus(@Param("username") String username, @Param("status") Integer status);SQL里写#{username}、#{status},完美对应。
还有一种新手特别容易绕晕的情况:SQL里用了#{},传进去一个List。你可能会想当然地写出WHERE id IN (#{list}),结果SQL执行报错,或者查出来结果不对。IN查询的正确姿势是使用foreach标签:
<select id="selectByIds" resultType="com.example.demo.entity.SysUser"> SELECT * FROM sys_user WHERE id IN <foreach collection="list" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>#{}是预编译占位符,它只会把一个值安全地放到占位符里,就像JDBC的PreparedStatement;而${}是字符串拼接,直接把值拼进SQL里,存在SQL注入风险,除非你是动态表名列名,否则不要用${}。这个区别我在文章里反复强调,因为我真的见过同事把用户输入的排序字段直接${}拼接,被扫描工具扫出高危漏洞的案例。
4.3 结果映射与TypeHandler
结果映射相对简单,但有两个话题值得展开:一个是resultType和resultMap的选择,一个是TypeHandler机制。
如果你只是查询结果映射到实体类,字段名能对得上,驼峰开关开了,那直接用resultType就够了。但如果你要做关联查询,比如查用户信息的时候顺带查出该用户的一篇博客,然后要把博客作为一个嵌套对象放进去,那就必须用resultMap的association。如果你要查一对多,比如一个用户有多篇文章,就用collection。
TypeHandler则是MyBatis很不起眼但非常强大的扩展点。它解决的问题是:Java类型和JDBC类型之间的双向转换。默认情况下基础类型、String、Date、LocalDate这些框架都内置了TypeHandler,但遇到一些特殊类型,比如一个JSON字段想映射成List<Map<String,Object>>,一个枚举想映射成数据库的字符串,默认就不够了。
解决办法是自定义TypeHandler,核心是继承BaseTypeHandler,实现setNonNullParameter和getNullableResult。举一个最简单的例子,把Java层的一个枚举直接映射成数据库的Int值。
@MappedTypes(StatusEnum.class) public class StatusEnumTypeHandler extends BaseTypeHandler<StatusEnum> { @Override public void setNonNullParameter(PreparedStatement ps, int i, StatusEnum parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } @Override public StatusEnum getNullableResult(ResultSet rs, String columnName) throws SQLException { return StatusEnum.of(rs.getInt(columnName)); } @Override public StatusEnum getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return StatusEnum.of(rs.getInt(columnIndex)); } @Override public StatusEnum getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return StatusEnum.of(cs.getInt(columnIndex)); } }配置在yml里加一行:
mybatis: type-handlers-package: com.example.demo.handler或者给字段单独指定,在XML的resultMap里加typeHandler属性。两种方式各有适用场景,包扫描适合全局通用类型,单独指定适合某个特殊字段。实际项目里TypeHandler用的最多的是:枚举转换、JSON字符串与对象互转、密文字段加解密。这三个场景都值得生产级应用。
5. 缓存机制详解
5.1 一级缓存
MyBatis的缓存分为两级,很多人面试被问到,但实操中往往没有感知。一级缓存是SqlSession级别的,默认开启,你没法完全关掉它,只能通过配置把localCacheScope从SESSION改成STATEMENT,把缓存作用范围改成每次语句执行完就清空。
概念是这样:同一个SqlSession对象,执行完全相同的SQL(包括相同的参数),第二次执行时直接命中一级缓存,不查数据库。Spring中,由于SqlSessionTemplate默认绑定在当前线程的事务范围内,所以一次事务里多次查询同一个Mapper的同一个方法、同样的参数,第二次会直接走缓存。
这一点在开发中其实是把双刃剑。好的方面是,在一个事务里,你查一次用户信息,后续接口多次用到,不用重复查库。坏的方面是,如果有人在同一个事务里把用户信息更新了,但更新操作没用MyBatis的Mapper去执行,或者干脆直接改数据库,一级缓存里还是旧数据,就会出现“在一个事务里先查后改再查,第二次查出来还是旧值”的现象。解决办法很简单:用同一个Mapper的update操作,MyBatis会在更新时自动清除一级缓存。
5.2 二级缓存
二级缓存是Mapper级别的,也就是跨SqlSession共享。它默认是关闭的,需要开启:
<cache eviction="LRU" flushInterval="60000" size="1024" readOnly="false"/>开启之后,同一个Mapper下的查询结果会保存在二级缓存中,并且可以配置为跨Session共享。但我要泼一盆冷水:业务系统里二级缓存真的用得很少,原因在于缓存一致性太难控制。你只要有一条UPDATE、INSERT、DELETE语句执行,MyBatis会立即清空该Mapper的二级缓存,但如果你的数据被别的系统、别的Mapper、或者直接通过SQL工具修改了,缓存就成了脏数据。
所以我的建议是:二级缓存适合那种数据基本不变、查询频率极高、接受一定时间不一致的场景,比如字典表、省份城市列表、静态配置等。核心业务表,尤其是用户相关的数据,不要开二级缓存,用Redis做业务缓存才是常规选择。
6. 常见问题与排查
6.1 条件不生效等常见坑
先列一个常见问题速查表,都是我实际遇到的。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| Mapper方法报Invalid bound statement (not found) | Mapper接口扫描到了,但XML没被解析 | 检查mapper-locations路径是否和XML实际位置一致 |
| 配置文件里的SQL不生效,改了半天没反应 | 项目热部署没有重新编译资源文件 | mvn clean,或者确保mapper XML在classpath下 |
| 查询返回null,字段全是null | 驼峰映射没开启,或者字段名对不上 | 打开map-underscore-to-camel-case,或写resultMap |
| 多参数查询报参数找不到 | 没加@Param | 加注解 |
| SQL里有大于号小于号报错 | XML标签被当作标签解析 | 用 < 和 > 或 |
| 返回List但MyBatis报TooManyResultsException | 期望单条结果但SQL返回多条 | 检查SQL,或改用List接收 |
这些坑每一个我都踩过。最典型的就是Invalid bound statement,我当时花了很多时间检查接口名和XML的namespace,最后发现是mapper-locations路径写错了。配置路径一定要和实际放置Mapper XML的目录完全一致,比如你把XML放在src/main/resources/mapper/下,配置就是classpath:mapper/*.xml。
6.2 排查技巧
排查MyBatis问题,我有一套自己比较固定的打法。
第一步,开日志。开发环境把log-impl设为StdOutImpl,或者按包名把Mapper包日志级别调到debug。你看到SQL语句执行情况,很多问题当场就能定位。
第二步,检查启动日志。Spring Boot启动时,MyBatis会打出TypeHandler注册信息、Mapper扫描到了哪些接口。如果你的Mapper没有被扫描到,启动日志里会有体现。
第三步,如果是SQL执行异常,不用急着看Java堆栈,先把堆栈里的Caused by拉到最后,通常是SQLException的具体原因。比如“Unknown column 'xxx' in 'field list'”,就是列名写错了。
第四步,如果怀疑一级缓存导致数据不一致,可以在事务里画出一条完整的查询链路,逐个排查最后一次查询结果来源。更快的办法:把localCacheScope改成STATEMENT,用日志确认是否还重复查询数据库,如果还是查的旧值,那基本是事务隔离级别或者缓存没刷新的问题,不是MyBatis的锅。
最后分享一点我在实际项目里的体会。Spring集成MyBatis这件事,入门确实简单,几个注解加一个XML就能跑起来,但真正想用好,理解框架背后那套初始化流程、Mapper代理机制和缓存模型,才是关键。很多生产环境里诡异的线上问题,最后定位下来,都不是MyBatis本身的bug,而是使用姿势出了问题——要么参数没加@Param,要么路径没对准,要么就是动了不该动的缓存。
如果你现在刚接触这套组合,建议按我上面第4节的注册功能完整跑一遍,然后手动制造几个故障,比如故意把路径写错、故意不加@Param,看看报错长什么样。这种“主动制造故障”的方式,比背面试题有效得多。等你对报错信息有了肌肉记忆,再去看那些面试题,你会发现答案其实都在报错里了。