☰
Mybatis 一连串提问,被面试官吊打了!2万字详解
2026/10/7 16:35:28 网站建设 项目流程

写在前面:为什么 MyBatis 总是面试重灾区

很多 Java 同学都有过这样的经历:项目里 MyBatis 用得很熟,增删改查写得飞快,结果一到面试,面试官从「#{} 和 ${} 有什么区别」一路追问到「一级缓存为什么会读到脏数据」「Mapper 接口没有实现类为什么能注入成功」,一环扣一环,最后直接被打穿。原因其实很简单:MyBatis 不是一个只会写 SQL 的封装层,它背后有一套完整的“配置解析—代理生成—执行器调度—结果映射—缓存管理”的链路。这篇长文把这条链路从头到尾拆开,按面试追问的逻辑串起来讲,覆盖基础认知、源码原理、缓存、动态 SQL、插件、延迟加载、Spring 整合和高频题,帮你把 MyBatis 建立成体系,而不是零散地背几个答案。

适用人群:准备初中高级 Java 面试、正在准备校招/社招八股、想系统补 MyBatis 原理的同学。

一、MyBatis 入门与整体认知

1.1 先回答最经典的问题:MyBatis 到底是什么

MyBatis 是一款半自动化的持久层框架,它帮助我们在 Java 对象和 SQL 之间建立映射关系。说它“半自动”,是因为它对 SQL 的控制权留给了开发者:我们可以自由编写 SQL、优化 SQL、调整索引策略,框架只负责参数绑定、结果集转换、连接管理和缓存等工作。与之相对,Hibernate 是全自动 ORM,它在多数场景下可以由框架自动生成 SQL,开发者对 SQL 的掌控力相对弱一些。

MyBatis 的优势可以概括为四点:

  • SQL 可控:复杂查询、报表统计、存储过程都能直接写 SQL,性能优化空间大。
  • 学习成本低:核心就是 XML 映射文件或注解,入门非常快。
  • 与 JDBC 兼容性好:底层仍然是 JDBC,因此可以灵活使用连接池、事务等 JDBC 能力。
  • 生态成熟:有 MyBatis-Spring、PageHelper、MyBatis-Plus、tk.mybatis 等大量配套组件。

1.2 与 Hibernate、JPA 的对比,要能说出取舍

面试官经常问「MyBatis 和 Hibernate 怎么选」,回答的重点不是“谁更好”,而是根据业务特征做取舍。

对比维度MyBatisHibernate
SQL 生成方式开发者手写 SQL大部分场景框架自动生成
自动程度半自动全自动
数据库可移植性弱,强依赖具体 SQL 方言强,可通过方言适配多种数据库
复杂查询性能调优灵活,直接改 SQL需要深入理解 HQL/Criteria/N+1 问题
学习成本低高,概念多、调优复杂
适用场景互联网高并发、报表、遗留系统、SQL 复杂多变以对象模型为中心、快速 CRUD 的业务系统

结论可以这样表达:互联网业务通常要求 SQL 精确可控、方便 DBA 优化,因此 MyBatis 更受欢迎;传统企业级系统中如果对象关系映射简单,Hibernate 能减少重复劳动。

1.3 MyBatis 核心组件清单

把下面这些组件记住以后,再看源码就不会迷路:

  • SqlSessionFactory:会话工厂,负责创建 SqlSession,整个应用通常一个实例就够。
  • SqlSession:一次数据库会话的入口,封装了增删改查、提交、回滚等操作。
  • Configuration:全局配置对象,保存 mybatis-config.xml 以及所有 Mapper 映射信息。
  • MappedStatement:封装一条 SQL 的完整信息,包括 id、参数类型、结果类型、SQL 语句、缓存配置等。
  • Executor:SQL 执行器,负责调度 StatementHandler、缓存、事务等。
  • StatementHandler:处理 JDBC Statement,完成参数设置和 SQL 执行。
  • ParameterHandler:把 Java 参数转换为 JDBC 参数。
  • ResultSetHandler:把 JDBC 结果集转换为 Java 对象或集合。
  • TypeHandler:在 Java 类型和 JDBC 类型之间转换。
  • MapperProxy:为 Mapper 接口生成 JDK 动态代理,把方法调用转交给 SqlSession。

