一次 mapper.selectById(id) 调用,既涉及方法到 SQL 的定位,也涉及参数命名、动态 SQL、缓存判等、JDBC 绑定和对象组装。重复查询没有发送 SQL、批量插入迟迟没有回填主键、一对多查询返回重复对象,往往发生在这些组件的交接处。

MyBatis 在启动时把 Mapper 描述编译成元数据,执行时围绕 MappedStatement 派发请求。理解这两段过程,才能判断某项配置在哪一层生效、一次方法调用何时真正访问数据库。

核心实现以 MyBatis 3.5.19 的固定版本源码为基准。Spring 部分以 MyBatis-Spring 3.0.4 的事务与会话实现为基准,单独说明集成层边界;不涉及 MyBatis-Plus 等增强框架。文中的 SQL 和代码是机制示例,不代表所有数据库驱动的行为;主键回填、游标和批处理需结合实际驱动验证。

总体架构

MyBatis 可以分成两条主线:构建期主线和执行期主线。

构建期负责把 XML、注解、类型别名、类型处理器、插件、缓存配置等材料编译成一个 Configuration 对象。执行期从 SqlSessionFactory 打开 SqlSession,再通过 MapperProxy 或 SqlSession API 找到 MappedStatement,一路委托到 Executor、StatementHandler、ParameterHandler、ResultSetHandler 和 JDBC。

MyBatis 构建期与执行期总览

这个图里最重要的是 Configuration。它不是普通配置类,而是 MyBatis 的运行时元数据仓库。MappedStatement、ResultMap、Cache、Interceptor、TypeHandler、ObjectFactory、LanguageDriver、MapperRegistry 都挂在这里。一次查询真正运行时,MyBatis 不是重新解析 XML,而是围绕 Configuration 里已经建好的对象图做派发。

XML 的结构解析发生在构建期;其中的动态表达式仍会在执行期求值。Configuration 通常在初始化完成后复用,不应在并发请求中随意修改注册表或插件列表。

三个入口对象

官方文档把 MyBatis 的核心入口压缩成三个对象:SqlSessionFactoryBuilder、SqlSessionFactory、SqlSession。这三个对象刚好对应三个生命周期。

对象 生命周期 线程安全 职责
SqlSessionFactoryBuilder 构建期临时对象 不应复用为服务对象 读取配置,构造 SqlSessionFactory
SqlSessionFactory 应用级单例 可长期复用 持有 Configuration,创建 SqlSession
SqlSession 请求/事务级短生命周期对象 不可跨线程共享 持有 Executor,执行 CRUD 与提交回滚

SqlSessionFactoryBuilder 的存在感很弱,但它划出了一条边界:解析配置不是每次 SQL 执行都做的事。构建完成后,DefaultSqlSessionFactory 内部持有同一个 Configuration。每次 openSession(),工厂从 Environment 中取出 DataSource 和 TransactionFactory,创建 Transaction,再由 Configuration.newExecutor() 创建执行器,最后组装成 DefaultSqlSession。

这个创建过程解释了一个常见误解:SqlSession 不是轻量的“数据库连接包装器”。它确实通过 Transaction 间接拿连接,但它更重要的身份是一次 MyBatis 执行上下文,里面有本次会话的 Executor、一级缓存、脏数据标记和提交回滚边界。

DefaultSqlSessionFactory.openSessionFromDataSource(...) 的源码骨架可以压缩成四步:

1
2
3
4
5
Environment environment = configuration.getEnvironment();
TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
Transaction tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
Executor executor = configuration.newExecutor(tx, execType);
return new DefaultSqlSession(configuration, executor, autoCommit);

MyBatis 创建 SqlSession 时沿着 Environment -> TransactionFactory -> Transaction -> Executor -> DefaultSqlSession 逐层组装。Environment 提供数据源和事务工厂,TransactionFactory 决定连接事务的管理方式,Executor 决定 SQL 的执行策略,DefaultSqlSession 暴露会话 API。

Configuration 是总装配台

Configuration 的职责密度很高,几乎所有核心组件都能从这里追到。它至少承担四类职责。

第一类是注册表。typeAliasRegistry 处理别名,typeHandlerRegistry 处理 Java 类型与 JDBC 类型转换,mapperRegistry 处理 Mapper 接口与代理工厂,languageRegistry 处理脚本语言驱动。

第二类是语句仓库。mappedStatements 保存每个 SQL 语句的最终描述,key 通常是 namespace.id。Mapper 接口方法能够定位 SQL,本质上靠的就是这个 id 约定:接口全限定名作为 namespace,方法名作为 statement id。

第三类是对象工厂。Configuration 负责创建 Executor、StatementHandler、ParameterHandler、ResultSetHandler,并在这些对象创建之后调用 interceptorChain.pluginAll()。插件之所以能拦截执行器、语句处理器、参数处理器和结果集处理器,是因为这些对象都经过 Configuration 的统一创建点。

第四类是全局行为开关。比如 cacheEnabled、lazyLoadingEnabled、mapUnderscoreToCamelCase、defaultExecutorType、localCacheScope 等配置并不散落在各处,而是在运行时被组件按需读取。

这些创建点也决定了插件的作用范围。绕过 Configuration.newXxx() 自行构造处理器,就不能假设它经过相同的插件包装。

Mapper XML 如何变成 MappedStatement

Mapper XML 的解析不是简单地把 SQL 字符串放进 Map。MyBatis 会把一条 <select>、<insert>、<update>、<delete> 编译成 MappedStatement,它是执行期最核心的语句描述对象。

一个 MappedStatement 通常包含这些信息:

