☰
MyBatis缓存机制与Mapper动态代理:SQL执行链路源码深度解析
2026/10/6 17:38:34 网站建设 项目流程

1. MyBatis 的执行链路:一条 SQL 是怎么跑起来的

聊 MyBatis 源码之前,我习惯先把整条执行链路在脑子里过一遍。因为无论是 Mapper 动态代理,还是一级二级缓存,本质上都是挂在这条链路上的某个环节。你如果只盯着某一段源码看,很容易陷入"每个字都认识,但不知道它在哪一步起作用"的尴尬。

一条 SQL 从业务代码发起,到数据库返回结果,中间大概经过这么几个角色:SqlSessionFactory负责在启动时构建全局配置对象Configuration,SqlSession是面向开发者的门面,真正的执行逻辑由Executor完成,再往下是StatementHandler、ParameterHandler、ResultSetHandler这一组负责 JDBC 底层操作的组件。Configuration是整个 MyBatis 的心脏,它里面装着所有解析好的MappedStatement(一条 SQL 对应一个)、所有 TypeHandler、所有 Mapper 接口的注册信息,甚至二级缓存实例也挂在它身上。

这里有个很多人忽略的细节:XMLConfigBuilder在解析 mybatis-config.xml 时,不会只做 XML 的 DOM 解析,它会把<settings>、<typeAliases>、<plugins>、<environments>、<mappers>这些节点分别转换成语义明确的配置对象。比如<mappers>节点里配置了<mapper resource="...">或<package name="...">,对应的XMLMapperBuilder就会去逐个解析 Mapper XML 文件,把每个<select>、<insert>、<update>、<delete>包装成MappedStatement,注册进Configuration.mappedStatements。同时,它还负责把 Mapper 接口注册到MapperRegistry。这一步经常被遗忘,但它是后面所有动态代理的基础——没有这一步,getMapper()根本不知道去哪里找对应的代理工厂。

理解了这条链路,你再看SqlSession就清楚了:它不是一个孤立的对象,而是把Configuration和Executor串在一起的胶水层。传统的 JDBC 代码里,你要自己管理 Connection、PreparedStatement、ResultSet,而 MyBatis 把这套流程收敛到了Executor和它下游的四个组件里,开发者接触的只是SqlSession这个门面。接下来要聊的 Mapper 动态代理,就是在SqlSession的getMapper()这一步发生的。

2. Mapper 动态代理:接口为什么不需要实现类

2.1 注册阶段:MapperRegistry 先认识接口

很多 MyBatis 初学者第一次看 Mapper 接口时会很困惑:这个接口明明只有方法签名,没有@Override,却能直接注入进来调用。答案就是 JDK 动态代理。但动态代理不是凭空生成的,Configuration里必须先有这个接口的注册记录。

启动阶段,XMLConfigBuilder解析完<mappers>节点后,会调用configuration.addMapper(Class)。这个方法内部就是往MapperRegistry.knownMappers这个 Map 里放东西,key 是接口的 Class 对象,value 是这个接口对应的MapperProxyFactory。如果你用的是@MapperScan,Spring Boot 的自动配置最终也会走到MapperScannerConfigurer,再由它调addMapper。到了这一步,接口和代理工厂的对应关系就算“登记在册”了。

注意一个细节:knownMappers的 key 是Class<?>,value 是MapperProxyFactory<?>。也就是说,注册的粒度是 Mapper 接口本身。一个UserMapper对应一个MapperProxyFactory,这个工厂没有状态,可以被多个SqlSession共用。正因为它是无状态的,MyBatis 才能做到每次getMapper()都生成一个轻量的代理对象,而不担心线程安全问题。

2.2 代理生成:getMapper()到Proxy.newProxyInstance

当你调用sqlSession.getMapper(UserMapper.class)时,DefaultSqlSession会把请求转给Configuration.getMapper(),最终落到MapperRegistry.getMapper()。这个方法的逻辑非常直白:从knownMappers里根据接口类型找到MapperProxyFactory,然后调factory.newInstance(sqlSession)。

