MyBatis 源码拆解:一条 SQL 从 Java 代码到数据库经历了什么

引言

团队里新来的同学提了个问题:"MyBatis 里面写的接口都没实现类,方法怎么就能执行 SQL 了?"我让他用 IDEA 在 sqlSession.selectOne() 打个断点,F5 步入跟一层——十分钟后他回来,表情像打开了新世界:"原来插件能拦我 SQL、二级缓存影响我查询、还有一层参数和结果映射,全在这条调用链里。"

这个问题背后的能力,是面试和排障的分水岭

  • 面试问"MyBatis 插件原理",答不上 Plugin.invoke 和责任链,说明只停留在 API 层;
  • 线上查"为什么这条 SQL 打了 SQL 日志却没走缓存"、"为什么驼峰映射突然失效",不懂 ResultSetHandler 和 TypeHandler 的分工就只能瞎猜;
  • 写分页插件、慢 SQL 监控插件(比如 PageHelper),本质上都是在给这条调用链"加料"。

这篇文章以 sqlSession.selectOne("orderMapper.getOrder", 10086L) 为起点,沿着SqlSession → Executor(CachingExecutor 装饰)→ StatementHandler(RoutingStatementHandler 路由)→ ParameterHandler → TypeHandler → ResultSetHandler → 插件拦截链逐层深入。每一层都给:源码关键片段(MyBatis 3.5.x)、IDEA 断点跟踪路径、以及"这一层存在的意义"。


一、全景时序图:先看骨架再看血肉

先给全景,后面逐层拆。断点跟踪时对着这张图走,不会迷路:

用户代码
  │  sqlSession.selectOne("orderMapper.getOrder", 10086L)
  ▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. SqlSession 接口层(DefaultSqlSession)                         │
│    直接委托给 Executor,自己只做"传话人"                            │
└──────────────┬──────────────────────────────────────────────────┘
               ▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. Executor 层(SimpleExecutor / ReuseExecutor / BatchExecutor) │
│    外面套着 CachingExecutor 装饰器                                │
│    ├─ 查二级缓存命中?→ 直接返回,不下游                            │
│    └─ 未命中 → SimpleExecutor.doQuery()                          │
│         ├─ StatementHandler 创建(RoutingStatementHandler 路由)  │
│         ├─ prepareStatement:连接池拿 Connection → 编译 SQL        │
│         └─ 交给 StatementHandler 执行                             │
└──────────────┬──────────────────────────────────────────────────┘
               ▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. StatementHandler 层(路由出 PreparedStatementHandler)          │
│    ├─ parameterize() → 委托 ParameterHandler                      │
│    ├─ 委托 Plugin 拦截链(四大对象都在这时被代理)                    │
│    └─ execute() → JDBC PreparedStatement.executeUpdate/query      │
└──────────────┬──────────────────────────────────────────────────┘
               ▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. ParameterHandler(DefaultParameterHandler)                    │
│    遍历参数映射 → TypeHandler.setParameter() → JDBC setXxx         │
└─────────────────────────────────────────────────────────────────┘
               ▼
         JDBC → MySQL(真正的 SQL 执行)
               ▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. ResultSetHandler(DefaultResultSetHandler)                    │
│    JDBC ResultSet → TypeHandler.getNullableResult() 类型转换      │
│    → 按映射规则组装 Java Bean(自动映射/ResultMap/嵌套查询)          │
│    → 返回 List<T>                                                 │
└──────────────┬──────────────────────────────────────────────────┘
               ▼
     逐层返回:ResultSetHandler → StatementHandler → Executor
               → 一路写入一级缓存(PerpetualCache)→ SqlSession → 用户

记住两个角色分工,整条链就懂了一半:

角色职责类比
Executor管全局的事:缓存、事务、Statement 生命周期"项目经理"
StatementHandler/ParameterHandler/ResultSetHandler管单条 SQL 的事:执行、参数、结果"干活的工人"

二、起点:SqlSession——最"薄"的一层

2.1 断点跟踪第一步

sqlSession.selectOne(...) 打断点,F5 步入:

① DefaultSqlSession.selectOne(statement, parameter)
②   → DefaultSqlSession.selectList(statement, parameter)     // selectOne 就是 selectList 取第 0 个
③     → executor.query(ms, wrapCollection(parameter), RowBounds.DEFAULT, Executor.NO_RESULT_HANDLER)