这十个组件之间的关系,后面会随着执行流程逐步展开。

二、MyBatis 核心源码流程:面试官从这里开始挖

2.1 从加载配置到创建 SqlSessionFactory

MyBatis 的使用入口是SqlSessionFactoryBuilder#build,它会读取核心配置文件,最终得到一个SqlSessionFactory。核心代码路径如下:

InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml"); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

build方法内部主要做了三件事:

  1. 用XMLConfigBuilder解析 mybatis-config.xml,构建出Configuration对象。
  2. 通过Configuration扫描并解析所有 Mapper XML 或注解,注册MappedStatement。
  3. 用Configuration创建DefaultSqlSessionFactory。

mybatis-config.xml 中的environments、settings、typeAliases、mappers等配置,最后都落到Configuration对象中。也就是说,Configuration 是 MyBatis 的“大管家”,一个应用生命周期内通常只创建一个,因为它内部包含大量 Map,创建成本较高。

2.2 Mapper 映射文件是怎么被解析的

在XMLConfigBuilder解析到<mappers>节点时,会通过XMLMapperBuilder逐个解析 Mapper XML。每个 Mapper XML 里的一条 SQL 节点,例如:

<select id="selectById" parameterType="long" resultType="com.example.User"> select id, username, age from user where id = #{id} </select>

会被解析成一个MappedStatement,其中包含:

  • id:命名空间 + SQL id,即com.example.UserMapper.selectById。
  • sqlCommandType:表示它是 SELECT。
  • parameterMap/resultMap:入参和出参映射信息。
  • SqlSource:原始 SQL 经过处理后得到的可执行 SQL 源,动态 SQL 和静态 SQL 处理方式不同。
  • 缓存配置:是否使用二级缓存、flushCache、useCache 等。

最后这些MappedStatement会被放入Configuration.mappedStatements这个 Map 中,key 就是全限定 SQL id。后续执行 SQL 时,都是先根据 id 拿到MappedStatement,再交给 Executor 执行。

2.3 Mapper 接口没有实现类为什么能调用

这是面试高频题。我们在 Service 里直接注入UserMapper,而这个接口没有任何实现类,为什么还能执行 SQL?答案是JDK 动态代理 + MapperProxy。

当调用sqlSession.getMapper(UserMapper.class)或 Spring 扫描 Mapper 接口时,MyBatis 会通过MapperProxyFactory为该接口创建代理对象:

UserMapper mapper = sqlSession.getMapper(UserMapper.class); User user = mapper.selectById(1L);

而真正干活的是MapperProxy,它实现了InvocationHandler接口。每次调用 Mapper 方法,都会进入MapperProxy#invoke,大致流程为:

  1. 如果不是Object类的方法(如toString、hashCode),就继续往下走。
  2. 根据接口全限定名 + 方法名找到对应的MappedStatement。
  3. 将方法参数与 SQL 绑定,最终委派给SqlSession执行selectOne、selectList、insert、update、delete等方法。
  4. 将执行结果返回给调用方。

所以,Mapper 接口能工作的本质是:接口方法名 + 参数 + 返回类型与 MappedStatement 的约定一致,Method 调用被代理成数据库操作。也正因如此,Mapper 接口方法不允许重载,因为映射 key 由「接口全限定名.方法名」构成,重载会导致 key 冲突;同时,返回类型如果和实际查询结果不一致,会在结果映射阶段抛异常。

2.4 SqlSession 执行 SQL 的完整链路

以selectById为例,完整链路如下:

// 1. 调用 Mapper 接口方法 mapper.selectById(1L) // 2. 进入 MapperProxy MapperProxy.invoke() // 3. 委派给 SqlSession.selectOne DefaultSqlSession.selectOne() // 4. 从 Configuration 中取出 MappedStatement configuration.getMappedStatement("com.example.UserMapper.selectById") // 5. 交给 Executor 执行 executor.query(ms, parameter, rowBounds, resultHandler) // 6. Executor 调度 StatementHandler 完成 JDBC 操作 statementHandler.query(stmt, resultHandler) // 7. ResultSetHandler 处理结果集,转换为 User 对象 resultSetHandler.handleResultSets(stmt)

这条链路上有三个关键角色需要重点理解:Executor、StatementHandler和ResultSetHandler,面试官很容易顺着往下问。

2.5 Executor 执行器的三种类型以及装饰器

Executor 是 MyBatis 执行 SQL 的核心接口,直接负责调度执行、缓存、事务。它有三种基础实现:

  • SimpleExecutor:默认执行器,每次执行 SQL 都会预编译一个新的 Statement。
  • ReuseExecutor:可复用执行器,会缓存 Statement,相同 SQL 能重复使用预编译结果。
  • BatchExecutor:批处理执行器,用于批量更新场景,需要手工 flush 提交。

除了基础实现,MyBatis 还使用了装饰器模式强化 Executor 能力:

  • CachingExecutor:在二级缓存开启时,包装基础 Executor,优先查缓存。
  • BaseExecutor:基础执行器抽象类,内部实现了一级缓存、事务、Statement 生命周期管理。

这里的面试考点是:一级缓存住在 BaseExecutor 里,二级缓存住在 CachingExecutor 里。所以二级缓存开启时,执行器结构是:CachingExecutor(负责二级缓存)→ 包裹SimpleExecutor/ReuseExecutor/BatchExecutor(继承 BaseExecutor,负责一级缓存和真正的 JDBC 执行)。

2.6 StatementHandler 与四大组件的关系

Executor 最终会调用StatementHandler执行数据库操作。StatementHandler 有三种实现,对应 JDBC 的三种 Statement:

  • SimpleStatementHandler:对应Statement,不支持占位符。
  • PreparedStatementHandler:对应PreparedStatement,支持#{}` 参数预编译,是默认实现。
  • CallableStatementHandler:对应CallableStatement,用于调用存储过程。

StatementHandler 内部依赖另外三个组件:

  • ParameterHandler:负责把 Java 参数设置到PreparedStatement上。
  • ResultSetHandler:负责把数据库返回的 ResultSet 映射为 Java 对象。
  • TypeHandler:负责 Java 类型与 JDBC 类型之间的双向转换。

如果面试官继续问TypeHandler,可以举两个典型场景:枚举类型存储、Json 字符串与对象互转。自定义 TypeHandler 只需要实现TypeHandler接口,并在配置文件或注解中注册即可。

三、参数处理、#{} 与 ${}、SQL 注入

3.1 重点: #{} 和 ${} 的区别

这几乎是 MyBatis 面试逃不掉的题目。回答时要抓住三个层次:预编译、SQL 注入风险、使用场景。

  • #{} 是预编译参数占位符。MyBatis 会把 SQL 中的#{}` 替换成 ?,然后把参数通过 PreparedStatement 的 setXxx 方法安全地绑定进去。数据库会先解析、编译 SQL,再传入参数值,因此参数不会改变 SQL 结构,能有效防止 SQL 注入。
  • ${} 是字符串替换。MyBatis 会在 SQL 生成阶段直接把变量值拼接进 SQL 字符串中,相当于字符串拼接。因为它发生在 SQL 编译之前,恶意内容可以直接改变 SQL 结构,存在 SQL 注入风险。

举两个例子。假设表名需要动态传入,必须使用${},因为表名和列名不能通过占位符预编译:

