MyBatis 面试深度指南

MyBatis 面试深度指南

本文面向已经用过 MyBatis、准备高级/资深 Java 岗位面试的同学。目标不是背"#{} 和 ${} 有什么区别"这类应试八股,而是讲清楚 MyBatis 几个核心机制背后的真实调用链路:Mapper 接口是怎么被动态代理出来的、一次查询具体经过哪几个对象、一二级缓存的作用域和坑在哪里、插件机制是怎么用动态代理把多个拦截器串成责任链的。

源码基准版本说明:本文源码分析基于 MyBatis 3.5.x 系列(结合 mybatis-spring 与 Spring Boot 3.x 整合场景)。Executor / StatementHandler / ParameterHandler / ResultSetHandler 四大对象体系、Mapper 动态代理机制、一二级缓存等核心机制在 MyBatis 3.x 系列各小版本之间结构保持稳定,但具体源码行号可能随小版本迭代略有出入,本文只引用真实存在且跨版本比较稳定的类名/方法名(如 MapperProxy#invoke、MapperMethod#execute、CachingExecutor、Plugin#wrap 等),不给出精确行号,涉及的简化代码块是对真实调用链语义的还原,个别版本细节差异点会在正文中明确标注"建议对照本地源码复核",不做绝对化表述。

目录


一、MyBatis 整体架构与启动流程

要点:MyBatis 启动阶段做的事情就是"把 XML/注解描述的配置,解析成一个内存里的大对象 Configuration",之后运行时的一切行为(执行哪条 SQL、怎么映射结果、走不走缓存)都是在查这个对象;理解了 Configuration 是运行时的"总注册表",后面所有机制都容易挂靠上去。

1.1 从 mybatis-config.xml 到 Configuration 对象

MyBatis 独立使用(不整合 Spring)时的典型启动代码:

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

SqlSessionFactoryBuilder.build(InputStream) 内部大致做了这几件事(简化还原,具体分支建议对照本地源码复核):

// 简化伪代码,还原语义而非逐行复制源码
public SqlSessionFactory build(InputStream inputStream) {
    XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
    Configuration configuration = parser.parse(); // 解析整个 mybatis-config.xml
    return new DefaultSqlSessionFactory(configuration);
}

XMLConfigBuilder.parse() 依次解析 <configuration> 下的各个子节点:properties(外部属性文件)、settings(全局开关,如 cacheEnabled、lazyLoadingEnabled)、typeAliases、plugins(插件注册,见第六节)、environments(数据源与事务管理器)、以及最关键的 mappers 节点——遍历每一个 <mapper resource=".."/> 或包扫描路径,交给 XMLMapperBuilder 解析对应的 Mapper XML 文件。

每个 Mapper XML 里的 <select>/<insert>/<update>/<delete> 节点,会被 XMLMapperBuilder(内部委托 XMLStatementBuilder)解析成一个 MappedStatement 对象,以 namespace + id(如 com.example.mapper.UserMapper.selectById)作为 key,注册进 Configuration 内部的 mappedStatements(一个 Map 结构,MyBatis 内部用的是自定义的 StrictMap,重复 key 会直接抛异常防止覆盖)。

如果这个 namespace 对应一个 Mapper 接口(大多数项目的用法),Configuration.addMapper() 还会把这个接口注册进 MapperRegistry,为它绑定一个 MapperProxyFactory(见第二节)。最终,SqlSessionFactoryBuilder 拿着这个装满了所有元信息的 Configuration 对象,构造出 DefaultSqlSessionFactory 返回——启动阶段的产出物本质上只有一个:一个填满了的 Configuration 对象。

1.2 MappedStatement 是什么

MappedStatement 是 MyBatis 对"一条 SQL 语句的完整元信息"的封装,可以理解成 XML 里一个 <select> 标签解析后的运行时对象,关键字段包括(字段名以主流版本为准,个别字段名建议对照本地源码复核):

字段 含义
id namespace + 方法名,全局唯一标识这条 SQL
sqlSource 持有 SqlSource,负责在真正执行时才把动态 SQL(<if>/<where>/${} 等)解析成最终的 SQL 文本和参数列表,返回 BoundSql
sqlCommandType SELECT/INSERT/UPDATE/DELETE/FLUSH,决定 MapperMethod 分发到 SqlSession 的哪个方法
statementType STATEMENT/PREPARED/CALLABLE,决定用 JDBC 的哪种 Statement,默认 PREPARED
resultMaps 结果集到 Java 对象的映射规则,ResultSetHandler 处理 ResultSet 时依据这个字段
cache 该 namespace 对应的二级缓存对象(<cache/> 未配置则为 null),见第五节

需要强调的一点是:MappedStatement 里存的还不是最终能直接拿去执行的 SQL 文本,<if>/<foreach> 这类动态标签、${} 占位符都要等到真正调用 sqlSource.getBoundSql(parameterObject) 时(也就是拿到实际入参之后)才会被求值展开,这也是"动态 SQL"这个名字的由来。

1.3 与 Spring 整合:MapperScannerConfigurer 如何注册 Mapper Bean

要点:MapperScannerConfigurer 并不是把 Mapper 接口的"实现类"注册成 Spring Bean(因为压根不存在实现类),而是给每个接口注册一个 beanClass 指向 MapperFactoryBean 的 BeanDefinition;Spring 容器里看到的"Mapper Bean",其实是 FactoryBean.getObject() 在每次(单例场景下是第一次)被请求时返回的动态代理对象。

在 Spring / Spring Boot 项目里,通常通过 @MapperScan 或 XML 配置 MapperScannerConfigurer 指定要扫描的包,其核心流程:

  1. MapperScannerConfigurer 实现了 BeanDefinitionRegistryPostProcessor,在 Spring 容器刷新阶段的 Bean 定义注册环节被回调。
  2. 内部委托 ClassPathMapperScanner(继承自 Spring 的 ClassPathBeanDefinitionScanner)扫描指定包下所有接口文件。注意这里的关键点:Spring 常规组件扫描是找 @Component 之类注解标注的“类”,而 Mapper 扫描允许纯接口没有任何 Spring 注解也能被扫到——因为 ClassPathMapperScanner 重写了过滤逻辑,默认接受所有独立的接口(可通过 annotationClass/markerInterface 进一步限定)。
  3. 对扫描到的每一个接口,注册一个 BeanDefinition:beanClass 设置为 org.mybatis.spring.mapper.MapperFactoryBean,并把接口本身的 Class 对象设置进这个 BeanDefinition 的构造参数(对应 MapperFactoryBean 的 mapperInterface 属性)。

MapperFactoryBean<T> 的定位是 SqlSessionDaoSupport 的子类,同时实现 FactoryBean<T>:

// 简化还原,语义为主
public class MapperFactoryBean<T> extends SqlSessionDaoSupport implements FactoryBean<T> {
    private Class<T> mapperInterface;

    @Override
    public T getObject() throws Exception {
        return getSqlSession().getMapper(this.mapperInterface);
    }

    @Override
    public boolean isSingleton() {
        return true;
    }
}

getSqlSession() 拿到的是一个 SqlSessionTemplate(mybatis-spring 提供的、线程安全的 SqlSession 包装,内部按当前 Spring 事务上下文动态绑定真正的 SqlSession),.getMapper(mapperInterface) 最终委托回 Configuration.getMapper() —— 也就是又绕回了 MapperRegistry.getMapper() → MapperProxyFactory.newInstance() 生成 JDK 动态代理对象这条路径(见第二节)。

所以业务代码里 @Autowired private UserMapper userMapper; 注入进来的,本质上是 Spring 容器持有的一个 FactoryBean,真正被注入到字段上的对象,是这个 FactoryBean.getObject() 返回的动态代理实例——这也是为什么 Mapper 接口永远"没有实现类却能被调用",因为它从来就不需要静态编译期存在的实现类。

MapperFactoryBean.getObject() 里到底返回了什么

如果在 IDE 里对着注入的 Mapper 字段做 instanceof 或者打印 getClass(),会发现它的类型是 com.sun.proxy.$Proxy<N>(JDK 动态代理生成的类名)或者在较新 JDK 版本下是隐藏类形式的代理类,而不是任何一个看得到源码的 XxxMapperImpl。这个现象本身就是"Mapper 接口没有实现类"这个高频面试题最直观的验证方式,具体动态代理的生成机制见第二节。


二、Mapper 接口没有实现类,是怎么工作的

要点:Mapper 接口的方法调用能落地为一次 SQL 执行,靠的是 JDK 动态代理——MapperProxy 实现 InvocationHandler,拦截接口上的每一次方法调用,把方法名和参数包装成 MapperMethod,再转发给 SqlSession 对应的 select/insert/update/delete 方法,业务代码看到的"接口调用",实际上全程没有一行真正的接口实现代码。

2.1 JDK 动态代理:MapperProxy 与 MapperProxyFactory

MapperRegistry 内部维护一个 Map<Class<?>, MapperProxyFactory<?>>,每个注册过的 Mapper 接口都对应一个 MapperProxyFactory:

// 简化还原
public class MapperProxyFactory<T> {
    private final Class<T> mapperInterface;
    private final Map<Method, MapperMethodInvoker> methodCache = new ConcurrentHashMap<>();

    public T newInstance(SqlSession sqlSession) {
        MapperProxy<T> mapperProxy = new MapperProxy<>(sqlSession, mapperInterface, methodCache);
        return newInstance(mapperProxy);
    }

    protected T newInstance(MapperProxy<T> mapperProxy) {
        return (T) Proxy.newProxyInstance(
                mapperInterface.getClassLoader(),
                new Class[]{mapperInterface},
                mapperProxy);
    }
}

MapperProxy 就是这个代理对象背后的 InvocationHandler,它持有当前的 SqlSession、Mapper 接口的 Class,以及一个方法级别的缓存(methodCache,避免每次调用都重新构建 MapperMethod)。

2.2 MapperProxy.invoke() 的分支处理

一次 userMapper.selectById(1) 调用,实际落到 MapperProxy.invoke(Object proxy, Method method, Object[] args):

// 简化还原核心分支,真实实现细节(尤其 default 方法处理方式)随版本略有差异,建议对照本地源码复核
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    if (Object.class.equals(method.getDeclaringClass())) {
        // toString()/hashCode()/equals() 等 Object 自身方法,直接反射调用 MapperProxy 自己(走本地逻辑,不涉及 SQL)
        return method.invoke(this, args);
    }
    // 其余方法(包括接口的 default 方法与真正需要执行 SQL 的方法)统一走 MapperMethodInvoker
    final MapperMethodInvoker invoker = cachedInvoker(method);
    return invoker.invoke(proxy, method, args, sqlSession);
}

