Skip to content

十、Mybatis(8 题)

10.1 ★★★★Mybatis 的核心执行流程是怎样的?从 SqlSession 到执行 SQL 的完整流程?各组件的职责是什么?

📖 点击查看答案

核心组件

  • SqlSessionFactory:创建 SqlSession 的工厂,重量级对象,全局单例。
  • SqlSession:会话对象,封装 CRUD API,线程不安全,方法级生命周期。
  • Executor:执行器,负责 SQL 执行与缓存管理,有 SIMPLE、REUSE、BATCH 三种类型。
  • StatementHandler:处理 JDBC Statement 的创建与参数设置。
  • ParameterHandler:设置 SQL 参数(占位符赋值)。
  • ResultSetHandler:处理结果集映射为 Java 对象。
  • TypeHandler:Java 类型与 JDBC 类型转换。

完整执行流程

  1. 调用 SqlSession.selectOne("namespace.id", param)
  2. Configuration 根据 statement id 获取 MappedStatement(封装 SQL、参数映射、结果映射)。
  3. SqlSession 委托给 Executor 执行。
  4. Executor 先查一级缓存 → 二级缓存,未命中则继续。
  5. 创建 StatementHandler,通过 ParameterHandler 设置参数。
  6. 执行 JDBC Statement,获取 ResultSet。
  7. ResultSetHandler 根据 <resultMap> 将行数据映射为 Java 对象。
  8. 返回结果,Executor 写入缓存。
java
// 核心流程源码(简化)
public <E> List<E> selectList(String statement, Object parameter) {
    MappedStatement ms = configuration.getMappedStatement(statement);
    return executor.query(ms, wrapCollection(parameter), RowBounds.DEFAULT, Executor.NO_RESULT_HANDLER);
}
// Executor.query → StatementHandler.prepare → ParameterHandler.setParameters → execute → ResultSetHandler.handleResultSets

动态代理机制:Mapper 接口通过 MapperProxyFactory 生成代理对象,方法调用被 MapperProxy.invoke 拦截,转为调用 SqlSession 的对应方法,statementId = 接口全限定名 + 方法名。

关键点Configuration 全局唯一,缓存所有 XML 解析结果;Executor 是缓存与执行的核心;插件通过拦截 Executor/StatementHandler/ParameterHandler/ResultSetHandler 实现增强。


10.2 ★★★Mybatis 的延迟加载原理是什么?Mapper 接口和 XML 文件是如何绑定的?为什么 DAO 接口没有实现类也能工作?

📖 点击查看答案

延迟加载原理

  • 配置 lazyLoadingEnabled=true 后,关联对象(association/collection)在真正访问时才执行 SQL。
  • 底层通过 CGLIB 动态代理 生成代理对象,拦截 toStringhashCodeequals 等方法及属性 getter。
  • 主对象加载时不加载关联属性,调用 getDetail() 时触发拦截器 → 调用 loadObject() → 执行关联 SQL。
  • aggressiveLazyLoading=false(3.4+ 默认)时按需加载,否则任一方法触发全部加载。
xml
<!-- 配置 -->
<settings>
    <setting name="lazyLoadingEnabled" value="true"/>
    <setting name="aggressiveLazyLoading" value="false"/>
</settings>

<!-- resultMap 中 association 配置 fetchType="lazy" -->
<association property="user" javaType="User" fetchType="lazy" select="selectUserById" column="user_id"/>

Mapper 接口与 XML 绑定

  • XML 中 namespace = Mapper 接口全限定名。
  • <select id="xxx"> 的 id 对应接口方法名。
  • 解析 XML 时将 MappedStatementnamespace.id 为 key 存入 Configuration

无实现类原理(JDK 动态代理)

  1. MapperRegistry 注册 Mapper 接口。
  2. 调用 getMapper(UserMapper.class) 时,MapperProxyFactory.newInstance() 通过 JDK 动态代理生成代理对象。
  3. 代理对象实现 UserMapper 接口,方法调用被 MapperProxy.invoke() 拦截。
  4. invoke 方法根据 method 全限定名拼出 statementId,委托给 SqlSession 执行。
