Skip to content

MyBatis 源码剖析 ​

Java 高级 · 底层原理

MyBatis 以"半自动 ORM"著称,优雅之处不在封装了多少,而在于把 JDBC 的样板代码(建连接、预编译、设参、遍历 ResultSet、关资源)拆成一条可插拔的执行链。本文从 SqlSessionFactory 构建讲到 Mapper 代理、Executor 体系、参数/结果映射与插件责任链,让你不仅会用,还能写出自己的拦截器。

一、启动:SqlSessionFactory 与 Configuration ​

SqlSessionFactory 是全局单例的"工厂的工厂",由 SqlSessionFactoryBuilder.build() 构建,核心产物是 Configuration——它是 MyBatis 的"全局配置中枢",所有 MappedStatement、类型处理器、拦截器、二级缓存都挂在它上面。

java
// 构建流程: 读 XML → 解析成 Document → XMLConfigBuilder 填充 Configuration
InputStream is = Resources.getResourceAsStream("mybatis-config.xml");
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(is);
// 内部: new XMLConfigBuilder(is, environment, props).parse() → 返回 Configuration

Configuration 里最关键的数据结构是 Map<String, MappedStatement> mappedStatements(key 是 namespace + id,即你调用 Mapper 方法时拼出的全限定名)。每个 MappedStatement 封装了一条 SQL 的元信息:SqlSource(SQL 来源)、ParameterMap、ResultMap、statementType(STATEMENT/PREPARED/CALLABLE)、flushCacheRequired、useCache 等。

TIP

MyBatis 是线程不安全的层级:SqlSessionFactory(全局单例)→ SqlSession(一次会话,线程不安全,用完必须 close)→ Executor(SqlSession 持有)→ StatementHandler 等。这和 Spring 整合后"为啥 Mapper 单例却安全"不矛盾——Spring 给每个 Mapper 注入的是代理,真正执行时从 SqlSessionTemplate 临时取 SqlSession(通过 TransactionSynchronizationManager 在同事务内复用)。

二、Mapper 接口代理:接口怎么变成可调用对象 ​

你只写了接口没写实现类,MyBatis 却能 userMapper.selectById(1),秘密是 JDK 动态代理。

java
// MapperProxyFactory.newInstance 生成代理
public T newInstance(SqlSession sqlSession) {
    MapperProxy<T> handler = new MapperProxy<>(sqlSession, mapperInterface, methodCache);
    return Proxy.newProxyInstance(mapperInterface.getClassLoader(),
                                  new Class[]{mapperInterface}, handler);
}
// MapperProxy.invoke: 把方法调用转成 MapperMethod 执行
public Object invoke(Object proxy, Method method, Object[] args) {
    if (Object.class.equals(method.getDeclaringClass())) return method.invoke(this, args);
    final MapperMethod mapperMethod = cachedMapperMethod(method);
    return mapperMethod.execute(sqlSession, args);   // 内部按 CRUD 类型调 session 对应方法
}

MapperMethod.execute 根据方法上的 @Select/XML 中的 SqlCommandType 决定调 sqlSession.selectOne/insert/...,并把方法名(接口全限定名)+ 参数拼成 MappedStatement 的 id,交给执行链。这样"调用接口方法"和"执行 SQL"就桥接起来了。

三、Executor:执行链的真正发动机 ​

SqlSession 把活儿交给 Executor,它有三种实现:

Executor行为场景
SimpleExecutor每条 SQL 新建 Statement、用完即关默认、简单
ReuseExecutor缓存 Statement(按 SQL 文本)复用同会话反复执行同 SQL
BatchExecutor批量 addBatch,攒批 executeBatch大批量写入

开启二级缓存时,会套一层 CachingExecutor(装饰器模式):先查二级缓存,未命中才委派内层 Executor,命中后回填。所以调用顺序是:CachingExecutor → (BaseExecutor) → StatementHandler。

3.1 参数与结果:TypeHandler 桥接 JDBC ​

  • ParameterHandler.setParameters:把 Java 参数按 TypeHandler 转成 JDBC 类型(StringTypeHandler → ps.setString,DateTypeHandler → ps.setTimestamp 等)。#{} 生成预编译占位符 ?,从根本上防 SQL 注入。
  • ResultSetHandler.handleResultSets:按 resultMap(或自动映射)把 ResultSet 列映射到 Java 对象。支持 association(一对一)、collection(一对多)、嵌套 resultMap、以及 autoMapping 驼峰映射(mapUnderscoreToCamelCase=true)。

