MyBatis 面试八股文 25 道|深度详解版(傻子都能看懂)

本篇是 MyBatis 面试八股文深度版,25 道高频题全覆盖,每题都有代码示例,帮你彻底搞懂原理,面试不再背答案。


Q1. MyBatis 是什么?和 Hibernate 的本质区别(半自动 vs 全自动 ORM)

MyBatis 是什么?

MyBatis 是一款优秀的持久层框架,它支持自定义 SQL、存储过程以及高级映射。MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作,通过简单的 XML 或注解来配置和映射原始类型、接口和 Java POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。

半自动 vs 全自动 ORM:核心区别

对比维度 MyBatis(半自动) Hibernate(全自动)
SQL 控制权 开发者自己写 SQL 框架自动生成 SQL
学习成本 低,会 SQL 就能上手 高,HQL/Criteria/缓存策略
灵活性 极高,SQL 随心所欲 较低,复杂查询需绕道
数据库移植性 差(SQL 与数据库方言绑定) 好(HQL 屏蔽方言差异)
适用场景 互联网高并发、复杂业务查询 快速开发、CRUD 简单场景

一句话记忆:

  • MyBatis:我告诉你 SQL,你帮我执行并把结果映射成对象。
  • Hibernate:你告诉我对象关系,我来决定怎么查数据库。

国内互联网公司(阿里、美团、字节)几乎清一色选 MyBatis,原因就是 SQL 可控、性能可调、DBA 可以 review。


Q2. MyBatis 整体架构(SqlSessionFactory → SqlSession → Executor → StatementHandler → ResultSetHandler)

MyBatis 的架构分为三层,从上到下依次是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户代码(Mapper 接口调用)
↓
SqlSessionFactory(工厂,单例)
↓
SqlSession(会话,线程不安全,用完即关)
↓
Executor(执行器:SimpleExecutor/ReuseExecutor/BatchExecutor)
↓
StatementHandler(语句处理器,封装 JDBC Statement)
↓
ParameterHandler(参数处理器,设置 SQL 参数)
↓
ResultSetHandler(结果集处理器,映射 ResultSet → Java 对象)
↓
JDBC(最终调用 java.sql.*)

各组件职责:

  • Configuration:全局配置对象,解析 mybatis-config.xml 和所有 Mapper XML,存储所有映射信息。
  • SqlSessionFactory:创建 SqlSession 的工厂,应用启动时创建一次,全局单例。
  • SqlSession:数据库会话,提供 CRUD 方法,内部持有 Executor。
  • Executor:真正执行 SQL 的地方,分三种:Simple(默认)、Reuse(复用 Statement)、Batch(批量)。
  • StatementHandler:对 JDBC PreparedStatement 的封装,负责 SQL 预编译和参数绑定。
  • ResultSetHandler:把 JDBC ResultSet 映射成 Java 对象,是 <resultMap> 发挥作用的地方。

插件的切入点正是这四个处理器接口:Executor、StatementHandler、ParameterHandler、ResultSetHandler。


Q3. SqlSessionFactory 的创建流程(Configuration 解析,单例重要性)

创建流程分三步:

第一步:读取配置

1
2
3
// 从 classpath 读取主配置文件
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);

第二步:构建 SqlSessionFactory

1
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(inputStream);

SqlSessionFactoryBuilder.build() 内部会:

  1. 创建 XMLConfigBuilder,解析 mybatis-config.xml
  2. 解析 <environments>、<typeAliases>、<plugins>、<mappers> 等标签
  3. 扫描每个 Mapper XML,将 <select>/<insert>/<update>/<delete> 解析为 MappedStatement 对象
  4. 所有解析结果存入 Configuration 对象
  5. 用 Configuration 创建 DefaultSqlSessionFactory

第三步:为什么必须是单例?

SqlSessionFactory 创建时解析了所有 XML 和注解,构建了完整的 Configuration,这个过程耗时且耗内存。如果每次请求都创建一个新的工厂,系统会崩溃。

正确用法:

1
2
3
4
5
6
7
8
9
10
11
// Spring 环境下,SqlSessionFactory 由 Spring 容器管理,自动单例
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setMapperLocations(
new PathMatchingResourcePatternResolver()
.getResources("classpath*:mapper/**/*.xml")
);
return factoryBean.getObject();
}

Q4. SqlSession 的生命周期和线程安全问题(为什么不能是单例)

SqlSession 的生命周期:

1
开启事务/会话 → 执行 SQL → 提交/回滚 → 关闭

标准用法(非 Spring 环境):

1
2
3
4
5
try (SqlSession session = factory.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class);
User user = mapper.selectById(1L);
session.commit();
} // 自动 close

为什么不能是单例?

SqlSession 内部持有:

  1. 数据库连接(Connection):每个线程应该有独立的连接
  2. 一级缓存(Local Cache):HashMap,线程 A 的缓存不能被线程 B 读到
  3. 未提交事务:事务状态不能在线程间共享

如果把 SqlSession 做成单例,多线程并发时会出现:

  • 数据库连接竞争
  • 脏读(线程 A 读到线程 B 还未提交的缓存)
  • 事务混乱

Spring 如何解决这个问题?

Spring 通过 SqlSessionTemplate 解决了线程安全问题。SqlSessionTemplate 内部使用 ThreadLocal 绑定 SqlSession,每个线程有自己独立的会话,用完自动关闭。这就是为什么在 Spring 项目里,@Autowired 注入的 Mapper 可以放心当单例使用。