MapperProxyFactory.newInstance源码其实就两步:

public T newInstance(SqlSession sqlSession) { final MapperProxy<T> mapperProxy = new MapperProxy<>(sqlSession, mapperInterface, methodCache); return newInstance(mapperProxy); } private T newInstance(MapperProxy<T> mapperProxy) { return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy); }

这里有两个关键点。第一,代理对象是 JDK 动态代理生成的,所以 Mapper 必须是接口,不能是类;如果你试图把 Mapper 做成一个 class,MyBatis 会直接报错。第二,MapperProxy实现了InvocationHandler,它是所有方法调用入口,后面所有逻辑都围绕这个类展开。这个设计其实很简单,无非是“接口 + 代理 + 拦截”的组合,但当你知道MapperProxy里还缓存了每个Method对应的执行器时,就会明白 MyBatis 在性能上做了不少功夫——它没有每次都反射取方法,而是用methodCache做了本地缓存。

2.3 方法分派: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); }

先说第一条路:如果调用的方法是Object的方法,比如toString()、hashCode()、equals(),直接反射调用MapperProxy自己的方法,不走任何 MyBatis 逻辑。这里有个常见坑:有人在 Mapper 接口里定义了一个toString()方法,结果调用时永远拿不到 SQL 结果,反而返回了代理对象的字符串——原因就是你方法名恰好和Object的方法撞了。

第二条路是 Java 8 之后接口默认方法(default method)。cachedInvoker(method)返回的可能是DefaultMethodInvoker,它会通过反射调用接口 default 方法的原始逻辑。这个能力早期版本没有,后来加上是为了支持 Mapper 接口里写默认方法做公共逻辑。不过说句实在话,我还是建议不要在 Mapper 接口里写复杂的 default 方法,一旦里面混了 SQL 逻辑,调试起来会非常混乱。

第三条路是绝大多数情况:PlainMethodInvoker调MapperMethod.execute()。这个MapperMethod会在第一次调用时创建,然后被methodCache缓存,后面再调用同一方法就直接复用。一个MapperMethod相当于把“方法签名 + 对应 SQL 语句”的关系固定下来,它的内部由SqlCommand和MethodSignature两个内部类支撑。

2.4 MapperMethod 内部结构及参数解析

MapperMethod做的事情用一句话概括:把一次接口方法调用,翻译成一次SqlSession.insert/update/delete/select的调用。具体来说,SqlCommand负责从Configuration中通过statementId(通常是“Mapper接口全限定名.方法名”)找到MappedStatement,并拿到 SQL 类型;MethodSignature则解析方法返回类型、参数注解、RowBounds、ResultHandler等信息。

execute()的代码分支很长,但核心逻辑是这样的:INSERT、UPDATE、DELETE 类型直接调sqlSession.insert/update/delete,SELECT 类型则根据返回类型分流。如果返回List或数组,调selectList;如果返回Map,看@MapKey注解调selectMap;如果返回Cursor,调selectCursor;多个参数带RowBounds时还要先判断是否需要分页。总之,方法签名决定了它走哪条路径。

参数解析也在这里完成。MethodSignature.convertArgsToSqlCommandParam会调用ParamNameResolver.getNamedParams。它的逻辑是:如果方法有多个参数或带@Param注解,就构建一个ParamMap;如果没有注解且只有一个参数,直接返回该参数本身;同时,它还会兼容param1、param2这种位置参数名。这个机制解释了为什么你在 XML 里写#{id}能取到方法第一个参数——因为ParamNameResolver内部已经把参数索引和名字映射好了。如果想用真实参数名,编译时需要加-parameters参数,否则只能用arg0或param1这种默认名。

到了这一步,动态代理的谜底就算解开了:接口本身没有实现类,但每次调用方法时,JDK 代理把方法调用转成了MapperMethod.execute,再由它去调SqlSession的对应方法。面试时把这个链路从getMapper()讲到ParamNameResolver,基本可以镇住绝大部分面试官。

