Spring+SpringMVC+MyBatis深入学习及搭建(八)——MyBatis查询缓存深度解析

在高并发应用中,数据库访问通常是性能瓶颈所在。频繁执行相同的SQL查询(尤其是复杂查询或关联查询)会消耗大量数据库资源和网络I/O。MyBatis提供的查询缓存机制旨在解决此问题:

  • 核心价值: 将数据库查询结果临时存储在内存中。当应用程序后续发起相同的SQL查询时(相同的statementId、相同的参数、相同的运行环境),MyBatis可以直接从缓存中返回结果,避免再次访问数据库
  • 核心收益:
    • 显著提升查询性能: 减少数据库访问次数,降低数据库压力。
    • 优化资源利用: 节省网络带宽和数据库连接资源。
    • 改善用户体验: 缩短请求响应时间。

目录#

  1. 引言:为什么需要查询缓存?
  2. MyBatis缓存概览
  3. 一级缓存(Local Cache)
    • 3.1 工作机制与范围
    • 3.2 源码解读(SqlSession级别)
    • 3.3 失效场景与触发条件
    • 3.4 实践演示与效果观察
  4. 二级缓存(Second Level Cache)
    • 4.1 工作机制与范围(跨SqlSession)
    • 4.2 启用与配置详解 (XML/Annotation)
    • 4.3 序列化与缓存策略
    • 4.4 脏读问题与脏数据处理
    • 4.5 配置实例与效果演示
  5. 一级缓存 vs 二级缓存:核心区别
  6. 缓存使用的最佳实践
  7. 常见问题与解决方案(FAQ)
  8. 结论
  9. 参考文献

1. 引言:为什么需要查询缓存?#

在高并发应用中,数据库访问通常是性能瓶颈所在。频繁执行相同的SQL查询(尤其是复杂查询或关联查询)会消耗大量数据库资源和网络I/O。MyBatis提供的查询缓存机制旨在解决此问题:

  • 核心价值: 将数据库查询结果临时存储在内存中。当应用程序后续发起相同的SQL查询时(相同的statementId、相同的参数、相同的运行环境),MyBatis可以直接从缓存中返回结果,避免再次访问数据库
  • 核心收益:
    • 显著提升查询性能: 减少数据库访问次数,降低数据库压力。
    • 优化资源利用: 节省网络带宽和数据库连接资源。
    • 改善用户体验: 缩短请求响应时间。

2. MyBatis缓存概览#

MyBatis提供两级缓存结构:

  1. 一级缓存 (Local Cache):

    • 又称SqlSession缓存
    • 默认开启,作用域是单个SqlSession
    • 生命周期与SqlSession绑定(当SqlSession关闭、close()时缓存被清空)。
    • 无法跨SqlSession共享。
  2. 二级缓存 (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的一级缓存

  1. 数据修改操作 (DML): 执行任何 insert, update, delete 语句(即使修改的不是当前缓存查询的对象)。
  2. 手动清空: 调用SqlSession.clearCache()
  3. 切换语句刷新设置:<select>标签中设置 flushCache="true" (默认为false)会在该查询执行清空一级缓存。
  4. 调用commit()/rollback() 提交或回滚事务通常会导致缓存被清空(取决于Executor实现,如SimpleExecutor会在commit/rollback时清空缓存,但BatchExecutor行为可能不同)。实践中依赖commit/rollback清空缓存是不可靠的。
  5. 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),两次获取的user1user2指向的是堆中的同一个对象。如果在两次查询之间修改了user1的属性,user2的属性也会相应改变(这可能不是期望的行为)。

4. 二级缓存(Second Level Cache)#