Q5. #{} 和 ${} 的区别(PreparedStatement vs Statement,SQL 注入风险,使用场景)

这是 MyBatis 最高频的面试题,必须答得清晰。

本质区别:

特性 #{} ${}
底层实现 PreparedStatement(预编译) Statement(字符串拼接)
SQL 注入 安全,参数作为占位符 ? 危险,直接字符串替换
性能 较高(数据库可缓存执行计划) 较低(每次重新编译)
使用场景 传递参数值 传递列名、表名、ORDER BY 字段

#{} 示例(安全):

1
2
3
<select id="selectByName" resultType="User">
SELECT * FROM user WHERE name = #{name}
</select>

实际执行的 JDBC:

1
2
3
4
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM user WHERE name = ?"
);
ps.setString(1, name); // 参数单独传入,不参与 SQL 解析

即使 name = "'; DROP TABLE user; --",也只会查名字是这个字符串的用户,不会执行恶意 SQL。

${} 示例(有注入风险):

1
2
3
4
<!-- 动态表名,只能用 ${} -->
<select id="selectFromTable" resultType="map">
SELECT * FROM ${tableName} WHERE id = #{id}
</select>
1
2
3
4
<!-- 动态排序字段,只能用 ${} -->
<select id="selectWithOrder" resultType="User">
SELECT * FROM user ORDER BY ${orderField} ${orderType}
</select>

使用 ${} 的注意事项: 传入的值必须来自受信任的来源(如枚举、白名单校验),绝不能直接接受用户输入。


Q6. Mapper 接口与 XML 的绑定原理(MapperProxy 动态代理)

你写了一个接口方法:

1
2
3
public interface UserMapper {
User selectById(Long id);
}

为什么调用 userMapper.selectById(1L) 就能执行 SQL?你从来没有写过实现类啊!

答案:JDK 动态代理 + MapperProxy

绑定流程:

  1. 注册阶段:MyBatis 启动时,扫描到 UserMapper 接口,将其注册到 MapperRegistry,与对应的 XML namespace 绑定。

  2. 获取 Mapper:调用 session.getMapper(UserMapper.class) 时,MapperRegistry 找到对应的 MapperProxyFactory,用 JDK 动态代理创建一个 UserMapper 的代理对象。

  3. 执行代理逻辑:调用代理对象的 selectById(1L) 时,实际执行的是 MapperProxy.invoke() 方法。

1
2
3
4
5
6
7
8
9
10
11
// MapperProxy 核心逻辑(简化)
public class MapperProxy<T> implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 根据接口类名 + 方法名,找到对应的 MappedStatement
// 例如:com.example.UserMapper.selectById
MapperMethod mapperMethod = cachedMapperMethod(method);
// 委托给 SqlSession 执行
return mapperMethod.execute(sqlSession, args);
}
}
  1. SQL 查找:mapperMethod.execute() 根据 接口全限定名.方法名(如 com.example.UserMapper.selectById)从 Configuration 中找到对应的 MappedStatement(即 XML 中的 <select id="selectById">),然后执行。

Namespace 必须与接口全限定名一致,就是这个原因。


Q7. MyBatis 执行一条 select 语句的完整流程(从 mapper.selectById() 到 JDBC)

面试官最爱问的流程题,把这个说清楚,至少 80 分。

1
2
3
4
5
6
7
8
9
10
11
12
13
mapper.selectById(1L)
↓ MapperProxy.invoke()
↓ MapperMethod.execute() → 判断是 SELECT,调用 sqlSession.selectOne()
↓ DefaultSqlSession.selectOne()
↓ executor.query(ms, parameter, rowBounds, handler)
↓ [查一级缓存] → 命中则直接返回
↓ executor.queryFromDatabase()
↓ StatementHandler.prepare() → conn.prepareStatement(sql)
↓ ParameterHandler.setParameters() → ps.setLong(1, 1L)
↓ StatementHandler.query() → ps.executeQuery()
↓ ResultSetHandler.handleResultSets() → 把 ResultSet 映射成 User 对象
↓ [写入一级缓存]
↓ 返回 User 对象

关键节点说明:

  1. 一级缓存检查:在真正查数据库之前,先查本次 SqlSession 的缓存(一个 HashMap),key 是 statementId + 参数 + 分页范围 + SQL。

  2. SQL 预编译:StatementHandler.prepare() 调用 connection.prepareStatement(boundSql.getSql()),得到 PreparedStatement。

  3. 参数绑定:ParameterHandler.setParameters() 遍历参数,调用 TypeHandler 把 Java 类型转换成 JDBC 类型并设置到 PreparedStatement。

  4. 结果映射:ResultSetHandler 遍历 ResultSet,根据 <resultMap> 或字段名映射规则,用反射把每一行转成 Java 对象。


Q8. 一级缓存(SqlSession 级别):原理、失效条件、坑点(Spring 集成后默认失效)

一级缓存的原理:

一级缓存是 SqlSession 级别的缓存,每个 SqlSession 内部有一个 PerpetualCache(本质是 HashMap)。同一个 SqlSession 执行相同的 SQL 且参数相同,第二次直接从 HashMap 读取,不再查数据库。

1
2
3
4
5
6
// 同一 SqlSession,第二次查询走缓存
try (SqlSession session = factory.openSession()) {
User u1 = session.selectOne("selectById", 1L); // 查数据库
User u2 = session.selectOne("selectById", 1L); // 走一级缓存
System.out.println(u1 == u2); // true,同一个对象!
}