三个立刻能看到的细节:

// DefaultSqlSession.java(删减)
@Override
public <T> T selectOne(String statement, Object parameter) {
    List<T> list = this.selectList(statement, parameter);
    if (list.size() == 1) {
        return list.get(0);
    } else if (list.size() > 1) {
        throw new TooManyResultsException(       // ← selectOne 查出多行会抛异常,就是这来的
            "Expected one result (or null) to be returned by selectOne(), but found: "
                + list.size());
    } else {
        return null;
    }
}

为什么说 SqlSession 是"最薄的一层"selectList 的实现就是一行 executor.query(...)——它不做缓存、不做执行、不做映射,只做三件事:把 namespace.id 换成 MappedStatement(后面解释)、把参数做一次包装、把活儿递给 Executor。SqlSession 是短生命周期的(一个请求/一个事务一个),线程不安全;真正干活的是它背后的 Executor。

wrapCollection 是个高频坑的源头:接口方法只传了一个 List 或数组时,MyBatis 会把它包成 map(list/array 为 key)——这就是为什么某些单参数场景 #{} 能随便写,而另一些场景必须写 #{list[0]} 或加 @Param。断点跟到这里看一眼返回值,这个坑就永远懂了。

2.2 MappedStatement:SQL 的"元数据档案"

executor.query 的第一个参数是 MappedStatement,它是 namespace.id 对应的一切元数据的封装:

字段装着什么后续被谁消费
idorderMapper.getOrder日志/缓存 key 组成部分
sqlSource解析后的 SQL(含 ${}/#{} 占位)StatementHandler 编译 SQL
parameterMap/parameterType参数映射元数据ParameterHandler
resultMap/type结果映射规则、返回类型ResultSetHandler
statementTypeSTATEMENT/PREPARED/CALLABLERoutingStatementHandler 路由
cache对应的二级缓存(XML 里 `` 或 @CacheNamespaceCachingExecutor

启动时 XML/注解里每一条 SQL 都被解析成一个 MappedStatement 存进 Configuration(key = namespace.id)。Configuration 是 MyBatis 的全局配置仓库,后面每一层都从它取数据


三、Executor 层:CachingExecutor 装饰器与二级缓存

3.1 装饰器结构:为什么打印的 Executor 有"两层"

断点跟到 executor.query(...) 步入时,你大概率会发现调用栈里出现两次 query 方法——这不是断点抖了,是装饰器模式:

CachingExecutor.query()          ← 装饰层:二级缓存在这
    └─ delegate.query()          ← 委托出去
        SimpleExecutor.query()   ← 实干层:BaseExecutor 模板方法

打开 DefaultSqlSessionFactory 的创建代码,一眼看清结构:

// Configuration.newExecutor()(删减)
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
        executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
        executor = new ReuseExecutor(this, transaction);
    } else {
        executor = new SimpleExecutor(this, transaction);      // 默认
    }
    if (cacheEnabled) {                                        // ← 默认 true!
        executor = new CachingExecutor(executor);              // 装饰器包裹
    }
    executor = (Executor) interceptorChain.pluginAll(executor); // 插件链(第七章)
    return executor;
}

三种 Executor 的区别(ExecutorType 可配,Spring 集成里由 SqlSessionTemplate 管理):

类型行为场景
SimpleExecutor每次 SQL 新建一个 Statement默认
ReuseExecutor复用相同 SQL 的 Statement大批量同构 SQL
BatchExecutor攒批 addBatch,flush 时统一执行批量插入/更新

3.2 CachingExecutor:二级缓存的"守门员"

// CachingExecutor.java(删减)
public <E> List<E> query(MappedStatement ms, Object parameterObject,
                         RowBounds rowBounds, ResultHandler resultHandler,
                         CacheKey key, BoundSql boundSql) {
    Cache cache = ms.getCache();            // 这个 namespace 配了二级缓存吗?
    if (cache != null) {
        flushCacheIfRequired(ms);           // <flushCache=true> 的语句先清缓存
        if (ms.isUseCache() && resultHandler == null) {
            List<E> list = (List<E>) tcm.getObject(cache, key);
            if (list == null) {
                list = delegate.query(ms, parameterObject, rowBounds,   // 未命中,往下走
                        resultHandler, key, boundSql);
                tcm.putObject(cache, key, list);   // 结果装进缓存
            }
            return list;
        }
    }
    return delegate.query(...);
}

