导读 :在Java后端面试中,MyBatis 往往是面试官手中的一把"双刃剑"。初级开发者只会 CRUD,而高级开发者则需要深谙其缓存机制、插件原理和动态代理。本文将带你从面试现场的"连环追问"切入,结合核心源码与实战场景,助你彻底征服这一板块。
无论是初级工程师还是高级架构师,MyBatis 都是 Java 后端开发中绕不开的核心组件。面试官对它的考察早已不再局限于"怎么写 SQL",而是深入到了 SQL 注入防护、缓存穿透/脏读、分页插件底层原理 等核心深度。
为了帮你"吊打"面试官,本文将从以下 5 个维度为你深度拆解:
#{}与${}的生死抉择 ------ 防止 SQL 注入的底线- 动态 SQL 的优雅与陷阱 ------ 什么时候该用
where,什么时候必须用trim - 缓存失效的真相 ------ 一级缓存与二级缓存的爱恨情仇
- 分页插件的黑魔法 ------ 物理分页 vs 逻辑分页的性能博弈
- Mapper 接口的秘密 ------ 为什么没有实现类也能运行?
🔍 第一回合:面试现场 ------ #{} 与 ${} 的区别
🎯 面试官提问
"我们在写 SQL 时,
#{}和${}到底有什么区别?什么时候必须用${}?"
💡 核心解析
这不仅仅是语法的区别,更是安全与性能的区别。
1. 核心对比表
| 对比维度 | #{} (预编译占位符) |
${} (字符串拼接) |
|---|---|---|
| 处理机制 | 预编译 (Prepared Statement),先编译 SQL 模板,再传参数 | 字符串替换,先拼接字符串,再编译执行 |
| SQL 注入 | ✅ 安全,参数被当作值处理 | ❌ 极度危险,参数可能被当作 SQL 代码执行 |
| 执行性能 | 高,数据库可缓存执行计划 (Execution Plan) | 低,每次 SQL 字符串都不同,无法缓存 |
| 类型转换 | 自动进行 Java Type -> JDBC Type 转换 | 无,直接拼接字符串 |
| 典型场景 | 绝大多数参数传递 (WHERE 条件) | 动态表名、列名、ORDER BY 字段 |
2. 深度场景剖析:为什么有时候非得用 ${}?
虽然 #{} 很安全,但在某些场景下它是"无能为力"的。
-
场景一:动态表名 如果你的业务需要根据时间分表(如
user_2024,user_2025),表名是 SQL 的结构部分,预编译占位符?不允许出现在表名位置。xml<!-- 必须使用 $ {} --> <select id="findUserByTable" resultType="User"> SELECT * FROM $ {tableName} WHERE id = #{id} </select>避坑指南 :如果必须用
${},请务必在 Java 代码层做白名单校验,绝对不能直接拼接用户输入! -
场景二:动态排序 (ORDER BY)
xml<!-- 动态按不同列排序 --> <select id="findUser" resultType="User"> SELECT * FROM user ORDER BY $ {sortColumn} $ {sortOrder} </select>
🪄 第二回合:动态 SQL ------ 让代码更聪明
🧩 核心标签实战
MyBatis 的动态 SQL 是基于 OGNL 表达式实现的,它能让你的 XML 像编程语言一样灵活。
1. 解决"多余关键字"的神器:<where> 与 <set>
- 痛点 :在拼接
AND或OR时,很容易出现语法错误(如WHERE AND name = 'xxx')。 - 方案 :使用
<where>标签,它会智能判断 :如果内部标签没有返回任何内容,它就不生成WHERE子句;如果第一个条件带AND,它会自动去除。
xml
<select id="findUser" resultType="User">
SELECT * FROM user
<!-- <where> 会自动处理第一个 and/or -->
<where>
<if test="name != null and name != ''">
AND name = #{name}
</if>
<if test="age != null">
AND age = #{age}
</if>
</where>
</select>
2. 批量操作:<foreach>
这是面试中考察并发和性能的常见点。
xml
<!-- IN 查询 -->
<select id="selectByIds" resultType="User">
SELECT * FROM user WHERE id IN
<foreach collection="list" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<!-- 批量插入 -->
<insert id="batchInsert">
INSERT INTO user (name, age) VALUES
<foreach collection="list" item="user" separator=",">
(#{user.name}, #{user.age})
</foreach>
</insert>
🧠 第三回合:缓存机制 ------ 一级与二级缓存的博弈
⚖️ 缓存对比全景图
| 特性 | 一级缓存 (Local Cache) | 二级缓存 (Global Cache) |
|---|---|---|
| 作用域 | SqlSession 级别 |
Mapper (Namespace) 级别 |
| 默认状态 | ✅ 开启 (无法关闭) | ❌ 关闭 (需手动配置) |
| 数据共享 | 同一个会话内共享 | 跨 SqlSession 共享 (应用级) |
| 脏读风险 | 无 (会话结束即销毁) | 有 (多表操作导致数据不一致) |
| 底层实现 | PerpetualCache (HashMap) |
PerpetualCache + 装饰器模式 |
🚨 经典面试题:二级缓存的"脏读"怎么解决?
场景模拟 :
假设你有两个 Mapper:
UserMapper:查询用户信息。UserRoleMapper:负责给用户分配角色(更新操作)。
问题 :
UserMapper 开启了二级缓存。当 UserRoleMapper 修改了用户的角色后,UserMapper 的缓存并没有失效!下次查询用户时,读到的还是旧的角色信息 ------ 这就是脏读。
解决方案:
-
方案 A (粗暴) :直接禁用二级缓存(推荐在分布式环境下使用 Redis 替代)。
-
方案 B (优雅) :使用
<cache-ref>标签,让两个 Mapper 引用同一个缓存区域。xml<!-- 在 UserRoleMapper.xml 中添加 --> <cache-ref namespace="com.demo.mapper.UserMapper"/>这样,当
UserRoleMapper执行增删改时,会刷新UserMapper的缓存,从而保持一致性。
📄 第四回合:分页插件原理 ------ 拒绝内存溢出
📊 分页方式大比拼
| 类型 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 逻辑分页 | RowBounds (内存分页) |
简单,不依赖数据库方言 | 极慢且危险,先查出所有数据再截取,大数据量直接 OOM |
| 物理分页 | LIMIT / ROWNUM |
高性能,只查需要的数据 | 需要处理不同数据库的方言 (MySQL vs Oracle) |
🛠️ PageHelper 插件是如何工作的?
PageHelper 的核心原理是利用了 MyBatis 的 Interceptor (拦截器) 机制。
执行流程图解:
- 入口 :
PageHelper.startPage(1, 10)------ 将分页参数存入ThreadLocal(保证线程安全)。 - 拦截 :拦截
Executor.query()方法。 - 改写 :从
ThreadLocal取出参数,将原 SQL 改写为带LIMIT的物理分页 SQL。 - 统计 :自动生成并执行
COUNT(*)查询,获取总记录数。 - 封装 :将数据和总数封装成
PageInfo对象返回。
避坑指南 :
PageHelper依赖ThreadLocal,如果在startPage后执行了多个查询,后面的查询会被"污染"。最佳实践 是紧跟着startPage写唯一的查询语句,或者手动调用clearPage()。
🧙♂️ 第五回合:Mapper 映射原理 ------ 无中生有的实现类
🤔 灵魂拷问
"为什么我们的 Mapper 只是一个接口,没有任何实现类,却能直接注入并调用?"
⚙️ 底层真相:JDK 动态代理
MyBatis 在启动时,会扫描所有的 Mapper 接口,并利用 JDK 动态代理 为它们生成代理对象(Proxy)。
调用链路:
- 解析 :解析 XML 或注解,生成
MappedStatement对象,并存入Configuration。 - 代理 :当调用
sqlSession.getMapper(UserMapper.class)时,生成MapperProxy。 - 执行 :调用
userMapper.selectById(1)时,代理对象会根据 接口全限定名 + 方法名 拼接成statementId(如com.demo.mapper.UserMapper.selectById)。 - 映射 :根据
statementId从Configuration中找到对应的 SQL 和参数,执行数据库操作。
为什么 Mapper 接口不能重载方法?
因为 statementId 仅由 接口名 + 方法名 组成,不包含参数列表。如果重载,会导致多个方法对应同一个 statementId,从而引发冲突。
📝 总结与避坑指南
为了方便记忆,我为你整理了这份面试速记表:
| 核心考点 | 关键词 | 避坑点 |
|---|---|---|
| 参数占位符 | 预编译 vs 字符串替换 | 动态表名必须用 ${},但要防注入 |
| 一级缓存 | SqlSession 级别 | 增删改操作会自动清空缓存 |
| 二级缓存 | Namespace 级别 | 多表操作同数据时需用 <cache-ref> 解决脏读 |
| 分页插件 | 拦截器 + ThreadLocal | 避免 RowBounds 导致的内存溢出 |
| 动态代理 | JDK Proxy | 接口方法不能重载 |
📚 关注《卷毛的技术笔记》
👋 我是卷毛,一名热爱分享技术干货的后端工程师。
在这里,你将获得:
- 硬核实战:拒绝空谈,只讲生产环境能落地的架构方案。
- 避坑指南:我踩过的坑,帮你填平。
- 面试突击:大厂高频面试题深度解析。
关注我,带你少加班,多升职!