3. 一级缓存:绑定在 SqlSession 上的隐形势能

3.1 缓存本体与作用域

一级缓存其实是 MyBatis 里最容易理解,也最容易被忽略的缓存。它不需要任何配置,默认就开着,作用域是单个SqlSession。底层实现类是BaseExecutor里的localCache字段,类型是PerpetualCache,本质上就是一个HashMap。

注意一点:一级缓存挂在Executor上,不是挂在某个 SQL 语句上。这意味着同一个SqlSession里,只要执行的是同一条 SQL(参数、分页条件等完全一致),第二次就会直接命中缓存,不再发起数据库查询。你可以用日志验证:第一次查会打印Preparing和Parameters,第二次查只打印缓存命中日志,没有 JDBC 相关的输出。

为什么 MyBatis 要在 Executor 这一层做一级缓存?核心目的是解决“同一个会话内重复查询”造成的无谓数据库开销。这在一次请求里多次查询同样数据时特别明显,比如循环里查字典表、权限数据等。但它也有一个天然限制:SqlSession之间不共享,会话关了,缓存就没了。

3.2 CacheKey 的构成与命中条件

一级缓存不能拿 SQL 字符串直接当 key,否则参数不同就会串数据。MyBatis 使用的CacheKey是一个精心设计的对象,BaseExecutor.createCacheKey会往里面累加多个维度:

  • MappedStatement.getId(),也就是 statementId
  • RowBounds的 offset 和 limit
  • BoundSql.getSql(),即最终 SQL 文本
  • 所有参数值(通过ParameterMapping逐个 update 进 CacheKey)
  • Configuration的环境 ID

CacheKey底层维护了一个updateList,每次update()都会把对象加进列表,hashCode()是这些元素的组合计算结果。所以哪怕 SQL 文本一样,只要参数值不同,CacheKey 就不同,会正常查库。这也是很多人以为自己“开了缓存”却没命中的原因之一——其实只是参数变了,key 自然对不上。

3.3 缓存什么时候失效

一级缓存的失效时机有几个。首先,任何 UPDATE、INSERT、DELETE 语句执行时,BaseExecutor.update都会先调用clearLocalCache(),这是为了避免同一个会话内读到自己的脏数据。其次,commit()、rollback()、close()都会清空一级缓存。这一点容易被忽略:你一个大事务里执行了几次查询,如果中途commit了一次,后面再查同样的数据,其实已经不会命中缓存了。

还有一个特殊配置:localCacheScope。默认值是SESSION,也就是整个 SqlSession 生命周期内有效;如果设置成STATEMENT,SQL 执行完就立刻清空缓存,等价于“仅本次查询内缓存”。这在某些一致性要求高的场景里可以用来规避“同一个会话内二次查询拿到旧数据”的问题。不过一般项目很少动这个参数,知道有它就行。

3.4 实战中的几个坑

先说对象引用共享的坑。一级缓存命中时返回的是同一个对象引用,不是拷贝。第一次查询返回一个User对象,第二个相同的查询命中缓存后,拿到的还是原来那个对象。如果你在中间修改了它的某个字段,第二次拿到的对象就是被改过的。这个行为在多线程场景下特别危险,因为对象可能被多个线程同时读写。解决办法要么是避免使用一级缓存命中后的对象做修改,要么用localCacheScope=STATEMENT让每次查询都去数据库重新获取。

再说 Spring 环境下的“假缓存”。很多人在 Spring Boot 里发现一级缓存好像没生效,每次查询都有 SQL 日志。原因在于SqlSessionTemplate的动态代理机制:如果没有开启事务,它每次执行都会从SqlSessionUtils获取一个新的SqlSession,执行完就关闭,一级缓存自然就没机会跨调用共享。只有在同一个 Spring 事务里,多个 Mapper 方法才会共用一个 SqlSession,一级缓存才会真正发挥作用。所以面试题里常问“Spring 下 MyBatis 一级缓存什么时候生效”,标准答案就是:在同一个事务范围内生效,没有事务时基本不生效。

