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

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

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

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

  1. #{}${} 的生死抉择 ------ 防止 SQL 注入的底线
  2. 动态 SQL 的优雅与陷阱 ------ 什么时候该用 where,什么时候必须用 trim
  3. 缓存失效的真相 ------ 一级缓存与二级缓存的爱恨情仇
  4. 分页插件的黑魔法 ------ 物理分页 vs 逻辑分页的性能博弈
  5. 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>

  • 痛点 :在拼接 ANDOR 时,很容易出现语法错误(如 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:

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

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

解决方案

  1. 方案 A (粗暴) :直接禁用二级缓存(推荐在分布式环境下使用 Redis 替代)。

  2. 方案 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 (拦截器) 机制。

执行流程图解

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

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


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

🤔 灵魂拷问

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

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

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

调用链路

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

为什么 Mapper 接口不能重载方法?

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


📝 总结与避坑指南

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

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

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

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

在这里,你将获得:

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

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

相关推荐
Java编程爱好者2 小时前
深入浅出 Java volatile:从硬件到 JMM 的完整剖析
后端
程序员cxuan2 小时前
36 张图彻底解释清楚 AI 圈 136 个造词艺术!!!
人工智能·后端·github copilot
over6972 小时前
面试官视角:TypeScript Pick 工具类型深度解析与手写实现
前端·面试
我还不赖2 小时前
「概念激活」提示词工程的技术原理:为什么一个名字比一万字描述更有效
后端
神奇小汤圆2 小时前
Java 的金额计算用 long 还是 BigDecimal?资深程序员这样选
后端
程序员柒叔2 小时前
OpenClaw Agent 运行时模块分析
后端·github
San302 小时前
从浏览器到 Node.js,这一次彻底搞懂 Event Loop 与异步模型
面试·node.js·浏览器
咸鱼翻身了么2 小时前
大文件上传-spark-md5
前端·后端