拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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 里用了HttpServletRequest,SpringBoot3 里必须写成:

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-spring
2.x2.3.x3.5.x2.1.x
3.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> <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>

注意 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 到第一条 SQL

2.1 最小可运行配置:数据源与 MyBatis 核心参数

依赖加好后,接下来是配置文件。我用的是 MySQL,最简配置如下:

spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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 这个 Bean,@Autowired注入的时候就会报NoSuchBeanDefinitionException。

有两种解决方式,选一种即可:

第一种,在启动类上加上@MapperScan:

@SpringBootApplication @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下的资源拷贝到 classpath,Java 目录下的.xml文件被忽略了。

解决办法有几种。

第一种,把 XML 放在src/main/resources/mapper下,配合mapper-locations: classpath:mapper/*.xml,这是最标准、最推荐的做法。

第二种,实在想和接口放一起,就在 pom.xml 里显式声明资源目录:

<build> <resources> <resource> <directory>src/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 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 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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 Result<Void> 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); }

XML:

<insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user (user_name, password, email, create_time) VALUES (#{userName}, #{password}, #{email}, NOW()) </insert> <select id="selectByUserName" resultType="User"> SELECT id, user_name, password, email, create_time FROM user WHERE user_name = #{userName} </select>

useGeneratedKeys="true"配合keyProperty="id"的意思是:数据库自增主键生成后,自动回填到传入 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 到 MappedStatement

4.1 MyBatis 是怎么完成初始化的:核心入口拆解

前面说了,starter 会自动配置SqlSessionFactory,但很多人不知道这个工厂背后发生了什么。理解了初始化流程,很多网上说不清的报错你都能自己定位。我把整条链路按顺序拆一下。

MyBatis 的初始化入口是XMLConfigBuilder。它的作用是解析 MyBatis 主配置文件(mybatis-config.xml)或者直接解析传入的Configuration对象。使用 starter 时,SpringBoot 会把application.yaml里的mybatis.*配置翻译成Configuration对象的属性,然后交给SqlSessionFactoryBean。

SqlSessionFactoryBean的核心方法是buildSqlSessionFactory(),它内部做了这几件事:

  1. 解析typeAliases,把type-aliases-package下的类注册到别名体系
  2. 解析mapperLocations,加载所有 XML 文件,转换成XMLMapperBuilder
  3. XMLMapperBuilder解析 XML 文件里的<mapper>节点,读取 namespace、SQL 片段、参数映射、结果映射
  4. 每条 SQL 语句被构建成一个MappedStatement,缓存在Configuration.mappedStatements这个 Map 里,key 是namespace + "." + id,比如com.example.demo.mapper.UserMapper.selectById
  5. 扫描@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 SqlSessionTemplate:Spring 整合下的线程安全会话

再强调一个容易被面试官追问的点:原生 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 都在,眼看就是找不到。

按我的经验,原因按频率排序如下:

  1. XML 文件没有被打进 target/classes。先直接去项目编译目录看有没有对应的 XML 文件,没有就是资源拷贝问题,参考 2.3 节解决。
  2. mapper-locations路径写错。比如 XML 在resources/mapper/user下,配置文件写的是classpath:mapper/*.xml,通配符*匹配不到子目录文件。需要改成classpath:mapper/**/*.xml。
  3. namespace没有指向接口全限定名。
  4. id和接口方法名对不上,包括大小写。

我的排查顺序是先看编译目录,再看配置路径,最后检查 XML 内标签。因为前两个错误最隐蔽,IDE 不报错,只有运行起来才知道。

5.3 MyBatis 条件不生效:动态 SQL 里 if 标签的隐藏规则

“mybatis 条件不生效”是搜索热词里出现率很高的一个点。最典型的情况是:

<select id="selectByCondition" resultType="User"> SELECT * FROM user WHERE 1 = 1 <if test="userName != null and userName != ''"> AND user_name LIKE CONCAT('%', #{userName}, '%') </if> </select>

调用时传入了userName,但生成的 SQL 里还是只有WHERE 1 = 1,条件没拼进去。常见原因有两个。

第一个,test表达式里的字符串比较写错了。XML 中判断空字符串必须用单引号:userName != '',写双引号会被 XML 解析成字符串的边界而报错,有些人为了避开双引号问题,写userName != ""后实际比较的是 JSON 字符串的语法错误,启动时直接抛异常。

第二个,方法参数没有加@Param注解。如果参数是普通对象User,test里可以直接写userName;如果参数是一个基本类型或 String,必须加@Param("userName"),否则 MyBatis 生成的参数名为param1,test写userName永远取不到值。

还有一个动态 SQL 的坑是where 1 = 1的写法虽然能跑,但性能上不算最优,也容易让后面接 SQL 的人困惑。我更推荐用<where>标签:

<select id="selectByCondition" resultType="User"> SELECT * FROM user <where> <if test="userName != null and userName != ''"> AND user_name LIKE CONCAT('%', #{userName}, '%') </if> </where> </select>

<where>会自动去掉第一个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 实体里想直接得到List<String>。

实体字段:

private List<String> tags;

自定义 TypeHandler:

@MappedTypes(List.class) @MappedJdbcTypes(JdbcType.VARCHAR) public class JsonTypeHandler extends BaseTypeHandler<List<String>> { private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, MAPPER.writeValueAsString(parameter)); } catch (JsonProcessingException e) { throw new SQLException("JSON序列化失败", e); } } @Override public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } @Override public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } @Override public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private List<String> parse(String json) { try { return MAPPER.readValue(json, new TypeReference<List<String>>() {}); } catch (JsonProcessingException e) { return Collections.emptyList(); } } }

这个类写在com.example.demo.handler包下,然后在application.yaml里配置:

mybatis: type-handlers-package: com.example.demo.handler

MyBatis 启动时会扫描这个包下的类,注册到TypeHandlerRegistry。注意@MappedTypes(List.class)和@MappedJdbcTypes(JdbcType.VARCHAR)注解是告诉注册器这个处理器匹配哪种组合,不写的话需要手动在 resultMap 里强制指定,比较麻烦。

6.3 在 XML 和注解两种场景下使用

XML 场景下,查询用简化写法:

<resultMap id="userMap" type="User"> <result column="tags" property="tags" typeHandler="com.example.demo.handler.JsonTypeHandler"/> </resultMap> <select id="selectWithTags" resultMap="userMap"> 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, typeHandler=com.example.demo.handler.JsonTypeHandler}。这个语法面试官偶尔会问,但实际项目里写 XML 时一般用不着,因为参数对象里的字段类型已经是List<String>,MyBatis 会自动走注册表匹配。

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: true

cache-enabled默认就是 true,所以这一步通常不用写。第二步在 Mapper XML 里加一行:

<cache eviction="LRU" flushInterval="600000" size="512" readOnly="true"/>

加了<cache/>标签,这个 namespace 的查询结果才会被缓存。我用到的参数含义是:

  • eviction:缓存淘汰策略,LRU 表示最近最少使用,默认也是 LRU
  • flushInterval:缓存刷新间隔,毫秒单位,不写表示没有刷新间隔,缓存直到被更新语句清空
  • size:最多缓存多少个对象
  • readOnly:true 表示直接返回缓存对象引用,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 对你来说就不再是背配置,而是怎么改都心里有数的体系。

返回列表