java
// JDK 动态代理核心
public Object invoke(Object proxy, Method method, Object[] args) {
    String statementId = interfaceName + "." + method.getName();
    Class<?> returnType = method.getReturnType();
    if (returnType == List.class) {
        return sqlSession.selectList(statementId, args[0]);
    } else {
        return sqlSession.selectOne(statementId, args[0]);
    }
}

关键点:Mapper 接口方法不能重载(id 唯一);延迟加载需关联查询在同一 SqlSession 内(缓存可用)。


10.3 ★★★★#{}${} 的区别是什么?为什么 #{} 能防止 SQL 注入?底层是如何实现的?

📖 点击查看答案

核心区别

维度#{}${}
底层PreparedStatement 占位符 ?字符串拼接
SQL 注入防止不能防止
类型处理自动 TypeHandler 转换原样拼接
性能可预编译缓存每次生成新 SQL
使用场景参数值表名、列名、排序字段等结构

#{} 防 SQL 注入原理

  1. 解析 SQL 时,#{} 被替换为 ?,参数值单独保存为 ParameterMapping
  2. 执行时使用 PreparedStatement.setXXX(position, value) 设置参数。
  3. JDBC 驱动对参数值做转义处理(如单引号转义),不会改变 SQL 结构。
java
// 源码解析(SqlSourceBuilder)
// 原 SQL: SELECT * FROM user WHERE name = #{name}
// 解析后: SELECT * FROM user WHERE name = ?
// 参数映射: ParameterMapping{property='name', javaType=String}

// 执行时
PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE name = ?");
ps.setString(1, "张三' OR '1'='1");  // 整体作为字符串值,不会拼接进 SQL

${} 字符串拼接示例

java
// 原 SQL: SELECT * FROM user ORDER BY ${column}
// 解析后: SELECT * FROM user ORDER BY id  (column=id 时)
// 风险:若 column = "id; DROP TABLE user",会执行恶意 SQL

使用场景对比

xml
<!-- #{} 用于值 -->
<select id="selectByName">
    SELECT * FROM user WHERE name = #{name}
</select>

<!-- ${} 用于结构(表名/列名/排序),需做白名单校验 -->
<select id="selectByOrder">
    SELECT * FROM user ORDER BY ${column}  <!-- 必须业务层校验 -->
</select>

关键点#{} 本质是 PreparedStatement 机制;${} 是字符串替换,安全性需业务层保证;动态表名/排序字段用 ${},但必须校验白名单。


10.4 ★★★★Mybatis 的动态 SQL 有哪些标签?底层是如何拼接 SQL 的?使用场景有哪些?

📖 点击查看答案

动态 SQL 标签

标签作用场景
<if>条件判断可选查询条件
<where>自动处理 AND/OR 前缀多条件 WHERE 拼接
<choose>/<when>/<otherwise>if-else 分支互斥条件
<trim>自定义前后缀与删除灵活拼接
<set>自动处理 UPDATE 末尾逗号动态更新
<foreach>遍历集合IN 查询、批量插入
<bind>定义 OGNL 变量模糊查询拼接

底层拼接原理

  1. 解析 XML 时,动态 SQL 被转换为 SqlNode 树(如 MixedSqlNodeIfSqlNodeForEachSqlNode)。
  2. 执行时调用 SqlNode.apply(DynamicContext),根据参数动态生成 SQL 片段,写入 DynamicContext
  3. DynamicContextSource 将拼接后的 SQL 与参数传给 SqlSourceBuilder,将 #{} 替换为 ?,最终生成 BoundSql