两个经典疑问在这里解密

  1. "为什么我的二级缓存不生效?"——ms.getCache() 返回 null:XML mapper 里没写 <cache/> 或接口没加 @CacheNamespace。全局 cacheEnabled=true 只是"允许使用",namespace 级声明才真正启用。
  2. "为什么拿到的列表是不可改的(UnsupportedOperationException)?"——二级缓存写入的是序列化快照,读出来是只读拷贝(要求实体可序列化)。缓存对象被业务直接改写导致脏读,正是很多人建议关闭二级缓存的原因——多表 join 场景下 namespace 间的缓存刷新无法联动,一致性问题无解。

3.3 CacheKey:二级缓存和一级缓存的身份证

缓存命中的前提是两次查询"完全一样"。CacheKey 由什么构成(BaseExecutor.createCacheKey):

MappedStatement.id + RowBounds.offset + RowBounds.limit
    + SQL 字符串本身
    + 每一个参数值(逐个 update() 进来)
    + 分页等额外信息

所以 getOrder(10086L)getOrder(10087L) 是两个不同的 key;而同一个事务里、同一条语句、同参数的第二次查询——这就是一级缓存的命中场景。一级缓存在 BaseExecutor 内部(PerpetualCache,HashMap 实现),作用域是 SqlSession;Spring 集成下 SqlSession 通常请求级复用,所以"同请求两次相同查询第二次不发 SQL"的现象由此而来。断点技巧:在 PerpetualCache.getObject 打条件断点(key 里含 10086),能直接观察一级缓存命中。


四、StatementHandler:从 Executor 到 JDBC 的桥梁

4.1 RoutingStatementHandler:一个"伪门面"

SimpleExecutor.doQuery 里的关键一行:

// SimpleExecutor.doQuery() → configuration.newStatementHandler(...)
public StatementHandler newStatementHandler(...) {
    StatementHandler handler = new RoutingStatementHandler(...);
    handler = (StatementHandler) interceptorChain.pluginAll(handler);   // ← 插件代理
    return handler;
}

// RoutingStatementHandler 构造器(删减)——它自己不干活,纯路由
public RoutingStatementHandler(Executor executor, MappedStatement ms, ...) {
    switch (ms.getStatementType()) {
        case STATEMENT:  delegate = new SimpleStatementHandler(...);    break;
        case PREPARED:   delegate = new PreparedStatementHandler(...);  break;  // 默认
        case CALLABLE:   delegate = new CallableStatementHandler(...);  break;  // 存储过程
    }
}

断点跟踪提示:在 RoutingStatementHandler 上步入,会"连续穿过"两层——先到 Routing 的 statement 方法,再自动落到 delegate(PreparedStatementHandler)的同名方法。三种子类对应 statementType 配置:默认 PREPARED(预编译,防注入的根基就在这层复用 JDBC PreparedStatement),STATEMENT 直接拼 SQL,CALLABLE 走存储过程。

4.2 prepareStatement:连接的获取与 SQL 的编译

// BaseStatementHandler.prepare() → instantiateStatement()(删减)
private Statement instantiateStatement(Connection connection) throws SQLException {
    if (mappedStatement.getStatementType() == StatementType.CALLABLE) {
        return connection.prepareCall(sql);          // 存储过程
    }
    if (keyGenerator instanceof Jdbc3KeyGenerator) {
        return connection.prepareStatement(sql, RETURN_GENERATED_KEYS);  // 要自增主键
    }
    return connection.prepareStatement(sql);         // 标准预编译
}

