资讯详情

SpringBoot3整合MyBatis:版本选型、原理剖析与高频踩坑实战

📅 2026/10/5 14:04:53 | 华诺云谱 👁 阅读
SpringBoot3整合MyBatis:版本选型、原理剖析与高频踩坑实战
最近把项目从 SpringBoot 2.7 升级到 SpringBoot 3最折腾的不是业务代码而是 MyBatis 这一套东西。上网搜“SpringBoot3 整合 MyBatis”十个帖子里有八个还在用 javax 开头的 servlet 依赖、老版本 starter照着抄启动直接就报错。今天我把完整版整理一下从版本选型、依赖配置、一个能跑通的注册功能到 SqlSessionFactory 的初始化原理、TypeHandler 自定义、两级缓存最后附一份我反复踩过的坑清单。不管你是刚入门想跑通第一个 MyBatis 程序还是准备面试想把底层原理讲清楚这篇都能直接用后续看开源商城项目源码或者自己封装 starter也都能省不少力气。1. SpringBoot3 整合 MyBatis 的版本格局与选型1.1 SpringBoot3 带来的底层变化Java 17 与 jakarta 命名空间先说结论SpringBoot3 和 SpringBoot2 不是简单的大版本升级它的底层有两个变化直接影响 MyBatis 整合。第一个是强制要求 Java 17 及以上。Java 17 本身不影响 MyBatis 的写法但很多老项目从 Java 8 迁过来之后明明代码没改编译却报一堆反射相关警告。MyBatis 这种重度使用反射、动态代理的框架在 Java 17 下默认的强封装反射限制需要额外处理不过 mybatis-spring-boot-starter 3.x 已经处理好了不需要你手动加--add-opens。真正需要手动加参数的情况后面排错章节我会提到。第二个是 Servlet 相关 API 从javax.servlet全面切到jakarta.servlet。如果你在 Controller 里用了HttpServletRequestSpringBoot3 里必须写成import jakarta.servlet.http.HttpServletRequest;很多人整合失败就是死在这一步——老帖子里的依赖坐标、import 路径全按 SpringBoot2 抄一编译全是红色报错。MyBatis 本身不依赖 Servlet API但 spring-boot-starter-web 和 mybatis-spring-boot-starter 一组合这个命名空间问题就暴露出来了。1.2 版本对应关系别再用 2.x 的 starter这是整个整合里最重要的选型问题。mybatis-spring-boot-starter的版本和 SpringBoot 主版本强绑定SpringBoot 主版本Starter 版本MyBatis 版本mybatis-spring2.x2.3.x3.5.x2.1.x3.x3.0.x3.5.x3.0.x我用的是 SpringBoot 3.2.5对应的 starter 版本是 3.0.3。如果你拿 2.3.1 的 starter 用于 SpringBoot3启动时会直接抛IllegalArgumentException: Invalid value type for attribute factoryBeanObjectType之类的问题本质是老包里的SqlSessionFactoryBean没有适配 Spring6 的新特性。我的 pom.xml 里核心依赖是这样的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注意 MySQL 驱动坐标已经变成com.mysql:mysql-connector-j老的mysql:mysql-connector-java在 SpringBoot3 里虽然能用但是官方已经不再推荐。1.3 为什么用 starter 而不是手写 SqlSessionFactoryBean我看过不少老项目喜欢自己定义SqlSessionFactoryBean手动指定dataSource、mapperLocations、typeAliasesPackage理由是“更可控”。在 SpringBoot 时代这个做法没有太大必要反而容易漏配。mybatis-spring-boot-starter的自动配置类MybatisAutoConfiguration已经帮你完成了几件事自动注入SqlSessionFactory从application.yaml读取mybatis.*配置自动扫描所有Mapper注解的接口注册到 Spring 容器自动创建SqlSessionTemplate替代裸的DefaultSqlSession保证线程安全没有显式配置时自动从 Spring 容器里找唯一的数据源如果你准备面试记住一个关键点SpringBoot 整合 MyBatis 的核心就是SqlSessionFactoryBean的afterPropertiesSet()方法它调用XMLConfigBuilder解析配置把每个 XML 里的 SQL 片段构造成MappedStatement存进Configuration对象。后面的第四章我会专门拆这条初始化链路。所以我的建议是正常项目直接用 starter把精力花在业务和 SQL 优化上。只有当你需要做多数据源、定制拦截器、复写Configuration这种高级功能时才考虑继承MybatisAutoConfiguration或手动声明SqlSessionFactoryBean。2. 依赖与配置落地从 application.yaml 到第一条 SQL2.1 最小可运行配置数据源与 MyBatis 核心参数依赖加好后接下来是配置文件。我用的是 MySQL最简配置如下spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai 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表示所有resources/mapper目录下的 XML 文件都会被加载。我习惯按模块分目录比如classpath:mapper/user/*.xml、classpath:mapper/order/*.xml避免后期文件一多全堆在一起。type-aliases-package是实体类的包名写了之后XML 里写resultType时可以用简单类名代替全限定名比如com.example.demo.entity.User直接写User。不写也能跑就是 SQL 里会啰嗦一点。map-underscore-to-camel-case必须开否则数据库字段user_name映射不到实体的userName。有人喜欢写resultMap手动映射我的习惯是能用驼峰映射就不用resultMap少写很多样板代码。log-impl我直接配成StdOutImpl这样 SQL 语句、参数、返回行数会直接打到控制台开发阶段最省事。生产环境建议换成 Log4j2 或者去掉。2.2 主类上的 MapperScan 与 Mapper 二选一这是整合过程中最容易被忽略的一步。starter 自动配置了SqlSessionFactory但它不知道你的 mapper 接口放在哪个包下。Spring 容器里如果没有 UserMapper 这个 BeanAutowired注入的时候就会报NoSuchBeanDefinitionException。有两种解决方式选一种即可第一种在启动类上加上MapperScanSpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }第二种在每个 Mapper 接口上加Mapper注解Mapper public interface UserMapper { User selectById(Long id); }我推荐项目里统一用第一种。原因有两个第一MapperScan一次性扫描整个包新加 Mapper 不用重复加注解第二如果项目里同时用了 MyBatis 和其他数据访问框架MapperScan能明确区分哪些接口归 MyBatis 管理。2.3 XML 文件的放置位置与资源拷贝问题这里有个很经典的坑。很多人刚学的时候把UserMapper.xml放在src/main/java下面的 mapper 包里IDE 里看得到但一运行就报Invalid bound statement (not found)原因不是 XML 写错了而是 Maven 默认只把src/main/resources下的资源拷贝到 classpathJava 目录下的.xml文件被忽略了。解决办法有几种。第一种把 XML 放在src/main/resources/mapper下配合mapper-locations: classpath:mapper/*.xml这是最标准、最推荐的做法。第二种实在想和接口放一起就在 pom.xml 里显式声明资源目录build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resources /build我不建议第二种因为会让构建目录变得混乱而且如果接口在 jar 包里XML 的加载顺序容易出问题。踩过一次之后我就老老实实把 XML 放 resources 了。2.4 第一个 XML 查询验证整合是否成功配置写完后我习惯先写一个最简单的selectById验证整条链路。实体类、Mapper 接口、XML 如下。public class User { private Long id; private String userName; private String password; // getter/setter 省略 }public interface UserMapper { User selectById(Param(id) Long id); }?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper select idselectById resultTypeUser SELECT id, user_name, password FROM user WHERE id #{id} /select /mapper然后写一个CommandLineRunner或者直接写单元测试调用一次控制台能看到 SQL 输出说明整合已经通了。这里注意 XML 的 namespace 必须是接口的全限定名id必须是接口的方法名这两个对不上启动不报错但运行时一定报错。3. 一个最简注册功能MVC Service Mapper 全链路实现3.1 为什么选择“注册功能”作为整合后的第一个业务如果你看网上各种开源商城项目源码会发现它们的模块划分基本都是从“用户注册”开始的。注册功能虽然简单但它把 SpringBoot3 MyBatis 整合的主要环节全串起来了前端请求参数校验、事务处理、Mapper 写入、数据库唯一约束、异常回滚。这个功能做好了后面扩展登录、鉴权、用户中心只是在这个骨架上添砖加瓦。3.2 建表与实体设计注册功能只需要一张用户表字段我按最小可用来设计CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, user_name VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码, email VARCHAR(100) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_name (user_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;user_name加了唯一索引。这个设计在注册业务里非常重要因为它是防止重复注册的数据库底线比应用层判断可靠得多。实体类我加上了createTime字段这里有个小细节MyBatis 把create_time映射到createTime完全依赖map-underscore-to-camel-case这个配置如果你没开这个字段就查不出来返回 null还不好排查。3.3 Controller、Service、Mapper 代码注册接口的调用链是Controller - Service - Mapper - MySQL。为了让新手能看懂我没做复杂的 DTO 转换直接接收参数。RestController RequestMapping(/api/user) public class UserController { Resource private UserService userService; PostMapping(/register) public ResultVoid register(RequestBody Valid RegisterRequest request) { userService.register(request); return Result.success(); } }public class RegisterRequest { NotBlank(message 用户名不能为空) Size(min 3, max 20, message 用户名长度需在3-20之间) private String userName; NotBlank(message 密码不能为空) Size(min 6, max 20, message 密码长度需在6-20之间) private String password; }这里用了 Spring 的Valid参数校验属于 spring-boot-starter-web 自带能力不需要额外依赖。校验失败时由全局异常处理器统一拦截注册逻辑不用关心参数合不合法职责很清晰。Service 层是注册功能的核心事务和业务规则都放在这里Service public class UserService { Resource private UserMapper userMapper; Transactional(rollbackFor Exception.class) public void register(RegisterRequest request) { User user new User(); user.setUserName(request.getUserName()); user.setPassword(DigestUtils.md5DigestAsHex(request.getPassword().getBytes())); userMapper.insert(user); } }密码必须加密存储这里用的DigestUtils.md5DigestAsHex是 Spring 自带的工具类演示够用。真实项目建议用 BCrypt这是另一个话题不展开。Transactional是必须加的虽然目前看起来只有一个 insert 操作没什么回滚可谈。但后续你大概率会加“初始化用户积分”“发注册优惠券”之类的联动操作到那时事务就是刚需。趁早养成习惯。Mapper 接口public interface UserMapper { int insert(User user); User selectByUserName(Param(userName) String userName); }XMLinsert idinsert useGeneratedKeystrue keyPropertyid INSERT INTO user (user_name, password, email, create_time) VALUES (#{userName}, #{password}, #{email}, NOW()) /insert select idselectByUserName resultTypeUser SELECT id, user_name, password, email, create_time FROM user WHERE user_name #{userName} /selectuseGeneratedKeystrue配合keyPropertyid的意思是数据库自增主键生成后自动回填到传入 User 对象的id属性上。这个配置很常用如果漏了insert 之后你拿不到新用户 id后续联表操作就会卡住。3.4 唯一约束与重复注册的处理注册功能最坑的是重复用户名。我在表上加了唯一索引所以并发情况下两个请求同时注册同一个名字数据库会直接报Duplicate entry异常。如果不在应用层提前判断用户看到的是一堆看不懂的错误。正确做法是在 register 方法开头先查一次if (userMapper.selectByUserName(request.getUserName()) ! null) { throw new BizException(ErrorCode.USER_EXISTS); }但你要清楚select insert不是原子的极端并发下还是会撞唯一索引。所以 Service 层还应该捕获DuplicateKeyException把数据库错误翻译成业务提示这就是事务的另一种价值——保证两个操作要么都成要么都败。我实际开发里见过很多新手只做了应用层校验结果压测一到高并发注册接口就开始 500。记住一个原则唯一性校验永远以数据库约束为最终准绳应用层只是提升用户体验的手段。4. SqlSessionFactory 自动装配与初始化流程从 XMLConfigBuilder 到 MappedStatement4.1 MyBatis 是怎么完成初始化的核心入口拆解前面说了starter 会自动配置SqlSessionFactory但很多人不知道这个工厂背后发生了什么。理解了初始化流程很多网上说不清的报错你都能自己定位。我把整条链路按顺序拆一下。MyBatis 的初始化入口是XMLConfigBuilder。它的作用是解析 MyBatis 主配置文件mybatis-config.xml或者直接解析传入的Configuration对象。使用 starter 时SpringBoot 会把application.yaml里的mybatis.*配置翻译成Configuration对象的属性然后交给SqlSessionFactoryBean。SqlSessionFactoryBean的核心方法是buildSqlSessionFactory()它内部做了这几件事解析typeAliases把type-aliases-package下的类注册到别名体系解析mapperLocations加载所有 XML 文件转换成XMLMapperBuilderXMLMapperBuilder解析 XML 文件里的mapper节点读取 namespace、SQL 片段、参数映射、结果映射每条 SQL 语句被构建成一个MappedStatement缓存在Configuration.mappedStatements这个 Map 里key 是namespace . id比如com.example.demo.mapper.UserMapper.selectById扫描Mapper接口为每个接口生成 MapperProxy 工厂我打个比方Configuration就像是 MyBatis 的“数据库字典”里面记录了所有 SQL、所有参数映射规则、所有结果集映射规则。XMLConfigBuilder负责读取配置“写字典”XMLMapperBuilder负责读取 XML“补充字典条目”MappedStatement就是字典里的一条完整记录。这个流程解释了为什么namespace或id写错会运行时报错而不是启动时报错因为启动时 MyBatis 只做“记录”真正执行 SQL 时才会根据 key 去“查字典”查不到就抛Invalid bound statement (not found)。4.2 Mapper 接口是如何变成 Spring Bean 的另一个关键机制是 Mapper 接口的动态代理注册。MapperScan注解通过MapperScannerConfigurer扫描指定包下的所有接口对每个接口调用MapperFactoryBean生成一个代理对象。你注入的UserMapper其实不是接口的实现类而是一个MapperProxy。当你调用userMapper.selectById(1L)时MapperProxy拦截方法调用根据方法签名找到对应的MappedStatement通过SqlSessionTemplate去执行 SQL然后把结果集按映射规则转成实体对象。这个代理逻辑极其稳定如果你的 mapper 接口里有两个方法名一样但参数不同MyBatis 会因为无法确定MappedStatement直接报错。所以一个接口里的方法名必须唯一这是很多人没意识到的隐性约束。4.3 自定义 Configuration 和拦截器定制 SqlSessionFactory 的方式了解了初始化流程你就可以在受控范围内定制SqlSessionFactory了。最常见的做法是声明一个ConfigurationCustomizerBean在自动配置完成后做二次修改Configuration public class MybatisConfig { Bean public ConfigurationCustomizer mybatisConfigurationCustomizer() { return configuration - { configuration.setMapUnderscoreToCamelCase(true); // 开启驼峰映射等价于 yaml 里的 map-underscore-to-camel-case }; } }如果你需要加 MyBatis 拦截器比如分页插件 PageHelper、自定义审计字段自动填充可以用这种方式Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }这里要注意拦截器的执行顺序是有讲究的。MyBatis 拦截器可以拦截 Executor、StatementHandler、ParameterHandler、ResultSetHandler 四类对象多个拦截器之间用Intercepts注解里的order属性控制执行顺序顺序错了会导致参数处理结果不符合预期。我在项目里就碰到过自动填充字段的拦截器和分页拦截器互相干扰的问题后来把审计拦截器放在最前面执行才解决。4.4 SqlSessionTemplateSpring 整合下的线程安全会话再强调一个容易被面试官追问的点原生 MyBatis 的DefaultSqlSession不是线程安全的每个线程应该使用独立的 SqlSession。Spring 整合后你并没有手动创建 SqlSession而是通过SqlSessionTemplate间接操作。SqlSessionTemplate是线程安全的内部持有一个 SqlSession 的动态代理。每个 Mapper 方法调用时它会从 Spring 事务上下文中获取 SqlSession如果当前没有事务则创建新的方法结束后自动关闭。所以同样的代码在 Spring 管理下和不使用 Spring 时SqlSession 的生命周期完全不同这也是第四章后面要讲的缓存行为变化的前提。5. 高频踩坑与排查链路Mapper 注入、XML 不生效、条件失效和 SQL 打印5.1 Mapper 注入失败NoSuchBeanDefinitionException 的完整定位过程SpringBoot3 下Resource注入 Mapper 报NoSuchBeanDefinitionException这是我被问过最多的问题。按照下面的链路一步步来第一步确认启动类上有没有MapperScan或者接口上有没有Mapper。很多人是抄的别人的搭建文档但自己把启动类改了包结构导致扫描路径对不上。MapperScan(com.example.demo.mapper)要求扫描的包路径必须真实存在接口。第二步看看接口文件是不是被放到了错误的模块。多模块项目里接口在api模块、实现在service模块如果启动类扫描的是service模块的包路径api模块的 Mapper 就永远扫不到。第三步检查有没有引入Mapper冲突。有些项目同时引入 tk.mybatis 或者 mybatis-plus 的Mapper两个注解全限定名不同扫描器可能不识别这时候统一用其中一个。排查到这里99% 的问题都能解决。剩下的 1% 是 IDEA 缓存导致的注解不生效重启一下或者重新 mvn clean compile 就好。5.2 XML 不生效Invalid bound statement 的靶向排查报错信息是Invalid bound statement (not found): com.example.demo.mapper.UserMapper.selectById但 Mapper 接口、XML 都在眼看就是找不到。按我的经验原因按频率排序如下XML 文件没有被打进 target/classes。先直接去项目编译目录看有没有对应的 XML 文件没有就是资源拷贝问题参考 2.3 节解决。mapper-locations路径写错。比如 XML 在resources/mapper/user下配置文件写的是classpath:mapper/*.xml通配符*匹配不到子目录文件。需要改成classpath:mapper/**/*.xml。namespace没有指向接口全限定名。id和接口方法名对不上包括大小写。我的排查顺序是先看编译目录再看配置路径最后检查 XML 内标签。因为前两个错误最隐蔽IDE 不报错只有运行起来才知道。5.3 MyBatis 条件不生效动态 SQL 里 if 标签的隐藏规则“mybatis 条件不生效”是搜索热词里出现率很高的一个点。最典型的情况是select idselectByCondition resultTypeUser SELECT * FROM user WHERE 1 1 if testuserName ! null and userName ! AND user_name LIKE CONCAT(%, #{userName}, %) /if /select调用时传入了userName但生成的 SQL 里还是只有WHERE 1 1条件没拼进去。常见原因有两个。第一个test表达式里的字符串比较写错了。XML 中判断空字符串必须用单引号userName ! 写双引号会被 XML 解析成字符串的边界而报错有些人为了避开双引号问题写userName ! 后实际比较的是 JSON 字符串的语法错误启动时直接抛异常。第二个方法参数没有加Param注解。如果参数是普通对象Usertest里可以直接写userName如果参数是一个基本类型或 String必须加Param(userName)否则 MyBatis 生成的参数名为param1test写userName永远取不到值。还有一个动态 SQL 的坑是where 1 1的写法虽然能跑但性能上不算最优也容易让后面接 SQL 的人困惑。我更推荐用where标签select idselectByCondition resultTypeUser SELECT * FROM user where if testuserName ! null and userName ! AND user_name LIKE CONCAT(%, #{userName}, %) /if /where /selectwhere会自动去掉第一个AND效果比1 1更干净。5.4 打印 SQL 与日志配置的两个层面排查 SQL 问题必须能看到实际执行的语句和参数。配置log-impl: org.apache.ibatis.logging.stdout.StdOutImpl是最快的方案它会把 SQL 直接打到控制台。但这里有个容易被忽略的问题StdOutImpl 会打印出 SQL 里的?占位符但不会打印绑定参数值。如果你需要看到参数值有两个选择。第一用 Log4j2 的BasicLoggingAdapter配置日志级别为 DEBUG第二在application.yaml里加logging: level: com.example.demo.mapper: debug这行配置会让 Mapper 接口所在包下的 SQL 打印出参数值和返回结果调试动态 SQL 时非常有用。我开发环境一般两种都开着生产环境只保留日志框架的慢 SQL 告警不保留全量 SQL 打印。6. TypeHandler 实战自定义类型处理器与 JSON 字段映射的工作时机6.1 TypeHandler 到底解决了什么问题TypeHandler 在 MyBatis 里负责 Java 类型与 JDBC 类型之间的双向转换写入数据库时把对象转成PreparedStatement能接受的参数读取数据库时把ResultSet里的值转成 Java 对象。默认情况下基本类型、String、Date、LocalDateTime 这些 MyBatis 都已经内置了对应的 TypeHandler。真正需要自定义的是两种情况数据库字段是 JSON 字符串Java 实体里想直接映射成 List 或对象某些数据库特有类型比如 PostgreSQL 的数组类型搜索热词里出现“mybatis中typehandler的工作流程图”说明很多人搞不清它的调用时机。简单描述一下执行过程执行 SQL 时PreparedStatementHandler 设置参数会调用 TypeHandler 的setParameter方法查询结果返回时ResultSetHandler 遍历结果集会调用 TypeHandler 的getResult方法。每个MappedStatement里都维护了参数和结果对应的 TypeHandler 实例MyBatis 通过TypeHandlerRegistry根据 Java 类型和 JdbcType 找到对应的处理器。6.2 自定义一个 JSON 字段的 TypeHandler我用一个实际场景演示User 表加一个tags字段存的是 JSON 数组Java 实体里想直接得到ListString。实体字段private ListString tags;自定义 TypeHandlerMappedTypes(List.class) MappedJdbcTypes(JdbcType.VARCHAR) public class JsonTypeHandler 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)); } Override public ListString getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } Override public ListString getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private ListString parse(String json) { try { return MAPPER.readValue(json, new TypeReferenceListString() {}); } catch (JsonProcessingException e) { return Collections.emptyList(); } } }这个类写在com.example.demo.handler包下然后在application.yaml里配置mybatis: type-handlers-package: com.example.demo.handlerMyBatis 启动时会扫描这个包下的类注册到TypeHandlerRegistry。注意MappedTypes(List.class)和MappedJdbcTypes(JdbcType.VARCHAR)注解是告诉注册器这个处理器匹配哪种组合不写的话需要手动在 resultMap 里强制指定比较麻烦。6.3 在 XML 和注解两种场景下使用XML 场景下查询用简化写法resultMap iduserMap typeUser result columntags propertytags typeHandlercom.example.demo.handler.JsonTypeHandler/ /resultMap select idselectWithTags resultMapuserMap SELECT id, user_name, tags FROM user WHERE id #{id} /select如果用了type-handlers-package自动注册并且字段映射能通过驼峰规则对上MyBatis 会自动选择匹配的 TypeHandler不写typeHandler属性也能工作。但我实际开发中还是习惯显式声明因为一旦实体字段类型变化自动匹配很容易选错处理器显式声明至少错误是可预期的。还有一个很容易踩的坑BaseTypeHandler要求子类必须能无参构造。如果你在setNonNullParameter里用了实例变量保存ObjectMapper然后不小心加了有参构造MyBatis 反射创建实例时会报NoSuchMethodException。所有自定义 TypeHandler 必须保留一个无参构造这是我翻车过好几次才记住的。6.4 TypeHandler 的局限性TypeHandler 能处理字段级别的类型转换但处理不了“整个查询结果集的异构结构”。比如一个 SQL join 了五张表返回各种嵌套对象这个需要靠 ResultMap 的关联映射或者手动组装 DTO 完成别指望 TypeHandler 全包。另外TypeHandler 在#{item}动态参数里也能用写法是#{item, typeHandlercom.example.demo.handler.JsonTypeHandler}。这个语法面试官偶尔会问但实际项目里写 XML 时一般用不着因为参数对象里的字段类型已经是ListStringMyBatis 会自动走注册表匹配。7. MyBatis 缓存机制一级缓存、二级缓存与 Spring 事务的真实边界7.1 一级缓存SqlSession 级别的默认缓存MyBatis 的一级缓存是默认开启的范围是 SqlSession 级别。同一个 SqlSession 里执行相同的 SQL 且参数相同第二次查询会直接命中缓存不再访问数据库。在原生 MyBatis 程序里这段代码会命中一级缓存try (SqlSession session sqlSessionFactory.openSession()) { UserMapper mapper session.getMapper(UserMapper.class); User u1 mapper.selectById(1L); User u2 mapper.selectById(1L); }但在 SpringBoot 整合环境下情况完全不同。SqlSessionTemplate在没有事务时会为每次 Mapper 方法调用创建新的 SqlSession方法结束即关闭所以上面的超凡代码改成userMapper.selectById(1L)连续调两次一级缓存并不会命中——两个调用属于不同的 SqlSession。那 Spring 整合下什么时候能吃到一级缓存把两个查询放进同一个事务里Transactional public void testCache() { User u1 userMapper.selectById(1L); User u2 userMapper.selectById(1L); // 第二次查询命中一级缓存 }这是因为在事务范围内SqlSessionUtils会把同一个 SqlSession 绑定到当前线程和事务上下文只有事务提交或回滚后才关闭。一级缓存的失效条件面试中经常问1) SqlSession 关闭2) 查询条件不同3) 两次查询之间执行了任何 insert、update、delete 操作MyBatis 会清空当前 SqlSession 的缓存4) 手动调用clearCache()。第 3 条很隐蔽很多人以为只有改同一张表才会清缓存实际上任何写操作都会清。7.2 二级缓存namespace 级别的跨会话缓存二级缓存的范围是 namespace 级别也就是一个 Mapper XML 里所有查询共享缓存。它跨 SqlSession 生效多个会话查询同一 namespace 下的数据第二次开始走缓存。配置二级缓存分两步。第一步在application.yaml确认全局开关是开启的mybatis: configuration: cache-enabled: truecache-enabled默认就是 true所以这一步通常不用写。第二步在 Mapper XML 里加一行cache evictionLRU flushInterval600000 size512 readOnlytrue/加了cache/标签这个 namespace 的查询结果才会被缓存。我用到的参数含义是eviction缓存淘汰策略LRU 表示最近最少使用默认也是 LRUflushInterval缓存刷新间隔毫秒单位不写表示没有刷新间隔缓存直到被更新语句清空size最多缓存多少个对象readOnlytrue 表示直接返回缓存对象引用false 表示序列化后返回副本。建议设 false防止并发修改缓存对象但实体类必须实现 Serializable注意二级缓存缓存的是查询结果对象执行任何 insert、update、delete 时MyBatis 会清空当前 namespace 的二级缓存。所以如果你的业务是“热点数据基本不更新”二级缓存收益很大如果表频繁写缓存刚填充就被清反而增加序列化开销。7.3 二级缓存最大的坑跨 namespace 的脏读二级缓存按 namespace 隔离但表之间是有业务关联的。最典型的场景UserMapper.xml配置了cache/基于user表缓存OrderMapper.xml里有一条 SQL join 了user表但 OrderMapper 没配置缓存反过来UserMapper下的 update 修改了用户信息后被清空但 OrderMapper 的查询结果可能缓存了 join 出来的旧用户信息这就是跨 namespace 脏读。MyBatis 官方也知道这个问题所以默认只开启一级缓存。对于需要缓存 join 结果的项目我的建议是要么用查询条件区分是否走缓存要么直接不用二级缓存改用 Redis 做应用层缓存可控性高得多。7.4 面试视角缓存相关的高频问题整理搜索热词里“mybatis 二级缓存实现”出现频率很高我按面试角度总结几个必背点二级缓存的实现类TransactionalCacheManager它在事务中记录缓存操作只有事务提交时才真正写入二级缓存事务回滚则丢弃。这也是为什么二级缓存能避免“事务未提交但其他会话读到脏数据”的原因。一级缓存失效跨 SqlSession、执行 update、清除缓存。为什么 Spring 整合下要重视事务边界因为没有事务时一级缓存相当于失效很多人在非事务方法里连续查询两次打到数据库误以为 MyBatis 没缓存。自定义缓存cache type.../允许你指定自定义缓存实现类比如把二级缓存换成 Redis。实现org.apache.ibatis.cache.Cache接口即可但要注意分布式环境下缓存一致性设计。面试官如果让你画“mybatis 二级缓存实现”的流程画不出来不要硬画直接说清楚TransactionalCacheManager的事务缓存机制和 namespace 边界就够了。非要画就用文字描述第一次查询未命中从数据库加载写入二级缓存事务提交后对其他会话可见后续查询命中直接从缓存对象反序列化返回执行更新操作清空 namespace 缓存。我个人在实际项目里的倾向是默认关闭二级缓存不写cache/就行高频读、低频写且业务允许短暂延迟同步的数据用 Redis 缓存 手动失效。MyBatis 二级缓存省了几行代码但跨 namespace 的一致性问题会让你在后期流量上来之后付出更大的维护成本。真要用就把flushInterval设短一点readOnly 设 false实体类做好序列化版本控制。最后再分享一个实用技巧如果你面试前复习 MyBatis 源码不要从SqlSessionFactoryBuilder.build()开始硬啃先看XMLConfigBuilder.parseConfiguration()的调用顺序再跟着Configuration.addMappedStatement()走一遍基本就能理解整个初始化的主链路。这时候再回头看我前面第四章讲的内容你会觉得它不再是一个个孤立的知识点而是一条完整的数据流。看懂了这条流SpringBoot3 整合 MyBatis 对你来说就不再是背配置而是怎么改都心里有数的体系。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