Java-11 MyBatis 一级缓存详解:SqlSession 本地缓存、CacheKey 与失效场景
TL;DR
- 场景:在 MyBatis 持久层开发中,同一个查询方法连续执行两次,第二次可能不会再次访问数据库。
- 核心结论 :MyBatis 一级缓存是
SqlSession级别的本地缓存,默认开启。相同SqlSession内,相同 SQL、相同参数、相同分页条件的查询可能命中缓存。 - 失效条件 :执行
insert、update、delete、commit、rollback、close,或者手动调用clearCache(),都会导致当前 session 的本地缓存被清理。 - 源码结构 :一级缓存主要由
BaseExecutor持有,底层使用PerpetualCache,当前 MyBatis 3.5.x 中PerpetualCache内部使用HashMap<Object, Object>存储数据。 - 注意点 :一级缓存不是业务缓存,也不是跨请求缓存。它主要用于同一个
SqlSession内减少重复查询,并辅助处理嵌套查询、循环引用等问题。
核心关键词:MyBatis、一级缓存、SqlSession、本地缓存、CacheKey、BaseExecutor、PerpetualCache、localCacheScope、缓存失效

背景与问题
在使用 MyBatis 查询数据库时,经常会看到这样的现象:
同一个 SqlSession 中,连续执行两次相同的查询方法,控制台只打印了一次 SQL。
这不是日志丢失,也不是数据库没有执行成功,而是 MyBatis 的一级缓存生效了。
一级缓存是 MyBatis 默认启用的本地缓存。它对开发者基本透明,所以很多时候并不会被显式感知。但如果不了解它的作用范围和失效条件,就容易产生几个误判:
- 为什么第二次查询没有打印 SQL?
- 为什么执行一次
update后,第二次查询又重新访问数据库? - 一级缓存是不是全局缓存?
- 一级缓存能不能用来做业务缓存?
- 在 Spring 项目中,它和事务、Mapper、SqlSession 又是什么关系?
本文围绕这些问题,结合示例代码和源码结构,梳理 MyBatis 一级缓存的工作方式。
环境与版本
本文以 MyBatis 3.5.x 的常见行为为说明对象,示例代码基于普通 Java main 方法演示。
| 项目 | 说明 |
|---|---|
| JDK | Java 8+ 均可理解本文示例 |
| MyBatis | 以 MyBatis 3.5.x 为主 |
| 数据库 | 示例使用用户表和订单表,数据库类型不影响一级缓存原理 |
| 日志 | 需要开启 MyBatis SQL 日志,便于观察 SQL 是否真正执行 |
| 示例方式 | 通过 SqlSessionFactory 手动创建 SqlSession |
说明:MyBatis 的一级缓存属于框架内部行为,核心机制长期稳定。但源码细节、默认配置说明和版本文档仍建议以当前项目实际依赖版本为准。
如果是 Spring Boot + MyBatis 项目,SqlSession 通常由框架托管,观察方式和普通 main 方法略有不同,后文会单独说明。
一级缓存是什么
MyBatis 中有两类缓存:
| 缓存类型 | 作用范围 | 默认状态 | 主要用途 |
|---|---|---|---|
| 一级缓存 / 本地缓存 | SqlSession 级别 |
默认开启 | 减少同一 session 内重复查询 |
| 二级缓存 | Mapper namespace 级别 | 需要额外配置 | 跨 session 复用查询结果 |
本文只讨论一级缓存。
一级缓存可以简单理解为:MyBatis 在当前 SqlSession 内部维护了一个本地 Map。当执行查询时,MyBatis 会根据当前查询生成一个 CacheKey。如果这个 key 已经存在于本地缓存中,就直接返回缓存中的结果;如果不存在,就访问数据库,并把查询结果放入缓存。
这个缓存不是全局缓存,也不是 Redis、Caffeine 这类业务缓存。它只在当前 SqlSession 生命周期内有效。
一级缓存为什么是 SqlSession 级别
SqlSession 是 MyBatis 执行 SQL、获取 Mapper、管理事务边界的核心入口。
一级缓存绑定在 SqlSession 上,主要有两个原因:
第一,避免同一个会话中重复执行完全相同的查询。
例如同一个业务流程里多次查询同一个用户信息,如果 SQL 和参数完全一致,第二次可以直接复用前一次查询结果。
第二,辅助处理嵌套查询和对象引用关系。
MyBatis 在处理复杂结果映射、嵌套查询时,需要避免重复加载和循环引用问题。本地缓存不仅是性能优化点,也是框架内部执行流程的一部分。
因此,MyBatis 一级缓存默认无法完全关闭,但可以通过 localCacheScope=STATEMENT 把缓存范围缩小到单条语句执行期间。
示例准备:Mapper 与 SQL
假设项目中有一个 UserMapper,提供两个方法:
java
public interface UserMapper {
List<WzkUser> findAll();
int updateById(WzkUser user);
}
对应的查询 SQL 类似如下:
xml
<select id="findAll" resultMap="userOrderMap">
select
*,
o.id oid
from wzk_user u
left join wzk_orders o on u.id = o.uid
</select>
更新 SQL 类似如下:
xml
<update id="updateById" parameterType="com.example.WzkUser">
update wzk_user
set username = #{username},
password = #{password}
where id = #{id}
</update>
为了观察 SQL 是否执行,需要开启 MyBatis 日志。例如可以在 MyBatis 配置中使用:
xml
<settings>
<setting name="logImpl" value="STDOUT_LOGGING"/>
</settings>
如果项目使用 Logback、Log4j2 或 Spring Boot,也可以通过日志框架把 Mapper 包或 MyBatis 相关包调整到 DEBUG 级别。
实验一:同一个 SqlSession 内重复查询
下面的代码在同一个 SqlSession 中连续执行两次 findAll()。
java
public class WzkicuCache01 {
public static void main(String[] args) throws IOException {
InputStream inputStream = Resources.getResourceAsStream("sqlMapConfig.xml");
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder()
.build(inputStream);
SqlSession sqlSession = sqlSessionFactory.openSession();
try {
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
List<WzkUser> wzkUser = userMapper.findAll();
System.out.println(wzkUser);
List<WzkUser> wzkUser2 = userMapper.findAll();
System.out.println(wzkUser2);
} finally {
sqlSession.close();
}
}
}
原文中的控制台截图如下:

从现象上看,两次 findAll() 之间没有第二次 SQL 打印。
原因是:
第一次查询时,本地缓存中还没有对应的 CacheKey,MyBatis 会访问数据库,并把查询结果放入当前 SqlSession 的一级缓存。
第二次查询时,SqlSession 没有关闭,SQL、参数、分页条件等信息也没有变化,因此生成的 CacheKey 相同,MyBatis 可以直接从本地缓存中返回结果。
这里要注意一个细节:命中一级缓存的前提不是"查询了同一张表",而是"生成的 CacheKey 一致"。SQL 语句、参数、分页条件、MappedStatement id 等因素都会影响缓存 key。
实验二:查询后执行 UPDATE,缓存为什么失效
下面的示例在两次查询中间加入一次 updateById(),然后执行 commit()。
java
public class WzkicuCache02 {
public static void main(String[] args) throws IOException {
InputStream inputStream = Resources.getResourceAsStream("sqlMapConfig.xml");
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder()
.build(inputStream);
SqlSession sqlSession = sqlSessionFactory.openSession();
try {
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
// 第一次查询
List<WzkUser> wzkUser = userMapper.findAll();
System.out.println(wzkUser);
// 执行一次 UPDATE
WzkUser wzkUserUpdate = WzkUser
.builder()
.id(1)
.username("wzk-update")
.password("123-update")
.build();
userMapper.updateById(wzkUserUpdate);
// 提交事务
sqlSession.commit();
// 再次查询
List<WzkUser> wzkUser2 = userMapper.findAll();
System.out.println(wzkUser2);
} finally {
sqlSession.close();
}
}
}
原文中的控制台截图如下:

这一次可以观察到执行顺序变成了:
text
第一次查询 SQL
UPDATE SQL
第二次查询 SQL
这说明第二次查询没有复用第一次查询的缓存。
这里需要把原因说准确:不是只有 commit() 会清空缓存,update() 本身也会清空当前 SqlSession 的本地缓存。
在 MyBatis 的执行流程中,只要发生数据修改操作,本地缓存就不应该继续保留旧查询结果。否则,同一个 session 后续查询可能读到修改前的数据。
所以这个实验验证的是:
在同一个 SqlSession 中,查询之后如果执行了写操作,原来的一级缓存会失效,后续相同查询需要重新访问数据库。
一级缓存的基本工作流程
可以把一级缓存的流程简化为下面几步:

第一次查询:
- MyBatis 根据当前查询生成
CacheKey。 - 到当前
SqlSession的本地缓存中查找。 - 如果没有命中,执行数据库查询。
- 查询结果写入本地缓存。
- 返回查询结果。
第二次相同查询:
- 再次生成
CacheKey。 - 到当前
SqlSession的本地缓存中查找。 - 如果命中,直接返回缓存结果。
- 不再执行 SQL。
中间发生写操作或事务操作:
- 执行
insert、update、delete时清空本地缓存。 - 执行
commit、rollback时清空本地缓存。 - 调用
clearCache()时清空本地缓存。 SqlSession关闭后,本地缓存随 session 生命周期结束。
关键配置解释:localCacheScope
MyBatis 提供了一个和一级缓存相关的重要配置:
xml
<settings>
<setting name="localCacheScope" value="SESSION"/>
</settings>
默认值是 SESSION。
| 配置值 | 含义 |
|---|---|
SESSION |
默认值。同一个 SqlSession 内,查询结果会在整个 session 生命周期内复用。 |
STATEMENT |
本地缓存只在语句执行期间使用,不会在同一个 SqlSession 的多次调用之间共享数据。 |
如果配置为 STATEMENT,前面"同一个 session 连续查询两次只打印一次 SQL"的现象通常就不会出现。因为每条语句执行结束后,本地缓存就会被清空。
示例配置:
xml
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>
STATEMENT 并不等于完全关闭一级缓存。MyBatis 仍然会在语句执行过程中使用本地缓存,只是不会把缓存数据保留到下一次查询调用。
源码链路:DefaultSqlSession → Executor → BaseExecutor → PerpetualCache
从调用链看,一次 Mapper 查询大致会经过以下层级:
text
Mapper 接口方法
↓
DefaultSqlSession
↓
Executor
↓
BaseExecutor
↓
PerpetualCache
原文中的源码截图如下:

一级缓存的核心字段在 BaseExecutor 中:
java
protected PerpetualCache localCache;
protected PerpetualCache localOutputParameterCache;
创建 BaseExecutor 时,会初始化本地缓存:
java
this.localCache = new PerpetualCache("LocalCache");
this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
PerpetualCache 是 MyBatis 的一个缓存实现。当前 3.5.x 源码中,它内部维护的是一个 Map<Object, Object>,具体实现是 HashMap。
可以简化理解为:
java
private final Map<Object, Object> cache = new HashMap<>();
当调用 clear() 时,本质上就是清空这个 Map:
java
@Override
public void clear() {
cache.clear();
}
原文中的源码截图如下:



CacheKey 如何生成
一级缓存能否命中,关键在于 CacheKey 是否一致。
在 BaseExecutor 中,查询前会创建 CacheKey:

CacheKey 不是简单地只用 SQL 字符串作为 key。它通常会综合以下信息:
| 组成部分 | 作用 |
|---|---|
MappedStatement id |
区分不同 Mapper 方法 |
RowBounds offset |
区分页偏移 |
RowBounds limit |
区分页大小 |
BoundSql sql |
区分最终 SQL 文本 |
| 参数值 | 区分不同查询条件 |
Environment id |
区分不同环境配置 |
因此,下面这些情况都可能导致一级缓存不命中:
- Mapper 方法不同;
- SQL 文本不同;
- 参数不同;
- 分页条件不同;
- 动态 SQL 最终生成结果不同;
- 不在同一个
SqlSession中。
原文中提到"SQL 和参数作为键"是一个便于理解的简化说法。更准确的说法是:MyBatis 会基于查询上下文生成 CacheKey,SQL 和参数只是其中最重要的组成部分。
查询时如何使用一级缓存
在 BaseExecutor.query() 中,核心逻辑可以简化为:
java
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);
}
也就是:
- 先通过
CacheKey到localCache查询。 - 如果命中,直接返回。
- 如果未命中,调用
queryFromDatabase()查询数据库。 - 数据库查询结果会写入本地缓存。
原文中的源码截图如下:

一级缓存的命中条件
一级缓存命中通常需要同时满足以下条件:
| 条件 | 说明 |
|---|---|
同一个 SqlSession |
不同 session 之间一级缓存隔离 |
| 相同 Mapper 语句 | MappedStatement id 需要一致 |
| 相同 SQL | 动态 SQL 最终生成结果需要一致 |
| 相同参数 | 查询参数值需要一致 |
| 相同分页条件 | RowBounds 信息会影响 CacheKey |
| 中间没有清空缓存 | 没有执行写操作、事务操作或 clearCache() |
localCacheScope=SESSION |
如果是 STATEMENT,跨语句复用会失效 |
示例:
java
List<WzkUser> list1 = userMapper.findAll();
List<WzkUser> list2 = userMapper.findAll();
如果这两次调用发生在同一个 SqlSession 中,并且中间没有写操作或清理缓存,就可能命中一级缓存。
一级缓存的失效场景
一级缓存失效主要有以下几类。
1. 使用了不同的 SqlSession
java
SqlSession sqlSession1 = sqlSessionFactory.openSession();
SqlSession sqlSession2 = sqlSessionFactory.openSession();
sqlSession1 和 sqlSession2 各自有独立的本地缓存。
即使两次查询 SQL 完全相同,只要不在同一个 session 中,就不能共享一级缓存。
2. 执行了 insert、update、delete
java
userMapper.findAll();
userMapper.updateById(user);
userMapper.findAll();
执行写操作后,当前 session 的本地缓存会被清空。
原因是写操作可能改变数据库状态。为了避免后续查询继续读取旧缓存,MyBatis 会主动清理本地缓存。
3. 执行了 commit 或 rollback
java
userMapper.findAll();
sqlSession.commit();
userMapper.findAll();
commit() 和 rollback() 都属于事务边界操作。事务状态发生变化后,本地缓存继续保留可能造成数据一致性问题,因此会被清空。
4. 手动调用 clearCache()
java
userMapper.findAll();
sqlSession.clearCache();
userMapper.findAll();
clearCache() 用于主动清空当前 SqlSession 的本地缓存。
如果怀疑缓存影响后续查询,可以手动调用它。但正常业务代码中,不建议到处调用 clearCache() 掩盖事务边界设计问题。
5. SQL 或参数发生变化
下面两次查询不会共用同一个 CacheKey:
java
userMapper.findById(1L);
userMapper.findById(2L);
即使查询的是同一张表,只要参数不同,就不会命中同一个缓存项。
动态 SQL 也需要注意:
xml
<select id="findByCondition" resultType="WzkUser">
select * from wzk_user
<where>
<if test="username != null">
username = #{username}
</if>
</where>
</select>
如果传入条件不同,最终生成的 SQL 可能不同,CacheKey 也会不同。
6. localCacheScope 设置为 STATEMENT
如果配置为:
xml
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>
那么本地缓存不会在同一个 SqlSession 的多次查询调用之间共享。
这种配置更保守,适合不希望查询结果在 session 内被复用的场景。
验证方式
原文已经通过控制台日志截图观察到一级缓存现象。为了让验证更清晰,可以按下面方式补充验证。
验证一:同一个 SqlSession 连续查询
执行代码:
java
List<WzkUser> list1 = userMapper.findAll();
List<WzkUser> list2 = userMapper.findAll();
观察点:
- 控制台是否只打印一次
Preparing: select ... - 第二次查询是否没有再次输出 SQL
- 两次查询是否在同一个
SqlSession中
预期现象:
text
第一次查询:执行 SQL
第二次查询:不执行 SQL,命中一级缓存
不要直接伪造日志。不同日志框架、不同 MyBatis 配置下,输出格式可能不同。
验证二:查询后执行 UPDATE
执行代码:
java
List<WzkUser> list1 = userMapper.findAll();
userMapper.updateById(user);
sqlSession.commit();
List<WzkUser> list2 = userMapper.findAll();
观察点:
- 第一次查询是否打印 SQL;
updateById()是否打印 UPDATE SQL;- 第二次
findAll()是否重新打印 SELECT SQL。
预期现象:
text
第一次查询:执行 SQL
UPDATE:执行 SQL,并清空本地缓存
commit:提交事务,并清空本地缓存
第二次查询:重新执行 SQL
验证三:手动 clearCache
可以增加一个单独实验:
java
List<WzkUser> list1 = userMapper.findAll();
sqlSession.clearCache();
List<WzkUser> list2 = userMapper.findAll();
预期现象:
text
第一次查询:执行 SQL
clearCache:清空当前 SqlSession 本地缓存
第二次查询:重新执行 SQL
验证四:设置 localCacheScope=STATEMENT
修改配置:
xml
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>
然后执行:
java
List<WzkUser> list1 = userMapper.findAll();
List<WzkUser> list2 = userMapper.findAll();
预期现象:
text
第一次查询:执行 SQL
第二次查询:重新执行 SQL
这个实验可以帮助理解 SESSION 和 STATEMENT 的区别。
常见问题
1. 一级缓存是不是默认开启?
是。MyBatis 默认会使用本地缓存。
但默认开启不等于可以跨 session 使用。一级缓存只在当前 SqlSession 内有效。
2. 一级缓存能不能关闭?
严格来说,MyBatis 的本地缓存不能完全关闭,因为它还承担了处理循环引用和嵌套查询的内部职责。
如果不希望同一个 SqlSession 内跨语句复用查询结果,可以把 localCacheScope 设置为 STATEMENT。
xml
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>
3. 一级缓存和二级缓存有什么区别?
| 对比项 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用范围 | SqlSession |
Mapper namespace |
| 默认状态 | 默认开启 | 需要配置 |
| 生命周期 | 随 session 结束而结束 | 可以跨 session |
| 使用复杂度 | 低,开发者通常无感 | 更高,需要关注序列化、刷新策略、一致性 |
| 适用场景 | 同一 session 内重复查询 | 特定读多写少场景 |
一级缓存是基础机制,二级缓存是更显式的缓存能力。二者不能混为一谈。
4. Spring 项目中一级缓存还存在吗?
存在,但观察方式不同。
在 Spring + MyBatis 项目中,通常不会手动创建 SqlSession,而是通过 SqlSessionTemplate 和事务管理器托管。
如果方法运行在同一个事务中,底层可能复用同一个 session,从而出现一级缓存命中的情况。
如果没有事务边界,或者每次 Mapper 调用对应不同 session,就不一定能观察到连续查询命中一级缓存的现象。
因此,在 Spring 项目中讨论一级缓存时,要结合事务边界一起看。
5. 查询结果对象可以修改吗?
不建议直接修改 MyBatis 返回的对象或集合后,又在同一个 SqlSession 中依赖相同查询结果。
原因是当 localCacheScope=SESSION 时,MyBatis 可能返回本地缓存中的同一个对象引用。你对返回对象的修改,可能影响当前 session 后续从缓存中取出的结果。
如果确实需要修改对象,建议明确区分"查询结果对象"和"用于更新的对象",不要把本地缓存当成对象状态管理工具。
6. 一级缓存能解决性能问题吗?
不能把一级缓存当成主要性能优化手段。
一级缓存只解决同一个 SqlSession 内重复查询的问题。对于跨请求、跨线程、跨服务的重复查询,它没有作用。
真正的性能优化通常应该优先考虑:
- SQL 是否合理;
- 索引是否命中;
- 是否存在 N+1 查询;
- 是否需要分页;
- 是否需要业务缓存;
- 是否需要读写分离;
- 是否需要改造数据模型。
一级缓存只是 MyBatis 的局部优化机制。
错误速查卡
| 症状 | 可能原因 | 定位方式 | 处理方式 |
|---|---|---|---|
| 两次相同查询都执行了 SQL | 两次查询不在同一个 SqlSession 中 |
检查 session 创建位置和事务边界 | 确认是否需要在同一事务或同一 session 内执行 |
| 第二次查询没有执行 SQL | 一级缓存命中 | 查看是否同一 session、相同 SQL、相同参数 | 正常现象,不需要处理 |
| UPDATE 后第二次查询重新执行 SQL | 写操作清空了本地缓存 | 查看两次查询之间是否执行了 insert/update/delete | 正常现象,符合一致性设计 |
手动 clearCache() 后缓存失效 |
主动清空了本地缓存 | 搜索代码中的 clearCache() |
删除不必要的清理,或保留明确注释 |
设置 localCacheScope=STATEMENT 后无法复用缓存 |
缓存范围被缩小到语句级别 | 检查 MyBatis settings | 如果需要 session 级复用,改回 SESSION |
| 查询相同表但没有命中缓存 | SQL、参数、分页或 Mapper statement 不同 | 对比最终 SQL、参数和 Mapper 方法 | 确保生成的 CacheKey 一致 |
| Spring 项目中现象和 main 方法不同 | SqlSession 由 Spring 托管 | 检查事务传播和 Mapper 调用边界 | 结合事务分析,不要直接套用 main 方法结论 |
适用边界
一级缓存适合用来理解 MyBatis 的执行机制,但不适合直接当业务缓存使用。
适用场景:
- 学习 MyBatis 查询执行流程;
- 分析为什么重复查询没有打印 SQL;
- 理解
SqlSession生命周期; - 排查同一事务内查询结果复用问题;
- 阅读
BaseExecutor、CacheKey、PerpetualCache源码。
不适用场景:
- 跨请求缓存;
- 跨线程缓存;
- 跨服务缓存;
- 高并发业务缓存;
- 数据强一致性要求很高但事务边界不清晰的场景;
- 希望通过一级缓存替代 Redis、本地缓存组件或数据库优化的场景。
生产环境中,不建议为了"提高缓存命中率"而刻意延长 SqlSession 生命周期。SqlSession 应该短生命周期使用,并且不能跨线程共享。
总结
MyBatis 一级缓存是默认开启的本地缓存,作用范围是当前 SqlSession。
它的核心逻辑可以概括为:
- 查询时,MyBatis 根据当前查询上下文生成
CacheKey。 - 如果当前
SqlSession的本地缓存中存在该 key,就直接返回缓存结果。 - 如果没有命中,就查询数据库,并把结果写入本地缓存。
- 当执行写操作、事务提交、事务回滚、关闭 session 或手动清理缓存时,本地缓存会失效。
- 如果配置
localCacheScope=STATEMENT,缓存只在语句执行期间有效,不会在多次查询调用之间复用。
理解一级缓存的重点不是"记住它底层是 HashMap",而是理解它和 SqlSession、CacheKey、事务边界之间的关系。
在日常开发中,一级缓存通常不需要手动配置。但当你看到"同样的查询为什么没有再次打印 SQL"或者"为什么更新后查询重新执行 SQL"时,就需要知道:这背后就是 MyBatis 一级缓存和缓存失效机制在起作用。
作者:武子康的个人博客