到这里,SQL 正式从 MyBatis 世界进入 JDBC 世界。几个源码级认知:

  • Connection 从哪来executor.getTransaction().getConnection()——Spring 集成下就是事务管理器绑定的 DataSource 连接(HikariCP 池里的连接)。MyBatis 从不自己管连接,连接池是外部的。
  • BoundSql 是什么:SqlSource 在启动/运行时把动态 SQL(<if>/<where>/<foreach>)解析完,产出带 ? 占位符的最终 SQL 和参数映射列表。断点看 boundSql.getSql(),就是发给数据库的那条 SQL 原文——线上慢 SQL 日志里打印的 SQL 就是从这拿的
  • statementTimeoutprepare() 里会设置 statement.setQueryTimeout(...),默认走全局 defaultStatementTimeout——找不到超时来源时回这里查。

4.3 parameterize → 四大对象的插件代理时机

// PreparedStatementHandler.parameterize()
public void parameterize(Statement statement) {
    parameterHandler.setParameter((PreparedStatement) statement);
}

调用前注意 newStatementHandler 里的那行 pluginAll(handler)——四大对象(Executor、StatementHandler、ParameterHandler、ResultSetHandler)都是在这里被 JdkDynamicAopProxy 风格的代理层层包住的。所以你在 IDEA 里 step into 时会先进 Plugin.invoke(第七章细讲)——这是很多人第一次跟踪源码时"明明进的 StatementHandler,怎么跳到 Plugin 里去了"的真相。


五、ParameterHandler 与 TypeHandler:参数怎么变成 ? 的值

5.1 DefaultParameterHandler:遍历参数映射逐个 set

// DefaultParameterHandler.setParameters()(删减)
public void setParameters(PreparedStatement ps) {
    List<ParameterMapping> mappings = boundSql.getParameterMappings();
    for (int i = 0; i < mappings.size(); i++) {
        ParameterMapping pm = mappings.get(i);
        if (pm.getMode() != ParameterMode.OUT) {              // 存储过程 OUT 参数跳过
            Object value = ...
            // 三个来源:额外参数 map / paramNameResolver 定位的实际参数 / null
            TypeHandler typeHandler = pm.getTypeHandler();     // ← 每个占位符配一个 TypeHandler
            JdbcType jdbcType = pm.getJdbcType();
            try {
                typeHandler.setParameter(ps, i + 1, value, jdbcType);   // 转 Java 值 → JDBC 值
            } catch (TypeException | SQLException e) {
                throw new TypeException("Could not set parameters for mapping #{" + pm.getProperty() + "}", e);
            }
        }
    }
}

断点定位"参数绑定错误":报 Could not set parameters for mapping #{}... 时,这个循环的 i 就是第几个 ?pm.getProperty() 就是出问题的参数名,typeHandler 的 class 直接告诉你它以为这个参数是什么类型——比看报错栈快得多。

#{}${} 的区别在这个链路里也一目了然:#{} 在 SqlSource 解析期就变成 ? 进 ParameterMapping 列表,值经 TypeHandler 安全 set;${} 在解析期直接文本拼接进 BoundSql 的 SQL 字符串,ParameterHandler 根本看不到它——注入风险的本质就是绕过了这套参数绑定机制。

5.2 TypeHandler:Java 类型 ↔ JDBC 类型的双向翻译官

// IntegerTypeHandler(所有内置 TypeHandler 的样子都类似)
public class IntegerTypeHandler extends BaseTypeHandler<Integer> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                    Integer parameter, JdbcType jdbcType) throws SQLException {
        ps.setInt(i, parameter);
    }
    @Override
    public Integer getNullableResult(ResultSet rs, String columnName) throws SQLException {
        int result = rs.getInt(columnName);
        return result == 0 && rs.wasNull() ? null : result;   // JDBC 的 0 和 SQL 的 NULL 语义区分
    }
}

内置的 TypeHandler 注册在 TypeHandlerRegistry,断点跟踪时值得看两处:

  1. TypeHandlerRegistry.getTypeHandler(Class, JdbcType)——查表逻辑。查不到(比如实体里有个没注册的自定义类型)时抛 Could not find a TypeHandler,或返回 UnknownTypeHandler 让它运行时猜。枚举、LocalDateTime 这些"感觉能自动映射"的类型,其实是启动时批量注册好的registerDefaultHandlers)。
  2. 枚举映射的两个内置选择EnumTypeHandler(按 name 字符串映射)vs EnumOrdinalTypeHandler(按序号)——序号版本在枚举中间插值时会全军覆没,团队里建议统一用 name 或自定义 handler 映射 code。