一级缓存的失效条件:

  1. SqlSession 关闭(缓存随之销毁)
  2. 调用了 session.clearCache()
  3. 执行了 INSERT / UPDATE / DELETE 操作(会清空整个 SqlSession 的缓存)
  4. <select> 标签配置了 flushCache="true"

最大的坑:Spring 集成后一级缓存几乎失效!

Spring 的事务管理下,每次 Mapper 方法调用都可能使用不同的 SqlSession(因为 SqlSessionTemplate 在没有事务的情况下,每次调用完就关闭 SqlSession)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Service
public class UserService {
@Autowired
private UserMapper userMapper;

public void test() {
// 没有 @Transactional 注解!
User u1 = userMapper.selectById(1L); // 用完关闭 SqlSession A
User u2 = userMapper.selectById(1L); // 新建 SqlSession B,一级缓存无效!
System.out.println(u1 == u2); // false
}

@Transactional
public void testWithTx() {
// 有事务,整个方法共用同一个 SqlSession
User u1 = userMapper.selectById(1L); // 查数据库
User u2 = userMapper.selectById(1L); // 走一级缓存
System.out.println(u1 == u2); // true
}
}

结论:不加 @Transactional 的情况下,MyBatis 一级缓存在 Spring 中基本不生效。


Q9. 二级缓存(Mapper/namespace 级别):开启方式、Cache 实现、与 Spring 集成的注意事项

二级缓存是什么?

二级缓存是跨 SqlSession 的缓存,作用域是 Mapper(namespace),同一个 Mapper 的不同 SqlSession 可以共享缓存。

开启方式:

第一步,全局配置开启(默认已开启):

1
2
3
4
<!-- mybatis-config.xml -->
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>

第二步,在具体的 Mapper XML 中声明使用缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- UserMapper.xml -->
<mapper namespace="com.example.UserMapper">
<!-- 开启二级缓存,使用默认的 PerpetualCache -->
<cache
eviction="LRU"
flushInterval="60000"
size="512"
readOnly="true"/>

<select id="selectById" resultType="User" useCache="true">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>

缓存实现(装饰器模式):

MyBatis 二级缓存通过装饰器模式层层包装:

1
2
3
4
5
SynchronizedCache(线程安全)
→ LoggingCache(命中率日志)
→ SerializedCache(序列化,要求实体实现 Serializable)
→ LruCache(LRU 淘汰)
→ PerpetualCache(实际存储,HashMap)

与 Spring 集成的注意事项:

Spring 的事务管理和 MyBatis 二级缓存有冲突:事务提交前,数据不会写入二级缓存(防止脏读)。这意味着在同一事务内,二级缓存不生效,必须等事务提交后才能被其他 SqlSession 读到。


Q10. 为什么 MyBatis 默认关闭二级缓存?(脏读风险,多表关联场景)

虽然 cacheEnabled 默认是 true,但每个 Mapper XML 默认不声明 <cache>,所以实际上二级缓存默认不生效。原因如下:

原因一:脏读风险

二级缓存是 namespace 级别的。如果 UserMapper 开了二级缓存,但另一个 OrderMapper 修改了 user 表(比如在 order 中记录了用户姓名),UserMapper 的缓存不会失效,导致读到过期数据。

1
2
3
4
OrderMapper.updateUserNameInOrder(userId, "新名字")
→ user 表被修改
→ 但 UserMapper 的二级缓存不知道!
→ UserMapper.selectById(userId) → 返回旧名字(脏读)

原因二:多表关联场景复杂

实际业务中,一条 SQL 可能 JOIN 了多张表,如果这几张表各自有不同的 Mapper,任何一个表变更都可能导致缓存失效,但 MyBatis 的二级缓存无法跨 namespace 联动失效。

最佳实践:

  • 生产环境不推荐使用 MyBatis 二级缓存
  • 需要缓存时,使用 Redis 等独立缓存方案,业务代码自行控制失效逻辑
  • 只在数据几乎不变、查询频繁的场景(如字典表、配置表)中谨慎使用

Q11. 插件(Interceptor)机制:拦截点、实现方式、常见场景

拦截点:

MyBatis 插件只能拦截以下四个接口的方法:

接口 常用拦截方法 典型用途
Executor query, update, commit 数据权限过滤、读写分离
StatementHandler prepare, parameterize SQL 改写(分页)
ParameterHandler setParameters 参数加密
ResultSetHandler handleResultSets 结果脱敏

实现一个插件(慢 SQL 监控):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Intercepts({
@Signature(
type = StatementHandler.class,
method = "query",
args = {Statement.class, ResultHandler.class}
)
})
public class SlowSqlPlugin implements Interceptor {

private static final long SLOW_THRESHOLD_MS = 1000L;

@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed(); // 执行原始方法
} finally {
long cost = System.currentTimeMillis() - start;
if (cost > SLOW_THRESHOLD_MS) {
StatementHandler handler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = handler.getBoundSql();
log.warn("慢SQL [{}ms]: {}", cost, boundSql.getSql());
}
}
}

@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this); // 用 JDK 动态代理包装目标对象
}
}

注册插件:

1
2
3
4
<!-- mybatis-config.xml -->
<plugins>
<plugin interceptor="com.example.SlowSqlPlugin"/>
</plugins>

