ARTICLE DETAIL

资讯详情

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

MyBatis Mapper动态代理与缓存机制深度剖析:从源码到实战

MyBatis Mapper动态代理与缓存机制深度剖析:从源码到实战 最近在一次面试模拟里被问到这么一个问题“你天天用MyBatis有没有想过为什么Mapper接口明明没有实现类调它的方法却能把SQL执行了还有一级缓存和二级缓存一个默认开一个默认关底层到底是怎么设计的”我当时答得磕磕绊绊只说了“JDK动态代理”“namespace隔离缓存”这种关键词但根本讲不清代理对象是怎么创建出来的、一级缓存的Key到底由什么组成、二级缓存为什么要等事务提交才真正写入。回去我把MyBatis源码从XMLConfigBuilder到BaseExecutor、CachingExecutor完整过了一遍理清了整条链路。这篇文章我就把自己梳理的内容完整写出来覆盖Mapper动态代理的完整构造过程、一级缓存的生效条件与局限性、二级缓存的装饰器设计、读写的完整路径以及日常开发和面试中真正会用到的排查方法。适合那些用过Spring Boot MyBatis但没认真读过源码的人也适合正在准备面试、想在“MyBatis缓存”这个问题上把深度聊出来的人。1. 先从“Mapper为什么是接口”说起动态代理的完整链路1.1 我面试被问懵的那个问题很多Java开发第一次用MyBatis的时候都有个疑惑我明明只写了一个Mapper接口连实现类都没有为什么Spring能把它注入进来为什么我调用userMapper.selectById(1)就能查到数据库里的数据这个问题的标准答案就是JDK动态代理。MyBatis在运行时并没有给Mapper接口生成什么“实现类”而是通过java.lang.reflect.Proxy创建了一个代理对象。你调用的userMapper.selectById(1)实际上调用的是代理对象里的invoke方法MyBatis在这个方法里帮你做了SQL解析、参数绑定、JDBC执行、结果集映射这一整套事情。MyBatis本质上是一个半自动ORM框架。它不像Hibernate那样把整个表映射成完全透明的对象而是把SQL还给你自己写框架负责处理那些最烦人的样板代码连接的获取和释放、PreparedStatement的创建、参数的设置、ResultSet的遍历和对象映射。Mapper接口在中间起到的作用就是“约定的载体”你声明一个方法方法名、参数、返回值对应一条SQLMyBatis通过代理把这个约定变成真正的数据库操作。为什么MyBatis选择用JDK动态代理而不是CGLIB原因不复杂。JDK动态代理的前提是目标对象必须实现接口而Mapper本来就是接口天然满足条件。如果要用CGLIB去代理一个类反而还得做额外的字节码生成、处理类的继承关系复杂得多。这里不是“能用就行”而是接口这个设计本身就为动态代理铺好了路。所以Mapper被设计成接口不是MyBatis偷懒是架构上一种很聪明的顺势而为。1.2 代理对象是怎么诞生和工作的整个链路从MyBatis初始化开始大致可以分成四个阶段。第一个阶段是配置解析。XMLConfigBuilder负责读取MyBatis主配置文件包括settings、typeAliases、environments、mappers这些标签。主配置文件解析完成后它会继续遍历mapper标签指向的Mapper XML文件用XMLMapperBuilder解析每个Mapper文件里的select、insert、update、delete节点把这些SQL封装成MappedStatement对象注册到全局Configuration里。如果使用的是基于XML的初始化方式整个加载顺序大概是XMLConfigBuilder.parse()→parseConfiguration()→mapperElement()→XMLMapperBuilder.parse()→buildStatementFromContext()。看到这里你就明白了很多人网络搜索里提到的xmlconfigbuilser其实就是这个XMLConfigBuilder它是整个MyBatis初始化的入口。第二阶段是Mapper注册。解析Mapper XML时MyBatis会把对应的Mapper接口注册到MapperRegistry里。MapperRegistry内部维护了一个knownMappers集合具体是个HashMapClass?, MapperProxyFactory?。addMapper()方法做的事情很简单把接口的Class对象作为Key存进去value是一个MapperProxyFactory这个Factory专门负责为当前接口创建代理对象。第三阶段是代理对象创建。当我们从Spring容器里拿到UserMapper这个Bean的时候Spring整合时这个Bean本质上就是一个Mapper代理对象或者手动执行sqlSession.getMapper(UserMapper.class)的时候最终都会走到MapperProxyFactory.newInstance()。这个方法的内部实现非常短public class MapperProxyFactoryT { private final ClassT mapperInterface; private final MapMethod, MapperMethod methodCache new ConcurrentHashMap(); protected T newInstance(MapperProxyT mapperProxy) { return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[]{mapperInterface}, mapperProxy); } }一段再普通不过的JDK动态代理传入接口类加载器、接口Class数组、以及一个InvocationHandler实现类也就是MapperProxy。从此刻起UserMapper这个接口就拥有了一个“虚拟实现”所有方法调用都会进入MapperProxy.invoke()。第四阶段是方法调用分发。看MapperProxy.invoke()的源码它把调用分成了三类Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } return cachedInvoker(method).invoke(proxy, method, args, sqlSession); }如果是toString、hashCode、equals这些Object自带的方法直接反射调用。如果是Java 8之后的default方法走DefaultMethodInvoker。否则走PlainMethodInvoker它会找到这个Method对应的MapperMethod再执行execute()。MapperMethod内部会根据SQL的类型SELECT、INSERT、UPDATE、DELETE和返回类型做分发然后调用sqlSession的对应方法。这里还有个小细节methodCache缓存了方法到MapperMethod的映射避免每次调用都重新解析这也是一个轻量级的缓存优化。1.3 动态代理在Spring整合中的第二次出现还有一件容易被忽略的事在Spring整合MyBatis的架构里动态代理其实出现了两次。第一次是Mapper接口的代理我们前面已经说过了。第二次是SqlSessionTemplate的代理。SqlSessionTemplate是MyBatis-Spring提供的核心类它实现了SqlSession接口内部通过SqlSessionInterceptor这个InvocationHandler把所有方法调用包装了一层。为什么要多此一举因为在多线程环境下一个SqlSession不是线程安全的不能让所有Mapper共用同一个SqlSession实例。SqlSessionTemplate通过动态代理让每个Mapper方法在执行前从Spring的事务管理器中获取当前线程绑定的SqlSession执行完再决定是归还还是关闭。这个设计引出了一个非常重要的结论**在Spring环境中一个Mapper方法执行时用的SqlSession可能每次都不一样。**这直接影响了一级缓存的生效范围后面我会专门说。2. 一级缓存那个默认开启、却常被说“没用”的会话级缓存2.1 一级缓存到底怎么工作的一级缓存也叫本地缓存local cache作用域是SqlSession级别。MyBatis的一级缓存是默认开启的不需要做任何配置。它的载体是BaseExecutor里的localCache字段类型是PerpetualCache。而PerpetualCache的内部说穿了就是一个HashMapObject, Object。BaseExecutor.query()这个方法是理解一级缓存的关键public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) { if (queryStack 0 ms.isFlushCacheRequired()) { clearLocalCache(); } ListE list localCache.getObject(key); if (list ! null) { handleLocallyCachedOutputParameters(ms, key, parameter, boundSql); return list; } list queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); return list; }这里有个关键问题CacheKey到底由什么组成它不是一个简单的字符串拼接而是一个组合Key源码里会依次把以下信息放入对象MappedStatement的ID也就是namespace id比如com.example.mapper.UserMapper.selectById分页的RowBounds包括offset和limit最终的SQL语句也就是BoundSql.getSql()实际的查询参数环境IDenvironmentId这些东西拼在一起意味着“同一个Mapper方法 同一条SQL 同样的参数 同样的分页”才会命中同一个Key。只要你改了任意一个参数Key就变了就会重新查库。我习惯用生活中的场景来理解一级缓存它就像你在会议室开会时在白板上写下的几个关键数据。同一批人同一个SqlSession在同一个会议室里讨论往白板看一眼就能拿到数据不用再回办公室翻电脑。但会议一散白板就被擦了缓存失效换一个会议室新的SqlSession白板也是空的。一级缓存方便就方便在同一个会话里查重复数据快局限也局限在“会话”这两个字上。2.2 为什么大家都在吐槽它“鸡肋”一级缓存看着方便实践里却被很多人嫌弃原因有三个。第一个原因是作用域太窄。一级缓存只在SqlSession生命周期内有效而很多人其实并没有真正掌握SqlSession的创建时机。在纯MyBatis原生使用中每次业务操作都可能是sqlSessionFactory.openSession()创建出的新会话会话关闭后缓存也没了。如果读者用的是MyBatis-Spring整合情况更明显没有事务的时候每次Mapper方法调用都会通过SqlSessionTemplate获取一个全新的、独立的SqlSession方法结束就关闭一级缓存等于形同虚设。所以你会看到“同一个Mapper方法在Spring无事务环境下连续调用两次SQL照样打印两次”的现象。第二个原因是一更新就失效。任何一个INSERT、UPDATE、DELETE操作都会触发flushCachetrue导致clearLocalCache()清空当前SqlSession的一级缓存。这样做从数据一致性角度看是合理的库里数据已经变了缓存里还是老数据必须清掉。但这也意味着在同一个SqlSession里如果你先查了一条数据、然后做了一次更新、再查同一条件的数据第二次查询还是会去访问数据库因为你更新的时候已经把所有缓存清掉了。第三个原因是CacheKey设计得“诚意不足”。有人会问为什么CacheKey里不包含数据库连接如果两个SqlSession用的是同一个数据库连接理论上它们查到的数据是一样的缓存能不能共享但MyBatis没有这么做因为SqlSession本身就绑定了一个连接没必要再区分同时把连接信息放进Key还会带来额外的复杂度和性能开销。所以它干脆就把缓存范围锁死在SqlSession上。关于“缓存失效”还有个小坑要提醒Spring里一级缓存是否生效核心取决于“当前线程有没有处于Spring事务中”。如果你在方法上加了Transactional那么从进入事务到事务结束整个期间所有Mapper方法获取到的都是同一个SqlSession结合SqlSessionTemplate的动态代理和SynchronizationManager的资源绑定机制一级缓存就会真正生效。如果没有开启事务每次Mapper方法调用的SqlSession都是独立的一级缓存自然不生效。这也是面试里很多人答不好的一点他只会说“一级缓存默认开启”不知道“Spring无事务时一级缓存基本等于无效”。补充一下MyBatis里提到的“一级缓存”和Spring的“三级缓存”完全是两个概念。Spring的三级缓存解决的是循环依赖场景下的Bean提前暴露问题和数据库查询缓存没有关系互联网上搜索“spring三级缓存原理”时不要把它们混为一谈。2.3 被一级缓存坑过的真实场景我见过一个很典型的例子。同事在一个方法里开启了事务分两步操作先调用userMapper.selectById(1)查出用户信息再调用userMapper.update(user)把用户昵称改了然后又调用userMapper.selectById(1)去读取最新的用户信息结果发现读到的还是修改前的旧值。原因就出在一级缓存上第一次查询的结果已经被localCache缓住了虽然之后执行了update操作但是MyBatis的flushCache清缓存动作是针对“当前执行的Mapper方法”而言的如果update的SQL标签上没有显式配置flushCachetrue默认情况下UPDATE语句的flushCache本来就是true所以这里其实会被清掉但是有一种情况是update操作发生在另一个SqlSession中或者由于某些框架层面的代理问题导致缓存没有被清就不是一个清缓存的问题了。更常见的版本是第一次查询和执行更新分别在不同的方法里、不同的SqlSession里因为Spring事务边界划分不清晰导致第二次查询拿到了第一次事务缓存的数据。这里我不建议把“怎么强制刷新缓存”作为重点核心要搞清楚的是事务边界。如果你需要“先查再改再查”保持数据可见那就应该把这三步放在同一个事务里让它们共享同一个SqlSession这样update完成后缓存被清掉后面的select自然能查到新数据如果你根本不需要一级缓存可以把localCacheScope设为STATEMENT让配置恢复到“没有缓存”的状态settings setting namelocalCacheScope valueSTATEMENT/ /settings设置完成后一级缓存的作用范围就被限制在单条SQL语句级别语句执行完就清空不会再跨多次查询共享。这个方法在排查一些涉及“事务内旧数据”的问题时很管用。3. 二级缓存装饰器模式拼出来的跨会话共享缓存3.1 Cache背后的装饰器设计二级缓存和一级缓存最大的区别在于它的作用域是namespace级别也就是一个Mapper XML文件对应一个缓存区域可以跨多个SqlSession共享。默认情况下二级缓存是关闭的需要显式配置才会生效。最简单的开启方式就是在一个Mapper XML里加上cache/你可以只写这一个空标签也可以配置一堆属性但更值得研究的是这个标签背后MyBatis到底创建了一个什么样的缓存对象看org.apache.ibatis.builder.CacheBuilder这个类的源码你会发现MyBatis用了装饰器模式来构建缓存。核心是一个PerpetualCache本质就是HashMap然后在它外面一层一层包裹装饰器。默认的缓存链大概是这样的顺序可能因配置略有不同PerpetualCache底层存储数据真正放在这个HashMap里LruCache当缓存条目数超过size限制时淘汰最久未被使用的条目对应evictionLRUScheduledCache每隔flushInterval毫秒清空一次缓存SerializedCache当readOnlyfalse时启用存进去的对象会被序列化取出来的是反序列化后的副本LoggingCache负责统计缓存命中率我们常看到的Cache Hit Ratio就是它输出的SynchronizedCache给缓存操作加锁保证并发安全每个装饰器只做一件事通过不同的组合方式拼出功能完整的缓存。这种设计妙在哪里它遵循了单一职责原则我如果只想加一个淘汰策略不需要去改PerpetualCache的代码只需要在外面套一个LruCache即可。我自己后来写本地缓存工具的时候也模仿了这种装饰器链把“过期清理”“容量限制”“命中统计”拆成独立的小类维护起来非常舒服。如果你设置了evictionFIFO那外层装饰器会换成FifoCache如果设置evictionSOFT或WEAK会分别使用基于Java软引用和弱引用的缓存包装。这里不展开太多知道“二级缓存的底层是一串装饰器”这个结论就够了。3.2 二级缓存的读写路径与事务控制二级缓存的读写不是直接操作那个Cache对象的而是通过CachingExecutor来间接完成的。它是Executor的装饰器包在BaseExecutor外面。查询的时候CachingExecutor.query()会做这样一组逻辑先看MappedStatement上有没有绑定Cache对象也就是Mapper XML里有没有配cache/没有就直接走普通查询如果有缓存对象就根据当前查询构造CacheKey调用cache.getObject(key)如果缓存命中了直接返回结果本次查询不再触碰数据库如果没有命中进入底层的BaseExecutor查询逻辑。注意这里有个细节底层查询过程中还会再判断一级缓存所以二级缓存未命中但一级缓存命中的情况同样不会去查数据库数据库查出来的结果并不会立刻写入二级缓存而是先放进一个TransactionalCache里暂存关键就在第5步。TransactionalCache会记录当前事务里所有待写入的缓存数据但不立即刷到真正的Cache里。只有事务提交时CachingExecutor.commit()方法被调用它内部的TransactionalCacheManager才会执行真正的缓存写入。如果事务回滚了这些暂存数据会被直接丢弃。这个设计的意图很明确防止未提交事务的脏数据被其他SqlSession读到。举个例子事务A查了一条数据放进TransactionalCache但事务还没提交此时事务B也用同一个二级缓存区查询如果A直接把数据写入了二级缓存B就会读到A可能回滚的脏数据。所以MyBatis宁可通过“提交时才落缓存”的方式来保证数据可见性和隔离性。这个机制也解释了很多人遇到过的情况**为什么明明开了二级缓存命中率却一直是0**大概率是因为查询没有包装在事务里。在Spring环境中SqlSessionTemplate只有在事务存在时才会把SqlSession绑定到当前线程并且事务提交时会调用SqlSession.commit()和Executor.commit()二级缓存的TransactionalCache才能把数据刷进真正的缓存。如果没有事务每次查询用的都可能是新SqlSession查询结束后数据还留在TransactionCache里没写进去就跟没开一样。理解了这一点“二级缓存为什么在Spring里必须配事务”就不需要死记了。3.3 横跨多个namespace的脏数据问题与分布式扩展二级缓存有一类很经典的脏数据问题我单独拿出来讲因为它最容易在线上暴雷。假设你有两个MapperUserMapper和OrderMapper。OrderMapper的某个查询和UserMapper的表做了联查查出来一批包含用户名信息的订单列表结果被缓存在了OrderMapper的namespace缓存区。这时另一个接口更新了UserMapper里的用户昵称它会清空UserMapper自己的缓存但OrderMapper里的缓存毫不知情下次再查询订单列表拿到的还是旧的用户名。这就是跨namespace缓存不一致。二级缓存的粒度是namespace它并不知道你的SQL到底涉及了哪些表也无法自动感知“其他namespace更新了我依赖的数据”。官方文档其实也提醒过这个问题多表操作时使用二级缓存需要格外小心。规避方法主要有两种。第一种是用cache-ref让多个Mapper共享同一个缓存区域cache-ref namespacecom.example.mapper.OrderMapper/放在UserMapper里表示UserMapper不使用自己的缓存区而是和OrderMapper共享同一个Cache对象。这样两个namespace的任何更新操作都会同时清空这同一个缓存区脏数据问题就消失了。但这种做法会扩大缓存失效范围如果两个Mapper都高频更新缓存命中率会掉得很厉害。第二种方法更彻底多表关联查询的Mapper不开二级缓存或者干脆全局关闭二级缓存。不要觉得这是“偷懒”很多数据一致性要求高的业务场景二级缓存真的是弊大于利。我的判断标准很简单如果一个Mapper的数据变更频率高或者经常被其他表的更新间接影响就别开只有读多写少、单表查询为主、对数据一致性容忍度高的场景才适合。再往下走一步二级缓存还有一个跨实例问题默认的缓存是JVM内存级的部署多个应用实例时每个实例各存一份互相之间完全不能感知。这时候要么使用Redis这种分布式缓存作为MyBatis的二级缓存实现要么直接放弃二级缓存、把缓存治理放到更上层的Redis服务里去。如果你想扩展一个Redis版的二级缓存需要实现org.apache.ibatis.cache.Cache接口public class RedisCache implements Cache { private final String id; private final RedisTemplateString, Object redisTemplate; public RedisCache(String id) { this.id id; this.redisTemplate SpringUtils.getBean(RedisTemplate.class); } Override public String getId() { return id; } Override public void putObject(Object key, Object value) { redisTemplate.opsForValue().set(id : key.toString(), value); } Override public Object getObject(Object key) { return redisTemplate.opsForValue().get(id : key.toString()); } Override public Object removeObject(Object key) { redisTemplate.delete(id : key.toString()); return null; } Override public void clear() { SetString keys redisTemplate.keys(id :*); if (keys ! null !keys.isEmpty()) { redisTemplate.delete(keys); } } Override public int getSize() { return 0; } }然后在Mapper XML里指定cache typecom.example.cache.RedisCache/注意几个容易踩的细节缓存Key要带上namespace前缀避免不同Mapper之间key冲突实体类要做好序列化方案Jackson、Fastjson2或Protostuff都行否则Redis存取会异常flushInterval过期时间要和Redis的TTL对应上不然会出现Redis里数据还没过期、MyBatis却认为已经过期去重新查库的情况。至于大家在互联网上经常搜到的“缓存穿透”“缓存击穿”那是分布式缓存治理层面的问题可以在Redis上层通过空值缓存、互斥锁、热点key重建等手段去做MyBatis这一层只需要保证读写路径畅通即可。4. 实战配置与排查缓存参数、常见坑和问题速查表4.1 一张表搞懂缓存相关配置把MyBatis缓存相关的核心配置汇总成一张表配置的时候照着看就行配置位置配置项可选值/默认值作用与建议全局settingscacheEnabledtrue/false默认true全局二级缓存总开关。设为false后所有Mapper的二级缓存全部失效全局settingslocalCacheScopeSESSION/STATEMENT默认SESSIONSESSION表示一级缓存跨多次查询生效STATEMENT表示只对单条语句生效Mapper XML的cache标签evictionLRU/FIFO/SOFT/WEAK默认LRU缓存淘汰策略。LRU最常用SOFT/WEAK依赖JVM引用回收适合内存敏感场景Mapper XML的cache标签flushInterval正整数单位毫秒默认不限定时刷新时间间隔。业务数据变频繁时可设置较短值Mapper XML的cache标签size正整数默认1024缓存最大对象数。设置过小会导致命中率低过大浪费内存Mapper XML的cache标签readOnlytrue/false默认falsefalse时返回序列化副本更安全但性能差true直接返回引用快但容易被外部修改污染缓存Mapper XML的cache标签blockingtrue/false默认falsetrue时启用BlockingCache同一key的并发查询只有一个线程访问数据库其他线程等待给一个相对完整的二级缓存配置示例cache evictionLRU flushInterval60000 size512 readOnlyfalse blockingtrue/flushInterval60000的意思是即使没有任何更新操作触发清缓存最多60秒也会自动清一次避免数据长时间陈旧。readOnlyfalse要求所有被缓存的实体类实现Serializable接口这是很容易漏的配置点。blockingtrue在缓存穿透比较严重的场景下很有用它相当于给同一个CacheKey加了一把本地锁防止大量并发请求同时打到数据库。至于那些从“Spring Boot MyBatis 入门”练手阶段就开始关注的问题比如搭建一个注册功能其实也绕不开这些配置刚开始用logImpl把SQL打印出来看每次查询是否真的走了缓存再逐步理解上面这些参数比死记概念效果好得多。4.2 常见问题速查表把我在实际开发和排查中遇到的缓存相关问题整理成一个速查表遇到问题直接对号入座问题现象排查思路解决方案一级缓存“不生效”同一条SQL在Spring里连续执行了两次确认方法有没有加Transactional没有事务时每次Mapper调用都是新SqlSession缓存本来就失效需要缓存时把多次查询放进同一事务不需要时直接忽略或设置localCacheScopeSTATEMENT来明确语义开了二级缓存但命中率一直是0%检查Mapper XML有没有加cache检查是否在事务中执行查询确认cacheEnabled没被全局关闭二级缓存的写入依赖事务提交Spring中查询方法必须包在事务里才有实际效果两个表联查后另一个Mapper更新数据查询方读到了旧数据查询方namespace缓存了跨表数据更新方的flushCache只清自己的缓存区用cache-ref共享缓存区域或者对涉及多表查询的Mapper关闭二级缓存报错“Error serializing object”或“Cannot serialize”二级缓存readOnlyfalse时需要序列化实体类没实现Serializable让实体类实现Serializable或者把readOnly设为true会缓存对象引用注意并发修改风险Mapper接口注入报错Spring Boot 3.x环境扫描不到检查包扫描路径是否正确mybatis-spring-boot-starter版本是否兼容Spring Boot 3.x检查是否有多个MapperScan配置冲突升级starter到2.3.x以上并保持spring boot版本匹配确认MapperScan主配置类路径覆盖了所有Mapper包排除重复扫描冲突动态SQL条件不生效SQL里少了预期拼接的where条件检查if test\里写的属性名是否拼写错误确认参数是POJO还是Map取值的key是否一致if testuserName ! null and userName ! \中userName必须和入参对象属性名完全一致多参数时用Param命名后再判断缓存里的对象在别处被改了后续查询数据异常readOnlytrue时缓存存的是对象引用外部拿到后修改会污染缓存非必要情况下保持readOnlyfalse让缓存存序列化副本或者对缓存返回结果做防御性拷贝第6条值得多说一句。网上有人搜“Mapper在SpringBoot4有什么问题”其实更多是版本更新带来的兼容性调整。Spring Boot 3.x基于Spring Framework 6和Spring Boot 2.x在自动配置机制上有不少变化如果你项目中突然发现Mapper接口没有被扫描到第一反应应该是核对版本匹配问题而不是急着改代码。4.3 开启日志定位缓存问题的三板斧排查缓存问题我一般分三步走。第一步是把SQL和执行日志打出来。在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl或者使用Slf4j实现把log-impl换成对应实现类。启动后控制台会打印每条SQL语句的执行情况。如果开了二级缓存还会打印Cache Hit Ratio [com.example.mapper.UserMapper]这样的日志后面跟一个百分比。这个比率是LoggingCache统计出来的命中率长期为0说明缓存根本没有被用起来。第二步是判断当前查询是否真的在事务里。这个判断非常关键因为它同时决定了一级缓存和二级缓存的实际行为。Spring日志里开启org.springframework.transaction的DEBUG级别可以明确看到事务边界。我在排查缓存问题时一定会先问自己这个查询是在事务里还是事务外只有先确认了这一点才能继续往下判断是缓存配置问题还是缓存设计问题。第三步是做隔离验证。如果怀疑二级缓存有问题可以先在全局配置里把cacheEnabled设为false跑一遍同样的用例对比SQL执行情况。一旦关闭二级缓存后问题消失基本可以锁定是二级缓存的数据一致性问题如果关掉后问题还在说明和二级缓存没关系要往一级缓存、事务边界或者动态SQL的方向继续查。调试技巧上可以临时把localCacheScope改成STATEMENT来验证一级缓存是否是干扰因素也可以借助代码里的SqlSession.clearCache()手动清空当前会话的一级缓存但这个方法只对当前归你管理的SqlSession有效。在Spring环境中更多的还是要靠事务边界来管理缓存生命周期而不是想办法手动去刷新它。我个人在实际操作中的体会是源码看一遍比背十篇面试题都管用。动态代理和装饰器模式以前在学习设计模式时总觉得抽象但在MyBatis里看到它们如何被落地成真实的框架代码后你再看其他框架的源码会轻松很多。面试的时候如果能把“一级缓存为什么在Spring无事务时基本不生效”和“二级缓存为什么要等事务提交才写入”这两个点讲透就已经比大多数只会说“有缓存、默认开、namespace隔离”的候选人高一截了。如果后续想继续扩展我建议自己动手给Mapper加一个基于Redis的二级缓存实现真正跑通一遍之后你对缓存的理解会彻底不一样。
返回列表