ElIauk

MyBatis 一级/二级缓存踩坑

缓存开了,数据反而旧了 有个很典型的场景:给字典表开了 MyBatis 二级缓存,本意是减少查询。结果运营在后台改了字典值,前台刷新还是旧数据,重启应用才恢复。开发查了半天代码,发现更新方法也执行了,数据库里的值确实是新的,就是接口返回旧的。 这不是 MyBatis 的 bug,而是缓存的作用域和失

评论
MyBatis 一级/二级缓存踩坑

缓存开了,数据反而旧了

有个很典型的场景:给字典表开了 MyBatis 二级缓存,本意是减少查询。结果运营在后台改了字典值,前台刷新还是旧数据,重启应用才恢复。开发查了半天代码,发现更新方法也执行了,数据库里的值确实是新的,就是接口返回旧的。

这不是 MyBatis 的 bug,而是缓存的作用域和失效规则没搞明白。MyBatis 有两级缓存,一级在 SqlSession 里,二级在 namespace 里,两者的生命周期、失效条件完全不同。再加上 Spring 对 SqlSession 的管理方式,实际表现和很多教程描述得不一样。

一级缓存:SqlSession 范围

一级缓存默认开启,作用域是同一个 SqlSession:同一个会话里执行两条完全相同的查询,第二条直接走缓存,不再发 SQL。

在 Spring 项目里,SqlSessionTemplate 会在每次 Mapper 调用后关闭会话。所以:

  • 不加事务时,每次 Mapper 调用都是新会话,一级缓存基本不起作用;
  • 加了 @Transactional 时,整个事务共用一个会话,一级缓存才真正生效。

这就解释了为什么有些"读到旧数据"的问题只在事务方法里出现。看下面这段代码:

@Transactional(rollbackFor = Exception.class)
public void updateAndRead(Long id) {
    User before = userMapper.selectById(id);      // 第一次查库
    jdbcTemplate.update("update user set name = ? where id = ?", "新名字", id);
    User after = userMapper.selectById(id);       // 命中一级缓存,还是旧名字
}

同一个事务里,MyBatis 认为这两次查询结果应该一样,直接返回了缓存。解决办法是用 sqlSession.clearCache()(不常用)、换一个会话查询,或者干脆避免这种写法。

如果不想要一级缓存,可以改成语句级:

mybatis-plus:
  configuration:
    local-cache-scope: statement

statement 表示每次查询结束就清空,等于关掉一级缓存。

二级缓存:namespace 范围

二级缓存默认是关闭的,需要在 Mapper 里显式声明。它的作用域是 namespace,多个 SqlSession 共享,在 Spring 环境下相当于跨请求、跨线程共享。

<mapper namespace="com.example.mapper.DictMapper">

    <cache eviction="LRU" flushInterval="600000" size="512" readOnly="false"/>

    <select id="listAll" resultType="com.example.entity.Dict" useCache="true">
        select id, type, code, name from dict where deleted = 0
    </select>
</mapper>

readOnly="false" 要求返回的对象实现 Serializable,缓存时会做序列化;readOnly="true" 直接返回同一个对象引用,性能好但有被调用方改坏的风险。

几个关键规则:

  1. 只有 <cache/> 所在的 namespace 才有二级缓存;
  2. 执行 insert、update、delete 时,默认会清空当前 namespace 的二级缓存;
  3. <select> 默认 useCache="true",flushCache="false";
  4. 二级缓存在事务提交后才真正写入,回滚不会污染缓存。

常见坑

1. 以为一级缓存跨请求

很多人看教程说"一级缓存默认开启",就以为两次 HTTP 请求能命中。实际上不加事务时每次调用都是新会话,缓存早就没了。真正跨请求的是二级缓存,别把两者混为一谈。

2. 多表更新清不掉缓存

订单 Mapper 的缓存里存了订单和用户名的关联结果,用户表更新走的是 UserMapper,只会清 UserMapper 的 namespace,订单缓存完全不知道。结果就是"用户名改了,订单列表还是旧名字"。

解决办法有三个:用 <cache-ref namespace="..."/> 让两个 Mapper 共享缓存;在更新用户时手动清订单缓存;或者干脆不开二级缓存。

3. 集群环境下各缓存各的

二级缓存是 JVM 本地缓存,部署三台机器就有三份。A 机器更新数据清了本地缓存,B、C 机器上的缓存还在。除非用 Redis 之类的集中式缓存,否则跨节点一致性无法保证。

4. 序列化失败

实体没实现 Serializable,或者字段里包含不可序列化的对象,第一次写缓存就抛 NotSerializableException。日志里通常只看到一条 WARN,业务还能跑,但缓存一直不生效。

5. 更新语句没触发清理

自定义的更新语句如果写在别的 namespace,或者用了 flushCache="false",缓存不会被清。检查更新语句所在 Mapper 是否和查询在同一个 namespace。

6. flushInterval 带来"定时脏数据"

flushInterval 只是定时清空,不是数据变更通知。在两次清空之间,读到的都可能是旧值。对一致性要求高的数据,不要依赖这个机制。

7. 缓存 key 和参数对象有关

二级缓存的 key 由 statement id、参数、分页信息等组成。参数对象没正确实现 equals 和 hashCode,会出现"同样的参数命中不了"或者"不同参数错误命中"。

排查步骤与建议

怀疑缓存问题时,先打开 SQL 日志:

logging:
  level:
    com.example.mapper: DEBUG
    org.apache.ibatis.cache: DEBUG

同样的请求连续发两次,看第二次有没有打印 Preparing:。没有打印说明走了缓存;打印了说明没命中。再把 cache-enabled 设为 false 对比一次,如果数据立刻变新,基本可以确认是缓存导致。

生产环境的建议是:二级缓存慎用。它适合字典、配置这类读多写少、单表、能容忍短时间不一致的数据;订单、库存、账户这类强一致数据不要开。需要缓存时,用 Redis 做显式的业务缓存,key 的设计、过期时间、失效时机都掌握在自己手里,比框架的隐式缓存好排查得多。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者