做后端开发这些年,Spring Boot 与 MyBatis 的组合是我在项目里用得最多的一套持久层方案。接触过不少团队,最后大多会选择 MyBatis:SQL 自己写、结果映射可控、动态 SQL 灵活,在复杂业务查询和报表类需求里尤其顺手。这篇文章围绕“Spring Boot 集成 MyBatis”展开,从初始化配置、核心语法、缓存机制、日志打印到常见报错排查,结合我已经踩过的坑逐个拆解。不管你是刚开始接触 Spring Boot 的新人,还是已经有几年开发经验、想系统梳理 MyBatis 细节的朋友,都能从这里拿到可以直接落地的方案。
1. 内容整体设计与思路拆解
1.1 为什么是 Spring Boot + MyBatis,而不选 JPA
先说选型。MyBatis 本质上是半自动 ORM,SQL 由开发者自己把控,框架只负责参数映射、结果集映射、SQL 执行和缓存。JPA/Hibernate 是全自动 ORM,可以自动生成大量 SQL,但遇到复杂多表关联、分页统计、多条件组合这些场景时,要么写 JPQL,要么原生 SQL,要么直接走 EntityManager,调试起来会比较绕。
我见过不少项目引入 JPA 后,开发者自己写 JOIN 查不出来问题,日志里看不出来 SQL 对不对,最后被迫回到写原生 SQL。而 MyBatis 的 XML 文件结构直观,一条 SQL 就是一条 SQL,DBA 审起来也方便。比如一个高校实验室预约管理系统,核心业务通常涉及:
- 用户与权限表
- 实验室、仪器设备表
- 预约记录表
- 审批记录表
- 分时段状态统计
这种系统天然适合用 MyBatis,因为预约查询往往带日期范围、状态、实验室类别等各种条件组合,SQL 可控性就是最大的优势。
需要提醒的是,这里说的 MyBatis 是指原生 MyBatis。现在 MyBatis-Plus、MyBatis-Flex 也都很流行,它们在 MyBatis 基础上封装了单表 CRUD、逻辑删除等能力。我认为团队的技术偏好和项目生命周期是选型关键:单表居多可以选 MyBatis-Plus 提升效率;复杂 SQL、多数据源、对 SQL 精细控制要求高的项目,原生 MyBatis 反而更稳定。两者并不互斥,后面会专门讲区别。
1.2 一个贯穿全文的小案例
为了让内容不空泛,我假设一个接近真实业务的需求:实验室预约记录综合查询。页面传入用户姓名、实验室名称、预约状态、开始日期、结束日期,后端返回预约列表并支持分页排序。这个案例会覆盖参数传入、动态 SQL、resultMap 映射、分页和缓存排查,基本能代表生产环境里最常见的 MyBatis 使用形态。
工程默认结构如下:
- Spring Boot 2.7.x(实际项目如果是 3.x,集成方式不变,但 starter 版本需要对应升级)
- Maven 构建
- MySQL 8.x
- mybatis-spring-boot-starter
- Lombok 辅助实体类
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程初始化与 Spring Boot 自动装配的关键配置
2.1 版本矩阵与依赖选择
Spring Boot 与 MyBatis Starter 的版本适配很关键。早期踩过一次坑:Spring Boot 3.0 出来后,我还继续用 mybatis-spring-boot-starter 2.x,结果启动直接报 ClassNotFound。原因是 Starter 2.x 默认依赖的是 Spring 5 和 Jakarta EE 8 之前的包,Spring Boot 3 全面迁移到了 Jakarta EE 9。
我这里给出当前比较稳定的搭配:
| 环境 | 版本建议 |
|---|---|
| JDK | 8 / 11 / 17 |
| Spring Boot | 2.7.14 或 3.x 对应版本 |
| mybatis-spring-boot-starter | Spring Boot 2.x 使用 2.3.x,Spring Boot 3.x 使用 3.0.x |
| MySQL Connector/J | 8.0.33 |
| 连接池 | HikariCP(Spring Boot 默认) |
Maven 依赖的核心部分长这样:
xml复制<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
如果你是在 2024 年之后新建 Spring Boot 3 项目,直接找 mybatis-spring-boot-starter 下的 3.x 版本即可。因为 Starter 的坐标没变,所以代码层面大多可以无缝迁移。
2.2 配置文件的推荐写法
这是我项目里沿用至今的一套精简配置:
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/lab_booking?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
hikari:
maximum-pool-size: 20
minimum-idle: 5
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.labbooking.entity
configuration:
map-underscore-to-camel-case: true
jdbc-type-for-null: "null"
几个容易混淆的细节:
mapper-locations用来声明 XML 文件路径。如果写的是classpath:mapper/*.xml,那么 XML 文件必须放在src/main/resources/mapper目录下。type-aliases-package是一个偷懒配置,声明之后,XML 里的resultType可以直接写实体类短类名User,不用写全限定名。map-underscore-to-camel-case: true表示把数据库中的create_time自动映射到实体的createTime。这是大多数人容易漏的一条,漏了以后列表接口能查出来,但字段全是 null,排查起来比较头疼。jdbc-type-for-null在部分数据库插入 null 值时报“无效的列类型”时可以辅助解决。
接着看启动类。两件事:加 @MapperScan 扫描 Mapper 接口。也可以不在启动类加,而在每个 Mapper 接口上加 @Mapper。两种方式都能用,但在大型项目里,我建议启动类统一 @MapperScan,避免漏注解。
java复制@SpringBootApplication
@MapperScan("com.example.labbooking.mapper")
public class LabBookingApplication {
public static void main(String[] args) {
SpringApplication.run(LabBookingApplication.class, args);
}
}
2.3 MyBatis 在 Spring Boot 中如何启动并工作
理解集成原理才能解决启动阶段的各种问题。当工程引入 mybatis-spring-boot-starter 后,会自动加载 MybatisAutoConfiguration。
启动阶段做的事情大致是这样的:
- Spring Boot 通过自动配置类感知到 DataSource。
MybatisAutoConfiguration创建SqlSessionFactory。这个过程中会读取mybatis.*配置项、解析 mapper XML 文件,生成MappedStatement对象。- 扫描 Mapper 接口,通过
MapperFactoryBean注册到 Spring 容器。 - 真正调用 Mapper 的方法时,Spring 拿到的其实是 JDK 动态代理对象。
- 代理对象根据接口方法找到对应的
MappedStatement,创建 BoundSql,把参数绑定到 SQL,然后交给 Executor 执行。
所以 MyBatis 的工作原理面试经常问,其实核心就一句话:配置解析 -> 代理生成 -> SQL 绑定 -> 执行器执行。日常我们用 Mapper 接口时加一个注解就能调用,背后是 MapperProxy 在做方法到 SQL 的映射。
如果启动时日志出现“No MyBatis mapper was found”这类警告,说明接口扫描路径不对或接口没有被 @MapperScan 覆盖,整个流程相当于没注册成功。
3. Mapper 层开发中的核心细节与实操要点
3.1 resultMap 与 POJO 映射的正确姿势
先放一个最常见的实体:
java复制@Data
public class User {
private Long id;
private String username;
private String realName;
private String email;
private Integer status;
private LocalDateTime createTime;
}
Mapper 接口:
java复制public interface UserMapper {
User selectById(@Param("id") Long id);
}
XML:
xml复制<select id="selectById" parameterType="long" resultType="User">
SELECT id, username, real_name, email, status, create_time
FROM sys_user
WHERE id = #{id}
</select>
由于 map-underscore-to-camel-case 开了,real_name 能自动转到 realName,create_time 能自动转到 createTime,此时可以不用显式 resultMap。但多表 JOIN、关联嵌套对象等场景,resultMap 还是有必要用的,因为它能明确表达“数据库列 -> Java 属性”的映射关系。
比如预约记录列表需要带出实验室信息:
xml复制<resultMap id="ReservationWithLab" type="Reservation">
<id property="id" column="reservation_id"/>
<result property="reservationTime" column="reservation_time"/>
<association property="lab" javaType="Lab">
<id property="id" column="lab_id"/>
<result property="name" column="lab_name"/>
</association>
</resultMap>
<select id="selectReservationWithLab" resultMap="ReservationWithLab">
SELECT
r.id AS reservation_id,
r.reservation_time,
l.id AS lab_id,
l.name AS lab_name
FROM reservation r
LEFT JOIN lab l ON r.lab_id = l.id
WHERE r.id = #{id}
</select>
在 resultMap 里,id 标签不只是“主键”的概念,更重要的角色是 MyBatis 用它来判断两条记录是否同一个对象。比如查询返回两条记录,实体的 id 一样,关联查询时结果集可能被合并成一条集合数据,这一点在写嵌套集合时要特别注意。
3.2 参数传递、@Param 与 #{} 和 ${} 的区别
MyBatis 方法参数多于一个时,不加 @Param 也可以,但是默认变量名是 param1、param2,代码可读性很差,而且有些写法直接引用参数名会报 BindingException。所以我一直建议显式加 @Param。
java复制List<Reservation> selectByCondition(@Param("name") String name,
@Param("labId") Long labId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
XML 里严格区分 #{} 与 ${} 是基础中的基础。#{} 会生成预编译 SQL 占位符,JDBC 使用 ? 占位,能有效防止 SQL 注入;${} 是字符串直接拼接。比如按排序字段排序,想要动态传入 order_by 和 order_type,只能用 ${},因为排序字段不是占位符能控制的:
xml复制<select id="selectPageList" resultType="Reservation">
SELECT *
FROM reservation
ORDER BY ${orderColumn} ${orderType}
</select>
但是这里必须加白名单校验,最保险的做法是控制在 Java 代码里枚举允许的字段名,再传入,避免外部数据直接拼进 SQL。
反例是常见的安全漏洞:
xml复制<!-- 千万不要这样写 -->
<select id="selectByName" resultType="user">
SELECT * FROM sys_user WHERE username = '${name}'
</select>
我在做过一次接口安全测试后,把项目里所有 ${} 全部清理了一遍,能用 #{} 的地方绝不用 ${}。同理,动态表名、动态列名也尽量不要拼进 XML,可以做成配置表或枚举映射;即便要做,也必须在 Java 层做严格合法性判断。
3.3 动态 SQL 的 if、where、set、foreach、choose 用法剖析
动态 SQL 是 MyBatis 最核心的价值点。它解决的是“多条件组合查询时 SQL 不确定”的问题,以预约记录查询为例:
xml复制<select id="selectByCondition" resultType="Reservation">
SELECT
r.id,
r.lab_id,
r.user_id,
r.reservation_date,
r.status,
r.remark
FROM reservation r
<where>
<if test="userId != null">
AND r.user_id = #{userId}
</if>
<if test="labId != null">
AND r.lab_id = #{labId}
</if>
<if test="startTime != null">
AND r.reservation_date >= #{startTime}
</if>
<if test="endTime != null">
AND r.reservation_date <= #{endTime}
</if>
<if test="status != null">
AND r.status = #{status}
</if>
</where>
ORDER BY r.reservation_date DESC
</select>
几个要点:
<where>会自动处理掉第一个AND,所以 SQL 片段里写不写起始AND都不影响。<if test="">内部是 OGNL 表达式,字符串判断要写成test="name != null and name != ''",集合判断要写上list != null and list.size() > 0。- 在 XML 中
>和<有特殊含义,<必须写成<,或者使用<![CDATA[ ... ]]>。我习惯把比较符号统一写成>=、<=。 - 注意 0 和空字符串问题。如果传入的 status 是 Integer 0,OGNL 里直接判断
status != null是没问题的,但如果有人这么写status != '',0 和空字符串在某些 OGNL 版本里会有歧义。建议判空只判断 null,不要混入空串判断。
另外还有几个常用标签:
<set> 用在 update 里,自动去掉末尾逗号:
xml复制<update id="updateReservationStatus">
UPDATE reservation
<set>
<if test="status != null">status = #{status},</if>
<if test="remark != null">remark = #{remark},</if>
</set>
WHERE id = #{id}
</update>
<foreach> 用在 in 查询和批量插入:
xml复制<select id="selectByIds" resultType="Reservation">
SELECT * FROM reservation
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<choose>、<when>、<otherwise> 相当于 Java 的 switch,适合互斥条件:
xml复制<choose>
<when test="queryType == 1">
AND r.status = 'PENDING'
</when>
<when test="queryType == 2">
AND r.status = 'APPROVED'
</when>
<otherwise>
AND r.status IS NOT NULL
</otherwise>
</choose>
3.4 分页与行号实现:从 PageHelper 到通用做法
MyBatis 原生不提供分页能力,因为它只是一个 SQL 映射框架。最常见的方案是 PageHelper 或 MyBatis-Plus 内置分页插件。PageHelper 的原理基于 MyBatis 拦截器:拦截 Executor 的 query 方法,在 SQL 后拼接 LIMIT ?。
用法就两行:
java复制PageHelper.startPage(pageNum, pageSize);
List<Reservation> list = reservationMapper.selectByCondition(...);
PageInfo<Reservation> pageInfo = new PageInfo<>(list);
但 PageHelper 有一个非常经典的坑:你在一个循环里先调了 PageHelper.startPage(),然后执行的下一条 SQL 不是希望分页的那一句,而是另一条查询,分页就会被错误地作用到那条 SQL 上。这里要注意 PageHelper 的 ThreadLocal 机制,分页参数只对下一次查询生效,必须紧跟调用。
另一个常见需求是查询结果增加行号,例如导出 Excel 时带序号。MySQL 8.0 之后推荐用窗口函数:
sql复制SELECT ROW_NUMBER() OVER (ORDER BY r.reservation_date DESC) AS row_no,
r.id,
r.lab_name,
r.status
FROM reservation r
ORDER BY r.reservation_date DESC
如果你用 @select 写,可以直接把这段 SQL 放进注解里。需要注意的是别名 row_no 返回后,MyBatis 会自动映射到实体的 rowNo 属性,前提是开了驼峰映射或写了 resultMap。
4. MyBatis 缓存机制与面试高频考点
4.1 一级缓存为什么有时生效,有时失效
MyBatis 的一级缓存是 SqlSession 级别的缓存,默认开启。它保存的是同一个 SqlSession 中执行过的 SQL 查询结果,Key 由 SQL、参数、分页条件等组成。
但在 Spring Boot 集成环境中,如果方法没有加 @Transactional,每次调用 Mapper 方法都会从连接池获取一个新的 SqlSession,方法结束就关闭。所以同一个 Service 方法里连续调两次相同查询,一级缓存未必命中——因为你每次拿到的都是不同 SqlSession。
如果方法上有 @Transactional,Spring 会将 SqlSession 和事务绑定,整个事务内使用的是同一个 SqlSession。这种情况下,同一事务里第一次查询和第二次相同查询之间,只要没有执行 update 或 delete,一级缓存就会命中,第二次查询不会再走数据库。
我自己在调一个“多次查询数据库导致慢 SQL”的问题时,就碰到过类似场景:同一个事务里循环查询 1000 次相同数据导致数据库压力大,后来分析日志发现一级缓存完全没有命中,就是因为某个微服务方法没有开启事务。
一个常见的面试题是“MyBatis 一级缓存能跨方法生效吗”,答案是:通常情况下不能。因为 SqlSession 生命周期不同。
另外,MyBatis 中执行 update、delete、insert 后,无论是否修改了缓存命中的那行数据,都会清空当前 SqlSession 的一级缓存,这是为了避免脏数据。
4.2 二级缓存到底该不该开
二级缓存是 Mapper(namespace)级别的缓存,多个 SqlSession 可以共享。默认不开启,需要在 XML 里显式配置:
xml复制<mapper namespace="com.example.labbooking.mapper.ReservationMapper">
<cache eviction="LRU"
flushInterval="60000"
size="512"
readOnly="false"/>
</mapper>
几个参数的含义:
- eviction:回收策略,LRU 表示最近最少使用
- flushInterval:刷新间隔,单位毫秒
- size:最多缓存对象个数
- readOnly:true 时所有调用方拿到同一个缓存对象,false 时会序列化拷贝返回
开启以后需要注意,缓存对象必须实现 Serializable 接口。我的踩坑经历是某次实体没有序列化,一开启 cache 立刻报 NotSerializableException。
但生产环境里我对二级缓存的态度比较保守。原因在于:一旦一个 SQL 关联了多张表,而另一张表的更新操作发生在别的 Mapper 里,缓存刷新就未必同步。比如预约记录 Mapper 开启了二级缓存,查询时 JOIN 了用户表;此时用户表的修改只发生在 UserMapper,reservation 表的二级缓存不会被清空,就会查到旧数据。在多实例部署时,还要考虑分布式缓存一致性。除非是字典、系统参数等极少变动且不跨表 join 的查询,否则不建议开二级缓存。
MyBatis 的三级缓存通常是指专用分布式缓存方案,比如把 MyBatis 的二级缓存实现替换为 Redis。但这不是开箱即用的,需要自己实现 Cache 接口或者引入第三方适配器。
4.3 调用链、Executor 与插件机制
MyBatis 的核心组件如下:
- SqlSessionFactory:创建 SqlSession
- SqlSession:对外门面,提供增删改查 API
- Executor:负责执行 SQL、维护一级/二级缓存
- StatementHandler:处理 JDBC Statement,完成 SQL 参数设置
- ParameterHandler:参数预处理
- ResultSetHandler:处理结果集并映射成实体
这一条链是理解 MyBatis 插件原理的钥匙。MyBatis 允许对 Executor、StatementHandler、ParameterHandler、ResultSetHandler 四个对象做拦截代理,PageHelper 的物理分页、很多 SQL 日志工具、字段自动填充功能都是在这里做手脚。
比如 PageHelper 是在 Executor 层拦截 query,拼接 limit SQL;一些 MyBatis 插件可以拦截 Update 操作自动填充 update_time。
理解这条调用链之后,排查 SQL 不生效、参数丢失等问题的效率会高很多。比如我遇到过一次 SQL 能执行但参数少了一个,就是 ParameterHandler 被某个自定义插件改坏了。
5. 常见问题排查与实操记录
5.1 write operations are not allowed in read-only mode 报错
网上关于这个报错的提问非常多,完整报错类似:
text复制MyBatis SystemException: write operations are not allowed in read-only mode (FlushMode.MANUAL)
这个报错在原生 MyBatis 集成的场景里,大概率是你把事务声明成了只读,然后又执行了写入操作。最常见的是 Service 方法上标了:
java复制@Transactional(readOnly = true)
public void updateReservation(Reservation reservation) {
reservationMapper.updateById(reservation);
}
Spring 对只读事务的处理是:若底层连接池支持 readOnly,会尝试对 Connection 设置只读模式。在 MySQL 驱动上可能不会报错,但在某些数据库驱动、某些连接池、或者开启了 Druid 的物理连接只读检测后,就会出现写入被拒绝的情况。
排查思路按照这三步走:
- 先看方法链路上有没有
@Transactional(readOnly = true)。很多老代码为了标榜“查询方法开只读事务”,结果方法里不只有查询,还有写入。 - 看数据源 / 中间件层是否配置了只读路由。例如一些分库分表中间件把当前连接路由到了只读实例,自然不能写。
- 看 MyBatis 的
default-executor-type配置是否影响了 FlushMode。如果配置了 BATCH 且没有主动 flush,也可能遇到 FlushMode 相关异常。
修复方案很简单:把写操作拆到另一个非只读事务方法里,不要整段链路都标 readOnly。
这里也顺带说一句,readOnly 本身在 MySQL 场景对普通查询没有太多性能收益,不要教条地给所有查询方法都加上。
5.2 查出来的实体属性全是 null
这是新手最容易遇到的问题。SQL 能查出来,数据库也有值,但返回的 List 里所有属性都 null。通常原因有:
map-underscore-to-camel-case没开,数据库列create_time和 Java 属性createTime对不上。- 查出来的列没有起别名,MyBatis 想把 SQL 列名映射到属性名,找不到对应字段。
- 实体属性是基本类型而不是包装类型。
- 因为用了多表 join,列名冲突。例如 sys_user 和 lab 里都有 name 列,结果集里第二个 name 覆盖了第一个 name。
在 open-in-view 或者复杂结果集场景,建议 SQL 中每个列都显式起别名,必要时写 resultMap。
我项目里有个统一规范:单表查询可以靠驼峰映射,多表 JOIN 一律显式写别名。这样能避免很多排查成本的浪费。
5.3 SQL 配置了却不打印日志
MyBatis 不像普通 CRUD 框架那样,默认会打印 SQL。想要在开发环境看到 SQL,有三种主流方式。
第一种,在 application.yml 里配置:
yaml复制mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
这种方式的优点是配置简单,启动后直接就能在控制台看到 Preparing 和 Parameters。缺点是格式比较粗糙,所有 Mapper 的 SQL 都会打印,日志量很大,不适合大型项目长期开启。
第二种,把日志交给 Spring Boot 的日志框架管理,只打印指定 Mapper 包下的 debug 日志,不配置 stdout:
yaml复制logging:
level:
com.example.labbooking.mapper: debug
这种情况下需要把 log-impl 那一行去掉,不然 stdout 会把你所有的 MyBatis 日志抢走。这种方式我会长期保留在开发环境里,因为可以按包控制范围,真正需要看哪块就开哪块。
第三种,如果你用 IDEA,可以装 MyBatis Log Free 插件。插件会自动拦截 MyBatis 日志,并把 Preparing 和 Parameters 两行日志还原成一条可以直接复制执行的完整 SQL。生产环境排查时,这种方式非常高效,不需要手动拼接占位符参数。
需要留意的是,SQL 打印在生产环境建议关闭或至少降级到 info,避免日志量过大以及敏感数据外泄。
5.4 Mapper 接口或 XML 注册不生效
现象一:启动不报错,但调用 Mapper 方法时报“Invalid bound statement (not found)”。
这说明 Mapper 接口被扫描到了,但通过接口方法全限定名找不到对应的 MappedStatement。常见原因是 XML 没有被放到 mapper-locations 指定的目录,或 XML 里的 namespace 写错。
排查方法:
- 检查 XML 的 namespace 是否与接口全限定名一致。
- 检查 XML 里的 id 是否与接口方法名一致。
- 确认编译后的 target/classes/mapper 目录下有没有对应的 XML。
现象二:项目一启动就报“Consider defining a bean of type 'xxxMapper' in your configuration.”
一般是两种情况:
- 启动类没有
@MapperScan,Mapper 接口上也没有@Mapper。 - Mapper 接口没被 Spring 组件扫描到。
我习惯把所有 mapper 接口放在同一包下,启动类上统一一个扫描注解,简单可靠。
5.5 事务回滚不生效的排查记录
MyBatis 的写操作默认不是自动提交的,理论上插入、修改失败应该回滚。但在 Spring Boot 中,如果你直接在 Controller 里调用多个 Mapper 方法,异常发生后部分数据已经写入,往往是因为没有加 @Transactional。
常见错误写法:
java复制@PostMapping("/reservation")
public Result create(@RequestBody ReservationDTO dto) {
reservationMapper.insert(dto);
logMapper.insertLog(dto);
return Result.success();
}
这里如果插入日志失败,预约已经落库。你应当把这两个写操作放进一个 Service 方法,并加上 @Transactional,确保它们在同一个事务内:
java复制@Transactional(rollbackFor = Exception.class)
public void createReservation(ReservationDTO dto) {
reservationMapper.insert(dto);
logMapper.insertLog(dto);
}
注意 rollbackFor 的细节。Spring 默认只对 RuntimeException 回滚,如果业务抛的是受检异常,不指定 rollbackFor 就不会回滚。这个不起眼的小问题经常把我逼到去看 MySQL 日志。
5.6 N+1 查询问题
先理解 N+1 是什么:主表查出来 N 条记录,关联子表时每一条都去查一次数据库,结果总共执行 N+1 条 SQL。最初系统数据量小的时候不会暴露,一旦预约记录查到几百上千条,数据库连接数会瞬间飙升。
MyBatis 的嵌套结果集是写法问题,而不是框架自动产生 N+1。如果你使用了嵌套 Select 的 association/collection,比如 <collection property="labs" select="selectLabsByUserId" />,MyBatis 会逐行执行子查询,产生 N+1。
推荐做法是 JOIN 查询后,通过 resultMap 完成一次结果集映射。如果必须分开查,就把主表数据查出来后,再按主键统一查一次关联表,在内存里组装。
6. 工具、生态与工程应用影响范围
6.1 MyBatis 与 MyBatis-Plus、MyBatis-Flex 选型对比
这个问题的热度一直很高。最早很多人以为 MyBatis-Plus 就是 MyBatis 的升级版,其实两者不是替代关系,MyBatis-Plus 是在 MyBatis 之上做增强的框架,核心能力是单表 CRUD 开箱即用、条件构造器、分页插件、逻辑删除、字段自动填充。MyBatis-Flex 是较新的框架,语法设计类似 MyBatis-Plus,但在多表查询、逻辑删除方面做了一些更灵活的改进。
| 对比项 | MyBatis | MyBatis-Plus | MyBatis-Flex |
|---|---|---|---|
| 单表基础 CRUD | 手写 SQL 或注解 | 内置 BaseMapper,免写 SQL | 内置 BaseMapper |
| 复杂多表 SQL | XML 控制力强 | 需要通过 Wrapper 或 XML | 支持多表查询构造器 |
| 动态 SQL | 灵活 | 支持 | 支持 |
| 逻辑删除 | 需要自己控制条件 | 内置逻辑删除 | 内置逻辑删除,且可配置多逻辑值 |
| 侵入性 | 低 | 较高,实体需继承 Model 等 | 较低 |
| 学习成本 | 需理解 SQL 与 XML | 需理解 Wrapper 语法 | 需理解新框架的一套 API |
我现在的建议是:如果只是单表 CRUD 系统、追求开发速度,MyBatis-Plus 会减少大量机械代码;如果项目有大量复杂统计报表、团队本身对 SQL 很熟练,原生 MyBatis 更稳妥。在一个工程里混用原生 MyBatis 与 MyBatis-Plus 也是可行的,因为它们共用 SqlSessionFactory,但会增加维护成本,不是必要场景不建议引入两套。
6.2 常用代码生成与 SQL 审计插件
每次手写实体、Mapper XML 其实很消耗时间。使用 MyBatis Generator 是传统方案,配置 generatorConfig.xml,指定数据库连接和表名后,可以自动生成实体类、Mapper 接口和 XML。但它生成的代码风格偏基础,不适合直接用于复杂业务,通常是生成后由开发者在此基础上修改。
近些年也有 MyBatisX 这样的 IDEA 插件,可以在 IDE 里对 Mapper 接口和 XML 跳转,也能辅助生成简单的 CRUD。SQL 日志审计方面,除了前面提到的 MyBatis Log Free,还可以在 Java 层用拦截器统一打印 SQL 执行耗时,或者接 micrometer 采集指标。
拦截器统计执行耗时的写法并不复杂,核心思路是让自定义拦截器实现 MyBatis 的 Interceptor 接口,拦截 Executor 的 update/query 方法,计算耗时后交给日志或监控系统。
6.3 在 Spring Boot 项目里配合 Actuator 与消息中心
在生产环境,很多人只关注到 MyBatis 能把 SQL 跑通,却忽略了连接池健康状态、Mapper 调用量这些指标。引入 spring-boot-starter-actuator 后,可以采集 HikariCP 连接池指标,通过 micrometer 上报到 Prometheus 或 Grafana。SQL 频繁超时、连接数打满这类问题,通常都能从图上看到端倪。
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
在涉及消息通知的场景,比如短信、站内信、App 推送服务时,通常有中间表保存待发送状态。消息接收服务收到请求后,先通过 Mapper 写入待发送表,再调用外部推送 API,推送成功后再更新状态。这里用上 MyBatis 的自定义更新 SQL 非常合适,也更方便做失败重试。
在部分 Java 工程中,还会把 MyBatis 与 Redis Stream 这样的队列配合:从队列拉取消息后落库,再执行后续业务。这种场景要注意事务边界,尽量让“拉消息”和“落库”解耦,避免把消费位移和业务数据放在同一个事务里导致长事务。
7. 长期陪伴项目后的几点实操建议
如果要说这几年最直观的感触,那就是不要把 MyBatis 复杂化。它本身是一个定位非常清晰的持久层框架,SQL 控制力强、动态脚本灵活,但它不做缓存银弹、不解决 N+1、不自动生成业务逻辑。很多问题不是框架 bug,而是对执行原理理解不够。比如二级缓存没用好、PageHelper startPage 和 SQL 顺序写错、只读事务上执行写入,这些一看日志就能发现,却常常耗掉一整天的排查时间。
日常开发中我有几个固定的检查习惯:写 SQL 前先确认字段映射,复杂的 join 查询永远显式写列名;XML 文件修改后强制看的目标目录是否更新;任何分页逻辑都要留意 ThreadLocal 参数是否串了;日志打印做到按 Mapper 包控制级别,不直接在全局 stdout 刷屏。
另外建议团队里每人都掌握 MyBatis Log 类工具的使用,看完 SQL 再看结果,效率提升明显。遇到疑难问题时不要急着去改配置,先打开 debug 日志,定位这条 SQL 是否真的执行了、参数是否正确、返回结果集与实体映射是否有偏差。这套流程跑完,大概率的故障都能浮出水面。
