最近在一次面试模拟里被问到这么一个问题:“你天天用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接口在中间起到的作用,就是“约定的载体”你声明一个方法,方法名、参数、返回值对应一条SQL,MyBatis通过代理把这个约定变成真正的数据库操作。
为什么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集合(具体是个HashMap<Class<?>, MapperProxyFactory<?>>)。addMapper()方法做的事情很简单:把接口的Class对象作为Key存进去,value是一个MapperProxyFactory,这个Factory专门负责为当前接口创建代理对象。
第三阶段是代理对象创建。当我们从Spring容器里拿到UserMapper这个Bean的时候(Spring整合时,这个Bean本质上就是一个Mapper代理对象),或者手动执行sqlSession.getMapper(UserMapper.class)的时候,最终都会走到MapperProxyFactory.newInstance()。这个方法的内部实现非常短:
public class MapperProxyFactory<T> { private final Class<T> mapperInterface; private final Map<Method, MapperMethod> methodCache = new ConcurrentHashMap<>(); protected T newInstance(MapperProxy<T> 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的内部,说穿了就是一个HashMap<Object, Object>。
BaseExecutor.query()这个方法是理解一级缓存的关键:
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) { if (queryStack == 0 && ms.isFlushCacheRequired()) { clearLocalCache(); } List<E> 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() - 实际的查询参数
- 环境ID(environmentId)
这些东西拼在一起,意味着“同一个Mapper方法 + 同一条SQL + 同样的参数 + 同样的分页”才会命中同一个Key。只要你改了任意一个参数,Key就变了,就会重新查库。
我习惯用生活中的场景来理解一级缓存:它就像你在会议室开会时,在白板上写下的几个关键数据。同一批人(同一个SqlSession)在同一个会议室里讨论,往白板看一眼就能拿到数据,不用再回办公室翻电脑。但会议一散,白板就被擦了(缓存失效),换一个会议室(新的SqlSession),白板也是空的。一级缓存方便就方便在同一个会话里查重复数据快,局限也局限在“会话”这两个字上。
2.2 为什么大家都在吐槽它“鸡肋”
一级缓存看着方便,实践里却被很多人嫌弃,原因有三个。
第一个原因是作用域太窄。一级缓存只在SqlSession生命周期内有效,而很多人其实并没有真正掌握SqlSession的创建时机。在纯MyBatis原生使用中,每次业务操作都可能是sqlSessionFactory.openSession()创建出的新会话,会话关闭后缓存也没了。如果读者用的是MyBatis-Spring整合,情况更明显:没有事务的时候,每次Mapper方法调用都会通过SqlSessionTemplate获取一个全新的、独立的SqlSession,方法结束就关闭,一级缓存等于形同虚设。所以你会看到“同一个Mapper方法,在Spring无事务环境下连续调用两次,SQL照样打印两次”的现象。
第二个原因是一更新就失效。任何一个INSERT、UPDATE、DELETE操作都会触发flushCache=true,导致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标签上没有显式配置flushCache="true"(默认情况下UPDATE语句的flushCache本来就是true,所以这里其实会被清掉,但是!有一种情况是update操作发生在另一个SqlSession中,或者由于某些框架层面的代理问题导致缓存没有被清),就不是一个清缓存的问题了。更常见的版本是:第一次查询和执行更新分别在不同的方法里、不同的SqlSession里,因为Spring事务边界划分不清晰,导致第二次查询拿到了第一次事务缓存的数据。
这里我不建议把“怎么强制刷新缓存”作为重点,核心要搞清楚的是事务边界。如果你需要“先查再改再查”保持数据可见,那就应该把这三步放在同一个事务里,让它们共享同一个SqlSession,这样update完成后缓存被清掉,后面的select自然能查到新数据;如果你根本不需要一级缓存,可以把localCacheScope设为STATEMENT,让配置恢复到“没有缓存”的状态:
<settings> <setting name="localCacheScope" value="STATEMENT"/> </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限制时,淘汰最久未被使用的条目,对应eviction="LRU"ScheduledCache:每隔flushInterval毫秒清空一次缓存SerializedCache:当readOnly=false时启用,存进去的对象会被序列化,取出来的是反序列化后的副本LoggingCache:负责统计缓存命中率,我们常看到的Cache Hit Ratio就是它输出的SynchronizedCache:给缓存操作加锁,保证并发安全
每个装饰器只做一件事,通过不同的组合方式拼出功能完整的缓存。这种设计妙在哪里?它遵循了单一职责原则,我如果只想加一个淘汰策略,不需要去改PerpetualCache的代码,只需要在外面套一个LruCache即可。我自己后来写本地缓存工具的时候,也模仿了这种装饰器链,把“过期清理”“容量限制”“命中统计”拆成独立的小类,维护起来非常舒服。
如果你设置了eviction="FIFO",那外层装饰器会换成FifoCache;如果设置eviction="SOFT"或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的脏数据问题与分布式扩展
二级缓存有一类很经典的脏数据问题,我单独拿出来讲,因为它最容易在线上暴雷。
假设你有两个Mapper:UserMapper和OrderMapper。OrderMapper的某个查询和UserMapper的表做了联查,查出来一批包含用户名信息的订单列表,结果被缓存在了OrderMapper的namespace缓存区。这时另一个接口更新了UserMapper里的用户昵称,它会清空UserMapper自己的缓存,但OrderMapper里的缓存毫不知情,下次再查询订单列表,拿到的还是旧的用户名。
这就是跨namespace缓存不一致。二级缓存的粒度是namespace,它并不知道你的SQL到底涉及了哪些表,也无法自动感知“其他namespace更新了我依赖的数据”。官方文档其实也提醒过这个问题,多表操作时使用二级缓存需要格外小心。
规避方法主要有两种。第一种是用<cache-ref>让多个Mapper共享同一个缓存区域:
<cache-ref namespace="com.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 RedisTemplate<String, 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() { Set<String> keys = redisTemplate.keys(id + ":*"); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } } @Override public int getSize() { return 0; } }然后在Mapper XML里指定:
<cache type="com.example.cache.RedisCache"/>注意几个容易踩的细节:缓存Key要带上namespace前缀,避免不同Mapper之间key冲突;实体类要做好序列化方案(Jackson、Fastjson2或Protostuff都行),否则Redis存取会异常;flushInterval过期时间要和Redis的TTL对应上,不然会出现Redis里数据还没过期、MyBatis却认为已经过期去重新查库的情况。至于大家在互联网上经常搜到的“缓存穿透”“缓存击穿”,那是分布式缓存治理层面的问题,可以在Redis上层通过空值缓存、互斥锁、热点key重建等手段去做,MyBatis这一层只需要保证读写路径畅通即可。
4. 实战配置与排查:缓存参数、常见坑和问题速查表
4.1 一张表搞懂缓存相关配置
把MyBatis缓存相关的核心配置汇总成一张表,配置的时候照着看就行:
| 配置位置 | 配置项 | 可选值/默认值 | 作用与建议 |
|---|---|---|---|
| 全局settings | cacheEnabled | true/false,默认true | 全局二级缓存总开关。设为false后所有Mapper的二级缓存全部失效 |
| 全局settings | localCacheScope | SESSION/STATEMENT,默认SESSION | SESSION表示一级缓存跨多次查询生效;STATEMENT表示只对单条语句生效 |
| Mapper XML的cache标签 | eviction | LRU/FIFO/SOFT/WEAK,默认LRU | 缓存淘汰策略。LRU最常用;SOFT/WEAK依赖JVM引用回收,适合内存敏感场景 |
| Mapper XML的cache标签 | flushInterval | 正整数,单位毫秒,默认不限 | 定时刷新时间间隔。业务数据变频繁时可设置较短值 |
| Mapper XML的cache标签 | size | 正整数,默认1024 | 缓存最大对象数。设置过小会导致命中率低,过大浪费内存 |
| Mapper XML的cache标签 | readOnly | true/false,默认false | false时返回序列化副本,更安全但性能差;true直接返回引用,快但容易被外部修改污染缓存 |
| Mapper XML的cache标签 | blocking | true/false,默认false | true时启用BlockingCache,同一key的并发查询只有一个线程访问数据库,其他线程等待 |
给一个相对完整的二级缓存配置示例:
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="false" blocking="true"/>flushInterval=60000的意思是,即使没有任何更新操作触发清缓存,最多60秒也会自动清一次,避免数据长时间陈旧。readOnly=false要求所有被缓存的实体类实现Serializable接口,这是很容易漏的配置点。blocking=true在缓存穿透比较严重的场景下很有用,它相当于给同一个CacheKey加了一把本地锁,防止大量并发请求同时打到数据库。
至于那些从“Spring Boot + MyBatis 入门”练手阶段就开始关注的问题,比如搭建一个注册功能,其实也绕不开这些配置:刚开始用logImpl把SQL打印出来,看每次查询是否真的走了缓存,再逐步理解上面这些参数,比死记概念效果好得多。
4.2 常见问题速查表
把我在实际开发和排查中遇到的缓存相关问题整理成一个速查表,遇到问题直接对号入座:
| 问题现象 | 排查思路 | 解决方案 |
|---|---|---|
| 一级缓存“不生效”,同一条SQL在Spring里连续执行了两次 | 确认方法有没有加@Transactional;没有事务时每次Mapper调用都是新SqlSession,缓存本来就失效 | 需要缓存时把多次查询放进同一事务;不需要时直接忽略,或设置localCacheScope=STATEMENT来明确语义 |
| 开了二级缓存但命中率一直是0% | 检查Mapper XML有没有加<cache>;检查是否在事务中执行查询;确认cacheEnabled没被全局关闭 | 二级缓存的写入依赖事务提交,Spring中查询方法必须包在事务里才有实际效果 |
| 两个表联查后,另一个Mapper更新数据,查询方读到了旧数据 | 查询方namespace缓存了跨表数据,更新方的flushCache只清自己的缓存区 | 用<cache-ref>共享缓存区域;或者对涉及多表查询的Mapper关闭二级缓存 |
| 报错“Error serializing object”或“Cannot serialize” | 二级缓存readOnly=false时需要序列化,实体类没实现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 test="userName != null and userName != ''"\>中userName必须和入参对象属性名完全一致;多参数时用@Param命名后再判断 |
| 缓存里的对象在别处被改了,后续查询数据异常 | readOnly=true时缓存存的是对象引用,外部拿到后修改会污染缓存 | 非必要情况下保持readOnly=false,让缓存存序列化副本;或者对缓存返回结果做防御性拷贝 |
第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的二级缓存实现,真正跑通一遍之后,你对缓存的理解会彻底不一样。