xml
<!-- 典型示例 -->
<select id="selectUsers" resultType="User">
    SELECT * FROM user
    <where>
        <if test="name != null">
            AND name = #{name}
        </if>
        <if test="age != null">
            AND age = #{age}
        </if>
        <choose>
            <when test="sort == 'name'">
                ORDER BY name
            </when>
            <otherwise>
                ORDER BY id
            </otherwise>
        </choose>
    </where>
</select>

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

SqlNode 树结构示例

java
// <where><if>...</if></where> 解析为
MixedSqlNode
├── StaticTextSqlNode("SELECT * FROM user")
├── WhereSqlNode
│   └── IfSqlNode(test="name!=null", contents=[StaticTextSqlNode("AND name = #{name}")])

使用场景

  • 多条件查询:用户可选填筛选条件。
  • 批量操作:<foreach> 拼接 IN/VALUES。
  • 动态更新:只更新非空字段。
  • 多数据库兼容:通过 <databaseId> 切换。

注意<if> 的 test 用 OGNL 表达式;<foreach> 的 index 是下标(List)或 key(Map);动态 SQL 频繁变化会导致预编译缓存失效。


10.5 ★★★★Mybatis 的缓存机制是怎样的?一级缓存和二级缓存的区别、作用域和失效场景是什么?

📖 点击查看答案

一级缓存(Local Cache)

  • 作用域:SqlSession 级别,默认开启,无法关闭(可设 localCacheScope=STATEMENT 关闭)。
  • 数据结构:PerpetualCache(HashMap),key = statementId + params + rowBounds + sql
  • 存放位置:BaseExecutor.localCache

二级缓存(namespace 级别)

  • 作用域:Mapper 的 namespace,跨 SqlSession 共享。
  • 默认关闭,需在 XML 中 <cache/> 开启,实体类需 Serializable
  • 查询顺序:二级缓存 → 一级缓存 → 数据库。

对比

维度一级缓存二级缓存
作用域SqlSessionnamespace(跨 SqlSession)
默认开启关闭
失效条件insert/update/delete、clearCache()、关闭会话整个 namespace 的增删改
实现Executor 内 HashMapDecorator 模式(LRU/FIFO/Soft/Weak)
并发单线程使用跨会话,需 Serializable

失效场景

  1. 一级缓存:执行 update/insert/delete(同 SqlSession 内清空)、调用 sqlSession.clearCache()、SqlSession 关闭。
  2. 二级缓存:namespace 内任意 insert/update/delete 后清空整个 namespace 缓存。
xml
<!-- 开启二级缓存 -->
<mapper namespace="com.xxx.UserMapper">
    <cache eviction="LRU" flushInterval="60000" size="1024" readOnly="false"/>
    <!-- eviction: LRU/FIFO/SOFT/WEAK -->
</select>
</mapper>

多表查询缓存问题

  • 二级缓存基于 namespace,若 A Mapper 查询关联 B 表,B 表更新不会清空 A 的缓存 → 脏读。
  • 解决:使用 <cache-ref namespace="..."/> 共享缓存,或禁用多表查询的二级缓存。

分布式缓存集成:通过实现 Cache 接口,可对接 Redis/Memcached:

java
public class RedisCache implements Cache {
    public String getId() { ... }
    public void putObject(Object key, Object value) { redis.set(key, value); }
    public Object getObject(Object key) { return redis.get(key); }
}

关键点:Spring 整合下 SqlSession 由 SqlSessionTemplate 管理,未开启事务时每次方法调用创建新 SqlSession → 一级缓存失效;开启事务时同事务内复用 SqlSession,一级缓存生效。


10.6 ★★★★Mybatis 的插件(Interceptor)机制原理是什么?可以拦截哪些对象的方法?如何实现一个自定义的分页插件?

📖 点击查看答案

插件原理

  • 基于 JDK 动态代理 + 责任链模式,在创建 Executor/StatementHandler/ParameterHandler/ResultSetHandler 时通过 InterceptorChain.pluginAll() 包装代理。
  • 每个插件层层包裹目标对象,调用时依次执行各插件的 intercept 方法。

