MyBatis常见面试题总结
MyBatis 面试题集中在四条线:参数绑定(#{} 与 ${})、代理机制(Mapper 为什么没有实现类)、缓存体系(一级与二级)、扩展点(插件与分页)。吃透这几条链路,比零散背三十道题更有用。
考察地图
- 参数与 SQL:
#{}vs${}——安全红线所在,必考。 - 代理机制:Mapper 接口为什么不需要实现类、方法能否重载——动态代理的经典应用。
- 执行链路:一条查询从接口方法到 JDBC 的完整路径——串起所有组件。
- 缓存:一级缓存的作用域与失效、二级缓存的生产困境。
- 扩展机制:分页插件原理、插件拦截的四大对象。
- 建议自行查阅:动态 SQL 标签清单、
resultMap子标签、useGeneratedKeys用法、Batch 执行器的批处理细节——属于记忆类内容,面试官很少展开深问。
参数与 SQL
#{} 和 ${} 有什么区别?
结论:#{} 生成 JDBC 预编译占位符 ?,参数由 PreparedStatement 绑定,天然防注入;${} 是文本替换,直接拼进 SQL 字符串,存在注入风险。
原理:MyBatis 解析映射语句时把 #{} 转成 ParameterMapping 和 ?,执行期由 ParameterHandler 调用 setXxx() 赋值——参数作为”数据”传给数据库,不参与 SQL 语法解析。${} 则在 SqlSource 生成 BoundSql 的阶段就被替换掉,等价于字符串拼接:
WHERE name = #{name} <!-- 安全:name 作为参数绑定 --> |
延伸:${} 并非一无是处——表名、列名、排序方向这些无法预编译的位置只能用它,但必须在 Java 侧做白名单映射(前端传 createTime,服务端映射为固定列名 create_time),绝不能让外部输入原样进 SQL。
追问:
LIKE '%${key}%'这种写法有什么问题?提示:既有注入风险又容易踩语法坑,正确做法是LIKE CONCAT('%', #{key}, '%')。
Mapper 接口的方法可以重载吗?
结论:Java 语法允许声明,但 MyBatis 不支持,实际项目不要用。
原理:MyBatis 用”接口全限定名 + 方法名”作为 MappedStatement 的唯一 ID,不包含参数签名;XML 里同一 namespace 也不允许两个相同 id 的语句。重载方法找不到区分依据,参数解析还会互相干扰。JDK 的编译期重载决议与 MyBatis 的运行期语句查找是两套逻辑,别混为一谈。
追问:不同 Mapper XML 里的语句
id能重复吗?提示:能,完整 ID 是namespace + id,namespace不同就不冲突。
代理机制
Mapper 接口为什么不需要实现类?
结论:MyBatis 用 JDK 动态代理在运行期为接口生成代理对象,方法调用被统一拦截后,按约定路由到对应的 SQL 语句。
原理:调用链是 MapperProxy → MapperMethod → SqlSession。MapperProxy 实现 InvocationHandler,拦截方法后拼出”接口全限定名.方法名”作为 Statement ID,再按返回值类型(单个对象/集合/void)转调 sqlSession.selectOne()、selectList()、insert() 等方法。配置加载阶段,XML 或注解已经被解析成 Configuration 里的 MappedStatement,运行期只是按 ID 查出后交给 Executor 执行。所以约定极其严格:namespace 必须等于接口全限定名,方法名必须等于语句 id。
延伸:这也是 Spring 能”注入一个接口”的原因——MapperFactoryBean 返回的正是这个代理对象。JDK 代理要求目标必须是接口,而 Mapper 恰好全是接口,堪称天作之合。
追问:为什么不用 CGLIB?提示:代理目标本来就是接口,JDK 原生代理零依赖、生成更快;CGLIB 留给没有接口的场景,比如延迟加载的实体代理。
执行链路
MyBatis 执行一条查询的完整流程是什么?
结论:分两段——启动期把 XML/注解解析成 MappedStatement 注册进 Configuration;运行期走”代理拦截 → 生成 BoundSql → Executor 查缓存 → JDBC 执行 → 结果映射”。
原理(运行期五步):
- 代理拦截:
MapperProxy按”接口名 + 方法名”定位MappedStatement。 - 生成 SQL:
SqlSource结合实参产出BoundSql(最终 SQL + 参数映射列表)。 - 执行:
Executor先查一级缓存,未命中则由StatementHandler创建PreparedStatement,ParameterHandler绑定参数。 - 映射:
ResultSetHandler按resultMap或自动映射把ResultSet转成 Java 对象。 - 返回前:结果写入一级缓存;若开启二级缓存,事务提交后再写入 namespace 缓存。
延伸:插件(Interceptor)可以包裹 Executor、StatementHandler、ParameterHandler、ResultSetHandler 四大对象,分页、加解密、SQL 审计都是在这条链路上”插队”。
追问:
SqlSession为什么不能做成单例?提示:它持有连接、事务、一级缓存等可变状态,非线程安全;Spring 注入的 Mapper 背后是线程安全的SqlSessionTemplate,按当前事务取会话。
缓存
一级缓存是什么?什么情况下会失效?
结论:SqlSession 级别的本地缓存,同一会话内”相同语句 + 相同参数”的查询直接返回缓存;默认开启、不可关闭。
原理:缓存 key 由 Statement ID、最终 SQL、参数值、分页参数(RowBounds)、环境标识等共同拼成。失效时机有四种:执行任意 insert/update/delete、显式调用 clearCache()、commit/rollback/close、语句配置 flushCache="true"。把 localCacheScope 设为 STATEMENT 可让缓存只在单次执行内有效。Spring 场景的常见误会:没有事务时每次 Mapper 调用都是新会话,”一级缓存不生效”其实是会话根本没复用。
追问:一级缓存会导致读到旧数据吗?提示:会——同一会话首次查询后,若其他事务改了库,再查仍返回缓存中的旧对象。
二级缓存是什么?为什么生产环境慎用?
结论:namespace 级别、可跨会话共享的缓存;默认关闭,需要 <cache/> 显式开启。慎用,因为它解决不了”别人改了数据我不知道”的问题。
原理:二级缓存按 Mapper 的 namespace 组织,查询结果在事务提交后才写入,该 namespace 下任何增删改都会清空整个缓存。问题在于:跨 namespace 操作同一张表时互相感知不到;其他应用或脚本直连改库,MyBatis 更无从得知;多实例部署时各节点缓存独立、无法同步;Join 查询涉及多表,任一表变更都会让缓存过期。
延伸:真正需要跨实例共享缓存时,业界通行做法是在业务层用 Redis/Caffeine,以数据库为唯一权威源。二级缓存只适合读极多、写极少、单表无 Join 的字典类数据。
追问:
useCache和flushCache各控制什么?提示:前者控制本条查询是否使用/写入缓存,后者控制执行后是否清空缓存。
扩展机制
分页插件的原理是什么?RowBounds 为什么不行?
结论:分页插件通过 Interceptor 拦截 Executor/StatementHandler,在执行前按数据库方言改写 SQL、追加 LIMIT 并生成 COUNT 查询。RowBounds 是内存分页——查出全量结果再跳过 offset,大表深分页必死。
原理:MyBatis 插件机制基于 JDK 动态代理:实现 Interceptor 接口,用 @Intercepts + @Signature 声明拦截目标(四大对象的某个方法),组件创建时被 pluginAll() 包上代理。分页插件拦截 Executor.query(),从 ThreadLocal(如 PageHelper.startPage())取分页参数,改写 BoundSql 中的 SQL 并调整参数映射,再放行原逻辑:
|
延伸:深分页(LIMIT 1000000, 20)即使有 LIMIT 也要扫描并丢弃百万行,正确解法是游标分页:WHERE id > #{lastId} ORDER BY id LIMIT 20,前提是排序键稳定且有索引。
追问:
startPage()为什么只对紧随其后的第一条查询生效?提示:分页参数存在 ThreadLocal 里,被消费一次后就清除,避免污染后续查询。
小结
#{}走预编译参数绑定,${}走文本拼接;后者只能用于白名单控制下的结构片段。- Mapper 不需要实现类,靠的是 JDK 动态代理 + “接口全限定名.方法名”的 Statement ID 约定。
- 一级缓存跟随会话、默认开启;二级缓存跨会话但有一致性硬伤,生产上大多用业务层缓存替代。
- 分页插件是插件机制的最佳样本:拦截
Executor,方言改写 SQL;深分页请改用游标。