什么时候要写自定义 TypeHandler:库里存 JSON 字符串而实体是对象、金额字段统一 BigDecimal 精度、加密字段透明加解密——都只在"Java 类型 ↔ JDBC 值"这一处拦截,Mapper 层零感知。


六、ResultSetHandler:从 ResultSet 到 Java 对象

SQL 执行完(StatementHandler.query → ps.execute() 后拿到底层 ResultSet),接力棒交到最后一棒:

// PreparedStatementHandler.query()
public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
    PreparedStatement ps = (PreparedStatement) statement;
    ps.execute();                                              // 真正的 JDBC 执行
    return resultSetHandler.handleResultSets(ps);              // ← 结果映射
}

6.1 DefaultResultSetHandler 的五步工作流

源码很长,但主干流程固定:

① wrapCollection:参数是 List/Map 时包装(和 2.1 的 wrapCollection 呼应,是另一处同名逻辑)
② handleResultSet:按 ResultMap(或自动映射规则)逐行处理
③ 属性填充:每列通过 TypeHandler.getNullableResult() 取 JDBC 值 → setter 注入
   ├─ 自动映射:列名 userName → 驼峰 userName(mapUnderscoreToCamelCase)→ setter
   └─ 显式 ResultMap:<result column="user_name" property="userName"/>
④ 嵌套映射:
   ├─ association/collection 联表内嵌 → join 数据按规则拆行组装
   └─ 嵌套 select(<association select="...">)→ 触发额外的 MappedStatement 查询
      (著名的 N+1 问题源头就在这:外层 100 行 = 内层 100 次查询)
⑤ 存一级缓存 + 返回 List

驼峰映射失效的排查路径(高频现场):断点直接停在 DefaultResultSetHandler.applyAutomaticMappings 附近,观察 createAutomaticMappings 生成的映射列表——如果列名没对上(库列 user_name 而实体字段 username),这一步能直接看出"哪个列被跳过了"。比改一遍全局配置来回试快十倍。

6.2 懒加载的一角

aggressiveLazyLoading=false(3.4.1+ 默认)时,嵌套 select 的 association 属性是个代理对象,首次访问该属性才真正发 SQL。断点在嵌套查询的 MappedStatement 上打条件断点,会发现调试器一展开对象树,查询立即触发——调试器自己把懒加载"点爆"了,这是源码调试期最容易迷惑的现象。


七、插件拦截链:Plugin.invoke 的责任链

前面第七层的伏笔:四大对象创建时都被 interceptorChain.pluginAll(...) 包过。这是 MyBatis 扩展能力的全部秘密。

7.1 代理结构:洋葱模型

// InterceptorChain.pluginAll()
public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
        target = interceptor.plugin(target);       // 依次包裹
    }
    return target;
}
// Interceptor.plugin 的默认实现(Plugin 帮你生成 JDK 动态代理)
public default Object plugin(Object target) {
    return Plugin.wrap(target, this);
}

假设注册了两个插件(SQL 日志、分页),对 StatementHandler 而言的结构:

被调用 StatementHandler.query()
  → Plugin(分页插件).invoke()          ← 最外层(后注册的先包)
      → Plugin(SQL日志插件).invoke()
          → PreparedStatementHandler.query()   ← 真正的对象
洋葱模型:invoke 里 target.proceed() 之前的代码 = SQL 执行前,之后 = 执行后

7.2 断点跟踪 Plugin.invoke

// Plugin.java(删减)——拦截的核心逻辑
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Set<Method> methods = signatureMap.get(method.getDeclaringClass());
    if (methods != null && methods.contains(method)) {      // 这个方法被 @Signature 签名了吗?
        return interceptor.intercept(new Invocation(target, method, args));   // → 走插件逻辑
    }
    return method.invoke(target, args);                     // 不拦截,原样放行
}

断点技巧:在 Plugin.invoke 打断点,看 signatureMap 里命中的方法名——能直观看到"每个对象被哪些插件拦截了哪些方法",排查"我的插件为什么没生效/拦多了"的最快路径。

7.3 可拦截的矩阵:四大对象 × 关键方法