字段/关联对象 作用
id 语句唯一标识,通常是 namespace.methodName
sqlSource SQL 来源,负责根据参数生成 BoundSql
statementType STATEMENT、PREPARED、CALLABLE
sqlCommandType SELECT、INSERT、UPDATE、DELETE
parameterMap 参数映射描述,现代用法里更多由 ParameterMapping 承担
resultMaps 结果集到对象的映射规则
cache namespace 级二级缓存引用
flushCacheRequired / useCache 缓存刷新和读取策略
keyGenerator 主键回填策略

Mapper XML 的语句部分经过 XMLMapperBuilder → XMLStatementBuilder → LanguageDriver.createSqlSource()。默认 XML 语言驱动再通过 XMLScriptBuilder 解析脚本节点,构造 SqlSource,最后由构建助手注册 MappedStatement。

SqlSource 是这里的关键。MyBatis 并不把 XML 中的 SQL 直接当字符串执行,而是把它包装成能在运行时产出 BoundSql 的对象。

静态 SQL 会尽量提前解析。动态 SQL 则会被解析成 SqlNode 树,比如 IfSqlNode、ForEachSqlNode、ChooseSqlNode、TrimSqlNode、MixedSqlNode。运行时传入参数后,DynamicSqlSource 通过这棵树生成最终 SQL,再产生 BoundSql。

BoundSql 是 SQL 执行前的最后形态:它包含最终 SQL 字符串、参数映射列表、原始参数对象,以及动态 SQL 产生的额外参数。#{} 会变成 ? 和 ParameterMapping;${} 会直接拼进 SQL 字符串。前者走 JDBC 参数绑定,后者是字符串替换,必须只用于白名单字段名、排序方向等无法参数化的位置。

Mapper 接口为什么能执行 SQL

Mapper 接口本身没有实现类。sqlSession.getMapper(UserMapper.class) 返回的是 JDK 动态代理,代理背后是 MapperProxy。

调用链可以写成这样:

1
2
3
4
5
UserMapper.selectById(1)
-> MapperProxy.invoke(...)
-> MapperMethod.execute(sqlSession, args)
-> SqlSession.selectOne / selectList / insert / update / delete
-> Executor.query / update

MapperProxy 只负责代理分发,不真正理解 SQL。真正把“Java 方法”翻译成“MyBatis 命令”的是 MapperMethod。它内部有两个重要对象:

内部对象 职责
SqlCommand 根据 Mapper 接口和方法名解析 MappedStatement id,并识别 SQL 命令类型
MethodSignature 解析方法返回值、参数名、RowBounds、ResultHandler、是否返回集合/Map/Optional 等

返回 List<User> 时通常调用 selectList,返回单个对象时调用 selectOne。只有标注 @MapKey 的 Map 返回值才按指定属性组织为 selectMap;普通 Map<String, Object> 可以表示单行的列名到值映射,仍走 selectOne。增删改支持影响行数、布尔值或 void,但 BATCH 的立即返回值另有含义。

selectOne 会先取得结果列表,再判断数量:零行返回 null,超过一行抛出 TooManyResultsException。它不会自动添加 LIMIT 1。如果业务必须唯一,应由唯一约束和查询条件保证,而不是依赖 Java 返回类型。Mapper 方法重载也不能靠参数类型区分 XML statement:定位键以 namespace 和方法名为核心,宜使用不同方法名。

参数名如何进入 SQL

MapperMethod.MethodSignature 使用 ParamNameResolver 把 Java 实参转换成 SQL 可读取的参数对象。RowBounds 和 ResultHandler 是特殊参数,不作为普通绑定参数计入命名。

1
2
List<User> findUsers(@Param("tenantId") long tenantId,
@Param("ids") List<Long> ids);

这个调用通常形成包含 tenantId、ids 和通用别名 param1、param2 的 ParamMap。XML 使用 #{tenantId}、collection="ids",就不会依赖编译器是否保留 Java 参数名。别名与显式名称冲突时,解析器会避免覆盖显式名称。

单个未标注 @Param 的普通对象通常直接传入,#{name} 读取其属性;单个 Collection 会被包装,List 常有 collection、list 别名,数组有 array 别名。启用实际参数名且编译时保留名称,还可能加入实际名称。加上 @Param("users") 后,集合入口就应明确写成 users,不能继续假设 list 存在。多参数场景建议使用 @Param,把参数名作为 Mapper 与 XML 的稳定接口。

依据:ParamNameResolver(3.5.19)。

动态 SQL 与参数绑定是两个阶段

参数命名、动态 SQL 与 JDBC 绑定

默认 XML 语言驱动中,RawSqlSource 会提前解析静态 SQL 的 #{} 和参数映射。SQL 只含 #{} 并不意味着它是动态 SQL。包含 <if>、<foreach> 等动态节点或 ${} 的脚本,会使用 DynamicSqlSource,每次执行根据当前参数应用 SqlNode 树。

<if> 的条件求值、<where> 清理开头的 AND/OR、<set> 清理尾部逗号发生在 SQL 生成阶段。生成的 BoundSql 仍只是 SQL 和绑定描述;接下来 DefaultParameterHandler 才按 ParameterMapping 的顺序调用 TypeHandler 设置 JDBC 参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
<select id="findUsers" resultType="com.example.User">
SELECT id, name FROM users
WHERE tenant_id = #{tenantId}
<choose>
<when test="ids != null and ids.size() > 0">
AND id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</when>
<otherwise>AND 1 = 0</otherwise>
</choose>
</select>

这里把空集合定义为“没有匹配结果”。foreach 对空集合不输出括号和元素;如果只写 AND id IN 加 foreach,可能留下非法 SQL。另一种危险写法是用 <if> 把整个 IN 条件省掉,使空集合变成查询全部记录。批量更新、删除更应明确拒绝空条件或生成恒假条件。nullable="true" 允许跳过 null 集合,不替业务决定空集合含义。