这里有两层判断需要拆开讲:

  1. Object 自身的方法(toString/hashCode/equals):因为动态代理对象本身没有覆写这些方法,如果不特殊处理,Proxy 生成的类默认行为也会走到 InvocationHandler,MyBatis 直接反射调用 MapperProxy 自己实现的对应方法(MapperProxy 自身重写了 toString/hashCode/equals),不会尝试去查找一条叫 toString 的 SQL,避免了明显不合理的行为。
  2. 接口的 default 方法(Java 8 起接口可以有默认实现):MyBatis 3.5.x 通过 MapperMethodInvoker 的两个具体实现区分——PlainMethodInvoker 处理需要执行 SQL 的普通抽象方法,内部持有一个 MapperMethod;DefaultMethodInvoker 处理接口自带默认实现的方法,通过 MethodHandle(privateLookupIn 等反射 API)直接调用接口自身的默认实现,不会经过 SQL 执行链路。这一段是版本演进中变动较多的实现细节,具体到某个小版本的确切写法建议对照本地源码复核。
  3. 真正需要执行 SQL 的方法:最终落到 PlainMethodInvoker 包装的 MapperMethod.execute(sqlSession, args)。

2.3 MapperMethod.execute() 如何分发到 SqlSession

MapperMethod 内部有两个核心组成部分:

  • SqlCommand:记录这个方法对应的 SQL 唯一标识(namespace.methodName)和 SqlCommandType(SELECT/INSERT/UPDATE/DELETE)。
  • MethodSignature:记录方法的返回值类型信息——是否 void、是否返回集合(returnsMany)、是否返回 Map(returnsMap,此时还要看有没有 @MapKey)、是否返回游标(returnsCursor)、参数列表里有没有 RowBounds/ResultHandler 这类特殊参数等。