最后一个实战小技巧:如果你在同一个 SqlSession 里确实想强制绕过一级缓存查库,可以调sqlSession.clearCache()清掉本地缓存,或者直接用SqlSessionTemplate配合ExecutorType.SIMPLE重新获取一条新会话。

4. 二级缓存:namespace 级共享缓存

4.1 开启方式和基本配置

二级缓存的作用域比一级缓存大得多:它是 Mapper namespace 级别的,可以在多个 SqlSession 之间共享。要想启用,只需要在 Mapper XML 里加一个<cache/>标签,或者在 Mapper 接口上加@CacheNamespace。

一个标准的<cache>配置长这样:

<cache eviction="LRU" flushInterval="60000" size="512" readOnly="false" blocking="false"/>
  • eviction是回收策略,默认 LRU
  • flushInterval是刷新间隔,单位毫秒,超过后缓存清空重建
  • size是缓存最大条目数,超出后按回收策略淘汰
  • readOnly表示缓存是否只读,默认 false
  • blocking表示是否启用阻塞缓存,防止并发击穿

开启二级缓存有一个硬性前提:所有存入二级缓存的对象必须可序列化,因为默认readOnly=false时,MyBatis 会使用SerializedCache做序列化拷贝,否则无法在多个会话之间安全共享。实际项目中经常遇到NotSerializableException,基本都是缓存对象没有实现Serializable导致。如果不想做序列化,可以设置readOnly="true",但这样多个会话会拿到同一个对象引用,并发读取时有一定风险。

4.2 CacheBuilder 与装饰器模式

到了源码层面,二级缓存不是简单的一个HashMap,而是一串装饰器的叠加。CacheBuilder.build()的组装顺序大致是:先创建基础实现,通常是PerpetualCache,然后根据配置依次加上LruCache、ScheduledCache、SerializedCache、LoggingCache、SynchronizedCache、BlockingCache。

每一层解决一个独立问题:LruCache基于LinkedHashMap的removeEldestEntry实现容量淘汰;ScheduledCache定时清空缓存;SerializedCache负责对象的序列化与反序列化,保证多个会话拿到的对象互不干扰;LoggingCache负责统计命中率并输出日志;SynchronizedCache给缓存的读写加了synchronized锁;BlockingCache在缓存 miss 时用锁让其他线程等待,避免大量请求同时打到数据库。这个设计是典型的装饰器模式,每一层都可以独立存在,也可以任意组合。

面试时如果被问“MyBatis 二级缓存为什么用装饰器模式”,可以这样回答:因为缓存策略是可组合的,LRU、TTL、序列化、统计、并发控制这些关注点彼此独立,装饰器可以按需叠加,而不是把逻辑全部塞进一个类里。这个思路在你设计自己的缓存组件时也很值得借鉴。

4.3 CachingExecutor 与 TransactionalCache

二级缓存的读写不直接操作Cache实例,而是经过一层CachingExecutor。它是Executor的装饰器,当某个MappedStatement关联了 Cache 时,CachingExecutor.query会先尝试从TransactionalCacheManager(tcm)里拿数据。

TransactionalCache这个名字起得很准确:二级缓存的数据写入不是立即生效的,要等事务提交后才真正刷入底层缓存。查询时,数据先放进entriesToAddOnCommit这个临时集合,事务commit()时才能flushPendingEntries(),事务rollback()时临时数据直接丢弃。这就回答了一个高频面试题:为什么二级缓存要等事务提交才能被别的 SqlSession 看到?因为 CachingExecutor 设计上就是为了避免事务回滚后缓存里还残留脏数据。

所以一次查询的完整缓存链路是:先查二级缓存(CachingExecutor),没命中再进入BaseExecutor查一级缓存,还不行才真正执行 JDBC 查询。数据库查询结果会同时进入一级缓存和事务缓存,事务提交后再进入真正的二级缓存。理解这个顺序,对排查“缓存没生效”“缓存数据哪来的”这类问题都很有帮助。