在 3.5.19 中,foreach 为迭代项生成独立的内部参数名,把对应值保存在 BoundSql.additionalParameters;bind 也会产生额外参数。绑定时优先读取这些额外参数,再考虑原参数对象。插件复制 BoundSql 时漏掉它们,即使 SQL 和问号数量看起来正确,绑定也可能失败。

#{value} 生成 JDBC 占位符,不能用来绑定表名、列名或 ASC/DESC。${orderBy} 直接改变 SQL 文本,必须由程序将外部输入映射到固定白名单;不能把前端提供的排序表达式直接替换进去。<where> 能清理连接词,不能阻止没有任何条件的全表查询或更新。

依据:XMLScriptBuilder、ForEachSqlNode、DefaultParameterHandler。

Executor 是执行主干

Executor 是 SQL 执行链的主干。SqlSession 面向用户,StatementHandler 面向 JDBC,中间调度、缓存、事务、延迟加载、批处理都集中在 Executor。

MyBatis 内置三种基础执行器:

执行器 适用场景 核心行为
SimpleExecutor 默认场景 每次执行创建新的 Statement
ReuseExecutor 同一 SQL 重复执行 复用相同 SQL 对应的 Statement
BatchExecutor 批量写入 暂存多个更新,等待 flushStatements()

Configuration.newExecutor() 会根据 ExecutorType 选择基础执行器。如果全局 cacheEnabled=true,还会用 CachingExecutor 包一层。最后再套插件代理。

对应的源码骨架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType == null ? ExecutorType.SIMPLE : executorType;

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) {
executor = new CachingExecutor(executor);
}
executor = (Executor) interceptorChain.pluginAll(executor);

这里有两个容易漏掉的细节。第一,ExecutorType 有两次兜底:先取全局 defaultExecutorType,仍为空再退回 SIMPLE。第二,CachingExecutor 是装饰器,不是第四种基础执行器;插件代理又包在缓存装饰器外层。因此一次执行可能同时经过“基础执行器策略”“二级缓存装饰器”“插件代理”三层结构。

BaseExecutor 提供了模板方法:先检查关闭状态和一级缓存,再决定是否调用子类的 doQuery()。真正和 JDBC Statement 打交道的是子类的 doQuery() / doUpdate(),但一级缓存、延迟加载、事务提交回滚这类横切逻辑放在基类里。

1
2
3
4
5
6
7
8
9
Executor.query(...)
-> MappedStatement.getBoundSql(parameter)
-> createCacheKey(ms, parameter, rowBounds, boundSql)
-> localCache.getObject(cacheKey)
-> doQuery(...) // 缓存未命中
-> configuration.newStatementHandler(...)
-> statementHandler.prepare(...)
-> statementHandler.parameterize(...)
-> statementHandler.query(...)

BaseExecutor.query(...) 还有几个判断点:

  • 查询前如果执行器已关闭,直接抛出 ExecutorException。
  • queryStack == 0 && ms.isFlushCacheRequired() 时会清空本地缓存。
  • 只有 resultHandler == null 时才尝试从 localCache 读取结果;自定义 ResultHandler 会绕开这条缓存读取路径。
  • 数据库查询前会把 EXECUTION_PLACEHOLDER 放进本地缓存,查询完成后移除占位并写入真实结果;这用于处理嵌套查询中的循环引用和延迟加载场景。
  • StatementType.CALLABLE 的输出参数会额外进入 localOutputParameterCache。
  • 最外层查询结束后会触发 deferredLoads,并在 localCacheScope=STATEMENT 时清空一级缓存。

这个结构很典型:BaseExecutor 不知道具体如何创建和复用 Statement,但它知道一次查询必须遵守哪些框架级规则。SimpleExecutor、ReuseExecutor、BatchExecutor 只替换真正执行 JDBC 的那部分策略。

REUSE 与 BATCH 的复用边界

ReuseExecutor 在当前执行器中按 SQL 文本复用 Statement,不是跨连接的 PreparedStatement 池。动态 SQL 生成不同文本时,复用机会减少;事务 flush、提交、回滚或关闭也会结束相应 Statement 的生命周期。驱动或连接池自己的 Statement 缓存是另一层机制。

BatchExecutor 只会把连续的、最终 SQL 相同且 MappedStatement 相同的更新合并进同一个批次。A、B、A 这样的调用顺序不会把两个 A 自动归并;动态字段导致 SQL 形状变化也会拆批。foreach 生成一条多行 INSERT 是“一条 SQL 多个值”,ExecutorType.BATCH 是“多次 JDBC addBatch”,两者的参数规模、错误定位和驱动行为不同。

BATCH 入队、flush 与提交时序

update() 在 BATCH 下调用 addBatch() 后立即返回 BATCH_UPDATE_RETURN_VALUE 占位值,不能据此判断实际影响行数,Mapper 的 boolean 返回值也不适合表示本次写入成功。flushStatements() 才调用 JDBC executeBatch(),并把结果放入 BatchResult.updateCounts。其中可能有 JDBC 的 SUCCESS_NO_INFO 等特殊值,不能全部当作普通行数相加。

flush 把语句发送到数据库,commit 才确认事务。默认非自动提交会话中,已 flush 的写入仍可回滚;尚未 flush 的批次在回滚时直接丢弃。发生批处理异常时,前面的批组可能已经执行,应由事务回滚保证整体撤销,不应仅重试最后一个参数对象。