<select id="selectByTable" resultType="map"> select * from ${tableName} where id = #{id} </select>

而普通参数应该坚决使用#{}`:

<select id="selectByName" resultType="com.example.User"> select * from user where username = #{username} </select>

如果用户传入username = "admin' or '1'='1",使用#{}时会被当作普通字符串查询,不会影响 SQL 结构;使用${}时,SQL 会被拼成where username = 'admin' or '1'='1',就会查到不该查的数据。

3.2 什么时候必须用 ${},以及如何安全使用

合法使用${}的场景主要有:

  • 动态表名:如按月分表的order_202501。
  • 动态字段名:如排序字段、分组字段。
  • 动态排序方式:如order by ${column} ${direction}。

在这些场景下,虽然无法完全使用#{},但必须做白名单校验,不能让用户输入直接进入${}。

四、缓存机制:一级缓存为什么会有脏数据

4.1 一级缓存的作用范围与失效条件

MyBatis 的一级缓存是SqlSession级别的本地缓存,默认开启。在同一个SqlSession中,对相同 SQL 和相同参数执行查询时,第二次不会再查数据库,而是直接返回缓存结果。

失效条件主要有:

  • 执行了insert/update/delete等修改操作,MyBatis 会清空一级缓存。
  • SqlSession提交或回滚时会清空一级缓存。
  • 查询时设置flushCache=true。
  • 缓存作用域是SqlSession,关闭后缓存消失。

4.2 一级缓存为什么会读到脏数据

经典脏数据场景出现在两个SqlSession操作同一张表时:

UserMapper mapper1 = session1.getMapper(UserMapper.class); UserMapper mapper2 = session2.getMapper(UserMapper.class); User u1 = mapper1.selectById(1L); // 查库,并放入 session1 一级缓存 mapper2.updateAge(1L, 30); // session2 更新 age=30,但 session1 不知道 User u2 = mapper1.selectById(1L); // session1 命中一级缓存,读到旧 age

这是面试常问的“一级缓存脏数据”问题:一级缓存只在同一个 SqlSession 内生效,无法感知其他会话的提交。解决办法通常是把查询和更新放在同一个 SqlSession 中,或者业务上不要依赖一级缓存,甚至通过flushCache立即清空。

4.3 二级缓存的工作机制与使用建议

二级缓存是Mapper命名空间级别的缓存,需要显式开启:在核心配置里设置cacheEnabled=true,并在 Mapper XML 中添加<cache/>。执行器结构会变成CachingExecutor包裹基础 Executor,先查二级缓存,再查一级缓存,最后查库。

使用二级缓存时要注意:

  • 只有SqlSession提交或关闭后,一级缓存才会同步到二级缓存。
  • 查询结果必须可序列化,否则缓存写入可能失败。
  • 多表关联查询时,一个命名空间更新、另一个命名空间缓存未失效,容易读到陈旧数据,生产环境要谨慎开启。

五、动态 SQL:if/where/foreach 的原理与易错点

5.1 动态 SQL 的执行原理

MyBatis 在解析 Mapper XML 时,会把动态 SQL 节点解析为SqlNode树。运行期根据参数对象动态拼接 SQL,拼完后再交给 JDBC 执行。常见标签有<if>、<where>、<set>、<foreach>、<choose>、<trim>。

5.2 常见标签与易错点

示例:

<select id="selectByCondition" resultType="com.example.User"> select id, username, age from user <where> <if test="username != null and username != ''"> and username = #{username} </if> <if test="age != null"> and age = #{age} </if> </where> </select>

易错点:

  • <if>里不要用参数名以外的变量:test表达式中的对象通常是参数对象属性,复杂判断建议用_parameter或@Param限定。
  • <where>能自动去掉开头的 AND/OR,但多个条件都为空时不会生成 where,避免where关键字残留。
  • <foreach>注意参数名:数组默认array,List 默认list,接口用@Param("ids")后应写collection="ids"。
