缓存开了,数据反而旧了
有个很典型的场景:给字典表开了 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" 直接返回同一个对象引用,性能好但有被调用方改坏的风险。
几个关键规则:
- 只有
<cache/>所在的 namespace 才有二级缓存; - 执行
insert、update、delete时,默认会清空当前 namespace 的二级缓存; <select>默认useCache="true",flushCache="false";- 二级缓存在事务提交后才真正写入,回滚不会污染缓存。
常见坑
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 的设计、过期时间、失效时机都掌握在自己手里,比框架的隐式缓存好排查得多。
评论