资讯动态

MyBatis缓存机制与Mapper动态代理:SQL执行链路源码深度解析

发布时间:2026/10/6 17:38:36 来源:尧图企业网站定制
1. MyBatis 的执行链路一条 SQL 是怎么跑起来的聊 MyBatis 源码之前我习惯先把整条执行链路在脑子里过一遍。因为无论是 Mapper 动态代理还是一级二级缓存本质上都是挂在这条链路上的某个环节。你如果只盯着某一段源码看很容易陷入每个字都认识但不知道它在哪一步起作用的尴尬。一条 SQL 从业务代码发起到数据库返回结果中间大概经过这么几个角色SqlSessionFactory负责在启动时构建全局配置对象ConfigurationSqlSession是面向开发者的门面真正的执行逻辑由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。如果你用的是MapperScanSpring 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 MapperProxyT mapperProxy new MapperProxy(sqlSession, mapperInterface, methodCache); return newInstance(mapperProxy); } private T newInstance(MapperProxyT mapperProxy) { return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy); }这里有两个关键点。第一代理对象是 JDK 动态代理生成的所以 Mapper 必须是接口不能是类如果你试图把 Mapper 做成一个 classMyBatis 会直接报错。第二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/deleteSELECT 类型则根据返回类型分流。如果返回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()也就是 statementIdRowBounds的 offset 和 limitBoundSql.getSql()即最终 SQL 文本所有参数值通过ParameterMapping逐个 update 进 CacheKeyConfiguration的环境 IDCacheKey底层维护了一个updateList每次update()都会把对象加进列表hashCode()是这些元素的组合计算结果。所以哪怕 SQL 文本一样只要参数值不同CacheKey 就不同会正常查库。这也是很多人以为自己“开了缓存”却没命中的原因之一——其实只是参数变了key 自然对不上。3.3 缓存什么时候失效一级缓存的失效时机有几个。首先任何 UPDATE、INSERT、DELETE 语句执行时BaseExecutor.update都会先调用clearLocalCache()这是为了避免同一个会话内读到自己的脏数据。其次commit()、rollback()、close()都会清空一级缓存。这一点容易被忽略你一个大事务里执行了几次查询如果中途commit了一次后面再查同样的数据其实已经不会命中缓存了。还有一个特殊配置localCacheScope。默认值是SESSION也就是整个 SqlSession 生命周期内有效如果设置成STATEMENTSQL 执行完就立刻清空缓存等价于“仅本次查询内缓存”。这在某些一致性要求高的场景里可以用来规避“同一个会话内二次查询拿到旧数据”的问题。不过一般项目很少动这个参数知道有它就行。3.4 实战中的几个坑先说对象引用共享的坑。一级缓存命中时返回的是同一个对象引用不是拷贝。第一次查询返回一个User对象第二个相同的查询命中缓存后拿到的还是原来那个对象。如果你在中间修改了它的某个字段第二次拿到的对象就是被改过的。这个行为在多线程场景下特别危险因为对象可能被多个线程同时读写。解决办法要么是避免使用一级缓存命中后的对象做修改要么用localCacheScopeSTATEMENT让每次查询都去数据库重新获取。再说 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 evictionLRU flushInterval60000 size512 readOnlyfalse blockingfalse/eviction是回收策略默认 LRUflushInterval是刷新间隔单位毫秒超过后缓存清空重建size是缓存最大条目数超出后按回收策略淘汰readOnly表示缓存是否只读默认 falseblocking表示是否启用阻塞缓存防止并发击穿开启二级缓存有一个硬性前提所有存入二级缓存的对象必须可序列化因为默认readOnlyfalse时MyBatis 会使用SerializedCache做序列化拷贝否则无法在多个会话之间安全共享。实际项目中经常遇到NotSerializableException基本都是缓存对象没有实现Serializable导致。如果不想做序列化可以设置readOnlytrue但这样多个会话会拿到同一个对象引用并发读取时有一定风险。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会先尝试从TransactionalCacheManagertcm里拿数据。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 的命中率很高但业务数据显示不一致。解决办法主要有三种第一种给涉及多表更新的语句加flushCachetrue强制清空当前 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这样多个应用实例之间就能共享缓存。下面是一个简化版的RedisCachepublic class RedisCache implements Cache { private final String id; private RedisTemplateObject, 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 typecom.example.cache.RedisCache readOnlytrue flushInterval600000 size1024/这里readOnly推荐设置成true因为 Redis 本身已经做了一层序列化你再让 MyBatis 用 Java 序列化包装一次会在 Redis 里存出乱七八糟的二进制 key-value排查问题非常痛苦。既然自己接管了缓存存储序列化方案就统一由你自己掌控反而更干净。5.3 常见问题与排查速查我整理了实战里比较容易踩的几个点按“现象、原因、解法”列了一张表方便你排查。现象可能原因排查与解法二级缓存命中率始终为 0Mapper 上没有配置cache/或配置了但查询没有commit确认 XML 或注解里是否需要开启useCache并确认事务最终 commit命中缓存后返回对象被其他线程修改readOnlytrue返回同一对象引用改为readOnlyfalse走序列化拷贝或业务层避免修改缓存对象更新了一张表另一张表查询还是旧数据两个 Mapper 的 namespace 不同缓存互相不感知使用cache-ref共享 namespace或对写语句加flushCachetrue序列化异常NotSerializableException缓存对象没实现Serializable但readOnly是 false让 POJO 实现Serializable或者设置readOnlytrueRedis 自定义缓存里出现乱码 keyMyBatis 的SerializedCache把对象序列化成了二进制readOnlytrue由自定义 Cache 自行控制序列化格式热点 key 穿透数据库被打爆二级缓存 miss 时大量请求同时落到数据库配置blockingtrue或自己在业务层做单飞缓存Spring 中一级缓存似乎失效非事务环境下每次 Mapper 调用拿到的是新 SqlSession在事务方法内多次查询验证一级缓存或者不要依赖一级缓存做跨调用共享5.4 缓存场景的取舍建议最后聊点实际的取舍。很多同学看完源码容易走向两极一种是觉得缓存是银弹所有查询都开二级缓存另一种是被脏读坑过一次后干脆把所有缓存都关了。其实两种都过于极端。我对缓存的使用原则很简单先在业务层面区分数据的特点。字典表、配置类数据、状态枚举这类“低频变更、高频读取”的数据二级缓存非常合适订单、库存、资金流水这类变更频繁、一致性要求高的数据二级缓存是负资产关了反而省心。一级缓存在事务内的作用确实显著但非事务环境下基本可以当它不存在。另外如果你做的是分布式系统本地缓存无论一级还是二级都要谨慎使用因为每个节点是独立的你无法保证多节点缓存的一致性。这种情况我建议要么直接用 Redis 做统一缓存层要么在应用层根据业务诉求自己设计本地缓存 失效策略而不是完全依赖 MyBatis 的二级缓存。MyBatis 把二级缓存的扩展点留出来了但接不接、怎么接责任还是落在你手上。从我维护过的项目看真正的线上问题几乎都不是“缓存没用”而是“不知道缓存的存在”。理解了它的生命周期、作用域和失效条件你自然能在合适的场景做出合适的选择。这些小坑如果有心写进自己的排查清单里下次遇到同类问题扫一眼日志就能快速定位省下的时间绝对值回票价。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