当查询真正进入 BatchExecutor.doQuery() 或 doQueryCursor() 时,会先 flush 待执行更新;缓存提前返回则不会进入该方法。更可预测的做法是批量写入和查询分段组织,并显式设置 flush 点。useGeneratedKeys 对应的键在批执行后处理,不能在 addBatch() 返回后就拿回填 id 执行依赖写入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 原生 MyBatis;Spring 管理的 Mapper 使用事务管理器控制边界。
try (SqlSession session = factory.openSession(ExecutorType.BATCH, false)) {
UserMapper mapper = session.getMapper(UserMapper.class);
try {
for (int i = 0; i < users.size(); i++) {
mapper.insert(users.get(i));
if ((i + 1) % 500 == 0) {
session.flushStatements();
}
}
session.flushStatements();
session.commit();
} catch (RuntimeException e) {
session.rollback();
throw e;
}
}

500 是示例分块大小,需按驱动、SQL 宽度和内存调整。分块 flush 不等于分块 commit:上述代码仍是一个事务,锁和数据库事务日志不会因为 flush 而自动释放。每块提交会改变原子性,应另行设计。

依据:BatchExecutor(3.5.19)。

StatementHandler 是 JDBC 边界

如果说 Executor 是执行主干,StatementHandler 就是 MyBatis 和 JDBC 的接缝。

RoutingStatementHandler 根据 MappedStatement.statementType 选择具体实现:

statementType StatementHandler JDBC 对象
STATEMENT SimpleStatementHandler Statement
PREPARED PreparedStatementHandler PreparedStatement
CALLABLE CallableStatementHandler CallableStatement

BaseStatementHandler 在构造时会创建两个关键协作者:ParameterHandler 和 ResultSetHandler。前者负责把 Java 参数绑定到 JDBC PreparedStatement,后者负责把 ResultSet 映射成 Java 对象。

一次 PreparedStatement 查询可以拆成三个动作:

  1. prepare(connection, timeout):创建 JDBC PreparedStatement,设置超时、fetchSize 等。
  2. parameterize(statement):通过 ParameterHandler 绑定参数。
  3. query(statement, resultHandler):执行 SQL,并交给 ResultSetHandler 处理结果。

这三个动作分开之后,插件也就有了清晰拦截点。分页插件通常拦截 Executor 或 StatementHandler,审计插件常拦截 Executor,参数加密或类型转换更适合靠 TypeHandler,结果处理则靠 ResultSetHandler 或自定义映射规则。

TypeHandler 是类型系统边界

TypeHandler 是 MyBatis 处理 Java 类型和 JDBC 类型差异的核心扩展点。它的边界很明确:参数入库时调用 setParameter(),结果出库时调用 getResult()。

1
2
3
4
5
6
7
8
9
10
Java 参数对象
-> MetaObject 读取属性
-> ParameterMapping 找到 TypeHandler
-> TypeHandler.setParameter(ps, index, value, jdbcType)
-> JDBC

JDBC ResultSet
-> ResultSetHandler 定位列
-> TypeHandler.getResult(rs, column)
-> MetaObject 写入 Java 对象属性

TypeHandlerRegistry 会根据 Java 类型和 JDBC 类型选择处理器。常见类型有内置处理器,枚举默认有 EnumTypeHandler,按枚举名称存储;业务使用稳定编码时,才需要另行实现编码转换。JSON、加密字段和数据库专有类型也可能需要自定义处理器。

TypeHandler 与插件的差别在于粒度。插件拦截执行过程,适合调整一次 SQL 调用;TypeHandler 转换单个字段值,适合处理字段进出数据库时的类型差异。把字段转换写进插件,会让它依赖更复杂的执行协议,难以复用。

null 参数是常见的驱动兼容边界:没有显式 jdbcType 时,DefaultParameterHandler 使用全局 jdbcTypeForNull,默认是 OTHER。某些驱动不接受这样的 setNull 类型,宜在字段映射中指定 #{name,jdbcType=VARCHAR},而不是把 null 转成空字符串。自定义处理器读数字时还需区分 SQL NULL 与 JDBC 返回的零值,必要时检查 wasNull()。

处理器通常由注册表长期复用,应避免在实例字段里存储当前请求的参数。JSON 序列化要定义非法内容和版本兼容策略;枚举编码要定义未知值行为;时间类型要确认数据库列类型、驱动和时区解释。类型转换成功不代表业务语义正确。

ResultSetHandler 如何组装对象

结果映射是 MyBatis 内部最复杂的部分之一。简单场景下,列名和属性名对应,DefaultResultSetHandler 用 ObjectFactory 创建对象,再用 MetaObject 写入属性。复杂场景则会牵涉 ResultMap、嵌套映射、延迟加载、自动映射、构造器注入和鉴别器。

MetaObject 值得单独注意。它把对象属性读写、Map 访问、集合访问统一到同一套反射包装接口里。MyBatis 的参数读取和结果写入都大量依赖它,因此业务对象可以是普通 JavaBean,也可以是 Map 或更复杂的属性路径。

结果映射的核心难点不在“把一列赋给一个字段”,而在“如何保持对象身份”。一对多嵌套结果映射时,同一个父对象会在多行结果中重复出现。MyBatis 需要根据 id 映射和缓存 key 合并对象,否则 join 查询会产生重复父对象。这也是为什么复杂 resultMap 里应该认真声明 <id>:它不只是文档信息,也会影响嵌套结果组装。

join 的多行如何成为一个对象图

一对多结果的对象身份与嵌套查询对照