4.4 Cache Hit Ratio 日志

二级缓存默认带LoggingCache,所以开启 DEBUG 日志后,你可以看到类似这样的输出:

Cache Hit Ratio [com.example.mapper.UserMapper]: 0.8571428571428571 Cache Hit Ratio [com.example.mapper.OrderMapper]: 0.0

这个命中率是LoggingCache在请求数达到一定阈值时打印的,用于衡量某个 namespace 的缓存利用情况。如果你发现某个 Mapper 的命中率长期是 0,要么是每次查询参数变化太大,要么缓存配置不对,要么缓存压根没开启。这个指标对调优非常有用,比你自己打点统计方便得多。

不过要提醒一句:Cache Hit Ratio只统计二级缓存,不统计一级缓存。一级缓存的命中日志在BaseExecutor的 DEBUG 日志里,形式是Cache Hit。两者的日志级别都是 DEBUG,别搞混了。

4.5 多表操作引发的脏读问题

二级缓存最大的坑就是多表操作导致脏读。原因很简单:二级缓存是 namespace 级别的,OrderMapper的缓存只管OrderMapper对应的 statement。假如OrderMapper的一个查询 JOIN 了sys_user表,当你通过UserMapper更新了用户数据后,OrderMapper的缓存不会自动失效,于是再次查询订单时,关联的用户信息还是老的。

定位这类问题其实很明显:日志里某个 Mapper 的命中率很高,但业务数据显示不一致。解决办法主要有三种:第一种,给涉及多表更新的语句加flushCache="true",强制清空当前 namespace 缓存;第二种,用<cache-ref namespace="...">把多个 Mapper 的缓存指向同一个 namespace,保证关联更新能互相感知;第三种,对一致性要求高的场景干脆不开二级缓存,只依赖一级缓存。第三种方案在数据变更频繁的系统中反而是最稳妥的,二级缓存真正适合的是低频变更、高频查询的字典表和配置类数据。

5. 实战:缓存调优与问题排查

5.1 如何确认缓存真的命中了

实战中判断缓存是否命中,最靠谱的方法是看日志。把 Mapper 接口对应的日志级别调成 DEBUG,观察一次查询的输出。如果二级缓存命中,会出现:

Cache Hit Ratio [com.example.mapper.UserMapper]: ...

如果是一级缓存命中,会出现:

Cache Hit

如果既有 Preparing 又有 Parameters 日志,说明最终走了数据库。有人会拿“查了一次数据库但接口调了两次”来判断缓存生效,其实不准确——也有一级缓存或二级缓存命中的情况,但完全没有 JDBC 日志,才是真正命中的铁证。

这里要特别提醒一个细节:分页插件(PageHelper)会拦截 Executor 的 query 方法,它对原始 SQL 做了改写,执行了 count 和 limit 两次查询,这会破坏二级缓存对相同语句的命中效果。所以分页相关的接口,缓存命中率天然会比较低,这不是配置问题。

5.2 自定义缓存接入 Redis

二级缓存并不局限于本地内存。你可以实现org.apache.ibatis.cache.Cache接口,把缓存数据放到 Redis,这样多个应用实例之间就能共享缓存。下面是一个简化版的RedisCache:

public class RedisCache implements Cache { private final String id; private RedisTemplate<Object, Object> redisTemplate; public RedisCache(String id) { this.id = id; } @Override public String getId() { return id; } @Override public void putObject(Object key, Object value) { getRedisTemplate().opsForHash().put(id, key.toString(), value); } @Override public Object getObject(Object key) { return getRedisTemplate().opsForHash().get(id, key.toString()); } @Override public Object removeObject(Object key) { return getRedisTemplate().opsForHash().delete(id, key.toString()); } @Override public void clear() { getRedisTemplate().opsForHash().delete(id); } @Override public int getSize() { return getRedisTemplate().opsForHash().size(id).intValue(); } }

