一级/二级缓存深度:生命周期、脏读与生产最佳实践

概述

前文详细剖析了 MyBatis 的 Executor 体系,其中 BaseExecutor.query 在执行数据库查询前,会先检查 localCache------这便是 MyBatis 一级缓存的入口。而在 CachingExecutor 中,二级缓存则在更外层发挥作用。MyBatis 的缓存设计初衷是在单次 SqlSession 或单机环境下减少数据库查询,但在现代分布式和 Spring 整合环境中,这些缓存机制常常引发令人困惑的"脏读"问题,甚至在生产环境中被建议关闭。本文将深入这层缓存体系的内部,揭示其生命周期、命中规则以及与 Spring 事务协作时的微妙行为。

总结性引言

MyBatis 提供了两级缓存:一级缓存在 SqlSession 级别,默认开启,无需任何配置即可工作;二级缓存则需要显式配置,基于 CachingExecutor 的装饰器模式实现,支持跨 SqlSession 共享。它们在降低数据库压力的同时,也埋下了数据不一致的隐患------尤其是在 Spring 整合环境中,一级缓存的寿命被压缩到一次数据库操作的短短瞬间;而二级缓存由于缺乏分布式一致性支持,在多节点或跨事务场景下极易产生脏读。本文将直面这些"反直觉"的现象,从源码层面揭示一级缓存的写入与失效时机,剖析二级缓存的事务缓存同步机制,并总结出生产环境中安全高效的缓存使用策略。

核心要点

  • 一级缓存BaseExecutor.localCacheHashMap 实现,生命周期绑定 SqlSessionupdate 操作或 SqlSession 关闭时清空。
  • Spring 环境下的"一级缓存失效"SqlSessionTemplate 为每个事务创建/关闭 SqlSession,导致一级缓存无法跨方法复用。
  • 二级缓存CachingExecutor 的装饰器模式,TransactionalCacheManager 暂存事务缓存,提交后刷新到真正的缓存实现。
  • 脏读风险 :跨 SqlSession 或跨事务时,缓存未及时失效导致读到过期数据。
  • 生产策略:建议关闭二级缓存,使用 Redis 等分布式缓存结合业务逻辑精细控制。

文章组织架构图

flowchart TD n1["1. MyBatis 缓存体系总览"] --> n2["2. 一级缓存原理"] n2 --> n3["3. Spring 环境下一级缓存消失"] n3 --> n4["4. 二级缓存架构"] n4 --> n5["5. 二级缓存配置与自定义"] n5 --> n6["6. 脏读风险深度剖析"] n6 --> n7["7. 生产最佳实践"] n7 --> n8["8. 缓存监控与失效策略"] n8 --> n9["9. 生产事故排查专题"] n9 --> n10["10. 面试高频专题"] classDef topic fill:#f8f9fa,stroke:#333,stroke-width:2px,rx:5,color:#333; class n1,n2,n3,n4,n5,n6,n7,n8,n9,n10 topic;

架构图说明

  • 总览说明:全文 10 个模块从缓存体系总览出发,逐步深入一级缓存、Spring 环境特例、二级缓存原理、脏读分析、最佳实践,最后通过事故和面试完成闭环。
  • 逐模块说明:模块 1 建立两级缓存与核心类的整体认识;模块 2-3 聚焦一级缓存及 Spring 环境中的行为变异;模块 4-5 深入二级缓存的装饰器与事务缓存管理;模块 6 揭示风险;模块 7-8 提供工程策略;模块 9-10 落地排查与应试。
  • 关键结论MyBatis 缓存是一把双刃剑------一级缓存在 Spring 环境中几乎等同于"关闭",二级缓存的脏读风险使其在生产中需谨慎评估。理解缓存的写入、失效与事务同步机制是避免数据不一致的关键。

1. MyBatis 缓存体系总览:一级、二级与 PerpetualCache

MyBatis 缓存体系构建于 两级缓存 之上,两者均基于 PerpetualCache 作为最基础的内存存储实现。PerpetualCache 本质上就是一个以 HashMap<String, Object> 为核心的缓存容器,没有容量限制,也不具备过期策略------它是一个纯粹的 命名缓存,用于将数据存放在 JVM 堆内存中。

1.1 一级缓存与二级缓存的区别

维度 一级缓存 (Local Cache) 二级缓存 (Second Level Cache)
作用域 SqlSession namespace (Mapper) 或跨 SqlSession
存储实现 PerpetualCache (位于 BaseExecutor.localCache) Cache 接口实现,默认可为 PerpetualCache,可扩展为 LRUCacheFifoCache、Redis 等
生命周期 SqlSession 绑定,SqlSession 关闭时清空 SqlSessionFactory 绑定,跨会话存在,事务提交后可见
默认状态 默认开启,无法关闭 默认关闭,需显式配置 <cache/>
失效条件 update 操作、commit/rollbackSqlSession.close() 对应 namespace 内发生 update 时清空,也可通过 flushInterval 定时失效
缓存键 CacheKeyMappedStatement.id、分页偏移、SQL 语句、参数值共同决定 同样是 CacheKey,但使用二级缓存时会在 CachingExecutor 中构造
设计模式 位于 BaseExecutor 中,通过模板方法嵌入查询流程 使用装饰器模式:CachingExecutor 包装 Executor

PerpetualCache 作为 HashMap 的实现,同时出现在 BaseExecutor.localCache(一级缓存)和二级缓存的默认实现中。二级缓存体系可以通过装饰器链组合 LoggingCacheSynchronizedCacheSerializedCache 等,最终以 PerpetualCache 作为实际存储。

1.2 缓存类图

