MyBatis缓存机制与注解开发实战:从两级缓存到@CacheNamespace避坑指南
2026/9/13 3:00:35 网站建设 项目流程

有个事我琢磨了很久,后来才彻底想明白,绝大多数把 MyBatis 用到生产环境的团队,其实都没有搞清楚它的缓存机制,更没利用好注解开发带来的便利。很多人一听到缓存就觉得是 Redis 的事,一听到注解开发就觉得不够灵活、没法写复杂 SQL,然后一头扎进 XML 的汪洋大海里,用最原始的方式堆 SQL,最后项目越来越臃肿。这篇内容不打算重复文档里那些干巴巴的概念,而是基于真实项目踩坑的经验,把 MyBatis 的缓存到底怎么工作、注解式开发到底能用到什么程度、两者结合时有哪些隐藏的坑,全部拆开讲明白。

这篇文章适合正在用 MyBatis 写业务、被“查询结果怎么是旧的”“缓存到底开没开”这类问题折磨过的人,也适合面试前想把缓存原理彻底梳理清楚的人。我说的都是基于实际代码验证过的结论,不是理论推演。

1. 从整体上理解 MyBatis 的两级缓存

很多人第一次接触 MyBatis 缓存时,看到“一级缓存、二级缓存”两个词就直接晕了,因为网上的解释要么太抽象,要么只讲概念不给代码。其实用一句话就能记住:一级缓存是 SqlSession 级别的,二级缓存是 Mapper 级别的。这两个“级别”决定了缓存数据的存活范围和共享范围,也决定了你在开发时会在哪里遇到坑。

1.1 一级缓存的作用域与生命周期

一级缓存默认就是开启的,不需要任何配置。它的作用域是 SqlSession 的生命周期。也就是说,同一个 SqlSession 对象内,如果执行两次完全相同的查询语句,并且中间没有执行增删改操作,第二次查询会直接从缓存返回结果,不会再去数据库查一遍。

我用一个最直观的例子说明。假设你有一个用户表,里面有一条 id 为 1 的数据。你在一个 SqlSession 里执行:

try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user1 = mapper.selectById(1); User user2 = mapper.selectById(1); System.out.println(user1 == user2); // 结果是 true }

这里user1 == user2输出为 true,说明两次查询返回的是同一个 Java 对象,第二次压根没走数据库。想验证很简单,在 MyBatis 配置里打开日志,你会发现第二次查询没有打印 SQL。

一级缓存的生命周期有几个关键节点:

  • 创建 SqlSession 时开始。
  • 调用session.clearCache()时清空。
  • 执行任何 insert、update、delete 操作时会清空。
  • 只有查询操作才会把结果放入一级缓存。
  • 调用session.close()后缓存消失。

这些节点里,最容易被忽略的就是“执行增删改会清空缓存”。MyBatis 这么设计是有道理的,它的逻辑很简单:查询结果依赖的表数据一旦变动,缓存就可能不再是准确的最新数据,所以宁可全部丢掉下次重新查,也不能继续用旧的。

1.2 二级缓存的作用域与生命周期

二级缓存的作用域是 Mapper 的 namespace 级别。你能简单地将其理解为:同一个 Mapper 接口(或者同一个 XML mapper 文件)下的所有查询,在任何 SqlSession 中执行时,都可能共享同一份缓存数据。

二级缓存默认是关闭的,需要手动开启。想开启要满足几个条件:

  • 在 MyBatis 全局配置文件中设置<setting name="cacheEnabled" value="true"/>,这个默认值就是 true,所以实际上主要工作不在这。
  • 在具体的 Mapper XML 中添加<cache/>标签,或者在 Mapper 接口上添加@CacheNamespace注解。
  • 对应的实体类需要实现Serializable接口。

二级缓存的数据存储不在内存堆里直接保存对象引用,而是把查询结果序列化后存储。一个常见的误解是“二级缓存能跨 SqlSession 共享对象”,实际上跨 SqlSession 拿到的是序列化再反序列化的副本,所以对缓存对象的修改不会影响其他会话拿到的数据。

这里补一个很容易被忽视的点:二级缓存生效的前提是 SqlSession 提交或关闭时,一级缓存的内容才会写入二级缓存。如果你在同一个 SqlSession 里查询后不提交、不关闭,一直开着,那么二级缓存里是没有数据的。这涉及 MyBatis 的缓存写入机制,后面讲二级缓存配置的时候我会再细说。