execute() 方法的核心分发逻辑(简化还原):

public Object execute(SqlSession sqlSession, Object[] args) {
    Object result;
    switch (command.getType()) {
        case INSERT: {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = rowCountResult(sqlSession.insert(command.getName(), param));
            break;
        }
        case UPDATE: {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = rowCountResult(sqlSession.update(command.getName(), param));
            break;
        }
        case DELETE: {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = rowCountResult(sqlSession.delete(command.getName(), param));
            break;
        }
        case SELECT:
            if (method.returnsVoid() && method.hasResultHandler()) {
                executeWithResultHandler(sqlSession, args);
                result = null;
            } else if (method.returnsMany()) {
                result = executeForMany(sqlSession, args);
            } else if (method.returnsMap()) {
                result = executeForMap(sqlSession, args);
            } else if (method.returnsCursor()) {
                result = executeForCursor(sqlSession, args);
            } else {
                Object param = method.convertArgsToSqlCommandParam(args);
                result = sqlSession.selectOne(command.getName(), param);
            }
            break;
        // FLUSH 分支对应 sqlSession.flushStatements()
        default:
            throw new BindingException("Unknown execution method for: " + command.getName());
    }
    return result;
}

method.convertArgsToSqlCommandParam(args) 背后是 ParamNameResolver:把方法的参数数组转换成一个 Map<String, Object>——单个非特殊类型参数直接透传,多参数场景下按 @Param("xxx") 注解取名,没标注 @Param 时按 arg0, arg1...(3.4.1 之前是 param1, param2 系列,两套 key 在多数版本里是共存的,具体以本地实际调试为准)生成默认 key,供 XML 里 #{xxx} 按名取值。

这三节合起来就回答了"Mapper 接口没有实现类是怎么工作的"这个最高频问题:JDK 动态代理生成一个实现了 Mapper 接口的匿名类实例 → 方法调用被 MapperProxy.invoke() 统一拦截 → 按方法类型分流(Object 方法本地处理 / default 方法走 MethodHandle / 其余方法转 MapperMethod)→ MapperMethod.execute() 按 SQL 类型和返回值类型分发到 SqlSession 对应的方法,走到第三节的执行链路。


三、一次查询的完整执行链路

要点:SqlSession 只是门面,真正干活的是 Executor(先查一级缓存,未命中才碰数据库)→ StatementHandler(创建/预编译 JDBC Statement)→ ParameterHandler(把参数绑定进占位符)→ ResultSetHandler(把 ResultSet 反射映射成 Java 对象),这四个对象是 MyBatis 插件机制唯一能拦截的四个点,理解这条链路是理解插件机制的前提。

3.1 从 SqlSession 到 JDBC 的调用链

以 sqlSession.selectList(statement, parameter) 为例,完整链路:

SqlSession.selectList(statement, parameter)
  └─ Configuration.getMappedStatement(id) 取出 MappedStatement
  └─ Executor.query(ms, parameterObject, rowBounds, resultHandler)
       ├─ 先用 CacheKey 查一级缓存(BaseExecutor 的 localCache,见 5.1 节)
       │    命中 → 直接返回缓存结果,不碰数据库
       └─ 未命中 → queryFromDatabase() → doQuery(...)(抽象方法,由具体 Executor 子类实现)
            └─ Configuration.newStatementHandler() 创建 RoutingStatementHandler
                 (内部根据 ms.getStatementType() 代理到
                  SimpleStatementHandler / PreparedStatementHandler / CallableStatementHandler)
            └─ StatementHandler.prepare(connection, transactionTimeout)
                 对 PreparedStatementHandler:connection.prepareStatement(sql) 完成预编译
            └─ StatementHandler.parameterize(statement)
                 内部调用 ParameterHandler.setParameters(statement),
                 遍历 BoundSql 里的 ParameterMapping 列表,
                 通过对应 TypeHandler.setParameter(ps, i, value, jdbcType) 把参数值 set 进占位符
            └─ StatementHandler.query(statement, resultHandler)
                 真正执行 JDBC(statement.execute() / executeQuery()),拿到 ResultSet
            └─ ResultSetHandler.handleResultSets(statement)
                 按 MappedStatement 里的 resultMap/resultType 映射规则,
                 反射创建目标对象、TypeHandler 做类型转换、处理嵌套关联,
                 最终返回 List<E>

