1. MyBatis 到底是什么:先看清它解决的核心问题
MyBatis 是 Java 后端绕不开的持久层框架。很多团队从早年直接写 JDBC 的年代一路走过来,最后把数据访问层稳定在 MyBatis 上,不是因为它花哨,而是因为它"刚刚好":JDBC 太原始,全自动 ORM 又太黑盒,MyBatis 把 SQL 控制权还给开发者的同时,又帮你把结果集映射、参数绑定、连接管理这些重复劳动做掉了。
1.1 它解决的核心问题
在没有持久层框架的时候,你写一条查询要经历六个步骤:加载驱动、获取连接、创建 Statement、填充参数、执行 SQL、手工遍历 ResultSet 并封装成对象。其中任何一个环节写得不严谨,都会带来连接泄漏、SQL 注入、类型转换异常这些很难查的问题。
MyBatis 做的事情其实很朴素:它把"参数 -> SQL -> 结果集 -> Java 对象"这条链路标准化了。SQL 仍然是你自己写的,不会像某些全自动框架那样自动生成难以控制的查询;但你不需要再关心 PreparedStatement 的 ? 占位符怎么填,也不需要手工处理 ResultSet 的 getString、getInt、getDate。底层这些繁琐动作,全部由框架内部帮你完成了。
所以它的定位很清晰:半自动 ORM 框架。你负责 SQL 和映射规则,框架负责执行和封装。正是这种"把控制权留给开发者"的设计,让它在复杂查询、报表统计、多表关联这些场景里特别好用。
1.2 为什么不是 JDBC,也不是全自动 ORM
如果你是第一次接触 MyBatis,可能不理解为什么放着现成的 JDBC 不用,或者为什么不用更"智能"的 ORM 框架。
JDBC 的问题不是功能不够,而是重复代码太多。一个简单的查询方法里,try-catch-finally 就要写一大段,而且参数和结果集映射完全靠手工,稍不注意就把列索引写错。MyBatis 相当于把这段模板代码收敛到了框架内部,让你只关注业务。
全自动 ORM 的问题恰好相反:它帮你生成 SQL,但如果团队里有人不熟悉底层机制,很容易写出性能很差的查询。比如「懒加载 + 循环调用」搞出 N+1 问题,或者因为联表策略没控制好,一次列表页加载就把整个数据库拖慢。MyBatis 因为 SQL 是显式写出来的,代码评审阶段就能看到实际在跑什么语句,排查问题也少一层黑盒。
1.3 什么时候该选 MyBatis
结合我自己的判断,MyBatis 特别适合下面几类项目:
- 业务复杂,SQL 需要精细调优,比如报表、统计、多条件组合查询;
- 团队里有大量对 SQL 很熟的工程师,不想学 HQL 那套抽象;
- 遗留数据库结构不规范,列名、表名无法通过约定直接映射到实体;
- 需要灵活控制批量插入、批量更新、自定义类型转换的中间件型项目。
反过来,如果项目模型非常简单,CRUD 占绝大多数,表结构和实体也完全一致,那用 MyBatis 反而会多一点配置成本。这种场景选择更轻量的方案也没问题。关键在于,MyBatis 的定位决定了它的核心价值在"可控制的 SQL",而不是"不需要写 SQL"。
2. 原理:一次查询从接口到数据库到底发生了什么
新手用 MyBatis 往往只知道"定义一个接口,写一个 XML,然后就能调用了"。但面试和实际排障真正要考的是:这个调用链里,接口没有实现类,为什么还能执行?SQL 是从哪里找到的?一级缓存又是怎么生效的?
2.1 启动阶段与映射注册
MyBatis 启动时会读取配置文件,把每个 Mapper 的 namespace、id 和 SQL 语句绑定到一个 MappedStatement 对象上。MappedStatement 就是一条 SQL 的"完整描述":包含 SQL 语句、参数类型、返回类型、是否使用动态 SQL、缓存配置等等。
同时,MyBatis 会把 Mapper 接口的 Class 对象注册到一个 MapperRegistry 中。这一步很关键:后续调用接口方法时,框架要根据接口 Class 和方法找到对应的 MappedStatement。
如果你在项目中遇到过 "Invalid bound statement (not found)" 这个报错,本质就是接口方法没能在启动阶段注册到 MapperRegistry。最常见的原因有:XML 文件没有放在和接口相同包路径下、resource 路径写错、namespace 不是接口全限定名、接口方法名和 XML id 对不上。
2.2 调用阶段与动态代理
当你在 Service 里注入 Mapper 接口,然后调用 userMapper.findById(1L) 时,实际执行的并不是接口方法,因为接口根本没有实现类。
MyBatis 用的是 JDK 动态代理。SqlSession 的 getMapper 方法会从 MapperRegistry 中根据接口 Class 拿到一个 MapperProxyFactory,再由它创建代理对象。代理对象收到方法调用后,会做三件事:
- 根据接口方法和参数,解析出对应的 MappedStatement;
- 创建 SqlSession 并交给 Executor 执行;
- 处理返回结果,必要时做二级缓存、延迟加载等逻辑。
这个过程在源码里其实不复杂,核心思路就是"把方法调用转换成 SQL 执行"。动态代理的好处是:你不需要为每个 Mapper 手写实现类,框架在运行时替你完成。这正是 MyBatis 能保持轻量的核心设计。
2.3 Executor 与四大组件
MyBatis 的执行核心是 Executor,它负责整个 SQL 执行流程的调度。Executor 下面还有 StatementHandler、ParameterHandler、ResultSetHandler 和 TypeHandler 这几类组件。
Procedure 大概是这样:
- Executor 先走缓存逻辑,缓存没命中就进入下一步;
- StatementHandler 负责创建 Statement,并处理 SQL 中的动态部分;
- ParameterHandler 负责把方法参数绑定到 PreparedStatement 的占位符上;
- ResultSetHandler 负责把查询结果映射成 Java 对象;
- TypeHandler 则贯穿参数绑定和结果映射,处理 Java 类型和 JDBC 类型之间的转换。
理解这个链路,最大的好处是能看懂插件机制。MyBatis 的拦截器只能拦截这四个接口:Executor、StatementHandler、ParameterHandler、ResultSetHandler。你平时用的分页插件、数据审计插件,本质都是拦截 Executor 或 StatementHandler,在 SQL 执行前改写 SQL,或者在执行后加工结果。
2.4 SqlSession 生命周期以及一级缓存
SqlSession 可以理解成"一次数据库会话"。每次会话内部都会创建一个 Executor,Executor 内部持有一个一级缓存。一级缓存默认开启,缓存 key 由 SQL 语句、参数、环境 ID 等共同构成。
第一次执行相同的查询时,结果会放进一级缓存;第二次再执行一模一样的查询,如果中间没有发生增删改操作,就会直接命中缓存,不再查库。注意条件有两个:一是确实是同一个 SqlSession,二是查询语句和参数完全相同。
实际开发中,一级缓存常常被忽略,但它带来过一个很经典的问题:在一个长事务里先后查同一个对象,中间如果别人改了数据库,这个长事务的第二次查询可能拿到的还是旧数据。因为 MyBatis 默认没法感知外部提交,只能通过执行 update/delete/insert 来清空缓存。理解了这个机制,在排查"数据怎么没变"时就不会只盯着 SQL 看。
3. 使用:从零搭一个可运行的 Mapper
原理讲完,下面直接进入实操。很多教程配置写得很多,但没有一个最小可运行样例。我更倾向于先跑起来,再逐步理解。
3.1 最小配置
首先准备 mybatis-config.xml,只保留核心配置:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <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"/> <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); }UserMapper.xml 放在 classpath 下的 mapper 目录:
<?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.mapper.UserMapper"> <select id="findById" resultType="com.example.entity.User"> select id, name, age from user where id = #{id} </select> </mapper>注意 xmlns 必须和接口全限定名一致。如果这里不一致,启动时会报 "Mapper method 'com.example.mapper.UserMapper.findById()' not found" 这类错误。这个步骤是新手最容易踩的坑。
3.2 resultMap 与字段映射
当实体字段和数据库列名不匹配时,就要用 resultMap。比如数据库列叫 user_name,而实体属性叫 username,直接在 select 里写别名是简单做法,但遇到复杂联表时,resultMap 会更清晰。
<resultMap id="userMap" type="com.example.entity.User"> <id property="id" column="id"/> <result property="username" column="user_name"/> <result property="createTime" column="create_time"/> </resultMap> <select id="findById" resultMap="userMap"> select id, user_name, create_time from user where id = #{id} </select>这里要记住:resultMap 的 id 子节点必须配置主键字段,尤其在做一对一、一对多关联映射时。MyBatis 判断同一行记录是否属于同一个对象,就是靠主键 id。如果主键没配置,有些关联场景会查出重复数据。
3.3 动态 SQL
动态 SQL 是 MyBatis 的一大招牌。以前你要在 Java 代码里通过 if-else 拼接 where 条件,现在可以直接写在 XML 里。
一个常用的多条件查询示例:
<select id="searchUsers" resultMap="userMap"> select id, user_name, create_time from user <where> <if test="name != null and name != ''"> and user_name like concat('%', #{name}, '%') </if> <if test="createTime != null"> and create_time >= #{createTime} </if> </where> order by id desc </select>where 标签会自动去掉开头的 and 或 or,避免 "where and ..." 这种错误。如果你用 if 手工拼,就很容易在条件全部为空或第一个条件为空时拼出错 SQL。
foreach 标签处理 in 集合:
<select id="findByIds" resultMap="userMap"> select id, user_name from user where id in <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>动态 SQL 虽然灵活,但有一个原则:越复杂的动态条件,越要小心索引失效。比如${}做参数拼接、隐式类型转换、like 前置通配符,都会让数据库无法走索引。业务允许的情况下,尽量把过滤条件控制在一个合理范围。
3.4 #{} 与 ${} 的差异
这个问题是面试高频题,也是线上事故高发点。
#{} 会被解析成 PreparedStatement 的参数占位符 ?,由数据库驱动做预编译和参数绑定,能够有效防止 SQL 注入。所以绝大多数场景都应该用 #{}。
${} 是纯字符串替换,直接把变量内容拼进 SQL。它适合那些无法用占位符表达的位置,比如动态排序列名、动态表名。
<select id="findByOrder" resultMap="userMap"> select id, user_name from user order by ${orderColumn} </select>这里如果 orderColumn 来自前端传入,必须做白名单校验。否则用户传入id; drop table user; --这种内容,整个语句就变成多条 SQL 注入。我曾经见过一个团队因为图方便,把排序字段直接 ${} 塞进去,结果被安全扫描打回来。正确做法是先把允许排序的字段列表控制在服务端,再映射到可靠值。
4. 缓存、插件和 Spring 整合的隐藏细节
很多项目用着用着会发现一些"诡异"问题,比如查询结果没更新、分页数据不对、缓存长期不刷新。这些问题多半出在缓存和插件机制上,而不在业务代码。
4.1 二级缓存
二级缓存是 Mapper 级别的缓存,跨 SqlSession 共享。它的默认实现是内存缓存,也可以配置 redis 之类的外部缓存。开启方式是在 Mapper XML 里加:
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>但我要提醒一句:二级缓存不是默认开启的,而且开启前一定要想清楚缓存一致性。如果两张表通过一条 SQL 关联查询,其中一张表被另一个 Mapper 更新了,而这个关联查询 Mapper 不知道,就会返回脏数据。
MyBatis 的二级缓存设计比较保守,它只会在当前 Mapper 的增删改操作后清空当前命名空间下的缓存,不感知其他 Mapper 对底层表的修改。所以在多表关联、频繁更新的系统里,我一般建议关闭二级缓存,把缓存控制交给业务层或 Redis。这样反而更容易排查和扩展。
4.2 拦截器:分页和自动填充
MyBatis 插件原理不复杂,就是 JDK 动态代理加责任链。你通过 @Intercepts 注解声明要拦截的方法,MyBatis 启动时会为目标对象生成代理。
一个典型的分页拦截器会拦截 Executor 的 query 方法。在 SQL 执行前,通过反射拿到原始 SQL,改写成分页方言 SQL,再执行查询并返回结果。注意,这里必须拿到 BoundSql 和参数对象,因为不同的分页方言参数绑定方式不一样。
拦截器的顺序由 @Intercepts 注解的排序决定,多个插件会形成嵌套代理。所以如果你同时用了分页插件和乐观锁插件,执行顺序会影响最终 SQL。遇到过最坑的是,两个插件都修改了 SQL,导致分页 count 查询和列表查询的 SQL 不一致。解决方法是插件里在做 count 查询时,必须把组装逻辑独立封装,不要污染原 BoundSql。
4.3 与 Spring 整合时踩过的坑
MyBatis 单独使用不常见,大多会与 Spring 集成。集成后,SqlSession 的管理会交给框架。这里最常见的坑是事务边界不清晰。
在 Spring 中,SqlSessionTemplate 是线程安全的,它会根据当前事务自动决定使用哪一个 SqlSession。如果方法没有加 @Transactional,那么每次 Mapper 调用都会创建新的 SqlSession,调用结束就关闭。一级缓存自然也就跟着消失。这个行为其实不是 Bug,而是一个容易误判的点。
另一个坑是 Mapper 接口和 XML 的绑定问题。Spring 整合后,如果包扫描路径和 XML resource 路径没对齐,就报 "Invalid bound statement"。建议统一约定:XML 和接口放在同一个包路径下,并在配置里显式指定 mapper-locations。
5. 常见面试题与项目避坑
面试题这部分,我按自己面试候选人的习惯来整理。如果你准备面试,下面几个问题不只是背答案,更要能说清楚"为什么"。
5.1 面试题:为什么 Mapper 接口不用写实现类
因为 MyBatis 在启动时通过 MapperRegistry 注册了接口,调用 getMapper 时,会用 JDK 动态代理创建 MapperProxy。MapperProxy 的 invoke 方法会把接口方法调用转换成对应 MappedStatement 的 id,然后交给 SqlSession 执行。
回答时最好补充一句:JDK 动态代理要求接口必须存在,所以 MyBatis 的 Mapper 必须声明为接口,不能是普通类。这也是它区别于 Spring Data JPA 那种运行时代理方式的地方。
5.2 面试题:缓存、延迟加载、TypeHandler
面试官如果问缓存,通常是想看你有没有踩过生产环境的坑。你需要说出三点:
- 一级缓存默认开启,作用域是 SqlSession;
- 二级缓存作用域是 namespace,可配置,但需要自行处理一致性;
- Spring 整合后,SqlSession 生命周期受事务控制,一级缓存不一定会跨多次方法调用生效。
延迟加载要提到 association 和 collection 的 fetchType="lazy" 属性,以及 proxyFactory 配置。如果配置了懒加载,但 Jackson 序列化时触发了代理对象访问,就会导致懒加载执行时机不可控。生产环境我通常建议关闭延迟加载,改用一次联表查询,代码可读性反而更高。
TypeHandler 常被忽略,但面试时问得深的人会想听你聊 LocalDateTime 和自定义枚举的转换。MyBatis 内置了很多基础类型处理器,遇到特殊类型时,你可以实现 BaseTypeHandler,并在 config 里注册。
5.3 面试题:性能相关问题
"MyBatis 慢在哪"这类问题,常见答案包括 N+1、超大结果集、缓存未命中、批量操作走了单条 SQL。
N+1 的经典场景是:先查列表,然后在循环里逐个查关联表。解决方案有三种:一次性 join 查询再用 resultMap 的 collection 装配;使用嵌套查询但开启懒加载;或者直接写一条支持批量 key 的查询,用 foreach 拼条件。
批量插入也是一个高频优化点。MyBatis 自带批量模式是在 ExecutorType.BATCH 下创建批量 Statement,但很多初学者直接在 XML 里写一个 foreach 循环插入几千条数据。这样做并非不行,只是 SQL 长度可能超过数据库参数上限。建议分批,比如每 500 条提交一次,既控制内存,也不至于撑爆 SQL。
5.4 实战中的高频报错
我列一个排查速查表:
| 报错表现 | 常见原因 | 处理方式 |
|---|---|---|
| Invalid bound statement | 接口方法、XML id、namespace 不匹配 | 检查三者是否一致,检查打包是否包含 XML |
| Mapped Statements collection does not contain | 接口方法名错误,或 XML 没被解析 | 核对 mapper 路径和资源扫描范围 |
| TooManyResultsException | 映射方法返回一个对象,但 SQL 查出了多行 | 改用 List 接收,或修正查询条件 |
| Parameter 'xxx' not found | 参数名不支持,Java 编译时没保留参数名 | 编译加 -parameters,或用 @Param 显式命名 |
| 查询结果为 null,但数据库有数据 | resultMap 字段映射不对 | 检查 column 和 property 是否匹配 |
这里特别说下参数问题。Java 字节码默认不带方法参数名,当你写#{name}时,MyBatis 可能无法通过反射知道你传的 Map key 是什么。最稳妥的做法是方法签名加 @Param:
User findByCondition(@Param("name") String name, @Param("age") Integer age);虽然现代编译器和 Spring Boot 默认配置能解决一部分问题,但 @Param 是显式约定,代码可读性也更好。
5.5 性能优化清单
我每次做 MyBatis 性能排查,基本按这个顺序来:
- 确认是否命中索引,用执行计划看实际扫描行数;
- 检查有没有循环查库,严格规避 N+1;
- 查看一级缓存和二级缓存是否被大量命中,如果缓存命中率低,先查调用方式;
- 批量写数据时,优先考虑 ExecutorType.BATCH,或分批 foreach;
- 只查需要的字段,不要无脑 select *;
- 结果集特别大时,用好分页,同时设置 fetchSize 防止 OOM。
很多人一慢就想到优化 SQL,但很多问题其实不在 SQL,而在代码调用方式。比如一次请求里同一份数据查了三遍,却被所有 SQL 优化掩盖了。
6. 最后,把 MyBatis 用顺手的几个小建议
我自己的体会是,MyBatis 最值得花时间的点不是记住每个标签,而是理解它" 半自动 "的边界在哪里。什么时候自己写 SQL,什么时候靠 resultMap 装配;什么时候开缓存,什么时候关缓存;什么时候用动态代理,什么时候别在 SQL 里拼接不可信变量。这些判断比背源码更值钱。
建议你在新项目里先做一个最小 Demo:一张表、一个接口、一个 XML、一个 Service,把完整链路跑通。跑通之后,再逐步加 resultMap、动态 SQL、缓存和插件。这样做的好处是,出问题时你知道是配置问题还是代码问题,而不是在一个大项目里猜来猜去。
另外,SQL 审计和慢查询日志一定要开起来。MyBatis 打印出的 SQL 往往是带 ? 的预编译语句,日志里配置好参数替换后,能直接复制到数据库执行。调试动态 SQL 时,这个功能能救命。
还有一点是我踩过多次坑后才养成的习惯:任何和团队协作相关的 Mapper 改动,都尽量在代码评审里把实际生成的 SQL 贴出来,而不是只说"我加了动态 SQL"。因为 MyBatis 的 where、if、foreach 组合形态太多,只看 XML 很难判断最终 SQL 是否符合预期。讨论 SQL 本身,比讨论框架标签更有价值。
最后分享一个小技巧:如果你不确定某段动态 SQL 最终长什么样,可以在测试环境开启日志输出完整 SQL,然后用一个单测用例构造所有分支参数逐一验证。这比反复启动服务要快得多。把这个习惯保持住,MyBatis 在你的项目里会变得越来越顺手。