云计算百科
云计算领域专业知识百科平台

MyBatis 缓存机制详解

MyBatis 缓存机制详解

定位:MyBatis 系列第 4 篇——一级缓存与二级缓存的完整机制、脏读分析与生产实践结论 适用版本:MyBatis 3.5.x(JDK 8+)


目录

  • 缓存全景
  • 一级缓存(Local Cache)
  • 一级缓存脏读分析
  • 二级缓存(Global Cache)
  • 缓存执行顺序与装饰结构
  • 生产实践与外部缓存整合
  • 总结
  • 常见高频面试题

  • 一、缓存全景

    MyBatis 内建两级缓存,查询时依次经过:

    SELECT 查询路径:
    二级缓存(namespace 级,跨 SqlSession)
    │ 未命中

    一级缓存(SqlSession 级)
    │ 未命中

    数据库
    │ 结果回填

    一级缓存立即写入;二级缓存在事务提交后写入

    维度一级缓存二级缓存
    作用域 SqlSession 生命周期 namespace(Mapper)级
    默认状态 开启,无需配置 关闭,需显式开启
    承载者 BaseExecutor.localCache MappedStatement 绑定的 Cache + CachingExecutor
    跨会话
    关闭方式 只能缩小作用域(localCacheScope) 不配置 <cache/> 即关闭

    缓存键与值:键是 CacheKey(见 2.2),值是查询结果对象列表;底层都是 PerpetualCache(HashMap 包装),二级缓存额外叠加淘汰策略等装饰器(见第五节)。


    二、一级缓存(Local Cache)

    2.1 基本特征

    • 实现:BaseExecutor 内的 localCache 字段(PerpetualCache),查询入口 BaseExecutor.query 先查缓存。
    • 默认开启,没有配置项可以整体关闭——只能通过 localCacheScope=STATEMENT 把作用域缩小到语句级。
    • 同一次查询(相同缓存键)在同一会话内重复执行,第二次直接返回缓存结果,不再访问数据库。

    2.2 CacheKey 构成

    CacheKey = statementId(namespace.方法名)
    + RowBounds(offset / limit)
    + BoundSql 的 SQL 文本
    + 各参数值(按 parameterMappings 顺序)
    + environment id

    任一要素不同即视为不同查询。这解释了:同一方法不同参数不会互相污染缓存;而动态 SQL 拼出的不同 SQL 文本也会产生不同缓存键。

    2.3 失效时机

    以下任一情况发生后,会话的一级缓存被清空:

    时机说明
    insert / update / delete 执行后 同会话任意写语句(无论是否影响缓存中的数据)
    commit / rollback 事务边界
    close 会话关闭
    flushCache="true" 语句级强制刷新(查询语句也可设置)
    session.clearCache() 手动清空(不清理二级缓存)

    注意:写操作清空的是整个会话缓存,不是精准失效某条缓存——MyBatis 不做依赖分析(不知道哪条查询依赖了被更新的表)。

    2.4 localCacheScope

    <settings>
    <setting name="localCacheScope" value="SESSION"/> <!– 默认:会话级 –>
    <!– <setting name="localCacheScope" value="STATEMENT"/> –>
    </settings>

    STATEMENT 模式下每条语句执行完即清空一级缓存,等价于放弃会话内重复查询优化,用于规避特定脏读场景(见第三节)。

    2.5 Spring 整合下的实际表现(重要)

    SqlSessionTemplate 在无事务场景下,每次 Mapper 方法调用都创建独立 SqlSession、执行完即关闭——因此一级缓存在两次独立调用之间基本不会命中。只有在同一 @Transactional 方法内,事务同步机制让多次调用共享同一 SqlSession,一级缓存才真正生效。

    推论:很多团队「一级缓存没起作用」的困惑,根源就是无事务调用模式;这不是 Bug,是 SqlSessionTemplate 的生命周期设计。


    三、一级缓存脏读分析

    3.1 场景还原(教学示例)

    时间轴:
    t1 会话A:SELECT * FROM user WHERE id = 1 → name='张三',写入 A 的一级缓存
    t2 会话B:UPDATE user SET name='李四' WHERE id = 1;提交
    t3 会话A:SELECT * FROM user WHERE id = 1 → 命中一级缓存,返回'张三'(脏读)
    t4 会话A:任意写操作/commit/close → 缓存清空,之后查询才能读到'李四'

    3.2 窗口与本质

    • 窗口期:A 首次查询之后,到 A 会话内发生任何写操作/关闭之前。
    • 本质:缓存读取绕过了数据库,数据库隔离级别(即使 SERIALIZABLE)也管不到应用进程内的 HashMap。
    • 影响放大:长会话(如批处理任务中一个 SqlSession 处理整个批次)窗口更大;分布式多节点下各节点的会话缓存互不感知。

    3.3 缓解手段

    手段代价
    localCacheScope=STATEMENT 放弃会话内重复查询优化
    缩短 SqlSession 生命周期 Spring 无事务模式天然如此
    关键业务读后校验 / flushCache="true" 语句级精准控制,增加 DB 压力

    四、二级缓存(Global Cache)

    4.1 开启方式

    <!– Mapper XML:namespace 级开启 –>
    <mapper namespace="com.example.mapper.UserMapper">
    <cache eviction="LRU" flushInterval="60000" size="512" readOnly="false"/>

    </mapper>

    // 注解方式
    @CacheNamespace(eviction = LruCache.class, flushInterval = 60000, size = 512)
    public interface UserMapper { ... }

    前提:全局 cacheEnabled=true(默认即为 true,仅控制是否允许);readOnly=false(默认)时实体类必须实现 Serializable——缓存存取的是序列化副本。

    4.2 cache 属性详解

    属性默认值说明
    eviction LRU 淘汰策略:LRU(最近最少使用)/ FIFO / SOFT(软引用)/ WEAK(弱引用)
    flushInterval 不设置 定时全量清空间隔(毫秒);不设置则只靠写操作触发刷新
    size 512 最多缓存多少个「语句结果」条目
    readOnly false false:返回序列化副本(安全、有开销);true:返回共享引用(快,但调用方修改会污染缓存)

    4.3 写入与刷新机制

    写入时机——TransactionalCache:二级缓存被 TransactionalCache 装饰,查询结果先放入事务临时区,事务提交(或会话关闭)后才真正写入缓存——避免未提交数据被其他会话读到。

    刷新时机:该 namespace 内任意 C/U/D 语句执行后清空整个 namespace 缓存(写语句 flushCache 默认为 true)。语句级控制:

    <!– 该查询不使用二级缓存 –>
    <select id="selectRealtime" useCache="false" >

    <!– 该查询执行前强制刷新二级缓存 –>
    <select id="selectFresh" flushCache="true" >

    4.4 两类经典脏读

    脏读一:跨 namespace

    场景(教学示例):
    OrderMapper.selectOrderWithUser:JOIN user 表,结果进入 OrderMapper namespace 缓存
    UserMapper.updateName:更新 user 表 → 只刷新 UserMapper namespace 缓存
    → OrderMapper 的 JOIN 缓存仍旧有效,返回过期的用户名

    MyBatis 不追踪 SQL 依赖了哪些表,缓存失效以 namespace 为单位,跨 namespace 的表依赖必然出现失效盲区。

    脏读二:多节点

    集群部署:
    节点A、节点B 各自持有本地二级缓存(JVM 内存 HashMap)
    节点A 执行更新 → 只刷新节点A 自己的缓存
    → 流量打到节点B 时持续读到旧值,且无失效广播

    4.5 结论

    二级缓存的设计目标是「单 namespace 只读为主的数据加速」,其失效粒度(namespace)与部署形态(单机 JVM)都难以匹配现代微服务场景。生产环境一般不开启二级缓存,缓存需求交给应用层外部缓存(见第六节)。


    五、缓存执行顺序与装饰结构

    5.1 查询完整路径

    // CachingExecutor.query(简化)
    Cache cache = ms.getCache(); // 该语句所属 namespace 的二级缓存
    if (cache != null && ms.isUseCache()) {
    List<E> result = (List<E>) tcm.getObject(cache, key); // 查二级缓存
    if (result == null) {
    result = delegate.query(ms, param, rowBounds, handler, key, boundSql);
    // ↑ 委托被装饰 Executor(BaseExecutor 内部再查一级缓存)
    tcm.putObject(cache, key, result); // 暂存,提交后生效
    }
    return result;
    }
    return delegate.query(...);

    // BaseExecutor.query(简化):一级缓存
    List<E> result = (List<E>) localCache.getObject(key);
    if (result == null) {
    result = queryFromDatabase(...); // 真正执行 SQL
    localCache.putObject(key, result);
    }
    return result;

    5.2 Cache 装饰链

    MyBatis 用装饰器模式组合缓存能力,<cache/> 的典型装配结果:

    TransactionalCache(提交时才写入)
    └── SynchronizedCache(并发安全)
    └── LoggingCache(命中率日志)
    └── SerializedCache(readOnly=false 时的序列化副本)
    └── LruCache(淘汰策略)
    └── PerpetualCache(HashMap 基础实现)

    各装饰器职责:

    装饰器职责
    PerpetualCache HashMap 存取,一切缓存的地基
    LruCache / FifoCache / SoftCache / WeakCache 淘汰策略
    SerializedCache 存取时序列化/反序列化,保证返回副本
    LoggingCache 记录命中率
    SynchronizedCache synchronized 包装
    TransactionalCache 事务提交前暂存,提交后提交到下层

    六、生产实践与外部缓存整合

    6.1 实践结论

    缓存建议
    一级缓存 保留默认;长会话 + 高一致性要求的批处理场景评估 localCacheScope=STATEMENT
    二级缓存 生产不开启(不写 <cache/>);仅接受 namespace 粒度失效的单机只读场景可例外

    6.2 自定义 Cache 对接 Redis?

    技术上可以实现 org.apache.ibatis.cache.Cache 接口把存储指向 Redis:

    public class RedisCache implements Cache {
    @Override public String getId() { return namespaceId; }
    @Override public void putObject(Object key, Object value) { /* redis set */ }
    @Override public Object getObject(Object key) { /* redis get */ }
    @Override public Object removeObject(Object key) { /* redis del */ }
    @Override public void clear() { /* 按 namespace 前缀删除 */ }
    // getSize 等省略
    }

    但不推荐,原因:

  • 失效粒度仍是 namespace,跨表 JOIN 的脏读问题原样存在;
  • TransactionalCache 提交后写入仍存在「DB 已提交、缓存未写入」的一致性窗口;
  • 缓存键由 MyBatis 生成(CacheKey),无法按业务语义管理与精准失效。
  • 6.3 推荐模式:应用层缓存

    读路径:应用代码 → Spring Cache(Caffeine/Redis)→ 未命中 → MyBatis 查询 → 回填缓存
    写路径:更新数据库 → 删除对应缓存 key(Cache-Aside)

    要点:

    • 单机高频读用 Caffeine(进程内、纳秒级);跨节点共享用 Redis;
    • 失效策略首选「先更新 DB,再删缓存」,而非更新缓存(避免并发写覆盖与无效计算);
    • 缓存 key 按业务语义设计(如 user:profile:{id}),粒度可控;
    • 与 MyBatis 解耦:缓存逻辑在 Service 层,切换 ORM 框架不受影响。

    6.4 认知分层

    ORM 内建缓存解决的是「会话内 / 命名空间内」的重复读优化,不解决分布式一致性。分布式场景的一致性要靠外部缓存 + 明确的失效协议(删缓存、延迟双删、binlog 订阅等)来保证——这是缓存选型的基本分界线。


    七、总结

  • 两级结构:查询顺序为二级缓存(namespace 级)→ 一级缓存(SqlSession 级)→ 数据库;一级默认开启、二级默认关闭。
  • 一级缓存:PerpetualCache 实现,CacheKey 由 statementId + 分页 + SQL + 参数 + environment 构成;写操作/提交/关闭时整体清空;localCacheScope=STATEMENT 可缩小作用域。
  • 一级缓存的真相:Spring 无事务模式下每次调用独立 SqlSession,一级缓存基本不命中;事务内才真正生效。
  • 一级缓存脏读:同会话先查后被他事务更新,缓存绕过数据库导致读到旧值;数据库隔离级别无法防御,只能靠缩小作用域或缩短会话生命周期。
  • 二级缓存机制:TransactionalCache 保证提交后才写入;失效以 namespace 为单位(C/U/D 触发全量清空)。
  • 二级缓存两大脏读:跨 namespace 的 JOIN 依赖失效盲区;多节点缓存独立无失效广播。因此生产环境不建议开启。
  • 装饰器架构:Cache 接口经 PerpetualCache → 淘汰策略 → 序列化 → 日志 → 同步 → 事务逐层装饰,是 MyBatis 扩展性设计的范例。
  • 生产方案:应用层 Spring Cache + Caffeine/Redis,Cache-Aside 失效策略,按业务 key 管理——把分布式一致性问题放在正确的层次解决。

  • 八、常见高频面试题

    1. MyBatis 一级缓存和二级缓存的区别?

    要点:一级缓存 SqlSession 级、默认开启、PerpetualCache(HashMap)实现、写操作/提交/关闭即清空;二级缓存 namespace 级、需 <cache/> 显式开启、跨 SqlSession 生效、TransactionalCache 保证提交后写入、C/U/D 后按 namespace 全量清空。查询顺序:二级 → 一级 → DB。

    2. 一级缓存的缓存键包含哪些内容?为什么这样设计?

    要点:statementId + RowBounds + SQL 文本 + 参数值 + environment id。设计意图:同方法不同参数隔离、动态 SQL 不同结构隔离、分页不同区间隔离;任一要素不同即不同缓存条目。

    3. 一级缓存有什么脏读风险?如何规避?

    要点:会话 A 查询后,数据被他事务/会话更新并提交,A 再次查询命中缓存返回旧值——缓存绕过数据库,隔离级别防不住。规避:localCacheScope=STATEMENT、缩短会话生命周期(Spring 无事务模式天然如此)、关键语句 flushCache=“true”。

    4. 为什么生产环境不建议开启 MyBatis 二级缓存?

    要点:① 失效粒度是 namespace,多表 JOIN 的查询缓存不会因关联表被他 namespace 更新而失效(跨 namespace 脏读);② 缓存是 JVM 本地的,多节点无失效广播;③ 默认序列化副本有开销。替代方案:应用层 Caffeine/Redis + Cache-Aside。

    5. 二级缓存的写入时机?为什么要这样设计?

    要点:事务提交(或会话关闭)后由 TransactionalCache 提交写入。目的:防止未提交数据进入缓存被其他会话读到(脏数据入缓存)。

    6. Spring 整合后一级缓存还有效吗?

    要点:取决于事务。无事务时 SqlSessionTemplate 每次调用创建独立 SqlSession,会话间不共享一级缓存,基本不命中;同一 @Transactional 内共享 SqlSession,一级缓存生效,且事务内写操作会清空缓存。

    7. readOnly 属性的作用?true 和 false 的区别?

    要点:false(默认)存取序列化副本,各调用方拿到独立对象,安全但有性能开销,实体需 Serializable;true 直接返回缓存中对象引用,性能好,但调用方修改返回值会污染缓存,只适合严格只读的数据。

    8. MyBatis 缓存与 Redis 缓存如何取舍?

    要点:MyBatis 内建缓存是会话/namespace 粒度的进程内优化,解决重复读,不解决分布式一致性;Redis 是分布式共享缓存,可按业务 key 精准失效、支持集群。生产做法:关闭二级缓存,用 Spring Cache 抽象统一接入 Caffeine(单机热点)或 Redis(共享),写操作采用先更新 DB 再删缓存。

    在这里插入图片描述

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » MyBatis 缓存机制详解
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!