订单和明细 join 得到的两行可能是 (order_id=1, item_id=10)、(order_id=1, item_id=11)。父 ResultMap 的 id 生成父对象身份,子 ResultMap 的 id 与父身份组合,决定子对象复用。结果应是一份订单,内含两个明细;这个过程不是 SQL DISTINCT,也不是按 Java 的 equals() 去重。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<resultMap id="orderMap" type="com.example.Order">
<id property="id" column="order_id"/>
<result property="number" column="order_number"/>
<collection property="items" ofType="com.example.OrderItem"
notNullColumn="item_id">
<id property="id" column="item_id"/>
<result property="sku" column="item_sku"/>
</collection>
</resultMap>

<select id="findOrder" resultMap="orderMap">
SELECT o.id AS order_id, o.number AS order_number,
i.id AS item_id, i.sku AS item_sku
FROM orders o LEFT JOIN order_item i ON i.order_id = o.id
WHERE o.id = #{id}
</select>

父子列使用不同别名,防止重名 id 被映射到错误属性。LEFT JOIN 没有明细时,notNullColumn="item_id" 限定何时创建子对象。复用 ResultMap 时可用 columnPrefix 对齐列前缀。省略 <id> 不一定立即重复:框架会尝试使用其他映射列构造身份,但键更大,也更容易把属性差异当作身份差异。复合主键应完整声明。

mapUnderscoreToCamelCase 只处理名字匹配,不会推断 join 中的父子关系。复杂 join 宜显式映射,避免 autoMappingBehavior=FULL 把其他实体的同名列写入当前对象。resultOrdered="true" 能让处理器在父对象切换时释放部分中间状态,但前提是同一父对象的行连续出现,SQL 必须保证这个顺序;错误声明可能产生不完整对象。

nested select、延迟加载与 N+1

<association select="..." column="..."> 或带 select 的 collection 会执行嵌套查询。先查询 N 个父对象,再逐个查询子集合,通常就形成 1+N 次访问。一级缓存可能合并参数相同的嵌套查询,但不同父 id 的查询不会自动变成批量 IN 查询。

延迟加载会为结果对象建立代理,在访问关联属性时执行查询。lazyLoadingEnabled 默认关闭,单个关联可通过 fetchType 覆盖;aggressiveLazyLoading 和 lazyLoadTriggerMethods 决定其他方法是否触发加载。默认触发方法包含 toString 等,因此日志、调试器或 JSON 序列化都可能让“还没访问关联”的代码发出 SQL。

延迟加载只是推迟 N 次查询,并没有消除 N+1。列表接口通常可采用一次 join,或先查父 id,再一次 IN 查询子表并分组组装。原会话关闭或跨线程时,ResultLoader 可能新建执行器加载数据,但不能假设仍使用原事务连接和同一快照。需要确定性响应时,应在明确的事务边界内把必要关联加载完成。

依据:DefaultResultSetHandler、ResultLoader。

缓存体系:一级缓存与二级缓存不是同一件事

MyBatis 有两层缓存,位置和生命周期完全不同。

最小配置只涉及两个开关:

1
2
<setting name="cacheEnabled" value="true" />
<setting name="localCacheScope" value="SESSION" />

cacheEnabled 的默认值是 true,但它只决定是否启用二级缓存装饰器;localCacheScope 的默认值是 SESSION,它决定一级缓存是保留到会话结束,还是在每条语句后按 STATEMENT 粒度清掉。

缓存 所在位置 生命周期 默认行为 主要用途
一级缓存 BaseExecutor.localCache 同一个 SqlSession 内 默认开启 避免同会话重复查询,支持嵌套查询循环引用处理
二级缓存 namespace 对应的 Cache,由 CachingExecutor 管理 跨 SqlSession,按 namespace 需要 mapper 启用 <cache/> 才真正使用 缓存较稳定的查询结果

一级缓存不靠 CachingExecutor。只要走 BaseExecutor,本地缓存就存在。localCacheScope=SESSION 时,同一个 SqlSession 内相同 cache key 的查询会命中;localCacheScope=STATEMENT 时,每条语句执行后清空本地缓存,缓存主要只服务执行过程中的循环引用和嵌套查询。

二级缓存靠 CachingExecutor。当 cacheEnabled=true 且 MappedStatement 有 namespace cache,并且该语句 useCache=true、没有自定义 ResultHandler 时,CachingExecutor 会通过 TransactionalCacheManager 读写二级缓存。查询结果先进入本次执行器的暂存区;提交会发布,符合条件的正常关闭也可能发布,强制回滚则丢弃暂存。它不是数据库事务日志,发布时机仍要结合会话关闭方式和集成层判断。

MyBatis 一级缓存与二级缓存边界

CacheKey 决定“同一条查询”如何判等。它不是只用 SQL 字符串做 key,而是把多个维度按顺序 update(...) 进去:

1
2
3
4
5
6
7
CacheKey =
ms.getId()
+ rowBounds.offset
+ rowBounds.limit
+ boundSql.sql
+ 每个非 OUT 参数的实际值
+ environment.id

判等时还会同时比较内部 hashcode、checksum、count 和 updateList 中每个对象。同一个 SqlSession 内,statement id、分页边界、最终 SQL、参数值、环境 id 一致是一级缓存判等的基础;缓存还必须未被清除,且读取路径未被 ResultHandler 等条件绕开。动态 SQL 只要生成的最终 SQL 或参数序列不同,就会得到不同的缓存 key。

CachingExecutor.query(...) 的骨架则是另一层:

1
2
3
4
5
6
7
8
9
10
11
12
13
Cache cache = ms.getCache();
if (cache != null) {
flushCacheIfRequired(ms);
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, parameterObject, rowBounds, resultHandler, key, boundSql);

