项目标题叫“MyBatis二级缓存相关配置”网上搜了一圈相关的热词也不少——SpringBoot集成、分页插件、Cache命中率、脏读、序列化异常基本把这个话题里的坑都点了一遍。我早年做门户网站的时候首页菜单、栏目树这类数据一天都变不了几次却每次请求都要查库数据库压力全耗在这种重复劳动上。后来上了二级缓存效果立竿见影。这篇文章就把二级缓存从原理、配置到排障完整拆一遍新手上手能少走不少弯路老手也可以当配置速查来用。1. 配置二级缓存之前先想清楚这四件事1.1 二级缓存到底解决了什么问题在聊配置之前得先摆正一个认知二级缓存不是“性能万金油”它是一个有明确适用边界的读写优化方案。MyBatis的查询链路是这样的Mapper接口 → SqlSession → Executor → 数据库。其中一级缓存作用在SqlSession级别同一个SqlSession里执行两次相同查询第二次直接走本地缓存不再发SQL到数据库。但SqlSession通常是短生命周期的每次请求都会新建请求结束就关闭一级缓存相当于“一次性纸杯”用完就扔。二级缓存则作用在SqlSessionFactory级别也就是namespaceMapper级别。它跨SqlSession共享只要SqlSessionFactory不销毁缓存数据就一直有效。说得更直白一点多个请求、多个线程、不同的SqlSession在访问同一个Mapper的查询结果时可以命中同一份缓存数据。所以二级缓存解决的是“大量重复查询集中在少数几个Mapper上”的场景。典型的就是基础数据类查询——省份表、字典表、菜单树、配置项、商品分类这些数据读多写极少一次查询全表也才几十行但每秒被访问几十上百次。如果每个请求都去数据库里翻一遍再走一遍ORM映射数据库连接池再大也有扛不住的时候。我见过一个真实案例某后台管理系统的导航菜单用了嵌套查询加权限过滤单次查询要20毫秒高峰期每秒被调用30次光这一个查询就给数据库带来不小的压力。加上二级缓存之后第一次查完数据进缓存后面29次直接内存命中耗时降到1毫秒以内数据库那边几乎零压力。这个提升是实打实的。但同时也得分清二级缓存适合“读多写极少、数据量小、一致性要求不极端”的场景。如果你的Mapper对应一张频繁插入更新的业务大表或者查询条件千变万化、每次SQL都不一样二级缓存命中率会低到没意义反而还要承担缓存维护和序列化的开销属于典型的吃力不讨好。1.2 一级缓存和二级缓存的分工很多人一上来就问“二级缓存怎么开”实际上如果搞不清它和一级缓存怎么配合后面出了问题根本无从下手。两条缓存各自的作用域用一张表就能说清楚维度一级缓存二级缓存作用范围SqlSession级别SqlSessionFactorynamespace/Mapper级别生命周期随SqlSession销毁随SqlSessionFactory存活共享范围单个会话内跨会话、跨线程共享默认状态开启关闭查询命中前提相同SqlSession、相同SQL、相同参数相同namespace、相同SQL、相同参数失效时机SqlSession关闭、执行更新、手动clearCache执行更新、Flush Cache、缓存过期、LRU淘汰理解这两者分工有一个关键逻辑MyBatis的缓存查询顺序是先二级缓存再一级缓存最后数据库。也就是说一个查询进来先看当前Mapper的二级缓存里有没有东西没有再看当前SqlSession的一级缓存也没有才真正去数据库查。查到结果后先放进一级缓存再根据配置决定是否同步到二级缓存。这条链路解释了为什么有些时候“开了二级缓存但第一次查询还是很慢”——第一次查询本来就要查库快慢取决于数据库性能二级缓存只保障“从第二次开始变快”。同时也解释了为什么“明明同一个Mapper换个SqlSession查一下速度差异巨大”——如果二级缓存没配好一级缓存被重复创建销毁缓存根本积累不起来等于白忙活。1.3 哪些场景适合用哪些别碰根据我的实操经验可以用三条规则来判断一个Mapper要不要开二级缓存。第一条查询频率高、结果集小、变化频率极低。典型就是字典表、枚举表、省份城市表、网站全局配置、商品类目树。这类数据最适合缓存命中率能稳定在90%以上。第二条查询结果集稳定不依赖用户身份、时间戳、随机数这类动态维度。一个查询如果把当前登录用户ID写进SQL参数那每个用户查出来的结果都不一样缓存里存了A用户的菜单B用户来查根本用不上命中率自然就上不去。第三条该Mapper的写操作极少或者写操作可以接受“最终一致”。二级缓存的失效机制很简单——只要Mapper上执行了新增、修改、删除MyBatis会清空该namespace下的整块缓存。但如果你的Mapper对应一张秒级写入的表等于缓存刚建好就被清掉命中率起不来还平白增加缓存管理开销。有几个场景我强烈不建议开二级缓存多表Join的结果集。原因后文会专门讲这里是脏读的高发区。还有分布式部署且未做集中式缓存的场景因为MyBatis自带二级缓存是节点本地的多实例部署时各节点缓存各自为政数据同步容易出问题更建议换用Redis这类外部缓存插件比如mybatis-redis这类扩展。2. 二级缓存配置的核心细节2.1 总开关与Mapper级cache标签二级缓存配置不是写一行配置就能生效的它有一个递进式的开关链路全局开关 → namespace开关 → 单条SQL开关。任何一层没开缓存都不会生效。首先要开全局开关在mybatis-config.xml里settings setting namecacheEnabled valuetrue/ /settingscacheEnabled默认值其实是true所以说它是个“总开关”并不完全准确。它管的是全局层面是否允许使用缓存如果这里配成了false下面所有Mapper的缓存配置全部作废。然后是Mapper这一层在Mapper XML文件里加一个cache标签cache evictionLRU flushInterval60000 size512 readOnlyfalse/cache标签加在XML的mapper元素内部放在所有select|/insert|等语句标签之前即可。它做几件事创建该namespace的缓存对象、绑定缓存策略、设定缓存大小和过期机制。配置生效时MyBatis会在初始化阶段解析这个标签并把缓存绑定到对应的Mapper代理对象上。如果你用的是注解式开发不需要写XML那可以在Mapper接口上直接加CacheNamespace注解效果类似。需要注意一点cache标签里还可以嵌套property来定制缓存实现比如指定自定义Cache类。但绝大多数场景下用内置的PerpetualCache就够了。2.2 四个高频参数怎么选cache标签里的四个参数我认为是配置的核心选择逻辑值得展开说。eviction缓存淘汰策略MyBatis内置了四种淘汰策略默认是LRU策略全称行为LRULeast Recently Used淘汰最久未使用的数据默认FIFOFirst In First Out按入缓存顺序淘汰SOFTSoft Reference基于JVM软引用内存不足时回收WEAKWeak Reference基于JVM弱引用GC时回收默认的LRU我建议不动。FIFO适合“数据越新越重要”的业务但MyBatis的FIFO一旦缓存满了就无脑踢掉最早的数据不考虑访问频次实际上很少用。SOFT和WEAK依赖JVM的GC机制行为不可控生产环境慎选。LRU覆盖了绝大多数“缓存空间有限、但部分数据高频访问”的场景按我经验90%的配置都不需要改eviction。flushInterval刷新间隔毫秒表示缓存多长时间自动清空一次。不配置的话缓存只会在执行DML语句时被动刷新。我建议对基础数据类Mapper设置一个合理的刷新周期比如5分钟、10分钟——即使数据库端被外部工具改了数据最多延迟一个周期就能看到新数据。但注意设置了flushInterval后它和“DML清空缓存”是同时生效的两者谁先触发算谁。提示flushInterval的单位是毫秒60000即60秒600000即10分钟。别把60000当成秒配那会导致缓存1分钟一清命中率难看。size最大缓存对象数量这个配置指的是缓存中最多存放多少个CacheKey对应的结果对象。默认值是1024。需要注意它不是“最大字节数”而是“对象个数”。一个查询结果即使是一万行的List只要它是同一个查询出来的一个对象也只占一个名额。size配多大取决于查询的缓存键有多少种组合。如果你的Mapper上有10条不同SQL每条有5组常用参数那最多也就50个缓存键size配个512绰绰有余。但如果Mapper上的查询条件组合非常多像动态SQL拼出几百种不同SQL那size就要谨慎评估太小了导致频繁淘汰太大了占用堆内存常见的做法是结合CacheKey的数量估算。readOnly只读缓存这是最容易踩坑的参数。readOnlytrue时MyBatis直接把缓存里的对象引用返回给调用方性能好但调用方如果修改了这个对象缓存里的数据也被改了。readOnlyfalse时MyBatis会序列化缓存对象然后反序列化出一个副本返回调用方拿到的是副本怎么改都不影响缓存。这个参数怎么选取决于你是否信任所有调用方“只读不改”。我强烈建议生产环境设成readOnlyfalse。虽然多了一次序列化/反序列化开销但这开销远小于一次数据库查询而且能避免大量莫名其妙的数据问题。除非你对性能有极致的追求并且能确保所有拿到缓存对象的地方都不会修改它。2.3 实体类序列化这一关readOnlyfalse时MyBatis需要把对象序列化后再反序列化。这就带来一个硬性要求实体类必须实现java.io.Serializable接口。这个要求很多新手会忽略。第一次查询一切正常第二次查询直接抛异常日志中间一段SerializationException: ...原因就是实体类没序列化。解决办法也很简单实体类加个implements Serializable即可建议同时加上serialVersionUIDpublic class SysDict implements Serializable { private static final long serialVersionUID 1L; private Integer id; private String dictCode; private String dictValue; }这里有个进阶细节如果实体类里有List、Map等成员变量这些成员变量的泛型对象也必须实现Serializable。MyBatis用的是Java原生序列化它会递归序列化整个对象图任何一个环节不可序列化都会抛异常。3. 一个可复用的二级缓存配置实例3.1 Spring Boot MyBatis项目里的三层配置纸上谈兵没意思我直接给一套可复用的完整配置模板。以Spring Boot为例我假设项目里已经集成了MyBatis和MySQL。第一步确认application.yml里MyBatis相关配置。Spring Boot下MyBatis的全局配置都不需要额外设置因为cacheEnabled默认为true。如果你之前没动过不需要特意配置mybatis: mapper-locations: classpath:mapper/*.xml configuration: cache-enabled: true这里把cache-enabled显式重申一下方便后来排查的人一眼确认全局开关状态。第二步在需要缓存数据的Mapper XML上配置cache。以字典查询为例mapper namespacecom.example.mapper.DictMapper cache evictionLRU flushInterval600000 size512 readOnlyfalse/ select idlistAllDicts resultTypecom.example.entity.SysDict SELECT id, dict_code, dict_value FROM sys_dict WHERE status 1 /select /mapper第三步确保SysDict实体类实现序列化。前面已经展示过不再赘述。这套三步配置完成后DictMapper下的查询就有了真实的二级缓存能力。验证方法前文提过准备两个SqlSession或两个请求发起相同查询第二次命中缓存耗时显著下降。3.2 Mapper层面与注解式配置有一个细节很多人不知道cache标签只作用于它所在的Mapper XML对应的namespace。也就是说如果你有10个Mapper只有加了cache的那几个有二级缓存。其他Mapper该查库还是查库。这一点既是优点也是陷阱。优点是精细控制不浪费缓存空间陷阱是如果数据库查询压力普遍高只缓存一两个Mapper整体性能提升有限需要理性评估哪些Mapper值得加。如果你用的是纯注解开发不写XML那在Mapper接口上加CacheNamespace注解CacheNamespace(eviction LruCache.class, flushInterval 600000, size 512) public interface DictMapper { Select(SELECT id, dict_code, dict_value FROM sys_dict WHERE status 1) ListSysDict listAllDicts(); }CacheNamespace和XML里的cache效果等价二选一不可同时使用否则会报“Cache namespace already exists”之类的初始化错误。还有一层精细控制是SQL级的useCache属性。select标签上可以单独指定这条SQL是否使用缓存select idlistDicts resultTypeSysDict useCachefalse SELECT ... /selectuseCachefalse意味着这条SQL即使所在Mapper开了二级缓存也不参与缓存。这种用法适合“同Mapper下绝大多数查询可缓存但个别SQL条件多变、不适合缓存”的场景。3.3 与分页插件配合时的注意点这里必须聊到分页插件因为PageHelper在热门关键词里出现了很多次。分页插件会在你的SQL上做两个操作生成count查询、改写原SQL加上LIMIT。这对二级缓存的影响非常大。先说结论分页查询的结果不建议放进二级缓存。原因不复杂——分页插件改写的SQL带有具体的LIMIT和OFFSET比如LIMIT 0, 10和LIMIT 10, 10在MyBatis的CacheKey看来是两条完全不同的SQL各自缓存各自的结果。这本身没问题问题在于分页数据经常伴随着筛选条件变化缓存键组合会非常多命中率能跌到10%以下。更麻烦的是分页查询底层依赖于当前数据的总条数count查询。如果同一条数据被更新了而分页缓存还没失效用户看到的列表页会短暂处于“过期数据”状态。多数情况下可接受但如果是商品列表、订单列表这种对实时性有一定要求的场景体验并不好。我的建议是对于分页列表类查询在select上显式设置useCachefalse让它跳过二级缓存。只对非分页的基础数据查询开缓存。也许你会问那分页查出来的结果不缓存性能怎么保证答案是用数据库端的索引优化、连接池调优去解决分页查询本身就是“多条件 排序 范围扫描”类操作缓存的收益远不如基础数据查询那么大不值得为了省一次查询引入一堆缓存一致性问题。3.4 关联查询与cache-ref的正确姿势有一个高频需求两个Mapper做关联查询缓存的数据需要同步失效。举个例子OrderMapper查出订单的同时关联查了UserMapper的数据那用户数据被UserMapper更新了OrderMapper里的缓存却还留着旧数据。MyBatis对这个场景提供了cache-ref标签mapper namespacecom.example.mapper.OrderMapper cache-ref namespacecom.example.mapper.UserMapper/ /mappercache-ref的作用是让当前Mapper共享指定namespace的缓存。OrderMapper里的查询结果存的缓存是UserMapper名下的那一个UserMapper执行更新清空缓存的时候OrderMapper的缓存也被一起清掉。这个方案解决了“关联查询导致缓存数据不一致”的问题但代价是OrderMapper和UserMapper共享同一块缓存空间缓存容量要按两者总量一起规划不能简单地按单个Mapper的size来配。使用cache-ref有一点强烈建议共享缓存的多个Mapper要么都设置cache-ref指向同一个namespace要么都自己独立建缓存不要一个Mapper既被cache-ref引用又有自己的cache容易两头混乱。项目里如果关联查询比较多我会倾向于创建一个CommonCacheMapper作为“缓存统一注册中心”其他Mapper通过cache-ref共享它的缓存便于统一管理沿途所有相关数据的失效逻辑。4. 高频踩坑与排查实录4.1 坑一多表Join带来的脏读这是二级缓存配置里最典型的坑很多团队上线半年后才发现数据对不上。直说原因多表Join后缓存里存的是“Join结果”。比如订单表 JOIN 用户表查出订单列表缓存在OrderMapper里。这时如果用户表的某个手机号在UserMapper里被更新了UserMapper执行update会清空自己的缓存但它不会动OrderMapper的缓存。于是“订单列表里显示的旧手机号”会一直存在直到OrderMapper自己执行update或者flushInterval到期。解决思路有几种不用二级缓存缓存Join结果。非要用就把Join查询拆成多次单表查询单独缓存各单表数据由应用层组装。让关联的多个Mapper共享同一缓存利用cache-ref让更新所有关联表的Mapper都触发同一缓存的清空。用很短的flushInterval兜底比如30秒或60秒保证脏数据窗口不超过一个刷新周期。我个人的偏好是核心业务查询的Join结果一律不进缓存基础数据、慢变化维度数据才可以考虑。项目经理当场听完骂骂咧咧但上线三个月后报表对不上数的时候他就不再说话了。4.2 坑二序列化异常与readOnly误用这个坑前面已经提过序列化的硬性要求这里补充一个现实中的案例。有一次同事负责一个报表模块配了缓存后第一次查询正常第二次查询报错我们沿日志查下去发现异常发生在反序列化阶段类名是他自己写的一个扩展POJO里面有个BigDecimal属性本身是能序列化的但POJO没有继承Serializable。改完后问题解决。后面我又遇到一个更隐蔽的readOnly设成true缓存倒是快但某个工具类在拿到的对象上做了setStatus(1)之类的操作结果“改了缓存里的共享对象”后续所有线程拿到的都是改过的数据。这种问题排查起来极难因为它不会报错只是数据静默地错。排查这类问题先用二分法判断查询报序列化异常优先检查实体类及其成员是否实现Serializable。查询正常返回但数据被“神秘修改”优先检查readOnly是否误配为true再看看有没有代码对缓存对象做了修改。4.3 坑三缓存不生效的常见原因“我明明配了缓存为什么查询还是很慢日志里还能看到SQL输出”这个问题几乎每周都有人进群问。按概率排序常见原因有这么几个原因一全局开关没开或配置被覆盖。虽然在Spring Boot下默认为true但如果项目里自己手写了MybatisConfiguration类或者用了自定义的ConfigurationCustomizer就可能无意中把cacheEnabled设为false。原因二Mapper没有加cache或CacheNamespace。我见过有人只在mybatis-config.xml里写了setting namecacheEnabled valuetrue/以为全局打开就够了忘了每个Mapper还要单独配缓存自然不生效。原因三Mapper方法对应的SQL根本没走MyBatis的Executor。比如用了MyBatis-Plus的ServiceImpl自带方法或者用了SqlSessionTemplate直接查这些路径和自定义Mapper XML的缓存不是一回事。原因四查询动态SQL生成了不同的CacheKey。两个查询虽然“看上去”一模一样但因为传入参数的Map里多个空值、或动态条件导致SQL片段不同MyBatis生成的CacheKey就不同缓存自然无法命中。排查缓存是否生效最快的方法是打印命中率。MyBatis的Cache对象提供了getSize,getReadWriteRatio等方法你也可以在日志中开启MyBatis的debug输出看SQL日志里是否存在 Preparing:。如果一个查询第二次执行时没有Preparing:也没Parameters:那就是命中缓存了。4.4 问题排查速查表把日常运维中遇到的二级缓存相关问题整理成一张表方便直接对照排查问题现象可能原因排查/解决相同查询第二次仍然发SQLMapper未配置cache/CacheNamespace检查Mapper XML或接口注解相同查询第二次发SQL但无Prepare二级缓存命中属正常日志只输出Cache Hit查询报序列化异常实体类未实现Serializable实体类及嵌套类加Serializable缓存数据出现错乱/旧数据readOnlytrue且被调用方修改改readOnlyfalse并检查修改点多表关联数据长时间不同步namespace缓存隔离使用cache-ref共享缓存或缩短flushInterval缓存命中率极低查询参数组合过多、动态SQL评估是否适合缓存对不适合的SQL设useCachefalse缓存占用内存过高size设置过大或缓存对象过重调小size精简查询结果字段避免缓存超大关联对象分布式环境数据不一致自带缓存为本地内存缓存换Redis等集中式缓存实现4.5 一个记忆深刻的缓存事故复盘最后分享一个我之前处理过的现场问题。线上系统某天突然出现大量用户反馈“订单状态不变”经排查定位到OrderMapper配置了二级缓存cache的flushInterval设的很大等于只有在执行写操作时才会清缓存。问题出在一个对账单接口它读取订单状态进行统计却因为订单状态在另一个线程里被更新了而OrderMapper的缓存还停留在旧状态导致统计结果滞后。当时的修复从三个层面入手把读多写少的订单状态查询单独拆到一个新Mapper配置flushInterval30000确保刷新周期短原OrderMapper移除二级缓存回归实时查库对统计类SQL禁用缓存。那次之后我给自己定了一条铁律业务数据的查询开缓存前必须回答“脏数据窗口多长是可以接受的”答不上来就不开。基础数据、静态数据、配置数据的缓存可以大胆开因为脏数据窗口对用户无感涉及金额、状态、库存这类核心业务一旦缓存用不好数据可信度会大打折扣。5. 二级缓存与一级缓存、第三方缓存的搭配思考5.1 实测下来的一组合适配置如果项目里已经用了Redis很多人问能不能直接拿Redis做二级缓存。MyBatis自带缓存体系是支持自定义Cache实现的网上也有mybatis-redis这个扩展包。做法是写一个类实现org.apache.ibatis.cache.Cache接口把读写操作委托给Redis然后在cache标签里指定cache typecom.example.cache.RedisCache property namehost value127.0.0.1/ /cache这确实能解决“多实例缓存不一致”的问题——毕竟Redis是各节点共享的。但用Redis做二级缓存要注意两件事序列化方案要统一Redis里存的对象必须是可序列化的JSON序列化和Java原生序列化要选一套别混用缓存失效策略以Redis的过期时间为主注意flushInterval要和Redis的TTL匹配否则可能出现flushInterval已过期但Redis里数据还在的中间态。如果对性能敏感但又不追求多实例共享我实测最稳妥的方案是“大而静态的数据开本地二级缓存 小而动态的数据直接查库 需要跨节点共享的数据用Redis”。没有一劳永逸的配置关键还是按数据维度拆。5.2 二级缓存和一级缓存配合的注意事项一个容易被忽略的点是一级缓存命中时结果不会进入二级缓存。只有在查询真正走了数据库、查完数据写入一级缓存后MyBatis才会根据全局配置把它同步到二级缓存里。所以“二级缓存的命中”前提是至少一次真实的数据库查询之后其他会话的重复查询才能走二级缓存。Spring Boot MyBatis场景中Service层方法如果开启了事务在事务边界内多个Mapper查询拿到的是同一SqlSession一级缓存会被频繁使用。这种情况下二级缓存的价值更多体现在跨请求的共享。所以从整体看二级缓存不是“一级缓存的加强版”而是“跨会话的共享查询结果缓存”两者配合才能把热点查询挡住。5.3 配置维护中的实用习惯我的团队在二级缓存的管理上有几条约定简单但管用需要开二级缓存的Mapper统一在XML头部写清楚“缓存理由”和“预期脏数据窗口”方便后来人判断全局搜索cache或CacheNamespace可以快速定位所有开了缓存的Mapper每次发布前梳理哪些Mapper的缓存会因为数据库结构变更而失效必要时在发布脚本里加入“刷新缓存”步骤监控层面每季度统计一次各Mapper的缓存命中率和缓存大小如果命中率长期低于50%就打回重审。这些习惯谈不上复杂但长期下来能避免很多“没人知道为什么这个数据慢/这个数据旧”的线上排查。我个人的一点体会如果你问我配置二级缓存最重要的经验是什么我会说先想清楚哪条SQL值得缓存再动手写配置。二级缓存真正解决问题的方式是把“重复性强、变化极少”的查询挡住而不是把“变化快、个性化强”的查询硬塞进缓存。另外序列化和readOnlyfalse这条线不能省。很多线上事故都源于“图省事配了readOnlytrue”或者“实体类忘了序列化”宁可多花一次反序列化的开销也要保证缓存对象在传递过程中是独立的副本。这组配置和经验我用了好几年在多个项目里都经受了验证。如果读完还有疑问建议从最小的cache标签开始先在一个字典Mapper上跑通观察一两天命中率摸透了再往核心业务扩展。顺着这个思路往下走你对MyBatis整套缓存体系的理解会比我当年踩了一堆坑之后的理解更快更准。