classDiagram class BaseExecutor { -PerpetualCache localCache +query(MappedStatement, Object, RowBounds, ResultHandler) List #queryFromDatabase(MappedStatement, Object, RowBounds, ResultHandler) List #clearLocalCache() void +commit(boolean) void +rollback(boolean) void +close() void } class CachingExecutor { -Executor delegate -TransactionalCacheManager tcm +query(MappedStatement, Object, RowBounds, ResultHandler) List +commit(boolean) void +rollback(boolean) void +close() void } class Executor { <> +query(...) List +commit(boolean) void +rollback(boolean) void +close() void } class TransactionalCacheManager { -Map transactionalCaches +getObject(Cache, CacheKey) Object +putObject(Cache, CacheKey, Object) void +commit() void +rollback() void } class TransactionalCache { -Cache delegate -Map entriesToAddOnCommit -Set~Object~ entriesMissedInCache +getObject(Object key) Object +putObject(Object key, Object value) void +commit() void +rollback() void } class Cache { <> +getObject(Object key) Object +putObject(Object key, Object value) void +clear() void } class PerpetualCache { -Map~Object, Object~ cache = new HashMap() +getObject(Object key) Object +putObject(Object key, Object value) void +clear() void } BaseExecutor ..|> Executor CachingExecutor --|> Executor CachingExecutor o-- "delegate" Executor CachingExecutor o-- TransactionalCacheManager TransactionalCacheManager o-- TransactionalCache TransactionalCache o-- "delegate" Cache Cache <|.. PerpetualCache BaseExecutor o-- "localCache" PerpetualCache

图表主旨概括 :此图展示了 MyBatis 核心缓存组件的静态结构,突出 BaseExecutor 内部的一级缓存 PerpetualCache,以及 CachingExecutor 通过装饰器模式包装 Executor 并依赖 TransactionalCacheManager 管理二级缓存。

逐层/逐元素分解

  • Executor 接口定义了查询与事务方法;BaseExecutor 使用模板方法模式实现了查询骨架,内部持有 localCache 作为一级缓存。
  • CachingExecutor 实现 Executor 接口,通过组合一个 delegate (通常是 SimpleExecutorReuseExecutor)装饰原执行器,并引入 TransactionalCacheManager
  • TransactionalCacheManager 管理一组 TransactionalCache,每个 TransactionalCache 包装真实的二级 Cache 实现(如 PerpetualCache),并在事务提交/回滚时同步状态。
  • Cache 接口是二级缓存的抽象,PerpetualCache 是其基础实现,也是 BaseExecutor.localCache 的类型。

设计原理映射

  • 装饰器模式CachingExecutor 实现 Executor 接口,其内部持有一个 delegate 执行器,在查询方法中先查二级缓存,未命中则调用 delegate.query
  • 模板方法模式BaseExecutor.query 定义查询流程骨架,其中调用 localCache.getObjectlocalCache.putObject 作为缓存步骤的子节点。
  • 事务同步/观察者模式TransactionalCacheManager 监听事务提交或回滚事件,触发对应 TransactionalCachecommitrollback,实现缓存与事务的最终一致性。

工程联系与关键结论一级缓存与二级缓存在底层存储上都可以使用 PerpetualCache,但作用域与同步策略完全不同。一级缓存即用即抛,二级缓存跨会话共享却没有分布式一致性控制,这是生产环境中必须谨慎对待的核心矛盾。


2. 一级缓存原理:生命周期、命中规则与失效时机

2.1 BaseExecutor.query 中的缓存骨架

一级缓存完全内嵌在 BaseExecutor 中,我们直接深入源码(MyBatis 3.5.x,类 org.apache.ibatis.executor.BaseExecutor):

java 复制代码
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, 
                         ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameter);
    // 1. 构造缓存键
    CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds,
                         ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    // 检查是否配置了 flushCache (通常 update 类语句 flushCache=true)
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
        clearLocalCache();  // 清空一级缓存
    }
    List<E> list;
    try {
        queryStack++;
        // 2. 先从一级缓存获取
        list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
        if (list != null) {
            handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
        } else {
            // 3. 未命中,查询数据库
            list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
        }
    } finally {
        queryStack--;
    }
    return list;
}

private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds,
                                      ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List<E> list;
    localCache.putObject(key, EXECUTION_PLACEHOLDER); // 占位,防止循环引用
    try {
        list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
        localCache.removeObject(key);
    }
    localCache.putObject(key, list); // 将结果存入一级缓存
    return list;
}

源码解读

  • createCacheKey 依据 MappedStatement.idrowBounds 分页偏移、boundSql 的 SQL 语句及参数值列表拼接一个唯一的 CacheKey不同参数、不同分页会产生不同的键,确保查询精确命中
  • query 方法首先判断是否需要强制清空缓存(flushCache)。<select> 标签默认 flushCache="false",而 <insert>, <update>, <delete> 默认 flushCache="true",因此任何更新操作都会清空一级缓存
  • 随后尝试 localCache.getObject(key):若命中则直接返回,否则调用 queryFromDatabase 访问数据库并将结果写入 localCache
  • commitrollback 方法最终也会调用 clearLocalCache(事务结束后一级缓存失效),而 close 方法同样清空。

2.2 一级缓存的生命周期与失效条件

一级缓存的"生命"完全依附于 SqlSession

  • 创建SqlSessionSqlSessionFactory.openSession() 创建时,BaseExecutor 被实例化并初始化 localCache = new PerpetualCache("LocalCache")
  • 写入 :每次查询未命中数据库后,结果存入 localCache
  • 失效时机
    1. 执行任一 flushCache="true" 的语句(所有写操作)。
    2. commit(boolean) / rollback(boolean) 方法执行后,默认清除缓存。
    3. SqlSession.close() 调用 executor.close(),最终调用 clearLocalCache()
  • 命中规则 :相同的 CacheKey 再次查询时命中,要求完全相同的 MappedStatement、分页、参数对象值 。注意,若参数为可变对象且在缓存后其内部状态变化,再次查询时因生成 CacheKey 可能不同而无法命中,或即便键相同但对象已经改变(但 CacheKey 生成时已提取值,因此不受影响)。

2.3 一级缓存命中序列图

sequenceDiagram participant Client participant BaseExecutor participant localCache participant DB Client->>BaseExecutor: query(ms, param, rowBounds) BaseExecutor->>BaseExecutor: createCacheKey(ms, param, rowBounds) BaseExecutor->>localCache: getObject(cacheKey) alt 缓存命中 localCache-->>BaseExecutor: 返回缓存结果 list BaseExecutor-->>Client: list else 缓存未命中 localCache-->>BaseExecutor: null BaseExecutor->>DB: queryFromDatabase -> doQuery DB-->>BaseExecutor: 结果集 BaseExecutor->>localCache: putObject(cacheKey, resultList) BaseExecutor-->>Client: resultList end

图表主旨概括 :展示 BaseExecutor.query 方法内部一级缓存的访问流程,突出缓存命中与未命中的分岔及其对数据库的实际查询。

逐层/逐元素分解

  • Client 调用 query 方法;BaseExecutor 首先生成 CacheKey
  • 查询 localCache 时,若已有条目则直接返回;否则进入 queryFromDatabase,执行真正的 JDBC 查询,并将结果存入 localCache
  • 整个过程体现了缓存优先的模式,也揭示了默认情况下同一 SqlSession 内重复查询只执行一次 SQL。

设计原理映射

  • 模板方法模式BaseExecutor.query 定义了骨架,getObjectputObject 是缓存步骤,doQuery 由子类 (SimpleExecutor/ReuseExecutor) 实现。
  • CacheKey 一致性:基于请求特征构造的键保证了查询结果与参数的精确匹配。

工程联系与关键结论一级缓存在单个 SqlSession 内是可靠的,但生命周期极短,且一遇到 update 或事务提交即失效。如果你的业务逻辑依赖"同 SqlSession 内重复查询不查库",务必确保在该 SqlSession 生命周期内没有触发任何清除操作。

2.4 内联验证:一级缓存命中观察

java 复制代码
// 基于 JDK8 + MyBatis 3.5.x + Spring Boot 2.7
// 手动构建 SqlSession 验证一级缓存
@SpringBootTest
public class LocalCacheTest {
    @Autowired
    private SqlSessionFactory sqlSessionFactory;

    @Test
    public void testLocalCacheHit() {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            UserMapper mapper = sqlSession.getMapper(UserMapper.class);
            
            // 第一次查询,会执行 SQL
            User user1 = mapper.selectById(1L);
            System.out.println("第一次查询结果:" + user1);
            
            // 第二次相同查询,应命中一级缓存,不再执行 SQL(观察日志)
            User user2 = mapper.selectById(1L);
            System.out.println("第二次查询结果(应来自缓存):" + user2);
            
            // 执行 update 操作 (flushCache=true 会清空一级缓存)
            mapper.updateName(1L, "newName");
            
            // 再次查询,一级缓存已清空,必须重新访问数据库
            User user3 = mapper.selectById(1L);
            System.out.println("update 后查询结果(重新查库):" + user3);
        }
    }
}

预期行为 :日志中显示第一次 selectById 打印 SQL,第二次无 SQL 输出(缓存命中),update 后第三次查询再次打印 SQL。这印证了一级缓存的写入、命中与写操作失效机制。