这段代码说明二级缓存有三道门槛:MappedStatement 必须能拿到 namespace cache,语句自身必须允许 useCache,并且不能使用自定义 ResultHandler。未满足条件或二级缓存未命中时,查询委托给基础执行器,由它检查一级缓存,仍未命中才查库。返回结果随后由 TransactionalCacheManager 暂存。

这里有几个实践结论:

  • cacheEnabled=false 主要影响二级缓存包装,不等于关闭一级缓存。
  • localCacheScope 控制一级缓存策略;这是排查“同一个 SqlSession 内重复读旧数据”时更容易被忽略的配置。
  • BaseExecutor.update() 会无条件清理一级缓存;namespace 二级缓存是否失效由 flushCacheRequired 控制,增删改默认要求失效。不能用 flushCache=false 阻止 update 清理一级缓存。
  • 二级缓存按 namespace 组织,跨 mapper 共享需要 cache-ref,否则不同 namespace 的缓存互不相干。
  • 一级缓存返回同一对象引用,业务修改结果可能影响同会话后续读取。默认 <cache/> 为读写缓存,通过序列化复制对象,要求结果对象可序列化;readOnly="true" 则共享引用,只有所有调用方都不修改对象时才适用。

namespace 失效不等于按表失效

如果 OrderMapper 缓存了 orders 与 users 的 join,UserMapper 更新 users,并不会自动找到 OrderMapper 的缓存并失效。MyBatis 按 namespace 管理缓存依赖,不解析 SQL 来追踪每张表。另一个应用、直接 JDBC 或数据库任务更新数据,同样不会触发这套失效机制。

cache-ref 可以共享同一缓存,使这些 Mapper 的失效落到同一对象,但粒度也随之变粗。频繁更新、跨服务写入或要求强一致的业务,通常应禁用该查询的二级缓存,或使用有明确一致性策略的外部缓存。缓存命中率不能代替失效正确性。

useCache="false" 只绕开二级缓存,一级缓存仍可能命中。原生会话需要重新读取时可调用 clearCache(),或采用 localCacheScope=STATEMENT;数据库事务隔离仍可能让新 SQL 读到同一快照。框架缓存与数据库隔离是两个不同层次。

依据:BaseExecutor、CachingExecutor、TransactionalCache。

插件链的边界

MyBatis 插件并不是任意 AOP。官方插件只支持拦截四类对象的方法:

  • Executor
  • StatementHandler
  • ParameterHandler
  • ResultSetHandler

插件通过 Interceptor、Invocation、Plugin 和 JDK 动态代理实现。每个目标对象创建后,Configuration 会调用 interceptorChain.pluginAll(target),按注册顺序逐层包装。

插件包装顺序与可观察边界

假设 A、B 按这个顺序注册,都使用默认 Plugin.wrap,且签名匹配同一方法、正常调用 proceed(),包装结果是 B(A(target))。执行顺序为 B 前置、A 前置、目标、A 后置、B 后置。插件之间会看到不同阶段的数据,不能仅以 XML 出现顺序推测谁最先修改 SQL。

Executor.query 有四参和六参重载,@Signature 必须声明准确的方法和参数类型。基础执行器四参 query 会内部直接调用六参 query;这种自调用不重新经过外部代理,所以只拦六参并不能保证捕获四参入口。CachingExecutor 的委托路径又有所不同,应沿实际包装结构验证。

缓存命中时不会创建 StatementHandler,因此拦截 StatementHandler.prepare 得到的是实际下发语句的观察点,无法统计所有 Mapper 调用。Executor.query 计时则可能包含缓存、对象映射、嵌套查询等时间,不能当作纯数据库耗时。两类指标应该明确命名。

插件能力强,但也有清晰代价:它拦的是框架内部协议。一个分页插件如果改写 BoundSql,就必须理解参数映射、cache key、count 查询、方言差异、RowBounds 行为;一个审计插件如果在 Executor.update 里读取参数对象,就必须处理 Map、集合、单参数、多参数、@Param 等不同形态。

尤其不能只在 Statement 层添加租户过滤条件:如果 Executor 已按未加租户条件的 SQL 和参数生成缓存键,缓存命中可能绕过过滤插件。改写后的 SQL、参数映射、额外参数与 CacheKey 必须保持一致。插件实例也应避免保存每次调用的可变状态;异常、提前返回和 finally 清理路径需要一起考虑。

插件适合做执行级横切逻辑,不适合承载领域逻辑。字段级转换用 TypeHandler,对象创建用 ObjectFactory,SQL 语言扩展用 LanguageDriver,结果结构变化优先用 resultMap。插件应该是最后的扩展手段,而不是第一个。

依据:InterceptorChain、Plugin。

事务与连接在哪里

MyBatis 的事务抽象很薄,主要围绕 Transaction 接口展开。Environment 同时持有 DataSource 和 TransactionFactory。打开 SqlSession 时,DefaultSqlSessionFactory 用事务工厂创建 Transaction,执行器通过事务对象获取连接、提交、回滚和关闭。

MyBatis 自带 JdbcTransactionFactory 和 ManagedTransactionFactory。前者直接管理 JDBC 连接的提交、回滚、关闭;后者把事务管理交给容器。在 Spring 集成场景中,真正常见的是 MyBatis-Spring 接管 SqlSession 生命周期,并把连接绑定到 Spring 事务同步机制。这个部分属于集成层,不改变 MyBatis 核心执行链的结构。

Spring 中共享的是 Template,不是原生会话

Spring 事务内的会话与连接复用时序

SqlSessionTemplate 是可共享的入口,内部每次调用通过 SqlSessionUtils 取得实际会话。通常在同一线程、同一 Spring 事务、同一 SqlSessionFactory 下,会话绑定到事务资源并被复用。SpringManagedTransaction 通过 DataSourceUtils 取得事务连接;最终 JDBC commit/rollback 由 Spring 事务管理器执行,Mapper 不应手动提交或关闭 Template。