几个容易被面试追问的细节:

  • Executor 同时承担事务管理职责:Executor 持有 Transaction 对象,commit()/rollback()/close() 都会委托给它,SqlSession.commit() 本质上是转发给 Executor.commit()。
  • 二级缓存不在这条链路的 Executor 内部生效,而是通过 CachingExecutor 这个装饰器包在最外层,在真正调用被包装的 Executor.query() 之前先检查二级缓存,见第五节。
  • BoundSql 是"这次执行"专属的:同一个 MappedStatement 因为动态 SQL 和入参不同,每次调用得到的 BoundSql(最终 SQL 文本 + 参数映射列表)可能都不一样,所以一级缓存的 CacheKey 必须把实际参数值也纳入计算,而不能只用 MappedStatement 的 id。

3.2 三种 Executor 的区别与适用场景

Executor 核心行为 适用场景 备注
SimpleExecutor(默认) 每次执行都创建一个新的 Statement,用完立即关闭 普通、非批量的常规 CRUD mybatis-config.xml 未配置 defaultExecutorType 时的默认值
ReuseExecutor 以 SQL 文本为 key 缓存 Statement(Map<String, Statement>),同一 SqlSession 内相同 SQL 复用已预编译的 Statement;SqlSession 关闭时统一关闭所有缓存的 Statement 同一会话内会反复执行相同 SQL(不同参数)的场景,减少重复预编译开销 复用范围限定在同一个 SqlSession 内,跨 session 不共享
BatchExecutor 对连续的相同 INSERT/UPDATE/DELETE 语句调用 PreparedStatement.addBatch() 累积,直到显式 flushStatements() 或提交时才 executeBatch() 一次性发给数据库 批量写入场景(如批量入库几千条记录),显著减少网络往返次数 对 SELECT 无意义;批量执行结果的行数返回值在部分数据库驱动下的语义需结合具体驱动确认

指定 Executor 类型的两种方式:全局配置 <settings><setting name="defaultExecutorType" value="BATCH"/></settings>,或者调用时显式指定 sqlSessionFactory.openSession(ExecutorType.BATCH)。生产上常见做法是:日常业务走默认的 SimpleExecutor,批量导入这类专门场景单独用 ExecutorType.BATCH 打开一个 session 处理完就关闭,不会把 BatchExecutor 设成全局默认。


四、#{} 与 ${} 的区别

要点:#{} 在 SQL 解析阶段被替换成 JDBC 的 ? 占位符,参数值通过 PreparedStatement.setXxx() 在驱动层安全绑定,数据库会把它当"纯数据"而不是 SQL 语法的一部分;${} 在 SQL 解析阶段就已经是字符串拼接,最终生成的是包含真实值的 SQL 文本,本质上和手写字符串拼接 SQL 没有区别,因此存在注入风险。

4.1 #{} 预编译参数绑定

#{} 的处理发生在 SQL 解析阶段(构建 SqlSource 时,核心是 SqlSourceBuilder 内部的 ParameterMappingTokenHandler):#{name} 这样的占位符会被替换成 JDBC 标准的 ?,同时把 name 对应的属性名、jdbcType、typeHandler 等元信息封装成一个 ParameterMapping 对象,追加进这次 BoundSql 的 parameterMappings 列表。

真正执行时,ParameterHandler.setParameters(PreparedStatement ps) 遍历这份 parameterMappings 列表,依次调用对应 TypeHandler.setParameter(ps, i, value, jdbcType),最终落到 ps.setString(i, value) / ps.setInt(i, value) 这类 JDBC 标准 API。这是 JDBC 驱动层面的预编译占位符机制——SQL 语法结构在 prepareStatement(sql) 那一刻就已经编译确定,之后绑定的参数值无论内容是什么,都只会被当作"这个位置上的一个数据值",不会被数据库解析成 SQL 关键字或运算符,这正是能防止 SQL 注入的根本原因。

4.2 ${} 字符串拼接与注入风险

${} 的处理也发生在 SQL 解析阶段,但方式完全不同:DynamicSqlSource(含有动态标签或 ${} 时使用)在生成最终 SQL 字符串的过程中,通过 TextSqlNode 内部的表达式求值机制(对 ${} 表达式求值,早期版本基于 OGNL),直接把求值结果当字符串拼接进 SQL 文本,拼接完成之后才作为最终 SQL 交给 JDBC 处理。也就是说,${} 在 SQL 真正被预编译之前就已经完成了"值→SQL 文本"的替换,生成出来的已经是一句"包含真实内容的、确定的" SQL 语句——如果这个值直接或间接来自用户输入且未做任何校验,效果等价于手写字符串拼接 SQL,天然存在注入风险。

4.3 会与不会触发注入的代码示例

会触发注入的写法:

<!-- 危险:直接把用户输入拼进 SQL 文本 -->
<select id="findByName" resultType="User">
    SELECT * FROM user WHERE name = '${name}'
</select>

如果调用方传入 name = "a' OR '1'='1",最终拼出的 SQL 是:

SELECT * FROM user WHERE name = 'a' OR '1'='1'

OR '1'='1' 是恒真条件,整张表的数据都会被查出来,是最经典的 SQL 注入演示;如果传入的是带 ;DROP TABLE 或联合查询的构造字符串,配合驱动/数据库是否支持多语句执行,后果可能更严重。

安全的写法:

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

不论 name 传入什么内容,最终发给数据库的 SQL 结构始终是 SELECT * FROM user WHERE name = ?,a' OR '1'='1 这整个字符串只会被当作 name 字段要匹配的值去比较,不会有任何一条记录被恒真条件命中,也不会有语法层面的注入空间。

${} 并非完全没有存在的意义——JDBC 的 ? 占位符只能代表"一个值",不能代表 SQL 关键字、标识符(表名、列名、排序方向 ASC/DESC 等),这类场景只能用 ${} 做字符串替换。这时候安全性完全依赖 Java 代码自己对输入做白名单校验(比如排序字段只能是预先枚举好的几个合法列名,命中枚举才允许拼接,其余一律拒绝或使用默认值),而不能指望 MyBatis 框架层面提供任何防护。