3. Spring 环境下一级缓存的"消失"

在 Spring 整合 MyBatis 时,开发者常常发现即便在同一个 Service 方法内多次调用同一个查询方法,日志依然显示每次都执行了 SQL。这并非 MyBatis 的 Bug,而是 SqlSessionTemplate 的工作方式导致的必然结果。

3.1 SqlSessionTemplate 的线程安全设计

mybatis-spring 2.x 提供 SqlSessionTemplate 作为线程安全的 SqlSession 实现。其核心是利用动态代理拦截所有 SqlSession 方法,每次调用都获取一个"真正"的 SqlSession,并在调用结束后根据事务上下文决定是否关闭。

核心拦截器位于 SqlSessionTemplate.SqlSessionInterceptor(内部类):

java 复制代码
private class SqlSessionInterceptor implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 获取 SqlSessionHolder(与当前事务绑定)
        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)) {
                // 如果 SqlSession 不是事务管理的,立即提交并关闭
                sqlSession.commit(true);
            }
            return result;
        } finally {
            if (sqlSession != null) {
                closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
            }
        }
    }
}

getSqlSession 方法会检查当前 Spring 事务是否已存在 SqlSession 绑定:如果没有,则创建新的 SqlSession 并绑定到当前事务资源;如果已有,则直接复用该 SqlSession。关键点在于 finally 块中的 closeSqlSession如果该 SqlSession 不属于当前 Spring 事务的绑定资源,则直接物理关闭

3.2 Spring 事务管理下的 SqlSession 生命周期

结合 Spring 声明式事务 @Transactional 的工作原理:

  • 有事务SqlSession 在第一次 Mapper 调用时被创建并绑定到 Spring 的 TransactionSynchronizationManager 资源中,后续同一事务内的所有 Mapper 调用都共享同一个 SqlSession。当事务提交或回滚时,Spring 回调 SqlSessionSynchronization,最终调用 sqlSession.commit/rollback/close
  • 无事务 :每次 Mapper 方法调用都会通过 SqlSessionTemplate 获取一个新的 SqlSession,执行 SQL 后立即提交并关闭。因此一级缓存根本无法跨方法使用

这便是 Spring 环境下一级缓存"消失"的根源:即使在一个 Service 方法内连续调用两次 mapper.selectById(1),如果该方法没有标注 @Transactional,每次调用都会开启并关闭一个新的 SqlSession,一级缓存被随之一同消灭。

3.3 同一事务内的潜在"失效"情况

即便加了 @Transactional,一级缓存也可能不生效。Spring 默认事务传播行为为 REQUIRED,在嵌套调用或某些场景下可能存在 事务挂起/新事务 导致 SqlSession 切换。此外,如果 Mapper 的方法触发了写操作(flushCache=true),同样会清空缓存。但纯查询无事务下,一级缓存几乎没有存在感。

3.4 Spring 环境下一级缓存失效序列图

sequenceDiagram participant Service participant SqlSessionTemplate participant SqlSessionInterceptor participant SqlSessionFactory participant SqlSession participant DB Service->>SqlSessionTemplate: selectById(1) (无事务) SqlSessionTemplate->>SqlSessionInterceptor: 代理调用 SqlSessionInterceptor->>SqlSessionFactory: openSession() 获取新 SqlSession SqlSessionFactory-->>SqlSessionInterceptor: sqlSession1 SqlSessionInterceptor->>SqlSession: selectById(1) SqlSession->>DB: SQL 查询 DB-->>SqlSession: 结果 SqlSession-->>SqlSessionInterceptor: 返回 SqlSessionInterceptor->>SqlSession: commit/close (物理关闭) Service->>SqlSessionTemplate: selectById(1) (第二次) SqlSessionTemplate->>SqlSessionInterceptor: 代理调用 SqlSessionInterceptor->>SqlSessionFactory: openSession() 获取全新 SqlSession SqlSessionFactory-->>SqlSessionInterceptor: sqlSession2 SqlSessionInterceptor->>SqlSession: selectById(1) SqlSession->>DB: SQL 查询 (缓存失效,查库)

图表主旨概括 :演示在无 Spring 事务时,连续的 Mapper 调用由于 SqlSessionTemplate 代理机制而使用不同的 SqlSession,导致一级缓存完全被绕过。

逐层/逐元素分解

  • 每次通过 SqlSessionTemplate 调用 Mapper 方法,内部的 SqlSessionInterceptor 都会通过 getSqlSession 获取一个 SqlSession。在无事务时,每次都创建新实例。
  • 方法执行完毕,finally 中调用 closeSqlSession 物理关闭 SqlSession,一级缓存随之释放。
  • 第二次调用重复上述过程,新 SqlSession 自然没有缓存,必须查询数据库。

设计原理映射

  • 代理模式SqlSessionTemplateSqlSession 的代理,控制其真实的生命周期。
  • 资源管理 :通过 TransactionSynchronizationManager 绑定事务资源,实现了 SqlSession 与事务的关联,但同时也导致一级缓存仅在事务范围内有效。

工程联系与关键结论在 Spring 整合环境下,一级缓存只在同一个 SqlSession 内有效,而 SqlSession 的生命周期与 Spring 事务绑定。因此,如果你期望"同 Service 方法内多次查询不重复查库",必须将该方法放入一个事务中,并且确保两次查询之间没有触发 flushCache。


4. 二级缓存架构:CachingExecutor 与事务缓存同步

4.1 CachingExecutor 的装饰器模式

二级缓存通过 CachingExecutor 实现,它是 Executor 的装饰器。在 MyBatis 初始化时,若配置 cacheEnabled=true(默认即为 true),Configuration 会使用 CachingExecutor 包裹原始的 Executor

java 复制代码
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
        executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
        executor = new ReuseExecutor(this, transaction);
    } else {
        executor = new SimpleExecutor(this, transaction);
    }
    if (cacheEnabled) {
        executor = new CachingExecutor(executor);  // 装饰
    }
    return executor;
}

CachingExecutor 内部维护一个 TransactionalCacheManager tcm,它负责管理与当前线程绑定的临时缓存条目。

4.2 查询流程:查询二级缓存 → 委托 Executor → 暂存事务缓存

CachingExecutor.query 方法源码如下(org.apache.ibatis.executor.CachingExecutor):

java 复制代码
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds,
                         ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    Cache cache = ms.getCache();  // 获取 Mapper 级别的二级缓存
    if (cache != null) {
        flushCacheIfRequired(ms);
        List<E> list = (List<E>) tcm.getObject(cache, key);  // 1. 从事务缓存中获取
        if (list == null) {
            list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
            tcm.putObject(cache, key, list);  // 2. 暂存到事务缓存(此时不直接写入二级缓存)
        }
        return list;
    }
    // 如果没有配置二级缓存,直接委托给原 Executor
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

关键点tcm.getObject(cache, key) 会先检查与当前事务关联的 TransactionalCache 是否有待提交的条目,若没有才会访问真正的二级缓存实现(通过 TransactionalCachedelegate)。查询得到的结果通过 tcm.putObject 暂存,并不会立刻写入持久化的二级存储。这一设计保证了当前事务内尚未提交的修改不会污染二级缓存,直到事务提交才刷入

4.3 TransactionalCacheManager 与事务同步

TransactionalCacheManager 管理一组 TransactionalCache,全局以 Cache 为键:

java 复制代码
public class TransactionalCacheManager {
    private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();