成功的非事务 Template 调用则取得本次会话、调用 commit(true)、随后关闭。连续两次 Mapper 查询不一定共享一级缓存;有事务时通常共享,无事务时通常各自创建会话。连接池再次提供同一物理连接,也不能证明一级缓存共享,因为缓存属于 Executor。

事务管理器与 SqlSessionFactory 必须使用相同的 DataSource,才能让会话正确参与事务。已有事务绑定会话时不能随意切换 ExecutorType;SIMPLE 与 BATCH 的边界应预先安排,而不是在同一事务内换一份 Template 期待生效。

@Transactional 依赖 Spring 代理,类内自调用不会通过常规代理触发新的事务拦截,异步线程也不会自动继承线程绑定的会话。多数据源场景需要确认调用使用哪个 factory 和事务管理器,不能仅凭方法上的注解判断两次写入属于一个事务。

Spring 同步在 beforeCommit 阶段调用 SqlSession commit,负责 flush 批次,也可能发布 MyBatis 二级缓存;物理连接提交由后续事务管理器完成。因此“二级缓存暂存具有事务语义”不能推导为“缓存与数据库提交严格原子”。发生最终提交失败时,仍需考虑集成层的失效与一致性边界。

依据:MyBatis-Spring 事务文档、SqlSessionTemplate(3.0.4)、SqlSessionUtils(3.0.4)。

分页、大结果集与资源生命周期

RowBounds 不会自动改写 SQL

核心 MyBatis 的 RowBounds(offset, limit) 在结果处理阶段跳过 offset 行,再限制返回行数。它不自动生成数据库的 LIMIT/OFFSET;向深页跳转仍可能让数据库处理大量记录,驱动也可能预先缓冲结果。

显式 SQL 分页或分页插件才能改变数据库端查询。深分页可考虑基于稳定排序键的 keyset 查询,例如 WHERE id > #{lastId} ORDER BY id LIMIT #{size};复杂排序还需唯一的次级键。具体方言和索引应由数据库执行计划验证。

对一对多 join 直接 LIMIT 的单位是结果行,不是父对象,可能截断最后一个父对象的子集合。常见做法是先分页选出父 id,再获取完整关联结果。结果处理器对嵌套映射与 RowBounds 还有安全检查,关闭 safeRowBoundsEnabled 不会消除截断问题。

fetchSize、ResultHandler、Cursor 各解决什么

机制 所在层 解决的问题 不能保证的事情
fetchSize JDBC Statement 提示驱动每次获取多少行 不保证按提示流式传输,也不限制最终 List 大小
ResultHandler 结果映射回调 边处理边消费,避免框架累积完整返回列表 驱动仍可能缓冲全部结果;复杂嵌套对象可能尚未完整组装
Cursor<T> MyBatis 游标包装 按迭代需要逐步映射对象 不能脱离有效会话,也不自动释放长期占用的连接

驱动是否真正流式获取,可能取决于自动提交、URL 参数、ResultSet 类型等条件。仅设置 fetchSize 后内存下降,不能直接证明服务器没有传输全量数据。使用 ResultHandler 时还要理解嵌套结果的安全检查,不应把未完整组装的对象过早交给业务。

1
2
3
4
5
6
7
// 原生会话:游标必须在 session 生命周期内消费。
try (SqlSession session = factory.openSession();
Cursor<User> cursor = session.getMapper(UserMapper.class).scanUsers()) {
for (User user : cursor) {
process(user);
}
}

通过 Spring Mapper 返回 Cursor,再在事务结束后迭代,会遇到会话和游标已关闭的问题。需要把创建、遍历、关闭都放在有效事务或显式会话中。给“返回 Cursor”的短方法加事务,仍不足以覆盖调用方之后的遍历。慢速消费还会长期占用池中的连接,应控制耗时与并发。

依据:DefaultResultSetHandler.skipRows、DefaultSqlSession.close。

一次查询的完整路径

把上面的组件串起来,一次普通 Mapper 查询大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1. 业务代码调用 mapper.selectById(id)
2. MapperProxy 拦截接口方法
3. MapperMethod 根据接口名和方法名定位 MappedStatement
4. MapperMethod 根据返回值类型选择 SqlSession.selectOne / selectList
5. DefaultSqlSession 调用 Executor.query
6. Executor 从 MappedStatement 取 BoundSql,并创建 CacheKey
7. CachingExecutor 先检查可用的二级缓存;未命中才委托 BaseExecutor 检查一级缓存
8. 缓存未命中时,Executor 创建 StatementHandler
9. StatementHandler 创建 PreparedStatement
10. ParameterHandler 使用 TypeHandler 绑定参数
11. PreparedStatement 执行 SQL
12. ResultSetHandler 使用 ResultMap、TypeHandler、ObjectFactory、MetaObject 组装结果
13. Executor 写入本地缓存,必要时暂存二级缓存
14. SqlSession 返回结果;二级缓存暂存由提交或相应关闭路径发布

执行链可以压缩成下面这张图:

MyBatis 一次查询执行链

普通列表查询缓存未命中时的交互时序

时序图展示普通列表查询且两层缓存都未命中的路径;任一缓存提前返回,都会跳过后面的数据库阶段。selectCursor、自定义 ResultHandler、嵌套查询和 BATCH 有各自分支,不能把一张查询图当作所有入口的固定执行顺序。

这条路径解释了 MyBatis 的可扩展性为什么集中在几个点上。Mapper 方法名定位语句,SqlSource 生成 SQL,Executor 管执行策略和缓存,StatementHandler 管 JDBC,TypeHandler 管值转换,ResultSetHandler 管对象组装。每一层只承担一个方向的变化。

