MyBatis:会话归属、执行时点与缓存失效
同一条 SELECT 没有 JDBC 日志,可能命中会话缓存,也可能命中 namespace 缓存;调用 commit 后仍可能没有物理提交。应先标出代理、实际会话、执行器及连接的所有者,再解释返回值。旧机制与摘录固定 MyBatis 3.5.16 / MyBatis-Spring 3.0.3 / Framework 6.1.12;U42 新配置及实验另用 MyBatis 3.5.19 / MyBatis-Spring 4.0.0 / Boot starter 4.0.1 / Framework 7.0.10,两个组合的源码与运行结论不混作一次验证。目标是能解释一条 Mapper 调用究竟有没有执行 JDBC、用了谁的连接、缓存何时可见,以及失败后谁负责回收资源。
U42:从配置到可复现的第一个 Mapper
U42 实验使用 JDK 21、Boot 4.0.9、starter 4.0.1 和本机 MySQL 8.4.7;项目 POM、接口、XML、输入、退出状态与证据均在实验入口。它通过 @MapperScan("study.u42.mapper") 注册 ItemMapper,同包同名 XML 注册 SQL,并以同一个 DataSource 创建 SqlSessionFactory 和 Spring 事务管理器。错误扫描包得到零候选;正确包在另一上下文注册一个 Mapper。普通 Spring @ComponentScan 不能代替 Mapper 扫描。多工厂时须指定 sqlSessionFactoryRef 或 sqlSessionTemplateRef;两者同时指定时模板优先。扫描器同时设置 annotation 与 marker 过滤条件时是并集。来源:MyBatis-Spring 4.0.0 映射器文档。
新版本使用的配置顺序由 3.5.19 配置 DTD 约束:properties → settings → typeAliases → typeHandlers → objectFactory → objectWrapperFactory → reflectorFactory → plugins → environments → databaseIdProvider → mappers;未使用的可省略,顺序不能任意重排。可运行原生配置 包含默认工厂、TypeHandler、插件、databaseId 与 mappers;实验在 MySQL 验证 resource 覆盖元素体、builder 参数再覆盖 resource,并分别选中 mysql 与无 databaseId 的语句。原生调用还必须在 XML 的 environments 中配置 DataSource 和事务工厂,或在代码中构造并注入 Environment;URL、用户名和密码可经 builder properties 等外部配置传入,不能把明文密码提交到教材。Spring 的 SqlSessionFactoryBean 会建立自己的 environment,XML 内的 environment、dataSource 和 transactionManager 不控制 Spring 的资源归属;Spring 事务管理器与工厂应使用同一个 DataSource。来源:FactoryBean 和 事务。
| 配置 | 具体作用与排查 |
|---|---|
properties | builder 显式传入的属性优先于 resource/url,后者优先于元素体。占位符默认值功能默认关闭;启用后默认冒号分隔符可能与属性名或 OGNL 三元表达式冲突。 |
settings | cacheEnabled=true 仅允许已配置的二级缓存,不自动给每个 Mapper 开缓存;localCacheScope=SESSION,lazyLoadingEnabled=false,aggressiveLazyLoading=false。fetchSize 是驱动提示,不是保证;shrinkWhitespacesInSql 也影响字符串字面量。 |
typeAliases / typeHandlers | 别名把可读名称映射为 Java 类型;TypeHandler 在参数设置和结果读取处处理 JDBC/Java 转换。排查 null/枚举时同时检查 Java 类型、JDBC 类型和处理器注册范围,别把 JDBC NULL 当普通值推断。 |
objectFactory / objectWrapperFactory / reflectorFactory | 分别参与结果对象创建、对象属性访问包装和反射元数据;通常保留默认,替换时验证构造器、集合和并发访问,不能把它们当 SQL 拦截器。 |
plugins / databaseIdProvider / mappers | plugins 包装指定扩展点;databaseId 根据厂商选择带 databaseId 的语句,同名匹配项优先于无 databaseId 项;mappers 注册接口或 XML,同一 statement id 不应定义冲突。 |
上述默认值和扩展点以 3.5.19 配置文档 为准。useActualParamName 需要编译保留参数名;跨编译选项的示例优先使用 @Param。环境来源、优先级及插件变更后应重新构建工厂并用实际 BoundSql 和数据库结果复验,不能靠配置文件存在即认为已生效。
映射、动态 SQL 和安全边界
实验的 ItemMapper.xml 用 resultMap 显式映射 tenant_id → tenantId,并在动态查询中固定 tenant_id=#{tenant};非空集合用 foreach 绑定每个值,空集合进入 otherwise 的 AND 1=0,避免把“空过滤器”误变成“查询全部”。实验实际用另一租户独有名称做正反例,并输入 one' OR 1=1 -- ,结果为空。#{} 绑定值;${} 是文本替换,列名和排序方向若必须动态选择,应从服务端枚举映射为固定片段,外部文本不可直接拼接。参数化不能补上缺失的租户授权。
if 适合独立可选条件,choose/when/otherwise 只取一个分支;where/trim 去除多余连接词,set 处理更新末尾逗号;foreach、bind、注解 script、_databaseId 和语言驱动也有各自作用域。批量更新前应先拒绝空更新集合与缺少主键/租户条件,而不是寄望动态标签自动保证安全。动态 SQL 和 Mapper XML 是本段版本依据。
下面是教学片段;U42 在本机 MySQL 实跑了相应 set 与 bind 分支:空 set 报 SQL 错误、数据未改变,随后有效更新成功。应用应在进入 Mapper 前校验租户来自已认证上下文、id 存在且至少一个待改字段非空,再调用;否则拒绝,不让空 set 生成无效语句。bind 只生成绑定值,不使 % 和 _ 失去 SQL LIKE 通配意义;需要字面搜索时须按数据库规则转义并声明 ESCAPE。
<update id="rename">
UPDATE item <set><if test="name != null">name = #{name,jdbcType=VARCHAR},</if></set>
WHERE id = #{id} AND tenant_id = #{tenant}
</update>
<select id="findPrefix" resultType="string">
<bind name="prefix" value="name + '%'"/>
SELECT name FROM item <where>tenant_id = #{tenant} AND name LIKE #{prefix}</where>
</select>例如状态列存 READY/DONE 时可将 Java 枚举注册给 EnumTypeHandler;持久化名是契约,重命名枚举会使旧行读取失败。EnumOrdinalTypeHandler 存序号,重排枚举会改变旧值含义,须先做迁移而非静默切换;nullable 参数应指定数据库能接受的 jdbcType 并按驱动实测。U42 用 EnumTypeHandler 实测 READY、SQL NULL 与未知枚举值失败后修正为 DONE 的恢复;没有运行自定义 TypeHandler 或 ordinal 迁移,不能把这组结果当兼容矩阵。
结果集的 id 映射用于区分对象身份;association/collection 可嵌套结果或嵌套查询。实验 XML 的 join association 实际读出商品分类,collection 把同一分类的两行聚合成两个商品,并以指向同一分类 ID 的另一租户商品检验 JOIN 隔离。事务内 fetchType="lazy" 的嵌套 select 在获取商品后尚未 prepare 分类 SQL,访问分类属性后观察到一次 prepare;这只证明本场景的时点。例:100 个订单各触发一次客户查询会形成 1+100 次查询;join 合并能减少往返,但一对多会重复父行,须用正确 id 聚合并评估内存。懒加载只推迟需要的数据,不能自动消除 N+1;还要考虑会话关闭后访问。构造器映射、discriminator 和多结果集在新基线尚未运行,应按实际驱动和返回形状测试。对有返回结果的 DML,3.5.19 的 affectData=true 用于标记事务脏状态,并按语义配置 flushCache;不要把有返回行的写入当普通缓存查询。
XML 与注解都可注册语句;筛选 databaseId 后仍重复注册同一完整 statement id 会冲突,而带匹配 databaseId 与无 databaseId 的同名项可按规则共存。实验 ItemSql 用 MyBatis 内置 SQL builder 生成含 #{tenant} 的条件,并通过 @SelectProvider 在 MySQL 实际执行;它与独立发行的 MyBatis Dynamic SQL 项目不是同一组件。原生 API 的 SqlSessionFactory.openSession() 应使用 try-with-resources,写入后在自己拥有事务时显式 commit/异常 rollback;autoCommit 和外部事务仍决定实际物理提交。Spring 内则注入 SqlSessionTemplate 或 Mapper,把提交与回滚交给事务管理器,业务代码不能手动调用 template 的 commit/rollback/close。
插件、分页与诊断的操作边界
插件只拦截声明的 Executor、StatementHandler、ParameterHandler、ResultSetHandler 方法;不是可拦截任意 Java 方法的 AOP。实验先注册 first、后注册 second 两个 Executor.update 插件,观察到 second:before → first:before → first:after → second:after。这是指定扩展点、指定注册顺序的一次真实观察;不同插件可能修改 SQL、参数或缓存键,必须检查完整链路而非把单个插件的单测当组合正确。插件拦截不是权限边界:直接 JDBC、其他 factory 或未经过该拦截点的 SQL 仍可能绕过,应让租户条件与数据库约束共同承担责任。
RowBounds 是结果边界接口,不保证生成数据库的 LIMIT/OFFSET;要做大表分页,显式使用受控 SQL 或核验分页插件生成的数据库方言及 count SQL。深 offset 的扫描成本随位置增长,可比较稳定排序键的 keyset 分页;keyset 需要唯一排序键和一致的过滤条件,无法无代价跳到任意页。日志应确认 MyBatis 日志实现、statement id、最终 SQL 与绑定参数,再结合数据库执行计划与请求窗口;日志参数可能含敏感值,线上应脱敏。没有 Preparing 日志可能命中缓存、日志级别不对或执行不同路径,不能单凭它断言数据库未执行。参考 logging、Java API。
三层身份:Mapper 代理、会话门面、执行器
Mapper 接口不是 DAO 实现类。配置阶段注册 MappedStatement、结果映射和 Mapper 元数据;MapperRegistry/MapperProxyFactory 创建接口代理。Spring 集成中扫描器把接口注册为 MapperFactoryBean 的定义,产物是 Mapper 代理,依赖线程安全的 SqlSessionTemplate。Template 再按当前事务解析实际 SqlSession,后者持有 Executor。
因此一个单例 Mapper 不代表所有线程共享同一个 DefaultSqlSession;一个单例 SqlSessionTemplate 也不表示把会话一级缓存全局共享。DefaultSqlSession 通常不应跨线程共享;Template 的安全来自按调用及线程事务选择实际会话。把三者身份混在一起,就会误以为“Mapper 单例,所以一级缓存跨请求”。
完整普通查询链为:
MapperProxy.invoke
-> MapperMethod.execute
-> SqlSessionTemplate -> SqlSessionInterceptor
-> SqlSessionUtils.getSqlSession -> DefaultSqlSession.selectList
-> CachingExecutor(启用时) -> BaseExecutor.query
-> SimpleExecutor.doQuery -> Configuration.newStatementHandler
-> RoutingStatementHandler -> PreparedStatementHandler
-> ParameterHandler -> JDBC execute -> ResultSetHandlerMapperProxy 对 Object 方法、接口 default 方法和普通 SQL 方法分支处理。普通方法缓存的是 MapperMethod/调用器元数据,不是查询结果。MapperMethod 按 INSERT/UPDATE/DELETE/SELECT/FLUSH 分派,再根据单值、集合、Map、Cursor、Optional 等返回形状选择 SqlSession 方法;单值却返回多行会在对应会话路径报错,基本类型方法得到 null 也会失败。源码入口见 MapperProxy、MapperMethod。
默认方法也可能再次经 Mapper 代理调用其他 SQL 方法,但不能把 Mapper 元数据缓存误认作会话缓存。MapperFactoryBean 的工厂身份沿用容器正文;这里权威解释的是持久层门面与会话。
MappedStatement、BoundSql 与参数边界
MappedStatement 保存 statement id、命令类型、SqlSource、结果映射、缓存选项等。调用时 SqlSource 根据输入产生 BoundSql,其中有最终 SQL、参数映射及动态 SQL 的附加参数。相同 Mapper 方法的不同动态分支可能产生不同 SQL,不能只用方法名作为缓存键或日志定位键。
井号占位符通过参数映射交给 PreparedStatement 绑定值,美元占位符则直接替换 SQL 文本。表名、列名、排序表达式不能当普通值参数绑定,必须把外部选择映射成服务端白名单片段。即使值已绑定,遗漏租户条件、越权读取及过宽查询仍可能发生;“防注入”不等于“已授权”。
类型转换由 TypeHandler 参与,包括 null、JDBC 类型、枚举和时间等。排查“数据库正确但 Java 值不对”要看列别名、resultMap、类型处理器和时区,不应先怀疑缓存。嵌套 select 可能形成 N+1,join 映射则可能增加行重复与对象组装成本;选择依据是结果基数和访问模式,而不只是 SQL 条数。
StatementHandler 的 prepare、parameterize、query/update 分工可沿 SimpleExecutor、PreparedStatementHandler 与 DefaultResultSetHandler 继续阅读。查询没有经过数据库,不意味着没有对象映射和缓存对象别名问题。
Executor 类型、插件及批量的真实时点
Configuration.newExecutor 选择 SIMPLE、REUSE 或 BATCH;cacheEnabled 时再包 CachingExecutor,最后用插件链包装。SIMPLE 常规创建语句,REUSE 复用语句资源,BATCH 把写入累积到批处理,flushStatements 才把批量操作真正发送执行;flush 不等于事务提交。
插件机制支持 Executor、StatementHandler、ParameterHandler、ResultSetHandler 等明确拦截点,不是 Spring AOP 的任意业务方法切点。Configuration 在创建这些对象时调用 interceptorChain.pluginAll,后注册插件会形成外层包装。插件中的内部 this 调用仍不当然重新经过外层代理。租户、分页、审计插件若都改 SQL,必须检查最终 BoundSql、参数映射和缓存键时点,不能只验证各插件单独可用。Configuration.newExecutor/newStatementHandler 是定位入口。
BATCH 方法的返回值不应按普通单条 update 行数解读,实际批结果要看 flush 的 BatchResult/驱动结果。查询可能触发先 flush 待执行批语句,生成主键也要结合驱动及 key generator 时点。一个批次中途失败,已发送和数据库接受的操作需要按驱动结果及事务回滚判断,不能把“Java 循环执行到第几行”当持久化边界。批量大小还受参数数量、包大小、内存和锁持有时间影响。
原码:BatchExecutor.doFlushStatements,mybatis-3.5.16,114–161 行。固定来源。
@Override
public List<BatchResult> doFlushStatements(boolean isRollback) throws SQLException {
try {
List<BatchResult> results = new ArrayList<>();
if (isRollback) {
return Collections.emptyList();
}
for (int i = 0, n = statementList.size(); i < n; i++) {
Statement stmt = statementList.get(i);
applyTransactionTimeout(stmt);
BatchResult batchResult = batchResultList.get(i);
try {
batchResult.setUpdateCounts(stmt.executeBatch());
MappedStatement ms = batchResult.getMappedStatement();
List<Object> parameterObjects = batchResult.getParameterObjects();
KeyGenerator keyGenerator = ms.getKeyGenerator();
if (Jdbc3KeyGenerator.class.equals(keyGenerator.getClass())) {
Jdbc3KeyGenerator jdbc3KeyGenerator = (Jdbc3KeyGenerator) keyGenerator;
jdbc3KeyGenerator.processBatch(ms, stmt, parameterObjects);
} else if (!NoKeyGenerator.class.equals(keyGenerator.getClass())) { // issue #141
for (Object parameter : parameterObjects) {
keyGenerator.processAfter(this, ms, stmt, parameter);
}
}
// Close statement to close cursor #1109
closeStatement(stmt);
} catch (BatchUpdateException e) {
StringBuilder message = new StringBuilder();
message.append(batchResult.getMappedStatement().getId()).append(" (batch index #").append(i + 1).append(")")
.append(" failed.");
if (i > 0) {
message.append(" ").append(i)
.append(" prior sub executor(s) completed successfully, but will be rolled back.");
}
throw new BatchExecutorException(message.toString(), e, results, batchResult);
}
results.add(batchResult);
}
return results;
} finally {
for (Statement stmt : statementList) {
closeStatement(stmt);
}
currentSql = null;
statementList.clear();
batchResultList.clear();
}
}isRollback 为 true 时直接返回空结果,但仍进 finally 清语句和批列表;普通 flush 才 executeBatch。生成键处理在执行后,BatchUpdateException 携带已完成批结果与失败批;finally 不论成功失败都关闭语句,不意味着此前数据库执行已被自动撤销。实际回滚由外层资源事务决定。实验区分同连接可见、独立连接可见与最终持久结果,不能拿 BATCH 的 update 返回哨兵值当更新行数。
一级缓存:会话内结果复用,不是隔离级别
BaseExecutor 保存 localCache,默认 SESSION 范围。CacheKey 包含 statement id、RowBounds 的 offset/limit、最终 SQL、参与绑定的参数值及 environment id。它不天然包含业务 ThreadLocal 的租户或权限;若 SQL 与参数没有表达这些区别,缓存无法替你隔离结果。
按 BaseExecutor.query 精简为教学伪代码:
if (closed) throw executorClosed();
if (queryStack == 0 && statement.flushCacheRequired()) clearLocalCache();
queryStack++;
try {
result = resultHandler == null ? localCache.get(key) : null;
if (result == null) result = queryFromDatabaseAndCache(key);
} finally {
queryStack--;
}
if (queryStack == 0) {
finishDeferredLoads();
if (localCacheScope == STATEMENT) clearLocalCache();
}
return result;queryFromDatabase 暂存执行占位标记,finally 移除,再登记结果;嵌套查询和延迟装载依赖这个状态,不能把缓存当作可随意删除的 HashMap。update、commit、rollback 等路径会清理一级缓存。STATEMENT 在最外层查询结束清理,但嵌套执行中仍有状态,因此不是彻底不存在 localCache。
同一 SqlSession 连续查询可能返回缓存中的同一对象引用,修改结果对象会影响后续会话内观察。另一个连接修改数据库,不会自动清除当前会话缓存;再次得到旧值不能据此证明数据库隔离级别是可重复读。要验证数据库隔离,应绕过或清理缓存并确认实际 JDBC 查询。也要区分命中一级、命中二级和数据库执行,不应只以日志缺少 Preparing 判定全部原因。
原码:一级缓存命中与实际数据库查询分流
原码连续摘录(仅统一公共缩进,截取方法局部):BaseExecutor.java,mybatis-3.5.16,L154–L166。
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
if (queryStack == 0) {
for (DeferredLoad deferredLoad : deferredLoads) {
deferredLoad.load();
}自定义resultHandler存在时不直接使用这条缓存命中路径;命中则处理缓存输出参数,未命中才queryFromDatabase。finally递减queryStack,最外层退出时继续处理deferredLoads及范围清理。看到相同结果不说明数据库再次执行,STATEMENT范围也不意味着整个执行过程中完全没有缓存状态。
二级缓存:namespace、事务缓冲和失效漏洞
CachingExecutor 先取 MappedStatement 的 Cache;有缓存且 useCache=true、无自定义 ResultHandler 等条件满足时,先查事务缓存管理器,未命中才委托 BaseExecutor 查询,并将结果放入待提交集合。只有全局 cacheEnabled 并不意味着每个 namespace 自动启用二级缓存,还需 cache/cache-ref 或相应注解配置。
TransactionalCache 暂存待写入条目和 clearOnCommit 标记;提交清除/发布,回滚丢弃待写入并处理未命中项解锁。语句 flushCache 的 namespace 范围与 cache-ref 关系决定失效边界。相关分支见 CachingExecutor 和 TransactionalCache。
错误答案是“任意修改表都会自动清 MyBatis 二级缓存”。反例:Mapper A 查询连接表,Mapper B 或 JDBC 脚本更新其中一张表,却不共享 A 的缓存失效协议,A 的缓存仍旧。多实例默认本地缓存也不会自动广播失效。即使建立了失效通知,还要分析并发查询旧值在失效后回填的竞争,不能把删缓存视为强一致证明。
原码:TransactionalCache.java,mybatis-3.5.16,连续 94–122 行。固定来源。
public void commit() {
if (clearOnCommit) {
delegate.clear();
}
flushPendingEntries();
reset();
}
public void rollback() {
unlockMissedEntries();
reset();
}
private void reset() {
clearOnCommit = false;
entriesToAddOnCommit.clear();
entriesMissedInCache.clear();
}
private void flushPendingEntries() {
for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
delegate.putObject(entry.getKey(), entry.getValue());
}
for (Object entry : entriesMissedInCache) {
if (!entriesToAddOnCommit.containsKey(entry)) {
delegate.putObject(entry, null);
}
}
}commit 按 clearOnCommit 清 delegate、发布待提交条目、reset;rollback 解锁未命中项并清缓冲,不撤销此前已经发布到 delegate 的条目。commit 的 reset 不在 finally:缓存适配器中途抛错可能留下部分副作用,不能把它当原子事务协议。CachingExecutor.close(false) 也可能发布;纯读会话/dirty=false 的关闭不能一概称为“数据库回滚必丢弃所有二级条目”。
Spring 集成还有更细的边界:SqlSessionSynchronization.beforeCommit 调 SqlSession.commit,BaseExecutor flush,CachingExecutor 发布事务缓存;SpringManagedTransaction 发现连接由 Spring 管理则不自行 JDBC commit。真正物理提交随后由 Spring 管理器执行。因此二级缓存可能先于物理提交更新;后续提交失败时不能承诺数据库与二级缓存原子一致。3.0.3 的 SqlSessionUtils.beforeCommit 明确保留了此风险说明。余额、库存裁决等强一致读不应建立在这种缓存假设上。
原码:SqlSessionUtils.java,mybatis-spring-3.0.3,连续 278–301 行。固定来源。
@Override
public void beforeCommit(boolean readOnly) {
// Connection commit or rollback will be handled by ConnectionSynchronization or
// DataSourceTransactionManager.
// But, do cleanup the SqlSession / Executor, including flushing BATCH statements so
// they are actually executed.
// SpringManagedTransaction will no-op the commit over the jdbc connection
// TODO This updates 2nd level caches but the tx may be rolledback later on!
if (TransactionSynchronizationManager.isActualTransactionActive()) {
try {
LOGGER.debug(() -> "Transaction synchronization committing SqlSession [" + this.holder.getSqlSession() + "]");
this.holder.getSqlSession().commit();
} catch (PersistenceException p) {
if (this.holder.getPersistenceExceptionTranslator() != null) {
DataAccessException translated = this.holder.getPersistenceExceptionTranslator()
.translateExceptionIfPossible(p);
if (translated != null) {
throw translated;
}
}
throw p;
}
}
}actualTransactionActive 才触发这里的会话提交,不因 readOnly=true 自动跳过。它先 flush 并可能发布缓存,物理提交由 Spring 后续执行;注释提到的 ConnectionSynchronization 负责资源同步,不是本地 JDBC 的物理 commit 实现。应沿 DataSourceTransactionManager.doCommit 而非只读注释。
本批 cache-failure 输入在这个同步之后、物理提交之前注入另一个 beforeCommit 异常:数据库回滚为 old,后续查询却命中 ghost。它证明本地 H2 + 默认缓存的这个可复现窗口,不证明所有第三方缓存的可见性或网络提交未知行为。库存/余额裁决应使用数据库条件和约束,避免依赖此缓存原子性。
原码:执行器提交与事务缓存提交有先后
原码连续摘录(仅统一公共缩进,截取方法局部):CachingExecutor.java,mybatis-3.5.16,L119–L122。
public void commit(boolean required) throws SQLException {
delegate.commit(required);
tcm.commit();
}delegate.commit(required)正常返回后才tcm.commit;前者若抛异常,下一句不执行。注意这只是执行器层调用顺序:SpringManagedTransaction的commit可能因连接由Spring管理而不做物理提交,所以不能据这四行就宣称二级缓存发布永远晚于数据库真正commit。需要再沿Spring同步回调核对具体集成边界。
与 Spring 的事务协作和资源释放
SqlSessionTemplate 的内部代理每次从 SqlSessionUtils 获取实际会话。若当前同步中已有同一 SqlSessionFactory 的 holder,就复用;否则 openSession,并在符合 SpringManagedTransactionFactory 与同步条件时登记 holder 和同步回调。已有事务中不能随意切换 ExecutorType,否则可能抛 TransientDataAccessResourceException。
SpringManagedTransaction 通过 DataSourceUtils 取得连接,并识别是否由 Spring 管理。若是,commit/rollback 对该连接不自行执行;close 也通过资源工具释放使用权。非事务调用的 Template 则可能在每次调用后 force commit 并关闭会话,因此连续两次 Mapper 调用未必复用一级缓存;“一个 HTTP 请求一个 SqlSession”不是这个集成的普遍契约。SqlSessionTemplate、SpringManagedTransaction 给出分支。
原码:SqlSessionTemplate.java,mybatis-spring-3.0.3,连续 420–450 行。固定来源。
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory,
SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
try {
Object result = method.invoke(sqlSession, args);
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
sqlSession.commit(true);
}
return result;
} catch (Throwable t) {
Throwable unwrapped = unwrapThrowable(t);
if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
// release the connection to avoid a deadlock if the translator is no loaded. See issue #22
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
sqlSession = null;
Throwable translated = SqlSessionTemplate.this.exceptionTranslator
.translateExceptionIfPossible((PersistenceException) unwrapped);
if (translated != null) {
unwrapped = translated;
}
}
throw unwrapped;
} finally {
if (sqlSession != null) {
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
}
}
}获取实际会话在 try 之前,故此 finally 不覆盖获取失败。已绑定事务会话通过 closeSqlSession 释放使用计数,真正关闭由同步完成;非事务会话成功调用后 force commit 并关闭,不能跨两次调用承诺一级命中。异常解包后先释放再翻译,置 null 防止 finally 重复关闭;这里没有直接 rollback 不表示外层事务不会回滚。
原码:SpringManagedTransaction.java,mybatis-spring-3.0.3,连续 79–108 行。固定来源。
private void openConnection() throws SQLException {
this.connection = DataSourceUtils.getConnection(this.dataSource);
this.autoCommit = this.connection.getAutoCommit();
this.isConnectionTransactional = DataSourceUtils.isConnectionTransactional(this.connection, this.dataSource);
LOGGER.debug(() -> "JDBC Connection [" + this.connection + "] will"
+ (this.isConnectionTransactional ? " " : " not ") + "be managed by Spring");
}
/**
* {@inheritDoc}
*/
@Override
public void commit() throws SQLException {
if (this.connection != null && !this.isConnectionTransactional && !this.autoCommit) {
LOGGER.debug(() -> "Committing JDBC Connection [" + this.connection + "]");
this.connection.commit();
}
}
/**
* {@inheritDoc}
*/
@Override
public void rollback() throws SQLException {
if (this.connection != null && !this.isConnectionTransactional && !this.autoCommit) {
LOGGER.debug(() -> "Rolling back JDBC Connection [" + this.connection + "]");
this.connection.rollback();
}
}只有连接非 null、并非 Spring 事务连接且原 autoCommit=false,才显式执行 JDBC commit/rollback。用了 Spring 类不等于永远 no-op,未开启事务也不等于一定显式 commit;autoCommit=true 已由驱动按语句处理。资源归属按 DataSource holder 与连接匹配检查,不看 URL 猜测。
Template 禁止业务手工 commit/rollback/close,统一由事务协议完成。异常翻译路径还会先适当释放会话,避免翻译器初始化再借连接造成死锁;随后 finally 收尾。手工 openSession、不同 DataSource 包装、选错事务管理器,都可能使注解存在却使用另一连接。应核对同一个 DataSource 资源身份,不只核对 JDBC URL 相同。
Cursor 的所有权与异常退出
Cursor 和流式结果需要会话及 JDBC 资源存活;在非事务 Template 调用返回后,会话可能已关闭,不能把 Cursor 交给任意后台线程慢慢消费。懒加载可能在访问时额外发 SQL,不应假定离开当前事务还能看到同一快照。NESTED 保存点只回退 JDBC 状态,未自动回退 Java 结果对象、一级缓存和待执行 BATCH;把嵌套保存点与会话缓存混用前要设计独立实验,不能从数据库回滚推断整个执行器回滚到了保存点。
Cursor 是资源句柄而非可任意延迟消费的 List。安全结构是“进入事务 → 取得 Cursor → 同步消费 → try-with-resources 关闭 → 完成事务”。消费端抛错也应关闭;若跨线程/异步交付,明确迁移查询责任而非迁移活连接。H2 fetchSize=2 的实验只核对生命周期,未证明 MySQL 驱动真实流式取数或网络内存上界。
实验:用 JDBC prepare 计数与独立会话查证
U42 的 新基线实验 在本机 MySQL 8.4.7 实跑,证据记录 DuplicateKeyException、SQLState 23000 和错误码 1062;SQL 异常发生在事务外,后续独立查询成功;这不证明在已失败事务内可继续。相同 factory 的 SIMPLE 与 BATCH 模板在同一事务内混用抛 TransientDataAccessResourceException;独立 REQUIRES_NEW 的 BATCH 写入提交,外层写入回滚,结果分别核对。Template 的 commit/rollback/close 均拒绝业务手工调用。错误扫描包为空与正确上下文有一个 Mapper 是两个独立输入,不是原上下文自动恢复。证据还覆盖 try-with-resources 的 Cursor 正常消费、离开非事务会话后不可迭代及消费抛错后的关闭和连接归还。
新组合的扫描、事务与异常结论可与 MyBatis-Spring 4.0.0 SqlSessionUtils 和 异常翻译器 对照。非 SQL 的 PersistenceException、禁用翻译、异 DataSource 与新版本二级缓存提交失败窗口没有在此新组合实跑,不应由重复键结果推断。以下旧组合的 H2 五模式保留为独立证据,不拼成新版本的运行记录。
从仓库根执行 JAVA_HOME=$(/usr/libexec/java_home -v17) bash labs/spring-senior/run.sh mybatis。五模式各自独立内存库;没有 Redis 或外部数据库。本批五模式实际运行通过,Liberica JDK17.0.19+11-LTS;输入/预期如下:
| 输入 | 实际配置与断言目标 |
|---|---|
| native | 原生会话,prepare计数区分L1/clearCache/L2;Mapper更新提交失效;外部JDBC写仍可读旧缓存;新增加写读后强制rollback,待发布值不覆盖原缓存 |
| spring | SpringManagedTransactionFactory + SqlSessionTemplate;同事务实际连接复用,关闭useCache的两次读只prepare一次,事务外两次读prepare两次;提交/回滚以独立读取判读,holder不残留 |
| batch | 同事务JDBC在flush前见0行、后见2行;显式回滚后见0行。重复主键在flush失败,验证SQLState=23505、表空及池active=0/holder已解绑 |
| cursor | 无事务Template返回的Cursor已关闭;事务内可完整消费;消费中主动抛错也关闭Cursor、归还连接,池active=0 |
| cache-failure | 会话beforeCommit发布ghost之后,order=2000的同步主动抛错;数据库old、缓存ghost,证明发布不能随数据库自动回滚 |
每项都核对具体异常或结果,不把任意启动失败当成功。StatementHandler.prepare 是实际SQL准备的观测,不能用 Executor.query 调用次数替代。spring 的读取显式禁用二级,避免L2掩盖L1会话差异。缓存故障输入是物理提交前的同步失败,不是注入网络断开,也不是伪造 Connection.commit 的未知结果。
package study.spring;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import org.h2.jdbcx.JdbcDataSource;
import org.apache.ibatis.annotations.*;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
public class MyBatisLab {
@CacheNamespace(readWrite=false) public interface Items {
@Select("select name from item where id=#{id}") String name(int id);
@Update("update item set name=#{name} where id=1") void rename(String name);
@Select("select name from item where id=#{id}") @Options(useCache=false) String uncached(int id);
@Insert("insert into item values(#{id},'batch')") int insert(int id);
@Select("select name from item order by id") @Options(useCache=false,fetchSize=2)
org.apache.ibatis.cursor.Cursor<String> cursor();
}
@Intercepts(@Signature(type=StatementHandler.class,method="prepare",args={java.sql.Connection.class,Integer.class}))
public static class Counter implements Interceptor {final AtomicInteger count=new AtomicInteger();public Object intercept(Invocation inv)throws Throwable{count.incrementAndGet();return inv.proceed();}}
public static void main(String[] args)throws Exception{
String mode=args.length==0?"native":args[0];
if(mode.equals("spring")){spring();return;}
if(mode.equals("batch")){batch();return;}
if(mode.equals("cursor")){cursor();return;}
if(mode.equals("cache-failure")){cacheFailure();return;}
if(!mode.equals("native"))throw new IllegalArgumentException(mode);
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:mybatis;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()){s.execute("create table item(id int primary key,name varchar(30))");s.execute("insert into item values(1,'old')");}
var cfg=new Configuration(new Environment("lab",new JdbcTransactionFactory(),ds));var counter=new Counter();cfg.addInterceptor(counter);cfg.addMapper(Items.class);var f=new SqlSessionFactoryBuilder().build(cfg);
try(var s=f.openSession()){var m=s.getMapper(Items.class);m.name(1);m.name(1);if(counter.count.get()!=1)throw new AssertionError("first level");s.clearCache();m.name(1);if(counter.count.get()!=2)throw new AssertionError("clearCache");s.commit();}
try(var s=f.openSession()){if(!s.getMapper(Items.class).name(1).equals("old")||counter.count.get()!=2)throw new AssertionError("second level");s.commit();}
try(var s=f.openSession()){s.getMapper(Items.class).rename("new");s.commit();}
try(var s=f.openSession()){if(!s.getMapper(Items.class).name(1).equals("new"))throw new AssertionError("invalidated");s.commit();}
try(var c=ds.getConnection();var s=c.createStatement()){s.execute("update item set name='external' where id=1");}
try(var s=f.openSession()){if(!s.getMapper(Items.class).name(1).equals("new"))throw new AssertionError("expected stale namespace cache");}
try(var s=f.openSession()){
s.getMapper(Items.class).rename("rolled");
if(!"rolled".equals(s.getMapper(Items.class).name(1)))throw new AssertionError("pending cache");
s.rollback(true);
}
try(var s=f.openSession()){
if(!"new".equals(s.getMapper(Items.class).name(1)))throw new AssertionError("rollback published pending cache");
}
System.out.println("PASS MyBatis L1/L2/mapper invalidation/external-write counterexample prepares="+counter.count);
}
static void spring() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:managed;DB_CLOSE_DELAY=-1");
var jdbc=new org.springframework.jdbc.core.JdbcTemplate(ds);
jdbc.execute("create table item(id int primary key,name varchar(30))");
jdbc.update("insert into item values(1,'old')");
var cfg=new Configuration(new Environment("managed",new org.mybatis.spring.transaction.SpringManagedTransactionFactory(),ds));
var counter=new Counter();cfg.addInterceptor(counter);cfg.addMapper(Items.class);
var factory=new SqlSessionFactoryBuilder().build(cfg);
var template=new org.mybatis.spring.SqlSessionTemplate(factory);
var mapper=template.getMapper(Items.class);
var tx=new org.springframework.transaction.support.TransactionTemplate(new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds));
tx.executeWithoutResult(status->{
var session=org.mybatis.spring.SqlSessionUtils.getSqlSession(factory);
try {
if(session.getConnection()!=org.springframework.jdbc.datasource.DataSourceUtils.getConnection(ds))
throw new AssertionError("not the Spring-bound connection");
} finally {
org.springframework.jdbc.datasource.DataSourceUtils.releaseConnection(session.getConnection(),ds);
org.mybatis.spring.SqlSessionUtils.closeSqlSession(session,factory);
}
mapper.uncached(1);mapper.uncached(1);
if(counter.count.get()!=1)throw new AssertionError("transaction did not reuse L1");
});
if(org.springframework.transaction.support.TransactionSynchronizationManager.hasResource(factory))throw new AssertionError("session holder leaked");
int before=counter.count.get();mapper.uncached(1);mapper.uncached(1);
if(counter.count.get()!=before+2)throw new AssertionError("nontransaction calls shared L1");
tx.executeWithoutResult(status->{mapper.rename("rolled");status.setRollbackOnly();});
if(!"old".equals(jdbc.queryForObject("select name from item where id=1",String.class)))throw new AssertionError("rollback");
tx.executeWithoutResult(status->mapper.rename("committed"));
if(!"committed".equals(jdbc.queryForObject("select name from item where id=1",String.class)))throw new AssertionError("commit");
System.out.println("PASS MyBatis spring L1/release/commit/rollback prepares="+counter.count);
}
static class Managed implements AutoCloseable {
final com.zaxxer.hikari.HikariDataSource ds;
final org.springframework.jdbc.core.JdbcTemplate jdbc;
final SqlSessionFactory factory;
final org.mybatis.spring.SqlSessionTemplate template;
final org.springframework.transaction.support.TransactionTemplate tx;
Managed(String name,ExecutorType type) {
var config=new com.zaxxer.hikari.HikariConfig();config.setJdbcUrl("jdbc:h2:mem:"+name+";DB_CLOSE_DELAY=-1");
config.setMaximumPoolSize(2);config.setConnectionTimeout(300);ds=new com.zaxxer.hikari.HikariDataSource(config);
jdbc=new org.springframework.jdbc.core.JdbcTemplate(ds);
jdbc.execute("create table item(id int primary key,name varchar(30))");
var cfg=new Configuration(new Environment(name,new org.mybatis.spring.transaction.SpringManagedTransactionFactory(),ds));
cfg.addMapper(Items.class);factory=new SqlSessionFactoryBuilder().build(cfg);
template=new org.mybatis.spring.SqlSessionTemplate(factory,type);
tx=new org.springframework.transaction.support.TransactionTemplate(new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds));
}
void released() {
if(org.springframework.transaction.support.TransactionSynchronizationManager.hasResource(factory)
||org.springframework.transaction.support.TransactionSynchronizationManager.hasResource(ds)
||ds.getHikariPoolMXBean().getActiveConnections()!=0)throw new AssertionError("resources leaked");
}
public void close(){ds.close();}
}
static void batch() {
try(var m=new Managed("batch",ExecutorType.BATCH)) {
var mapper=m.template.getMapper(Items.class);
m.tx.executeWithoutResult(status->{
mapper.insert(1);mapper.insert(2);
if(m.jdbc.queryForObject("select count(*) from item",Integer.class)!=0)throw new AssertionError("batch executed before flush");
var results=m.template.flushStatements();
if(results.size()!=1||results.get(0).getUpdateCounts().length!=2)throw new AssertionError("batch results");
if(m.jdbc.queryForObject("select count(*) from item",Integer.class)!=2)throw new AssertionError("flush not executed");
status.setRollbackOnly();
});
if(m.jdbc.queryForObject("select count(*) from item",Integer.class)!=0)throw new AssertionError("flush was treated as commit");
try {
m.tx.executeWithoutResult(status->{mapper.insert(3);mapper.insert(3);m.template.flushStatements();});
throw new AssertionError("expected duplicate batch failure");
} catch(org.springframework.dao.DataAccessException expected) {
Throwable cause=expected;while(cause.getCause()!=null)cause=cause.getCause();
if(!(cause instanceof java.sql.SQLException sql)||!"23505".equals(sql.getSQLState()))throw expected;
}
if(m.jdbc.queryForObject("select count(*) from item",Integer.class)!=0)throw new AssertionError("batch failure was not rolled back");
m.released();System.out.println("PASS MyBatis batch flush/rollback/duplicate/resource-release");
}
}
static void cursor() {
try(var m=new Managed("cursor",ExecutorType.SIMPLE)) {
m.jdbc.update("insert into item values(1,'one'),(2,'two')");
var mapper=m.template.getMapper(Items.class);
// Nontransaction template closes its session before the cursor can escape.
try(var cursor=mapper.cursor()) {
try {cursor.iterator();throw new AssertionError("escaped cursor remained usable");}
catch(IllegalStateException expected){if(!expected.getMessage().contains("closed"))throw expected;}
} catch(java.io.IOException e){throw new RuntimeException(e);}
m.tx.executeWithoutResult(status->{
try(var cursor=mapper.cursor()) {
var values=new ArrayList<String>();cursor.forEach(values::add);
if(!values.equals(List.of("one","two")))throw new AssertionError(values);
} catch(java.io.IOException e){throw new RuntimeException(e);}
});
var captured=new java.util.concurrent.atomic.AtomicReference<org.apache.ibatis.cursor.Cursor<String>>();
try {
m.tx.executeWithoutResult(status->{
try(var cursor=mapper.cursor()) {
captured.set(cursor);cursor.iterator().next();throw new IllegalStateException("consumer failure");
} catch(java.io.IOException e){throw new RuntimeException(e);}
});
throw new AssertionError("expected consumer failure");
} catch(IllegalStateException expected){if(!"consumer failure".equals(expected.getMessage()))throw expected;}
if(captured.get()==null||captured.get().isOpen())throw new AssertionError("cursor leaked");
m.released();System.out.println("PASS MyBatis cursor lifetime/resource-release");
}
}
static void cacheFailure() {
try(var m=new Managed("cachefailure",ExecutorType.SIMPLE)) {
m.jdbc.update("insert into item values(1,'old')");
var mapper=m.template.getMapper(Items.class);
try {
m.tx.executeWithoutResult(status->{
mapper.rename("ghost");
if(!"ghost".equals(mapper.name(1)))throw new AssertionError("transaction value");
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(
new org.springframework.transaction.support.TransactionSynchronization(){
public int getOrder(){return 2000;}
public void beforeCommit(boolean readOnly){throw new IllegalStateException("after cache publication");}
});
});
throw new AssertionError("expected beforeCommit failure");
} catch(IllegalStateException expected){if(!"after cache publication".equals(expected.getMessage()))throw expected;}
String database=m.jdbc.queryForObject("select name from item where id=1",String.class);
String cached=mapper.name(1);
if(!"old".equals(database)||!"ghost".equals(cached))throw new AssertionError(database+"/"+cached);
m.released();System.out.println("PASS MyBatis cache-failure database="+database+" cached="+cached);
}
}
}未运行真实MySQL批错误/流式fetch、跨实例缓存广播、NESTED保存点与BATCH/缓存联合回退、插件互改SQL、懒加载跨事务及网络提交失败;这些契约风险保留为推演。不要由H2中回滚成功推出生产数据库所有错误后都可继续。
高级追问与架构师条件变化
高级答案:“MapperProxy 缓存调用元数据,经 MapperMethod 到实际 SqlSession;Executor 决定缓存和执行策略,Statement/Parameter/ResultSetHandler 完成 JDBC 与映射。一级属于实际会话,二级按 namespace 且有事务缓冲;Template 按 Spring 事务绑定会话和连接,物理提交归管理器。”
三轮追问:先问 Mapper 单例为何安全,答代理与实际会话分离;再问同事务第二次无 SQL 是否证明数据库隔离,答可能命中一级缓存;最后问提交失败二级缓存是否安全,答 beforeCommit 的缓存发布可早于物理提交,不能宣称原子。各 2 分,能指出资源清理与给出独立证据各加 2 分。
架构变化:读多写少商品详情有 95% 重复查询,可用显式缓存降低数据库压力,但需定义版本、TTL、外部写入口和多实例失效;库存扣减是否成功必须靠数据库约束/条件更新裁决,不能用同一缓存策略。若新增跨 namespace 报表、ETL 和运维 SQL,原先“只有一个 Mapper 写”的失效前提已经改变,应重新决定是否关闭二级或迁移到有明确一致性协议的缓存。
另一个条件是日终导入从 1000 行升为千万行。SIMPLE 逐行开销高,BATCH 可以减少交互,但超大单事务会占连接、内存、锁并放大回滚成本;可按可恢复分片提交,用业务键保证重跑幂等,记录批次游标和失败行。代价是整体不再一个物理原子事务,若业务强制全有全无,应比较暂存表校验后切换与巨型事务,而非只调 batchSize。
闭卷验收
本页 15 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。