1.3 为什么面试总爱问缓存:缓存失效与事务的纠缠

面试官爱问 MyBatis 缓存,不是因为它简单,而是因为它看似简单,实则藏着大量边界场景。最典型的几个怪问题:

  • 为什么同一个 SqlSession 里先查询再更新再查询,第二次查到的还是旧数据?
  • 为什么 Spring 管理的 Service 层里连续调用两次同一个 mapper 方法,一级缓存却没有生效?
  • 为什么开启了二级缓存后,多表关联查询会出现脏数据?

这些问题的根源都在于缓存的生命周期和事务、数据库连接的绑定关系。MyBatis 的 SqlSession 本质是对数据库连接的一层封装,一级缓存的生命周期实际上跟连接绑定在一起。Spring 整合 MyBatis 后,SqlSession 由 SqlSessionTemplate 管理,它会对每个数据库事务创建一个新的 SqlSession,并且每次 mapper 方法调用后立即关闭。因为每次方法调用都相当于新建一个 SqlSession,一级缓存自然就失效了,因为缓存的生命周期只有一次方法调用那么短。

所以你在 Spring Boot 项目里测试就会发现,连续调两次userMapper.selectById(1),SQL 会打印两次,一级缓存完全没起作用。这不能算 MyBatis 的 bug,而是框架集成方式决定的。理解了这条链路,才算真正看懂缓存与事务纠缠的本质。

2. 一级缓存的隐性深坑:SqlSession 生命周期里的真相

网上很少有人认真讲一级缓存的实际开发影响,因为默认开启、配置简单,大家反而不重视。但在我自己的项目经历里,一级缓存带来的困惑往往是最多的。

2.1 默认开启却最容易被忽略的缓存

一级缓存的默认行为是“查询结果保存在当前 SqlSession 里,增删改后自动清空”。听起来很合理,但在某些场景下会产生让你百思不得其解的“陈旧数据”。

举个例子。你在一个业务方法里,需要先查一次用户信息,然后执行一个存储过程,这个存储过程会直接修改数据库里的数据(比如把用户余额清零),之后你又查询同一用户信息。理论上,第二次查询应该拿到余额清零后的最新数据,但一级缓存会直接返回第一次查询的旧对象,余额还是原来的值。

这是因为 MyBatis 根本不知道外部程序(比如其他服务、存储过程、其他应用直接改库)修改了数据。它只能感知自己执行过的增删改语句,而存储过程并不在它的感知范围内。

这个坑在做数据迁移、批量修正、多系统共享数据库的项目里特别容易出现。我的建议是:如果某个业务方法里查询后数据库内容可能被“外部力量”修改,不要复用同一个 SqlSession 去查询,或者在第二次查询前调用sqlSession.clearCache()。但这里必须强调,在 Spring 管理的环境里你几乎不会直接操作 SqlSession,所以这个坑更多出现在手写 MyBatis 工具类的代码里。

2.2 为什么一级缓存会带来“幽灵数据”问题

还有一个场景更容易误导人:同一个 SqlSession 里,先按条件查询出一个对象列表,然后修改其中某个对象的某个属性值(不调用 update,只是 setter),再次执行同样的查询,你拿到的列表里那个对象的属性值已经被改过了,并且这个修改会“污染”一级缓存里保存的同一个对象。

原因在于一级缓存保存的是对象引用,不是序列化副本。第一次查询返回的对象列表进入缓存,第二次查询时,MyBatis 直接把缓存里的对象返回给你,没有做任何拷贝。你在业务层对这个对象做的修改,等于直接修改了缓存中的对象,第三次查询拿到的还是这个被改过的“脏对象”。

我刚开始用 MyBatis 时遇到过一个问题:一个查询方法返回 List,业务层在循环里给每个对象填充了某些额外属性,结果另一个无关的方法再次查询同一批数据时,发现多了一些不该有的属性值。排查了半天才发现,两次查询在同一个 SqlSession 中执行,第二次拿到的是第一次查询后被业务层改过的对象。