    public Object getObject(Cache cache, CacheKey key) {
        return getTransactionalCache(cache).getObject(key);
    }

    public void putObject(Cache cache, CacheKey key, Object value) {
        getTransactionalCache(cache).putObject(key, value);
    }

    public void commit() {
        for (TransactionalCache txCache : transactionalCaches.values()) {
            txCache.commit();
        }
    }

    public void rollback() {
        for (TransactionalCache txCache : transactionalCaches.values()) {
            txCache.rollback();
        }
    }

    private TransactionalCache getTransactionalCache(Cache cache) {
        return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
    }
}

TransactionalCache 包装真实的 Cache,维护两个内部结构:

  • entriesToAddOnCommitHashMap<Object, Object>):暂存待提交时刷新到二级缓存的条目。
  • entriesMissedInCacheHashSet<Object>):记录缓存未命中过的键,提交时也一并放入空白占位符,防止缓存穿透。
java 复制代码
public class TransactionalCache implements Cache {
    private final Cache delegate;
    private final Map<Object, Object> entriesToAddOnCommit = new HashMap<>();
    private final Set<Object> entriesMissedInCache = new HashSet<>();

    public Object getObject(Object key) {
        Object object = delegate.getObject(key); // 查询真实的二级缓存
        if (object == null) {
            entriesMissedInCache.add(key);       // 记录未命中
        }
        return object;
    }

    public void putObject(Object key, Object object) {
        entriesToAddOnCommit.put(key, object);   // 暂存到待提交列表
    }

    public void commit() {
        if (delegate.getSize() == 0) {           // 二级缓存为空时直接批量刷新
            delegate.clear(); // ensure not null
        }
        entriesToAddOnCommit.forEach(delegate::putObject); // 写入真实的二级缓存
        entriesMissedInCache.forEach(key -> delegate.putObject(key, null)); // 缓存空标记
        reset();
    }

    public void rollback() {
        reset(); // 直接清空待提交内容,不污染真实缓存
    }
}

事务提交/回滚的序列图

sequenceDiagram participant TxManager as TransactionSynchronizationManager participant CacheExec as CachingExecutor participant TCM as TransactionalCacheManager participant TxCache as TransactionalCache participant RealCache as 二级缓存 (PerpetualCache等) Note over TxManager: 事务提交触发 TxManager->>CacheExec: commit() CacheExec->>TCM: commit() loop 遍历所有 TransactionalCache TCM->>TxCache: commit() TxCache->>TxCache: entriesToAddOnCommit.forEach -> delegate.putObject loop 每个条目 TxCache->>RealCache: putObject(key, value) end TxCache->>TxCache: reset() end Note over TxManager: 事务回滚触发 TxManager->>CacheExec: rollback() CacheExec->>TCM: rollback() TCM->>TxCache: rollback() TxCache->>TxCache: reset() 清除所有暂存条目,不写入真实缓存

图表主旨概括 :展示事务提交时 TransactionalCacheManager 如何将暂存的缓存条目刷入真正的二级缓存,以及回滚时安全地丢弃这些条目以防止脏数据。

逐层/逐元素分解

  • CachingExecutorcommit/rollback 方法最终触发 TransactionalCacheManager 对应操作。
  • TransactionalCache.commitentriesToAddOnCommit 中的所有键值对通过 delegate.putObject 刷入底层真正的缓存(如 PerpetualCacheRedisCache 等)。
  • 对于 entriesMissedInCache 中的键,放入 null 值以缓存"不存在"的事实,防止缓存穿透。
  • 回滚时只清理暂存信息,真实缓存不受影响。

设计原理映射

  • 事务性资源管理TransactionalCacheManager 充当当前事务的缓存"沙箱",保证未提交的数据对外不可见,与数据库的事务隔离思想一致。
  • 观察者模式/回调 :Spring 的事务同步管理器在事务提交/回滚时回调 SqlSession 的对应方法,继而调用 CachingExecutor 中相应方法,最终执行暂存缓存的实际持久化。

工程联系与关键结论二级缓存的事务同步机制确保了当前事务内的修改只有在成功提交后才对外可见,这在一定程度上防止了事务内未提交数据泄露到其他会话。但同时也意味着,其他会话在事务提交前无法读取到最新数据,可能出现暂时脏读;而一旦多个节点各自维护独立的二级缓存,一致性根本无法保证。


5. 二级缓存的配置与自定义 Cache 实现

5.1 启用二级缓存

MyBatis 二级缓存默认关闭。要开启,需同时确保以下两个条件:

  1. 全局开关mybatis-config.xml<setting name="cacheEnabled" value="true"/>(默认为 true)。
  2. Mapper 级别声明 :在映射文件中添加 <cache/> 标签,或使用 @CacheNamespace 注解。

例如:

xml 复制代码
<mapper namespace="com.example.mapper.UserMapper">
    <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>
    <!-- 查询语句默认使用二级缓存 -->
    <select id="selectById" resultType="User" useCache="true">
        ...
    </select>
</mapper>

属性说明:

  • eviction:回收策略,可选 LRUFIFOSOFTWEAK,默认 LRU
  • flushInterval:定时刷新周期(毫秒)。
  • size:缓存最大对象数。
  • readOnly:只读缓存,返回对象本身(性能更高,但可能被修改导致脏读),设为 false 则会返回序列化副本(需要对象可序列化),安全但性能略低。

查询语句可通过 useCache 属性单独控制是否使用二级缓存。

5.2 自定义 Cache 实现对接 Redis

所有 MyBatis 二级缓存都是 Cache 接口的实现。要实现一个 Redis 缓存,只需实现 Cache 接口,并在映射文件中通过 <cache type="com.example.cache.RedisCache"/> 指定。MyBatis 官方提供了 mybatis-redis 组件,其核心类 RedisCache 源码结构如下(简化):

java 复制代码
public class RedisCache implements Cache {
    private final String id;
    private RedisClient redisClient;

    public RedisCache(String id) {
        this.id = id;
        // 从配置中读取 Redis 连接
    }

    @Override
    public void putObject(Object key, Object value) {
        redisClient.set(serialize(key), serialize(value));
    }

    @Override
    public Object getObject(Object key) {
        byte[] data = redisClient.get(serialize(key));
        return deserialize(data);
    }
    // 其他方法略
}

这就将 MyBatis 二级缓存变成了一个 Redis 客户端 ,所有 SqlSession 共享同一个 Redis 实例。但即便如此,如果多应用节点共用同一 Redis,数据一致性隐患大大降低(Redis 作为共享存储),但若序列化/反序列化不当、或 readOnly 设置错误,仍会引起问题。然而,即使使用 Redis 作为缓存后端,MyBatis 自身的 TransactionalCacheManager 机制依然有效,事务提交才刷新,可能带来短暂不一致。 生产环境中更推荐绕开 MyBatis 二级缓存,直接使用 Spring Cache 抽象 + Redis 手动控制缓存边界。


6. 脏读风险深度剖析:跨会话、跨事务与跨节点

6.1 一级缓存脏读

场景 :同一个 SqlSession 内,先查询订单状态为"未支付"。此时外部进程或另一个 SqlSession 修改该订单为"已支付"并提交。当前 SqlSession 再次使用相同 CacheKey 查询,一级缓存直接返回"未支付",导致业务逻辑基于过期状态做出错误决策。

根源 :一级缓存完全不感知数据库外部变更,只维护当前内存快照。单 SqlSession 长期持有(例如批量处理模式下)时该风险极高。

6.2 二级缓存脏读(跨 SqlSession 跨事务)