五、一级缓存与二级缓存

要点:一级缓存是 SqlSession 级别、默认开启、无法真正关闭其数据结构只能让它总是失效的本地 Map;二级缓存是 namespace 级别、需要显式开启、由 CachingExecutor 装饰器介入,最大的坑是缓存粒度太粗导致跨表关联查询的脏读,生产上通常不直接用它,而是换成业务代码自己控制的外部缓存。

5.1 一级缓存:SqlSession 级别的 PerpetualCache

BaseExecutor(SimpleExecutor/ReuseExecutor/BatchExecutor 的共同父类)持有一个 PerpetualCache 类型的字段 localCache——PerpetualCache 本质就是对 HashMap 做了一层简单封装的缓存实现,没有淘汰策略、没有容量上限。

缓存的 key 是 CacheKey,由多个因子一起参与计算(MappedStatement 的 id、RowBounds 的 offset/limit、最终生成的 SQL 文本、全部实际入参值、当前 Environment 的 id 等)组合出一个复合 hashCode——意味着必须 SQL 语句和全部参数完全一致才会命中,参数只要有一个不同,就是不同的缓存条目。

一级缓存的失效场景:

触发条件 是否失效一级缓存
同一个 SqlSession 内,两次 select 参数和 SQL 完全一致 不失效,第二次直接命中缓存
同一个 SqlSession 内执行了任意 INSERT/UPDATE/DELETE 失效,BaseExecutor.update() 内部会调用 clearLocalCache() 清空整个一级缓存(因为无法精确判断这次写操作影响了哪些查询结果,索性全部清空)
显式调用 sqlSession.clearCache() 失效
SqlSession.close() 该 session 的一级缓存随对象一起被回收
不同的 SqlSession 之间 天然不共享——每个 SqlSession 对应独立的 Executor 实例,localCache 各自独立

Spring 整合场景下的一个常见认知误区:不少人以为"开了 MyBatis 就默认有一级缓存加速",但在 Spring 环境下,Mapper 方法通过 SqlSessionTemplate 执行,如果两次查询不在同一个事务(进而不在同一个真正的底层 SqlSession)范围内,各自会拿到不同的 SqlSession,一级缓存根本不会跨这两次调用生效。这也是"一级缓存在 Spring 项目里经常感觉不起作用"这个说法的来源,具体表现和 SqlSessionTemplate 的实现细节、事务传播行为强相关,建议结合实际项目配置对照源码复核,不要不加限定地断言"Spring 下一级缓存失效"。

5.2 二级缓存:namespace 级别与 CachingExecutor

二级缓存作用域是整个 Mapper namespace,默认不开启,需要在 Mapper XML 里显式声明:

<mapper namespace="com.example.mapper.UserMapper">
    <cache/>
    <!-- 也可以自定义淘汰策略、刷新间隔、是否只读等 -->
</mapper>

或者在接口上用 @CacheNamespace 注解达到同样效果。

底层仍然实现的是 Cache 接口,默认情况下 MyBatis 会用装饰器模式把多层能力叠加在最基础的 PerpetualCache 外面,典型的装饰链包括 LruCache(默认的淘汰策略)、SerializedCache(要求被缓存对象可序列化,缓存的是深拷贝而非引用)、LoggingCache、SynchronizedCache、ScheduledCache 等,具体默认叠加了哪几层、顺序如何,随版本可能有细节差异,建议对照本地源码复核。

CachingExecutor 是二级缓存介入执行链路的关键:当某个 namespace 开启了二级缓存后,MyBatis 在创建 Executor 时会用 CachingExecutor 把真正的 Executor(Simple/Reuse/Batch 其中之一)包装起来,形成典型的装饰器模式:

CachingExecutor.query(ms, parameterObject, rowBounds, resultHandler)
  ├─ 先检查该 MappedStatement 所属 namespace 的二级缓存(Cache 对象)
  │    命中 → 直接返回缓存结果,delegate(被包装的真正 Executor)完全不会被调用
  └─ 未命中 → delegate.query(...) 委托给真正的 Executor
       (这一步内部还会先查一级缓存,见 5.1 节,两级缓存互不冲突、各查各的)

二级缓存的写入是"事务性"的:查询到的结果不会立即写进 Cache,而是先放进一个和当前事务绑定的 TransactionalCache(由 TransactionalCacheManager 管理),只有等事务真正提交(commit())时才会把暂存的结果一次性刷进真正的二级缓存;如果事务回滚,这些暂存结果会被丢弃——这是为了避免把一个尚未提交、可能被回滚的事务中查到的数据缓存下来,造成其他事务读到"脏"数据。

5.3 二级缓存的脏读问题

这是二级缓存在生产环境上最大的坑,也是面试里经常追问的点:

典型场景

OrderMapper.xml 里有一条关联查询,一次查询里同时带出了订单信息和下单用户的名字(比如通过关联表 join 或者嵌套查询拿到 user.name),这条查询的结果会被缓存进 OrderMapper 自己 namespace 下的二级缓存。此时如果业务在别处调用了 UserMapper 更新了这个用户的名字,MyBatis 只会清空 UserMapper 自己 namespace 的二级缓存——因为 MyBatis 判断"要不要清空某个 namespace 的二级缓存"的依据非常简单粗暴:只要这个 namespace 下发生了写操作(INSERT/UPDATE/DELETE),就清空这个 namespace 自己的缓存,它并不理解 SQL 语义、不知道 OrderMapper 那条查询语句其实引用了 user 表的数据。于是 OrderMapper 那条关联查询下次再执行,命中的还是修改前的旧缓存,返回了一个已经过期的用户名——这就是跨 namespace 关联查询导致的二级缓存脏读。

