ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

面经手册·第39篇:MyBatis 缓存机制全解析——从一级缓存、二级缓存到 Redis 整合的源码级拆解

面经手册·第39篇:MyBatis 缓存机制全解析——从一级缓存、二级缓存到 Redis 整合的源码级拆解 面经手册·第39篇MyBatis 缓存机制全解析——从一级缓存、二级缓存到 Redis 整合的源码级拆解【免费下载链接】CodeGuide:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总旨在为大家提供一个清晰详细的学习教程侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助请给予支持(关注、点赞、分享)项目地址: https://gitcode.com/gh_mirrors/code/CodeGuide本文是 CodeGuide 仓库面经手册系列的第 39 篇聚焦 MyBatis 面试中几乎必问的缓存考点。以 原文档 为主体骨架结合仓库内手写 MyBatis专栏第18章一级缓存、第19章二级缓存从零实现缓存的过程讲透缓存原理与源码调用链。读完你不仅能回答一级缓存何时失效、二级缓存为何默认关闭、跨 namespace 缓存如何保持一致还能直接落地一套 MyBatis 二级缓存 Redis 的分布式缓存方案。一、前言缓存为什么是高频考点MyBatis 的缓存机制是面试中知识点多且细的典型代表。很多候选人能脱口而出一级缓存默认开、二级缓存默认关但被追问下面这几个问题就答不上来了同一个 SqlSession 内什么操作会清空一级缓存一级缓存返回的对象为什么是同一个实例二级缓存为什么不是二级缓存为什么默认关闭多表关联查询缓存数据不一致怎么办与 Redis 整合怎么做和 MyBatis 自带的二级缓存有什么区别本篇文章沿着原理 → 验证 → 源码 → 实战的顺序把 MyBatis 缓存机制彻底讲透。二、面试题谢飞机的翻车现场谢飞机小记面试继续。面试官MyBatis 有一级缓存和二级缓存能说说区别吗谢飞机一级缓存 SqlSession 级别默认开启二级缓存 Mapper 级别需要手动开启。面试官一级缓存什么时候会失效谢飞机SqlSession 关闭面试官还有呢在同一个 SqlSession 中什么操作会清空一级缓存谢飞机增删改面试官对。那二级缓存为什么默认关闭谢飞机因为……数据一致性问题面试官具体说说什么问题谢飞机多表关联查询时缓存数据可能不一致面试官那和 Redis 整合怎么做和 MyBatis 自带的二级缓存有什么区别谢飞机这个我真没做过……面试官好吧你回去研究下。下一个这些问题正是本文要逐一攻克的。三、一级缓存Local Cache1. 基本概念一级缓存是SqlSession 级别的缓存默认开启且不可真正关闭只能通过localCacheScopeSTATEMENT把作用范围缩小到单条语句。它的生命周期与 SqlSession 完全一致SqlSession1 SqlSession2 ┌──────────┐ ┌──────────┐ │ 一级缓存 │ │ 一级缓存 │ │ (独立) │ │ (独立) │ └──────────┘ └──────────┘ 互不影响 互不影响两个 SqlSession 之间的一级缓存互相隔离、互不影响。这也是它线程安全风险低的原因——缓存不跨会话共享。2. 缓存验证命中后不再查数据库用最朴素的编程式 API 就能验证一级缓存SqlSession sqlSession sqlSessionFactory.openSession(); UserMapper mapper sqlSession.getMapper(UserMapper.class); // 第一次查询查数据库 User user1 mapper.findById(1); // 第二次查询命中一级缓存不查数据库 User user2 mapper.findById(1); System.out.println(user1 user2); // true同一个对象关键在于user1 user2为true一级缓存存储的是对象引用同一个 SqlSession 内相同查询命中的是同一个 Java 对象实例。佐证在手写 MyBatis专栏的 第18章一级缓存 中明确给出了这一缓存的设计动机——在数据库的一次会话中有时候我们可能需要反复执行完全相同的查询语句。如果不采取优化手段每一次查询都会查询一次数据库而在极短的会话周期内反复查询出来的结果几乎完全相同。相比从内存获取相同数据反复查询数据库的代价很大调用量一大就是严重的资源浪费因此需要在会话周期内引入一级缓存并在会话结束 commit/close/clear 时清空缓存。3. 缓存 Key 的构成什么决定一次查询是否命中一级缓存用CacheKey作为 Key。从org.apache.ibatis.executor.BaseExecutor.createCacheKey()可以看到它的组成CacheKey cacheKey new CacheKey(); cacheKey.update(ms.getId()); // MappedStatement ID cacheKey.update(rowBounds.getOffset()); // 分页偏移 cacheKey.update(rowBounds.getLimit()); // 分页限制 cacheKey.update(boundSql.getSql()); // SQL 语句 // 遍历参数值 for (ParameterMapping pm : boundSql.getParameterMappings()) { cacheKey.update(value); } cacheKey.update(environment.getId()); // 环境 IDCacheKey 由以下因素决定Statement ID 分页参数 SQL 参数值 环境 ID。任何一个不同CacheKey 就不同缓存就不命中。4. 一级缓存失效场景场景是否失效说明SqlSession 关闭/新建✅ 失效缓存随 SqlSession 生死执行 INSERT/UPDATE/DELETE✅ 失效清空该 SqlSession 的所有缓存手动 clearCache()✅ 失效sqlSession.clearCache()同一查询但参数不同✅ 不命中CacheKey 不同同一查询但 RowBounds 不同✅ 不命中CacheKey 不同flushCachetrue✅ 失效Statement 级别配置注意区分失效与不命中参数不同、分页不同属于 CacheKey 不同导致的不命中而增删改、clearCache、SqlSession 关闭属于真正的缓存清空。5. 一级缓存作用范围localCacheScope!-- mybatis-config.xml -- settings !-- SESSION缓存作用整个 SqlSession默认 -- !-- STATEMENT缓存仅作用当前 Statement执行完即清 -- setting namelocalCacheScope valueSESSION/ /settingsSESSION默认缓存作用于整个 SqlSession 生命周期STATEMENT缓存仅作用于当前这条 Statement执行完即清空效果上等同于关闭一级缓存。6. 源码追踪BaseExecutor 的 query/update一级缓存在执行器BaseExecutor中实现核心字段就是protected PerpetualCache localCache// org.apache.ibatis.executor.BaseExecutor public abstract class BaseExecutor implements Executor { protected PerpetualCache localCache; // ← 一级缓存 Override public E ListE query(MappedStatement ms, Object parameter, ...) { BoundSql boundSql ms.getBoundSql(parameter); CacheKey key createCacheKey(ms, parameter, rowBounds, boundSql); return query(ms, parameter, rowBounds, resultHandler, key, boundSql); } Override public E ListE query(..., CacheKey key, BoundSql boundSql) { // 1. 先查一级缓存 ListE list localCache.getObject(key); if (list ! null) { // 缓存命中 return list; } // 2. 缓存未命中查数据库 list queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); return list; } private E ListE queryFromDatabase(...) { localCache.putObject(key, EXECUTION_PLACEHOLDER); // 占位 try { list doQuery(ms, parameter, rowBounds, resultHandler, boundSql); } finally { localCache.removeObject(key); } localCache.putObject(key, list); // 写入缓存 return list; } Override public int update(MappedStatement ms, Object parameter) { clearLocalCache(); // ← 增删改操作清空一级缓存 return doUpdate(ms, parameter); } }值得注意的细节占位符机制查询数据库前先放入EXECUTION_PLACEHOLDER占位避免嵌套查询如延迟加载在同一查询执行过程中递归查询自身时造成缓存错乱执行完再从缓存中移除占位、写入真实结果。模板方法模式doQuery/doUpdate是抽象方法由SimpleExecutor默认、ReuseExecutor复用预编译 Statement、BatchExecutor批量执行各自实现——这也印证了 面经手册·第34篇 中模板方法模式定义执行器骨架的说法。四、二级缓存Second Level Cache1. 基本概念二级缓存是Mappernamespace级别的缓存多个 SqlSession 共享需要手动开启SqlSession1 ──┐ ├──→ 二级缓存共享──→ 数据库 SqlSession2 ──┘佐证仓库手写 MyBatis专栏的 第19章二级缓存 给出的设计目标是——以一个 Mapper 为生命周期在这个 Mapper 内的同一个操作无论发起几次会话都可以使用缓存来处理数据。之所以叫二级缓存是因为它是在一级缓存会话层之上添加的额外缓存操作当会话发生 close、commit 操作时把数据刷新到二级缓存中保存直到执行器发生 update 操作时清空缓存。2. 开启步骤三步缺一不可第一步全局开关默认 true一般不需要改settings setting namecacheEnabled valuetrue/ /settings第二步Mapper XML 中声明cache/必须!-- UserMapper.xml -- mapper namespacecom.example.mapper.UserMapper !-- 简单声明 -- cache/ !-- 或带参数 -- cache evictionLRU flushInterval60000 size1024 readOnlytrue/ /mapper第三步实体类实现 Serializablepublic class User implements Serializable { private static final long serialVersionUID 1L; // ... }序列化要求是二级缓存与一级缓存的本质差异之一二级缓存存的是对象的序列化副本不是对象引用。3. 缓存参数参数说明默认值eviction清理策略LRUflushInterval刷新间隔毫秒无不刷新size最大缓存对象数1024readOnly只读false4. 二级缓存执行流程commit 时才真正写入查询请求 ↓ 1. 先查二级缓存CachingExecutor ↓ 命中 → 直接返回 2. 再查一级缓存BaseExecutor ↓ 命中 → 返回 写入二级缓存 3. 查数据库 ↓ 4. 结果写入一级缓存 5. SqlSession close/commit 时 → 一级缓存数据写入二级缓存关键二级缓存的数据是在 SqlSession 关闭或提交时才写入的不是查询时立即写入。这一点正是TransactionalCache事务缓存的设计目的——避免脏读和事务回滚后残留脏数据。5. 源码追踪CachingExecutor 装饰器二级缓存的实现方式是在Executor外层再包一层装饰器CachingExecutor对应 面经手册·第34篇 中的装饰器模式——CachingExecutor。装饰链为SimpleExecutor → CachingExecutor → 插件代理。// org.apache.ibatis.executor.CachingExecutor public class CachingExecutor implements Executor { private final Executor delegate; private final TransactionalCacheManager tcm; Override public E ListE query(MappedStatement ms, Object parameter, ...) { Cache cache ms.getCache(); // ← 二级缓存 if (cache ! null) { flushCacheIfRequired(ms); if (ms.isUseCache() resultHandler null) { CacheKey key createCacheKey(ms, parameter, rowBounds, boundSql); // 查二级缓存 ListE list tcm.getObject(cache, key); if (list null) { // 未命中交给 delegateBaseExecutor查一级缓存 数据库 list delegate.query(ms, parameter, rowBounds, resultHandler, key, boundSql); // 放入待提交缓存 tcm.putObject(cache, key, list); } return list; } } return delegate.query(ms, parameter, rowBounds, resultHandler, key, boundSql); } }注意两个分支条件ms.isUseCache()要求该 Statement 开启了 useCache默认 trueresultHandler null要求没有自定义结果处理器否则跳过二级缓存。6. TransactionalCache事务缓存机制二级缓存的值不是直接写入真实缓存的而是先放进TransactionalCache的待提交区直到 commit 才批量写入真实缓存// 二级缓存使用事务缓存commit 时才真正写入 public class TransactionalCache implements Cache { private final Cache delegate; // 真正的缓存 private final MapObject, Object entriesToAddOnCommit; // 待提交 Override public void putObject(Object key, Object value) { entriesToAddOnCommit.put(key, value); // 先放待提交区 } public void commit() { // commit 时批量写入真正的缓存 for (Map.EntryObject, Object entry : entriesToAddOnCommit.entrySet()) { delegate.putObject(entry.getKey(), entry.getValue()); } entriesToAddOnCommit.clear(); } public void rollback() { entriesToAddOnCommit.clear(); // 回滚时清空待提交区 } }这套延迟写入 回滚清空的设计带来的实际收益事务回滚时不会把未提交的数据写进二级缓存避免缓存污染。这也是面试官追问为什么二级缓存要等 commit/close 才写入的标准答案——第19章 的作业区里就有学员专门追问过为啥二级缓存需要 sqlsession commit/close 等操作。五、缓存清理策略1. 四种清理策略eviction策略说明适用场景LRU默认最近最少使用通用FIFO先进先出按时间顺序SOFT软引用内存不足时回收内存敏感WEAK弱引用GC 时回收缓存时间短对应 MyBatis 中的LruCache、FifoCache、SoftCache、WeakCache它们都实现了Cache接口且内部是装饰器套装饰器的结构比如LruCache内部持有真正的PerpetualCache作为 delegate。2. 缓存清理时机一级缓存清理 - 执行 INSERT/UPDATE/DELETE → clearLocalCache() - 手动 sqlSession.clearCache() - SqlSession 关闭 - localCacheScopeSTATEMENT 时每次执行完清理 二级缓存清理 - 执行 INSERT/UPDATE/DELETE → flushCacheIfRequired(ms) - flushInterval 到期自动清理 - size 达到上限按 eviction 策略清理 - namespace 整体清空3. Statement 级别的 flushCache!-- 每次 select 执行前清空缓存 -- select idfindById flushCachetrue ... !-- 增删改默认 flushCachetrue -- insert idinsert ... update idupdate ... delete iddelete ...flushCachetrue表示执行该语句前先清空对应缓存select 默认 false增删改默认 true。注意它在CachingExecutor.query里的表现flushCacheIfRequired(ms)会判断 Statement 的flushCache属性为 true 则调用tcm.clear(cache)清空整个 namespace 的缓存。六、二级缓存为什么默认关闭1. 数据一致性风险核心原因场景user 表和 order 表关联 SqlSession1: 查询 user(1) → 缓存到 UserMapper 二级缓存 SqlSession2: 修改 user(1) 的 name → UserMapper 二级缓存已清空 SqlSession3: 查询 order 关联 user(1) → OrderMapper 二级缓存有旧数据 ❌ user 数据不一致核心问题二级缓存是 namespace 级别跨 namespace 的关联查询无法保证一致性。用户数据更新时只有UserMapper的缓存被清空而OrderMapper里缓存着的订单关联用户的旧数据并不会被同步清理。2. 序列化问题二级缓存存储的数据需要序列化实体类必须实现Serializable。缓存的不是对象引用而是对象的序列化副本——读取时是不同的对象实例为 false。序列化/反序列化本身也有性能开销。3. 细粒度控制不足无法对某个查询条件精细控制缓存失效只能整个 namespace 一起清。flushCachetrue、增删改、flushInterval到期都是全量清空粒度过粗。七、与 Redis 整合把二级缓存搬到分布式环境1. 为什么用 RedisMyBatis 自带二级缓存的问题 ❌ 单机内存分布式环境不共享 ❌ 重启丢失 ❌ namespace 粒度粗 Redis 解决 ✅ 分布式共享 ✅ 持久化 ✅ 更灵活的过期策略在多实例部署的分布式环境下JVM 内存缓存天然不共享MyBatis 自带二级缓存形同虚设。用 Redis 做缓存存储介质本质是替换Cache接口的实现类。2. 整合方式mybatis-redis第一步引入依赖dependency groupIdorg.mybatis.caches/groupId artifactIdmybatis-redis/artifactId version1.0.0-beta2/version /dependency第二步Mapper XML 配置mapper namespacecom.example.mapper.UserMapper cache typeorg.mybatis.caches.redis.RedisCache/ /mapper通过cache type...指定自定义 Cache 实现类MyBatis 会用反射实例化该类并交给CachingExecutor使用——注意此时实体类依然需要实现SerializableRedis 序列化存储。第三步redis.properties 配置redis.hostlocalhost redis.port6379 redis.password redis.database0 redis.timeout30003. 自定义 Redis Cache实现 Cache 接口在 Spring 生态下更常见的做法是自己实现Cache接口用RedisTemplate管理读写与过期public class CustomRedisCache implements Cache { private final String id; private RedisTemplateString, Object redisTemplate; public CustomRedisCache(String id) { this.id id; this.redisTemplate SpringContextHolder.getBean(redisTemplate); } Override public String getId() { return id; } Override public void putObject(Object key, Object value) { String cacheKey id : key.toString(); redisTemplate.opsForValue().set(cacheKey, value, 30, TimeUnit.MINUTES); } Override public Object getObject(Object key) { String cacheKey id : key.toString(); return redisTemplate.opsForValue().get(cacheKey); } Override public Object removeObject(Object key) { String cacheKey id : key.toString(); redisTemplate.delete(cacheKey); return null; } Override public void clear() { SetString keys redisTemplate.keys(id :*); if (keys ! null !keys.isEmpty()) { redisTemplate.delete(keys); } } Override public int getSize() { SetString keys redisTemplate.keys(id :*); return keys null ? 0 : keys.size(); } }要点解析实现org.apache.ibatis.cache.Cache接口必须提供(String id)构造函数——MyBatis 通过反射用id即 namespace 全限定名实例化它putObject/getObject/removeObject/clear/getSize/getId六个方法是Cache接口的全部抽象方法Key 采用namespace : CacheKey的拼接方式天然实现了 namespace 隔离通过RedisTemplate的 TTL 参数可以比 MyBatis 自带缓存更灵活地控制过期策略。八、常见面试追问Q1一级缓存能关闭吗不能完全关闭但可以设置localCacheScopeSTATEMENT让缓存只在当前 Statement 有效执行完即清。效果上等同于关闭。Q2一级缓存返回的对象是同一个吗是的。一级缓存存储的是对象引用同一个 SqlSession 内多次查询返回的是同一个对象实例为 true。所以修改返回对象会影响缓存中的数据——这也是面试官常考的缓存污染隐患点。Q3二级缓存返回的对象是同一个吗不是。readOnlyfalse默认时二级缓存通过序列化/反序列化存储返回的是不同的对象实例。readOnlytrue时则返回同一引用但修改会影响缓存。Q4多表关联查询怎么处理二级缓存最佳实践关联查询涉及的 Mapper 使用同一个 namespacerefid或者只在读多写少且变更可控的场景使用二级缓存。分布式环境推荐直接用 Redis。九、总结记住三个核心要点 1. 一级缓存SqlSession 级别 默认开启不可关闭可设 STATEMENT 范围 增删改操作清空SqlSession 关闭失效 存储对象引用返回同一个实例 2. 二级缓存Mapper namespace 级别 手动开启commit 时才写入 跨 SqlSession 共享但跨 namespace 不保证一致性 默认关闭原因数据一致性风险 序列化问题 3. 缓存选择建议 单机 读多写少 → 一级缓存 二级缓存 分布式环境 → 一级缓存 Redis 强一致性要求 → 只用一级缓存或不用缓存面试回答模板MyBatis 有一级缓存和二级缓存。一级缓存是 SqlSession 级别默认开启增删改操作会清空SqlSession 关闭后失效存储对象引用。二级缓存是 Mapper namespace 级别需要手动开启SqlSession commit 时才写入通过序列化存储。二级缓存默认关闭核心原因是跨 namespace 的关联查询无法保证缓存一致性。比如用户表和订单表分属不同 namespace用户数据更新后订单关联缓存可能还是旧数据。分布式环境下建议用 Redis 替代 MyBatis 自带二级缓存实现自定义 Cache 接口通过 RedisTemplate 管理缓存数据的读写和过期。如果想要真正吃透这套缓存机制的落地实现可以顺着 CodeGuide 仓库中手写 MyBatis专栏的 第18章一级缓存 与 第19章二级缓存 走一遍从零实现流程再对照 mybatis-source-code-analysis-diagram 中的一二级缓存源码设计图把背面试题升级成真懂源码。【免费下载链接】CodeGuide:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总旨在为大家提供一个清晰详细的学习教程侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助请给予支持(关注、点赞、分享)项目地址: https://gitcode.com/gh_mirrors/code/CodeGuide创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表