← 返回博客
2026-10-29 08:00:01

一次查询穿过 MyBatis 的四道关卡

一次查询穿过 MyBatis 的四道关卡

你写 userMapper.selectById(1L),一行代码就拿到了对象。中间发生了什么?面试官爱问这条链路,因为它能把 SqlSession、Executor、StatementHandler、ParameterHandler、两级缓存和插件全串起来。这篇就顺着一次查询走一遍,每到一个岔口停下来讲清楚为什么这么设计。

手把手实操:把链路打出来

先别急着看源码,让链路自己说话。建一张表,写一个最普通的 Mapper 方法,然后加一个插件把四个组件都打印出来。

@Intercepts({
  @Signature(type = Executor.class, method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
  @Signature(type = StatementHandler.class, method = "prepare",
    args = {Connection.class, Integer.class}),
  @Signature(type = ParameterHandler.class, method = "setParameters",
    args = {PreparedStatement.class})
})
public class TracePlugin implements Interceptor {
  @Override
  public Object intercept(Invocation inv) throws Throwable {
    System.out.println(">>> " + inv.getTarget().getClass().getSimpleName()
        + "." + inv.getMethod().getName());
    return inv.proceed();
  }
}

注册到 mybatis-config.xml 的 里,跑一次查询。你会看到 Executor 的 query 先出现,接着是 StatementHandler.prepare,再是 ParameterHandler.setParameters。

关键点在 @Signature 的 args 必须和真实方法签名完全一致,写错一个参数类型插件就不生效,而且不报错,静默跳过。这是最常见的踩坑。inv.proceed() 不能漏,漏了原方法不执行,查询直接返回 null。

再跑第二次同样的查询,你会发现 Executor 的 query 还在,但底层 JDBC 没再发 SQL——一级缓存接住了。

看源码及解析:四道关卡各管什么

SqlSession 是门面,不是干活的。 DefaultSqlSession.selectOne 最终调 executor.query。它自己只做参数个数校验和结果集取第一条,真正的活全交给 Executor。为什么这么分?因为 SqlSession 是有状态的(连接、事务、缓存作用域),而 Executor 才是可替换的策略点。openSession() 默认 ExecutorType.SIMPLE,还有 REUSE 和 BATCH 两种,换的是 Executor 实现类,SqlSession 不用动。

Executor 管缓存和路由。 BaseExecutor.query 先查 localCache,命中就直接返回;没命中才 queryFromDatabase,查完 localCache.put。这个 localCache 就是一级缓存,作用域是 SqlSession,默认开着,localCacheScope 默认 SESSION。改成 STATEMENT 后每次查询结束就清空,等于关掉。这里有个坑:同一个 SqlSession 里两次查询之间如果执行了 update,BaseExecutor.update 会调 clearLocalCache(),所以一级缓存不会脏。但如果你在两次查询之间用另一个 SqlSession 改了数据,当前会话是看不到的——这是面试常追问的点。

StatementHandler 管 SQL 怎么发。 RoutingStatementHandler 按 MappedStatement.statementType 选 Simple、Callable 或 Prepared。默认 Prepared,prepare 方法里调 connection.prepareStatement,同时触发 ParameterHandler.setParameters 和插件链。SQL 里的 ${} 在这一层之前就被 SqlSource 拼好了,#{} 则留成 ? 占位符。

ParameterHandler 管占位符填值。 DefaultParameterHandler.setParameters 遍历 ParameterMapping,按 TypeHandler 把 Java 值塞进 PreparedStatement。#{} 走这里,天然防注入;${} 在 SqlSource 阶段就拼进 SQL 文本,有注入风险。这个区别面试必问。

插件是动态代理插进来的。 Plugin.wrap 用 JDK 动态代理包住四大组件,InterceptorChain.pluginAll 在创建时逐个套。所以插件能拦的只有这四类对象的方法,拦不到 SqlSession 本身。

验证方法

改 localCacheScope 为 STATEMENT,再跑两次相同查询,日志里应该出现两条 SQL,说明一级缓存被绕过了。给插件 @Signature 故意写错参数类型,确认插件静默失效——这能帮你记住签名必须精确匹配。最后在两次查询之间插一次 update,观察 clearLocalCache 的效果。

面试速答

核心收获:MyBatis 的每个组件只干一件事,缓存、SQL 生成、参数绑定各归各管。下一步,把 localCacheScope 改成 STATEMENT 亲手跑一遍,看 SQL 日志怎么变。

本文关键词:MyBatis、SqlSession、Executor、StatementHandler、ParameterHandler、一级缓存、二级缓存、插件、@Signature、localCacheScope