
SpringBoot3一出来很多老项目的Mybatis整合方案直接翻车配置文件不生效、Mapper扫描不到、循环依赖报错这些破事一堆。我把SpringBoot3和Mybatis从零到生产可用的整个过程重新捋了一遍这次是完整版重点放在版本选择、配置原理、日志打印、缓存机制、TypeHandler、批量操作、分页和多数据源这些真正会卡住人的地方。这篇内容适合刚接触SpringBoot3的新手也适合做过旧版SpringBoot但没摸清Mybatis底层逻辑的开发者看完你至少能知道自己项目里哪里容易炸、为什么炸以及怎么修。1. 动手之前版本选型与整体设计1.1 SpringBoot3与Mybatis的版本组合为什么这么关键SpringBoot3最大的变化是从javax迁移到jakarta命名空间并且强制要求JDK17以上。这就导致很多老项目的依赖直接失效最典型的是Spring Boot 2.x的spring-boot-starter-jdbc、旧版pagehelper、甚至一些老版本Mybatis-spring-boot-starter都会出现类找不到或者反射调用失败的问题。我实测下来的稳定组合是SpringBoot 3.2.x 或 3.3.xmybatis-spring-boot-starter 3.0.3注意是3开头的版本不是2.xmybatis-plus 如果要用也必须是3.5.3的对应SpringBoot3版本MySQL驱动用8.x版本主键生成策略、时区配置都更规范为什么官方特意把Mybatis适配SpringBoot3的starter版本号跳到3.0因为Mybatis的SqlSessionFactoryBean、MapperFactoryBean这些核心类要兼容新的Spring Framework 6.x底层换了不少字节码操作和bean初始化方式。说白了不是改个依赖版本号就万事大吉而是整个会话生命周期和bean注册逻辑都变了。1.2 项目结构设计与依赖规划我这里采用一个标准的单模块结构方便演示也方便拆解。springboot3-mybatis-demo ├── src/main/java │ ├── com/demo/mybatis │ │ ├── SpringBoot3MybatisApplication.java │ │ ├── config │ │ │ ├── MybatisConfig.java │ │ │ └── PageConfig.java如果有分页 │ │ ├── controller │ │ ├── mapper │ │ ├── model │ │ │ ├── entity │ │ │ └── dto │ │ ├── service │ │ └── typehandler │ └── resources │ ├── mapperXML文件放这里 │ └── application.yml └── pom.xml很多人喜欢把XML文件放Java包里其实没必要放resources/mapper下反而清爽而且打包后路径也稳定。依赖规划上最简配置就是三个东西spring-boot-starter-webmybatis-spring-boot-startermysql-connector-j如果做测试再加spring-boot-starter-test和mybatis-spring-boot-starter-test。分页插件按需引入不要一开始就把所有东西塞进去后面排查问题会很痛苦。2. 完整整合实操从一个可运行的项目开始2.1 基础依赖与配置pom.xml里面最关键的几个依赖如下这是可直接抄的版本parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.2/version relativePath/ /parent properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies这里有个容易翻车的点SpringBoot3的mysql-connector-j不再推荐mysql-connector-java新版驱动类名还是com.mysql.cj.jdbc.Driver但是依赖坐标变了很多从旧项目迁移的人会漏掉。2.2 核心配置项逐个拆解application.yml这段配置我建议每一行都别删删掉就可能踩坑spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8zeroDateTimeBehaviorconvertToNulluseSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai username: root password: root hikari: minimum-idle: 5 maximum-pool-size: 10 idle-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.demo.mybatis.model.entity configuration: map-underscore-to-camel-case: true cache-enabled: true aggressive-lazy-loading: false lazy-loading-enabled: true jdbc-type-for-null: nulltype-aliases-package让XML里写实体别名的时候不用写全限定类名比如resultTypeUser就够。map-underscore-to-camel-case是必须开的不然数据库user_name匹配不上实体里的userName你会在运行期收到一堆“找不到属性”的报错。jdbc-type-for-null: null是为了解决某些Oracle、MySQL场景下参数为空导致SQL执行异常的问题。虽然现在很多驱动不强制了但开着更稳尤其在存储过程或者批处理时。2.3 第一个启动即战的Mapper怎么写实体类我习惯用普通POJO加Lombok这里注意Mybatis的自动映射靠的是setter方法不是字段所以Lombok的Data必须保证所有需要映射的属性都有setter。Data public class User { private Long id; private String userName; private Integer age; private String email; private LocalDateTime createTime; }Mapper接口最简单的方式是加Mapper注解Mapper public interface UserMapper { User selectById(Param(id) Long id); }对应XML文件?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN https://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.demo.mybatis.mapper.UserMapper select idselectById resultTypecom.demo.mybatis.model.entity.User SELECT id, user_name, age, email, create_time FROM user WHERE id #{id} /select /mapper这里我把resultType写成了全限定类名因为一旦涉及复杂泛型或继承别名反而容易让人糊涂。很多老手会说别名省事但可读性和IDE跳转才是第一位。然后启动类不要忘记扫包虽然Mapper已经让每个Mapper可以被独立注册但如果你是强依赖MapperScan的流派直接在主类上加上SpringBootApplication MapperScan(com.demo.mybatis.mapper) public class SpringBoot3MybatisApplication { public static void main(String[] args) { SpringApplication.run(SpringBoot3MybatisApplication.class, args); } }这里有个原则冲突Mapper和MapperScan只能选一种统一风格混用容易出现重复注册或者启动报错。我个人倾向只用MapperScan好处是接口不用一个个加注解新增类不会漏。3. 避坑与进阶配置、日志、缓存与XML问题3.1 日志打印让SQL和结果集无处可藏开发阶段最痛苦的是什么SQL写错了不知道参数传进去不对不知道结果集映射不上也不知道。不管用logback-spring.xml还是单纯的application配置先把日志打开。SpringBoot3配合Mybatis最常见的日志设置logging: level: com.demo.mybatis.mapper: debug这样就够了。不要傻乎乎在整个root级别开debug生产环境会爆炸。Mybatis的mapper接口日志级别设为debug后会打印Preparing、Parameters、Total等关键信息。但这里有个坑如果你使用的是mybatis-plus或者某些自定义拦截器SQL日志可能会先被装饰再输出导致你看到的预编译SQL和实际执行的参数对不上。建议理解官方日志的基本格式例如 Preparing: SELECT id, user_name, age, email, create_time FROM user WHERE id ? Parameters: 3(Long), 18(Integer) Columns: ID, USER_NAME, AGE, EMAIL, CREATE_TIME Row: 3, tom, 18, tomqq.com, 2024-01-01 10:00:00 Total: 1你如果你看到Parameters显示{id3, age18}而不是单个值大概率是参数被封装成Map了这不一定错但会影响你查错效率。另外logback-spring.xml里用mapper包名的logger时别把additivity设成false否则可能输出不到控制台很多新人卡在这个细节上。3.2 二级缓存与一级缓存实战Mybatis缓存是面试重点也是实际项目中容易出事的地方。一级缓存是SqlSession级别的SpringBoot整合后被Spring管理默认每次执行Mapper方法都会新建SqlSession吗其实不是Spring的SqlSessionTemplate里每执行一次Mapper方法就会关闭SqlSession所以一级缓存基本等于没用。我见过很多人在SpringBoot下测试一级缓存发现第二次查询还是走库就是这个原因。二级缓存是namespace级别的开启需要三件事在mybatis.configuration.cache-enabled: trueMapper XML中加cache/注意实体类必须实现序列化接口或者配置readOnlytrue才能绕过序列化如果你就是这么干的恭喜你踩进了另一个深坑关联查询的缓存问题。假设selectUserWithOrders里关联查了order表如果只给UserMapper开了缓存而OrderMapper没有那么一旦订单表数据变化用户缓存还是旧数据。这就是面试题里常说的“脏数据”来源。我的个人建议SpringBoot项目里默认关闭二级缓存除非你是纯读场景且能容忍数据短时不刷新。不要因为Mybatis默认支持二级缓存就打开分布式环境下缓存漂移问题几乎无解。一级缓存虽然在Spring下“失效”但在同一个事务内还是生效的。如果你在Service方法上加Transactional两次查询同一个SqlSession会走一级缓存这时候你会发现第二次查询total还是1但不是SQL执行的结果是缓存返回。这个问题排查起来很诡异建议熟记。3.3 XML映射文件常见问题XML文件位置配置不对是新手报错最多的。项目里出现Invalid bound statement (not found)第一反应不是代码问题而是编译后XML没有进到classes目录。Maven默认只把src/main/resources下的文件打进classpath如果你把XML放在src/main/java里需要额外配置build-helper-maven-plugin我从来不做这件事规规矩矩放resources。DOCTYPE声明不要写错。Mybatis 3.5.x对应的是!DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN https://mybatis.org/dtd/mybatis-3-mapper.dtd很多人直接复制旧项目的http://mybatis.org/dtd/mybatis-3-mapper.dtd本地如果没缓存过DTD会报错。直接用https版本或者下载DTD后配置XML catalog反正别在运行时因为这种低级问题浪费时间。XML中的特殊字符转义也是个经典坑。、直接写会被XML解析器当成标签边界必须写gt;和lt;。或者用![CDATA[ ... ]]包住SQL。我推荐后者尤其日期区间查询select idselectBetween resultTypeUser SELECT * FROM user WHERE create_time gt; #{start} AND create_time lt; #{end} /select4. 从入门到生产参数处理、TypeHandler与批量操作4.1 参数绑定与#{}、${}使用心得这个几乎每个Mybatis面试题都会考也是实际写SQL最容易出问题的点。#{}是预处理参数会生成?占位符由驱动设置值天然防SQL注入。${}是字符串拼接直接拼到SQL里虽然可以用于动态表名、排序字段但必须确保内容不是用户输入的原始值。举个例子分表场景Select(SELECT * FROM user_${year} WHERE id #{id}) User selectByTableName(Param(year) String year, Param(id) Long id);常见的面试题是#{}和${}什么时候等价其实它们产生的SQL日志不一样#{}显示Preparing: SELECT * FROM user WHERE id ?${}直接是SELECT * FROM user WHERE id 3。如果表名写死两者都能跑但从日志、注入风险、预编译性能来看能用#{}就别用${}。参数写法大家还容易混淆几个点单参数不加注解Mybatis直接用参数名但如果你用Param就会变成Map多参数必须加Param否则只能用param1、param2这种丑到家的命名POJO作为参数时#{userName}直接取属性不需要#{user.userName}Map参数用#{key}取但可读性极差我平时统一用Param就算单参数也加因为后期升级时加参数不用改整个SQL语句里的命名。4.2 TypeHandler的触发流程与自定义实践搜索热词里专门有“typehandler的工作流程图”这里必须说透。TypeHandler就是Mybatis在JDBC的PreparedStatement和ResultSet之间做类型转换的桥接器。流程是这样的Mybatis解析参数遇到#{attr}时根据参数对象的属性类型找一个对应的TypeHandler通过TypeHandlerRegistry根据Java类型和JdbcType找到注册的handler调用setParameter写入PreparedStatement查询结果时从ResultSet按列名拿数据再调用getResult转换成Java对象举一个实战中常见的需求数据库里存JSON字符串Java实体里想直接放ListUserTag。自定义TypeHandlerMappedJdbcTypes(JdbcType.VARCHAR) MappedTypes(List.class) public class StringListTypeHandler extends BaseTypeHandlerListString { private static final ObjectMapper MAPPER new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, MAPPER.writeValueAsString(parameter)); } catch (JsonProcessingException e) { throw new SQLException(转JSON失败, e); } } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } private ListString parse(String value) { if (value null || value.isEmpty()) { return Collections.emptyList(); } try { return MAPPER.readValue(value, new TypeReferenceListString() {}); } catch (JsonProcessingException e) { return Collections.emptyList(); } } }使用方式有两种。全局注册可以在MybatisConfig里用typeHandlerPackage扫包mybatis: type-handlers-package: com.demo.mybatis.typehandler也可以只在字段上指定Data public class User { private Long id; private String userName; TableField(typeHandler StringListTypeHandler.class) private ListString tags; }注意一个容易踩的坑如果你在字段上用了TableField这套MyBatis-Plus注解那是MyBatis-Plus的用法不是原生Mybatis。原生Mybatis在XML中要在resultMap里配置result columntags propertytags typeHandlercom.demo.mybatis.typehandler.StringListTypeHandler/。千万别混。4.3 批量操作的正确姿势批量插入这种需求新人常犯的错是在Java里for循环执行单条insert几万条数据跑下来直接连数据库都连不上了。正确做法是使用foreach或者ExecutorType.BATCH。先说foreach批量插入这个最直观insert idbatchInsert INSERT INTO user (user_name, age, email) VALUES foreach collectionlist itemitem separator, (#{item.userName}, #{item.age}, #{item.email}) /foreach /insert这个方案适合几千条以内的数据SQL字符串会很长如果超过MySQLmax_allowed_packet就会报错。我自己试过3万条一次插入默认包大小下基本炸掉所以需要根据条数分批比如每500条提交一次。关于批量更新千万别用foreach拼多个SQL用分号隔开。依赖allowMultiQueriestrue不仅危险而且不同数据库支持程度不一致。正确姿势是开启批处理模式SqlSession sqlSession sqlSessionTemplate.getSqlSessionFactory() .openSession(ExecutorType.BATCH); try { UserMapper mapper sqlSession.getMapper(UserMapper.class); for (User user : userList) { mapper.updateById(user); } sqlSession.commit(); } finally { sqlSession.close(); }这里特别提醒Spring的SqlSessionTemplate默认会把ExecutorType覆盖成SIMPLE所以你要拿到原生的SqlSessionFactory来开会话否则批处理不生效。网上很多人说Mybatis的批处理在SpringBoot下是“假的”多半就是这个原因。5. 生产环境的加分项分页、多数据源与Configuration定制5.1 物理分页方案选型Mybatis的逻辑分页有多坑就不说了直接说物理分页。最常见的方案是PageHelper但是PageHelper 6.x已经和SpringBoot3兼容我这里提醒一个核心原理PageHelper是静态拦截Executor的query方法拼上LIMIT这对于简单查询没问题但遇到count查询复杂、嵌套子查询、多表关联时它生成的count SQL可能不是最优的。SpringBoot3使用PageHelper的配置pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql我个人在生产项目里反而更推荐自己写一个轻量级分页拦截器因为可控性高能处理特殊逻辑。但如果你不想造轮子PageHelper的PageInfo和Page封装确实省事。只是要记住分页参数永远不要从Controller直接透传要做白名单校验和上限保护否则用户传个pageSize100000你数据库就遭罪了。另一种方案是MyBatis-Plus自带的分页插件PaginationInnerInterceptor。它比PageHelper更贴合SpringBoot3的mybatis-plus风格但前提是你接受使用MyBatis-Plus体系。项目里如果Mapper都是纯Mybatis的XML不建议为了分页整个改成Plus。5.2 多数据源下Mybatis的角色多数据源的常规做法是引入spring-boot-starter-jdbc加AbstractRoutingDataSource。Mybatis在这里只是“会话挂载”的橡皮泥关键在SqlSessionFactory的DataSource指向哪个路由。有人会在项目里配置两个SqlSessionFactoryBean分别绑定不同的DataSource和Mapper扫描路径。这个方案适合完全隔离的数据源比如订单库和日志库。Bean Primary public SqlSessionFactory primarySqlSessionFactory(Qualifier(primaryDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/primary/*.xml)); return bean.getObject(); } Bean public SqlSessionFactory logSqlSessionFactory(Qualifier(logDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/log/*.xml)); return bean.getObject(); }这里有一个隐蔽的坑当存在多个DataSource时SpringBoot自动配置的DataSourceTransactionManager会绑到主数据源如果你用Transactional操作从库很容易出现事务不生效或者连接串库。解决方法是给不同数据源单独配PlatformTransactionManager并且使用Transactional(transactionManager logTransactionManager)指定事务管理器。还有更复杂的动态切换数据源场景基于AbstractRoutingDataSource实现这里不展开。但不管怎么做核心思路都是先确定SqlSessionFactory绑定了哪颗数据源Mybatis自己只是按JDBC接口工作。5.3 自定义Configuration的细节热词里有“mybatis中自定义configuration”说明很多人想通过定制org.apache.ibatis.session.Configuration来扩展Mybatis。最常见的是注册自定义TypeHandler、添加拦截器插件、修改默认参数。在SpringBoot3里你可以不管mybatis.configuration.*配置完全自己定义Configuration对象Configuration public class MybatisConfig { Bean public ConfigurationCustomizer configurationCustomizer() { return configuration - { configuration.setMapUnderscoreToCamelCase(true); configuration.setCacheEnabled(false); configuration.setLazyLoadingEnabled(true); configuration.setAggressiveLazyLoading(false); configuration.getTypeHandlerRegistry().register(JsonTypeHandler.class); }; } }注意SpringBoot3的ConfigurationCustomizer是函数式接口返回一个lambda即可。但是有个细节要记住如果项目里已经有了SqlSessionFactoryBean这个Customizer不一定会生效。因为Mybatis的自动配置逻辑里SqlSessionFactoryBean创建时会先应用自定义Configuration但如果你手动重写了SqlSessionFactoryBean就得自己set这个Configuration否则静默失效这个问题我排查过很久。如果是纯用SqlSessionFactoryBean定制更稳妥的方式是Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); org.apache.ibatis.session.Configuration configuration new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); // 注册Interceptor configuration.addInterceptor(myInterceptor()); factoryBean.setConfiguration(configuration); return factoryBean.getObject(); }但这样一来mybatis.configuration.*配置文件里如果还有别的设置就会被这个Configuration覆盖掉。要么全在代码里管要么全在配置文件里管不要混用这是减少精神内耗的最好方式。6. 常见问题排查速查表与最后一点私货6.1 高频异常与解决方向异常或现象常见原因解决办法Invalid bound statement (not found)Mapper接口和XML的namespace、id对不上XML没编译进classes检查namespace全限定名检查target/classes/mapper下是否存在XMLProperty userName not found下划线字段没映射到驼峰属性开启map-underscore-to-camel-case或者resultMap显式映射AspectJ com.sun.proxy.$Proxy cannot be castSpringBoot3代理机制与Mybatis类冲突确认是不是把Mybatis接口当普通Bean注入到了AOP代理中尝试MapperFactoryBean方式SQLSyntaxErrorException near ?多半是MySQL驱动版本过低或者#{}写到了动态表名里升级驱动表名和排序字段改用${}但必须校验白名单Cause: com.mysql.cj.exceptions.InvalidConnectionAttributeException时区问题URL加serverTimezoneAsia/Shanghai二级缓存出现脏数据多个namespace缓存没有联动刷新纯读场景才开二级缓存关联表缓存要配置cache-ref批量操作报错或性能极慢用的是SIMPLEExecutor使用ExecutorType.BATCH或分批foreach启动时循环依赖Mybatis Mapper Bean被Service循环依赖使用Lazy或者重构依赖方向6.2 几个我摔过的跤第一个是真事SpringBoot3刚出那会儿我一股脑把所有依赖升到最新结果Mybatis报了一堆实现org.apache.ibatis.session.SqlSessionFactory接口的类找不到。后来一看是误引入了旧版mybatis-spring-boot-starter它的自动配置类还在用Spring Framework 5的老API。后来版本对齐后一次就好了。第二个是打印SQL日志时项目里接了trace级别的链路追踪导致日志输出量爆炸生产环境硬盘一天吃满。排查后发现不是Mybatis本身的问题是logging.level.root: debug和链路日志叠加。建议在生产永远只开mapper包debug别用root debug。第三个是自定义TypeHandler处理LocalDateTime数据库字段是DATETIMEJava类型是LocalDateTime虽然Mybatis 3.5自带默认处理器但如果你在字段上加自定义TypeHandler会把默认的行为覆盖容易导致LocalDateTime变成Timestamp。所以非必要不要给基础类型自定义TypeHandler。6.3 一篇集成文最后要留下的手记SpringBoot3整合Mybatis这件事难点从来不在“加依赖、写配置”这两步而在于你如何理解Mybatis底层的会话机制、缓存边界和类型处理。我做了这么多年项目最大的感受是少整花活把#{}和${}用明白把日志打印开好把TypeHandler和缓存先吃透比引入十个插件都强。如果看完这篇你还是有点发怵我的建议是先建一个最小的SpringBoot3项目只连一次数据库、写一个Mapper、打印出来SQL然后把resultMap、association、collection挨个试一遍。这比看任何文档都有效因为所有报错都是最直观的老师。之后再慢慢加二级缓存、TypeHandler、分页、多数据源每加一项就增量验证一遍你会发现很多之前记不住的概念全部串起来了。