拦截对象常用 @Signature 方法典型插件
Executorupdate, query, commit二级缓存实现、SQL 耗时统计、慢 SQL 记录
StatementHandlerprepare, parameterize, query分页 SQL 改写(PageHelper)、SQL 防火墙
ParameterHandlersetParameters参数加密/脱敏
ResultSetHandlerhandleResultSets结果解密、敏感数据脱敏

八、实战:手写一个自定义 MyBatis 插件

理论到头了,来一发实战。目标:实现一个SQL 执行时间打点 + 慢 SQL 告警插件(公司自研监控没接 OpenTelemetry 时的轻量方案)。

8.1 定义拦截器

/**
 * 慢 SQL 监控插件:拦截 StatementHandler.query / update
 * 逻辑:委托前记时间 → 委托真执行 → 统计耗时并告警
 */
@Intercepts({
        @Signature(type = StatementHandler.class, method = "query",
                args = {Statement.class, ResultHandler.class}),
        @Signature(type = StatementHandler.class, method = "update",
                args = {Statement.class})
})
@Component                                                    // Spring 集成下自动注册
public class SlowSqlMonitorPlugin implements Interceptor {

    /** 超过 500ms 记 warn */
    private static final long SLOW_THRESHOLD_MS = 500;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        // 反射拿 BoundSql(四大对象的可访问字段都是私有的,插件里反射是惯例)
        BoundSql boundSql = handler.getBoundSql();
        String sql = boundSql.getSql().replaceAll("\\s+", " ");

        long start = System.currentTimeMillis();
        try {
            return invocation.proceed();                      // ← 放行:真执行
        } finally {
            long cost = System.currentTimeMillis() - start;
            if (cost > SLOW_THRESHOLD_MS) {
                MappedStatement ms = (MappedStatement)
                        ReflectHelper.getFieldValue(handler, "mappedStatement");
                log.warn("[慢SQL] {}ms | {} | {}",
                        cost, ms.getId(), sql);
            } else {
                log.debug("[SQL] {}ms | {}", cost, sql);
            }
        }
    }

    /** 目标不是 StatementHandler 时原样返回(安全网) */
    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }
}

8.2 验证:用断点看插件"长"在链上

启动应用后重复第三章的断点路径,这次会有新发现:

executor.query 步入
  → CachingExecutor.query
    → SimpleExecutor.query
      → newStatementHandler → pluginAll(handler)     ← 这时 target 已经是代理
      → RoutingStatementHandler.statement(parameterize/query)
        → Plugin(SlowSqlMonitorPlugin).invoke()      ← 断点在这停下
          → invocation.proceed()
            → PreparedStatementHandler.parameterize / query   ← 真正的对象

对应一次 selectOne 的日志输出:

DEBUG [SQL] 12ms | orderMapper.getOrder
WARN  [慢SQL] 623ms | orderMapper.listByDateRange

8.3 插件开发的四条军规

军规原因
别拦 Executor 的 query 覆盖太多层分页插件改造 SQL 时拦 StatementHandler 的 prepare 更精准;在 Executor 层改写会和缓存 key 错位
proceed() 必须调用且只调用一次忘调 = SQL 没执行;调两次 = SQL 执行两遍(都是真实事故级别的 bug)
插件里别有慢逻辑插件在所有 SQL 的关键路径上,插件的 1ms = 全站每个 SQL 的 1ms
多插件顺序要评估后注册的在洋葱外层、先执行 pre 逻辑;分页插件和加密插件的前后顺序决定行为差异

九、常见问题

9.1 为什么接口没有实现类,方法却能执行 SQL?

动态代理:MapperProxy implements InvocationHandler,接口代理对象的 invoke 拦截所有方法调用,转成 MapperMethod(依据方法全限定名定位 MappedStatement,判断是 insert/update/delete/select)→ 最终调到 sqlSession 的对应方法。断点入口:MapperProxy.invoke —— 和本文的主链路正好接上。

9.2 一级缓存、二级缓存到底谁先谁后?

顺序是反直觉的:CachingExecutor(二级缓存)在最外层先查,未命中才进 BaseExecutor(一级缓存)查,再未命中才真正执行 SQL。所以一次查询的缓存检查顺序是:二级 → 一级 → 数据库。跨 SqlSession(比如不同请求)只可能命中二级;同 SqlSession 两次相同查询,二级未开启时靠一级兜底。