解决思路有几种:

  • 不要让缓存对象跨业务方法共享,在 Spring 环境里这通常不会发生,因为每个方法都有新的 SqlSession。
  • 在返回对象给前端之前,做一次深拷贝。
  • 查询方法里避免返回实体对象,改用 DTO 接收,但这又涉及 Bean Copy 的性能问题。

2.3 MyBatis 与 Spring 集成后,一级缓存为什么频繁失效

绝大多数人在 Spring Boot 里用 MyBatis,一级缓存基本等于摆设。核心原因我在前面提过:SqlSessionTemplate 会为每次数据库操作创建新的 SqlSession,方法执行完就关闭。一级缓存连一次完整事务都覆盖不了,更别说跨方法共享。

但也有人会问,那我在同一个 Service 方法里开启事务,连续查询两次相同条件,一级缓存会生效吗?答案是仍然不会。

原因在于 Spring 的事务和 MyBatis 的一级缓存存在“提交时间差”。Spring 整合后,SqlSession 是在每次 mapper 方法调用时从 SqlSessionTemplate 获取的,每次都会打开新的,然后在 finally 里关闭。即使你外层开启了事务,底层用的 SqlSession 也和你在原生 MyBatis 里手动创建的不是同一个概念。

所以,如果你期望用一级缓存来减少重复 SQL,在 Spring Boot 项目里是完全不现实的。想减少重复查询,只能靠二级缓存或应用层缓存(比如 Redis)。这一点很重要:不要把性能优化的希望寄托在一级缓存上。

3. 二级缓存配置实战:从 Mapper 粒度开始

二级缓存是我觉得 MyBatis 缓存体系里最有实际价值的部分,但也是配置后最容易出问题的部分。它在生产环境中的使用率其实没那么高,一个原因是 MyBatis 官方文档自己都强调“二级缓存是可选功能,默认不开启”,另一个原因就是失误率太高,配置不当会出现缓存不一致,这种问题的排查成本远高于它带来的性能收益。但如果你对它的机制足够清楚,在数据量小、读多写少、单表查询多的场景下,它的性价比会非常可观。

3.1 开启全局开关与配置缓存标签

先在全局配置里确认cacheEnabled的状态。MyBatis 的默认值就是 true,所以这一步通常不用动。关键是每个 Mapper 要单独声明是否启用二级缓存。

XML 方式在 mapper 文件顶部加:

<mapper namespace="com.example.mapper.UserMapper"> <cache/> </mapper>

这个<cache/>标签有很多可配属性,常用的有:

  • eviction:缓存回收策略,默认 LRU,可选 FIFO、SOFT、WEAK。
  • flushInterval:缓存刷新间隔,单位毫秒,默认不刷新,只有增删改才触发清空。
  • size:缓存最多能保存的对象数量,默认 1024。
  • readOnly:默认 false,返回序列化副本;true 时直接返回缓存对象引用,性能更高但有并发风险。
  • blocking:默认 false,开启后如果缓存未命中,会阻塞后续查询直到前一个查询完成并写入缓存,防止缓存击穿。

一个我在生产环境常用的配置:

<cache eviction="LRU" flushInterval="60000" size="512" readOnly="false" blocking="true"/>

这里解释一下为什么这么配。flushInterval设为 60000 毫秒是一分钟,保证即使没有增删改操作,缓存最迟一分钟也会过期一次,避免数据长期陈旧。readOnly设为 false,避免不同会话之间共享同一个 Java 对象引用,减少并发修改带来的风险。blocking设为 true,在高并发下防止同一查询同时穿透到数据库。

3.2 缓存命中率怎么看:debug 日志与缓存统计

配置完缓存后,怎么判断它到底有没有生效?这是很多新手卡住的地方。你需要做的第一件事是把 MyBatis 日志级别调到 debug。在 application.yml 或者 logback 配置里,将 mapper 接口所在的包日志级别设为 debug:

logging: level: com.example.mapper: debug

然后看控制台日志。开启二级缓存后,MyBatis 会打印类似这样的日志:

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

这个数字表示缓存命中率。第一次查询时,命中率是 0.0,因为缓存里没有数据。第二次相同查询时,命中率变成 0.5。如果第三、第四次都命中,比率会逐步上升。如果你一直看到Cache Miss或者命中率始终为 0,说明缓存并没有生效,需要检查配置。

