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但如果团队里有人不熟悉底层机制很容易写出性能很差的查询。比如「懒加载 循环调用」搞出 N1 问题或者因为联表策略没控制好一次列表页加载就把整个数据库拖慢。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 可以理解成一次数据库会话。每次会话内部都会创建一个 ExecutorExecutor 内部持有一个一级缓存。一级缓存默认开启缓存 key 由 SQL 语句、参数、环境 ID 等共同构成。第一次执行相同的查询时结果会放进一级缓存第二次再执行一模一样的查询如果中间没有发生增删改操作就会直接命中缓存不再查库。注意条件有两个一是确实是同一个 SqlSession二是查询语句和参数完全相同。实际开发中一级缓存常常被忽略但它带来过一个很经典的问题在一个长事务里先后查同一个对象中间如果别人改了数据库这个长事务的第二次查询可能拿到的还是旧数据。因为 MyBatis 默认没法感知外部提交只能通过执行 update/delete/insert 来清空缓存。理解了这个机制在排查数据怎么没变时就不会只盯着 SQL 看。3. 使用从零搭一个可运行的 Mapper原理讲完下面直接进入实操。很多教程配置写得很多但没有一个最小可运行样例。我更倾向于先跑起来再逐步理解。3.1 最小配置首先准备 mybatis-config.xml只保留核心配置?xml version1.0 encodingUTF-8 ? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN http://mybatis.org/dtd/mybatis-3-config.dtd configuration environments defaultdev environment iddev transactionManager typeJDBC/ dataSource typePOOLED property namedriver valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/demo/ property nameusername valueroot/ property namepassword value123456/ /dataSource /environment /environments mappers mapper resourcemapper/UserMapper.xml/ /mappers /configuration然后写一个最简单的 Mapper 接口public interface UserMapper { User findById(Long id); }UserMapper.xml 放在 classpath 下的 mapper 目录?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.UserMapper select idfindById resultTypecom.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 iduserMap typecom.example.entity.User id propertyid columnid/ result propertyusername columnuser_name/ result propertycreateTime columncreate_time/ /resultMap select idfindById resultMapuserMap 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 idsearchUsers resultMapuserMap select id, user_name, create_time from user where if testname ! null and name ! and user_name like concat(%, #{name}, %) /if if testcreateTime ! null and create_time gt; #{createTime} /if /where order by id desc /selectwhere 标签会自动去掉开头的 and 或 or避免 where and ... 这种错误。如果你用 if 手工拼就很容易在条件全部为空或第一个条件为空时拼出错 SQL。foreach 标签处理 in 集合select idfindByIds resultMapuserMap select id, user_name from user where id in foreach collectionids itemid open( separator, close) #{id} /foreach /select动态 SQL 虽然灵活但有一个原则越复杂的动态条件越要小心索引失效。比如${}做参数拼接、隐式类型转换、like 前置通配符都会让数据库无法走索引。业务允许的情况下尽量把过滤条件控制在一个合理范围。3.4 #{} 与 ${} 的差异这个问题是面试高频题也是线上事故高发点。#{} 会被解析成 PreparedStatement 的参数占位符 ?由数据库驱动做预编译和参数绑定能够有效防止 SQL 注入。所以绝大多数场景都应该用 #{}。${} 是纯字符串替换直接把变量内容拼进 SQL。它适合那些无法用占位符表达的位置比如动态排序列名、动态表名。select idfindByOrder resultMapuserMap 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 evictionLRU flushInterval60000 size512 readOnlytrue/但我要提醒一句二级缓存不是默认开启的而且开启前一定要想清楚缓存一致性。如果两张表通过一条 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 的 fetchTypelazy 属性以及 proxyFactory 配置。如果配置了懒加载但 Jackson 序列化时触发了代理对象访问就会导致懒加载执行时机不可控。生产环境我通常建议关闭延迟加载改用一次联表查询代码可读性反而更高。TypeHandler 常被忽略但面试时问得深的人会想听你聊 LocalDateTime 和自定义枚举的转换。MyBatis 内置了很多基础类型处理器遇到特殊类型时你可以实现 BaseTypeHandler并在 config 里注册。5.3 面试题性能相关问题MyBatis 慢在哪这类问题常见答案包括 N1、超大结果集、缓存未命中、批量操作走了单条 SQL。N1 的经典场景是先查列表然后在循环里逐个查关联表。解决方案有三种一次性 join 查询再用 resultMap 的 collection 装配使用嵌套查询但开启懒加载或者直接写一条支持批量 key 的查询用 foreach 拼条件。批量插入也是一个高频优化点。MyBatis 自带批量模式是在 ExecutorType.BATCH 下创建批量 Statement但很多初学者直接在 XML 里写一个 foreach 循环插入几千条数据。这样做并非不行只是 SQL 长度可能超过数据库参数上限。建议分批比如每 500 条提交一次既控制内存也不至于撑爆 SQL。5.4 实战中的高频报错我列一个排查速查表报错表现常见原因处理方式Invalid bound statement接口方法、XML id、namespace 不匹配检查三者是否一致检查打包是否包含 XMLMapped 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 是什么。最稳妥的做法是方法签名加 ParamUser findByCondition(Param(name) String name, Param(age) Integer age);虽然现代编译器和 Spring Boot 默认配置能解决一部分问题但 Param 是显式约定代码可读性也更好。5.5 性能优化清单我每次做 MyBatis 性能排查基本按这个顺序来确认是否命中索引用执行计划看实际扫描行数检查有没有循环查库严格规避 N1查看一级缓存和二级缓存是否被大量命中如果缓存命中率低先查调用方式批量写数据时优先考虑 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 在你的项目里会变得越来越顺手。