结论:二级缓存的失效粒度是"namespace 是否发生过写操作",而不是"某个具体表的数据是否变化",一旦查询语句跨表关联,这种简单粗暴的失效策略就无法保证一致性。

因此生产上使用二级缓存要非常谨慎:

  • 可以考虑开启的场景:单表、只读或极少变更的数据(如字典表、配置表),且没有被其它 namespace 关联查询引用。
  • 不建议使用 MyBatis 自带二级缓存的场景:多表关联查询频繁、读写都活跃的核心业务数据——这类场景通常直接不开二级缓存,改用业务代码自己控制的外部缓存方案(如 Redis),因为外部缓存可以按具体的业务实体维度精确设计缓存 key 和失效策略(比如更新用户信息时,能精确地按 user:id 维度失效,而不是笼统地按整个 Mapper namespace 清空/保留),一致性可控性明显更好。这也是"二级缓存在真实生产项目里很少被启用"这个现象背后的根本原因。

六、插件机制原理

要点:MyBatis 插件能拦截的只有 Executor/ParameterHandler/ResultSetHandler/StatementHandler 这四大对象的方法调用,实现方式是 JDK 动态代理——Plugin.wrap() 根据 @Intercepts/@Signature 注解判断当前插件要不要包装这个对象,多个插件依次包装会形成一层套一层的代理链,调用时逐层触发各插件的 intercept(),是责任链模式的典型实现。

6.1 四大可拦截对象

Configuration 里创建这四类对象的工厂方法,内部都会调用 interceptorChain.pluginAll(instance) 把创建出来的实例交给拦截器链做一次包装:

// 简化还原
public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
    ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql);
    parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
    return parameterHandler;
}
// newExecutor / newStatementHandler / newResultSetHandler 逻辑类似,都会走 pluginAll()

也就是说,插件能切入的时机被严格限定在这四个接口的方法上,不能像 Spring AOP 那样任意拦截业务代码里的方法——这是 MyBatis 插件机制"够用但克制"的设计取舍:这四个对象覆盖了从"要不要真正执行" (Executor)、"参数怎么绑" (ParameterHandler)、"SQL 怎么预编译执行" (StatementHandler)、到"结果怎么映射" (ResultSetHandler) 这条链路上所有关键节点,已经足够支撑分页、SQL 审计、数据脱敏、多租户改写等绝大多数插件场景的需求。

6.2 @Intercepts 与 @Signature 如何声明拦截目标

自定义插件需要实现 Interceptor 接口,并在类上用注解声明"要拦截哪个接口的哪个重载方法":

@Intercepts({
    @Signature(
        type = Executor.class,
        method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
    )
})
public class MyInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 前置逻辑
        Object result = invocation.proceed(); // 放行给下一层(可能是真正的目标方法,也可能是下一个插件代理)
        // 后置逻辑
        return result;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        // 读取 <plugin> 标签下 <property> 配置的参数
    }
}

@Signature 里的 type + method + args(参数类型数组)三者组合,唯一确定了"这个插件要拦截的是 Executor 接口里签名为 query(MappedStatement, Object, RowBounds, ResultHandler) 的这一个重载方法"——之所以需要精确到参数类型数组,是因为 Executor/StatementHandler 这些接口本身就存在多个同名重载方法(比如 Executor.query 就有好几个重载),必须靠完整方法签名才能唯一定位。

6.3 Plugin.wrap() 与责任链

Interceptor.plugin(Object target) 的默认写法几乎都是直接返回 Plugin.wrap(target, this),真正的包装逻辑在 Plugin 这个工具类里:

// 简化还原核心逻辑
public class Plugin implements InvocationHandler {
    public static Object wrap(Object target, Interceptor interceptor) {
        Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor); // 解析 @Intercepts/@Signature
        Class<?> type = target.getClass();
        Class<?>[] interfaces = getAllInterfaces(type, signatureMap); // 只挑 target 实现了、且被该插件声明要拦截的接口
        if (interfaces.length > 0) {
            return Proxy.newProxyInstance(
                    type.getClassLoader(),
                    interfaces,
                    new Plugin(target, interceptor, signatureMap));
        }
        return target; // target 没有实现任何这个插件关心的接口,不包装,原样返回
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Set<Method> methods = signatureMap.get(method.getDeclaringClass());
        if (methods != null && methods.contains(method)) {
            return interceptor.intercept(new Invocation(target, method, args));
        }
        return method.invoke(target, args); // 不在拦截范围内的方法,直接透传给真正的 target
    }
}

关键点:Plugin.wrap() 会先判断 target 是否实现了这个插件签名里声明的接口,只有匹配才会用 JDK 动态代理包一层;Plugin.invoke() 里进一步判断"当前被调用的方法"是否恰好就是 @Signature 声明的那个方法,是才真正调用 interceptor.intercept(),否则直接透传给被代理的原始对象,不会误拦截同一个接口上没声明的其它方法。

多个插件叠加时的责任链效果:mybatis-config.xml 里 <plugins> 下配置了几个 <plugin>,Configuration 在创建 Executor/StatementHandler 等对象时,会依次对同一个 target 调用每一个已注册 Interceptor 的 plugin() 方法——第一个插件包一层代理,第二个插件在这个代理的基础上再包一层,如此层层嵌套。最终调用最外层代理的方法时,会先触发外层插件的 intercept(),它内部调用 invocation.proceed() 才会继续往里传递,依次触发下一层插件,直至到达最里层真正的目标对象——这就是典型的责任链模式,且是通过"层层代理嵌套"这种结构隐式实现的,而不是显式维护一个 List 遍历。

