前两天把一个内部项目从SpringBoot 2.7往SpringBoot 3.2上迁移,代码层面倒是没费多大劲,反而是Mybatis这一块的整合让我踩了几个意想不到的坑。网上讲SpringBoot2整合Mybatis的教程一抓一大把,但到了SpringBoot3,情况确实变了不少——JDK版本要求、依赖坐标、自动配置的行为都有变化,照着旧教程抄很容易一上来就编译报错或者启动失败。这篇文章把我这次整合的全过程整理出来,包括版本选型、配置细节、报错排查和一些进阶玩法。新手可以直接照做,老手也能对应查漏补缺,少走点弯路。
1. 项目基础与版本选型:先别急着写代码
1.1 SpringBoot3带来的两个关键变化
SpringBoot 3.0发布后,底层有两件事直接影响了Mybatis整合方式:JDK17基线、javax到jakarta的命名空间迁移。
先说JDK17。SpringBoot3是基于Spring Framework 6构建的,官方最低要求就是JDK17。这意味着你在SpringBoot2时代常用的那些依赖,如果没有同步升级,很有可能会出现类加载、自动配置时机不兼容的问题。Mybatis官方给出的适配方案也很明确:想要在SpringBoot3上用Mybatis,必须使用mybatis-spring-boot-starter的3.x分支,而不是老的2.x分支。2.3.x系列是给SpringBoot 2.x准备的,强行走Boot3会出现类似Invalid value type for attribute 'factoryBeanObjectType'的启动报错,原因就是Mybatis-Spring 2.x里的FactoryBean与Spring6的新机制不兼容。
然后是javax到jakarta的迁移。SpringBoot3全面采用了Jakarta EE 9+规范,之前常见的javax.annotation.Resource、javax.validation.constraints都改成了jakarta.*。如果你的实体类、公共模块里有这些旧路径的依赖,编译期就会直接挂掉。不过对于Mybatis本身来说,这个迁移的影响相对小,因为Mapper接口、XML映射文件用的都是Mybatis自己的类型和注解,和Servlet、Validation这类规范没有直接关系。改动主要集中在项目里的其他依赖上,比如要换jakarta.validation-api、spring-boot-starter-validation这类坐标。
很多人一上来就纠结“为什么我的代码和旧教程一模一样,却起不来”,其实根因往往就在这两个底层变化上。SpringBoot3不是简单的版本号变大,它的自动配置机制和Spring6的AOT、原生镜像支持都绑在一起,依赖选型必须跟着新生态走。
1.2 Mybatis官方适配版本与依赖坐标
我实测下来比较稳妥的版本组合是这样:
| 组件 | 版本 | 说明 |
|---|---|---|
| SpringBoot | 3.2.x | 3.2比较成熟,JDK17+ |
| mybatis-spring-boot-starter | 3.0.3 | 适配Boot 3.0/3.1/3.2 |
| mybatis | 3.5.16 | starter 3.0.3内置 |
| mybatis-spring | 3.0.4 | starter 3.0.3内置 |
| mysql-connector-j | 8.x | SpringBoot3.2默认管理的版本 |
一个非常常见的坑是:很多老资料里写的是org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.2,如果你在SpringBoot3项目里照抄,大概率启动直接抛异常。这时候别怀疑自己代码有问题,先看版本。
另外从MySQL驱动角度,SpringBoot3的依赖管理里已经默认使用com.mysql:mysql-connector-j,旧坐标mysql:mysql-connector-java已废弃。如果你是Oracle数据库,对应要引入com.oracle.database.jdbc:ojdbc11,并且注意不同版本Oracle驱动对应不同的JDK要求。
依赖坐标选对之后,Mybatis的自动配置基本不需要你写一行@Configuration,因为mybatis-spring-boot-autoconfigure会在DataSource存在的前提下,自动注册SqlSessionFactory和SqlSessionTemplate。这也是SpringBoot3整合Mybatis比老式SSM整合舒服的地方,框架已经帮你把大部分样板代码封装完了。
2. 完整整合实操:从空项目到第一条SQL跑通
2.1 初始化工程与依赖导入
我习惯先用Spring Initializr生成一个空工程,选好SpringBoot版本和JDK17,然后手动往pom.xml里加Mybatis相关依赖。最终核心的依赖大概是这样的:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> </dependencies>注意mybatis-spring-boot-starter这个坐标要带版本号,因为SpringBoot的BOM并不管理它。很多初学者在这里漏写版本,然后Maven直接报错找不到依赖,或者静默拉到了一个旧版本。
如果你不需要Web能力,也可以不加spring-boot-starter-web,单纯做一个服务端任务。但大部分业务场景都需要HTTP接口,所以我一般保留web starter。后续如果要用分页、通用Mapper这些增强工具,再在这个基础上追加对应依赖,这一点放到后面进阶章节细说。
2.2 编写启动类、实体、Mapper与XML
工程结构我通常这样组织,简单清晰:
com.example.demo ├── DemoApplication.java ├── controller ├── service ├── mapper ├── entity └── resources └── mapper └── UserMapper.xml启动类上需要加@MapperScan,把Mapper接口所在的包告诉Mybatis:
package com.example.demo; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }有人会问:那Mapper接口上还要不要加@Mapper?如果你已经用了@MapperScan,接口上可以不加;如果你没加@MapperScan,就必须在每个Mapper接口上标@Mapper。这两种方式本质上一个靠包扫描,一个靠逐一声明,我建议直接统一用@MapperScan,省得漏标。
实体类就是一个普通的POJO:
package com.example.demo.entity; import java.time.LocalDateTime; public class User { private Long id; private String name; private String email; private LocalDateTime createTime; // getter / setter }Mapper接口:
package com.example.demo.mapper; import com.example.demo.entity.User; import org.apache.ibatis.annotations.Param; import java.util.List; public interface UserMapper { User selectById(@Param("id") Long id); List<User> selectAll(); int insert(User user); }对应的UserMapper.xml放在src/main/resources/mapper目录下:
<?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="com.example.demo.entity.User"> select id, name, email, create_time from user where id = #{id} </select> <select id="selectAll" resultType="com.example.demo.entity.User"> select id, name, email, create_time from user </select> <insert id="insert" parameterType="com.example.demo.entity.User" useGeneratedKeys="true" keyProperty="id"> insert into user (name, email, create_time) values (#{name}, #{email}, #{createTime}) </insert> </mapper>这里有几个点要说一下。resultType我习惯写全限定类名,这样最稳。虽然可以通过type-aliases-package配置别名,但全限定名在IDE里能直接跳转,重构时也更安全,不会因为某个别名没改过来导致运行时才报错。useGeneratedKeys="true"配合keyProperty="id"是MySQL自增主键回填的标准写法,插入后实体的id会被自动填充,这个我强烈推荐保留。
2.3 核心配置参数详解
接下来是application.yml,这里集中了Mybatis整合的几乎所有关键配置:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 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.slf4j.Slf4jImpl logging: level: com.example.demo.mapper: debug逐行看几个重点。
mapper-locations指定XML文件位置,这里用通配符classpath:mapper/*.xml,它决定Mybatis去哪里加载SQL映射文件。如果这个配置忘写,而你的Mapper方法又依赖XML里的SQL,运行时就会报Invalid bound statement (not found)。
type-aliases-package是类型别名扫描包。配了它之后,在XML里可以把com.example.demo.entity.User简写成User或user。不过还是那句话,为了可读性和可追踪性,我一般不在XML里用别名,保持全限定名。
configuration.map-underscore-to-camel-case是下划线转驼峰映射开关。数据库字段create_time默认不会自动映射到Java属性createTime,打开这个开关后,Mybatis会在结果集映射时自动把下划线风格转成驼峰风格。这一步能省掉大量resultMap和as别名,属于必开项。
log-impl使用Slf4jImpl而不是网上常见的StdOutImpl,原因我在下一章详细说。
3. 核心配置背后为什么这样写:关键参数拆解
3.1 驼峰映射:省掉90%的字段映射配置
map-underscore-to-camel-case: true这个配置看起来简单,实际是Mybatis日常开发中最实用的一个开关。
它的原理是:Mybatis在结果集映射阶段,默认把数据库返回的列名直接作为Java属性的匹配值。如果你的表列叫create_time,Java属性叫createTime,两者对不上,最终结果就是该字段为null。很多人排查半天发现查出来的数据明明是有的,但对象里这个字段就是空,往往就是这个开关没打开。
打开之后,Mybatis内部会通过MapUnderscoreToCamelCase把下划线风格的名字转换成驼峰风格,再和Java属性匹配。这个转换只影响结果集映射,不会影响你SQL里写的别名。比如你SQL里写select create_time as ct from user,那映射匹配的就是ct这个别名,而不是把create_time转成createTime。
所以在建表规范里,我建议数据库字段统一用下划线风格,Java属性统一用驼峰风格,然后打开这个开关。这样90%的场景都不需要手写resultMap,写起来很痛快。
3.2 SQL日志打印实测(log-impl搭配logback)
日志打印配置是排查SQL问题最有效的工具,但很多人配置完发现日志没打出来,或者打印格式很乱,问题往往出在log-impl和日志框架搭配上。
StdOutImpl是Mybatis自带的输出实现,直接把SQL日志打到标准输出。它简单粗暴,不需要额外配置,但不走日志框架,不方便做级别控制,也不方便按包名过滤。所以我更推荐Slf4jImpl,配合SpringBoot默认的Logback使用,这样logging.level.com.example.demo.mapper=debug就能精细控制只打印Mapper包的SQL。
完整一点的logback-spring.xml长这样:
<configuration> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <logger name="com.example.demo.mapper" level="debug" additivity="false"> <appender-ref ref="CONSOLE"/> </logger> <root level="info"> <appender-ref ref="CONSOLE"/> </root> </configuration>注意Mybatis打印SQL日志的logger名是Mapper接口的全限定名,所以logger name="com.example.demo.mapper"可以覆盖所有Mapper接口。如果你的Mapper包很深,也可以直接用com.example.demo或者更上层的包名。
打印出来的日志不是一条完整的SQL,而是两部分:Preparing:显示的是预编译后的SQL模板,Parameters:显示的是实际绑定的参数。比如:
Preparing: select id, name, email, create_time from user where id = ? Parameters: 1(Long)这个设计是因为JDBC预编译机制本身就不拼接字符串,参数是单独设置到PreparedStatement上的。很多初看日志的人会疑惑“为什么SQL里不是直接拼好的id=1”,这是正常现象,不是Bug。
3.3 类型别名与包扫描路径的取舍
type-aliases-package和@MapperScan这两个配置都涉及包路径,但它们扫描的目标完全不同。type-aliases-package扫描的是实体类,用于生成类型别名;@MapperScan扫描的是Mapper接口,用于注册映射器。很多人把它们混在一起,导致报错时思路混乱。
对于type-aliases-package,我建议如果你项目里有大量实体类,并且XML里想少写几个字,可以配置它。但要注意别把包路径写得太宽,比如写成com.example,然后包下有很多DTO、VO、请求参数类,它们也会被扫描进来生成别名,一是启动耗时增加,二是可能出现同名类冲突。Mybatis遇到同名的不同类,启动时会直接报TypeAliasRegistry冲突异常,这时候大多数人会一脸懵,其实只要把扫描范围缩小到真正需要起别名的实体包就行。
对于@MapperScan,它会导致Mapper接口被Spring代理并注册到容器。如果你同时还有@Mapper注解,扫描范围交叉了也没关系,Mybatis会自动去重。但要注意,如果你有多个数据源,@MapperScan一定要指定对应的sqlSessionFactoryRef,否则所有包都用同一个SqlSessionFactory,数据源就串了。
4. 常见报错排查与实测记录
4.1 Invalid bound statement (not found)
这是整合Mybatis时最高频的报错,没有之一。报错信息长这样:
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.example.demo.mapper.UserMapper.selectById意思是Mybatis在启动时虽然注册了UserMapper接口的代理,但在解析SQL映射时没有找到selectById这个statement。排查顺序我整理成一个清单:
UserMapper.xml的namespace是否等于com.example.demo.mapper.UserMapper,一个字母都不能差。- XML里
<select>的id是否等于Mapper接口的方法名。 application.yml里的mapper-locations是否覆盖了XML文件所在的目录。最常见的是classpath:mapper/*.xml,如果你的XML放在resources/mapper下,这个配置没问题。target/classes下有没有这个XML文件?如果XML放在src/main/java目录里,Maven默认不会把它打包到classes,所以会找不到。
针对第4条,如果非要XML和Mapper接口放同一个包,你需要改Maven构建配置:
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> <resource> <directory>src/main/resources</directory> </resource> </resources> </build>不过我还是要劝一句:XML统一放src/main/resources/mapper是更省心的方案,不需要额外配置Maven资源过滤,目录职责也更清晰。
4.2 启动报错 Failed to configure a DataSource
这个报错出现得非常无厘头,因为它经常在你什么都没写错的情况下出现:
*************************** APPLICATION FAILED TO START *************************** Description: Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.看到这个报错,先别急着检查application.yml里的url写没写,大概率是缺数据库驱动依赖。SpringBoot的DataSource自动配置需要根据classpath上的驱动类型来创建连接池,如果mysql-connector-j没加进去,或者scope是provided导致运行时不可见,它就无法从url推断出连接类型,最终抛这个错。
另一种可能:你确实改了配置,但配置文件名不对。SpringBoot只能识别application.yml或application.yaml,如果你写了app.yml,那就不生效。
4.3 日期时间类型映射错误
新老项目切换时,日期时间类型最容易出问题。典型报错:
org.apache.ibatis.type.TypeException: Could not set parameters for mapping: ParameterMapping{property='createTime', jdbcType=OTHER ...}这个问题的关键在于Java类型和数据库类型不匹配。比如Java用了LocalDateTime,数据库是MySQL的datetime,一般没问题;但如果是Oracle的DATE类型,或者某些特殊的驱动实现,Mybatis在调用PreparedStatement.setObject时无法确定该用哪种JdbcType,就会默认用OTHER,然后抛出异常。
常见的解决办法有两个。一是在SQL参数上显式指定jdbcType:
insert into user (create_time) values (#{createTime, jdbcType=TIMESTAMP})二是注册自定义TypeHandler。实际项目中建议用TypeHandler统一解决,不要在几百条SQL里到处写jdbcType。这个我在第5章专门展开。
4.4 事务不生效排查
SpringBoot + Mybatis的事务不生效,排查顺序也很固定。
先看@Transactional是不是加在public方法上,而且是不是从外部调用。同类内部this.method()调用不会走Spring代理,事务直接失效,这是最隐蔽的情况。
再看异常是不是被try/catch吃掉了。Spring默认只对RuntimeException回滚,如果你catch掉异常不重新抛出,框架根本感知不到,事务自然不会回滚。如果你希望检查型异常也回滚,要配置@Transactional(rollbackFor = Exception.class)。
还要注意Mybatis和Spring事务的配合原理。单独使用Mybatis时,SqlSession的提交是我们手动调用sqlSession.commit();整合Spring后,Spring的DataSourceTransactionManager会接管SqlSession的提交、回滚和关闭。如果你的Mapper方法没有在事务中运行,SpringBoot使用SqlSessionTemplate每次都会获取新的SqlSession,操作完成后立即关闭。所以在没有事务的方法里多次调用Mybatis,一级缓存基本没有意义。想让一级缓存发挥效果,就得保证多次查询发生在同一个Spring事务里。
5. 进阶:缓存、TypeHandler与批量写入
5.1 一级缓存与二级缓存的真实使用时机
Mybatis缓存这个话题,很多人面试时会背概念,但实际项目里不见得用对。
一级缓存是SqlSession级别的,默认开启。同一个SqlSession里执行两次相同的查询,第二次会直接命中缓存。但在Spring整合环境下,如果方法没有事务,每次Mapper调用都会开一个新的SqlSession,一级缓存几乎等于没有。只有在同一个事务里,Spring会让多次Mapper调用共享同一个SqlSession,一级缓存才会显现效果。举个例子,事务A里先查了一次用户,事务B更新了用户,事务A又查一次用户,如果没有缓存刷新机制,还是可能读到旧值。所以不要把一级缓存当成可靠的业务缓存。
二级缓存是Mapper Namespace级别的,跨SqlSession共享。开启方式很简单,在XML里加一行:
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>配置项的含义:eviction是回收策略,LRU表示最近最少使用;flushInterval是刷新间隔毫秒数;size是缓存对象个数;readOnly为true时缓存返回同一实例,性能更好,为false时需要实体可序列化并做拷贝。
但我要泼一盆冷水:二级缓存默认关闭是有道理的,因为它很容易踩“脏数据”的坑。比如A Mapper的某个查询关联了user表,B Mapper的某个update也改了user表,但B Mapper的namespace里没有配置和A Mapper同名的缓存刷新机制,A Mapper继续读二级缓存,就会读到旧数据。多表关联查询越多,这个风险越大。我的经验是:只对极少更新、字典类、配置类的Mapper开启二级缓存,核心业务表一律不碰,除非你能保证该namespace下的所有写操作都只会通过这个Mapper完成。
5.2 自定义TypeHandler处理特殊类型
TypeHandler的作用是在JDBC的ResultSet/PreparedStatement和Java对象之间做类型转换。如果你有“数据库存了一个JSON字符串,Java端希望直接拿到List”这类需求,自定义TypeHandler是比每次手动JSON序列化/反序列化优雅得多的方案。
写一个处理JSON数组的TypeHandler:
package com.example.demo.handler; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.MappedTypes; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; @MappedTypes(List.class) public class JsonListTypeHandler extends BaseTypeHandler<List<String>> { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, objectMapper.writeValueAsString(parameter)); } catch (Exception e) { throw new SQLException(e); } } @Override public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException { String value = rs.getString(columnName); return parse(value); } @Override public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String value = rs.getString(columnIndex); return parse(value); } @Override public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String value = cs.getString(columnIndex); return parse(value); } private List<String> parse(String value) { if (value == null) { return null; } try { return objectMapper.readValue(value, new TypeReference<List<String>>() {}); } catch (Exception e) { throw new RuntimeException(e); } } }注册方式有两种。一种是在application.yml里配置:
mybatis: type-handlers-package: com.example.demo.handler另一种是在XML的resultMap或者SQL参数中指定。比如你需要针对某个字段使用:
<result column="tags" property="tags" typeHandler="com.example.demo.handler.JsonListTypeHandler"/>TypeHandler还有一个小用途:数据库字段是加密密文,Java拿到的应该是明文。你可以把解密逻辑放在TypeHandler的getNullableResult里,把加密逻辑放在setNonNullParameter里,调用方无感知。这样业务代码不会到处都是加解密的散弹式逻辑。
5.3 批量插入的三种方式与性能对比
批量插入是实际开发中绕不开的需求,三种方式我都试过,各有利弊。
第一种是XML里的foreach拼接,最常见:
<insert id="batchInsert"> insert into user (name, email, create_time) values <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.email}, #{item.createTime}) </foreach> </insert>这种方式本质是一条多值SQL,性能不错,但要注意SQL长度会随记录数增长,容易撞上MySQL的max_allowed_packet限制。实测下来,单批500条左右比较稳,太多就要拆批。
第二种是ExecutorType.BATCH。通过SqlSession手动控制:
SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); try { UserMapper mapper = sqlSession.getMapper(UserMapper.class); for (User user : users) { mapper.insert(user); } sqlSession.commit(); } finally { sqlSession.close(); }这种方式会把多条insert语句缓存到JDBC批处理中,一次性发送。它的效率在某些场景下高于foreach拼接,但要手动管理SqlSession,而且批量模式下最好不要混用查询操作,否则可能出现结果未刷出的误解。MySQL下还可以在连接URL里加rewriteBatchedStatements=true优化。
第三种是用Mybatis-Plus或者Spring Batch提供的批处理组件。如果项目本身用了Mybatis-Plus,直接用内置的批量能力,代码量最少。不过如果你只引了原生Mybatis,手动开ExecutorType.BATCH也完全够用。
三种方式我简单对比一下:
| 方式 | 代码量 | 性能 | 坑点 |
|---|---|---|---|
| foreach拼接 | 低 | 一般 | SQL长度受限,需拆批 |
| ExecutorType.BATCH | 中 | 较好 | 手动管理SqlSession,不能混查 |
| 框架批处理 | 低 | 好 | 引入额外依赖 |
5.4 分页插件PageHelper整合
分页插件是Mybatis生态里用的最多的增强组件,SpringBoot3下的版本要特别注意。老写法是pagehelper-spring-boot-starter:1.4.7,那个版本是为SpringBoot2准备的,在Boot3下会有兼容问题。新版本用2.1.0:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>2.1.0</version> </dependency>用法没有变化:
PageHelper.startPage(pageNum, pageSize); List<User> list = userMapper.selectAll(); PageInfo<User> pageInfo = new PageInfo<>(list);核心注意点有三个。
第一,startPage只对紧随其后的第一条SQL生效。它底层是ThreadLocal,如果你在startPage和查询之间又执行了其他Mapper方法,那这个分页就会作用到错误的查询上,而且下一行线程池复用时还可能把分页参数带到其他不相关的SQL上,必须保证两者紧挨着。
第二,PageInfo要在执行完查询后立刻包装,不要在中间做流式操作又转成普通List,否则总数会丢失。
第三,复杂SQL的分页count查询是额外执行一条count语句,如果SQL本身关联了多张表,count耗时不可忽略。这时候可以重写countSql,或者提前评估是否真的需要返回总数。
6. 常用增强工具与方案取舍
6.1 多数据源下的Mybatis配置
如果你的项目需要同时访问多个数据库,Mybatis的自动配置就不够用了,需要手动定义多个SqlSessionFactory。基本思路是:为每个数据源定义独立的DataSource、SqlSessionFactory、SqlSessionTemplate,然后用不同的@MapperScan(basePackages = "...", sqlSessionFactoryRef = "...")绑定到不同的Mapper包。
一个常见的坑是:主数据源配置漏掉了@Primary,结果Spring注入DataSource时不知道选哪个,启动直接报NoUniqueBeanDefinitionException。另一个坑是直接在多个类里写@Bean创建SqlSessionFactory,没有给核心组件命名统一规范,最后排查时很难理清哪个Mapper用的是哪套配置。
多数据源场景我觉得更推荐的办法是引入dynamic-datasource-spring-boot-starter这类封装好的框架,它把数据源路由、事务切换都做好了,业务侧只需要用@DS("slave")注解就能切换,比手动维护多个Factory更省心。但只要表数量少、Mapper边界清晰,手写多Factory也能接受。
6.2 原生Mybatis与Mybatis-Plus怎么选
这是团队选型里最常被问到的问题。原生Mybatis的优势在于SQL完全可控,XML写复杂查询、动态SQL都很直观,团队规范可以用XML diff来把控。不足是单表CRUD也要手写XML或注解,开发效率偏低。
Mybatis-Plus的优势是单表CRUD直接继承BaseMapper,配合LambdaQueryWrapper可以在Java里做条件构造,不需要写XML,开发效率高很多。代价是Wrapper拼SQL的隐性逻辑多,复杂SQL不好调,而且一些深度定制需要理解Mybatis-Plus对Mybatis底层做了什么改动。
我的个人倾向是:如果项目以简单增删改查为主,直接上Mybatis-Plus;如果项目里有大量复杂报表查询、多表关联、SQL性能敏感,原生Mybatis更稳妥。两者在SpringBoot3下的依赖坐标也不同,Mybatis-Plus要用mybatis-plus-spring-boot3-starter,注意别和原生Mybatis的starter混在一起引入,否则会出现SqlSessionFactory配置冲突。
7. 一些个人体会与小技巧
整合次数多了,我现在的一套固定做法是这样的:SpringBoot用3.2.x,Mybatis用官方的mybatis-spring-boot-starter3.0.3,配置里把map-underscore-to-camel-case打开,日志用Slf4jImpl配合logback的debug级别,XML统一放在resources/mapper下,Mapper接口包用@MapperScan统一定位。这套组合我实测在多个项目里都跑得很稳。
再分享两个小技巧。
一个是启动时加上mybatis.configuration.safe-rollback-on-connection这种边缘配置不要乱开,默认值经过大量项目验证,改动前一定要想明白业务影响。
另一个是遇到SQL映射问题时,优先看打印出来的Preparing日志和Parameters日志,不要盯着报错信息猜。Mybatis的报错链路都比较长,但核心信息通常集中在最前面的那句BindingException或者TypeException上,顺着日志从Mapper接口找到XML,再从XML找到SQL,问题基本都能定位。
最后补充一点:Mybatis整合这一套东西,版本号是最不能抄错的部分。SpringBoot3生态升级很快,网上不少资料还停留在Boot2时代,照着抄之前一定要确认依赖坐标和版本分支。把版本问题解决了,配置问题就好说了。