说个实在的,现在 Java 后端面试和日常开发里,Spring + MyBatis 基本就是标配组合。尤其 Spring Boot 出来以后,MyBatis 的集成难度被大幅降低,但很多人只是会“用”,一旦遇到缓存失效、分页慢、嵌套查询 N+1、日志看不到 SQL 这类问题,就容易抓瞎。这篇文章我不打算只贴一段配置就完事,而是把 Spring 中使用 MyBatis 的完整链路拆开讲清楚,从选型思路、核心配置、Mapper 开发、缓存机制到分页插件原理和常见坑,尽量让你看完之后不仅会写,还能知道为什么这么写。
这套内容适合谁?刚把 Spring Boot 跑起来、准备接数据库的新手可以照着一步步做;写过一阵子 MyBatis 但没深究过缓存和拦截器的同学,也能在这里找到不少“原来如此”的瞬间;就算你是老手,最后那部分问题排查清单也值得扫一眼,里面有不少我实际踩过的坑。
1. 技术选型与整体设计思路
1.1 为什么是 Spring + MyBatis 而不是 JPA 或纯 JDBC
先聊点宏观的。你用 Spring 开发,操作数据库的方案大致有三条路:纯 JDBC、Spring JDBC Template、JPA/Hibernate、MyBatis。纯 JDBC 的问题不是不能用,而是样板代码太多。我早期写过一段 JDBC 查询,光处理 ResultSet 的 getString、getInt、null 判断,一个方法就写了七八十行,而且每个表都得来一遍,维护成本极高。Spring JDBC Template 能做一定封装,但复杂查询时 SQL 拼接依旧痛苦。JPA/Hibernate 则走了另一条路,用对象关系映射帮你自动生成 SQL,简单 CRUD 很爽,但一旦涉及多表联查、复杂动态条件、报表类 SQL,要么写 JPQL,要么用原生 SQL 混搭,反而有种“戴着镣铐跳舞”的感觉。
MyBatis 的核心思路是:SQL 你自己写,映射关系我来管。它不做全自动的 SQL 生成,而是把 SQL 的控制权完全交给你,再把结果集自动映射成 Java 对象。这种做法对国内大量“SQL 为王”的业务系统非常友好——性能可控、调优直观、团队里任何一个人打开 XML 都能看懂查询逻辑。在 Spring 生态里,MyBatis 通过 mybatis-spring 适配器无缝融入事务管理,配合 Spring Boot 的 starter 更是开箱即用。所以选型上,我的判断是:如果你的项目是重 SQL、重报表、多表关联复杂的业务系统,MyBatis 是性价比极高的选择;如果项目模型简单、CRUD 为主、希望少写 SQL,那 JPA 也可以考虑。但既然标题是 Spring 中使用 MyBatis,下面的内容就全按 MyBatis 来展开。
1.2 包结构与分层设计建议
很多初学者喜欢把 Mapper 接口和 XML 文件分开乱放,结果不是扫描不到就是路径配错。实际上 MyBatis 对 Mapper 的位置非常宽容,但你需要有一条清晰的约定。我个人推荐的结构是标准的 Maven 分模块或单模块分层:
controller层负责接收 HTTP 请求、参数校验、返回结果封装。service层负责业务逻辑、事务边界、调用多个 Mapper。mapper包存放 MyBatis 的 Mapper 接口。resources/mapper目录存放对应的 XML 文件。entity(或 domain、po)包放数据库表对应的实体类。dto、vo包放接口入参和出参对象。
这里有个常见问题:Mapper 接口在com.example.demo.mapper,XML 文件在resources/mapper下,怎么让 MyBatis 知道它们的对应关系?两种方式:一种是在application.yml里写mybatis.mapper-locations: classpath:mapper/*.xml,另一种是约定 XML 的 namespace 与接口全限定名一致。我个人建议两种都做到,接口和 XML 名字保持一致,配置里显式声明 mapper-locations,双保险。
注意:如果 XML 文件的 namespace 写错了,或者接口方法 id 与 XML 中的 statement id 对不上,启动时往往不会立刻报错,而是在第一次调用这个方法时抛出
BindingException。这类问题排查起来比较费劲,所以写接口时一定要保持接口方法名、XML id、SQL 三者的严格对应。
1.3 依赖选型:mybatis-spring-boot-starter vs 原生 mybatis + mybatis-spring
现在 Spring Boot 项目接 MyBatis 有三类依赖可用:
org.mybatis.spring.boot:mybatis-spring-boot-starter:官方 starter,版本随 Spring Boot 版本走,例如 Spring Boot 2.7 用 2.3.x,Spring Boot 3.x 用 3.0.x。org.mybatis:mybatis+org.mybatis:mybatis-spring:手动组合,适合非 Spring Boot 的纯 Spring 项目。com.baomidou:mybatis-plus-boot-starter:MyBatis 的增强版,内置通用 CRUD、分页插件、条件构造器。它底层仍是 MyBatis,但别提的是它默认会改变一些行为,比如逻辑删除、自动填充,选择之前要和团队对齐。
如果只是单纯想用 MyBatis 而不是 MyBatis-Plus,我建议直接用官方 starter,不要手动引入 mybatis 和 mybatis-spring,除非你对版本兼容性有严格管控需求。starter 的好处是自动帮你注册SqlSessionFactory、SqlSessionTemplate、MapperScannerConfigurer,还会把application.yml中的mybatis.*配置自动绑定到MybatisProperties。省事且不容易出错。
2. 环境搭建与核心配置实操
2.1 基于 Spring Boot 快速集成
我用 Spring Boot 3.2 + mybatis-spring-boot-starter 3.0.3 演示一个最小可用工程。首先在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>如果你的数据库是 PostgreSQL、Oracle 或国产数据库,替换驱动即可。接 MySQL 8.x 时注意驱动类名是com.mysql.cj.jdbc.Driver,而不是老的com.mysql.jdbc.Driver。Spring Boot 3.x 下如果使用默认的 HikariCP 连接池,基本不用额外配置。
然后是application.yml:
spring: datasource: url: jdbc:mysql://localhost:3306/test_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password 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这里有几个参数需要解释一下。map-underscore-to-camel-case我建议必须打开,它可以把数据库的user_name自动映射为实体的userName,省去一大堆column和property的显式声明。type-aliases-package则让 XML 中写resultType时可以直接写实体类的简单名称,不用每次写全限定名。log-impl配成 StdOutImpl 会把执行的 SQL 直接打印到控制台,开发阶段非常有帮助。
2.2 启动类与 Mapper 扫描配置
Spring Boot 中对 Mapper 接口有两种扫描方式:
- 在启动类上加
@MapperScan("com.example.demo.mapper"):这个注解会扫描指定包下所有接口,把它们注册为 MyBatis 的 Mapper。 - 在每个 Mapper 接口上单独加
@Mapper注解:这种方式比较啰嗦,但更显式。
两种方式可以共存,但通常显式声明@MapperScan就足够了。需要注意的是,如果 Mapper 接口没有被任何注解扫描到,Spring 容器启动时不会报错,但你在 Service 里注入时会直接启动失败,报NoSuchBeanDefinitionException。
@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }如果你写的不是 Spring Boot 而是传统 Spring 项目,需要在 XML 或 JavaConfig 中配置:
@Configuration @MapperScan("com.example.demo.mapper") public class MyBatisConfig { @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/*.xml")); org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); factoryBean.setConfiguration(configuration); return factoryBean.getObject(); } }这只是用于理解幕后逻辑。实际上 Spring Boot starter 帮我们做了大部分事情,传统 Spring 集成才需要手动写这些。
2.3 日志与 SQL 打印:开发期必备配置
排查问题第一步永远是看 SQL。MyBatis 打印 SQL 有三种常见方式:自带日志实现、mybatis.configuration.log-impl、logging.level配置 Mapper 包路径。如果你使用StdOutImpl,SQL 和参数都会直接输出到控制台,格式比较原始,但够用。生产环境不建议开StdOutImpl,因为日志都进了控制台,难以统一收集和管理。
我在实际项目中发现一个坑:如果同时配置了mybatis.configuration.log-impl和logging.level.com.example.demo.mapper: debug,可能会看到 SQL 打印两次或者日志级别混乱。原因是 MyBatis 内部会根据 log-impl 找一个日志实现,而 Spring Boot 的日志体系也会拦截 mapper 的 debug 日志。解决办法是想清楚到底用哪一套:如果项目有统一的日志框架(logback/log4j2),建议用logging.level方式,不设置 log-impl,让 MyBatis 自动发现底层日志框架;如果只是本地调试,直接 log-impl 配 StdOutImpl 最直观。
3. Mapper 开发:从注解到 XML 的完整实践
3.1 注解 SQL vs XML SQL:选择与边界
MyBatis 支持在 Mapper 接口方法上加@Select、@Insert、@Update、@Delete注解直接写 SQL。这种方式对极简单的 CRUD 很方便,代码文件少,看着清爽。
@Mapper public interface UserMapper { @Select("SELECT * FROM user WHERE id = #{id}") User findById(Long id); @Insert("INSERT INTO user(name, age) VALUES(#{name}, #{age})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(User user); }但一旦遇到动态 SQL(if、foreach、choose),注解里写字符串拼接会非常痛苦,可读性极差。比如一个根据多个可选条件查询用户列表的 SQL,用注解写@Select("<script> ... </script>")能写到你怀疑人生。所以我的建议是:单表简单操作可以用注解,涉及多条件动态查询、多表关联、复杂映射的必须用 XML。
3.2 XML 映射文件的完整示例
我们先看一个完整的用户表 CRUD XML 应该长什么样:
<?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"> <resultMap id="UserResultMap" type="com.example.demo.entity.User"> <id property="id" column="id"/> <result property="userName" column="user_name"/> <result property="age" column="age"/> <result property="createTime" column="create_time"/> </resultMap> <select id="findById" resultMap="UserResultMap"> SELECT id, user_name, age, create_time FROM user WHERE id = #{id} </select> <select id="listByCondition" resultType="User"> SELECT id, user_name, age, create_time FROM user <where> <if test="userName != null and userName != ''"> AND user_name LIKE CONCAT('%', #{userName}, '%') </if> <if test="age != null"> AND age = #{age} </if> </where> ORDER BY create_time DESC </select> <insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user(user_name, age) VALUES(#{userName}, #{age}) </insert> <update id="update" parameterType="User"> UPDATE user <set> <if test="userName != null">user_name = #{userName},</if> <if test="age != null">age = #{age},</if> </set> WHERE id = #{id} </update> <delete id="deleteById"> DELETE FROM user WHERE id = #{id} </delete> </mapper>这里有三个高频易错点必须提:
<where>会自动去除多余的 AND/OR,所以<if>里写AND age = #{age}是安全的,不要自己去掉 AND。<set>会自动处理末尾逗号,但如果你在 if 内部写逗号,多个条件组合时要注意不要多出多余逗号。resultType="User"依赖type-aliases-package配置;如果没配,就必须写全限定名com.example.demo.entity.User。
3.3 参数传递的几种方式与陷阱
Mapper 接口方法的参数传递是日常开发中经常踩坑的地方。最基本的规则是:
#{id}是预编译占位符,MyBatis 会生成?占位符并用 PreparedStatement 传参,可以防 SQL 注入。${column}是字符串拼接,直接把值拼进 SQL,无法防注入,只适合排序字段、表名等不能使用占位符的场景。
单参数时,如果参数是基本类型,使用#{任意名字}都能取到。多参数时,直接用#{param1}、#{param2}不太优雅,建议用@Param("xxx")进行显式命名:
List<User> findByCondition(@Param("userName") String userName, @Param("age") Integer age);XML 里写成:
<select id="findByCondition" resultType="User"> SELECT * FROM user WHERE age = #{age} <if test="userName != null"> AND user_name = #{userName} </if> </select>为什么不建议直接用#{param1}、#{param2}?因为一旦调整参数顺序,或者增加参数,XML 里的名称就和方法对不上了,非常容易出隐藏 bug。用@Param显式命名既保证了可读性,也提升了重构时的安全性。另外,当参数是对象时,#{userName}会直接取对象属性,不需要@Param,但如果你还要同时传其他参数,那就必须加上@Param并在 XML 中使用#{user.userName}来访问属性。
3.4 动态 SQL 的核心:if、choose、foreach、trim
动态 SQL 是 MyBatis 最值钱的能力。很多新手把这一块当成“会在 XML 里写 if”就没问题了,实际远远不够。
if是最基础的判断,用于动态拼接条件。注意test里写的是 OGNL 表达式,基本类型判断 null 和空字符串即可,但如果是 Integer 类型判断!= ''会出问题,因为 Integer 和字符串比较会走字符串转换,建议只判断!= null。
choose相当于 Java 的 switch:
<choose> <when test="queryType == 'name'"> AND user_name = #{keyword} </when> <when test="queryType == 'age'"> AND age = #{keyword} </when> <otherwise> AND create_time >= #{startTime} </otherwise> </choose>foreach用于 IN 查询,要特别注意collection的取值。如果参数是List直接用list,如果是数组用array,如果是@Param("ids") List<Long> ids则用ids:
<select id="findByIds" resultType="User"> SELECT * FROM user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>trim是最灵活的标签,<where>和<set>本质上就是 trim 的预设形式。理解trim的prefix、suffixOverrides可以让你写出更自由的控制逻辑。比如<where>等价于<trim prefix="WHERE" prefixOverrides="AND |OR ">,<set>等价于<trim prefix="SET" suffixOverrides=",">。
3.5 ResultMap 与复杂映射
当数据库字段和实体属性不完全一致时,resultType的自动映射可能不够用。resultMap可以显式声明列与属性的映射关系,还可以处理关联映射(association)和集合映射(collection)。
举个例子,查询订单同时返回订单对应的用户:
<resultMap id="OrderWithUserMap" type="com.example.demo.entity.Order"> <id property="id" column="order_id"/> <result property="orderNo" column="order_no"/> <result property="totalAmount" column="total_amount"/> <association property="user" javaType="com.example.demo.entity.User"> <id property="id" column="user_id"/> <result property="userName" column="user_name"/> </association> </resultMap> <select id="findOrderWithUser" resultMap="OrderWithUserMap"> SELECT o.id AS order_id, o.order_no, o.total_amount, u.id AS user_id, u.user_name FROM orders o LEFT JOIN user u ON o.user_id = u.id WHERE o.id = #{id} </select>关联查询时要特别注意id和result的命名,尤其是联表后出现同名字段时(比如两张表都有 id),一定要用别名区分,否则映射结果会错乱。
4. 进阶机制:缓存、分页插件与拦截器原理
4.1 MyBatis 一级缓存与二级缓存:原理、局限与坑
MyBatis 的缓存机制分两级。一级缓存是SqlSession级别的,默认开启且无法关闭。它的作用范围是同一个 SqlSession 中两次相同的查询,第一次查询结果会缓存在本地 Map 中,第二次相同查询直接返回缓存。在 Spring 环境中,每次请求通常会新建 SqlSession,使用完即关闭,所以一级缓存的命中范围很小,但对同一个事务内的重复查询还是有明显效果。
这里有个经典坑:如果同一个 SqlSession 中先查询了一条记录,然后对这条记录做了 update,再查同一条记录,一级缓存会失效吗?答案是会。MyBatis 在执行 insert/update/delete 时会把 SqlSession 的缓存清空。但如果先查两次再更新,这两次查询之间缓存生效,后一次只是查缓存,不会真正执行 SQL。这在调试时会产生“明明数据库已经变了,为什么查出来还是旧数据”的错觉。
二级缓存是 Mapper 级别的,跨 SqlSession 共享,默认关闭。需要三步开启:
- 在 MyBatis 全局配置里设置
cache-enabled: true。 - 在 XML 中添加
<cache/>标签,局部缓存会优先生效。 - 实体类需要实现
Serializable接口,因为二级缓存可能会写入磁盘或需要序列化传输。
二级缓存的失效时机同样是执行增删改时清空对应 Mapper 的缓存。但需要注意:如果使用多表查询并且多个 Mapper 共享表数据,二级缓存非常容易产生脏读。比如订单查询缓存了用户信息,之后直接通过 UserMapper 更新用户名,OrderMapper 的缓存并不知道,依然返回旧名称。这是我在项目里不推荐新手随便开二级缓存的核心原因。我的建议是:默认只开启一级缓存,不用二级缓存。分布式环境下用 Redis 做业务缓存,远比本地二级缓存可靠。
4.2 分页插件为什么高效:PageHelper 的工作原理
分页插件是 MyBatis 生态里使用频率极高的组件。最常见的 PageHelper 用法是:
PageHelper.startPage(pageNum, pageSize); List<User> userList = userMapper.selectList(); PageInfo<User> pageInfo = new PageInfo<>(userList);第二行执行时,PageHelper 会通过 MyBatis 拦截器机制拦截即将执行的 SQL,在 SQL 末尾追加LIMIT ?, ?或对应的数据库方言分页语句,同时执行一条 count 查询获取总数。这就是我们常说的“物理分页”,和内存分页有本质区别。内存分页是把所有数据查出来后在 Java 内存里截取一段,数据量大时必然 OOM;PageHelper 的物理分页是在数据库层面完成,性能可控。
这里有几个 PageHelper 的经典坑:
PageHelper.startPage必须紧接着 Mapper 方法调用,如果中间隔着其他查询方法,分页会作用到错误的 SQL 上。- 使用 MyBatis-Plus 时,
startPage可能与 MP 自带的分页插件冲突,建议只引入一个分页方案。 - PageHelper 的 count 查询在某些复杂 SQL(比如包含 group by、union)上可能生成错误的 count SQL,必要时可以手写 count 查询并设置
countSql。
4.3 MyBatis 拦截器:原理与一个实际案例
MyBatis 的拦截器是它最强大的扩展点之一。其原理是使用 JDK 动态代理,对四大核心对象(Executor、StatementHandler、ParameterHandler、ResultSetHandler)进行代理拦截。我们常见的分页、数据权限、字段自动填充,底层都是靠拦截器实现的。
实现一个拦截器需要实现org.apache.ibatis.plugin.Interceptor接口,并用@Intercepts注解声明要拦截的方法签名。这里给一个实际例子:拦截所有查询,自动追加一个逻辑删除的条件。
@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class LogicDeleteInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0]; BoundSql boundSql = mappedStatement.getBoundSql(invocation.getArgs()[1]); String oldSql = boundSql.getSql(); // 简单判断是否包含某张需要逻辑删除的表,实际生产需要更严谨的规则 String newSql = oldSql + " AND deleted = 0"; // 通过反射修改 BoundSql 中的 sql 字段 Field field = BoundSql.class.getDeclaredField("sql"); field.setAccessible(true); field.set(boundSql, newSql); return invocation.proceed(); } }注意拦截器代码直接修改了BoundSql的私有字段,这种反射方式的实现依赖于 MyBatis 内部实现细节,升级 MyBatis 版本时需要回归测试。另外,拦截器注册时需要添加到 MyBatis 配置中:
@Configuration public class MyBatisConfig { @Bean public ConfigurationCustomizer configurationCustomizer() { return configuration -> { configuration.addInterceptor(new LogicDeleteInterceptor()); }; } }虽然拦截器很强大,但我的经验是:能不用就不用。拦截器是隐式的、全局的,出了问题非常难排查。比如说你写了一条 SQL,到数据库执行时发现多了个条件,第一反应肯定去查业务代码,而不会想到是某个拦截器在作怪。所以,除非是分页、数据权限这类必须全局处理的场景,否则尽量把逻辑写在 SQL 中,保持显式。
5. 性能优化与工程实践建议
5.1 N+1 查询问题与批量操作优化
先说 N+1 问题。当查询一个订单列表,然后循环遍历每个订单去查对应的用户,就会产生“1 条主查询 + N 条子查询”的 N+1 情况。数据量小的时候看着没问题,数据量一大,数据库连接和查询耗时都会飙升。解决办法有三个层面:
- 使用关联查询,一次性把用户信息 join 出来。
- 使用
IN批量查询,先把订单查出来,收集所有 userId,再用WHERE id IN (...)查询用户,最后在内存中做映射。 - 使用 MyBatis 的
collection嵌套结果映射,但要注意避免结果集笛卡尔积。
批量插入也是优化重点。MyBatis 的<foreach>批量 insert 是常用方案:
<insert id="batchInsert"> INSERT INTO user(user_name, age) VALUES <foreach collection="list" item="user" separator=","> (#{user.userName}, #{user.age}) </foreach> </insert>这里有一个 MySQL 的注意事项:max_allowed_packet默认可能有 4MB 或 64MB 的限制。如果一次批量插入的行数过大(比如一次性插入 10 万条),拼接的 SQL 可能超过包大小限制,导致执行失败。建议分批操作,比如每批 1000~2000 条,既能保证执行效率,又不会触及数据库单包限制。
5.2 慢 SQL 排查:从数据库到 MyBatis 的完整链路
关于“mybatis @update 执行慢”这类问题,我的排查路径如下:
- 先看数据库本身:用
EXPLAIN分析 SQL 是否走了索引,有没有全表扫描。 - 看连接池状态:如果连接池等待时间过长,说明数据库连接不够用或者连接被长时间占用。
- 看 MyBatis 日志:确认执行的具体 SQL 和参数,有可能你想执行的是索引字段,但实际传参类型与字段类型不匹配,导致索引失效。
- 看是否被拦截器影响:有的项目里配置了全局拦截器,导致 SQL 被修改后无法走索引。
还有一个常见情况是更新操作执行慢:UPDATE user SET age = #{age} WHERE id = #{id}在表数据量大且 id 是主键时理论上应该很快。但如果数据库的innodb_buffer_pool_size太小,或者事务隔离级别设置过高、存在锁等待,都会表现为“慢”。建议排查时先跑一次SHOW PROCESSLIST看当前是否有锁等待,再用慢查询日志定位具体 SQL。
5.3 工程化建议:统一分页、统一审计字段、代码生成
到了项目工程化层面,有几点实践值得坚持。
第一,统一分页返回结构。不要各个接口自己返回PageInfo或List,建议统一封装成PageResult<T>,包含total、current、size、records等字段,前端对接成本会大大降低。
第二,统一审计字段。比如create_time、update_time、deleted、create_by这几个字段,如果每个 Mapper 都手动写一遍容易遗漏。可以通过 MyBatis-Plus 的自动填充或者自定义拦截器统一处理。如果没有引入 MP,我建议在实体中定义统一的 BaseEntity,在插入和更新 SQL 中手动维护,至少保证一致性。
第三,代码生成器。用 MyBatis Generator(MBG)或者 MyBatis-Plus 的代码生成器,把基础 CRUD 代码自动生成出来,然后人工改。这是最省力的方案,而且能保证命名风格一致。我见过太多团队手写重复的UserMapper、UserServiceImpl,耗时且容易出错。自动生成后再通过 Code Review 补充业务逻辑,整体效率会高很多。
6. 常见问题与排查技巧实录
我把这些年实际遇到的 MyBatis 相关高频问题整理成一张速查表,方便你直接对照处理。
| 问题现象 | 可能原因 | 排查与解决方案 |
|---|---|---|
启动报Invalid bound statement (not found) | Mapper 接口与 XML 没有绑定 | 检查 XML namespace 是否等于接口全限定名;检查 mapper-locations 是否覆盖 XML 路径;检查接口方法名与 XML id 是否一致 |
| 查询返回 null,没有任何 SQL 报错 | 字段映射失败 | 检查是否开启 map-underscore-to-camel-case;检查 resultMap 中 column 与 property 是否对应;检查 SQL 别名是否写错 |
| 分页不生效,返回全量数据 | PageHelper.startPage 与 Mapper 方法之间隔了其他查询 | 确保 startPage 之后紧跟目标 Mapper 方法;检查分页插件是否被重复注册 |
| 日志看不到 SQL | 日志配置与 MyBatis log-impl 冲突 | 确认 log-impl 是否设置;确认 logging.level 是否覆盖 mapper 包为 debug;检查是否被生产日志级别过滤 |
@Update执行很慢 | 锁等待、索引失效、连接池不足 | 用 SHOW PROCESSLIST 看锁等待;用 EXPLAIN 分析索引;检查连接池活跃数;检查 SQL 参数类型是否匹配 |
| 更新后查询结果还是旧数据 | 一级或二级缓存未失效 | 确认是否同一个 SqlSession 中先查询后更新再查询;检查是否开启二级缓存且未清空对应 Mapper 缓存 |
foreach 批量插入报packet too large | 单次插入条数过多,超过 max_allowed_packet | 分批插入,每批 1000~2000 条;增大 max_allowed_packet(不推荐无限调大) |
| 多个数据源时 Mapper 注入错乱 | @MapperScan 扫描到了多个数据源对应的 Mapper | 每个数据源使用独立的 @MapperScan,配合 basePackages 与 SqlSessionTemplate 区分 |
| 接口返回的 List 是空但不是 null,前端判断出错 | MyBatis 查询结果为 null 时,List 类型字段返回空集合 | 在 Service 层统一判断,或者前端统一使用 isEmpty 判断 |
6.1 一次“日志看不到 SQL”的实际排查过程
有一次我帮同事排查一个接口,数据库里数据明明存在,但接口返回空。打开控制台想看看 SQL,结果发现啥日志都没有。我当时的第一反应不是去改代码,而是先看他的application.yml。果然,他的log-impl没有配置,而且logging.level.com.example.demo.mapper也没有设置。于是 MyBatis 走了 Spring Boot 默认的日志体系,但 mapper 包默认级别是 info,SQL 的 debug 日志当然不会输出。加上一行配置就解决了:
logging: level: com.example.demo.mapper: debug这类问题不复杂,但如果你不知道 MyBatis 日志的层级脉络,就会无从下手。
6.2 一个典型的 N+1 优化案例
我再分享一个真实优化案例。某个订单列表接口,每条订单需要查用户名称、商品名称、店铺名称,最初代码是在循环里分别调用 UserMapper、ProductMapper、ShopMapper。一次查询 100 条订单,就会执行 1 + 100 + 100 + 100 = 301 条 SQL,耗时 800ms 左右。优化方案是先把订单列表查出来,然后收集所有 userId、productId、shopId,分别用IN查询一次,得到用户/商品/店铺的 Map,在内存中组装。优化后 SQL 总数变成 4 条,耗时降到 50ms。这个案例说明,MyBatis 的 SQL 能力再强,也救不了循环查询这种应用层问题。批量查询 + 内存组装往往是最稳妥的优化手段。
6.3 缓存导致的数据“不改”假象
还有一个非常有代表性的缓存踩坑经历。一个管理后台系统,运营反馈说“用户信息改完再查,还是旧数据”。我们查了数据库,数据明明已经更新了。最后定位到问题出在二级缓存:OrderMapper 的一个查询结果被二级缓存缓存了,而 Order 表里关联了 User 表的信息。更新 User 表时只清空了 UserMapper 的二级缓存,OrderMapper 的缓存没有失效,所以通过订单维度查用户信息时命中旧缓存。最终方案是把 OrderMapper 的<cache/>标签去掉,同时业务层引入 Redis 缓存并设置合理的过期时间。这个案例也是我后来坚持“默认不用二级缓存”的原因。
7. 后续可以玩的扩展方向
如果你把上面这些内容都吃透了,Spring + MyBatis 这块基本就没什么能难住你的了。接下来可以按兴趣继续深入:
- 阅读 MyBatis 源码中
SqlSessionTemplate与MapperProxy的逻辑,理解为什么 Mapper 接口不需要实现类也能被注入。 - 尝试用 MyBatis 拦截器实现一个简单的数据权限插件,给部门、租户级别的数据隔离练手。
- 引入 MyBatis-Plus,对比它与原生 MyBatis 在通用 CRUD、多租户、乐观锁、逻辑删除上的异同。
- 结合 Spring Boot 的
@Transactional看 MyBatis 的 SqlSession 和数据库连接的绑定关系,深入理解事务与连接池的协作原理。
我个人在实际使用中有个体会:MyBatis 的上手门槛确实低,但真正拉开水平差距的,往往是你对 SQL 本身的理解,以及对 MyBatis 内部机制(缓存、拦截器、动态代理)的把握程度。工具只是中间层,底层还是数据库和 SQL 基本功。希望这篇文章能帮你在 Spring + MyBatis 这条路上少踩几个坑,遇到问题时能多一份把握。