插件的底层原理:

MyBatis 在创建 Executor、StatementHandler 等对象后,会依次调用所有插件的 plugin() 方法,层层用动态代理包装,形成代理链。调用时从最外层代理依次执行,类似责任链模式。


Q12. PageHelper 分页插件的原理(ThreadLocal + SQL 改写)

使用方式(极简):

1
2
3
4
5
// 设置分页参数
PageHelper.startPage(1, 10);
// 紧接着的第一条查询会被分页
List<User> users = userMapper.selectAll();
PageInfo<User> pageInfo = new PageInfo<>(users);

原理:

第一步:ThreadLocal 存储分页参数

PageHelper.startPage(1, 10) 把分页参数存入 ThreadLocal<Page>,当前线程可以读到。

第二步:拦截 StatementHandler.prepare() 改写 SQL

PageHelper 实现了 MyBatis Interceptor 接口,拦截 StatementHandler.prepare()。在这个阶段,从 ThreadLocal 读取分页参数,对原始 SQL 进行改写:

1
2
3
4
5
6
7
8
-- 原始 SQL
SELECT * FROM user WHERE status = 1

-- MySQL 改写后
SELECT * FROM user WHERE status = 1 LIMIT 0, 10

-- 同时额外执行 COUNT 查询
SELECT COUNT(0) FROM (SELECT * FROM user WHERE status = 1) tmp_count

第三步:清除 ThreadLocal

分页查询完成后,清除 ThreadLocal 中的分页参数,防止内存泄漏。

一个常见的坑:

1
2
3
4
5
6
7
// 错误!startPage 和查询之间不能有其他逻辑
PageHelper.startPage(1, 10);
if (condition) {
// 如果 condition 为 false,查询不执行,ThreadLocal 没清理
// 下一次查询会意外带上分页参数!
}
List<User> users = userMapper.selectAll();

正确姿势:startPage 必须紧贴查询语句。


Q13. 动态 SQL:<if>, <choose>, <foreach>, <trim>, <where>, <set> 的使用场景

动态 SQL 是 MyBatis 最强大的特性之一。

<if>:条件判断

1
2
3
4
5
6
7
8
9
10
<select id="selectByCondition" resultType="User">
SELECT * FROM user
WHERE 1=1
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
</select>

<where>:智能去除多余的 AND/OR(推荐替代 WHERE 1=1)

1
2
3
4
5
6
7
8
<select id="selectByCondition" resultType="User">
SELECT * FROM user
<where>
<if test="name != null">AND name = #{name}</if>
<if test="status != null">AND status = #{status}</if>
</where>
</select>
<!-- <where> 会在有子句时插入 WHERE,并去掉第一个 AND/OR -->

<set>:UPDATE 场景,去除多余的逗号

1
2
3
4
5
6
7
8
9
10
<update id="updateSelective">
UPDATE user
<set>
<if test="name != null">name = #{name},</if>
<if test="email != null">email = #{email},</if>
<if test="status != null">status = #{status},</if>
</set>
WHERE id = #{id}
</update>
<!-- <set> 会去掉最后一个逗号 -->

<choose>/<when>/<otherwise>:相当于 switch-case

1
2
3
4
5
6
7
8
<select id="selectByPriority" resultType="User">
SELECT * FROM user WHERE
<choose>
<when test="id != null">id = #{id}</when>
<when test="email != null">email = #{email}</when>
<otherwise>status = 1</otherwise>
</choose>
</select>

<foreach>:IN 查询、批量插入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- IN 查询 -->
<select id="selectByIds" resultType="User">
SELECT * FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>