这里有一个容易被坑的细节:只有主动提交事务或者关闭 SqlSession,二级缓存里的数据才会真正写入。在非事务环境下,如果你用了自动提交模式,查询完缓存才会写入二级缓存。在 Spring Boot 中,如果你的方法没有加事务注解,这个问题通常不会出现,因为 SqlSessionTemplate 会自动处理提交。但如果某个流程里 SqlSession 始终不关闭也不提交(比如在一个长循环里复用同一个 SqlSession),缓存写入就一直不会发生,命中率永远是 0。

3.3 二级缓存的刷新策略与事务边界

二级缓存的清空时机很粗暴:只要这个 namespace 下执行了任何 insert、update、delete 操作,整个缓存全部清空。它不像 Redis 那样可以做精细化失效,而是“一损俱损”的全量失效。

这也带来了一个取舍问题。如果一个表是热点表,写入频繁但查询也频繁,缓存会因为每次写入都被清空,命中率非常低,甚至可能因为反复重建缓存而拖慢系统。我在开发后台管理系统时深有体会:某些字典表虽然频繁被查询,但每天只有管理员修改一两次数据,这种场景非常适合二级缓存。而订单表这种每秒都在更新的表,二级缓存基本没有意义。

事务边界对二级缓存的影响也很明显。二级缓存的写入时间是 SqlSession 提交或关闭时。在 Spring 管理中,如果你在一个事务方法里先查询数据,然后执行更新操作,这时二级缓存并不会立即刷新,而是要等到事务提交后才会触发缓存清空。如果事务里查询了数据,但事务最终回滚了,那么这次查询的结果也不会写入二级缓存。逻辑上这很合理:回滚事务意味着数据状态不确定,不能把不确定的查询结果缓存下来。

3.4 我踩过的坑:多表关联查询与脏缓存

这是二级缓存里最经典的一个坑,文档里只提了一句“如果有多个 namespace 操作同一张表,可能出现脏数据”,但实际发生后会让你排查到怀疑人生。

假设你有两个 Mapper,一个是UserMapper,一个是OrderMapperOrderMapper里有一条 SQL 关联了用户表:

SELECT * FROM t_order o LEFT JOIN t_user u ON o.user_id = u.id

你给OrderMapper开启了二级缓存,第一次查询关联数据后,结果被缓存了。接着,UserMapper里更新了某个用户的昵称,UserMapper的缓存被清空,但OrderMapper里的缓存并不知道用户表变了,它的缓存里仍然保留着更新前的关联结果。下一次查询OrderMapper的关联 SQL,返回的还是旧昵称。

这个问题没有完美的自动解决方案。常见做法有几种:

  • 不开启多表查询的二级缓存。对于涉及多表关联的 SQL,在方法上设置useCache="false",只对简单的单表查询开启缓存。
  • 自定义缓存刷新。通过应用层代码,在更新用户表后手动调用sqlSession.clearCache(),或者调用某个清空 OrderMapper 缓存的方法。
  • 统一 namespace 粒度。尽量不跨表设计 Mapper 方法,把关联查询放到主表对应的 Mapper 里,并避免在其他 Mapper 中更新关联表。

我在实际项目中采用的是第一种方案:默认只对单表查询开启二级缓存,所有关联查询强制useCache="false"。牺牲一点查询性能,换来的是数据准确性。

4. 注解式开发:抛开 XML 也能写出清晰 SQL

再来看注解式开发。很多项目里 XML 文件的体量已经失控了,几百行甚至上千行的 XML 堆在一个文件里,查一个 SQL 要翻半天。注解式开发的价值不仅仅是少写一个 XML 文件,更重要的是把 SQL 直接放在方法旁边,让代码的可读性回归到一个直觉舒服的状态。

4.1 常用注解一览:@Select / @Insert / @Update / @Delete

MyBatis 提供了一组最基础的注解,直接对应 XML 里的增删改查标签。用法非常直观:

public interface UserMapper { @Select("SELECT * FROM t_user WHERE id = #{id}") User selectById(Long id); @Insert("INSERT INTO t_user(name, age) VALUES(#{name}, #{age})") int insert(User user); @Update("UPDATE t_user SET name = #{name} WHERE id = #{id}") int updateName(User user); @Delete("DELETE FROM t_user WHERE id = #{id}") int deleteById(Long id); }

