1. Spring Boot中集成MyBatis,究竟应该怎么选型
先交代一下我的背景:我从SSM时代就开始用MyBatis,后来Spring Boot流行起来,第一件事就是把老项目往Boot上迁移。最近几年带团队面试,也经常被问到“Spring Boot里MyBatis到底怎么用”“MyBatis和JPA怎么选”,所以这篇东西我打算按照我实际项目里的使用经验来写,而不是把官方文档翻译一遍。
如果你刚接触这套组合,别慌,看完你不仅能跑通一个最基础的增删改查,还能理解Mapper接口为什么不需要写实现类、一级缓存和二级缓存什么时候会失效、TypeHandler到底在什么场景能救命。如果你是老手,重点看第4节缓存机制和第6节问题排查,这两个部分我踩过的坑比较多,写出来的东西应该能帮大家少走弯路。
先说结论:Spring Boot + MyBatis的组合,在当前企业级Java后端开发里依然是“快速出活”的主力方案。Spring Boot解决了框架配置的繁琐,MyBatis解决了SQL灵活控制的问题,两者互补性很强。相比JPA那种“面向对象操作数据库”的思路,MyBatis把SQL的执行权完全交还给你,对于复杂查询、多表关联、报表统计这类场景,控制力是JPA很难比拟的。
2. 从零开始:搭建一个可运行的Spring Boot + MyBatis项目
2.1 依赖引入的几个细节
用Spring Boot集成MyBatis,核心依赖不是mybatis本身,而是官方提供的启动器mybatis-spring-boot-starter。我见过不少新手直接引入mybatis和mybatis-spring,然后自己手工配置一堆Bean,这在早期确实可行,但既然用了Spring Boot,就应该用它特有的自动配置机制来简化工作。
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>注意几个点:第一,版本号不写的话,Spring Boot的依赖管理会自动帮你选一个匹配的版本,但建议还是显式声明,方便追踪。第二,MySQL 8以上版本的驱动类名是com.mysql.cj.jdbc.Driver,不是老的com.mysql.jdbc.Driver,这个很多教程没更新,照抄老配置会导致连接报错。第三,如果你的项目是Spring Boot 3.x,那么starter要用3.0对应版本,因为底层做了Jakarta包名的迁移。
2.2 application.yml里的核心配置
配置文件是很多人容易图省事只写数据源,其实MyBatis相关属性值得认真调一调。我习惯的配置模板长这样:
spring: datasource: url: jdbc:mysql://localhost:3306/test_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver 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逐一说明:
mapper-locations:指定XML文件的位置。有人问为什么不写也能跑?因为默认值是classpath:mapper/*.xml,你只要保证XML都在这个目录下就行。我的习惯是显式写明,防止以后调整目录结构时忘了改。type-aliases-package:实体类的别名包,开启后XML里写resultType可以直接用类名,不用带全限定名。map-underscore-to-camel-case:下划线转驼峰映射。数据库字段create_time自动映射到Java属性createTime,不开启就等着字段全是null吧。log-impl:控制台打印SQL。开发环境强烈建议打开,线上则改成Slf4jImpl配合日志框架统一管理。
提示:
configuration节点里的配置项实质上对应MyBatis的org.apache.ibatis.session.Configuration类的setter方法,如果你对MyBatis原生配置项足够熟悉,在这里配置的逻辑是一样的。
2.3 最小可运行的代码骨架
一个最简项目至少要包含:实体类、Mapper接口、XML文件(或注解SQL)、Service层、Controller层。我直接贴核心代码。
实体类:
public class User { private Long id; private String username; private String email; private Date createTime; // getter/setter 省略 }Mapper接口:
@Mapper public interface UserMapper { User selectById(@Param("id") Long id); List<User> selectAll(); int insert(User user); int update(User user); int deleteById(Long id); }XML文件(放在resources/mapper/UserMapper.xml):
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <select id="selectById" resultType="User"> select * from user where id = #{id} </select> <select id="selectAll" resultType="User"> select * from user </select> <insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id"> insert into user(username, email) values(#{username}, #{email}) </insert> <update id="update"> update user set username = #{username}, email = #{email} where id = #{id} </update> <delete id="deleteById"> delete from user where id = #{id} </delete> </mapper>有人问:Mapper接口不写实现类也能工作,原理是什么?这就要说到MyBatis给你生成的代理对象了。Spring Boot启动时,@MapperScan或@Mapper注解会把接口扫描进来,MyBatis通过MapperProxy为每个接口生成一个JDK动态代理。你调用接口方法时,代理会根据方法名去namespace匹配对应的SQL语句,执行并返回结果。所以,Mapper接口只是一个约定,真正的逻辑在XML和代理里。
2.4 Service和Controller没什么特别的
Service层就是普通Spring Bean,注入Mapper即可:
@Service public class UserService { @Resource private UserMapper userMapper; public User getUserById(Long id) { return userMapper.selectById(id); } }Controller简单暴露接口,这样整套请求链路就通了。到这里,一个小而完整的项目已经能跑通注册、查询、修改、删除的基本功能,对应很多热词里提到的“第1关:项目整合 - springboot + mybatis”和“第2关:使用springboot + mybatis实现一个最简单的注册功能”,其实本质就是把上面这套骨架走一遍。
3. 深入原理:Spring Boot启动时MyBatis到底做了什么
3.1 自动配置的装配链路
很多人用MyBatis用得很溜,但问到底层发生了什么就说不清了。这块其实是面试高频点,也是排查问题的基础。我梳理一下启动时的关键动作。
mybatis-spring-boot-starter里的MybatisAutoConfiguration是核心。它做了以下几件事:
第一,构造SqlSessionFactory。SqlSessionFactory是MyBatis所有操作的入口,它读取你配置的mapper-locations、type-aliases-package、configuration等属性,解析XML里的SQL语句,构建出完整的MappedStatement集合。
第二,注册SqlSessionTemplate。SqlSessionTemplate可以理解为线程安全的SqlSession代理。MyBatis原生使用的DefaultSqlSession不是线程安全的,每个线程应该独立持有,但在Spring管理下,SqlSessionTemplate通过对SqlSession的代理和事务同步,解决了这个问题。
第三,扫描Mapper接口。通过@MapperScan或@Mapper,Spring把这些接口注册为Bean。注意,这时的Bean实际上是一个FactoryBean,生产的是前面提到的代理对象。
整个链路可以用一句话概括:Spring Boot启动时解析你的配置,生成SqlSessionFactory,然后用工厂创建代理对象注入到使用Mapper的地方。理解了这条链路,后面遇到“Mapper无法注入”“SQL语句找不到”“配置文件不生效”这类问题,你排查起来就有方向了。
3.2 MapperProxy与SQL执行过程
每次你调用userMapper.selectById(1L)时,具体发生了什么?
首先,MapperProxy拦截到这次调用,发现对应的MapperMethod已经缓存了方法的解析结果。MapperMethod里有SqlCommand,它记录了这条SQL的类型是SELECT还是UPDATE,以及对应的MappedStatement的id。接着,通过SqlSessionTemplate获取SqlSession,执行selectOne或update方法。执行时会通过参数处理器把Java对象转换成JDBC能识别的类型,再交给数据库驱动执行。返回结果经过ResultSetHandler映射成实体对象,但这步涉及的细节很多:比如自动映射如何处理下划线、类型转换如何判断INTEGER到Long的转换等。
这里面有一个非常实用的点:MyBatis对参数的处理。#{}和${}的区别很多人云亦云。#{}使用的是PreparedStatement的占位符,数据库会预编译,能有效防SQL注入;${}是字符串拼接,直接把值拼进SQL,存在注入风险。但在动态排序(order by ${column})场景中,排序字段不能作为占位符,只能用${},这时候你就必须在业务层做白名单校验。我项目里通常维护一个允许排序的字段集合,防止用户传入任意字符串。
3.3 XML初始化与Configuration对象的角色
之前列出的热词里出现了mybatis中xmlconfigbuilser、mybatis中基于xml配置的初始化工作原理,这些引出了MyBatis底层初始化常谈的XMLConfigBuilder。它本质是负责把XML配置解析为Configuration对象。但要注意,Spring Boot整合场景下,大多数配置已经通过Spring Boot的配置项处理,XMLConfigBuilder更多是MyBatis单独使用时的主角。我们在Spring Boot里遇到的摸错摸错,通常还是去检查mybatis.configuration.*前缀的属性是否生效。
有个值得养成的习惯:在项目启动后,打印一份SqlSessionFactory对象,看看它内部的Configuration对象包含了哪些MappedStatement。写一个ApplicationRunner,在启动完成后遍历:
@Component public class SqlCheckRunner implements ApplicationRunner { @Resource private SqlSessionFactory sqlSessionFactory; @Override public void run(ApplicationArguments args) { Configuration configuration = sqlSessionFactory.getConfiguration(); configuration.getMappedStatementNames().forEach(System.out::println); } }这个方法能帮你确认XML是否被成功解析、接口和Statement是否注册成功。如果XML有语法错误,Boot启动时就会直接报错,但如果是Statement命名空间不对,错误可能要等到调用时才暴露。提前打印,能少很多排查时间。
4. MyBatis缓存机制:一级缓存和二级缓存的实践与陷阱
4.1 一级缓存为何“查不到最新数据”
MyBatis的一级缓存默认是开启的,作用范围是SqlSession。在Spring Boot里,SqlSession的生命周期跟着事务走。如果你在一个不使用事务的Service方法里连续调用两次selectById(1L),由于每次调用都是在同一个SqlSessionTemplate里执行的,如果中间没有update/insert/delete操作,第二次查询会直接命中缓存,不会真正访问数据库。
这个特性有好有坏。好在同一个请求内相同参数的重复查询性能很高;坏在如果你在同一事务里先查了数据,然后其他线程修改了数据库,你的事务里再查还是旧数据。这种情况往往让人摸不着头脑。排查思路是看日志:如果第二条查询没有打印SQL语句,说明它走了一级缓存。如果你希望强制刷新,可以在Mapper方法上加flushCache=true,或者让查询方法带上@Options(flushCache = Options.FlushCachePolicy.TRUE)。不过我的建议是:不要过度依赖一级缓存,它的生命周期太短,控制不了反而容易出问题。
4.2 二级缓存:根治你的“查询快”还是“脏数据”选择题
二级缓存是跨SqlSession的,作用范围是同一个namespace(即同一个Mapper接口)。默认不开启,需要显式配置。配置方式分两步:
首先在Mapper XML中添加缓存声明:
<mapper namespace="com.example.demo.mapper.UserMapper"> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/> </mapper>然后在需要缓存的查询语句上设置useCache="true"(默认就是true)。
eviction选项有LRU、FIFO、SOFT、WEAK几种。实际项目我用得最多的是LRU,即最近最少使用,缓存满后淘汰最久没使用的数据。flushInterval设置缓存刷新间隔,单位毫秒。size是缓存容量。readOnly=true表示返回的缓存对象直接复用,性能好但修改会影响缓存内容;readOnly=false会做序列化拷贝,安全但性能差。实体类要实现Serializable接口才能支持读写缓存。
二级缓存最大的坑在于:同一个namespace的insert/update/delete会自动清空缓存,但如果你在别的Mapper里直接更新了这张表的数据(比如UserLogMapper里通过联表更新了user表),当前namespace的二级缓存根本感知不到,就会返回脏数据。所以,如果你的表被多个Mapper操作,开二级缓存要非常谨慎。我通常只在数据很少变化、且只被单一Mapper访问的字典表上开启二级缓存,业务表一律不开。
4.3 缓存总结速查表
| 项目 | 一级缓存 | 二级缓存 |
|---|---|---|
| 默认状态 | 开启 | 关闭 |
| 作用范围 | SqlSession | namespace级别 |
| 生命周期 | 短(事务或会话结束即失效) | 长(直到flushInterval或被清空) |
| 冲突风险 | 同事务内读到旧数据 | 跨Mapper更新会被无视 |
| 适用场景 | 默认即可 | 低频更新的字典表 |
很多面试题会把一级缓存、二级缓存、cache-ref、@CacheNamespace等等打包考你,核心不是背概念,而是说明清楚在Spring Boot的项目中,缓存生效的边界在哪里——因为Spring Boot的事务管理方式决定了SqlSession的边界,这个边界找对了,缓存问题就解了一半。
5. TypeHandler:为什么它能让你少写几十行转换代码
5.1 什么是TypeHandler,何时需要自定义
Java类型和JDBC类型之间不是天然一一对应的。比如数据库有一个json字段,你希望映射到Java的Map<String, Object>;或者你存了一个枚举类型,希望用数字存进数据库但Java代码里还是枚举对象。这些场景,默认的TypeHandler无法覆盖,你就需要自定义。
MyBatis里处理类型转换的组件就叫TypeHandler,核心接口就两个方法:setParameter把Java值转成JDBC值,getResult把结果集的值转成Java值。最常见的自定义TypeHandler之一就是枚举映射。我举一个实际例子:假设有订单状态枚举OrderStatus { PENDING, PAID, SHIPPED, COMPLETED },你希望在数据库里存一个int(0/1/2/3),Java里直接操作枚举。
5.2 自定义枚举TypeHandler实例
实现如下:
@MappedTypes(OrderStatus.class) @MappedJdbcTypes(JdbcType.INTEGER) public class OrderStatusTypeHandler extends BaseTypeHandler<OrderStatus> { @Override public void setNonNullParameter(PreparedStatement ps, int i, OrderStatus parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.ordinal()); } @Override public OrderStatus getNullableResult(ResultSet rs, String columnName) throws SQLException { int code = rs.getInt(columnName); return OrderStatus.values()[code]; } @Override public OrderStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException { int code = rs.getInt(columnIndex); return OrderStatus.values()[code]; } @Override public OrderStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { int code = cs.getInt(columnIndex); return OrderStatus.values()[code]; } }注册方式有两种:一种是在配置里指定:
mybatis: type-handlers-package: com.example.demo.handler另一种是在XML里显式指定:
<resultMap id="OrderResultMap" type="Order"> <result column="status" property="status" typeHandler="com.example.demo.handler.OrderStatusTypeHandler"/> </resultMap>我个人的建议是:能用type-handlers-package扫描就尽量扫描,因为一旦某个实体字段忘了指定TypeHandler,默认会按Integer处理,大概率报错或不生效。扫描方式全局生效,比较省心。
5.3 工作中的实际收益
用了TypeHandler之后,Service层的代码干净很多。以前你要手动判断枚举的code值,现在直接拿枚举对象做逻辑判断,赋值也直接传枚举。持久层、业务层、展示层之间的类型是统一的,维护成本直线下降。
另外再分享一个复杂场景:数据库存的JSON字符串,想直接映射成Java的JSONObject。项目里我实现过一个JsonTypeHandler,内部用的是Hutool或FastJSON解析字符串,查询时把字符串parse成JSONObject,插入时toJSONString。这套方案在配置表、扩展字段这类场景里非常实用,替我省了不知道多少VO转换代码。
6. 常见问题与排查技巧实录
6.1 Mapper接口无法注入
报错信息一般是Consider defining a bean of type 'xxxMapper' in your configuration。原因和解决办法:
- 忘了在主类上使用
@MapperScan("com.example.mapper"),或者Mapper接口上没标@Mapper - 单纯检查一下扫描包的路径对不对,别写错包名。
- 有多个数据源时,要指定不同的
@MapperScan和sqlSessionFactoryRef,这块我改天单独写一篇。
6.2 SQL参数条件不生效
热词里有“mybatis条件不生效”,这属于动态SQL常见问题。我列几个典型:
第一,<if test="name != null">里判断的是JavaBean的属性,不是数据库列名。你如果写test="user_name != null",这不成立,因为实体里没有userName属性(或字段名不匹配),条件永远为false。
第二,传参是单个基础类型没有加@Param。比如List<User> selectByName(String name),XML里写#{name}在低版本MyBatis里会报错找不到参数,虽然新版本默认用param1这样也能取到,但可读性太差。建议单参数也加@Param("name")。
第三,<if>标签内部的条件写错了逻辑。最常见的莫过于有“查询全部”和“按条件查询”切换时,忘记处理空值导致SQL语法错误。这里有个技巧:用<where>标签替代手工写where 1=1,它会自动移除前缀的WHERE或者多余的AND/OR。
<select id="selectByCondition" resultType="User"> select * from user <where> <if test="username != null and username != ''"> and username like concat('%', #{username}, '%') </if> <if test="email != null"> and email = #{email} </if> </where> </select><where>标签会自动处理第一个and,你不需要在SQL里写死where 1=1,体验好很多。
6.3 打印SQL却看不到参数值
如果你配置了log-impl: StdOutImpl,控制台会打印SQL语句,但它打印的是带?占位符的SQL和参数列表,参数值是在下一行以Parameters:开头的。新手容易以为自己SQL写错了,其实只要看到Parameters: [1, zhangsan]这行就说明参数绑定成功。多提一嘴,如果你想看完整的可执行SQL,可以配置MyBatis的configuration.log-impl配合p6spy这类工具来实现,不过我一般调试阶段用StdOut就够了,线上用日志文件。
6.4 版本冲突与Spring Boot 3.x迁移
热词里提到了“springboot版本太高”和一些新版本适配问题。Spring Boot 3.0之后,javax命名空间迁移到了jakarta,很多老项目升级时Mapper接口本身没大问题,但如果有代码直接用了javax.annotation.*、javax.sql.*,编译期就会报错。另外,MyBatis官方starter对Spring Boot 3的支持直到3.0.x版本才跟上,强烈建议升级前先查一下兼容矩阵:
| Spring Boot版本 | 推荐starter版本 |
|---|---|
| 2.x | 2.3.x |
| 3.x | 3.0.x |
| 3.2+ | 3.0.4或3.0.5 |
6.5 JDBC连接配置的隐藏坑
数据库URL里一定记得加serverTimezone=Asia/Shanghai,否则MySQL 8以上版本连接时会因为默认时区问题抛异常。另外,useSSL=false建议显式写,本地开发环境没有SSL证书时,true会告警甚至报错。
6.6 简单问题速查表
| 现象 | 常见原因 | 建议动作 |
|---|---|---|
| 实体属性全是null | 未开启下划线转驼峰 | map-underscore-to-camel-case: true |
| 控制台没有SQL | log-impl未配置 | 配置StdOutImpl |
| Mapper无法注入 | 缺扫描或注解 | 加@MapperScan或@Mapper |
| 动态SQL条件不生效 | 条件判断属性名写错 | 检查test中Java属性名 |
| 批量插入太慢 | 使用单条insert语句 | 用<foreach>批量拼接或ExecutorType.BATCH |
7. 性能优化与最佳实践建议
7.1 批量操作的正确打开方式
很多初学者批量插入是循环调insert,这会导致数千次网络往返和事务开销。正确做法有两种:
第一种,XML里的<foreach>批量拼接:
<insert id="batchInsert"> insert into user(username, email) values <foreach collection="list" item="user" separator=","> (#{user.username}, #{user.email}) </foreach> </insert>注意foreach拼接的SQL字符串不能超过数据库的max_allowed_packet限制,一般几百上千条没问题,再多就需要分批了。
第二种,MyBatis内置的ExecutorType.BATCH。在Spring Boot里不用手工操作SqlSession,重写一个事务模板即可,相关代码网上都有完备方案。我项目里的经验是:几百条以内用<foreach>,几千条以上用BatchExecutor,且必须配合事务,否则BatchExecutor的SQL不会真正执行。
7.2 善用@MapperScan和@Mapper的区别
@Mapper加在单个接口上,@MapperScan加在配置类或主类上批量扫描包。两者效果一样,只是粒度不同。我建议在启动类上用@MapperScan("com.example.mapper"),这样每个Mapper接口不用重复标注,代码干净些。如果是多数据源场景,@MapperScan还能指定对应的sqlSessionFactoryRef,比单用@Mapper灵活得多。
7.3 设计Mapper层时,XML好还是注解好
这是个老生常谈,但我的观点很明确:复杂SQL用XML,简单CRUD用注解。注解方式(@Select、@Insert等)优点是零配置、快速,适合两三个字段的简单查询;但只要SQL变长、涉及动态条件,注解里的<script>标签写起来极其痛苦,维护性也很差。XML方式虽然多了个文件,但语法提示、格式化、版本管理都友好,团队协作时更清晰。我甚至还见过有人把注解SQL写到几百行,纯粹是给后人埋坑。
7.4 监控SQL执行时间与慢SQL排查
MyBatis不支持直接打印耗时,但你可以通过MybatisInterceptor实现一个简单的MyBatis插件,统计每个Mapper方法的执行时间。核心思路是实现Interceptor接口,在invoke方法里记录方法调用的起止时间。这个插件对排查慢SQL特别有用,尤其是接口响应时间突然变长时,你能快速定位是数据库慢还是业务逻辑慢。关于插件,有一个细节必须注意:插件可以拦截Executor、ParameterHandler、ResultSetHandler、StatementHandler,但不要随意拦截Executor执行之外的地方,否则容易导致NPE。
7.5 多数据源场景下的注意事项
如果你的系统需要同时连多个数据库(比如主从分离,或者业务库加统计库),MyBatis集成复杂度会立刻上升。你需要为每个数据源独立配置DataSource、SqlSessionFactory、MapperScan指向不同的Mapper包。很多踩坑的人往往忘了为每个数据源设置独立的transactionManager,导致事务只对默认数据源生效。Spring Boot里推荐使用@Primary标注主数据源,再用@Qualifier指定次数据源的Bean名称。这块不是本文主旋律,但我还是提醒一句:不要等项目上线了才想起来多数据源问题,架构设计阶段就该把Mapper包按数据源边界拆分清楚。
8. 我的近十年使用体检:什么情况下我会弃用MyBatis
刚才提到我一直在用MyBatis,但不代表它适合所有项目。如果你遇到以下情况,建议考虑其他方案:
第一,你只需要极简单的CRUD且不需要复杂的动态SQL,团队又不熟悉SQL调优,用Spring Data JPA反而效率更高。JPA的自动建表能力在项目早期很舒服。
第二,你的项目以报表和数据仓库为主,查询都是稀碎的外连接、窗口函数和超大结果集,MyBatis虽然能写,但逐条映射的开销不低,这时候不如直接上Jooq或者干脆用SQL模板引擎配合JDBC。
第三,你的团队完全没有SQL功底,写出来的SQL连索引都不会走,那用MyBatis只会加速生产事故。
但我个人体会是,大多数业务系统、权限系统、工作流平台、后台管理系统,MyBatis的灵活性和可控性恰好是最匹配的。你把SQL握在自己手里,数据库索引设计和查询优化都有明确的发力点,排查问题也直截了当——看SQL一眼基本就能判断是索引没走还是条件写错了。相比之下,我在几个JPA项目里遇到“明明看着是同一个查询,结果却多出一堆关联条件”的诡异问题时,调试成本真心不低。
最后再分享一个日常开发的小习惯:每次新建Mapper或改动XML后,先运行一遍项目里最小的测试用例,确认SQL能正确解析,而不是等到联调阶段才暴露。写这篇文章时,我还在项目里保留了一个SqlCheckRunner的启动检查类,作用就是打印所有已注册的Statement名称,谁动过XML,启动时立刻见分晓。这套组合拳打下来,Spring Boot + MyBatis的项目我基本没有遇到过部署后才发现的SQL配置问题,希望对你们也有帮助。