<!-- 批量插入 -->
<insert id="batchInsert">
INSERT INTO user (name, email) VALUES
<foreach collection="users" item="u" separator=",">
(#{u.name}, #{u.email})
</foreach>
</insert>

<trim>:通用版 <where> 和 <set>

1
2
3
4
5
6
7
8
<!-- 等价于 <where> -->
<trim prefix="WHERE" prefixOverrides="AND |OR ">
...
</trim>
<!-- 等价于 <set> -->
<trim prefix="SET" suffixOverrides=",">
...
</trim>

Q14. <resultMap> vs resultType:何时必须用 resultMap

resultType:简单场景用它

当查询结果列名和 Java 对象字段名完全一致(或开启了驼峰映射),用 resultType 最简洁:

1
2
3
4
5
6
7
8
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>

<!-- user_name → userName,自动驼峰映射 -->
<select id="selectById" resultType="com.example.User">
SELECT id, user_name, email FROM user WHERE id = #{id}
</select>

<resultMap>:以下场景必须使用

场景一:列名和字段名不一致且无法用驼峰规则解决

1
2
3
4
5
<resultMap id="userMap" type="User">
<id column="user_id" property="id"/>
<result column="user_name" property="name"/>
<result column="create_time" property="createAt"/>
</resultMap>

场景二:嵌套对象(一对一关联)

1
2
3
4
5
6
7
8
9
<resultMap id="orderMap" type="Order">
<id column="id" property="id"/>
<result column="amount" property="amount"/>
<!-- 嵌套的 User 对象 -->
<association property="user" javaType="User">
<id column="user_id" property="id"/>
<result column="user_name" property="name"/>
</association>
</resultMap>

场景三:集合(一对多关联)

1
2
3
4
5
6
7
8
9
<resultMap id="userWithOrdersMap" type="User">
<id column="id" property="id"/>
<result column="name" property="name"/>
<!-- 多个 Order 映射成 List -->
<collection property="orders" ofType="Order">
<id column="order_id" property="id"/>
<result column="amount" property="amount"/>
</collection>
</resultMap>

Q15. <association> vs <collection>:一对一 vs 一对多映射

<association>:一对一关联(对象中嵌套对象)

场景:一个订单对应一个用户。

1
2
3
4
5
6
7
8
9
10
11
12
13
<resultMap id="orderResultMap" type="Order">
<id column="order_id" property="id"/>
<result column="amount" property="amount"/>

<!-- 一对一:association,property 是 Order 中的字段名,javaType 是嵌套对象类型 -->
<association property="user" javaType="User"
select="selectUserById" column="user_id"
fetchType="lazy"/>
</resultMap>

<select id="selectUserById" resultType="User">
SELECT * FROM user WHERE id = #{userId}
</select>

<collection>:一对多关联(对象中嵌套集合)

场景:一个用户有多个订单。

1
2
3
4
5
6
7
8
9
10
11
12
13
<resultMap id="userResultMap" type="User">
<id column="user_id" property="id"/>
<result column="user_name" property="name"/>

<!-- 一对多:collection,ofType 是集合中元素的类型 -->
<collection property="orders" ofType="Order"
select="selectOrdersByUserId" column="user_id"
fetchType="lazy"/>
</resultMap>

<select id="selectOrdersByUserId" resultType="Order">
SELECT * FROM order WHERE user_id = #{userId}
</select>

JOIN 查询方式(避免 N+1,推荐):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<resultMap id="userWithOrdersMap" type="User">
<id column="uid" property="id"/>
<result column="uname" property="name"/>
<collection property="orders" ofType="Order">
<id column="oid" property="id"/>
<result column="amount" property="amount"/>
</collection>
</resultMap>

<select id="selectUserWithOrders" resultMap="userWithOrdersMap">
SELECT u.id uid, u.name uname, o.id oid, o.amount
FROM user u LEFT JOIN order o ON u.id = o.user_id
WHERE u.id = #{userId}
</select>

Q16. 懒加载(Lazy Loading)的原理(CGLIB/Javassist 代理)及配置

什么是懒加载?

懒加载指关联对象不在主查询时加载,而是在第一次访问该属性时才发起额外的 SQL 查询。目的是减少不必要的数据库查询。

配置方式:

1
2
3
4
5
6
7
<!-- mybatis-config.xml -->
<settings>
<!-- 开启全局懒加载 -->
<setting name="lazyLoadingEnabled" value="true"/>
<!-- 关闭"访问任意属性触发加载所有懒加载属性"的行为 -->
<setting name="aggressiveLazyLoading" value="false"/>
</settings>

也可以在 <association> 或 <collection> 上单独设置:

1
2
<association property="user" fetchType="lazy" .../>
<collection property="orders" fetchType="eager" .../>

原理:CGLIB/Javassist 动态代理

MyBatis 懒加载的核心是:给返回的对象创建代理,拦截 getter 方法。

1
2
3
4
5
6
7
8
9
selectOrder() 返回 Order 对象(user 字段为 null,但被代理)
↓
order.getUser() ← 调用 getter
↓
代理拦截:发现 user 属性尚未加载
↓
执行 SELECT * FROM user WHERE id = #{order.userId}
↓
填充 user 字段,返回真实 User 对象

MyBatis 3.3+ 默认使用 Javassist 生成代理类(比 CGLIB 轻量,无需引入 cglib 依赖),也可以切换到 CGLIB:

1
<setting name="proxyFactory" value="CGLIB"/>

注意: 如果在 toString()、equals() 等方法中访问懒加载属性,可能意外触发加载。aggressiveLazyLoading=false 可以避免这个问题。


Q17. N+1 查询问题:产生原因及 fetchType=eager 的权衡

N+1 问题描述:

查询 1 个用户列表(1 条 SQL),再为每个用户查询其订单(N 条 SQL),共 N+1 条 SQL。

1
2
3
4
5
6
7
// 查 10 个用户,然后懒加载每人的订单
List<User> users = userMapper.selectAll(); // 1 条 SQL
for (User user : users) {
// 每次访问 orders 都触发一条 SQL
List<Order> orders = user.getOrders(); // 10 条 SQL
}
// 共 11 条 SQL = N+1 问题

解决方案:

方案一:改为 fetchType="eager"

立即加载,一次 JOIN 查询搞定,但如果不需要订单数据,就做了无用的 JOIN。

方案二:使用 JOIN 查询(最推荐)

1
2
3
4
5
<select id="selectUsersWithOrders" resultMap="userWithOrdersMap">
SELECT u.*, o.id o_id, o.amount
FROM user u
LEFT JOIN order o ON u.id = o.user_id
</select>

方案三:批量懒加载(aggressiveLazyLoading + 合理的批次大小)

MyBatis 支持 fetchSize 设置,减少数据库往返次数,但不能从根本上解决 N+1。

权衡原则:

  • 数据量小、关联简单:fetchType="eager" + JOIN
  • 关联数据很少被用到:fetchType="lazy" + 懒加载
  • 大数据量、高并发:避免 ORM 的关联查询,改用应用层手动 JOIN(先查用户,再按 userIds 批量查订单)

Q18. 批量操作:<foreach> 批量 insert vs ExecutorType.BATCH 的区别和性能对比

方式一:<foreach> 拼接多值 INSERT(推荐)

1
2
3
4
5
6
<insert id="batchInsert">
INSERT INTO user (name, email, status) VALUES
<foreach collection="users" item="u" separator=",">
(#{u.name}, #{u.email}, #{u.status})
</foreach>
</insert>

生成 SQL:INSERT INTO user (name, email, status) VALUES (?,?,?),(?,?,?),(?,?,?)

优点: 一次网络往返,极快。
缺点: 数据量太大时 SQL 过长(建议每批 500-1000 条),MySQL 需确认 max_allowed_packet。

方式二:ExecutorType.BATCH

1
2
3
4
5
6
7
try (SqlSession session = factory.openSession(ExecutorType.BATCH)) {
UserMapper mapper = session.getMapper(UserMapper.class);
for (User user : users) {
mapper.insert(user); // 不立即发送,攒在 JDBC batch 中
}
session.commit(); // 批量发送所有 INSERT
}

底层原理: JDBC PreparedStatement.addBatch() + executeBatch(),每条 SQL 预编译一次,参数多次批量提交。

性能对比:

方式 1000 条数据(参考) 特点
逐条 INSERT ~5000ms 最慢,不推荐
ExecutorType.BATCH ~500ms 减少网络往返,但多次预编译
<foreach> 拼接 ~100ms 最快,一条 SQL

Spring 中使用 ExecutorType.BATCH:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Autowired
private SqlSessionTemplate sqlSessionTemplate;

public void batchInsert(List<User> users) {
SqlSession batchSession = sqlSessionTemplate
.getSqlSessionFactory()
.openSession(ExecutorType.BATCH);
try {
UserMapper mapper = batchSession.getMapper(UserMapper.class);
for (int i = 0; i < users.size(); i++) {
mapper.insert(users.get(i));
if (i % 500 == 0) {
batchSession.flushStatements(); // 每 500 条提交一次
}
}
batchSession.commit();
} catch (Exception e) {
batchSession.rollback();
throw e;
} finally {
batchSession.close();
}
}

Q19. MyBatis-Plus:常用注解(@TableName, @TableId, @TableField),条件构造器(LambdaQueryWrapper)

MyBatis-Plus 是 MyBatis 的增强工具,在不改变 MyBatis 基础上,提供了大量实用功能。

常用注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@TableName("t_user")  // 指定表名(默认驼峰转下划线)
public class User {

@TableId(type = IdType.AUTO) // 主键类型:AUTO 自增,ASSIGN_ID 雪花算法
private Long id;

@TableField("user_name") // 指定列名
private String name;

@TableField(exist = false) // 该字段不对应数据库列
private List<Order> orders;

@TableLogic // 逻辑删除字段(详见 Q20)
private Integer deleted;
}

条件构造器(LambdaQueryWrapper,强烈推荐):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 普通 QueryWrapper(字符串,有拼写错误风险)
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("user_name", "张三").ge("age", 18).orderByDesc("create_time");

// LambdaQueryWrapper(Lambda 表达式,编译时检查字段名,推荐!)
LambdaQueryWrapper<User> lambda = new LambdaQueryWrapper<>();
lambda.eq(User::getName, "张三")
.ge(User::getAge, 18)
.orderByDesc(User::getCreateTime)
.last("LIMIT 10");

List<User> users = userMapper.selectList(lambda);

// 链式写法(更简洁)
List<User> users2 = userMapper.selectList(
Wrappers.<User>lambdaQuery()
.eq(StringUtils.isNotBlank(name), User::getName, name) // 动态条件
.between(User::getAge, 18, 60)
.select(User::getId, User::getName) // 只查指定列
);

常用 BaseMapper 方法:

1
2
3
4
5
6
7
8
9
10
11
userMapper.selectById(1L);
userMapper.selectBatchIds(Arrays.asList(1L, 2L, 3L));
userMapper.insert(user);
userMapper.updateById(user);
userMapper.deleteById(1L);

// 分页查询
Page<User> page = new Page<>(1, 10);
IPage<User> result = userMapper.selectPage(page, lambda);
result.getRecords(); // 当前页数据
result.getTotal(); // 总条数

Q20. MyBatis-Plus 的逻辑删除实现原理(@TableLogic)

什么是逻辑删除?

不真正执行 DELETE 语句,而是更新一个标志位字段(如 is_deleted=1),查询时自动过滤已删除数据,保留数据可追溯。

配置方式:

1
2
3
4
5
6
7
# application.yml
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted # 全局逻辑删除字段名
logic-delete-value: 1 # 已删除的值
logic-not-delete-value: 0 # 未删除的值

实体类:

1
2
3
4
5
6
7
public class User {
private Long id;
private String name;

@TableLogic // 标记逻辑删除字段
private Integer deleted;
}

原理:SQL 自动改写

MyBatis-Plus 通过插件拦截,在执行时自动改写 SQL:

1
2
3
4
5
6
7
-- 调用 userMapper.deleteById(1L) 时,实际执行:
UPDATE user SET deleted = 1 WHERE id = 1 AND deleted = 0

-- 调用 userMapper.selectById(1L) 时,实际执行:
SELECT * FROM user WHERE id = 1 AND deleted = 0

-- 所有查询自动加上 AND deleted = 0 条件

注意事项:

  1. 自定义 SQL 不会自动加逻辑删除条件,需要手动加 AND deleted = 0
  2. 如果字段名不是全局配置的名称,必须在字段上明确声明 @TableLogic
  3. 逻辑删除后,相关唯一索引可能冲突(已删除的记录仍占用唯一值),通常需要把删除时间戳或 ID 加入联合唯一索引

Q21. MyBatis 多数据源配置方案(AOP + AbstractRoutingDataSource 或 Sharding-JDBC)

方案一:AbstractRoutingDataSource + AOP(适合读写分离、分库)

第一步:定义数据源路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 从 ThreadLocal 中获取当前应使用哪个数据源
return DataSourceContextHolder.getDataSourceType();
}
}

