Spring+SpringMVC+MyBatis深入学习及搭建(八)——MyBatis查询缓存深度解析
在高并发应用中,数据库访问通常是性能瓶颈所在。频繁执行相同的SQL查询(尤其是复杂查询或关联查询)会消耗大量数据库资源和网络I/O。MyBatis提供的查询缓存机制旨在解决此问题:
- 核心价值: 将数据库查询结果临时存储在内存中。当应用程序后续发起相同的SQL查询时(相同的
statementId、相同的参数、相同的运行环境),MyBatis可以直接从缓存中返回结果,避免再次访问数据库。 - 核心收益:
- 显著提升查询性能: 减少数据库访问次数,降低数据库压力。
- 优化资源利用: 节省网络带宽和数据库连接资源。
- 改善用户体验: 缩短请求响应时间。
目录#
- 引言:为什么需要查询缓存?
- MyBatis缓存概览
- 一级缓存(Local Cache)
- 3.1 工作机制与范围
- 3.2 源码解读(SqlSession级别)
- 3.3 失效场景与触发条件
- 3.4 实践演示与效果观察
- 二级缓存(Second Level Cache)
- 4.1 工作机制与范围(跨SqlSession)
- 4.2 启用与配置详解 (XML/Annotation)
- 4.3 序列化与缓存策略
- 4.4 脏读问题与脏数据处理
- 4.5 配置实例与效果演示
- 一级缓存 vs 二级缓存:核心区别
- 缓存使用的最佳实践
- 常见问题与解决方案(FAQ)
- 结论
- 参考文献
1. 引言:为什么需要查询缓存?#
在高并发应用中,数据库访问通常是性能瓶颈所在。频繁执行相同的SQL查询(尤其是复杂查询或关联查询)会消耗大量数据库资源和网络I/O。MyBatis提供的查询缓存机制旨在解决此问题:
- 核心价值: 将数据库查询结果临时存储在内存中。当应用程序后续发起相同的SQL查询时(相同的
statementId、相同的参数、相同的运行环境),MyBatis可以直接从缓存中返回结果,避免再次访问数据库。 - 核心收益:
- 显著提升查询性能: 减少数据库访问次数,降低数据库压力。
- 优化资源利用: 节省网络带宽和数据库连接资源。
- 改善用户体验: 缩短请求响应时间。
2. MyBatis缓存概览#
MyBatis提供两级缓存结构:
-
一级缓存 (Local Cache):
- 又称SqlSession缓存。
- 默认开启,作用域是单个SqlSession。
- 生命周期与SqlSession绑定(当SqlSession关闭、
close()时缓存被清空)。 - 无法跨SqlSession共享。
-
二级缓存 (Second Level Cache):
- 又称Mapper级别缓存。
- 默认关闭,需要显式配置开启。
- 作用域是Mapper(Namespace),可跨SqlSession共享。
- 生命周期更长,通常与Mapper配置文件的生命周期一致。
- 应用重启或显式清空时缓存失效。
- 可通过多种第三方缓存库(如EhCache, Redis)实现。
3. 一级缓存(Local Cache)#
3.1 工作机制与范围#
- 范围: 仅对同一个SqlSession有效。
- 存储位置: 存在于SqlSession的
Executor对象中的LocalCache成员变量。 - 数据结构: 底层通常是
PerpetualCache(一个简单的HashMap实现)。 - 命中条件:
- 相同的
statementId(Mapper接口全限定名 + 方法名)。 - 完全相同的SQL(包含动态SQL解析后的最终SQL)。
- 相同的参数值。
- 相同的分页参数(RowBounds)。
- 相同的运行环境(同一数据库)。
- 相同的
- 默认行为: 只要满足命中条件,同一个SqlSession内的多次查询会直接返回缓存结果,不发送新的SQL到数据库。
3.2 源码解读(关键点)#
- 执行查询的入口在
org.apache.ibatis.executor.BaseExecutor#query。 - 核心逻辑:
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { ... // 尝试从LocalCache获取 list = resultHandler == null ? (List<E>) localCache.getObject(key) : null; if (list != null) { // 缓存命中,处理存储过程输出参数等,直接返回list handleLocallyCachedOutputParameters(ms, key, parameter, boundSql); } else { // 缓存未命中,查询数据库 list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); } ... return list; } - 缓存Key的生成 (
BaseExecutor.createCacheKey) 包含了上面提到的所有命中条件因子(statementId, SQL, params, environment id等)。
3.3 失效场景与触发条件#
一级缓存并非事务结束(SqlSession关闭)才失效,以下操作都会清空当前SqlSession的一级缓存:
- 数据修改操作 (DML): 执行任何
insert,update,delete语句(即使修改的不是当前缓存查询的对象)。 - 手动清空: 调用
SqlSession.clearCache()。 - 切换语句刷新设置: 在
<select>标签中设置flushCache="true"(默认为false)会在该查询执行前清空一级缓存。 - 调用
commit()/rollback(): 提交或回滚事务通常会导致缓存被清空(取决于Executor实现,如SimpleExecutor会在commit/rollback时清空缓存,但BatchExecutor行为可能不同)。实践中依赖commit/rollback清空缓存是不可靠的。 - SqlSession关闭 (
close()): 这是最彻底的清除。
重要: 一级缓存失效是全部清除,不支持部分清除。
3.4 实践演示与效果观察#
- 场景: 在一个SqlSession中,连续执行两次相同的查询。
- Java代码片段:
try (SqlSession sqlSession = sqlSessionFactory.openSession()) { UserMapper mapper = sqlSession.getMapper(UserMapper.class); // 第一次查询,访问数据库 User user1 = mapper.selectUserById(1L); System.out.println("First Query: " + user1); // 第二次查询,相同的参数 - 一级缓存命中 User user2 = mapper.selectUserById(1L); System.out.println("Second Query: " + user2); System.out.println("user1 == user2? " + (user1 == user2)); // true (默认浅拷贝) } // SqlSession关闭,一级缓存销毁 - 观察日志: 只会看到一次SQL查询的执行日志 (
Preparing: ...和Parameters: ...),第二次查询没有SQL日志输出。 - 对象关系: 由于一级缓存返回的是同一个对象实例(
user1),两次获取的user1和user2指向的是堆中的同一个对象。如果在两次查询之间修改了user1的属性,user2的属性也会相应改变(这可能不是期望的行为)。
4. 二级缓存(Second Level Cache)#
4.1 工作机制与范围#
- 范围: Mapper (Namespace) 级别,跨SqlSession共享(只要在同一个JVM进程内且使用同一个
SqlSessionFactory创建SqlSession)。 - 存储位置: 由配置或实现的
Cache接口决定。默认是PerpetualCache,但可扩展集成其他缓存。 - 启用方式: 需在Mapper配置文件 (
xxxMapper.xml) 或Mapper接口上显式开启。 - 工作流程 (SqlSession关闭后):
- 查询语句执行。
- 查询结果数据在SqlSession
commit()或close()之后,才被序列化后存入对应的Mapper(Namespace)二级缓存。 - 当有新的SqlSession执行相同查询条件时,会先从二级缓存获取。
- 若命中,则将缓存数据反序列化成对象返回。
- 若未命中或缓存过期失效,则查询数据库,并将结果存入二级缓存(同样在
commit()/close()后)。
4.2 启用与配置详解#
1. 核心配置 (在 mybatis-config.xml 中): 确保<settings>全局开启缓存(此设置通常默认是true)。
xml <settings> <!-- 显式设置为true更清晰,实际上默认就是true --> <setting name="cacheEnabled" value="true"/> </settings>
2. Mapper XML 文件启用:
xml <mapper namespace="com.example.dao.UserMapper"> <!-- 添加cache标签启用二级缓存 --> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/> ... <select id="selectUserById" resultType="User" useCache="true"> SELECT * FROM user WHERE id = #{id} </select> <update id="updateUser" parameterType="User" flushCache="true"> UPDATE user SET ... WHERE id = #{id} </update> </mapper>
<cache>标签属性:eviction: 缓存回收策略。LRU(Least Recently Used - 默认): 最近最少使用。FIFO(First In First Out): 先进先出。SOFT(Soft References): 软引用,基于GC清除。WEAK(Weak References): 弱引用,易被GC清除。
flushInterval(ms): 缓存刷新间隔。设置为正整数时,每隔指定毫秒清空一次缓存(不管有没有操作)。不设置则永不清空(只靠增删改触发或手动清除)。size(References数量): 缓存的最大对象引用数(1024意味着缓存最多存放102个键值对,取决于策略)。readOnly:true: 缓存返回的是对象的引用(直接返回缓存对象的地址)。性能最高,但不同SqlSession获取的是同一个对象实例,修改存在风险(脏读风险高)。false(推荐): MyBatis会使用序列化机制(默认SerializedCache)克隆缓存中的对象。性能略低(序列化/反序列化开销),但不同SqlSession获取的是独立的对象副本,避免并发修改冲突。
<select>标签的useCache属性:useCache="true"(默认): 该查询结果将被放入二级缓存。useCache="false": 禁用该查询的二级缓存。
<insert>,<update>,<delete>标签的flushCache属性:flushCache="true"(默认): 执行该DML操作后,不仅清除一级缓存,还会清除整个关联Mapper(Namespace)的二级缓存。 这是保证数据一致性的关键机制!flushCache="false": 慎用! 执行DML后不刷新二级缓存,可能导致其他SqlSession读取到陈旧数据(脏读)。
3. Mapper 接口启用 (使用 @CacheNamespace 注解):
```java
@CacheNamespace(eviction = LruCache.class, flushInterval = 60000, size = 512, readOnly = true)
public interface UserMapper {
@Options(useCache = true)
@Select("SELECT * FROM user WHERE id = #{id}")
User selectUserById(Long id);
@Options(flushCache = Options.FlushCachePolicy.TRUE) // 默认就是TRUE
@Update("UPDATE user SET ... WHERE id = #{id}")
int updateUser(User user);
}
```
* `@CacheNamespace` 等同于 XML 的 `<cache>`。
* `@Options(useCache = true/false)` 等同于 `<select>` 的 `useCache`。
* `@Options(flushCache = Options.FlushCachePolicy.TRUE/FALSE)` 等同于 DML 标签的 `flushCache`。
4.3 序列化与缓存策略#
- 序列化 (
readOnly=false时):- 核心目的:保证不同SqlSession获取的是缓存数据的独立副本。
- 实现机制:MyBatis使用
SerializedCache包装器。在写入缓存前(putObject),将对象序列化为byte[];在读取缓存时(getObject),将byte[]反序列化为新的对象实例。
- 缓存策略选择 (
eviction):LRU(推荐): 适合大多数读多写少的热点数据缓存场景。FIFO: 按顺序淘汰,适合简单缓存。SOFT/WEAK: 利用JVM的软/弱引用机制,允许缓存数据在内存不足时被GC回收,适合缓存非常大的对象或对内存敏感的场景。可能导致缓存命中率波动。
4.4 脏读问题与脏数据处理#
- 核心挑战: 二级缓存跨SqlSession共享,如何保证一个SqlSession修改数据后,其他SqlSession能感知到最新的数据?
- 核心保障机制 (
flushCache="true"):- 任何
<insert>,<update>,<delete>操作执行时(且flushCache=true),MyBatis不仅清空当前SqlSession的一级缓存,更重要的是会清空整个Mapper(Namespace)对应的二级缓存。这强制后续查询重新读取数据库,获取最新数据。
- 任何
readOnly="false"的作用: 防止在读操作层面,一个SqlSession修改了缓存的引用对象(虽然在commit前未实际落库)导致另一个SqlSession读取到“脏”的引用。序列化隔离了对象的副本。flushInterval的补充作用: 可以作为一种兜底策略,防止极端情况(如DML操作配置错误flushCache="false")导致脏数据长时间存在。
4.5 配置实例与效果演示#
- 场景: 跨SqlSession查询同一条数据。
- Java代码片段:
// 第一次 SqlSession (查询、提交) try (SqlSession sqlSession1 = sqlSessionFactory.openSession()) { UserMapper mapper1 = sqlSession1.getMapper(UserMapper.class); User user1 = mapper1.selectUserById(1L); System.out.println("Session1 First Query: " + user1); sqlSession1.commit(); // 提交后,user1结果才会存入二级缓存! } // 第二次 SqlSession (应命中二级缓存) try (SqlSession sqlSession2 = sqlSessionFactory.openSession()) { UserMapper mapper2 = sqlSession2.getMapper(UserMapper.class); User user2 = mapper2.selectUserById(1L); // 命中二级缓存 System.out.println("Session2 Query: " + user2); // 测试读隔离(如果readOnly=false) user2.setName("ModifiedInSession2"); // 修改user2属性 System.out.println("user2 modified: " + user2); } // 第三次 SqlSession (再次查询) try (SqlSession sqlSession3 = sqlSessionFactory.openSession()) { UserMapper mapper3 = sqlSession3.getMapper(UserMapper.class); User user3 = mapper3.selectUserById(1L); // 依然命中之前的二级缓存 System.out.println("Session3 Query: " + user3.getName()); // 输出原始的name,不是"ModifiedInSession2"(因为Session2修改未commit) } // 修改数据 (在另一个SqlSession) try (SqlSession sqlSession4 = sqlSessionFactory.openSession()) { UserMapper mapper4 = sqlSession4.getMapper(UserMapper.class); User userToUpdate = new User(1L, "NewName"); int count = mapper4.updateUser(userToUpdate); // 执行UPDATE flushCache="true"会清空UserMapper二级缓存 sqlSession4.commit(); // 提交修改 } // 第五次 SqlSession (修改后查询) try (SqlSession sqlSession5 = sqlSessionFactory.openSession()) { UserMapper mapper5 = sqlSession5.getMapper(UserMapper.class); User user5 = mapper5.selectUserById(1L); // 二级缓存已清空,查询数据库获取最新数据 System.out.println("After Update: " + user5.getName()); // 输出 "NewName" } - 观察日志:
- Session1: 有一次查询SQL。
- Session2: 没有查询SQL(命中二级缓存)。
- Session3: 没有查询SQL(命中二级缓存)。
- Session4: 有一次UPDATE SQL。
- Session5: 有一次查询SQL(修改后缓存失效)。
5. 一级缓存 vs 二级缓存:核心区别#
| 特性 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | SqlSession (会话) | Mapper (Namespace) |
| 生命周期 | SqlSession创建到关闭 (短生命周期) | 应用运行期间 (长生命周期) |
| 共享性 | 不可跨SqlSession共享 | 可跨SqlSession共享 |
| 启用方式 | 默认开启 | 需显式配置开启 |
| 缓存存储位置 | BaseExecutor.LocalCache (JVM 堆内存) | Cache实例 (可通过第三方缓存扩展到进程外) |
| 数据序列化 | 默认不涉及 | readOnly=false时需要序列化 |
| 清空触发时机 | DML操作、clearCache()、特定commit/rollback | DML操作(且flushCache=true)、commit(<cache>)、配置flushInterval到期、手动调用clear() |
| 清空范围 | 整个SqlSession缓存 | 整个Mapper(Namespace)缓存 |
6. 缓存使用的最佳实践#
- 理解范围与生命周期: 清晰区分一二级缓存的作用域和失效规则,避免误用。
- 优先考虑一级缓存: 在单个业务操作(事务)内有效利用一级缓存。它是最直接、安全的缓存。
- 二级缓存适用场景: 针对读远高于写、数据实时性要求不高(允许短暂延迟)的数据。例如:基础配置数据、历史记录、聚合统计结果。
- 避免过度使用二级缓存:
- 严禁写入大对象或不支持序列化的对象。
- 慎用在频繁更新的数据上,缓存频繁失效等于没用甚至更耗资源。
- 关联查询陷阱: 当缓存一个关联了其他Mapper数据的对象(如
User关联Orders),如果只更新了Order而没有刷新User缓存,会导致User的关联Orders数据陈旧(脏读)。MyBatis关联映射本身不是为二级缓存设计的。如果多表关联频繁更新,要么禁用相关查询的二级缓存,要么使用细粒度缓存策略(如专门缓存User和OrderService组合逻辑)。
- 设置合理的
flushInterval/eviction: 根据数据变更频率和容忍度设置刷新和淘汰策略。对于重要且变更频次适中的数据,推荐LRU。 - 使用
readOnly="false": 强烈建议启用,避免并发修改导致的引用混乱。 - 利用第三方缓存: 对于分布式应用或需要持久化、LRU更精细控制、内存共享的场合,配置MyBatis集成 EhCache 或 Redis。这通常通过实现MyBatis的
Cache接口完成(如org.mybatis.caches.ehcache.EhcacheCache,org.mybatis.caches.redis.RedisCache)。 - 监控与统计: 使用
Cache接口的getHitRatio()等方法监控缓存命中率。低命中率或短flushInterval导致缓存无价值时应考虑禁用。 - 显式清除缓存 (按需):
- Mapper级别:
sqlSession.getMapper(SomeMapper.class); sqlSession.clearCache();(清除一级缓存) 或通过特定的服务方法调用缓存清除逻辑(如果扩展了Cache接口)。 - MyBatis内置工具: 获取
Configuration对象,再通过getCache(String namespace)拿到某个Mapper的Cache实例,调用clear()方法(需在框架层面处理)。
- Mapper级别:
- 单元测试关注: 测试包含缓存的DAO方法时,需模拟SqlSession的开启关闭,并检查增删改操作是否有效清除了预期缓存。
7. 常见问题与解决方案(FAQ)#
-
Q: 为什么我配置了二级缓存,但第二次SqlSession查询还是执行了SQL?
- A1: 检查
SqlSession1是否提交了 (commit()) 或关闭了 (close())?没提交/关闭,数据不会存入二级缓存。 - A2: 检查
<select>标签是否设置了useCache="false"? - A3: 确认配置
<cache>或@CacheNamespace在正确的Mapper上? - A4: 检查DML操作(即使是其他SqlSession的)是否触发
flushCache="true"清空了二级缓存? - A5:
flushInterval设置过短导致被定期清空? - A6: 实体类是否未实现
Serializable(readOnly="false"时必需)?
- A1: 检查
-
Q: 为什么我在Session1修改数据后,Session2查询到的还是旧数据?
- A1: 确认
SqlSession1修改操作执行后是否提交了事务 (commit())? 只有提交后,DML的flushCache="true"才会真正生效去清除二级缓存。事务提交前清除无效(清除操作也在事务内)。 - A2: 检查修改操作的
<update>标签是否设置了flushCache="false"? - A3: 确认修改操作是同一个Mapper下的方法?二级缓存是Mapper(Namespace)隔离的,非本Mapper操作不会清除其他Mapper缓存。(除非配置了
<cache-ref>引用,但强烈不推荐这种脆弱的机制)。
- A1: 确认
-
Q: 二级缓存导致对象被修改,其他SqlSession也受影响?
- A: 一定是你配置了
readOnly="true"! 改为readOnly="false",MyBatis会返回副本。
- A: 一定是你配置了
-
Q: 分布式环境下使用二级缓存有问题?
- A: 是的! MyBatis默认的二级缓存
PerpetualCache仅存在于单个应用节点进程内。在集群环境下,一个节点更新数据库并清空本地二级缓存,其他节点的二级缓存还是旧的! 必须使用分布式缓存方案(如基于Redis实现MyBatis的Cache接口)。
- A: 是的! MyBatis默认的二级缓存
-
Q: 关联查询 (一对多、多对多) 使用二级缓存风险大?
- A: 非常大! 如上文所述,关联对象更新可能无法触发外层对象缓存的清除,极易产生脏数据。除非整个数据逻辑变更周期非常长且容忍度高,或者你设计了精确的缓存清除联动机制(非常复杂),否则不建议在涉及深度复杂关联的查询上开启二级缓存。
8. 结论#
MyBatis的查询缓存(尤其是一级缓存)是提升数据库访问性能的有效手段。深入理解其工作机制(作用域、生命周期、失效规则)、配置细节(<cache>属性、useCache、flushCache)以及实践中可能遇到的脏读风险和性能陷阱,是高效安全使用缓存的关键。
- 一级缓存: 开箱即用,善用可显著提升事务内操作效率。注意其局限性(作用域小)。
- 二级缓存: 功能强大(跨会话共享),但配置复杂、风险更高。务必谨慎评估数据场景(读多写少,允许延迟),合理配置参数(
readOnly="false",flushInterval,eviction),并警惕关联查询问题和分布式环境问题。在复杂场景下,考虑将缓存逻辑上移到Service层并结合Redis等专用缓存中间件通常是更优解。
合理运用缓存,能让你的SSM应用性能飞升;滥用缓存,则可能引入隐蔽的bug和性能恶化。遵循最佳实践,持续监控,才能让缓存真正成为提升系统性能的利器。
9. 参考文献#
- MyBatis Official Documentation - Cache: https://mybatis.org/mybatis-3/sqlmap-xml.html#cache (英文)
- MyBatis GitHub Source Code: https://github.com/mybatis/mybatis-3 (
Executor,BaseExecutor,CachingExecutor,Cache,PerpetualCache) - 《MyBatis技术内幕》 - 徐郡明 著
- MyBatis-Spring Integration: https://mybatis.org/spring/index.html
- MyBatis Redis Cache (Example Integration): https://github.com/mybatis/redis-cache