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

0 阅读2分钟

导读:在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 的结构部分,预编译占位符 ? 不允许出现在表名位置

    <!-- 必须使用  $ {} -->
    <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>

  • 痛点:在拼接 ANDOR 时,很容易出现语法错误(如 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. UserMapper:查询用户信息。
  2. UserRoleMapper:负责给用户分配角色(更新操作)。

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

解决方案

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

  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. 入口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. 映射:根据 statementId 从 Configuration 中找到对应的 SQL 和参数,执行数据库操作。

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


📝 总结与避坑指南

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

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

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

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

在这里,你将获得:

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

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