// ThreadLocal 持有数据源标识
public class DataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();

public static void setDataSourceType(String type) { CONTEXT.set(type); }
public static String getDataSourceType() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}

第二步:注册多数据源

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public DataSource dynamicDataSource(
@Qualifier("masterDs") DataSource master,
@Qualifier("slaveDs") DataSource slave) {
DynamicDataSource ds = new DynamicDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", master);
targetDataSources.put("slave", slave);
ds.setTargetDataSources(targetDataSources);
ds.setDefaultTargetDataSource(master);
return ds;
}

第三步:AOP 自动切换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Aspect
@Component
public class DataSourceAspect {

@Before("@annotation(ds)")
public void switchDataSource(JoinPoint point, DS ds) {
DataSourceContextHolder.setDataSourceType(ds.value());
}

@After("@annotation(ds)")
public void restoreDataSource(JoinPoint point, DS ds) {
DataSourceContextHolder.clear(); // 必须清理,防止内存泄漏
}
}

// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DS {
String value() default "master";
}

// 使用
@DS("slave")
public List<User> selectAll() { ... }

方案二:ShardingSphere / Sharding-JDBC

适合更复杂的分库分表场景,配置声明式,侵入性更低,支持水平分片、读写分离、数据脱敏等,推荐大型项目使用。