可拦截的四大对象

对象拦截方法典型场景
Executorupdate, query, commit, rollback缓存、事务、慢 SQL
StatementHandlerprepare, parameterize, batch改写 SQL(分页)
ParameterHandlersetParameters参数加密
ResultSetHandlerhandleResultSets结果脱敏

实现分页插件示例

java
@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class PageInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        // 通过反射获取原始 SQL
        MetaObject metaObject = SystemMetaObject.forObject(handler);
        String originalSql = (String) metaObject.getValue("delegate.boundSql.sql");
        // 从 ThreadLocal 获取分页参数
        Page page = PageContext.get();
        if (page != null) {
            // 改写 SQL
            String pageSql = originalSql + " LIMIT " + page.getOffset() + ", " + page.getPageSize();
            metaObject.setValue("delegate.boundSql.sql", pageSql);
            // 查询总数(可选)
        }
        return invocation.proceed();
    }
    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);  // 仅拦截目标类型
    }
    @Override
    public void setProperties(Properties properties) { }
}
xml
<!-- 注册插件 -->
<plugins>
    <plugin interceptor="com.xxx.PageInterceptor">
        <property name="dialect" value="mysql"/>
    </plugin>
</plugins>

关键点

  • @Intercepts + @Signature 指定拦截的方法签名。
  • Plugin.wrap 用动态代理包装目标对象,仅拦截匹配方法。
  • 通过 MetaObject 反射读写目标对象私有属性(如 boundSql.sql)。
  • 多插件按注册顺序反向执行(洋葱模型)。

注意:插件过多影响性能(多层代理);分页插件要考虑不同数据库方言;PageHelper 是成熟的开源实现,优先使用。


10.7 ★★★Mybatis 是如何进行分页的?RowBounds 分页和插件分页的区别?分页插件 PageHelper 的原理是什么?

📖 点击查看答案

分页方式

  1. SQL 分页(推荐):在 SQL 中写 LIMIT offset, size,性能最好。
  2. RowBounds 分页(逻辑分页):Mybatis 内置,通过 ResultSet 跳过前 N 条记录。
  3. 插件分页(物理分页):通过 Interceptor 改写 SQL,加 LIMIT 子句。

RowBounds vs 插件分页

维度RowBounds插件分页
类型逻辑分页物理分页
实现ResultSet.absolute(offset) 跳过SQL 加 LIMIT
数据量全量查询后跳过只查需要的
性能差(大结果集慢)
适用小数据量生产环境
java
// RowBounds(不推荐)
List<User> users = sqlSession.selectList("selectAll", null, new RowBounds(10, 20));
// 实际执行:SELECT * FROM user(全量),ResultSet 跳过前 10 条

PageHelper 原理

  1. 调用 PageHelper.startPage(pageNum, pageSize) → 将 Page 对象存入 ThreadLocal。
  2. 紧接的下一次查询被拦截器拦截(PageInterceptor)。
  3. 拦截器从 ThreadLocal 取 Page,改写 SQL:
    • MySQL:SELECT * FROM user LIMIT ?, ?
    • Oracle:使用 ROWNUM 子查询
  4. 额外执行 COUNT 查询获取总数,封装为 PageInfo
  5. 查询完成后清理 ThreadLocal。
java
// PageHelper 使用
PageHelper.startPage(2, 10);  // 第 2 页,每页 10 条
List<User> list = userMapper.selectAll();
PageInfo<User> pageInfo = new PageInfo<>(list);
// pageInfo.getList() → 当前页数据
// pageInfo.getTotal() → 总记录数
java
// PageInterceptor 核心逻辑
@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
public class PageInterceptor implements Interceptor {
    public Object intercept(Invocation invocation) throws Throwable {
        Page page = PageHelper.getLocalPage();
        if (page != null) {
            Object[] args = invocation.getArgs();
            MappedStatement ms = (MappedStatement) args[0];
            BoundSql boundSql = ms.getBoundSql(args[1]);
            String sql = boundSql.getSql();
            // 改写 SQL
            String pageSql = sql + " LIMIT " + page.getStartRow() + ", " + page.getPageSize();
            // 通过反射更新 boundSql.sql
            ReflectUtil.setFieldValue(boundSql, "sql", pageSql);
        }
        return invocation.proceed();
    }
}

