MyBatis实战:从环境搭建到增删改查,详解动态SQL与缓存
说句实在话MyBatis 在国内 Java 后端圈子里的普及率高到什么程度呢你随便打开一个招聘 JD十有八九都写着“熟悉 SSM/Spring Boot MyBatis”。它核心就干一件事把数据库增删改查从繁琐的 JDBC 模板代码里解放出来让你用接口方法直接操作数据库同时还能保留 SQL 的可控性。这篇东西就是写给两类人看的一类是刚接触 MyBatis、想从零跑通第一个增删改查的初学者另一类是用了挺久但一直没搞懂 #{} 和 ${}、resultType 和 resultMap、一级缓存和二级缓存这些细节的同学。我会从一个真实的项目视角出发把环境搭建、增删改查实现、常见坑位全部过一遍不说废话全部可落地。1. 为什么大家都在用 MyBatis先搞清楚它是干什么的1.1 JDBC 的痛点MyBatis 怎么解决在讲 MyBatis 之前很值得先回忆一下原生的 JDBC 操作数据库到底有多烦。基本流程是这样的加载驱动、建立 Connection、创建 Statement 或 PreparedStatement、手动拼接 SQL、执行、遍历 ResultSet 取数据、最后还得记得关闭连接。这套流程本身不复杂但问题非常明显。第一是重复代码太多。一个最简单的按 id 查询用户的方法从加载驱动到释放资源大概要写二十多行而且每次写都大同小异。第二是 SQL 和 Java 代码强耦合。SQL 写成字符串拼在 Java 里改一个字段就要改代码重新编译维护成本很高。第三是结果集映射全靠手动。从 ResultSet 里一个一个 getString、getInt 取出来再 set 到对象里字段一多就是纯体力活还特别容易漏字段。第四是连接管理容易出问题。忘关连接、异常时连接泄漏在并发高的时候是灾难。MyBatis 的解决思路其实很朴素把 SQL 独立到 XML 或注解里把 Java 方法的参数通过占位符绑定到 SQL 上再把查询结果自动映射成实体对象。你只需要关注 SQL 本身和业务逻辑剩下的连接管理、参数设置、结果集处理由框架代劳。打个比方JDBC 像自己买菜切菜炒菜洗碗MyBatis 像请了个靠谱的后厨你只需要把菜单写好、点好菜等着上菜就行。这个比喻很贴切也解释了为什么它能在国内流行这么多年——它没有把 SQL 完全藏起来反而给了你足够的控制权。1.2 核心组件和一次完整请求的流转要真正用好 MyBatis脑子里得有这张地图。一次典型的增删改查请求大致会经过这些组件SqlSessionFactoryBuilder读取配置文件构建 SqlSessionFactory通常一个应用只执行一次。SqlSessionFactory全局单例负责创建 SqlSession。SqlSession代表一次数据库会话可以理解成 JDBC 里的 Connection 的增强版还封装了执行 SQL、事务控制、一级缓存的能力。ExecutorMyBatis 内部真正执行 SQL 的组件分为 SimpleExecutor、ReuseExecutor、BatchExecutor 三种。MappedStatement封装了一条 SQL 的所有元数据包括 id、参数类型、返回值类型、SQL 语句本身。Mapper 接口你写的 Java 接口MyBatis 通过动态代理生成实现类接口方法调用最终会解析到对应的 MappedStatement。整个流程走下来其实就是三个字绑、执、映。绑就是把 Mapper 接口方法绑定到 MappedStatement执就是通过 Executor 执行 SQL映就是把 ResultSet 结果映射成对象。理解这三点再去看什么 MyBatis 面试题都不怵因为所有机制都是围绕它展开的。1.3 什么项目适合 MyBatis什么不适合MyBatis 并不是银弹。它最擅长的是中小型项目、SQL 复杂、需要精细化调优的场景尤其是那种查询条件多、报表多、需要手写复杂 SQL 的业务。因为它把 SQL 暴露出来DBA 和开发可以针对慢查询做精准优化。反过来如果项目以简单的 CRUD 为主实体关系复杂、需要大量继承和多态映射那 JPA/Hibernate 这类全自动 ORM 框架会更省力。MyBatis 的灵活是优点也是代价它需要你自己维护 SQL如果项目纯 CRUD 且实体特别多写 XML 的工作量会很大。我的经验是MyBatis 适合你明确知道自己要什么 SQL 的项目JPA 适合希望框架替你生成大部分 SQL 的项目。选型没有绝对的对错看你团队的 SQL 功底和业务特征。2. 上手前的准备工作环境搭建与第一个配置文件2.1 依赖引入和数据库准备我以 Maven 项目为例演示一遍最标准的搭建过程。项目结构先准备好src/main/java src/main/resources在 pom.xml 里引入依赖。我用的是 MySQL 数据库所以需要 MySQL 驱动MyBatis 用 3.5.16 版本实际使用时建议去官网或 Maven 仓库查最新稳定版测试方便的话再加个 JUnit。dependencies dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.16/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdjunit/groupId artifactIdjunit/artifactId version4.13.2/version scopetest/scope /dependency /dependencies注意 MySQL 8.0 的驱动类名和连接串跟 5.x 版本不一样了驱动类是com.mysql.cj.jdbc.Driver连接串里建议加上useSSLfalseserverTimezoneAsia/Shanghai不然很容易因为时区问题报错。我自己刚换 MySQL 8 的时候就被这个坑过一次报错信息是The server time zone value Öйú±ê׼ʱ¼ä is unrecognized看着头大其实就是连接串少了时区参数。数据库这边建一张最简单的用户表CREATE DATABASE IF NOT EXISTS mybatis_demo DEFAULT CHARACTER SET utf8mb4; USE mybatis_demo; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, email VARCHAR(100), created_time DATETIME );表结构故意做得很简单方便聚焦增删改查本身。id自增主键username、password必填email可空created_time是时间字段。这些都跟你后面的实体类一一对应。2.2 mybatis-config.xml 核心配置的每项含义在src/main/resources下建mybatis-config.xml这是 MyBatis 的全局配置文件。先把完整内容贴出来再逐行解释?xml version1.0 encodingUTF-8 ? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN https://mybatis.org/dtd/mybatis-3-config.dtd configuration settings setting namemapUnderscoreToCamelCase valuetrue/ setting namelogImpl valueSTDOUT_LOGGING/ /settings typeAliases package namecom.example.demo.entity/ /typeAliases environments defaultdevelopment environment iddevelopment transactionManager typeJDBC/ dataSource typePOOLED property namedriver valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/mybatis_demo?useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value123456/ /dataSource /environment /environments mappers package namecom.example.demo.mapper/ /mappers /configuration几个关键点说一下。mapUnderscoreToCamelCase下划线转驼峰。数据库字段created_timeJava 属性createdTime开启这个设置后就不用写繁琐的 resultMap 了对增删改查体验的提升非常明显。这个配置我几乎是必开的不然每个实体类都得配 resultMap工作量翻倍。logImpl打印 SQL 日志。STDOUT_LOGGING意思是直接用控制台输出适合入门阶段调试。实际项目中一般会接 Logback 或 Log4j2但入门阶段这个够用了。typeAliases给实体类起别名。配了package后你在 Mapper XML 里写resultTypeUserMyBatis 会自动去找com.example.demo.entity.User不用写全限定类名。这里有个小坑如果实体类分布在不同包就配多个package或者干脆用全限定类名避免同名类冲突。environments环境配置。transactionManager用 JDBC表示事务由 MyBatis 自己管理后面交给 Spring 的时候会改成 Spring 的事务管理器。dataSource typePOOLED是 MyBatis 自带的连接池实现入门阶段够用生产环境一般会换 Druid 或 HikariCP。2.3 构建 SqlSessionFactory写一个工具类在src/main/java/com/example/demo/util下写一个 MyBatisUtil用来加载配置并获取 SqlSession。这里有个设计要点SqlSessionFactory 是重量级对象每个应用只应该创建一次所以用静态代码块初始化。package com.example.demo.util; import org.apache.ibatis.io.Resources; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionFactoryBuilder; import java.io.IOException; import java.io.InputStream; public class MyBatisUtil { private static SqlSessionFactory sqlSessionFactory; static { try { String resource mybatis-config.xml; InputStream inputStream Resources.getResourceAsStream(resource); sqlSessionFactory new SqlSessionFactoryBuilder().build(inputStream); } catch (IOException e) { e.printStackTrace(); } } public static SqlSession getSqlSession() { return sqlSessionFactory.openSession(); } }为什么强调单例因为 SqlSessionFactory 内部包含配置信息、MappedStatement 等大量元数据频繁创建代价很高而且没有必要。SqlSession 则不同它是轻量的、线程不安全的每次操作都应该新建用完了就 close。这跟 JDBC 的 Connection 用法逻辑一致但很多人容易混淆。写个测试跑一下验证配置没问题package com.example.demo; import com.example.demo.util.MyBatisUtil; import org.apache.ibatis.session.SqlSession; import org.junit.Test; public class MyBatisTest { Test public void testConnection() { try (SqlSession sqlSession MyBatisUtil.getSqlSession()) { System.out.println(拿到 SqlSession连接成功); } } }这里用了 try-with-resourcesSqlSession 实现了 Closeable可以自动关闭。如果控制台没有任何异常说明环境基本通了。3. 增删改查完整实操从 Mapper 接口到 SQL 语句3.1 实体类和 Mapper 接口定义实体类放在com.example.demo.entity包下字段跟表结构对应。我用的是驼峰命名配合mapUnderscoreToCamelCase就能自动映射。package com.example.demo.entity; import java.time.LocalDateTime; public class User { private Integer id; private String username; private String password; private String email; private LocalDateTime createdTime; // getter / setter 略实际项目用 Lombok Data }然后定义 Mapper 接口放在com.example.demo.mapper包下package com.example.demo.mapper; import com.example.demo.entity.User; import java.util.List; public interface UserMapper { int insert(User user); int deleteById(Integer id); int updateById(User user); User selectById(Integer id); ListUser selectList(); }这里要强调一下Mapper 接口本身没有任何实现类MyBatis 会通过 JDK 动态代理在运行时生成一个实现。你只需要保证三点接口的全限定名和 XML 的 namespace 一致接口方法名和 XML 的 id 一致方法返回值类型和 XML 的 resultType 兼容。这三点任何一个对不上都会报错后面我会专门讲排错。3.2 insert 语句主键回填怎么做插入语句的核心难点是主键回填。表结构里id是自增主键但很多时候业务上需要在插入之后立刻拿到这个新 id比如新增用户之后返回给前端或者往下走关联逻辑。MyBatis 提供了useGeneratedKeys和keyProperty两个属性来解决。insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid INSERT INTO t_user (username, password, email, created_time) VALUES (#{username}, #{password}, #{email}, #{createdTime}) /insert执行完 insert 之后传入的 User 对象的 id 字段会被自动回填。测试代码User user new User(); user.setUsername(zhangsan); user.setPassword(123456); user.setEmail(zhangsanexample.com); user.setCreatedTime(LocalDateTime.now()); userMapper.insert(user); System.out.println(插入成功主键 id user.getId());你一定会好奇这里useGeneratedKeys和keyProperty的作用机制。useGeneratedKeystrue告诉 MyBatis数据库会生成主键需要把生成的主键拿回来keyProperty指定回填到传入对象的哪个属性。底层实际上是通过 JDBC 的getGeneratedKeys()方法获取自增主键。这个方法在 MySQL 的 AUTO_INCREMENT 上没问题PostgreSQL 的 SERIAL 也可以但像 Oracle 这种用序列的方式就不好使需要改用selectKey标签。3.3 delete 和 update动态 SQL 的雏形删除比较简单按主键删delete iddeleteById parameterTypeinteger DELETE FROM t_user WHERE id #{id} /delete注意parameterTypeinteger是 MyBatis 的别名机制大小写不敏感int、integer、java.lang.Integer都能识别。实际开发中delete 操作还要考虑逻辑删除还是物理删除的问题这里先不展开先把物理删除跑通。更新操作要多讲一点。真正业务里前端传过来的对象往往只有部分字段有值其他字段是 null。如果直接写死所有字段update idupdateById parameterTypeUser UPDATE t_user SET username #{username}, password #{password}, email #{email}, created_time #{createdTime} WHERE id #{id} /update那只要传入对象的某个属性是 null数据库里这一列就被覆盖成 NULL 了。我在实际项目里见过太多次这种事故特别是那种“前端只改了邮箱结果用户名被清空了”的情况。更稳妥的做法是用动态 SQL只更新非空字段update idupdateById parameterTypeUser UPDATE t_user set if testusername ! nullusername #{username},/if if testpassword ! nullpassword #{password},/if if testemail ! nullemail #{email},/if if testcreatedTime ! nullcreated_time #{createdTime},/if /set WHERE id #{id} /updateset标签会自动处理多余的逗号这是 MyBatis 动态 SQL 里比较贴心的设计。如果所有字段都是 nullSQL 会变成UPDATE t_user WHERE id ?这在 MySQL 里会报语法错误所以实际使用中建议在代码层做校验至少保证有一个字段在更新。3.4 select 查询参数传递的多种姿势查询是增删改查里花样最多的部分。先看最简单的按 id 查询select idselectById parameterTypeinteger resultTypeUser SELECT id, username, password, email, created_time FROM t_user WHERE id #{id} /select因为开启了mapUnderscoreToCamelCasecreated_time能自动映射到createdTime。再看查询列表select idselectList resultTypeUser SELECT id, username, password, email, created_time FROM t_user /select这里有几个点要讲清楚。第一当 Mapper 接口方法只有一个参数时XML 里的#{id}、#{xxx}写什么都行MyBatis 都能拿到值但为了可读性建议保持跟方法参数名一致。当参数多于一个时情况就不一样了比如ListUser selectByCondition(String username, String email);XML 里直接写#{username}会报错因为 MyBatis 不知道这个username对应哪个参数。解决办法有三种用Param注解、用param1/param2、或者封装成对象。用Param是最推荐的方式ListUser selectByCondition(Param(username) String username, Param(email) String email);这样在 XML 里写#{username}、#{email}就能准确绑定。如果你不想用注解MyBatis 也支持#{param1}、#{param2}但可读性很差多人协作时容易乱。最优雅的方式其实是封装成一个查询对象比如UserQuery把条件都放进去XML 里通过#{username}直接取属性这样既清晰又方便扩展分页参数。第二模糊查询的 LIKE 怎么传参也有讲究。很多人会写成WHERE username LIKE %${username}%这确实能跑通但有 SQL 注入风险。正确做法是用#{}配合拼接select idselectByKeyword resultTypeUser SELECT id, username, password, email, created_time FROM t_user WHERE username LIKE CONCAT(%, #{keyword}, %) /select或者传入参数时就在 Java 侧拼好%keyword%XML 里WHERE username LIKE #{keyword}。用CONCAT的好处是参数始终保持预编译安全且没有兼容性问题。第三多表联查时如果几个表有同名字段自动映射就可能出错。这时候要么在 SQL 里给列起别名要么使用显式的 resultMap。这属于进阶范畴放后面细说。4. 参数绑定与结果映射的细节别在阴沟里翻船4.1 #{} 和 ${} 的本质区别这是 MyBatis 面试里必考、实际开发里必踩的坑。#{}是预编译占位符MyBatis 会把 SQL 里的#{}替换成?然后通过 PreparedStatement 的 setXXX 方法安全地设置参数。参数值不参与 SQL 语句的拼装所以能有效防止 SQL 注入。${}是字符串拼接MyBatis 会直接把参数值原样拼到 SQL 语句中。比如ORDER BY ${sortColumn}传入username最终 SQL 变成ORDER BY username。如果传入的是username; DROP TABLE t_user;--后果不堪设想。这里不是危言耸听很多注入案例都是因为开发图省事用了${}。正因为如此能用#{}的地方绝对不用${}。但有两个场景必须用${}一是动态排序字段二是动态表名比如按月分表。这两个场景本质上都是 SQL 片段而不是值占位符没意义。用的时候一定记住字段名、表名这类东西只能内部传参绝不能直接接收前端传来的原始值必须做白名单校验。我自己的做法是写一个排序字段映射把允许排序的字段名放在一个 Map 里传入的字符串必须命中 Map 的 key 才拼接否则用默认值。4.2 resultType 和 resultMap 怎么选简单来说resultType 是自动映射resultMap 是手动映射。resultType 适合数据库字段和 Java 属性一一对应、或者靠mapUnderscoreToCamelCase能转过来的情况。它的底层其实也是在构建一个隐式的 resultMap只是省了你写 XML 的功夫。resultMap 适合字段对不上、有多表联查、有复杂嵌套关系的场景。比如resultMap iduserResultMap typeUser id propertyid columnid/ result propertyusername columnusername/ result propertyemail columnemail/ /resultMap如果遇到列名不一致还可以用column起别名的方式兜底select idselectById resultTypeUser SELECT id, username, password, email, created_time AS createdTime FROM t_user WHERE id #{id} /select我的建议是能用 resultType 就别用 resultMap少写一堆 XML但一旦涉及联查、聚合、一对多嵌套该上 resultMap 就上千万别硬扛。resultMap 最强大的地方在于association和collection比如一个订单关联多个订单明细可以用嵌套结果映射一次查出来避免 N1 查询问题。不过这个复杂度较高入门阶段先把基础映射搞明白。4.3 打印 SQL 的配置方法排查增删改查问题的时候最需要的就是 SQL 日志。入门阶段用STDOUT_LOGGING就够了在mybatis-config.xml的 settings 里配置settings setting namelogImpl valueSTDOUT_LOGGING/ /settings配置好之后执行每个 Mapper 方法都会在控制台打印类似这样的内容 Preparing: SELECT id, username, password, email, created_time FROM t_user WHERE id ? Parameters: 1(Integer) Columns: id, username, password, email, created_time Row: 1, zhangsan, 123456, zhangsanexample.com, 2025-01-01 10:00:00 Total: 1这里有个非常实用的排查技巧如果Preparing后面的 SQL 跟你预期的不一样说明是 XML 里的 SQL 写错了如果 SQL 对但查不到数据那就是参数问题或者数据本身不存在如果查询结果字段是 null那就是结果映射的问题。三步定位能省下大量调试时间。在 Spring Boot 项目里一般不用 STDOUT_LOGGING而是直接配置日志级别logging.level.com.example.demo.mapperdebug第二种方式的好处是只打印 Mapper 包下面的 SQL日志量可控也不会打印结果集细节。4.4 动态 SQL 的核心能力if、where、foreach动态 SQL 是 MyBatis 的杀手锏也是面试必问的。初学者容易把它当成“拼接字符串的模板”但其实它解决的是很实际的场景查询条件不确定、更新字段不确定、批量操作参数数量不确定。if标签配合where标签做条件查询是最常见的组合select idselectByCondition resultTypeUser SELECT id, username, password, email, created_time FROM t_user where if testusername ! null and username ! AND username #{username} /if if testemail ! null and email ! AND email #{email} /if /where /selectwhere标签会自动处理首个条件前面的 AND非常方便。如果你自己拼 SQL很容易出现WHERE AND ...这种语法错误很多新手在这里卡过。foreach标签处理 IN 查询也很常用select idselectByIds resultTypeUser SELECT id, username, password, email, created_time FROM t_user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /select这里collection应该填方法参数名如果方法签名是ListInteger selectByIds(Param(ids) ListInteger ids)那 collection 就是ids。foreach 还有index属性可以拿到循环下标在批量 update 时会用到。动态 SQL 的原理其实不复杂本质上就是 OGNL 表达式结合 XML 标签做 SQL 拼接。但用好了能极大减少方法数量比如统一的用户查询接口只写一个 select 就能覆盖精确查询、模糊查询、组合查询各种场景。5. 常见问题与排查技巧实录5.1 经典报错BindingException、ParameterType我把自己和身边同事踩过的坑整理一下。第一个是org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.example.demo.mapper.UserMapper.insert。这个报错 90% 的原因是接口方法和 XML 里的 id 对不上或者 namespace 没写对。排查步骤非常固定先检查 XML 文件名是不是UserMapper.xml再检查 namespace 是不是接口的全限定名再检查 XML 里的 id 是不是接口方法名。还有一种可能Maven 构建时把 XML 文件漏掉了。解决办法是在 pom.xml 里配置 resources把src/main/java下的 xml 也打包进去或者把 XML 统一放在src/main/resources下。第二个是There is no getter for property named id in class java.lang.Integer。出现这个报错通常是因为方法参数上有Param(id)但 XML 里写成了#{userId}两边对不上。多参数绑定时Param的名字就是 XML 里的唯一依据务必保持一致。第三个是我见过很多新手问题parameterType写错。比如方法入参是User对象XML 里写parameterTypeinteger运行时不一定会立刻报错但参数绑定就会出问题。入门阶段建议先严格按照参数类型写不要图省事。还有一个容易忽略的resultType 写成实体类本身没有带包名而全局配置里又没有 typeAliases 扫描。这时候会报TypeAliasRegistry相关的异常告诉你找不到对应的类。配置 typeAliases 或者写全限定类名都能解决。5.2 缓存相关的坑一级缓存、二级缓存热词里有 mybatis 缓存和二级缓存实现这里专门说两句。一级缓存是 SqlSession 级别的默认开启无法关闭。同一个 SqlSession 里执行同一条 SQL 且参数相同第二次会直接命中缓存不再查数据库。这个机制本身没毛病但有个经典坑在一个 SqlSession 里先查询用户然后执行了插入或更新操作MyBatis 会清空一级缓存。如果你在同一个 SqlSession 里先查、再改、再查第二次查询会重新走数据库这其实是框架在帮你避免脏数据。二级缓存是 namespace 级别的默认不开启。开启方式是在 Mapper XML 里加cache/标签之后这个 Mapper 下的查询结果会被缓存到二级缓存区域。二级缓存的坑更隐蔽如果一个表被多个 Mapper 操作而每个 Mapper 各自开了二级缓存当一个 Mapper 更新数据后其他 Mapper 的缓存不会自动失效很容易查出脏数据。我的观点很明确单体小项目、读多写少、并发不高的场景可以谨慎开二级缓存只要表可能被多个 Mapper 操作就别开。真需要缓存直接用 Redis别让 MyBatis 干这活。很多人面试喜欢背二级缓存原理但实际项目里它往往是麻烦的制造者。5.3 与 Spring Boot 整合时的几个注意点现在真实项目里很少会有人像上面那样手写SqlSessionFactoryBuilder基本都是 Spring Boot MyBatis 的组合。整合时的注意点也说说。首先依赖上要引入mybatis-spring-boot-starter而不是直接在 Spring Boot 里引官方 mybatis否则要手动配置很多东西。引入 starter 之后在application.properties里配置数据源和 Mapper 扫描spring.datasource.urljdbc:mysql://localhost:3306/mybatis_demo?useSSLfalseserverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.password123456 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver mybatis.mapper-locationsclasspath:mapper/*.xml mybatis.type-aliases-packagecom.example.demo.entity # 打印 SQL mybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl然后在启动类上加MapperScan(com.example.demo.mapper)或者在每个 Mapper 接口上直接加Mapper注解。前者适合接口数量多的项目后者适合接口少且想避免扫描粒度问题的场景。我一般倾向于启动类上加MapperScan因为接口多了以后逐个加注解太麻烦。Spring Boot 整合时还有个连接池选择问题。HikariCP 默认就配好了性能业内顶尖不用额外配置。Druid 的优势是监控能力可以看 SQL 执行统计、慢查询、活跃连接数。我的建议是没特殊需求就用 HikariCP如果确实需要监控 SQL 和连接池状态再考虑换 Druid。工具选择要服务于需求不要为了赶潮流而替换。5.4 Maven 打包漏掉 XML 的坑这个坑我遇到太多次了必须要单独拿出来说。Maven 项目如果 XML 文件放在src/main/java目录下跟 Java 源码放一起默认打包时是不会被复制到 classes 目录的。结果就是本地 IDE 里跑得好好的一打成 jar 包部署到服务器就报Invalid bound statement排查半天发现 classes 目录里根本没有 Mapper XML。解决办法有两种。最简单的是把 XML 统一放到src/main/resources/mapper/目录下然后在配置里指定mappers package namecom.example.demo.mapper/ /mappers或者 Spring Boot 配置里mybatis.mapper-locationsclasspath:mapper/*.xml第二种方法是在 pom.xml 里显式声明build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resources /build我个人强烈推荐第一种把 XML 和 Java 分开目录结构干净也能避免很多无谓的问题。很多人为了省事喜欢把 XML 放在接口同级目录下但部署阶段踩坑的概率太高了。最后再分享一个我个人的习惯。每次新项目引入 MyBatis我都会先把增删改查这四个基础方法写在同一个 Mapper 里完整跑通一遍刻意看一眼控制台打印的 SQL 有没有意外比如该参数化的地方被拼接了、该走索引的字段没生效、日期字段映射对不对。这个流程跑通之后后面再加多表查询、动态 SQL、复杂结果映射心里都有底了。MyBatis 这个框架入门门槛确实不高难点全在细节上比如主键回填、多参数绑定、缓存失效、XML 打包路径这些位置。你踩过一次坑把原因记下来后面就顺了。如果你看完这篇内容能把第一批坑提前绕过去那这篇文章的价值就体现出来了。