组件关系速查

组件 上游 下游 关键问题
SqlSessionFactoryBuilder 配置输入流 SqlSessionFactory 如何把外部配置编译成 Configuration
Configuration XML、注解、全局设置 全部运行时组件 元数据放在哪里,组件从哪里创建
MappedStatement Mapper XML / 注解 Executor 一条 SQL 的完整执行描述是什么
SqlSource XML 脚本 / 注解 SQL BoundSql 参数进入后最终 SQL 长什么样
MapperProxy Mapper 接口调用 MapperMethod 接口方法如何变成命令
SqlSession MapperMethod / 用户 API Executor 一次会话如何管理执行器和事务边界
Executor SqlSession StatementHandler 执行策略、缓存、事务如何协调
StatementHandler Executor JDBC 如何创建 Statement 并执行
ParameterHandler StatementHandler TypeHandler / JDBC Java 参数如何绑定到 SQL
ResultSetHandler JDBC ResultSet Java 对象 结果集如何变成对象图
TypeHandler 参数/结果映射 JDBC 类型 Java 类型和数据库类型如何互转
Interceptor Configuration.pluginAll 四类可拦截对象 横切逻辑插在哪里

设计模式视角

MyBatis 的源码适合用设计模式阅读,但不要停在“它用了某某模式”的标签层面。更有价值的是看每个模式解决的变化点。

模式 代表位置 解决的变化
Builder XMLConfigBuilder、XMLMapperBuilder、MappedStatement.Builder XML/注解输入复杂,最终对象需要稳定且完整
Registry TypeHandlerRegistry、MapperRegistry、LanguageDriverRegistry 按类型、接口、语言查找扩展实现
Dynamic Proxy MapperProxy、插件 Plugin 接口无实现类,运行时拦截方法调用
Template Method BaseExecutor、BaseStatementHandler 固定执行骨架,延迟具体执行策略
Decorator CachingExecutor 在不改基础执行器的情况下加入二级缓存
Chain of Responsibility InterceptorChain 多个插件按顺序包装同一目标对象
Strategy ExecutorType、TransactionFactory、LanguageDriver、TypeHandler 同一抽象下替换不同实现
Adapter / Facade MetaObject、ObjectWrapper 统一 JavaBean、Map、Collection 的属性访问

阅读源码的三条锚点

阅读 MyBatis 源码时,最容易陷入包和类名的细节。更有效的方式是抓三条锚点。

第一条锚点是 MappedStatement。任何 SQL 最后都要落到它。读懂 MappedStatement 的字段,就能理解 Mapper XML 里大部分配置为什么存在。

第二条锚点是 Executor.query()。它串起 BoundSql、CacheKey、一级缓存、二级缓存、StatementHandler 和事务边界。MyBatis 的运行时行为,大半都能从这里解释。

第三条锚点是 Configuration.newXxx()。凡是想找插件为什么能生效、某个处理器在哪里创建、默认策略在哪里替换,都应该回到 Configuration 的工厂方法。

1
2
3
MappedStatement 解释“这条 SQL 是什么”
Executor.query 解释“这条 SQL 怎么执行”
Configuration.newXxx 解释“执行链组件从哪里来”

常见误区

误区一:关闭 cacheEnabled 就关闭了所有缓存。
cacheEnabled 控制的是是否包装 CachingExecutor,主要影响二级缓存。一级缓存属于 BaseExecutor.localCache,仍然存在。要改变一级缓存行为,需要看 localCacheScope 和语句的 flush cache 设置。

误区二:Mapper 代理直接执行 SQL。
MapperProxy 只做方法拦截和缓存 MapperMethod。SQL 执行仍然经过 SqlSession 和 Executor。Mapper 接口只是类型安全入口,不是执行引擎。

误区三:插件可以随意拦截 MyBatis 任意类。
插件只支持四类目标。拦截不到的类不是配置写法问题,而是 MyBatis 插件协议本身的边界。

误区四:动态 SQL 是运行时拼字符串。
动态 SQL 在构建期已经解析成 SqlNode 树,运行时是根据参数应用这棵树,生成 BoundSql。这比散乱字符串拼接有更清晰的参数映射和上下文。

误区五:resultMap 只是字段别名表。
resultMap 还承载对象身份、嵌套映射、延迟加载、构造器注入等规则。复杂 join 场景里,<id> 配置会影响父子对象合并。

排查时从症状定位组件

症状 优先观察 验证内容
参数不存在、foreach 绑定失败 ParamNameResolver、BoundSql 实际 ParamMap 键、ParameterMapping 和 additionalParameters 是否一致
第二次查询没发 SQL或读到旧对象 CachingExecutor、BaseExecutor 实际会话是否复用、哪层命中、查询键和失效来源
join 结果重复或子集合缺失 DefaultResultSetHandler 父子 id、列别名、null 子行、LIMIT 和 resultOrdered 前提
批量写入未回填 id或报错位置延后 BatchExecutor 是否 flush、updateCounts、驱动生成键、失败后是否回滚
分页慢或游标已关闭 SQL、驱动与会话边界 是否数据库端分页、真实流式配置、消费是否仍在有效会话内

核对日志时应同时记录 statement id、最终 SQL、脱敏参数、执行器类型和事务边界。Mapper 调用次数、实际 JDBC 执行次数、结果对象数量是三种不同计数;把它们分别观察,才能区分缓存、嵌套查询和批处理造成的差异。

参考资料

在线文档和 XRef 会随项目更新,涉及版本差异时以本节的固定版本源码及正文中的版本链接为准。