运营说:我明明改了数据,页面还是旧的
去年做后台管理系统时遇到件怪事。运营在页面上改了一条商品记录的库存,保存成功,数据库里确认已经改了,但刷新列表看到的还是旧值。过几分钟再刷又对了。
我把日志级别调到 DEBUG,看到这么一段:
==> Preparing: SELECT id, name, stock FROM t_goods WHERE id = ?
==> Parameters: 1001(Long)
<== Total: 1
(中间没有任何 SQL,第二次同样的查询直接返回了)
只有一次查询,第二次查询压根没打到数据库。这就是 MyBatis 的一级缓存。
一级缓存:SqlSession 级别的坑
一级缓存默认开启,作用域是 SqlSession。同一个 SqlSession 里执行相同的 SQL(相同 statement id + 相同参数 + 相同分页),第二次直接从内存拿。
但这里的"同一个 SqlSession"有个陷阱——在 Spring 里,SqlSession 的生命周期跟事务绑定:没开事务的时候,每个 Mapper 方法调用都是独立的 SqlSession,一级缓存根本不生效;一旦加了 @Transactional,整个事务方法共用同一个 SqlSession,一级缓存就开始起作用了。
我们那个 bug 就是这么来的:
@Transactional
public void updateAndReload(Long goodsId, int newStock) {
Goods before = goodsMapper.selectById(goodsId); // 查一次,进缓存
goodsMapper.updateStock(goodsId, newStock); // 更新,会清空缓存
Goods after = goodsMapper.selectById(goodsId); // 重新查,拿到新值
// ...
}
这段代码看着没问题。但我们的场景是另一个服务通过 Dubbo 直接改了库,我们这边事务内先查了一次,缓存住;外部改完,我们事务内再查,拿的还是缓存里的旧值。中间没有任何写操作触发缓存失效,MyBatis 又不知道别的进程改了数据。
验证了一下确实如此:
SqlSession session = factory.openSession();
GoodsMapper mapper = session.getMapper(GoodsMapper.class);
Goods g1 = mapper.selectById(1001L);
// 此时另一个连接把 stock 改成了 50
Goods g2 = mapper.selectById(1001L);
System.out.println(g1 == g2); // true,同一个对象引用!
session.close();
g1 == g2 是 true,说明返回的是同一个对象。这意味着如果你在中间改了 g1 的字段,g2 也跟着变了——这种"对象被意外修改"的问题更难查。
解决方式
- 查询方法上配
flushCache="true",每次查询前先清缓存:
<select id="selectById" resultType="Goods" flushCache="true">
SELECT * FROM t_goods WHERE id = #{id}
</select>
- 在 MyBatis 配置文件里把一级缓存级别降为 STATEMENT,这样每次语句执行完就清掉:
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>
我们最后选了方案 2,全局配 STATEMENT。因为后台系统并发不高,一级缓存带来的收益远小于它造成的困惑。
二级缓存:namespace 级别,坑更多
二级缓存作用域是 Mapper 的 namespace,跨 SqlSession 共享。开启要三步:全局配置打开、Mapper XML 里加 <cache/>、实体类实现 Serializable。
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
<mapper namespace="com.xxx.mapper.GoodsMapper">
<cache eviction="LRU" flushInterval="60000" size="1024" readOnly="false"/>
</mapper>
几个我实际踩到的点:
- readOnly="false" 才会做序列化拷贝。 默认是 false,每次从缓存取会反序列化出新对象,所以你改了返回对象不会影响缓存里的。如果配成 true,返回的是同一个共享实例,改了会污染缓存,还线程不安全。
- 任意一个 namespace 下的 insert/update/delete 会清空该 namespace 的整个缓存。 注意是整个 namespace,不是某一条。所以写多读少的表开二级缓存反而会不断失效,白搭。
- 多表关联查询是脏数据的温床。 比如 GoodsMapper 里有个关联查 Category 的语句,缓存在 GoodsMapper 的 namespace 下。CategoryMapper 更新了分类名,GoodsMapper 的缓存不会失效,查出来就是旧分类名。解决办法是用
<cache-ref namespace="..."/>让两个 namespace 共享同一块缓存,但这样耦合又变重了。
我的结论
一级缓存在 Spring 事务环境下风险不小,我们全局改成了 STATEMENT。二级缓存我最后只在字典表、配置表这种几乎不写、数据量小、改动后可以接受短暂延迟的场景开了,业务主表一律不开。真要缓存,交给 Redis,至少失效时机自己可控,还能跨应用共享。
注解方式下的配置
我们项目老的代码用的是 XML,新的模块用的是注解,两种写法都要会。
// 一级缓存的 flushCache,注解里对应的是 @Options
@Select("SELECT * FROM t_goods WHERE id = #{id}")
@Options(flushCache = Options.FlushCachePolicy.TRUE)
Goods selectById(Long id);
// 二级缓存用 @CacheNamespace,作用在接口上
@CacheNamespace(implementation = PerpetualCache.class,
eviction = LruCache.class,
flushInterval = 60000,
size = 1024)
public interface DictMapper {
@Select("SELECT * FROM t_dict WHERE type = #{type}")
List<Dict> selectByType(String type);
}
这里有个我踩过的坑:同一个 Mapper 不能既用 XML 又用 @CacheNamespace。如果一个 namespace 在 XML 和注解里被声明了两次,启动时会直接报:
org.apache.ibatis.builder.BuilderException:
Error parsing Mapper XML. Cause: java.lang.IllegalArgumentException:
Mapped Statements collection already contains value for ...
我们有个 Mapper 是 XML 里写了基础 CRUD、注解里加了几个复杂查询,加缓存的时候就撞上了。最后是全部挪到 XML 里解决的。
自查清单
复盘完之后,我给自己列了四条检查项,每次接手新项目过一遍:
localCacheScope是不是默认的 SESSION?是的话评估一下要不要改成 STATEMENT。cacheEnabled开着的项目,搜一下有多少个<cache/>或@CacheNamespace,逐个确认合理性。- 凡是开了二级缓存的 namespace,检查有没有跨表关联查询。
- 涉及缓存的实体类有没有实现 Serializable,没实现的话二级缓存根本写不进去,还会报 NotSerializableException。
MyBatis 的缓存设计确实有点鸡肋,好处没享到多少,排查成本倒是不低。现在我接手新项目的第一件事就是把这两级缓存的开关看一遍。