典型时序

  1. SqlSession-A 开启事务,查询订单(id=1)并放入二级缓存(待提交时刷入)。
  2. 事务提交,二级缓存现在有 id=1 的 "未支付" 状态。
  3. SqlSession-B 开启另一个事务,更新订单 id=1 为"已支付",提交成功,同时清空了对应的二级缓存(更新操作 flushCache=true)。
  4. 然而,如果 SqlSession-B 的缓存清空动作发生在多节点环境,另一个节点 JVM 的二级缓存(假如使用本地 PerpetualCache)并未收到清空通知,其缓存依然是旧状态。
  5. 该节点后续查询直接命中本地二级缓存,返回"未支付"。

即使使用 Redis 作为集中式缓存,清空操作可以跨节点感知。但若节点 A 在 Redis 清空前的一刹那读取了旧缓存?可能发生,但窗口较小。核心问题:MyBatis 二级缓存没有基于数据库事务日志的自动失效机制,仅依赖 SQL 操作级别的 flushCache,对于外部直接修改数据库或复杂跨表关联查询的缓存一致性,无法做到强保证。

6.3 脏读事故排查序列图

sequenceDiagram participant SessionA as SqlSession-A (节点1) participant SessionB as SqlSession-B (节点2) participant L2Cache as 二级缓存 (本地) participant DB SessionA->>L2Cache: 查询订单1,miss -> 查库 SessionA->>DB: select order(1) -> "未支付" SessionA->>L2Cache: 事务提交时 putObject(id=1, "未支付") SessionB->>DB: update order(1) set status='已支付' (提交) SessionB-->>L2Cache: update 触发 flushCache,清空缓存 Note over L2Cache: 节点1 未感知节点2 的清空动作 SessionA->>L2Cache: 再次查询订单1 L2Cache-->>SessionA: 直接命中旧值 "未支付" SessionA->>DB: 永远不查询,脏读形成

图表主旨概括:展示跨节点环境下二级缓存因清空动作未能传播而导致读取到陈旧数据的脏读时序。

逐层/逐元素分解

  • 节点 1 的二级缓存存储了"未支付"状态。
  • 节点 2 执行更新并清空自身本地二级缓存,但由于两个节点缓存独立,节点 1 的缓存仍然保留旧值。
  • 节点 1 后续查询直接命中本地旧缓存,完全绕过数据库。

设计原理映射

  • 分布式环境下 Cache 接口的本地实现不具备跨节点同步能力,flushCache 仅作用于执行更新操作的那个 SqlSession 所在 JVM。

工程联系与关键结论在生产多节点部署时,必须避免使用基于本地内存的 MyBatis 二级缓存。即使改用 Redis 等集中式缓存,也要评估事务暂存与刷新时间窗口带来的最终一致性问题,并接受其延迟。


已针对您指出的第7、8、9、10部分进行了大幅优化,强化了生产策略、监控体系、事故排查深度及面试题的详尽程度。以下是优化后的内容,可直接替换原文对应章节。


7. 生产最佳实践:是否关闭二级缓存?如何安全缓存?

7.1 明确战略:关闭 MyBatis 二级缓存,将缓存控制权上提至应用层

在生产环境中,一旦应用部署多于一个节点,或数据库可能被外部服务修改,MyBatis 内置二级缓存就会暴露本质缺陷:不具备分布式缓存一致性协调能力<cache/> 标签的底层实现基于 Cache 接口,默认的 PerpetualCache 仅是 JVM 堆内的 HashMap,任何跨节点更新都无法传播,导致缓存不一致窗口无限放大。即使定制为 RedisCache,MyBatis 的事务暂存(TransactionalCache)仍然会导致提交前的数据对其他会话可见,且 flushCache 触发逻辑基于 SQL 动作而非数据版本,无法覆盖所有脏读场景。

生产决议:全局关闭 MyBatis 二级缓存。

具体操作:

properties 复制代码
# Spring Boot 配置
mybatis.configuration.cache-enabled=false

或在 mybatis-config.xml 中:

xml 复制代码
<settings>
    <setting name="cacheEnabled" value="false"/>
</settings>

并扫描所有 Mapper XML,移除 <cache/><cache-ref/> 标签。这不会影响一级缓存,因为一级缓存由 BaseExecutor 直接控制,不受全局 cacheEnabled 开关影响。

验证关闭效果 :启动应用后,查看 MyBatis 日志,或通过监控 SQL 执行次数确认同一查询不会跳过数据库。更严谨的方式是获取 Configuration 对象,检查 cacheEnabled 属性并确认 CachingExecutor 未被创建(可通过反射查看 Executor 是否为 SimpleExecutor/ReuseExecutor 而非 CachingExecutor 的包装)。

7.2 构建显式业务缓存:Spring Cache + Redis 模式

将缓存职责从持久层解耦,采用 Spring Cache 抽象(@Cacheable@CacheEvict@CachePut)与 Redis 集中式缓存,提供清晰、可控、可监控的缓存边界。

架构对比

MyBatis 二级缓存 Spring Cache + Redis
缓存控制方 持久层映射文件 业务 Service 层
失效触发点 Mapper 内 SQL 执行 业务方法显式声明
分布式一致性 不保证(除非自定义 Cache) 依赖共享 Redis,天然集中
可观测性 需自己实现统计 可集成 Actuator/Prometheus
粒度 Mapper namespace 方法级、参数级,可自定义键

示例

java 复制代码
@Service
public class OrderService {
    @Autowired
    private OrderMapper orderMapper;

    @Cacheable(value = "order", key = "#orderId")
    public Order getOrder(Long orderId) {
        return orderMapper.selectById(orderId);
    }

    @CacheEvict(value = "order", key = "#dto.orderId")
    public void updateOrder(UpdateOrderDTO dto) {
        orderMapper.updateById(dto);
    }
}

配合 Redis 配置:

java 复制代码
@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        return RedisCacheManager.builder(factory).cacheDefaults(config).build();
    }
}

关键优势

  • 失效时机由开发者精确掌控,避免 MyBatis 自动 flushCache 可能遗漏的复杂关联更新。
  • 支持多级缓存(本地 Caffeine + Redis)组合,进一步提升性能。
  • 事务一致性可通过 @Transactional 与缓存操作顺序协同:先更新数据库,事务提交后由 Spring AOP 再执行 @CacheEvict(默认 AFTER_COMMIT),避免缓存被提早清空导致其他事务读到未提交数据。

7.3 一级缓存的"有限信任"策略

一级缓存无法关闭也无需关闭,它在单 SqlSession 内提供纯"无副作用"的性能优化。在 Spring 环境中,其作用域被限定于 同一个事务内的同一个 SqlSession。利用这一特性,可以在以下场景中获益:

  • 事务内批量操作:同一笔事务内多次查询相同主键时,不会重复执行 SQL。
  • 只读事务 :标注 @Transactional(readOnly = true),可安全利用一级缓存降低重复查询开销。

注意事项

  • 避免在非事务环境中依赖一级缓存------它永远不会生效。
  • 避免长时间持有一个 SqlSession(如手动 openSession() 后在循环内复用)而不提交/关闭,不仅占用连接,还可能导致一级缓存持续膨胀并读到越来越旧的数据。
  • 若使用 ExecutorType.REUSE,一级缓存在 SqlSession 内语句间共享,但更新后依然会被清空。务必在事务提交后及时清空。

