MyBatis 执行器、缓存与插件:查询复用和批量发送的实际时点
连续调用两次 mapper.find(1),可能只创建一次 PreparedStatement;把执行器换成 REUSE 后,也可能只创建一次 PreparedStatement。两种现象外观相似,实际省掉的工作不同:前一种可能直接返回缓存对象,后一种仍执行查询和结果映射,只复用语句对象。
判断查询成本,需要沿着 Executor、缓存和 JDBC 分开观察。批处理还多了一个时点:Mapper 方法返回时,写入可能尚未发送。
从两次查询观察本地缓存
准备独立数据库和完整工程
下载完整实验 ZIP。工程使用 MyBatis 3.5.19、pgJDBC 42.7.13、PostgreSQL 18.6,Maven 3.9.12 搭配 JDK 25;源码以 Java 17 为编译目标,也可以使用 JDK 17 执行。不同模式创建独立 SqlSessionFactory,避免前一个实验的缓存影响下一个判断。
核心源码包括 Factories.java、ItemMapper.xml、PrepareCounter.java 和 ExecutorLab.java。工厂使用真实 PGSimpleDataSource、JdbcTransactionFactory 与 Mapper XML,没有 Spring 事务代理参与。
在安装了 Docker Engine、Compose、unzip、openssl 的 Linux Bash 中执行:
test "$(id -u)" -ne 0 || exit 1
docker version
docker compose version
unzip mybatis-executor-cache-plugin-lab.zip
cd mybatis-executor-cache-plugin
export LAB_DIR="$PWD"
export LAB_CACHE="$LAB_DIR/.m2-cache"
mkdir -p "$LAB_CACHE"
export LAB_DB_PASSWORD="$(openssl rand -hex 24)"
export LAB_APP_PASSWORD="$(openssl rand -hex 24)"
export LAB_DB_URL='jdbc:postgresql://db:5432/executor_lab'
docker compose -p da10-executor up -d --wait数据库进程使用 999:999,Java 容器使用宿主普通用户的 UID/GID。初始化角色是 lab_owner,实验应用使用只获得表 DML 权限的 lab_app。数据库没有宿主端口,数据存放在 tmpfs;停止这套容器会丢弃实验数据。
init.sh 创建两张表:
CREATE TABLE item (
id bigint PRIMARY KEY,
label text NOT NULL
);
INSERT INTO item VALUES (1, 'original'), (2, 'second');
CREATE TABLE batch_item (
id bigint PRIMARY KEY,
label text NOT NULL
);item 用于查询和缓存实验,batch_item 用于批量写入。后续模式会更新标签或清空批量表,只能对这套独立库运行。
定义一个复用容器调用的 Bash 函数;参数是实验模式:
run_lab() {
docker run --rm --user "$(id -u):$(id -g)" --read-only \
--network da10-executor_default \
--tmpfs /tmp:rw,exec,mode=1777 \
-v "$LAB_DIR:/src:ro" -v "$LAB_CACHE:/cache" \
-e LAB_DB_URL -e LAB_APP_PASSWORD -e MAVEN_CONFIG=/tmp/maven \
maven:3.9.12-eclipse-temurin-25 bash /src/run.sh "$1"
}
run_lab first脚本在容器 /tmp 中编译并执行,结束时清理自己的构建目录。可写的 Maven 缓存保存下载依赖,公开源码目录始终只读。
首次运行应在 Maven 成功之后输出:
sessionSameObject=true prepares=1 memoryMutationVisible=true databaseUnchanged=true
statementScopeNewObject=true prepares=2如果容器无法加入网络,先检查 Compose 是否成功启动;数据库认证失败则核对当前 shell 中的密码是否仍与初始化时相同。查看初始化日志:
docker compose -p da10-executor logs --tail=80 dbtmpfs 数据库的密码由首次启动时的环境确定,修改 shell 变量不会修改已运行数据库中的角色密码。
一次 prepare 对应哪些工作
本地缓存实验显式关闭二级缓存,使用 SIMPLE 执行器:
var counter = new PrepareCounter();
var factory = Factories.create(false, LocalCacheScope.SESSION, counter);
try (var session = factory.openSession()) {
var mapper = session.getMapper(ItemMapper.class);
Item first = mapper.find(1);
Item second = mapper.find(1);
// first == second;PrepareCounter 记录到一次 prepare。
}计数来自拦截 StatementHandler.prepare(Connection, Integer) 的真实回调:
@Intercepts(@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}))
public final class PrepareCounter implements Interceptor {
public final AtomicInteger prepares = new AtomicInteger();
@Override
public Object intercept(Invocation invocation) throws Throwable {
prepares.incrementAndGet();
return invocation.proceed();
}
}这个计数说明语句准备路径进入了几次,不统计数据库执行计划缓存,也不代表网络包数量。当前实验同时断言对象身份,才得出“第二次查询返回本地对象”的结论。
SESSION 缓存保存对象引用
默认 localCacheScope=SESSION 时,本地缓存属于一个 Executor,通常与一个 SqlSession 同生共灭。再次查询命中后,调用方拿到缓存中的对象引用;对返回对象的普通 Java 修改,会影响这个会话后续看到的值。SqlSession 本地缓存
实验把第一次查出的 label 改为 memory-only,随后 Mapper 再查得到同一值,而独立 JDBC 连接仍读到 original。Java 对象修改没有生成 UPDATE。
因此,Mapper 查询结果用于后续计算时,可以保持只读,或者复制为独立 DTO。把对象当临时容器反复改写,会让同一会话中的后续查询出现难以定位的内存污染。
STATEMENT 在最外层查询结束后清理本地缓存。它仍允许一次查询内部的嵌套映射利用缓存与延迟加载协作,并非移除了所有内部缓存结构。对应实验执行两次顶层查询,得到两个对象和两次 prepare。
clearCache() 只清理当前 SqlSession 的本地缓存。它的作用范围要与下一节的 namespace 缓存区分。
查询先经过二级缓存,再进入 BaseExecutor
Executor 的实际嵌套顺序
MyBatis 根据 ExecutorType 创建 SimpleExecutor、ReuseExecutor 或 BatchExecutor;启用 cacheEnabled 时,再在外侧包一层 CachingExecutor。插件代理最后加入外层。配置项和插件注册方式见 MyBatis Configuration。
常规列表查询的路径可以展开为:
Executor 插件代理
└─ CachingExecutor
├─ 语句具备可用 namespace cache,且 useCache=true、ResultHandler=null
│ ├─ 二级命中 → 返回结果,不进入 BaseExecutor
│ └─ 未命中 → 调用内部执行器,并暂存查询结果
└─ 语句不使用二级缓存 → 调用内部执行器
└─ BaseExecutor
├─ 本地缓存命中 → 返回结果
└─ 未命中 → Simple / Reuse / Batch 的查询实现 → JDBC二级缓存在外层,因此不能把查询顺序画成“先一级,后二级”。flushCache、存储过程 OUT 参数、游标和 ResultHandler 还会改变具体分支;上述路径对应普通 selectList 查询。CachingExecutor 3.5.19
CacheKey 不只包含方法参数
BaseExecutor 建立的查询键包含语句 ID、RowBounds 的 offset/limit、最终 SQL、参与映射的实际参数值,以及存在时的 Environment ID。动态 SQL 的附加参数也要参与取值。
example.ItemMapper.find
+ RowBounds(offset, limit)
+ SELECT id, label FROM item WHERE id = ?
+ 参数值 1
+ environment "lab"不同 SQL 或参数形成不同键;相同 Java 方法名还不足以判断是否可以复用结果。租户条件如果只存在于 ThreadLocal 中、没有进入 SQL 和缓存键,缓存层看不到这个差别。
BaseExecutor 还使用 queryStack 跟踪嵌套查询,并在外层查询结束时处理 deferredLoads。它既缓存已读取结果,也参与循环关联的装配协调。BaseExecutor 3.5.19
namespace 缓存怎样启用
Mapper XML 中的 <cache/> 为该 namespace 建立缓存。全局 cacheEnabled=true 允许使用它;仅打开全局选项而没有 namespace 缓存配置,不会让所有 Mapper 自动拥有二级结果缓存。
<mapper namespace="example.ItemMapper">
<cache/>
<select id="find" resultType="example.Item">
SELECT id, label FROM item WHERE id = #{id}
</select>
</mapper>默认的读写缓存通过序列化复制对象,本实验的 Item 实现 Serializable。实验会验证跨会话返回的对象与最初对象不是同一个引用。readOnly=true 可以共享同一对象引用,调用方必须遵守只读约定;配置名称中的 readOnly 指缓存对象的使用方式,不会把数据库连接设为只读。
缓存的容量、淘汰方式、刷新间隔、读写属性,以及 cache-ref 的共享规则见 Mapper XML 缓存配置。默认进程内缓存不会自动通知另一台应用实例。
查询结果在何时发布
运行:
run_lab second-level结果:
beforeCommitTwoPrepares=true afterCommitSharedHit=true
normalReadClosePublished=true serializedCopyReturned=true
forcedRollbackDiscarded=true nextReadPrepared=true第一组使用三个会话。会话 A 查询后尚未发布缓存,会话 B 仍要执行一次 SQL;B 使用 rollback(true) 丢弃自己的待发布项。A 提交后,会话 C 命中共享缓存,prepare 总数保持为 2。
第二组只查询,然后正常关闭会话。这个普通、未标记 dirty 的读会话会走 CachingExecutor.close(false),待发布条目同样进入共享缓存。因此,“只有显式调用 commit 才会发布二级缓存”会漏掉正常关闭读会话的情况。
第三组显式 rollback(true) 后关闭,下一会话重新 prepare。对只读会话,普通 rollback() 还会经过 dirty/force 判断;需要明确丢弃待发布项的实验应使用强制参数。具体判断由 DefaultSqlSession 3.5.19 与外层缓存执行器共同完成。
缓存发布讨论的是共享查询结果。数据库事务何时提交,仍由实际事务和连接决定;在 Spring 托管的 Mapper 中,应让 Spring 管理提交回滚,不手动提交 SqlSessionTemplate。MyBatis-Spring 事务
外部更新为什么留下旧结果
运行外部写入反例:
run_lab staleexternalWriteBypassedInvalidation=true staleValueObserved=true clearThenFresh=true顺序为:MyBatis 查询 original 并正常关闭读会话;独立 JDBC 连接把数据库更新成 external-new;新 SqlSession 仍从二级缓存拿到 original。清空 namespace 缓存后,再查才得到新标签。
MyBatis 的失效动作来自它处理的映射语句。外部脚本、其他服务、另一套 SqlSessionFactory,或者没有共享 cache-ref 的其他 namespace,可能绕过这条通知路径。
在同 namespace 中,insert/update/delete 默认请求刷新缓存;失效也要结合事务完成处理。跨 namespace 联表查询需要额外考虑共享数据被谁修改。给一张高频变化的业务表开启缓存前,先列出所有写入口,再决定失效方案。无法覆盖的外部写入很多时,可以保持二级缓存关闭,把数据新鲜度交给数据库查询。
实验通过 Configuration.getCache(namespace).clear() 清除当前 JVM 的缓存以验证原因。这不是分布式失效方案,也不应作为每次请求的补救动作。
SIMPLE、REUSE 和 BATCH 各自复用什么
结果缓存与语句复用分开测
运行:
run_lab executorsexecutor=SIMPLE prepares=2 twoResultMappings=true
executor=REUSE prepares=1 twoResultMappings=true两组都关闭二级缓存,并设置 STATEMENT 范围,避免查询结果缓存掩盖执行器差异。SIMPLE 为两次顶层查询分别准备语句;REUSE 在执行器内部按 SQL 复用可用的 Statement。两组都执行结果映射,返回两个不同的 Item。
| 执行器 | 保留的对象或动作 | 常见用途 | 实际成本仍包含 |
|---|---|---|---|
| SIMPLE | 每次操作创建、使用并关闭语句 | 普通业务读写 | 驱动调用、数据库执行、结果映射 |
| REUSE | 执行器范围内复用相同 SQL 的 Statement | 同会话反复执行同形 SQL | 每次查询和结果处理 |
| BATCH | 保存 Statement 与待发送参数组 | 大量同形写入 | flush 时的批执行与事务提交 |
REUSE 的生命周期随执行器和 flush 等动作变化,不是跨连接的全局 Statement 池。数据库服务器的计划缓存与 JDBC 驱动的预编译策略又是另外两层。
BATCH 返回值与发送时点
BATCH 的 update/insert 调用先加入批次,返回 BatchExecutor.BATCH_UPDATE_RETURN_VALUE 这一占位值。此时不能把返回的 int 当作成功写入行数。实际计数应在 flushStatements() 返回的 BatchResult 中读取。
批次分组要求连续的 MappedStatement 和最终 SQL 匹配。动态 SQL 改变列集合,或者相同 SQL 中间插入了另一种语句,都可能拆成不同组。正常 flush 调用 JDBC executeBatch;进入 BatchExecutor 实际查询路径时,也会先冲刷待发送写入。若查询在外层缓存就返回,便不会进入这个路径。commit 则会完成冲刷再提交。BatchExecutor 3.5.19
运行:
run_lab batchbatchSentinel=true beforeFlushRows=0 afterFlushOwnRows=2 externalBeforeCommit=0 afterCommit=2
batchConstraintRejected=true sqlState=23505 rollbackRows=0第一次调用的核心流程:
try (var session = factory.openSession(ExecutorType.BATCH)) {
var mapper = session.getMapper(ItemMapper.class);
mapper.insert(1, "one");
mapper.insert(2, "two");
// 此处使用同一 Connection 的直接 JDBC 查询观察,避免 Mapper 查询触发 flush。
var results = session.flushStatements();
// 同一事务已能查询到两行;独立连接仍查询到零行。
session.commit();
// 提交后,独立连接可以查询到两行。
}这里有三个不同阶段:
Mapper insert × 2 JDBC executeBatch Connection commit
│ │ │
▼ ▼ ▼
客户端待发送 当前事务中已有行 其他事务可读到新行
本连接查询为 0 本连接 2 / 外部 0 外部查询为 2flushStatements 可以用来限制客户端积压的参数和结果对象数量。若整个导入仍共用一个事务,已经 flush 的部分也要等待最后提交,数据库锁与事务资源仍会持续占用。分段 flush 和分段 commit 应根据业务原子性分别设计。
批次失败和主键回填
第二组连续加入两个相同主键,在 flush 时得到 PostgreSQL 23505。实验捕获 MyBatis 包装异常,沿 cause 查找 SQLException,强制回滚后用独立连接确认零行。
异常可能发生在第三批甚至提交阶段。上层入口需要包住写入、flush 和 commit 整个过程,保留原始异常并处理回滚失败;不能只给每个 Mapper 方法加 try/catch。
使用 JDBC generated keys 时,MyBatis 在批执行之后处理回填,驱动也必须支持对应语句。新增对象在加入批次后、flush 前,主键可能仍未写回。后续语句若立即依赖该主键,需要先完成适当 flush,或采用提前可用的应用 ID/序列方案。
批次计数可能包含 SUCCESS_NO_INFO 或失败标记,处理原则与 JDBC 批处理相同,详见事务与批处理。不要把“返回数组长度正确”当作全部成功条件;还要检查异常、事务结果和最终数据。
插件怎样包裹真实调用
两个插件的先入后出
MyBatis 支持拦截 Executor、StatementHandler、ParameterHandler、ResultSetHandler 的指定接口方法。插件创建 JDK 代理,只有经过代理且签名匹配的调用才进入 intercept;这与普通 Java 自调用存在同样的差别。
实验QueryTrace.java 拦截 Executor 的四参数 query。按 A、B 顺序注册后运行:
run_lab pluginspluginEvents=[B.before, A.before, A.after, B.after]后注册的 B 包在外侧:
B.before
A.before
target.query(...)
A.after
B.after每个插件都在 finally 中记录 after,并调用 invocation.proceed()。如果插件主动短路返回,后续插件和目标对象就可能完全不执行。插件顺序必须结合实际拦截方法分析,不能只按配置文件上下位置猜测 SQL 改写顺序。
选择能够观察目标事实的位置
| 想回答的问题 | 适合的观察位置 | 解释时要排除的情况 |
|---|---|---|
| 应用调用了几次逻辑查询 | Executor.query | 缓存命中也会进入外部 query 代理 |
| 创建了多少语句对象 | StatementHandler.prepare | REUSE 可能省去重复 prepare |
| 参数怎样绑定 | ParameterHandler.setParameters | 缓存命中不会走 JDBC 参数绑定 |
| 结果怎样映射 | ResultSetHandler.handleResultSets | 游标消费与普通列表处理不同 |
| 批量何时发送、是否成功 | flushStatements 与 BatchResult/异常 | update 的占位返回值尚无成功行数 |
CachingExecutor 的四参数 query 在对象内部转调六参数 query。只拦截六参数签名,未必能收到这次内部调用;方法存在于接口,并不意味着所有调用都会重新穿过代理。
计时插件还要说明测量范围。Executor 上的耗时可能包含缓存查找、连接获取和 JDBC 工作,StatementHandler 上的耗时则覆盖另一段。两张图表如果使用不同测点,不能直接比较成数据库“快了多少倍”。
SQL 改写必须与缓存键一致
租户、权限过滤、分页等插件会改变实际执行内容。若插件在 CacheKey 已形成之后才改 SQL 或参数,却保留原有缓存键,两次逻辑不同的查询可能共享结果。这个问题不会因把插件移到 StatementHandler 就自动解决。
需要改写 SQL 时,应明确最终 SQL、ParameterMapping、附加参数与 CacheKey 的生成顺序,同时验证二级缓存命中、本地缓存命中、分页参数及批执行路径。若某种改写方式无法在现有缓存模型中表达,先限制它的缓存使用范围。关闭缓存是有代价的取舍,但比跨租户返回错误对象更容易评估和验证。
插件可以改变数据库访问行为,适合用于必要的横向能力。简单业务条件优先写进 Mapper 和服务层,使参数与 SQL 在同一处可读。
缓存异常和批处理问题怎样定位
查不到 SQL 时先确定哪一层省掉了调用
同一个请求得到结果但没有 prepare,可以先复现四个对照:关闭 namespace 缓存、新建 SqlSession、清当前本地缓存、使用 STATEMENT 范围。每次只改变一个条件,并记录 Executor 调用与 StatementHandler 调用。
若新会话仍读到旧值,检查二级缓存或数据库读目标;若当前会话读旧值、新会话直接查库是新值,优先检查本地缓存与事务快照。缓存问题不能仅凭日志中“没有 SQL”定性。
批处理失败后不要继续查询验证
PostgreSQL 的事务发生 SQL 错误后,后续语句常得到 25P02。先回滚,再用新事务验证。直接在失败事务里 SELECT,看到的第二个错误可能掩盖最初的唯一约束或类型错误。PostgreSQL 错误代码
查看实验库最终状态:
docker compose -p da10-executor exec -T db \
psql -X -U lab_owner -d executor_lab -v ON_ERROR_STOP=1 \
-c "SELECT id, label FROM item ORDER BY id; SELECT count(*) FROM batch_item;"完整实验结束后,item 应恢复为 original/second,batch_item 行数为 0。若 Java 进程被强制终止在外部写模式中,finally 可能没有执行;可重建这套一次性数据库后重跑,勿对业务库执行清表补救。
| 现象 | 第一项核对 | 后续处理 |
|---|---|---|
| 同会话对象值变化,数据库未变 | 是否改写过查询返回对象 | 复制 DTO,或重新建立合适的会话范围 |
| 外部 JDBC 已更新,Mapper 仍返回旧值 | namespace 缓存与全部写入口 | 修正失效方案或关闭该缓存 |
| read-write 缓存发布时序列化失败 | 返回对象及其字段能否序列化 | 调整结果类型;谨慎选择共享只读对象 |
| BATCH 返回很大的负数 | 当前 ExecutorType | 在 flush 结果和最终提交处判断写入 |
| 插件少收到一次 query | 拦截签名与对象自调用 | 选择能经过代理的实际入口 |
| 改写 SQL 后偶发跨条件结果 | 改写发生在 CacheKey 前还是后 | 让键包含全部查询条件并重测缓存路径 |
运行所有模式:
run_lab all普通读写二级缓存使用 Java 序列化,MyBatis 可能提示配置序列化过滤器。应用需要按实际返回对象限定允许反序列化的类型;不要把不受信任字节流接到这种缓存实现中。序列化过滤的配置原则见 Java 序列化过滤。
结束实验
在最初解压目录中执行:
docker compose -p da10-executor down
unset LAB_DB_PASSWORD LAB_APP_PASSWORD LAB_DB_URL LAB_DIR LAB_CACHE
unset -f run_lab这会移除本实验容器和网络,并丢弃 tmpfs 数据;解压源码与本机 Maven 缓存保留,可以再次使用。
权威资料与规范地址
缓存配置查官方手册,补丁版本敏感的执行顺序查固定 3.5.19 源码。
| 资料 | 完整地址 |
|---|---|
| MyBatis Java API 与本地缓存 | https://mybatis.org/mybatis-3/java-api.html |
| Configuration 与插件设置 | https://mybatis.org/mybatis-3/configuration.html |
| Mapper XML 与 namespace 缓存 | https://mybatis.org/mybatis-3/sqlmap-xml.html |
| BaseExecutor 3.5.19 | https://github.com/mybatis/mybatis-3/blob/mybatis-3.5.19/src/main/java/org/apache/ibatis/executor/BaseExecutor.java |
| CachingExecutor 3.5.19 | https://github.com/mybatis/mybatis-3/blob/mybatis-3.5.19/src/main/java/org/apache/ibatis/executor/CachingExecutor.java |
| DefaultSqlSession 3.5.19 | https://github.com/mybatis/mybatis-3/blob/mybatis-3.5.19/src/main/java/org/apache/ibatis/session/defaults/DefaultSqlSession.java |
| BatchExecutor 3.5.19 | https://github.com/mybatis/mybatis-3/blob/mybatis-3.5.19/src/main/java/org/apache/ibatis/executor/BatchExecutor.java |
| MyBatis-Spring 事务 | https://mybatis.org/spring/transactions.html |
| PostgreSQL 错误代码 | https://www.postgresql.org/docs/18/errcodes-appendix.html |
| Java 序列化过滤 | https://docs.oracle.com/en/java/javase/25/core/serialization-filtering1.html |
