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 对应的一切元数据的封装:
| 字段 | 装着什么 | 后续被谁消费 |
|---|---|---|
| id | orderMapper.getOrder | 日志/缓存 key 组成部分 |
| sqlSource | 解析后的 SQL(含 ${}/#{} 占位) | StatementHandler 编译 SQL |
| parameterMap/parameterType | 参数映射元数据 | ParameterHandler |
| resultMap/type | 结果映射规则、返回类型 | ResultSetHandler |
| statementType | STATEMENT/PREPARED/CALLABLE | RoutingStatementHandler 路由 |
| cache | 对应的二级缓存(XML 里 `` 或 @CacheNamespace) | CachingExecutor |
启动时 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(...);
}
两个经典疑问在这里解密:
- "为什么我的二级缓存不生效?"——
ms.getCache()返回 null:XML mapper 里没写<cache/>或接口没加@CacheNamespace。全局cacheEnabled=true只是"允许使用",namespace 级声明才真正启用。 - "为什么拿到的列表是不可改的(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 就是从这拿的。 - statementTimeout:
prepare()里会设置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,断点跟踪时值得看两处:
TypeHandlerRegistry.getTypeHandler(Class, JdbcType)——查表逻辑。查不到(比如实体里有个没注册的自定义类型)时抛Could not find a TypeHandler,或返回UnknownTypeHandler让它运行时猜。枚举、LocalDateTime 这些"感觉能自动映射"的类型,其实是启动时批量注册好的(registerDefaultHandlers)。- 枚举映射的两个内置选择:
EnumTypeHandler(按 name 字符串映射)vsEnumOrdinalTypeHandler(按序号)——序号版本在枚举中间插值时会全军覆没,团队里建议统一用 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 方法 | 典型插件 |
|---|---|---|
| Executor | update, query, commit | 二级缓存实现、SQL 耗时统计、慢 SQL 记录 |
| StatementHandler | prepare, parameterize, query | 分页 SQL 改写(PageHelper)、SQL 防火墙 |
| ParameterHandler | setParameters | 参数加密/脱敏 |
| ResultSetHandler | handleResultSets | 结果解密、敏感数据脱敏 |
八、实战:手写一个自定义 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 GitHub 源码仓库
- MyBatis 官方文档:Java API
- MyBatis 官方文档:插件(插件开发指南)
- MyBatis 官方文档:XML 映射器(缓存章节)
- MyBatis TypeHandlerRegistry 源码
- PageHelper(StatementHandler 层插件实现参考)
标题:MyBatis 源码拆解:一条 SQL 从 Java 代码到数据库经历了什么
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/07/1788586703615.html
公众号:服务端技术精选
- 引言
- 一、全景时序图:先看骨架再看血肉
- 二、起点:SqlSession——最"薄"的一层
- 2.1 断点跟踪第一步
- 2.2 MappedStatement:SQL 的"元数据档案"
- 三、Executor 层:CachingExecutor 装饰器与二级缓存
- 3.1 装饰器结构:为什么打印的 Executor 有"两层"
- 3.2 CachingExecutor:二级缓存的"守门员"
- 3.3 CacheKey:二级缓存和一级缓存的身份证
- 四、StatementHandler:从 Executor 到 JDBC 的桥梁
- 4.1 RoutingStatementHandler:一个"伪门面"
- 4.2 prepareStatement:连接的获取与 SQL 的编译
- 4.3 parameterize → 四大对象的插件代理时机
- 五、ParameterHandler 与 TypeHandler:参数怎么变成 ? 的值
- 5.1 DefaultParameterHandler:遍历参数映射逐个 set
- 5.2 TypeHandler:Java 类型 ↔ JDBC 类型的双向翻译官
- 六、ResultSetHandler:从 ResultSet 到 Java 对象
- 6.1 DefaultResultSetHandler 的五步工作流
- 6.2 懒加载的一角
- 七、插件拦截链:Plugin.invoke 的责任链
- 7.1 代理结构:洋葱模型
- 7.2 断点跟踪 Plugin.invoke
- 7.3 可拦截的矩阵:四大对象 × 关键方法
- 八、实战:手写一个自定义 MyBatis 插件
- 8.1 定义拦截器
- 8.2 验证:用断点看插件"长"在链上
- 8.3 插件开发的四条军规
- 九、常见问题
- 9.1 为什么接口没有实现类,方法却能执行 SQL?
- 9.2 一级缓存、二级缓存到底谁先谁后?
- 9.3 ${} 注入风险在源码里怎么体现?
- 9.4 插件能拦截 MyBatis-Plus / PageHelper 吗?
- 9.5 怎么把这条链路用于日常排障?
- 十、总结
- 调用链速查卡
- 断点路径速查
- 一句话
- 给团队的建议
- 参考资料
评论