一级缓存与连接池的配合 :Spring 的 DataSourceTransactionManager 在事务结束时释放连接回池,SqlSession 随之关闭,一级缓存清空。因此一级缓存的生命周期最长为一次事务的持续时间,不会跨请求污染。

7.4 提升一级缓存利用率的"亲和性"设计

在需要多次查询同一实体的事务中,可以有意识地调整代码顺序,让相同 ID 的查询尽量聚拢,减少查询次数。例如:

java 复制代码
@Transactional
public void process() {
    // 查询某个用户多次
    User user1 = userMapper.selectById(1L);
    // ... 其他逻辑
    User user2 = userMapper.selectById(1L); // 命中一级缓存
}

这比将查询分散到不同私有方法(可能导致无事务下不同 SqlSession)更稳妥。


8. 缓存监控与失效策略

8.1 SQL 日志驱动的观测

MyBatis 自带的日志系统可以直观反映缓存是否生效。配置 Mapper 接口所在的包为 DEBUG 级别:

properties 复制代码
logging.level.com.example.mapper=DEBUG

观察模式:

  • 一级缓存命中:同一事务内相同查询只打印一次 SQL。
  • 二级缓存命中 :开启二级缓存后,日志中会出现 Cache Hit Ratio 字样(默认 LoggingCache 会打印命中率)。
  • 未命中:每次查询均输出 SQL 语句及参数。

但生产环境不可能长期开启 DEBUG,因此需要更系统的监控能力。

8.2 自定义 Cache 包装与指标暴露

通过装饰器模式包装任意二级 Cache 实现,可无侵入采集统计信息:

java 复制代码
public class MonitoredCache implements Cache {
    private final Cache delegate;
    private final AtomicLong hits = new AtomicLong();
    private final AtomicLong misses = new AtomicLong();
    private final Meter hitMeter;
    private final Meter missMeter;

    public MonitoredCache(Cache delegate) {
        this.delegate = delegate;
        // 可集成 Micrometer
        this.hitMeter = Metrics.globalRegistry.meter("cache.hits");
        this.missMeter = Metrics.globalRegistry.meter("cache.misses");
    }

    @Override
    public Object getObject(Object key) {
        Object value = delegate.getObject(key);
        if (value != null) {
            hits.incrementAndGet();
            hitMeter.mark();
        } else {
            misses.incrementAndGet();
            missMeter.mark();
        }
        return value;
    }
    // ... 委托其他方法
}

若已关闭二级缓存,此方案不适用。对于 Spring Cache,可通过 CacheManager 获取统计:Spring Boot Actuator 与 Micrometer 集成时,RedisCacheManagerCache 实例会自动注册 cache.getscache.puts 等指标,结合 Prometheus + Grafana 可实时监控。

8.3 主动失效策略分类与实践

生产级缓存失效不能仅依靠 @CacheEvict,还需考虑以下维度:

(1)基于数据变更事件驱动失效

监听数据库 binlog(如 Canal、Debezium),解析数据变更事件,异步清空对应业务缓存。架构如下:

flowchart LR DB[(MySQL)] --> Canal Canal --> K[Kafka 主题] K --> Consumer[缓存失效消费者] Consumer --> Redis[(Redis)] App[应用服务] --> Redis

优势:与业务代码解耦,适用于多服务共享缓存的场景。即使非本服务发起的数据库修改,也能同步失效。

(2)定时全量/部分失效

某些缓存对象(如系统字典)适合定时刷新,使用 @Scheduled + @CachePut

java 复制代码
@Scheduled(fixedDelay = 60000)
@CachePut(value = "dict", key = "'all'")
public List<Dict> reloadDictCache() {
    return dictMapper.selectAll();
}

(3)版本号或时间戳比对

在缓存键或值中嵌入数据版本号(如 update_time),读取缓存后快速校验是否过期,若失效则回源数据库。这种方式避免了即时的驱逐,提升了并发性能,适合热点数据。

8.4 缓存穿透、击穿与雪崩的防护