4.1 工作机制与范围#

  • 范围: Mapper (Namespace) 级别跨SqlSession共享(只要在同一个JVM进程内且使用同一个SqlSessionFactory创建SqlSession)。
  • 存储位置: 由配置或实现的Cache接口决定。默认是PerpetualCache,但可扩展集成其他缓存。
  • 启用方式: 需在Mapper配置文件 (xxxMapper.xml) 或Mapper接口上显式开启。
  • 工作流程 (SqlSession关闭后):
    1. 查询语句执行。
    2. 查询结果数据在SqlSession commit()close() 之后,才被序列化后存入对应的Mapper(Namespace)二级缓存。
    3. 当有新的SqlSession执行相同查询条件时,会先从二级缓存获取。
    4. 若命中,则将缓存数据反序列化成对象返回。
    5. 若未命中或缓存过期失效,则查询数据库,并将结果存入二级缓存(同样在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/rollbackDML操作(且flushCache=true)、commit(<cache>)、配置flushInterval到期、手动调用clear()
清空范围整个SqlSession缓存整个Mapper(Namespace)缓存

6. 缓存使用的最佳实践#

  1. 理解范围与生命周期: 清晰区分一二级缓存的作用域和失效规则,避免误用。
  2. 优先考虑一级缓存: 在单个业务操作(事务)内有效利用一级缓存。它是最直接、安全的缓存。
  3. 二级缓存适用场景: 针对读远高于写数据实时性要求不高(允许短暂延迟)的数据。例如:基础配置数据、历史记录、聚合统计结果。
  4. 避免过度使用二级缓存:
    • 严禁写入大对象或不支持序列化的对象。
    • 慎用在频繁更新的数据上,缓存频繁失效等于没用甚至更耗资源。
    • 关联查询陷阱: 当缓存一个关联了其他Mapper数据的对象(如User关联Orders),如果只更新了Order而没有刷新User缓存,会导致User的关联Orders数据陈旧(脏读)。MyBatis关联映射本身不是为二级缓存设计的。如果多表关联频繁更新,要么禁用相关查询的二级缓存,要么使用细粒度缓存策略(如专门缓存User和OrderService组合逻辑)。
  5. 设置合理的 flushInterval / eviction 根据数据变更频率和容忍度设置刷新和淘汰策略。对于重要且变更频次适中的数据,推荐LRU
  6. 使用 readOnly="false" 强烈建议启用,避免并发修改导致的引用混乱。
  7. 利用第三方缓存: 对于分布式应用或需要持久化、LRU更精细控制、内存共享的场合,配置MyBatis集成 EhCacheRedis。这通常通过实现MyBatis的Cache接口完成(如org.mybatis.caches.ehcache.EhcacheCache, org.mybatis.caches.redis.RedisCache)。
  8. 监控与统计: 使用Cache接口的getHitRatio()等方法监控缓存命中率。低命中率或短flushInterval导致缓存无价值时应考虑禁用。
  9. 显式清除缓存 (按需):
    • Mapper级别: sqlSession.getMapper(SomeMapper.class); sqlSession.clearCache(); (清除一级缓存) 或通过特定的服务方法调用缓存清除逻辑(如果扩展了Cache接口)。
    • MyBatis内置工具: 获取 Configuration 对象,再通过 getCache(String namespace) 拿到某个Mapper的Cache实例,调用clear()方法(需在框架层面处理)。
  10. 单元测试关注: 测试包含缓存的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"时必需)?
  • Q: 为什么我在Session1修改数据后,Session2查询到的还是旧数据?

    • A1: 确认SqlSession1修改操作执行后是否提交了事务 (commit())? 只有提交后,DML的flushCache="true"才会真正生效去清除二级缓存。事务提交前清除无效(清除操作也在事务内)。
    • A2: 检查修改操作的<update>标签是否设置了flushCache="false"
    • A3: 确认修改操作是同一个Mapper下的方法?二级缓存是Mapper(Namespace)隔离的,非本Mapper操作不会清除其他Mapper缓存。(除非配置了<cache-ref>引用,但强烈不推荐这种脆弱的机制)。
  • Q: 二级缓存导致对象被修改,其他SqlSession也受影响?

    • A: 一定是你配置了readOnly="true" 改为readOnly="false",MyBatis会返回副本。
  • Q: 分布式环境下使用二级缓存有问题?

    • A: 是的! MyBatis默认的二级缓存PerpetualCache仅存在于单个应用节点进程内。在集群环境下,一个节点更新数据库并清空本地二级缓存,其他节点的二级缓存还是旧的! 必须使用分布式缓存方案(如基于Redis实现MyBatis的Cache接口)。
  • Q: 关联查询 (一对多、多对多) 使用二级缓存风险大?

    • A: 非常大! 如上文所述,关联对象更新可能无法触发外层对象缓存的清除,极易产生脏数据。除非整个数据逻辑变更周期非常长且容忍度高,或者你设计了精确的缓存清除联动机制(非常复杂),否则不建议在涉及深度复杂关联的查询上开启二级缓存。

8. 结论#

MyBatis的查询缓存(尤其是一级缓存)是提升数据库访问性能的有效手段。深入理解其工作机制(作用域、生命周期、失效规则)、配置细节<cache>属性、useCacheflushCache)以及实践中可能遇到的脏读风险性能陷阱,是高效安全使用缓存的关键。

  • 一级缓存: 开箱即用,善用可显著提升事务内操作效率。注意其局限性(作用域小)。
  • 二级缓存: 功能强大(跨会话共享),但配置复杂、风险更高。务必谨慎评估数据场景(读多写少,允许延迟),合理配置参数readOnly="false", flushInterval, eviction),并警惕关联查询问题和分布式环境问题。在复杂场景下,考虑将缓存逻辑上移到Service层并结合Redis等专用缓存中间件通常是更优解。

合理运用缓存,能让你的SSM应用性能飞升;滥用缓存,则可能引入隐蔽的bug和性能恶化。遵循最佳实践,持续监控,才能让缓存真正成为提升系统性能的利器。


9. 参考文献#

  1. MyBatis Official Documentation - Cache: https://mybatis.org/mybatis-3/sqlmap-xml.html#cache (英文)
  2. MyBatis GitHub Source Code: https://github.com/mybatis/mybatis-3 (Executor, BaseExecutor, CachingExecutor, Cache, PerpetualCache)
  3. 《MyBatis技术内幕》 - 徐郡明 著
  4. MyBatis-Spring Integration: https://mybatis.org/spring/index.html
  5. MyBatis Redis Cache (Example Integration): https://github.com/mybatis/redis-cache