需要注意:插件包装的先后顺序和最终"谁在最外层、先执行"之间的对应关系,容易在不同资料里出现说法不一致的情况(是先注册的在最外层还是后注册的在最外层,和 InterceptorChain.getInterceptors() 遍历顺序以及 <plugins> 里配置的先后顺序直接相关)。这一点建议实际写一个简单的两插件 demo,在各自 intercept() 里打印日志对照本地源码验证顺序,而不要死记某个结论,因为这个细节确实容易记错、也偶尔随版本实现调整。

6.4 典型应用:分页插件的实现思路

以 PageHelper 一类分页插件的核心机制为例(不是逐行还原某个具体开源项目的源码,只讲思路),通常会拦截 Executor.query() 或者更下游的 StatementHandler 相关方法(常见做法是拦截 StatementHandler 的 prepare 方法,因为这一步之前 SQL 文本还可以被修改):

  1. 业务代码调用 PageHelper.startPage(pageNum, pageSize),本质是把分页参数存进一个 ThreadLocal,跟当前线程绑定,且这个 ThreadLocal 通常设计成"用一次就清空",避免污染同线程后续不相关的查询。
  2. 插件拦截到 StatementHandler.prepare() 调用时,先检查当前线程的 ThreadLocal 里有没有分页参数:没有则直接放行,不做任何改写;有则往下走。
  3. 通过反射拿到当前 StatementHandler 内部已经生成好的 BoundSql(MetaObject 是 MyBatis 提供的反射工具类,插件里访问这类内部字段基本都靠它),取出原始 SQL 文本。
  4. 根据当前数据源使用的数据库方言(MySQL 用 LIMIT offset, size,Oracle/SQL Server 用 ROWNUM/OFFSET FETCH 等不同语法),把原始 SQL 包一层或改写成带分页子句的新 SQL;同时通常还会再拼一条 COUNT 查询获取总记录数,用于计算总页数。
  5. 通过 MetaObject 把改写后的 SQL 反射设置回 BoundSql 的 sql 字段,让后续 StatementHandler.parameterize()/执行阶段使用的是这条已经带了分页子句的新 SQL。

这套机制之所以要拦在 StatementHandler.prepare() 之前(或者更早的 Executor.query 阶段),核心原因是 SQL 文本一旦进入 connection.prepareStatement(sql) 预编译阶段就不能再改了——分页插件必须赶在这一步之前完成 SQL 重写,这也是判断"某个功能该拦截四大对象里的哪一个"的通用思路:看这个功能需要在执行链路的哪个时间点介入,是要改 SQL 文本(拦 StatementHandler)、改参数(拦 ParameterHandler)、还是改最终返回结果(拦 ResultSetHandler)、或是要控制整个查询要不要真正执行(拦 Executor)。


七、延迟加载原理

要点:延迟加载靠的是给关联属性先塞一个字节码生成的代理对象占位,真正调用这个属性的 getter 时代理才会触发一次额外的 SQL 查询把真实数据填进去,做到"用到才查",但要注意代理对象脱离原 SqlSession 生命周期后再访问会出问题。

开启方式:<settings><setting name="lazyLoadingEnabled" value="true"/></settings>,作用于 <association>/<collection> 里通过 select 属性指定的嵌套查询(也就是"分步查询"关联对象的写法)。

原理简述:ResultSetHandler 在映射主对象时,遇到配置了延迟加载的关联属性,不会立即执行那条嵌套查询,而是:

  1. 通过字节码生成技术(历史上 MyBatis 默认基于 Javassist,也支持配置为 CGLIB,具体默认值和可配置项随版本可能有调整,建议对照本地 proxyFactory 相关配置和源码复核)为主对象类型动态生成一个代理子类实例,替代真实的关联对象先占位挂在主对象上。
  2. 这个代理对象背后关联一个 ResultLoaderMap,记录了"哪些属性还没有真正加载"、以及加载这个属性所需要的全部信息(对应的嵌套查询 MappedStatement id、已经从主查询结果里能确定的关联参数值等)。
  3. 业务代码第一次真正调用这个关联属性的 getter 方法时,代理对象的方法拦截逻辑会检查 ResultLoaderMap 里这个属性是否还处于"待加载"状态:如果是,就同步执行那条嵌套查询的 SQL,拿到结果后通过反射设置到真实字段上,再放行本次 getter 调用,返回刚加载好的数据;如果已经加载过,直接返回已缓存的真实值,不会重复查询。

这样做的价值在于避免"查一个订单顺带把关联的用户、商品、物流等一大堆关联对象无论用不用得到全部预先加载"造成的不必要数据库开销,把加载动作推迟到真正用到那个属性的时刻。

常见的踩坑点:如果关联对象所在的父对象已经跨出了原来的 SqlSession 生命周期才第一次访问延迟属性(例如对象被序列化传输到别的进程、缓存起来跨请求复用、或者在原 session 已经关闭之后的另一个线程里才访问这个属性),此时代理对象内部持有的 SqlSession/Executor 可能已经不可用(连接已关闭),会抛出异常或者拿到不符合预期的结果——这是使用延迟加载时需要特别注意的边界情况,也是"实体类跨层传递时最好确保关联属性已经被真正访问过一次,或者干脆不依赖延迟加载"这类工程实践建议的由来。


八、常见面试高频问题清单

要点:这一节是前七节内容的问答体浓缩版,每题给出可以直接在面试里说出口的简答,展开细节参考对应章节。

8.1 #{} 和 ${} 的区别

#{} 在 SQL 解析阶段被替换成 JDBC 的 ? 占位符,参数通过 ParameterHandler 借助 TypeHandler 调用 PreparedStatement.setXxx() 在驱动层安全绑定,数据库把它当纯数据处理,能防止 SQL 注入;${} 在 SQL 解析阶段就直接完成字符串替换,生成的是包含真实值的最终 SQL 文本,等价于手写字符串拼接,存在注入风险。能用 #{} 的地方一律用 #{},${} 只在必须拼接 SQL 关键字/标识符(动态表名、列名、排序方向)等 #{} 无法表达的场景使用,且必须在 Java 代码里对拼接内容做白名单校验。详见第四节。