<select id="selectByIds" resultType="com.example.User"> select id, username from user where id in <foreach collection="list" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>

六、插件机制:MyBatis 插件如何做分页与慢 SQL 监控

6.1 插件原理:拦截器与责任链

MyBatis 插件基于Interceptor接口和 JDK 动态代理实现,可以拦截Executor、ParameterHandler、ResultSetHandler、StatementHandler四类组件。插件通过@Intercepts注解声明要拦截的类型和方法,在初始化阶段包装目标对象,形成类似责任链的调用链。

6.2 简单慢 SQL 插件示例

@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) public class SlowSqlInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { long start = System.currentTimeMillis(); Object result = invocation.proceed(); long cost = System.currentTimeMillis() - start; if (cost > 1000) { System.out.println("慢 SQL 耗时: " + cost + "ms"); } return result; } }

注意StatementHandler可能需要从MetaObject中取出真实目标对象,多个插件包装后要处理代理嵌套问题。

七、延迟加载:为什么有时查到的是空对象或代理对象

7.1 延迟加载触发条件

MyBatis 延迟加载默认关闭,需要在配置中开启:lazyLoadingEnabled=true、aggressiveLazyLoading=false。当查询主对象时,关联属性不会立即加载,而是在真正调用该属性的 getter 时才触发第二次 SQL。其本质是ResultSetHandler用 CGLIB/Javassist 为关联对象生成代理。

7.2 延迟加载的坑

  • 必须配置 aggressiveLazyLoading=false,否则所有懒加载属性都会在首次访问任意属性时被加载。
  • 延迟加载依赖 SqlSession 存活,如果 SqlSession 已关闭,再访问懒加载属性会抛异常。
  • 序列化问题:代理对象默认不能被 Jackson 序列化,需要 DTO 转换或关闭延迟加载。

八、Spring 整合 MyBatis:Mapper 扫描与事务

8.1 Spring 如何扫描 Mapper 接口

Spring 整合 MyBatis 时不使用sqlSession.getMapper手动获取,而是通过MapperScannerConfigurer或@MapperScan扫描指定包路径,为每个 Mapper 接口注册MapperFactoryBean。Spring 会创建 Mapper 接口的代理对象并注入到 Service 中,代理内部仍然调用 MyBatis 的SqlSession。

@Configuration @MapperScan("com.example.mapper") public class MyBatisConfig { }

8.2 事务管理与 SqlSession 生命周期

在 Spring 托管事务下,每个SqlSession与 Spring 事务的Connection绑定,同一事务内多个 Mapper 操作会复用同一个SqlSession。这就是为什么 Spring 中经常不会再遇到一级缓存脏数据:同一个事务内共享同一 SqlSession。注意@Transactional声明式事务由 Spring 管理,MyBatis 自身不负责事务提交和回滚。

九、MyBatis 高频面试题速查与总结

9.1 高频题速查表

题目回答要点
MyBatis 和 Hibernate 怎么选半自动 vs 全自动,SQL 可控性,业务取舍
Mapper 接口没有实现类为什么能调用JDK 动态代理 + MapperProxy
#{} 和 ${} 的区别预编译占位 vs 字符串替换,SQL 注入
一级缓存和二级缓存作用域、脏数据、开启条件
Executor 有哪几种Simple/Reuse/Batch + CachingExecutor
插件原理动态代理 + 拦截器 + 责任链
延迟加载原理代理对象 + 懒查询
如何防止 SQL 注入尽量 #{},必须 ${} 时白名单校验

9.2 总结

MyBatis 面试也遵循梯度:先问#{} 和 ${},再问Mapper 代理,随后往执行链路、缓存、插件、延迟加载深入。建议把配置文件解析、SqlSessionFactory 到 MappedStatement 这条主线默画一遍,再结合上述章节补齐每个延申专题,就能从“会写 CRUD”升级到“能讲清原理”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询