关键点startPage 必须紧邻查询,否则 ThreadLocal 会被下次调用污染;COUNT 查询会解析原 SQL 去除 ORDER BY 优化;多数据源时需指定 dialect


10.8 ★★★★Mybatis-Plus 相比原生 Mybatis 有哪些核心增强功能?代码生成器和通用 CRUD 的底层原理是什么?

📖 点击查看答案

核心增强功能

  1. 通用 CRUD(BaseMapper):无需 XML 即可实现 insert/updateById/deleteById/selectById/selectList 等。
  2. 条件构造器(Wrapper):链式 API 构建查询条件,如 QueryWrapper.eq("name", "张三").ge("age", 18)
  3. 代码生成器:根据数据库表自动生成 Entity/Mapper/Service/Controller。
  4. 分页插件:内置 PaginationInnerInterceptor,无需额外配置。
  5. 主键策略@TableId(type = IdType.AUTO/ASSIGN_ID/ASSIGN_UUID)
  6. 逻辑删除@TableLogic 字段,删除转为 update。
  7. 自动填充@TableField(fill = FieldFill.INSERT/UPDATE) + MetaObjectHandler
  8. 乐观锁@Version 字段 + OptimisticLockerInnerInterceptor
  9. 多租户TenantLineInnerInterceptor 自动拼接租户条件。

通用 CRUD 原理

  • 启动时 MP 扫描 @TableName 注解的实体类,注册到 MybatisMapperRegistry
  • 为每个 BaseMapper 方法注入 MappedStatement(SQL 模板预定义)。
  • SQL 通过 SqlInjector 动态注入到 Configuration 中,与 XML 解析的 statement 共存。
  • 实体类通过反射获取表名(@TableName 或类名驼峰转下划线)、字段名(@TableField)。
java
// BaseMapper 方法注入核心
public class DefaultSqlInjector extends AbstractSqlInjector {
    @Override
    public List<AbstractMethod> getMethodList(Class<?> mapperClass) {
        return Stream.of(
            new Insert(),
            new DeleteById(),
            new UpdateById(),
            new SelectById(),
            new SelectList()
        ).collect(Collectors.toList());
    }
}
// 每个 AbstractMethod 调用 inject() 生成 MappedStatement

代码生成器原理

  1. 通过 JDBC DatabaseMetaData 获取表结构与字段信息。
  2. 模板引擎(Velocity/Freemarker)渲染 Entity/Mapper/Service/Controller。
  3. 命名策略:表名 → 类名(下划线转驼峰),字段名 → 属性名。
java
// 代码生成器配置
FastAutoGenerator.create("jdbc:mysql://localhost/test", "root", "root")
    .globalConfig(builder -> builder.author("author").outputDir("src/main/java"))
    .packageConfig(builder -> builder.parent("com.xxx").moduleName("user"))
    .strategyConfig(builder -> builder.addInclude("user").addTablePrefix("t_"))
    .execute();

Wrapper 条件构造器原理

  • QueryWrapper 内部维护 List<ISqlSegment> 存储条件片段(name = ?age > ?)。
  • getCustomSqlSegment() 拼接为 ${ew.customSqlSegment},参数通过 paramNameValuePairs 传递。
  • XML 中使用 #{ew.paramNameValuePairs.MPGENVAL1} 引用参数。

关键点:MP 不破坏 Mybatis 原生能力,XML 与 BaseMapper 可混用;BaseMapper 方法本质是预生成的 MappedStatement;自定义 SQL 仍走 XML/注解方式。

基于 4 份主流面试题库整合精选,共 200 题