8.2 Mapper 接口没有实现类,是怎么被调用的

MyBatis 通过 JDK 动态代理为每个 Mapper 接口生成一个运行时代理对象(MapperProxyFactory.newInstance() 借助 Proxy.newProxyInstance()),代理对象的 InvocationHandler 是 MapperProxy。方法调用被 MapperProxy.invoke() 拦截后分流处理:Object 自身方法本地直接调用,接口 default 方法通过 MethodHandle 调用接口自身实现,其余方法包装成 MapperMethod 并调用 execute(),按 SQL 类型和方法返回值类型分发到 SqlSession 对应的 select/insert/update/delete 方法,最终走到 Executor 执行 SQL。整个过程从始至终不需要一个手写或编译期生成的实现类。详见第二节。

在 Spring 环境下,容器里注入的"Mapper Bean"是 MapperFactoryBean.getObject() 每次返回的这个动态代理对象,MapperScannerConfigurer 负责把每个扫描到的接口注册成一个 beanClass 为 MapperFactoryBean 的 BeanDefinition。详见 1.3 节。

8.3 一级缓存和二级缓存的区别,二级缓存有什么坑

维度 一级缓存 二级缓存
作用域 SqlSession 级别 Mapper namespace 级别
默认状态 默认开启,无法彻底关闭(只能让它总失效) 默认关闭,需要显式 <cache/> 或 @CacheNamespace
实现 BaseExecutor 内的 PerpetualCache(localCache) CachingExecutor 装饰被包装的真正 Executor,配合 TransactionalCache 做事务性写入
失效条件 同 session 内任意增删改、clearCache()、session 关闭 对应 namespace 内发生任意增删改
跨会话共享 不共享 共享(同一 namespace 下所有 SqlSession 都能命中)

二级缓存最大的坑是缓存失效粒度按 namespace 走、不理解 SQL 语义:某条查询语句关联了多张表(比如 OrderMapper 里一条 SQL 关联查了 user 表的字段),只要不是 UserMapper 自己 namespace 下发生写操作,OrderMapper 的缓存并不会因为 user 表数据变了而失效,导致查询命中一份已经过期的关联数据——这就是二级缓存的脏读问题。因此生产上对二级缓存要谨慎,涉及多表关联、写多读多的核心数据通常直接不开,改用 Redis 等外部缓存按业务实体维度精确控制失效。详见第五节。

8.4 MyBatis 插件的实现原理

插件只能拦截 Executor/ParameterHandler/ResultSetHandler/StatementHandler 这四大对象。实现 Interceptor 接口,用 @Intercepts/@Signature 声明要拦截的接口、方法名、参数类型;Configuration 创建这四类对象时都会调用 interceptorChain.pluginAll(),内部依次调用每个 Interceptor.plugin()(通常直接 return Plugin.wrap(target, this)),Plugin.wrap() 判断 target 是否实现了该插件关心的接口,是则用 JDK 动态代理包一层,代理的 InvocationHandler 就是 Plugin 自身;调用代理方法时 Plugin.invoke() 判断该方法是否命中 @Signature 声明,命中则调用 interceptor.intercept(new Invocation(...)),否则透传给原始对象。多个插件依次包装会形成层层嵌套的代理链,intercept() 内部调用 invocation.proceed() 才会继续调用下一层,构成责任链模式。详见第六节。

8.5 MyBatis 是如何进行分页的,分页插件原理

MyBatis 自带的 RowBounds 是内存分页(先把满足条件的全部结果查出来,再在 Java 层跳过 offset 条、截取 limit 条),本质没有减少数据库端的扫描和网络传输量,数据量大时性能很差,通常不直接用在生产环境的大表分页上。

生产上常用的是 PageHelper 一类基于插件机制的物理分页:拦截 Executor.query()(或 StatementHandler.prepare())方法,在 SQL 真正预编译执行之前,通过反射改写 BoundSql 里的 SQL 文本,按当前数据库方言拼上 LIMIT(MySQL)或对应分页语法(Oracle/SQL Server 等),让分页动作下推到数据库层完成,同时通常还会自动生成一条 COUNT 查询获取总记录数。分页参数一般通过 ThreadLocal 在业务代码调用 startPage() 和插件真正介入之间传递。详见 6.4 节。


结语:面试回答的通用框架

回答 MyBatis 相关问题时,比较稳妥的表达顺序是:这个机制表面上解决了什么问题 → 背后具体经过哪些对象/哪条调用链 → 默认实现有什么局限或适用边界 → 生产上遇到这个局限时会怎么权衡取舍。比如被问"二级缓存能不能用",不要只回答"能,配置 <cache/> 就行",而是完整讲清楚"二级缓存解决的是跨 SqlSession 复用查询结果、减少数据库压力的问题 → 实现上是 CachingExecutor 装饰真正的 Executor,按 namespace 维度缓存、事务提交时才真正写入 → 但失效粒度按 namespace 走、不理解 SQL 语义,多表关联查询容易脏读 → 因此生产上简单只读表可以开,核心业务数据通常不开,改用 Redis 等外部缓存做更精细的失效控制"——这个"问题 → 机制 → 局限 → 权衡"的框架同样适用于 Mapper 动态代理、Executor 分层、插件责任链、延迟加载等几乎所有 MyBatis 面试题,比单纯背诵结论更能体现出对源码和工程取舍的真实理解。

posted @ 2026-07-20 15:44  zhangph  阅读(23)  评论(0)    收藏  举报