Q22. TypeHandler:自定义类型转换(枚举存储、JSON 字段、加解密)

TypeHandler 的作用:

TypeHandler 负责 Java 类型和 JDBC 类型之间的转换。MyBatis 内置了常用类型的 Handler,当遇到特殊类型(枚举、JSON、加密字段),需要自定义。

场景一:枚举存储 code 值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public enum UserStatus {
ACTIVE(1), INACTIVE(0);
private final int code;
// getter...
}

@MappedTypes(UserStatus.class)
@MappedJdbcTypes(JdbcType.INTEGER)
public class UserStatusTypeHandler extends BaseTypeHandler<UserStatus> {

@Override
public void setNonNullParameter(PreparedStatement ps, int i,
UserStatus parameter, JdbcType jdbcType) throws SQLException {
ps.setInt(i, parameter.getCode()); // Java → JDBC
}

@Override
public UserStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
int code = rs.getInt(columnName);
return Arrays.stream(UserStatus.values())
.filter(s -> s.getCode() == code)
.findFirst().orElse(null); // JDBC → Java
}
// ... 其他 getNullableResult 重载
}

场景二:JSON 字段(List 存为 JSON 字符串)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@MappedTypes(List.class)
public class JsonListTypeHandler<T> extends BaseTypeHandler<List<T>> {
private static final ObjectMapper MAPPER = new ObjectMapper();

@Override
public void setNonNullParameter(PreparedStatement ps, int i,
List<T> parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, MAPPER.writeValueAsString(parameter));
}

@Override
public List<T> getNullableResult(ResultSet rs, String columnName) throws SQLException {
String json = rs.getString(columnName);
return json == null ? null : MAPPER.readValue(json, new TypeReference<List<T>>(){});
}
}

注册 TypeHandler:

1
2
3
4
<typeHandlers>
<typeHandler handler="com.example.UserStatusTypeHandler"/>
<package name="com.example.typehandler"/>
</typeHandlers>

Q23. MyBatis 与 Spring 集成的关键组件(SqlSessionTemplate、MapperScannerConfigurer)

关键组件一:SqlSessionTemplate

SqlSessionTemplate 是 Spring 提供的线程安全的 SqlSession 实现。它通过 Spring 的事务管理和 TransactionSynchronizationManager 来保证:

  • 有事务时:从当前事务绑定的 SqlSession 中获取(共享同一个会话)
  • 无事务时:每次调用创建新 SqlSession,用完即关
1
2
3
4
@Bean
public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory factory) {
return new SqlSessionTemplate(factory);
}

关键组件二:MapperScannerConfigurer

自动扫描 Mapper 接口,为每个接口创建 MapperFactoryBean,注册到 Spring 容器中。

1
2
3
4
5
6
7
@Bean
public MapperScannerConfigurer mapperScannerConfigurer() {
MapperScannerConfigurer configurer = new MapperScannerConfigurer();
configurer.setBasePackage("com.example.mapper");
configurer.setSqlSessionFactoryBeanName("sqlSessionFactory");
return configurer;
}

等效的注解方式(更常用):

1
2
3
@SpringBootApplication
@MapperScan("com.example.mapper") // Spring Boot 中推荐用这个
public class Application { ... }

MyBatis-Spring-Boot-Starter 自动配置:

引入 mybatis-spring-boot-starter 后,SqlSessionFactory、SqlSessionTemplate、MapperScannerConfigurer 都由 MybatisAutoConfiguration 自动创建,只需在 application.yml 中配置基本属性即可。

