ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

MyBatis核心机制:Mapper动态代理与两级缓存源码解析

MyBatis核心机制:Mapper动态代理与两级缓存源码解析 面试官抛来一个很基础的问题你 Service 里注入了一个 Mapper 接口方法名写好了SQL 写在 XML 里但接口根本没有实现类这一调怎么就执行了紧接着又问同一个事务里同一个查询调两次走缓存吗一级缓存和二级缓存谁在前大多数平时写业务的人能用但真被问到就有点卡壳。这篇文章不绕圈子直接从源码拆 MyBatis 的 Mapper 动态代理、一级缓存和二级缓存的完整链路最后再给几个实战中排查缓存、打印 SQL、自定义缓存的操作既能当面试复习材料也能当项目排坑手册。1. Mapper 动态代理为什么接口没有实现类也能干活1.1 从 JDBC 到 Mapper 接口你少写了的那层胶水回想没用 MyBatis 的时候写一个数据库操作要经历什么获取 Connection创建 PreparedStatement手动 setParameter执行 executeQuery再手动遍历 ResultSet 转成对象最后还要关掉一堆资源。后来用 MyBatis你只需要定义接口方法、写一个 XML 里的 SQL或者用注解写一行 SQL然后在 Service 里直接注入 Mapper 调方法。中间那几千行样板代码全部消失了。但这引出第一个疑问接口不能 newMyBatis 到底往接口里塞了什么答案不是字节码增强插桩而是 JDK 自带的一个通行做法——动态代理。MyBatis 在启动阶段扫描 Mapper 接口给每个接口生成一个代理对象你调用接口方法实际上是调到了代理对象的 invoke 方法上。代理内部再做两件事定位这条 SQL然后交给 SqlSession 去执行。1.2 MapperProxy 与 Proxy.newProxyInstance动态代理的入口所有 Mapper 接口代理的源头在MapperRegistry。MyBatis 启动解析配置文件时会把你配置的每个 Mapper 接口通过registry.addMapper(Class)注册进去每个接口对应生成一个MapperProxyFactory。后面你需要这个接口的实例时工厂调用public T newInstance(SqlSession sqlSession) { MapperProxyT mapperProxy new MapperProxy(sqlSession, mapperInterface, methodCache); return newInstance(mapperProxy); } protected T newInstance(MapperProxyT mapperProxy) { return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy); }关键就在Proxy.newProxyInstance它要求目标必须是接口Mapper 恰好是接口然后动态生成一个实现这些接口的代理类。以后你调用userMapper.selectById(1)其实是调用到MapperProxy.invoke上。很多初学者会把 Spring AOP 和这个搞混。这里没有 CGLIB、没有 Aspect 切面就是 JDK 原生动态代理。也正因如此Mapper 接口在设计上天生不能被写成普通类只能接口否则就无法走 JDK 代理这条路。1.3 MapperMethod 的分发逻辑一个方法如何找到对应的 SQLinvoke方法并不直接执行 SQL它先过滤掉Object类的方法比如toString、hashCode、equals剩下的业务方法交给MapperMethod处理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); }MapperMethod构造时会解析两样东西SqlCommand和MethodSignature。SqlCommand做的是从方法名去Configuration.mappedStatements里找对应的MappedStatement并根据 SQL 类型分成 UNKNOWN、INSERT、UPDATE、DELETE、SELECT。MethodSignature做的是解析方法参数搞清楚有多少个参数、是否需要Param注解、返回类型是 List 还是单个对象、有没有分页参数等。真正执行时execute方法会按 SQL 命令类型分发switch (command.getType()) { case INSERT: { return sqlSession.insert(command.getName(), param); } case SELECT: { if (method.returnsVoid() method.hasResultHandler()) { sqlSession.select(command.getName(), param, method.getResultHandler()); return null; } else if (method.returnsMany()) { return sqlSession.selectList(command.getName(), param); } else { return sqlSession.selectOne(command.getName(), param); } } ... }到这里一个 Mapper 方法被映射成了一次普通的 SqlSession 调用。剩下的查询过程就进入 Executor 链路也就是缓存发挥作用的地方。提示如果你在自定义拦截器里想拿到 Mapper 方法的注解或方法名通常是通过Invocation.getArgs()[0]即 mappedStatement.getId()反推Mapper接口的完全限定名和方法名原理就是这里讲的动态代理链路。2. 一级缓存藏在 Executor 里的那张查重表2.1 一级缓存的数据结构PerpetualCache 与 CacheKey一级缓存官方叫 Local Cache作用域是 SqlSession 级别。大多数人知道它存在但不知道它长什么样。其实 MyBatis 的缓存实现很朴素缓存对象统一实现Cache接口一级缓存用的是PerpetualCache内部就是一个普通HashMappublic class PerpetualCache implements Cache { private final String id; private final MapObject, Object cache new HashMap(); }注意这个 HashMap 没有任何加锁保护所以一级缓存不是线程安全的它天然只服务于当前 SqlSession。那缓存 key 是什么不能简单拿 SQL 字符串做 key因为同一个 SQL 传不同参数就是不同结果。MyBatis 封装了一个CacheKey核心字段包括MappedStatement 的 id、查询 SQL、参数对象、RowBounds 偏移量甚至还会往里追加一些环境标识。最终生成一个 hash 值作为 HashMap 的 key。所以“同一条 SQL 但参数不同”“同一个方法但 offset/limit 不同”都会被识别为不同缓存项。2.2 什么情况命中、什么情况失效一次完整查询的判定过程一级缓存的查询动作发生在BaseExecutor.query。这段逻辑是整个缓存体系的心脏值得记清楚先从 BoundSql 和参数构建出 CacheKey。查一级缓存localCache.getObject(key)。如果有值直接返回如果没值进入数据库查询queryFromDatabase查到结果后放进localCache。返回结果前如果当前处于嵌套查询中还要把结果保存到localOutputParameterCache这是处理存储过程输出参数的细节。那么什么时候清空四个时机执行了任意 insert/update/delete。因为写操作后数据变了再拿着旧缓存没意义MyBatis 默认在写操作前执行clearLocalCache()。手动调用sqlSession.commit()、rollback()或close()。在select标签上显式设置flushCachetrue每次查询前先清缓存。查询涉及嵌套结果映射且localCacheScope设置为 STATEMENT 时每条语句结束后清缓存。这里有个容易踩的坑一级缓存默认就是开启的不需要任何配置。同一个 SqlSession 里前一个 SQL 查过的数据下一次完全相同的查询直接命中缓存根本不走数据库。看起来是好事但如果你的业务场景是“同一个事务里先查一次然后别的系统改了库你再查一次想拿最新数据”拿到的还是旧值。这不是 bug是缓存语义如此。2.3 最容易误解的一点Spring 整合后一级缓存并不是全程有效单独用 MyBatis 时SqlSession 生命周期由你控制一级缓存的行为很好理解。但到了 Spring/Spring Boot 中事情就变了你的 Service 通常没开事务也没手动拿 SqlSession一切靠SqlSessionTemplate和SqlSessionInterceptor代理。SqlSessionTemplate内部每次调用方法会通过SqlSessionUtils.getSqlSession()获取 SqlSession。如果你当前没有 Spring 事务这个方法会新建一个 SqlSession用完后立刻 close。两次独立的 Mapper 调用其实是两个不同的 SqlSession一级缓存完全不共享等于每次查询都重新查库。一旦 Service 方法加上Transactional情况就不同了Spring 事务管理器会把 SqlSession 和事务绑定在当前线程上整个事务范围内的多次查询复用同一个 SqlSession一级缓存这才真正生效。所以你在网上会看到两种矛盾的说法有人说 MyBatis 一级缓存默认开启很好用有人说自己试了发现没用。两种说法都对区别就在于是不是处在 Spring 事务中。这也是一个非常经典的面试考点。提示如果在 Spring 环境里你确实想模拟“每次查询强制走库”最干净的办法是在对应的 select 标签上加flushCachetrue而不是幻想着关掉一级缓存本身。MyBatis 虽然给了localCacheScopeSTATEMENT这个配置但它清缓存的动作在 Spring 无事务环境下意义不大因为 SqlSession 本身已经是全新的了。3. 二级缓存namespace 边界上的共享仓库3.1 开启二级缓存的条件cacheEnabled 与 标签缺一不可二级缓存作用域是 namespace也就是一个 Mapper 接口/XML 文件对应一个缓存仓库。网上不少人搞混一件事mybatis.configuration.cache-enabled默认是 true就以为默认开了二级缓存。实际上cacheEnabled只决定Configuration.newExecutor时要不要给你的 Executor 套上CachingExecutor装饰器。真正的开关还有一道你的 Mapper XML 里必须写了cache标签或者通过注解CacheNamespace启用。两道闸都打开后查询链路就变成CachingExecutor先查二级缓存。二级缓存没命中进入BaseExecutor查一级缓存。一级缓存也没有查数据库。两个缓存的作用域差异决定了一级缓存是会话私有的二级缓存是 namespace 内多个 SqlSession 共享的。这也是它名字里“二级”的意义——跨会话共享。3.2 TransactionalCache为什么要等事务提交才真正写入二级缓存最容易让人想当然的地方是写入时机。看CachingExecutor.query源码命中二级缓存直接返回没命中查询数据库后它并不会立刻把结果放进二级缓存而是先放进一个TransactionalCache的临时区。TransactionalCache内部有两个关键结构entriesToAddOnCommit本事务内准备写入二级缓存的数据先放这里。entriesMissedInCache本事务内查过但没命中的 key 也先记下来提交时把这些 key 标记为“缓存了空值”。只有当 SqlSession 提交时TransactionalCacheManager.commit()才会把entriesToAddOnCommit真正刷进二级缓存仓库。为什么这样设计因为如果你的查询还没提交就写进二级缓存另一个 SqlSession 可能读到一条事务未提交的数据这就是脏读。MyBatis 用一个“延迟写入提交时落库”的机制规避了这个问题。同时事务回滚时会调用rollback()把临时区数据直接丢弃避免已回滚的数据进入共享缓存。理解了这一点你就明白为什么有人遇到“明明查了数据二级缓存命中率却不高”——大概率是 SqlSession 一直没提交数据始终压在临时区里。3.3 readOnly、flushInterval、eviction 这些参数到底改了什么cache标签的典型配置长这样cache evictionLRU flushInterval60000 size512 readOnlytrue /每个参数背后都对应一个装饰器参数默认值作用对应实现evictionLRU缓存淘汰策略控制容量满时移除谁LruCache、FifoCacheflushInterval无定时清空缓存的毫秒数到期后下次查询会刷新仓库ScheduledCachesize1024最多缓存多少个对象引用外层装饰器控制readOnlyfalse返回缓存对象的策略SerializedCachereadOnly是最值得展开的参数。默认readOnlyfalse时二级缓存返回的对象不是原来那个实例而是通过序列化反序列化复制出来的副本。这就强制要求所有放进二级缓存的对象实现Serializable接口。好处是安全调用方随便改返回对象都不会污染缓存仓库里的原数据。坏处是性能开销大每次命中都要做一次序列化拷贝。readOnlytrue时MyBatis 直接返回缓存里那个对象引用性能高很多但调用方一旦修改对象仓库里的数据也跟着变。业务代码如果没意识到这一点很容易出现“缓存被悄悄改掉”的诡异问题。我的建议是只有确认对象不会被修改、且读取极频繁的场景才用readOnlytrue否则老老实实保持默认。3.4 多表关联场景下的脏数据为什么大家都说二级缓存要慎用二级缓存有一个先天短板它只知道 namespace不知道你 SQL 里到底碰了哪些表。举个例子OrderMapper里有个查询 join 了user表查出来的订单列表带着用户昵称这些数据被放进了OrderMapper的二级缓存。另一个UserMapper执行更新MyBatis 只清UserMapper自己的缓存OrderMapper缓存里那些旧昵称还在。下次再查订单命中的是脏数据。这就是二级缓存脏读的经典场景。官方文档对此的表述很含蓄但业内实践早就形成共识二级缓存适合字典表、配置表这类极少更新的只读数据不适合频繁变更的业务主表尤其不适合 join 查询很多的接口。如果一定要在多 Mapper 间共享一块缓存可以用cache-ref namespace其他Mapper全限定名/让两个 namespace 指向同一个缓存仓库但你要在业务上保证所有变更语句都会刷到同一个仓库否则还是躲不过脏读。提示项目里遇到“改了数据库但查询结果没变”的线上事故第一反应应该是看二级缓存。尤其是你最近给某个 Mapper 加了cache标签但没细想它 SQL 涉及哪些表事故率非常高。4. 实战排查缓存命中率、SQL 打印与自定义缓存扩展4.1 配置 SQL 打印日志实现类的选择与效果对比排查缓存问题第一步永远是看清 SQL 到底有没有真正发到数据库。MyBatis 的日志输出靠LogImpl配置常见两种# 控制台直接输出适合本地调试 mybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl # 接入 Slf4j适合生产环境按级别过滤 mybatis.configuration.log-implorg.apache.ibatis.logging.slf4j.Slf4jImpl logging.level.你的Mapper包路径debug选 Slf4j 更合理因为可以配合logback或log4j2把 SQL 日志单独输出到文件不至于刷爆应用日志。StdOutImpl就是往System.out直接 println本地调试方便上生产最好不要用日志格式不可控。日志打印的 SQL 是预处理语句能看到类似Preparing: SELECT * FROM user WHERE id ?以及Parameters: 1(String)。如果两次相同查询只有第一次出现Preparing第二次没了说明走了缓存每次都出现Preparing说明缓存没有命中或者当前会话一级缓存已经失效。4.2 手写一个带命中率统计的 Cache覆盖官方实现我排查缓存问题时最喜欢先看一眼命中率。MyBatis 自带的LoggingCache其实会记录命中率信息只要在配置里把缓存对应 namespace 的日志级别调到 DEBUG日志里就会输出Cache Hit Ratio。如果你不想依赖日志级别也可以自己写一个统计包装器public class HitRateCache implements Cache { private final Cache delegate; private final AtomicLong hits new AtomicLong(); private final AtomicLong total new AtomicLong(); public HitRateCache(Cache delegate) { this.delegate delegate; } Override public Object getObject(Object key) { total.incrementAndGet(); Object value delegate.getObject(key); if (value ! null) { hits.incrementAndGet(); } return value; } Override public void putObject(Object key, Object value) { delegate.putObject(key, value); } Override public double getHitRate() { long t total.get(); return t 0 ? 0 : (double) hits.get() / t; } Override public String getId() { return delegate.getId(); } // removeObject、clear、getSize 直接透传 }然后在cache配置里用cache type你的类全限定名/替换默认实现。不过要注意用type指定后MyBatis 默认那一套 LRU、Serialized 装饰器就被跳过了你需要自己在HitRateCache里把需要的策略组合起来比如再包一层LruCache和SerializedCache。最简单的做法是让自定义类内部持有一个组装好的Cache链条而不是全部自己重写。4.3 自定义 Redis 二级缓存Cache 接口的落地细节单机多线程下用内置缓存没问题但应用一上多实例问题立刻暴露每个实例的二级缓存互相独立一个实例更新了数据其他实例还是旧值。这需求本质是“分布式缓存”很多人都会动手实现一个 Redis 版本。MyBatis 的Cache接口非常小实现思路是public class RedisCache implements Cache { private final String id; private final RedisTemplateObject, Object redisTemplate; public RedisCache(String id) { this.id id; this.redisTemplate SpringContextHolder.getBean(RedisTemplate.class); } Override public void putObject(Object key, Object value) { redisTemplate.opsForValue().set(getKey(key), value, 30, TimeUnit.MINUTES); } Override public Object getObject(Object key) { return redisTemplate.opsForValue().get(getKey(key)); } Override public void clear() { // 按 namespace 前缀删除注意性能 } // 其他方法略 }落地时有几个坑值得提前说构造函数必须只有一个String id参数MyBatis 解析cache时靠反射调用它。普通RedisTemplate序列化方案要统一建议用 JSON 序列化或自定义序列化器否则存入的对象变更字段后反序列化容易出兼容性问题。Cache接口没有批量删除能力clear()只能按前缀扫描删除量大会有性能隐患。事务未提交时数据不写真实缓存这个语义由TransactionalCache保证自定义RedisCache只需要实现最基本的存取即可。待定如果你同时在配置里设置了flushIntervalScheduledCache装饰器依然会生效要留意定时清空和 Redis 过期时间会双重叠加。5. 面试与源码阅读常见问题背后的源码证据5.1 高频面试题盘点从动态代理到缓存失效这几年 MyBatis 相关的面试题翻来翻去就是下面几个把源码位置记清楚回答就能落到底为什么 Mapper 接口不需要实现类答案是 JDK 动态代理入口在MapperProxyFactory和MapperProxy核心是Proxy.newProxyInstance。一个 Mapper 方法怎么找到对应的 SQL入口是MapperMethod它会根据方法名从Configuration.mappedStatements里找到MappedStatement再根据 SQL 类型调用SqlSession对应方法。一级缓存和二级缓存有什么区别一级缓存作用域是 SqlSession存储结构是PerpetualCacheHashMap执行 insert/update/delete 或 commit/rollback/close 时清空二级缓存作用域是 namespace要cacheEnabled和cache同时开启才有效。二级缓存为什么会有脏数据缓存只按 namespace 组织无法感知 SQL 涉及的真实表多表 join 和跨 Mapper 更新时其他 namespace 的缓存不会主动失效。Spring 事务下缓存为什么表现不同无事务时每次 Mapper 调用新建和关闭 SqlSession一级缓存失效有事务时整个事务复用同一个 SqlSession一级缓存能命中。还有一道容易被问倒的缓存命中后返回对象和原对象是同一个吗取决于二级缓存的readOnly。readOnlyfalse时对象经过序列化拷贝不是同一个引用readOnlytrue时是同一个引用。一级缓存则直接返回原对象引用所以通过一级缓存拿到的对象被修改会影响下次命中结果。5.2 源码阅读路径从 Configuration 到 Executor 的调用链如果你只想读一遍关键源码就能把整个查询链路串起来我建议按这个方法走拿到源码后别从头读直接从一个真实查询跟踪调用链。步骤如下先看SqlSessionTemplate.selectList这里是 Spring 整合后进入 MyBatis 的入口。顺着SqlSessionUtils.getSqlSession看 Spring 怎么管理 SqlSession 生命周期。进入DefaultSqlSession.selectList它调用Executor.query。看CachingExecutor.query这里判断二级缓存是否存在、flushCache 是否开启。没命中后落到BaseExecutor.query这里操作一级缓存CacheKey 在这里构建。一级缓存也没命中进入SimpleExecutor.doQuery到这里才真正创建 PreparedStatement。整个链路一层层剥下来比单独看任何一个类都有效。建议自己写两个 Mapper、一个带cache、一个不带然后在CachingExecutor.query和BaseExecutor.query打上断点看两次相同查询分别停在哪里缓存行为立刻一目了然。提示读源码时有个很容易忽略的小地方——Configuration.newExecutor决定是否包上CachingExecutor而这个方法是被SqlSessionFactory创建时调用的。Spring Boot 里SqlSessionFactoryBean装配完毕后这个 executor 链就固定了后续改cacheEnabled配置需要重启才生效。最后再分享一个排查心得我自己的经验是遇到 MyBatis 缓存相关问题先别急着怀疑框架 bug按“二级缓存是否开启→当前有没有事务→SQL 是否真的发出→对象是否被修改”这个顺序排查。很多“缓存不生效”其实是 Spring 事务没开导致一级缓存作用域不符合预期很多“缓存脏数据”其实是多表操作撞上了 namespace 隔离的墙。另外给团队定一条规矩默认不开启二级缓存除非这个 Mapper 的 SQL 足够简单、数据更新频率足够低并且在代码评审时明确说明涉及哪些表、为什么安全。MyBatis 的缓存机制本身设计得相当精巧但精巧的默认值并不等于放之四海而皆准业务场景才是最终的尺子。希望这篇文章能把这条链路讲透下次再有人问起动态代理和两级缓存你至少能直接告诉他答案在源码的哪个位置。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表