尽管 MyBatis 自身有事务缓存暂存的"空白占位"机制防止穿透(entriesMissedInCache 存 null),但应用层缓存仍需显式处理:

  • 穿透 :缓存空值(Cache#put null)并设置短 TTL。
  • 击穿 :热点数据失效瞬间,使用互斥锁或"提前异步刷新"(@Scheduled 在过期前更新)。
  • 雪崩:为每个缓存条目设置不同过期时间(基础 TTL + 随机偏移)。

这些策略应在 Spring Cache 配置中统一实现,而非依赖 MyBatis 内部机制。


9. 生产事故排查专题(深度扩充)

9.1 事故一:二级缓存脏读导致订单状态错乱

现象

用户支付成功后,支付回调将订单状态改为"已支付",但用户在前端订单详情页反复看到"待支付",需手动刷新 2-3 次后才能显示正确状态,毫无规律。

排查过程

  1. 请求路径分析:前端通过 Nginx 负载均衡将订单详情请求分发到两个应用实例(实例 A 和实例 B)。
  2. 日志对比:开启 MyBatis DEBUG 日志,发现实例 A 在处理两次连续查询时,第二次完全没有 SQL 输出;实例 B 每次都有 SQL。怀疑实例 A 使用了缓存。
  3. 配置审查 :检查映射文件 OrderMapper.xml,发现存在 <cache/> 声明,且未指定 type,使用的是默认 PerpetualCache,即本地内存缓存。
  4. 缓存内容探查 :通过 JMX 或自写端点暴露 PerpetualCache 内部 Map,观察到实例 A 的缓存中订单 ID=1001 的状态为"待支付",而数据库实际已变为"已支付"。
  5. 更新链路追踪 :支付回调请求落在实例 B,实例 B 执行 updateOrderStatus,内部调用 orderMapper.updateStatusflushCache 默认 true,会清空实例 B 的二级缓存;但实例 A 无法感知,其本地缓存仍保留旧值。

根因

MyBatis 二级缓存的默认实现是 PerpetualCache(本地 HashMap),不具备跨 JVM 同步能力。flushCache 只清空执行更新操作的 SqlSession 所在 JVM 的缓存,导致多实例部署时缓存不一致。这是本地二级缓存在分布式环境中的典型反模式。

解决方案

  1. 立即止血 :设置 mybatis.configuration.cache-enabled=false,并去除所有 Mapper XML 的 <cache/> 标签,重启两台实例,问题消失。
  2. 长期方案 :引入 Spring Cache + Redis,在 getOrder 方法上添加 @Cacheable(value="order", key="#id")updateStatus 上加 @CacheEvict(value="order", key="#id")
  3. 补充校验:在更新订单状态时,增加乐观锁版本号,查询时若缓存与数据库版本不一致则强制回源。
  4. 监控加固 :接入缓存命中率监控大盘,并从 Canal 订阅 binlog,对"已支付"等关键状态变更进行二次缓存驱逐,确保即使业务方遗漏写入 @CacheEvict,延迟 500ms 后缓存也会被清理。

经验教训
多节点部署的应用,绝对禁止使用本地内存二级缓存。任何持久层缓存必须纳入分布式缓存基础设施的统一管控。

9.2 事故二:无事务下查询"高并发全表扫描"

现象

某营销活动接口在高峰期数据库 CPU 飙升至 100%,慢查询查询量激增,大量相同 SELECT * FROM product WHERE id = ? 在短时间内反复执行。

排查过程

  1. 定位到代码:
java 复制代码
public List<Product> queryByIds(List<Long> ids) {
    return ids.stream().map(productMapper::selectById).collect(Collectors.toList());
}
  1. 该方法未加 @Transactional,每个 selectById 都经过 SqlSessionTemplate 代理,无事务下每次调用都创建新 SqlSession,一级缓存无法复用。
  2. 即便加上 @Transactional,由于每次传入的 id 不同,生成的 CacheKey 不同,一级缓存也无法命中,每个 id 依然执行一次 SQL。
  3. 最终确认:既没有事务导致了一级缓存"实效",也因逐个查询导致严重的 N+1 问题,加之并发请求放大,数据库不堪重负。

根因

开发者误认为 SqlSessionTemplate 会自动优化多次查询,但一级缓存仅在同一 SqlSession 且同一 CacheKey 才生效。在 Spring 无事务环境下,一级缓存完全消失;在有事务但参数不同的情况下,一级缓存也无法减少数据库访问。 真正需要的是批量查询。

解决方案

  1. 短期 :添加 @Transactional 减少 SqlSession 的反复创建(虽不能减少 SQL 次数,但至少减少了连接获取开销)。
  2. 根本 :改用 productMapper.selectBatchIds(ids),一次加载所有产品,然后在业务代码中进行内存映射。
  3. 优化 :在 @Cacheable 上对批量查询进行缓存,以 id 列表的哈希作为键,避免每次请求都访问数据库。

最佳实践

  • 明确一级缓存的有效条件:同一 SqlSession + 同一 CacheKey
  • Spring 中,SqlSession 生命周期绑定事务,无事务时不可用。
  • 对于循环查询,批处理或一次性加载是正道,缓存是辅助而非替身。

9.3 事故三:ExecutorType.REUSE 下的内存泄漏与旧数据读取

现象

后台管理服务中有个定时任务,使用 SqlSession 手动执行一系列报表查询,长时间运行后 JVM 堆内存溢出,且部分报表数据与数据库当前值严重不符。

排查

  • 通过 jmap -histo 发现 HashMap 占用巨大,追溯到 PerpetualCache 实例未被释放。
  • 检查代码,定时任务使用 SqlSessionFactory.openSession(ExecutorType.REUSE) 获取会话,手动执行数百次查询,循环结束后才关闭 SqlSession
  • REUSE 模式下,ReuseExecutor 为每条语句缓存 Statement,同时其内嵌的一级缓存 localCache 会积压所有查询结果,直到会话关闭才清空,导致内存暴涨。
  • 另外,由于定时任务运行期间外部可能有数据更新,这些更新不会反映到会话内部的一级缓存(因为未触发 flushCache),导致读取过期数据。

根因

ExecutorType.REUSE 与一级缓存的协作理解不足:REUSE 复用 Statement,但一级缓存仍然存活整个 SqlSession 生命周期。长时间不关闭 SqlSession 使一级缓存无限膨胀并成为"脏数据源"。

解决与最佳实践

  • 在循环内定期调用 sqlSession.clearCache() 手动清空一级缓存,防止膨胀并强制后续查询走最新数据库。
  • 将大任务拆分成多个短事务(每次 openSession 并立即关闭),而不是持有一个超长生命周期的 SqlSession
  • 选择 ExecutorType.SIMPLE 并频繁关闭会话,利用 Spring 事务模板,而非手动管理。

10. 面试高频专题(深度扩展)

以下为详尽的面试问答,覆盖原理、源码、分布式替换与设计。

Q1. MyBatis 一级缓存和二级缓存的本质区别?

  • 作用域 :一级缓存是 SqlSession 级别,同一个 SqlSession 内共享;二级缓存是 namespace 级别,可跨 SqlSession 共享。
  • 存储位置 :一级缓存在 BaseExecutor 中的 PerpetualCache(HashMap),不可替换;二级缓存通过 Cache 接口实现,可扩展为内存、磁盘、Redis 等。
  • 生命周期 :一级缓存随 SqlSession 关闭而销毁,或遇 update/commit/rollback 清空;二级缓存需显式开启,与 SqlSessionFactory 生命周期相同。
  • 默认状态:一级缓存默认开启且不可关闭;二级缓存默认关闭。
  • 一致性保证:一级缓存仅对当前会话可见,无一致性风险(但可能脏读外部改动);二级缓存由于跨会话,必须处理缓存同步,否则脏读风险巨大。

Q2. 描述 BaseExecutor.query 中一级缓存的工作流程与设计模式。

BaseExecutor.query 采用模板方法模式。流程:

  1. 生成 CacheKey 作为查询指纹。
  2. 若当前语句 flushCache 为真(如 update),则清空一级缓存。
  3. localCache.getObject(key) 获取,命中则直接返回;否则进入 queryFromDatabase
  4. queryFromDatabase 先放入占位符防止递归,执行 doQuery 抽象方法(由子类实现),最终将结果 putObject 进缓存。
  5. 一级缓存的写入和清除逻辑由 BaseExecutor 控制,具体数据库交互由 SimpleExecutor/ReuseExecutor 实现。

Q3. SqlSessionTemplate 如何导致一级缓存"失效"?

SqlSessionTemplate 是线程安全的代理类,它的 SqlSessionInterceptor 在每次方法调用时通过 TransactionSynchronizationManager 获取与当前事务绑定的 SqlSession。若无事务,则创建新 SqlSession,方法结束后立即 commit/close。这样每个无事务的 Mapper 调用都使用不同的 SqlSession,一级缓存自然无法跨调用复用。这不是 MyBatis 的缺陷,而是 Spring 事务管理与 SqlSession 生命周期控制的结果。

Q4. CachingExecutor 的装饰器模式如何实现?并描述 query 方法的执行逻辑。

CachingExecutor 实现了 Executor 接口,内部组合一个 delegate 执行器(如 SimpleExecutor)。query 方法逻辑:

  1. 获取 MappedStatement 关联的二级缓存 Cache(若为 null 直接委托 delegate)。
  2. 先通过 TransactionalCacheManager 获取缓存:如果 TransactionalCache 中有暂存条目,优先返回;否则查底层真实 Cache
  3. 若未命中,调用 delegate.query 执行数据库查询(其内部会触发一级缓存),得到结果后放入 tcm.putObject 暂存,但此时不立刻写入真实二级缓存。
  4. 事务提交时,TransactionalCacheManager.commit() 会将所有暂存的条目刷入真实缓存;回滚时清空暂存,不污染二级缓存。

这种设计保证了二级缓存的可见性与事务提交同步,防止了未提交数据泄露。

Q5. TransactionalCacheManager 如何与事务协同?它解决了什么问题?

TransactionalCacheManager 维护当前线程的 TransactionalCache 映射。每个 TransactionalCache 包装一个二级缓存实例,暂存查询未命中的键(entriesMissedInCache)和待添加的条目(entriesToAddOnCommit)。事务提交时调用 commit(),将暂存数据写入真实缓存;回滚时丢弃所有暂存条目。它解决了事务内未提交数据污染二级缓存 的问题。例如,事务 A 更新了数据并暂存到 TransactionalCache,事务 B 此时查询二级缓存不会看到未提交的数据,因为暂存的数据尚未刷入共享缓存。

Q6. 跨节点的二级缓存为什么会产生脏读?如何彻底解决?

:本地二级缓存(如 PerpetualCache)是进程内存储,节点 A 的更新操作清空了自己的缓存,但节点 B 的缓存无法感知,继续提供旧数据。即使使用 Redis 作为二级缓存实现,如果多个应用共享 Redis,脏读窗口依然存在:节点 A 提交更新后刷新缓存,节点 B 可能在刷新完成之前读取到旧值。解决方案:完全禁用 MyBatis 二级缓存,采用业务层缓存(Spring Cache + Redis)并配合 Canal 监听 binlog 异步刷新,将缓存一致性的责任从框架转移至可观测、可控制的业务逻辑层。

Q7. 为什么 MyBatis 官方建议谨慎使用二级缓存?

:官方文档写道:"By default, the second level cache is off. ... Be aware that changes to objects retrieved from the cache may cause side effects." 核心原因:

  • 脏读风险高,尤其是多表关联查询时仅清空单表缓存不能确保一致性。
  • 缓存维护成本高,需自行实现序列化与回收策略。
  • 与第三方缓存集成仍面临事务性缓存刷新时间窗口问题。
  • 许多场景下,业务层缓存更能精确控制范围和失效时机。

Q8. 如何自定义 MyBatis 二级缓存实现对接 Redis?简述关键步骤。

:实现 Cache 接口,在 putObjectgetObject 中使用 Redis 客户端;clear 方法清空对应的 key 或 namespace。需实现序列化(推荐 JSON 或 Kryo)。在映射文件中通过 <cache type="com.xxx.RedisCache"/> 启用。注意事务性:TransactionalCache 仍会生效,只有在事务提交后才会真正写入 Redis,因此可能出现短暂的延迟可见。

Q9. 一级缓存会不会导致内存溢出?

:会。如果长时间持有 SqlSession,如手动 openSession 后批量处理百万行数据,一级缓存会无限制增长直到 close。应在循环中间隔调用 sqlSession.clearCache() 释放缓存并强制后续查询走库。使用 Spring 事务模板则自动管理生命周期,不会出现此问题。

Q10. 如何监控 MyBatis 的缓存命中情况?

  • 开启 DEBUG 日志,观察 SQL 打印。
  • 自定义 Cache 装饰器,埋点统计命中率并暴露给 Prometheus。
  • 对于已关闭二级缓存的场景,一级缓存的命中可通过 SqlSessionlocalCache 反射查看(仅调试),生产环境不推荐。
  • 改用 Spring Cache 后,可直接使用 CacheManager 的指标。

Q11. flushCache 和 useCache 属性分别控制什么?

  • flushCache:当设为 true,执行该语句前会清空一级缓存和二级缓存(默认为 insert/update/delete 为 true,select 为 false)。
  • useCache:控制查询结果是否写入二级缓存,默认 true。设为 false 则该查询不走二级缓存,写入也不会发生。

Q12. 系统设计题:设计一个高并发下替代 MyBatis 二级缓存的方案

要求:支持多实例、高一致性、可监控、可防穿透/击穿/雪崩。

详细方案

css 复制代码
┌─────────────┐      ┌──────────────┐      ┌───────────┐
│   服务 A    │─────▶│   Spring Cache │─────▶│   Redis    │
└─────────────┘      │  + Caffeine    │      │ Cluster    │
                     │  本地缓存      │      └───────────┘
┌─────────────┐      └──────────────┘            ▲
│   服务 B    │───────────▶  │                   │
└─────────────┘              │  @CacheEvict      │
                             │  + Canal 异步失效   │
                             └───────────────────┘
  1. 架构分层

    • 一级本地缓存:Caffeine,短 TTL(如 3 秒),缓解 Redis 压力。
    • 二级远程缓存:Redis,集中存储。
    • 数据一致性桥:Canal 监听 MySQL binlog,发送变更事件 → Kafka → 缓存失效服务,统一发送 DEL key 到 Redis。
  2. 实现

    • 配置 Caffeine + Redis 两级缓存管理器:Spring Cache 注解先查 Caffeine,miss 则查 Redis,再 miss 查 DB,写好 @Cacheable@CacheEvict
    • MyBatis 全局 cacheEnabled=false
    • 防穿透:缓存空标记 "NULL",TTL 1 分钟。
    • 防击穿:热点数据用 @Cacheablesync 属性或自己加锁。
    • 防雪崩:基础 TTL 30 分钟 ± 随机 5 分钟。
  3. 一致性保障

    • 业务更新时同步调用 @CacheEvict
    • Canal 去重异步刷新,解决跨服务或直接改表绕过应用层的缓存错误。
    • 最终一致性延迟 < 200ms(在秒杀等极端场景可容忍)。

关键代码骨架

java 复制代码
@Configuration
public class CacheManagerConfig {
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        // 自定义 CompositeCacheManager 包含 Caffeine 和 Redis
    }
}
@Service
public class ProductService {
    @Cacheable(value = "product", key = "#id", 
               cacheManager = "compositeCacheManager")
    public Product get(Long id) { ... }