9.3 ${} 注入风险在源码里怎么体现?

${} 在 SqlSource 构建期(TextSqlNode/DynamicSqlSource)就把用户值拼进 SQL 文本,最终 BoundSql 里没有对应的 ParameterMapping——ParameterHandler 的循环根本轮不到它,值已经"长"在 SQL 字符串里了。#{} 则始终是 ? 占位 + TypeHandler set。这就是"数据位必须 #{}"在源码层面的铁证。

9.4 插件能拦截 MyBatis-Plus / PageHelper 吗?

能,但注意它们本身就是插件。PageHelper 是 StatementHandler 层插件(prepare 拦截,改写 SQL 加 LIMIT);你的插件和它组成洋葱链,顺序影响行为(比如你的耗时统计想包住分页改写后的执行,就该比 PageHelper 更晚注册,站在外层)。

9.5 怎么把这条链路用于日常排障?

三条断点路径背下来:① 参数绑定错 → DefaultParameterHandler.setParameters;② 结果映射错 → DefaultResultSetHandler.applyAutomaticMappings;③ SQL 执行慢 → Plugin.invoke 配合自定义打点插件。加上一个 boundSql.getSql() 条件断点(SQL 里含关键字),任何"莫名其妙的 SQL"都能在两分钟内定位到来源 MappedStatement。


十、总结

调用链速查卡

┌──────────────────┬────────────────────────────────────────────────┐
│ 层               │ 关键类与职责                                     │
├──────────────────┼────────────────────────────────────────────────┤
│ SqlSession       │ DefaultSqlSession:薄委托层 + selectOne 多行检查   │
│ Executor         │ CachingExecutor(二级缓存装饰)+ SimpleExecutor   │
│                  │ (一级缓存/事务/Statement 生命周期)               │
│ StatementHandler │ RoutingStatementHandler 路由 → PreparedStatement  │
│                  │ Handler:编译 SQL、parameterize、JDBC 执行        │
│ ParameterHandler │ 遍历 ParameterMapping → TypeHandler.setParameter  │
│ TypeHandler      │ Java↔JDBC 类型双向转换(双向:参数侧 + 结果侧)      │
│ ResultSetHandler │ 自动映射/ResultMap/嵌套查询 → 组装 Java Bean       │
│ 插件链           │ Plugin.invoke 责任链,四大对象创建时被代理包裹      │
└──────────────────┴────────────────────────────────────────────────┘

断点路径速查

排障目标断点位置
SQL 从哪来BoundSql.getSql() 条件断点(含关键字)
参数绑定错DefaultParameterHandler.setParameters
结果映射错DefaultResultSetHandler.applyAutomaticMappings
缓存没生效CachingExecutor.query(看 ms.getCache() 是否为 null)
插件行为排查Plugin.invoke(看 signatureMap 命中情况)

一句话

一条 SQL 的旅程是四段接力:SqlSession 把方法调用翻译成 MappedStatement,Executor 带着两级缓存和事务决定"要不要执行、怎么执行",StatementHandler 把参数和 SQL 交给 JDBC 并把 ResultSet 捡回来,ParameterHandler/TypeHandler/ResultSetHandler 分别解决"参数进得去、类型对得上、结果出得来"。插件不过是给这条流水线的四个工位装了"监工"——读懂这条链,MyBatis 的一切配置、异常、插件都从魔法变成代码。

给团队的建议

场景建议
新人入职带着走一遍本文断点路径,一小时内建立整链路心智模型
排障三个断点路径 + boundSql 条件断点作为标准动作
扩展需求优先插件(耗时/脱敏/防火墙),遵守 8.3 四条军规
类型扩展枚举映射统一策略,特殊类型写 TypeHandler 而不是在业务层转换
缓存二级缓存默认关,需要时明确评估一致性风险

互动话题:你写的第一个 MyBatis 插件是干什么的?跟踪源码时被 Plugin.invoke 绕晕过吗?评论区聊聊。


参考资料


标题:MyBatis 源码拆解:一条 SQL 从 Java 代码到数据库经历了什么
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/07/1788586703615.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消