1
2
3
4
5
mybatis:
mapper-locations: classpath*:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

Q24. 面试高频:MyBatis 如何防止 SQL 注入?(底层 PreparedStatement 原理)

SQL 注入的本质是:用户输入的数据被当作 SQL 结构的一部分来解析执行。

MyBatis 防注入的核心:#{} 使用 PreparedStatement

当你写 WHERE name = #{name} 时,MyBatis 生成的 JDBC 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 预编译 SQL 模板(? 是占位符,不是字符串拼接)
String sql = "SELECT * FROM user WHERE name = ?";
PreparedStatement ps = connection.prepareStatement(sql);
// SQL 结构在这一步已经固定,后续无法改变

// 2. 单独设置参数(参数不参与 SQL 解析)
ps.setString(1, userInput); // 即使 userInput = "'; DROP TABLE user; --"
// 数据库会把整个字符串当作 name 列的值来查找
// 而不会把单引号解释为 SQL 字符串结束符

// 3. 执行(参数已经被数据库驱动转义处理)
ResultSet rs = ps.executeQuery();

对比 ${} 的危险:

1
2
3
4
5
6
7
// ${name} 直接字符串替换,生成的 SQL:
String sql = "SELECT * FROM user WHERE name = '" + name + "'";
// 如果 name = "' OR '1'='1"
// 生成:SELECT * FROM user WHERE name = '' OR '1'='1'
// 返回全部用户数据!这就是 SQL 注入
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql); // 危险!

MyBatis 防注入总结:

  1. 永远用 #{} 传递用户输入的参数值
  2. ${} 只用于传递列名、表名等 SQL 结构元素,且这些值必须来自可信的枚举或白名单
  3. MyBatis-Plus 的 LambdaQueryWrapper 底层也是 PreparedStatement,同样安全
  4. 额外防线:在 Web 层对输入参数做校验和过滤(配合 @Valid、@Pattern 等)

Q25. 实战:MyBatis 慢 SQL 排查步骤(日志配置 → Explain 分析 → 索引优化)

第一步:开启 MyBatis SQL 日志,定位慢 SQL

1
2
3
4
5
6
7
8
# application.yml
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl

logging:
level:
com.example.mapper: DEBUG # 打印 SQL 和参数

或使用 P6Spy 打印完整 SQL(含实际参数,非 ? 占位符):

1
2
3
4
5
6
<!-- pom.xml -->
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>
1
2
3
4
5
# spy.properties
driverlist=com.mysql.cj.jdbc.Driver
outagedetection=true
outagedetectioninterval=2 # 超过 2 秒的 SQL 打印告警
logMessageFormat=com.p6spy.engine.spy.appender.MultiLineFormat

第二步:用 EXPLAIN 分析执行计划

把慢 SQL 复制到数据库客户端,前面加 EXPLAIN:

1
2
3
4
EXPLAIN SELECT u.*, o.amount
FROM user u
LEFT JOIN order o ON u.id = o.user_id
WHERE u.status = 1 AND o.create_time > '2026-01-01';

重点关注以下字段:

字段 危险信号 含义
type ALL 全表扫描,最危险
key NULL 没有使用索引
rows 很大的数字 扫描行数过多
Extra Using filesort 需要额外排序
Extra Using temporary 使用了临时表

第三步:针对性索引优化

1
2
3
4
5
6
7
8
9
-- 根据 WHERE 条件和 JOIN 字段添加索引
ALTER TABLE user ADD INDEX idx_status (status);
ALTER TABLE order ADD INDEX idx_user_create (user_id, create_time);

-- 联合索引遵循最左前缀原则
-- idx_user_create 可以支持:
-- WHERE user_id = ?
-- WHERE user_id = ? AND create_time > ?
-- 但不能支持:WHERE create_time > ?(未使用索引)

第四步:MyBatis 层面优化

1
2
3
4
5
6
7
<!-- 只查需要的列,避免 SELECT * -->
<sql id="baseColumns">id, name, email, status</sql>

<select id="selectByStatus" resultType="User">
SELECT <include refid="baseColumns"/>
FROM user WHERE status = #{status}
</select>

第五步:监控告警(生产环境必备)

结合 Q11 中的慢 SQL 插件,在插件中集成 Prometheus 指标上报或发送钉钉/飞书告警,做到慢 SQL 实时预警,不等问题扩大。


总结

分类 核心考点
架构原理 SqlSessionFactory 单例、SqlSession 非线程安全、Executor 三种类型
SQL 安全 #{} vs ${}、PreparedStatement 防注入
缓存 一级缓存 Spring 集成后失效、二级缓存脏读风险
高级特性 动态代理绑定 Mapper、插件拦截链、懒加载代理
动态 SQL <where>/<set>/<foreach> 的使用场景
MyBatis-Plus LambdaQueryWrapper、逻辑删除 SQL 改写
实战 批量操作性能选型、多数据源路由、慢 SQL 排查

面试建议:把 Q2(整体架构)、Q5(#{} vs ${})、Q8(一级缓存坑点)、Q11(插件原理)这四道题答得滚瓜烂熟,MyBatis 相关面试基本稳了。


MyBatis 面试八股文 25 道|深度详解版(傻子都能看懂)
https://whyalwaysme.lol/2026/09/05/2026-09-05-mybatis-interview-deep/
作者
Cassiur
发布于
2026年9月5日
许可协议