MyBatis 的面试重点不是会写 CRUD,而是理解它如何在「手写 SQL」和「对象映射」之间做取舍,以及这些设计在事务、缓存和批量场景下的副作用。
1. #{} 和 ${} 有什么区别?
#{} 会被编译成 PreparedStatement 的占位符 ?,参数通过 TypeHandler 绑定,能防止 SQL 注入,也能复用执行计划,应该作为传值的默认选择。
${} 是字符串直接替换,发生在 SQL 解析之前。它适合表名、列名、排序字段这类无法用占位符表达的位置,但必须对输入做白名单校验,否则就是注入漏洞。
一个常见误区是「#{} 会加引号,${} 不会」——本质区别是预编译绑定与文本拼接,引号只是表象。order by #{column} 会变成 order by 'name',排序不生效,正是这个原因。
另外,可空参数在部分数据库(如 Oracle)需要显式 jdbcType,否则可能报无效列类型;MySQL 通常不强制要求。
2. Mapper 接口没有实现类,为什么能调用?
启动时 MyBatis 扫描 Mapper 接口,把每个方法解析成 MappedStatement,以「命名空间 + 方法名」为 key 存入 Configuration。
调用时通过 MapperProxyFactory 生成 JDK 动态代理。MapperProxy 拦截方法调用,根据方法签名找到对应语句,转换成 SqlSession.selectOne、selectList、insert 等操作。
所以接口只是「声明」,SQL 的真正来源是 XML 或注解。方法重载在 MyBatis 中通常不被支持,因为语句 id 只由方法名决定;参数名在编译时若没保留,需要用 @Param 显式命名。
理解这一点也能解释为什么 Mapper 不能被随意 new:它依赖 SqlSession 和配置上下文,脱离容器创建出来的代理无法工作。
3. 一级缓存和二级缓存是怎么回事?生产环境要不要开?
一级缓存是 SqlSession 级别的,默认开启,底层是一个 PerpetualCache。同一会话中相同的语句和参数会命中缓存,执行增删改或提交、回滚后失效。localCacheScope=STATEMENT 可以让它只在单条语句内生效。
与 Spring 集成后,如果方法没有事务,每次 Mapper 调用都可能使用新的 SqlSession,一级缓存几乎不命中;处于同一事务时才会共享。
二级缓存是命名空间级别,需要显式配置 <cache/>,跨会话共享,要求结果可序列化。它在多节点部署下容易读到陈旧数据,写操作也只清空当前命名空间的缓存,因此生产环境通常建议关闭,改用 Redis 等集中式缓存。
4. resultMap 和 N+1 问题怎么处理?
resultMap 负责列到属性的映射,支持 association(一对一)和 collection(一对多)。它能处理下划线转驼峰、类型转换和复杂嵌套,比 resultType 的自动映射更可控。
嵌套结果有两种写法:一种是 join 一次查回,用 resultMap 的 id 标签合并重复行;另一种是「嵌套查询」,主查询后再按 id 发子查询,配置 fetchType="lazy" 可以延迟加载。
后者就是 N+1 的来源:查 100 条订单会再发 100 次明细查询。解决思路是 join 一次查回,或先收集主键再用 IN 批量查、在内存里组装。延迟加载只能推迟开销,不能消除次数。
还要注意 join 一对多时结果集行数会膨胀,分页要谨慎,先分页主表再查明细通常更稳。
5. 插件机制是怎么实现的?分页插件原理是什么?
MyBatis 允许拦截四类对象的方法:Executor、StatementHandler、ParameterHandler、ResultSetHandler。插件通过 @Intercepts 和 @Signature 声明拦截点,再由 InterceptorChain 逐层包装成动态代理。
多个插件形成责任链,顺序由配置决定;包装的是同一批核心对象,所以顺序会影响 SQL 改写和参数处理的结果。
分页插件通常在 Executor.query 处拦截:先根据原 SQL 生成 count 语句,再改写为带 limit 的语句,最后把结果包装成分页对象。有的实现依赖方言识别,复杂 SQL 的 count 优化需要谨慎。
使用分页插件时要注意:分页参数常放在 ThreadLocal 中,必须「设置后立即查询」,中间插入其他查询会串页;异步线程里也不会自动传递。
6. 大批量写入和超大结果集怎么处理?
批量写入有三条路:在 foreach 中拼多值 insert;使用 ExecutorType.BATCH 并定期 flushStatements;或交给上层做分批提交。MySQL 连接串加 rewriteBatchedStatements=true 才能把批量语句真正合并发送。
批处理要注意事务大小:一次提交几十万行会撑大 undo、拉长锁持有时间。按几百到几千条分批,并评估失败重试的幂等性。
超大结果集不要用 selectList 一次装进内存。可以用 ResultHandler 逐行处理,或用 Cursor 流式读取,同时把 fetchSize 设为合适的值;MySQL 驱动需要流式模式才真正逐行拉取,连接不能被其他查询复用。
7. MyBatis 和 JPA / Hibernate 怎么选?
MyBatis 把 SQL 控制权交给开发者,适合复杂查询、遗留库、需要精细优化的场景;代价是重复的映射和手写 SQL,简单 CRUD 显得啰嗦。
JPA/Hibernate 以对象模型为中心,自动生成 SQL,开发效率高,但复杂查询容易失控,懒加载和缓存的行为需要经验才能驾驭。
实际项目里常见混合方案:核心写操作和复杂报表用 MyBatis,后台管理等简单 CRUD 用 JPA 或 MyBatis-Plus。MyBatis-Plus 在 MyBatis 之上提供通用 CRUD、条件构造器和分页,但复杂 SQL 仍要手写,别把它当成完全的 ORM。
选型的关键不是「哪个更先进」,而是团队能否为生成出来的 SQL 负责。
8. 日常使用中有哪些容易踩的坑?
参数名:Spring Boot 编译默认保留参数名,但依赖构建配置;多参数方法建议显式 @Param,避免升级 JDK 或改编译参数后失效。
映射:开启 mapUnderscoreToCamelCase 后仍要注意别名覆盖;resultType 与 resultMap 不要混用;枚举、JSON 字段需要自定义 TypeHandler。
查询:selectOne 返回多行会抛 TooManyResultsException;<if test="flag == 'Y'"> 在 OGNL 中比较的是字符,字符串判断应写成 <if test='flag == "Y"'>,或使用 'Y'.toString().equals(flag) 这类明确写法。
事务与连接:批处理、流式查询会长时间占用连接;多数据源要确认事务管理器和 SqlSessionFactory 是否配对;allowMultiQueries 打开后要评估注入风险。
易错点与自测
${}不是「不加引号的#{}」,而是文本替换,任何来自用户的输入都不能直接拼。- 二级缓存是命名空间级、跨会话的,分布式下默认不要开。
- 延迟加载解决的是「何时查」,不是「查多少次」,N+1 仍要改成批量或 join。
自测:① 描述一次 Mapper 方法调用从代理到 JDBC 的完整链路;② 写出批量插入的两种实现并说明事务边界;③ 解释为什么分页插件在异步线程里会失效。
参考资料
- MyBatis 3 官方文档 — 动态 SQL、resultMap、缓存与插件
- MyBatis 源码(GitHub) —
MapperProxy、Executor与拦截链实现 - MySQL Connector/J 配置 —
rewriteBatchedStatements与流式结果集参数