乐于分享
好东西不私藏

MyBatis 面试连环炮:从源码原理到实战避坑,彻底拿下 Offer 通关秘籍

MyBatis 面试连环炮:从源码原理到实战避坑,彻底拿下 Offer 通关秘籍

导读:在Java后端面试中,MyBatis 往往是面试官手中的一把“双刃剑”。初级开发者只会 CRUD,而高级开发者则需要深谙其缓存机制、插件原理和动态代理。本文将带你从面试现场的“连环追问”切入,结合核心源码与实战场景,助你彻底征服这一板块。

无论是初级工程师还是高级架构师,MyBatis 都是 Java 后端开发中绕不开的核心组件。面试官对它的考察早已不再局限于“怎么写 SQL”,而是深入到了 SQL 注入防护、缓存穿透/脏读、分页插件底层原理 等核心深度。

为了帮你“吊打”面试官,本文将从以下 5 个维度为你深度拆解:

  1. 1. #{} 与 ${} 的生死抉择 —— 防止 SQL 注入的底线
  2. 2. 动态 SQL 的优雅与陷阱 —— 什么时候该用 where,什么时候必须用 trim
  3. 3. 缓存失效的真相 —— 一级缓存与二级缓存的爱恨情仇
  4. 4. 分页插件的黑魔法 —— 物理分页 vs 逻辑分页的性能博弈
  5. 5. Mapper 接口的秘密 —— 为什么没有实现类也能运行?

🔍 第一回合:面试现场 —— #{} 与 ${} 的区别

🎯 面试官提问

“我们在写 SQL 时,#{} 和 ${} 到底有什么区别?什么时候必须用 ${}?”

💡 核心解析

这不仅仅是语法的区别,更是安全与性能的区别。

1. 核心对比表

对比维度
#{}

 (预编译占位符)
${}

 (字符串拼接)
处理机制 预编译 (Prepared Statement)

,先编译 SQL 模板,再传参数
字符串替换

,先拼接字符串,再编译执行
SQL 注入
✅ 安全,参数被当作值处理
❌ 极度危险,参数可能被当作 SQL 代码执行
执行性能
高,数据库可缓存执行计划 (Execution Plan)
低,每次 SQL 字符串都不同,无法缓存
类型转换
自动进行 Java Type -> JDBC Type 转换
无,直接拼接字符串
典型场景
绝大多数参数传递 (WHERE 条件)
动态表名、列名、ORDER BY 字段

2. 深度场景剖析:为什么有时候非得用 ${}

虽然 #{} 很安全,但在某些场景下它是“无能为力”的。

  • • 场景一:动态表名如果你的业务需要根据时间分表(如 user_2024user_2025),表名是 SQL 的结构部分,预编译占位符 ? 不允许出现在表名位置

    <!-- 必须使用  $ {} --><select id="findUserByTable" resultType="User">    SELECT * FROM  $ {tableName} WHERE id = #{id}</select>

    避坑指南:如果必须用 ${},请务必在 Java 代码层做白名单校验,绝对不能直接拼接用户输入!

  • • 场景二:动态排序 (ORDER BY)

    <!-- 动态按不同列排序 --><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,它会自动去除。
<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>

这是面试中考察并发和性能的常见点。

<!-- 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:

  1. 1. UserMapper:查询用户信息。
  2. 2. UserRoleMapper:负责给用户分配角色(更新操作)。

问题UserMapper 开启了二级缓存。当 UserRoleMapper 修改了用户的角色后,UserMapper 的缓存并没有失效!下次查询用户时,读到的还是旧的角色信息 —— 这就是脏读

解决方案

  1. 1. 方案 A (粗暴) :直接禁用二级缓存(推荐在分布式环境下使用 Redis 替代)。
  2. 2. 方案 B (优雅) :使用 <cache-ref> 标签,让两个 Mapper 引用同一个缓存区域。

    <!-- 在 UserRoleMapper.xml 中添加 --><cache-ref namespace="com.demo.mapper.UserMapper"/>

    这样,当 UserRoleMapper 执行增删改时,会刷新 UserMapper 的缓存,从而保持一致性。


📄 第四回合:分页插件原理 —— 拒绝内存溢出

📊 分页方式大比拼

类型
实现方式
优点
缺点
逻辑分页 RowBounds

 (内存分页)
简单,不依赖数据库方言
极慢且危险

,先查出所有数据再截取,大数据量直接 OOM
物理分页 LIMIT

 / ROWNUM
高性能

,只查需要的数据
需要处理不同数据库的方言 (MySQL vs Oracle)

🛠️ PageHelper 插件是如何工作的?

PageHelper 的核心原理是利用了 MyBatis 的 Interceptor (拦截器)  机制。

执行流程图解

  1. 1. 入口PageHelper.startPage(1, 10) —— 将分页参数存入 ThreadLocal(保证线程安全)。
  2. 2. 拦截:拦截 Executor.query() 方法。
  3. 3. 改写:从 ThreadLocal 取出参数,将原 SQL 改写为带 LIMIT 的物理分页 SQL。
  4. 4. 统计:自动生成并执行 COUNT(*) 查询,获取总记录数。
  5. 5. 封装:将数据和总数封装成 PageInfo 对象返回。

避坑指南PageHelper 依赖 ThreadLocal,如果在 startPage 后执行了多个查询,后面的查询会被“污染”。最佳实践是紧跟着 startPage 写唯一的查询语句,或者手动调用 clearPage()


🧙‍♂️ 第五回合:Mapper 映射原理 —— 无中生有的实现类

🤔 灵魂拷问

“为什么我们的 Mapper 只是一个接口,没有任何实现类,却能直接注入并调用?”

⚙️ 底层真相:JDK 动态代理

MyBatis 在启动时,会扫描所有的 Mapper 接口,并利用 JDK 动态代理 为它们生成代理对象(Proxy)。

调用链路

  1. 1. 解析:解析 XML 或注解,生成 MappedStatement 对象,并存入 Configuration
  2. 2. 代理:当调用 sqlSession.getMapper(UserMapper.class) 时,生成 MapperProxy
  3. 3. 执行:调用 userMapper.selectById(1) 时,代理对象会根据 接口全限定名 + 方法名 拼接成 statementId(如 com.demo.mapper.UserMapper.selectById)。
  4. 4. 映射:根据 statementId 从 Configuration 中找到对应的 SQL 和参数,执行数据库操作。

为什么 Mapper 接口不能重载方法?因为 statementId 仅由 接口名 + 方法名 组成,不包含参数列表。如果重载,会导致多个方法对应同一个 statementId,从而引发冲突。


📝 总结与避坑指南

为了方便记忆,我为你整理了这份面试速记表

核心考点
关键词
避坑点
参数占位符
预编译 vs 字符串替换
动态表名必须用 ${},但要防注入
一级缓存
SqlSession 级别
增删改操作会自动清空缓存
二级缓存
Namespace 级别
多表操作同数据时需用 <cache-ref> 解决脏读
分页插件
拦截器 + ThreadLocal
避免 RowBounds 导致的内存溢出
动态代理
JDK Proxy
接口方法不能重载

📚 关注《卷毛的技术笔记》

👋 我是卷毛,一名热爱分享技术干货的后端工程师。

在这里,你将获得:

  • • 硬核实战:拒绝空谈,只讲生产环境能落地的架构方案。
  • • 避坑指南:我踩过的坑,帮你填平。
  • • 面试突击:大厂高频面试题深度解析。

关注我,带你少加班,多升职!