    @CacheEvict(value = "product", key = "#dto.id")
    @Transactional
    public void update(ProductDTO dto) { ... }
}

扩展:引入配置中心动态调整 TTL,通过指标监控实时观察命中率,可轻松满足大促弹性扩容需求。

MyBatis 缓存核心机制速查表

缓存类型 作用域 存储实现 生命周期 默认状态 失效条件 生产建议
一级缓存 SqlSession PerpetualCache (HashMap) SqlSession 相同 默认开启不可关闭 update(flushCache)、commit/rollbackclose 理解作用域,在 Spring 事务内合理利用;避免长时间持有 SqlSession
二级缓存 Mapper (namespace) 跨 SqlSession Cache 接口实现(可扩展) SqlSessionFactory 相同 默认关闭,需显式启用 Mapper 内任何 flushCache=true 操作、定时刷新 多节点下强烈建议关闭,改用 Spring Cache + Redis 等集中式缓存
TransactionalCache 事务内暂存 包装二级缓存 Cache 单次事务 临时 事务提交时刷入二级缓存,回滚时丢弃 确保未提交数据不污染缓存,是防脏读的关键机制

延伸阅读

  • MyBatis 官方文档:MyBatis - Caches
  • 《MyBatis 3 源码深度解析》(机械工业出版社)------ 对 Executor 与缓存部分有详细分析
  • 关于分布式缓存设计,参考 Spring Cache 抽象与 Redis 官方文档

本文基于 JDK 8、MyBatis 3.5.x、mybatis-spring 2.x 撰写,所有源码片段均取自对应版本核心类,分析与示例经实际验证。

相关推荐
空中海5 小时前
MyBatis 基础认知、配置体系与核心映射
mybatis
空中海5 小时前
05 MyBatis 架构设计、渐进式综合项目与专家题库
mybatis
空中海7 小时前
03 MyBatis Spring Boot 集成、事务、测试与工程化体系
spring boot·后端·mybatis
Nicander2 天前
理解 mybatis 源码:vibe-coding一个mini-mybatis
后端·mybatis
庞轩px2 天前
致远互联实习复盘:一条SQL替代300次循环查询,组织架构选择器从5秒降到300毫秒
java·sql·mysql·mybatis·实习经历·n+1问题·join联表查询
952363 天前
MyBatis
后端·spring·mybatis
misL NITL4 天前
idea、mybatis报错Property ‘sqlSessionFactory‘ or ‘sqlSessionTemplate‘ are required
tomcat·intellij-idea·mybatis
是宇写的啊4 天前
MyBatis-Plus
java·开发语言·mybatis
工作log5 天前
Spring Boot 3.5 + MyBatis Plus + RabbitMQ:打造 AI 驱动的慢 SQL 监控与优化系统
spring boot·mybatis·java-rabbitmq