上个月组里一个小伙子排查一个线上问题,查了整整一下午。场景不复杂:后台系统里有一个状态筛选,前端传status=0时,列表应该过滤出所有未启用的用户,结果过滤条件完全没生效。他把 Mapper 里的 SQL 翻来覆去看了好几遍,SQL 单独拎到数据库客户端里跑也没有问题,最后才发现是 XML 里一个不起眼的<if>判断把"0"给吞了。
这种问题在 MyBatis 项目里太典型了。MyBatis 作为 Java 生态里使用率最高的持久层框架之一,表面上看不过是"接口加 XML",但真正跑起来之后,动态 SQL 的解析规则、缓存的作用边界、批量执行的底层行为、日志的配置方式,每个环节都有不少隐藏细节。这篇我就用一个完整的 MyBatis 示例串起这些内容,从项目搭建讲到实际开发里最容易踩的坑,尽量把框架讲透。
1. 最小示例跑通之后,先搞清楚这几层对应关系
1.1 三要素:配置、Mapper接口、XML映射文件
不管你是从零学 MyBatis,还是在 Spring Boot 项目里用现成的 starter,底层都离不开这三样东西:全局配置文件、Mapper 接口、XML 映射文件。我见过不少人上来就撸代码,结果连 Mapper 接口和 XML 是怎么对应上的都说不清,后面排查问题全靠猜。
先准备一个最简单的库表:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) DEFAULT NULL, `age` int(11) DEFAULT NULL, `status` varchar(10) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;对应的实体类:
public class User { private Long id; private String name; private Integer age; private String status; // getter/setter 省略 }然后是最小可用的mybatis-config.xml。单独使用 MyBatis(不接 Spring)时,这个文件是入口:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "https://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> <environments default="dev"> <environment id="dev"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <property name="driver" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="123456"/> </dataSource> </environment> </environments> <mappers> <mapper resource="mapper/UserMapper.xml"/> </mappers> </configuration>Mapper 接口:
public interface UserMapper { User findById(Long id); List<User> search(Map<String, Object> params); int insert(User user); int batchInsert(List<User> users); }XML 映射文件UserMapper.xml:
<mapper namespace="com.example.mapper.UserMapper"> <select id="findById" resultType="com.example.entity.User"> SELECT id, name, age, status FROM user WHERE id = #{id} </select> <insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user(name, age, status) VALUES(#{name}, #{age}, #{status}) </insert> </mapper>这里最容易被忽略的是对应关系的三条规定:
namespace必须等于 Mapper 接口的全限定名;<select>等标签的id必须等于接口方法名;- 接口方法的参数类型、返回值类型,要和 XML 里
parameterType、resultType严格匹配。
这三条只要有一条对不上,启动时不一定报错,调用时才报"Invalid bound statement (not found)",排查起来特别容易绕圈子。
1.2 为什么是"半自动"框架:JDBC 到 MyBatis 的设计思路
有人会问,都什么年代了,项目里还有必要单独拎一个 MyBatis 示例出来讲吗?我的看法是,越基础的框架越值得搞明白底层。MyBatis 的设计思路,说穿了就是"把 JDBC 的样板代码收编,把 SQL 控制权留给开发者"。
用原生 JDBC 写一个查询,要经历加载驱动、获取连接、创建 Statement、填充参数、执行查询、遍历 ResultSet、释放资源这一整套流程。MyBatis 帮你做掉了其中大部分,但你依然要亲手写 SQL。这就是"半自动"和 Hibernate 这类"全自动"ORM 的核心差别。全自动框架想在单表 CRUD 上省事,但遇到复杂查询、多表关联、数据库分页方言,反而要花大量时间绕开它的规则。MyBatis 选择了相反的方向:把 SQL 的控制权交给你,框架只负责参数映射和结果映射。
所以你在评估一个项目要不要引入 MyBatis 时,不要只看 CRUD 快不快,要看团队的 SQL 能力。团队对 SQL 有掌控力,MyBatis 能让复杂查询如虎添翼;团队全是 ORM 思维,一开始就倾向于"全自动",那 MyBatis 的灵活反而可能变成负担。
1.3 示例运行时的常见卡点
跑通这个最小示例时,有几个高频问题我直接列出来:
org.apache.ibatis.binding.BindingException:多半是 namespace 写错,或者接口全限定名和 XML 的 namespace 不一致;Invalid bound statement:XML 文件没有打到 classpath 里。Maven 项目要注意src/main/resources下的 mapper 目录是否被正确打包,如果 XML 放在src/main/java下,需要在 pom.xml 里配置 resource 规则;Unknown JDBC driver:MySQL 8.x 驱动类名是com.mysql.cj.jdbc.Driver,老版本才是com.mysql.jdbc.Driver,网上很多教程还在用旧类名;- 连接串里的
&符号,在 XML 里必须写成&,否则解析报错。
这些坑都不深,但拦住了不少人。建议你把上面这个最小示例亲手跑一遍,再往后看动态 SQL 和缓存,就不会觉得是在听天书了。
2. 单个数字字符比较的翻车现场:OGNL 在 XML 里的隐性规则
2.1 事故复盘:status=0 为什么被过滤条件丢弃
回到开头那个线上问题。当时 Mapper 里的动态 SQL 是这样写的:
<select id="search" resultType="com.example.entity.User"> SELECT id, name, age, status FROM user WHERE 1 = 1 <if test="status != null and status != ''"> AND status = #{status} </if> <if test="status == '0'"> AND name LIKE CONCAT('%', #{name}, '%') </if> </select>前端传了status=0和name=zhang,结果status == '0'这个条件怎么都不进。单独拿 SQL 去数据库客户端跑,数据没问题;去掉<if>直接写死AND status = '0',也没问题。于是整段 SQL 被反复检查,浪费了大量时间。
其实问题出在引号上。MyBatis 的<if>标签是交给 OGNL 表达式引擎解析的,在 OGNL 的世界里,单引号包一个字符是char类型,不是String类型。'0'是一个字符常量Character['0'],而status从 HTTP 请求进来也好,从Map里取出来也好,都是String类型。String.equals(Character)的结果永远是false,所以这个条件永远不走,而且不会报错。
这就是"单个数字字符比较"最坑的地方:它不像空指针那样大声报错,而是安静地吞掉你的条件。
2.2 OGNL 中单引号与双引号根本不是一回事
我用一个小工具类做了个对照实验,把各种写法在 MyBatis 3.5.16 里的真实表现记录了下来:
| test 表达式 | status 实际值 | 结果 | 原因 |
|---|---|---|---|
status == '0' | "0"(String) | false | '0'被 OGNL 解析为 char,String 与 char 不相等 |
status == "0" | "0"(String) | true | "0"是 String,两者 equals |
status == '0'.toString() | "0"(String) | true | 显式转成 String 再比较 |
status != null and status != '' | "0"(String) | true | ''在 OGNL 里被当成空字符串,能正常判断 |
status != null and status != '' | ""(String) | false | 空字符串被正确排除 |
status != null | "0"(String) | true | 只判 null 永远进 |
注意看表格里第二种写法:status == "0"。在 XML 属性值本身用双引号包裹时,内层再写双引号会冲突,所以很多人习惯性地把内层也改成单引号,于是坑就踩上了。正确做法是让 XML 属性值用单引号包裹,内部表达式用双引号:
<if test='status == "0"'>如果你已经被注释或代码风格绑架,必须让 test 属性用双引号,那内部可以用'0'.toString()或者干脆写成"0".equals(status)。我个人最推荐的是:
<if test='status != null and status == "0"'>这样可读性最好,也不容易再被 OGNL 的类型规则坑到。
2.3 再深挖一层:为什么有的写法在旧版本里表现不一样
聊到这儿可能有老读者会问:网上好多文章说status != ''也会把"0"吞掉,怎么回事?
这个说法确实存在,但多半是旧版本 OGNL 对单引号空字符串的解析有差异,或者是把''和' '混为一谈。' '是单个空格字符,''是空字符串,两者完全不同。我在新版本里实测,status != ''对"0"的判断是正常的。
但为了避免版本差异和团队理解成本,我建议你在判断字符串是否为空时,不要过度依赖这种魔法写法。如果需求只关心有没有传值,优先写status != null;如果确实需要排除空字符串,再在 Java 层把空串统一转成 null,让 XML 里的判断保持简单。把复杂逻辑从模板语言里挪到 Java 里,是减少这类问题的最有效手段。
这个坑还有个变体,就是拿数字0和空字符串比较。如果status是Integer类型,status != ''在 OGNL 里的行为同样会受到类型转换影响,稳妥做法依然是只判 null 或者在 Java 层预处理。
3. 缓存不能"想当然":一级二级缓存在示例里的真实表现
3.1 一级缓存:同一个 SqlSession 里的隐身命中
MyBatis 的一级缓存默认就是开启的,作用域是SqlSession。也就是说,在同一个SqlSession里,执行完全相同的两条查询语句,第二次不会真的打数据库。
比如这段代码:
try (SqlSession session = MyBatisConfig.build().openSession(true)) { UserMapper mapper = session.getMapper(UserMapper.class); User u1 = mapper.findById(1L); User u2 = mapper.findById(1L); System.out.println(u1 == u2); }输出结果是true。原因就是第二次findById(1L)命中了一级缓存,返回的是同一个对象引用。
但这里有一个容易误解的点:一级缓存是SqlSession级别的,不是 Mapper 级别的,也不是全局的。很多人以为"既然 MyBatis 有缓存,那我在任何地方查同一个 id 都是缓存",这是错的。一旦SqlSession关闭或清空,一级缓存就没了。
而到了 Spring Boot 整合的场景,SqlSessionTemplate会为每次数据库操作管理SqlSession的生命周期:没有事务时,每次 Mapper 方法调用基本都是新建一个会话、用完关闭,一级缓存自然就不跨方法共享。所以你别指望靠一级缓存提升 Spring Boot 项目里的查询性能,先弄清楚它的作用域再谈优化。
3.2 二级缓存:开启很简单,踩坑更容易
二级缓存的作用域是 namespace,也就是一个 Mapper XML 对应一个缓存区域。开启方式很简单,在UserMapper.xml里加一行:
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>但开启之前必须先明确两个硬性条件:
- 查询结果对应的实体类必须实现
Serializable接口,因为二级缓存可能涉及序列化存储; - Mapper 里的所有增删改操作都会触发该 namespace 的缓存清空,但其他 namespace 的更新不会通知这里。
怎么观察二级缓存有没有生效?把日志级别调到 DEBUG,你会看到类似输出:
Cache Hit Ratio [com.example.mapper.UserMapper]: 0.5这个数字代表缓存命中率。第一次查询时缓存未命中,执行 SQL;第二次相同查询命中缓存,不再执行 SQL。我建议你写个独立示例,手动开两个SqlSession,分别查同一个 id,对比日志里 SQL 的输出次数。这样远比死记"二级缓存是跨 SqlSession 的"要牢靠。
3.3 一次多表查询导致的缓存脏读
二级缓存最大的坑是多表关联时的脏读。举一个很典型的案例:UserMapper里有一条 SQL 关联查询order表,查询结果被缓存到了com.example.mapper.UserMapper这个 namespace。此时另一个请求在OrderMapper里删掉或修改了一条订单,OrderMapper的清空操作只会清空OrderMapper自己的 namespace 缓存,不会去管UserMapper的缓存。
结果就是,UserMapper的缓存里还留着旧订单数据,用户在页面上看到的数据是错的。
所以我的经验是:二级缓存只有在"单一 Mapper 完全负责自己涉及的所有表"时才比较安全。一旦出现跨表查询,要么给关联查询所在的 Mapper 设置useCache="false",要么干脆不用二级缓存。现实中很多项目默认关闭二级缓存,不是因为它不好,而是因为脏读一旦发生,定位成本远比省下的那点数据库压力高。
4. 批量写操作三选一:foreach、BATCH 执行器还是插件
4.1 批量插入最常见的做法:foreach 拼接
很多人的第一个批量插入示例就是foreach拼 SQL:
<insert id="batchInsert"> INSERT INTO user(name, age, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.status}) </foreach> </insert>这种写法本质上是生成一条多 VALUES 的大 SQL,然后一次性发给数据库。优点非常明显:网络往返只有一次,SQL 清晰直观,数据量不大的时候速度非常快。
但它有两个限制你要心里有数:
- 数据库对大 SQL 有长度限制。MySQL 的
max_allowed_packet默认一般是 64MB,看起来够大,但如果你批量插入的字段里有超大文本、图片 base64 之类,很容易超限; - 某些数据库或中间件对单条 SQL 的 VALUES 数量有限制,比如 Oracle 旧版本允许的
INSERT ALL数量有限,分页中间件也可能因为 SQL 过大影响性能。
另外,foreach拼接出来的 SQL 没法利用预编译语句的 SQL 缓存。网上有人拿 10 万条数据测过,foreach是能跑,但性能和内存都不是最优,所以它适合"几百条以内"的场景。
4.2 ExecutorType.BATCH 的原理与使用限制
批量操作的第二个方案是使用 MyBatis 的ExecutorType.BATCH:
try (SqlSession session = MyBatisConfig.build().openSession(ExecutorType.BATCH, false)) { UserMapper mapper = session.getMapper(UserMapper.class); for (User user : userList) { mapper.insert(user); } session.commit(); }关键区别在于,ExecutorType.BATCH不会每调用一次insert就立刻执行 SQL,而是把多条相同结构的 SQL 累积在 JDBC 的批量缓冲区里,最后在commit()时一次性提交。
但这里有个很容易被忽略的配置:MySQL JDBC 驱动默认不一定真的走批量执行。你需要在连接串上加上参数:
jdbc:mysql://localhost:3306/demo?rewriteBatchedStatements=true不加这个参数,驱动可能把批量语句拆成单条执行,性能收益大打折扣。加了之后,BATCH才能真正形成多行 INSERT 或批量预编译执行。
使用BATCH执行器还有几个注意事项:
- 混用查询时要小心。在
BATCH模式下执行查询,会先触发一次隐式的flushStatements(),把之前的批量操作刷出去,否则可能拿到过时数据; - 批量插入后立即获取自增主键,行为可能和普通模式不一样,返回的
id可能没有被正确回填,需要根据场景验证; - 批量操作一定要放在事务里,并且注意
commit()的时机,不要每循环一次就 commit,那等于让批处理白干。
4.3 实际开发中怎么选型
搜索热词里有一条是"实际开发时这种情况多吗"。我可以明确说:批量写操作在真实项目中非常常见,数据导入、报表初始化、库存批量调整、消息落库,都会遇到。
我的选型习惯是:
| 场景 | 推荐方案 |
|---|---|
| 一次性导入几百到几千条 | foreach拼接,简单直观,够快 |
| 几万条以上,且 SQL 结构固定 | ExecutorType.BATCH+rewriteBatchedStatements |
| 单表 CRUD 为主,项目已用 MyBatis-Plus | IService.saveBatch,省事,但要看它对批次的封装方式 |
| 大量数据且 SQL 复杂 | 考虑分批 + 多线程,或者走中间件导入,不要硬怼一条超大 SQL |
还有个实操心得:无论用哪种方案,都建议先设一个小批次做上限,比如每批 1000 条,循环插入。这样可以避免单次事务过大导致锁竞争和回滚日志膨胀。把"批"的概念从"一次全部"改成"分批固定大小",是很多性能问题的最简单解法。
5. 让 SQL 日志开口说话:三个层级的排查手段
5.1 最省事的办法:STDOUT_LOGGING 与 logImpl
MyBatis 日志打印,很多人第一反应是加 log4j 或者 logback 配置,其实 MyBatis 自己还带了一个极其简单的STDOUT_LOGGING。
在mybatis-config.xml里:
<settings> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings>运行示例时,控制台会直接输出类似这样的内容:
==> Preparing: SELECT id, name, age, status FROM user WHERE id = ? ==> Parameters: 1(Long) <== Columns: id, name, age, status <== Row: 1, zhang, 20, 0 <== Total: 1这种方式的优点是零依赖、配置简单,缺点是输出格式比较原始,而且会打印到标准输出。在本地调试时用一用没问题,但生产环境千万别开,日志量会变得非常大,还可能把参数值打到日志文件里,带来安全隐患。
5.2 用 MyBatis Log Plugin 还原完整 SQL 的注意事项
很多人喜欢在 IDEA 里装 MyBatis Log Plugin 这类插件,它能把上面那段Preparing和Parameters还原成一条可以直接复制的完整 SQL:
SELECT id, name, age, status FROM user WHERE id = 1这在排查复杂 SQL、人工验证逻辑时确实很方便。但你需要清楚它的原理:它只是拦截了 MyBatis 的日志输出,把?占位符替换成参数值,并不是 MyBatis 真的执行了这样一条 SQL。所以遇到参数类型转换、绑定失败之类的问题时,它还原出的"完整 SQL"不一定能准确反映数据库真实接收到的内容。
使用这类插件的安全建议是:
- 不要因为它显示了"完整 SQL"就忽略预编译本身的价值,生产环境仍然要依赖
#{}参数绑定来防注入; - 插件也可能因为版本不兼容导致控制台输出错乱,误以为 SQL 有问题,其实只是插件的解析问题;
- 在团队项目里,建议把排查结论留在代码注释里,而不是口口相传"这个插件能看到完整 SQL",方便新人理解。
5.3 从日志反推参数绑定问题的排查思路
日志排查最有价值的场景,是定位参数绑定相关的诡异问题。
比如你在 XML 里写了:
<select id="search" resultType="com.example.entity.User"> SELECT id, name, age, status FROM user WHERE name = #{name} AND status = #{status} </select>调用时只传了一个参数对象,或者参数名写错了,MyBatis 的报错信息有时不够直观。这时把日志打开,先看Preparing那行打印出来的?数量是否和 SQL 里的#{xxx}数量一致。如果?数量对但值不对,再看Parameters行的参数顺序是不是和#{}顺序对应。
还有一个我自己踩过的坑:多个参数没有加@Param注解时,MyBatis 会当成param1、param2来处理。日志里参数显示正常,但 SQL 结果就是不对,就是因为 XML 里写的是#{name},实际参数名却是param1。这时候日志能帮你迅速发现到底是参数名不匹配,还是 SQL 本身逻辑有问题,而不是盯着那一大段 SQL 干瞪眼。
6. 进入 Spring Boot 生态:注解、XML、MyBatis-Plus 怎么选
6.1 Spring 是如何把 Mapper 接口变成 Bean 的
单独使用 MyBatis 时,Mapper 接口要靠手动session.getMapper(UserMapper.class)来实例化。而在 Spring Boot 项目里,你可以直接在 Service 里@Autowired一个 Mapper 接口,这就让很多人产生了疑问:接口没有实现类,Spring 是怎么注入进来的?
答案藏在MapperFactoryBean和MapperScannerConfigurer里。Spring Boot 启动时会扫描@MapperScan指定包下的所有接口,为每个接口生成一个动态代理对象,这个代理负责把方法调用转发到 SqlSession 去执行 XML 或注解里定义的操作。
所以你可以这样理解:Spring 容器里的 Mapper Bean 不是接口的实现类,而是 MyBatis 为接口生成的一个代理。这也是为什么我们强调 namespace 全限定名必须和接口全限定名一致,因为代理要找 XML 全靠这个命名空间去定位。
如果启动时报MapperScan扫描不到 Mapper,排查顺序一般是:
@MapperScan的包路径是否覆盖了 Mapper 接口所在包;- Mapper 接口上是否加了
@Mapper注解(用了@MapperScan就不用每个接口都加); - 是否在启动类上同时出现多个冲突的扫描配置。
6.2 注解与 XML 的边界
Spring Boot 项目里,经常看到有人用注解写 SQL:
@Select("SELECT id, name, age, status FROM user WHERE id = #{id}") User findById(Long id);也有很多人坚持用 XML。我的经验是两条路都行,但要看边界。
注解适合简单 CRUD、固定 SQL,优点是零配置文件、代码量少。但一旦出现动态 SQL:<if>判断、<foreach>循环、<choose>分支,注解里写起来就非常痛苦。你当然可以用<script>标签包一层,那可就太丑了,维护性很差。
XML 适合复杂查询、动态 SQL、多表关联,支持团队沉淀 SQL 审查流程。缺点是文件数量多,需要配置 mapper 路径。
个人建议的划分标准是:
- 单表简单 CRUD、现场排障时方便快速看逻辑的,可以用注解;
- 涉及动态条件、批量操作、复杂关联查询的,一律放 XML。
6.3 MyBatis-Plus 能解决什么,不能解决什么
热词里出现了springboot + mybatis-plus,我顺便聊聊它。MyBatis-Plus 不是一个替换 MyBatis 的框架,它是 MyBatis 的增强工具包。它的核心价值在单表 CRUD:内置了BaseMapper和IService,提供selectById、saveBatch、updateById、分页插件、逻辑删除、乐观锁等开箱即用的能力。
如果你项目里 80% 以上的操作是单表 CRUD,MyBatis-Plus 能极大减少模板代码。条件和结果映射也做了很多自动化处理,比如驼峰命名自动转下划线,让mapUnderscoreToCamelCase的功力进一步发挥。
但 MyBatis-Plus 解决不了复杂查询。多表 JOIN、特殊数据库方言、复杂子查询,最终你还是得回到 XML 或者@Select注解里写原生 SQL。它的条件构造器QueryWrapper适合简单场景,一旦嵌套复杂,可读性会直线下降。团队里如果有人滥用Wrapper拼复杂条件,我一般是建议他在代码里加注释,甚至直接规定超过三层嵌套必须改 XML。
从面试角度来说,MyBatis 与 Spring 的集成原理、MyBatis-Plus 的自动填充和乐观锁实现、Mapper 代理机制,都是高频考点。但比背考点更重要的是,你要能说清楚"什么场景该用哪个工具"。这比会写十条 SQL 更能体现功底。
最后再分享一个我在实际项目里的体会:把 MyBatis 用好的关键,不是背下所有标签和配置,而是理解它的设计边界。什么时候把活儿交给框架,什么时候自己控制 SQL,这个判断力是踩过坑之后才有的。如果你刚接触 MyBatis,建议先把这个最小示例亲手跑一遍,然后故意制造几次缓存、日志、参数绑定的坑,亲眼看看现象。只有亲眼见过那些"不报错但结果不对"的时刻,你才算真正开始懂它。