WARNING

  • #{} 与 ${} 混用导致注入:${} 是字符串直接拼接(无法预编译),只允许用于表名/排序字段(且必须白名单校验);查询条件一律 #{}。
  • 一级缓存跨方法串数据:SqlSession 级别一级缓存,同会话重复查会命中;但多会话或事务隔离可能读到旧值。写多读少场景建议 localCacheScope=STATEMENT(每次查询清缓存)。
  • resultMap 字段名不匹配静默为空:下划线未开驼峰映射会映射失败。
  • Mapper 接口重载:XML 中 id 必须唯一,接口重载但 SQL id 相同会冲突(MyBatis 不支持方法重载按参数区分)。

四、插件机制:责任链 + 动态代理 ​

MyBatis 的四大可拦截点:Executor、StatementHandler、ParameterHandler、ResultSetHandler。插件基于责任链 + JDK 动态代理:InterceptorChain.pluginAll 把每个插件用 Plugin.wrap(动态代理)层层包裹目标对象。

java
// Plugin.wrap: 用 InvocationHandler 代理, 命中签名才走 intercept
public static Object wrap(Object target, Interceptor interceptor) {
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    Class<?> type = target.getClass();
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    return Proxy.newProxyInstance(type.getClassLoader(), interfaces,
        new Plugin(target, interceptor, signatureMap));
}

// 自定义分页插件: 拦截 Executor.query
@Intercepts({@Signature(type = Executor.class, method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
public class PageInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation inv) throws Throwable {
        MappedStatement ms = inv.getArgs()[0];
        Object param = inv.getArgs()[1];
        // 1. 取出原始 SQL  2. 拼 count 与 limit  3. 重写 BoundSql 替换 args[0]
        // 4. 先查总数, 再改分页参数执行
        return inv.proceed();   // 责任链继续向下
    }
    @Override public Object plugin(Object target) { return Plugin.wrap(target, this); }
}

mybatis-config.xml 注册:<plugins><plugin interceptor="com.demo.PageInterceptor"/></plugins>。多个插件按配置顺序形成链,执行顺序与包裹顺序相反(最先包装的最后执行),调试时务必注意。

TIP

插件能拦 Executor 就拿到 MappedStatement 和 BoundSql,能拦 ParameterHandler 就改入参,能拦 ResultSetHandler 就改出参——这是 MyBatis 做分页、多租户隔离、逻辑删除、数据脱敏、SQL 审计的统一入口。但插件是全局生效、对性能有影响(每层代理一次方法调用),非必要不滥加。

五、缓存与一级/二级差异 ​

  • 一级缓存:SqlSession 级别,BaseExecutor.localCache 的 PerpetualCache。同会话内相同查询命中;update/commit/close 会清空。默认开启,无法关闭(可降为 STATEMENT 级)。
  • 二级缓存:Mapper(namespace) 级别,多个 SqlSession 共享,需 <cache/> 开启 + 实体类实现 Serializable。读多写少、且数据变化不频繁时才有价值。分布式部署下二级缓存是本地内存,多节点不一致——生产请用 Redis 等集中缓存替代。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 画出 userMapper.selectById(1) 从 MapperProxy.invoke 到 ResultSetHandler 返回对象的完整调用链,标注每一层职责。
  2. 写一个基于 @Intercepts 的 SQL 执行耗时统计插件(拦截 StatementHandler 的 update/query,用 invocation.proceed() 前后计时)。
  3. 解释一级缓存与二级缓存的作用范围、生命周期差异,并说明什么场景应把 localCacheScope 设为 STATEMENT。
  4. 读 InterceptorChain.pluginAll 源码,解释多个插件叠加时 intercept 的实际执行顺序。

本节小结

MyBatis 的核心是一条"代理 → Executor → Handler"的执行链:MapperProxy 把接口调用转成 MappedStatement 执行;Executor 决定 Statement 策略(Simple/Reuse/Batch,外加 CachingExecutor 装饰);参数与结果都靠 TypeHandler 桥接 JDBC;Interceptor 用责任链在任意 Handler 上织入逻辑(分页/多租户/脱敏)。吃透执行链,你就能写出任何定制化数据访问能力。