看到这里你会发现,注解里写的 SQL 本质上和 XML 里的一模一样,只是少了一层 XML 转义,写起来反而更自然。比如<这种符号,在 XML 里必须写成&lt;,而在注解里直接写<就行。

但注解式开发也不是没有限制。复杂的动态 SQL 在注解里处理起来确实不如 XML 直观,这是一个真实的痛点,我后面会分享一个折中方案。

4.2 @Param、@Options、@ResultMap 的正确用法

方法参数有多个时,必须在参数前加@Param注解,否则 MyBatis 无法正确处理参数名。一个典型场景:

@Select("SELECT * FROM t_user WHERE name = #{name} AND age > #{minAge}") List<User> findByNameAndAge(@Param("name") String name, @Param("minAge") Integer minAge);

这里如果不加@Param,MyBatis 在运行时可能只能通过param1param2这种位置参数去取值,SQL 里的#{name}就对应不上。加上@Param后,映射关系就明确有据了。

@Options注解最常用的场景是配置主键回填。执行 insert 后拿自增主键,XML 里要在<insert>上配useGeneratedKeyskeyProperty,注解开发里就用@Options

@Insert("INSERT INTO t_user(name, age) VALUES(#{name}, #{age})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(User user);

执行完 insert 后,user.getId()就能拿到数据库生成的主键值,无需再单独查询。

@ResultMap的作用是引用一个在 XML 中定义好的 ResultMap。它解决了注解开发里复杂映射难以表达的问题。如果你的实体属性和表字段命名不一致,或需要级联查询,可以在 XML 里定义 ResultMap,在注解方法上直接引用:

@Select("SELECT * FROM t_user WHERE id = #{id}") @ResultMap("userResultMap") User selectById(Long id);

这种方式适合已经存在 XML 文件、不想全量迁移的项目,注解负责查询入口,XML 负责结果映射,各取所长。

4.3 动态 SQL 在注解里的实现:@SelectProvider 的实战

这是注解开发的重头戏。动态 SQL(if、foreach、where)是 XML 里的看家本领,很多人说注解实现不了,其实是通过 Provider 注解来实现的,只是写法比较绕。

@SelectProvider允许你定义一个独立的类,用 Java 代码拼接 SQL 字符串。看一个例子:

@SelectProvider(type = UserSqlProvider.class, method = "selectByCondition") List<User> selectByCondition(@Param("name") String name, @Param("minAge") Integer minAge);

Provider 类:

public class UserSqlProvider { public String selectByCondition(@Param("name") String name, @Param("minAge") Integer minAge) { return new SQL() {{ SELECT("*"); FROM("t_user"); if (name != null && !name.isEmpty()) { WHERE("name = #{name}"); } if (minAge != null) { WHERE("age > #{minAge}"); } }}.toString(); } }

这里用了 MyBatis 提供的SQL类来构造 SQL,它内置了SELECTFROMWHEREANDOR等方法,可以避免字符串拼接时遗漏空格和逗号。

但这里有一个很关键的踩坑点:Provider 方法返回的字符串里,如果出现<>这种特殊符号,一定要用转义字符或在 SQL 注释里避开。因为SQL类生成的 SQL 最终会被 MyBatis 当作一条完整语句处理,而不是 XML,所以不用担心&lt;的问题。真正要注意的是别把用户输入直接拼进 SQL,不然会引入 SQL 注入风险。Provider 里只能拼 SQL 结构,参数必须用#{xxx}占位符。

如果你想让代码更可控,也可以不用SQL类,直接手写字符串拼接,但必须确保每个WHERE前都有空格,否则多个条件会拼成语法错误。我建议能用SQL类就用,它能帮你避免这种低级错误。

4.4 注解开发与 XML 如何取舍

说一句大实话:在真实项目中,注解和 XML 并不是互斥关系,它们是共存的。我的经验是——简单的单表 CRUD 用注解,复杂的动态查询和报表类 SQL 用 XML。

判断标准有三个:

  • SQL 是否可能频繁调整。如果 SQL 只服务于一个业务点且参数固定,注解足够。
  • SQL 是否有多个可选查询条件。如果 where 后面的条件数量不固定,XML 的<where><if>更直观。
  • 团队协作方式。有的团队有专职 DBA 审核 SQL,如果所有 SQL 都在 Java 注解里,DBA 不方便检索;XML 文件可以集中管理,方便评审。

在实际项目中,我也见过一些人把复杂的多表关联 SQL 用 Provider 实现,维护起来非常痛苦。因为 Java 字符串拼接动态 SQL 的可读性,远不如 XML 的标签式语言。尤其是超过 20 行的 SQL,用 XML 维护会让你少掉很多头发。

5. 注解式开发中的缓存控制

注解开发里的缓存控制,核心就两个注解:@CacheNamespace@CacheNamespaceRef。前者负责在当前 Mapper 上开启二级缓存,后者负责引用其他 namespace 的缓存。这一节我会用完整的示例演示如何把缓存配置和注解开发组合起来。

5.1 @CacheNamespace 如何在注解里配置二级缓存

先看一个标准用法:

@CacheNamespace( eviction = LruCache.class, flushInterval = 60000, size = 512, readWrite = true, blocking = true ) public interface UserMapper { @Select("SELECT * FROM t_user WHERE id = #{id}") User selectById(Long id); }

这里eviction对应缓存回收策略类,LruCache.class是 MyBatis 默认提供的 LRU 实现。flushInterval是刷新间隔毫秒数。readWrite对应 XML 里的readOnly,true 表示返回序列化副本,false 表示直接返回缓存对象引用。

配置了@CacheNamespace之后,这个 Mapper 下的所有查询方法都默认使用二级缓存,除非在某个方法上单独指定不使用缓存。方法级别的控制可以通过@Options(useCache = false)实现:

@Select("SELECT * FROM t_order WHERE user_id = #{userId}") @Options(useCache = false) List<Order> findOrdersByUserId(Long userId);

useCache = false意味着这条 SQL 的结果不会写入二级缓存,适合那些数据时效性要求高、不适合缓存的查询。

5.2 注解方法怎么控制清空缓存:flushCache 的实战

@Options里还有一个重要的属性flushCache,它控制的是“执行这条 SQL 之前要不要清空缓存”。默认情况下,查询方法的flushCache = false,增删改方法的flushCache = true。这意味着执行 update 后,当前 namespace 下的缓存会自动清空,防止后续读到旧数据。

如果一个更新操作涉及多个表,只清了当前 Mapper 的缓存,其他关联 Mapper 的缓存就成了“脏缓存”。遇到这种情况,可以通过@CacheNamespaceRef让一个 Mapper 引用另一个 Mapper 的缓存空间,使它们共享同一个缓存区域:

@CacheNamespaceRef(UserMapper.class) public interface OrderMapper { @Select("SELECT * FROM t_order WHERE user_id = #{userId}") List<Order> findByUserId(Long userId); }

配置了@CacheNamespaceRef(UserMapper.class)后,OrderMapperUserMapper共享同一个缓存命名空间。当UserMapper执行更新导致缓存清空时,OrderMapper的缓存也会一起被清空,这样关联查询的脏数据问题就能从框架层面尽量避免。

这个方案需要你非常清楚地知道哪些表之间存在关联关系,并在设计 Mapper 时就把它们的缓存关系规划好,否则容易把缓存区域搞得一团糟。

5.3 缓存与注解开发结合的完整示例

我来写一个真正能直接落地的示例:一个用户管理的 Mapper,包含普通 CRUD、缓存配置和关联表缓存共享配置。

@CacheNamespace( eviction = LruCache.class, flushInterval = 60000, size = 512, readWrite = true, blocking = true ) public interface UserMapper { @Select("SELECT * FROM t_user WHERE id = #{id}") User selectById(@Param("id") Long id); @Select("SELECT * FROM t_user WHERE name = #{name}") List<User> selectByName(@Param("name") String name); @Insert("INSERT INTO t_user(name, age) VALUES(#{name}, #{age})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(User user); @Update("UPDATE t_user SET name = #{name} WHERE id = #{id}") int updateName(@Param("id") Long id, @Param("name") String name); @Delete("DELETE FROM t_user WHERE id = #{id}") int deleteById(@Param("id") Long id); @Select("SELECT * FROM t_user WHERE age > #{minAge}") @Options(useCache = false) List<User> selectByAge(@Param("minAge") Integer minAge); }

配合一个关联查询的OrderMapper,通过@CacheNamespaceRef共享缓存:

@CacheNamespaceRef(UserMapper.class) public interface OrderMapper { @Select("SELECT * FROM t_order WHERE user_id = #{userId}") List<Order> selectByUserId(@Param("userId") Long userId); }

这样配置后,查询用户信息会自动走二级缓存,更新用户会自动清空用户缓存,同时订单表里关联用户的缓存也会因为共享 namespace 而一起失效,数据一致性得到保障,所有操作无需任何 XML 文件。

6. 常见问题与排查技巧实录

把实际开发和代码评审里最常见的缓存与注解问题整理成了一份速查表,每个问题都是我亲身遇到或帮别人排查过的。

6.1 缓存为什么不生效 / 为什么数据是旧的

缓存不生效,先按下面顺序排查:

现象可能原因解决办法
命中率一直为 0二级缓存未开启,Mapper 没配置<cache/>@CacheNamespace检查全局开关和 Mapper 配置
命中率一直为 0查询后 SqlSession 未提交/未关闭在 Spring 环境加事务注解@Transactional
数据总是旧的flushInterval设置太长,或者表数据被外部系统直接修改缩短flushInterval,或对实时性要求高的查询设置useCache=false
数据总是旧的多表关联查询缓存了关联表的数据,但另一 Mapper 更新了关联表对关联查询设置useCache=false,或用@CacheNamespaceRef共享缓存
一级缓存未生效Spring 整合后每个方法都新建 SqlSession不要依赖一级缓存做性能优化

6.2 缓存与 N+1 查询:开发中真实场景

N+1 查询问题是很多人在使用 MyBatis 时遇到的性能杀手。一个典型场景:查询订单列表,然后遍历订单去查用户信息。

List<Order> orders = orderMapper.selectAll(); for (Order order : orders) { User user = userMapper.selectById(order.getUserId()); }

如果用户查询开启了二级缓存,第一次遍历后会缓存用户数据,之后再次查询同一个用户就能直接命中缓存。但前提是缓存里已经有数据,第一次遍历时缓存还是空的,仍然会产生 N 次 SQL。

所以要想从根上解决 N+1,还得靠 SQL 层面去优化,比如用一条关联查询把用户信息一次查出来。二级缓存只是锦上添花,不能作为解决 N+1 的手段。

6.3 排查 MyBatis 缓存的实用技巧

分享两个我在实际排查中觉得特别管用的小技巧。

第一个是善用日志。把 mapper 包的日志级别调到 debug 后,你会看到Cache Hit Ratio和每次执行的 SQL。如果发现某个方法打印的 SQL 次数和你预期的不一致,就能快速定位缓存是否生效。这个手段非常直接,比反复猜配置强得多。

第二个是写一个简单的测试接口,专门验证缓存行为。比如在一个 Controller 里暴露一个测试端点,模仿前端的一次业务操作:第一次查询,第二次查询,执行更新,第三次查询。通过日志观察三次行为是否符合预期。我通常会在本地开发环境保留一个这样的调试接口,排查问题时几乎百试百灵。

最后说一个很多人不知道的小知识点:@Options(useCache = false)@Options(flushCache = true)是可以同时使用的。如果一个查询 SQL 很重,你不想让它写入缓存,但又担心查询前该 namespace 的缓存太旧,可以先 flush 再查询,这样既保证了每次执行时缓存清空,又不会把这个重查询的结果写入缓存。这种组合在定时任务里非常好用。

我自己用注解式开发近三年,最深的体会是:注解不是一个简单替代 XML 的工具,它强迫你把 SQL 的复杂度降到“一眼能看懂”的层次。当你发现某个 SQL 在注解里写得很别扭时,那大概率不是注解的局限,而是这条 SQL 本身已经复杂到应该重新设计了。缓存机制也一样,它最理想的运行状态,是你感觉不到它的存在。真到需要反复排查缓存问题的时候,问题往往不是出在 MyBatis 的缓存逻辑上,而是出在业务边界和数据一致性设计上。理解这层逻辑之后,你再回头看那些面试题里层层嵌套的缓存场景,就会发现它们其实都在围绕一个终极问题:你的数据在什么范围内、什么时间点上,可以被安全地复用。

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

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

立即咨询