有几个关键点需要说清楚。第一,RedisCache必须有带String id参数的构造方法,因为CacheBuilder创建缓存实例时通过反射调用构造函数,找不到这个构造方法直接报错。第二,上面代码里的redisTemplate没法直接 Spring 注入,因为 MyBatis 创建 Cache 实例时还在 Spring 容器启动早期。我实际项目里的做法是让RedisCache实现ApplicationContextAware,在setApplicationContext里手动拿RedisTemplate,避免 Bean 初始化顺序问题。第三,key.toString()用的是CacheKey的字符串表示,在 Redis 里要设计好 key 前缀,避免多个环境或者多个 Mapper 之间 key 冲突。

使用自定义缓存时,配置和原来一样:

<cache type="com.example.cache.RedisCache" readOnly="true" flushInterval="600000" size="1024"/>

这里readOnly推荐设置成true,因为 Redis 本身已经做了一层序列化,你再让 MyBatis 用 Java 序列化包装一次,会在 Redis 里存出乱七八糟的二进制 key-value,排查问题非常痛苦。既然自己接管了缓存存储,序列化方案就统一由你自己掌控,反而更干净。

5.3 常见问题与排查速查

我整理了实战里比较容易踩的几个点,按“现象、原因、解法”列了一张表,方便你排查。

现象可能原因排查与解法
二级缓存命中率始终为 0Mapper 上没有配置<cache/>,或配置了但查询没有commit确认 XML 或注解里是否需要开启useCache,并确认事务最终 commit
命中缓存后返回对象被其他线程修改readOnly="true"返回同一对象引用改为readOnly="false"走序列化拷贝,或业务层避免修改缓存对象
更新了一张表,另一张表查询还是旧数据两个 Mapper 的 namespace 不同,缓存互相不感知使用<cache-ref>共享 namespace,或对写语句加flushCache="true"
序列化异常NotSerializableException缓存对象没实现Serializable,但readOnly是 false让 POJO 实现Serializable,或者设置readOnly="true"
Redis 自定义缓存里出现乱码 keyMyBatis 的SerializedCache把对象序列化成了二进制readOnly="true",由自定义 Cache 自行控制序列化格式
热点 key 穿透,数据库被打爆二级缓存 miss 时大量请求同时落到数据库配置blocking="true",或自己在业务层做单飞缓存
Spring 中一级缓存似乎失效非事务环境下每次 Mapper 调用拿到的是新 SqlSession在事务方法内多次查询验证一级缓存,或者不要依赖一级缓存做跨调用共享

5.4 缓存场景的取舍建议

最后聊点实际的取舍。很多同学看完源码容易走向两极:一种是觉得缓存是银弹,所有查询都开二级缓存;另一种是被脏读坑过一次后,干脆把所有缓存都关了。其实两种都过于极端。

我对缓存的使用原则很简单:先在业务层面区分数据的特点。字典表、配置类数据、状态枚举这类“低频变更、高频读取”的数据,二级缓存非常合适;订单、库存、资金流水这类变更频繁、一致性要求高的数据,二级缓存是负资产,关了反而省心。一级缓存在事务内的作用确实显著,但非事务环境下基本可以当它不存在。

另外,如果你做的是分布式系统,本地缓存(无论一级还是二级)都要谨慎使用,因为每个节点是独立的,你无法保证多节点缓存的一致性。这种情况我建议要么直接用 Redis 做统一缓存层,要么在应用层根据业务诉求自己设计本地缓存 + 失效策略,而不是完全依赖 MyBatis 的二级缓存。MyBatis 把二级缓存的扩展点留出来了,但接不接、怎么接,责任还是落在你手上。

从我维护过的项目看,真正的线上问题几乎都不是“缓存没用”,而是“不知道缓存的存在”。理解了它的生命周期、作用域和失效条件,你自然能在合适的场景做出合适的选择。这些小坑如果有心,写进自己的排查清单里,下次遇到同类问题,扫一眼日志就能快速定位,省下的时间绝对值回票价。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询