Administrator
发布于 2018-02-16 / 607 阅读
10

MyBatis 一级缓存与二级缓存:一次读到脏数据的排查

运营说:我明明改了数据,页面还是旧的

去年做后台管理系统时遇到件怪事。运营在页面上改了一条商品记录的库存,保存成功,数据库里确认已经改了,但刷新列表看到的还是旧值。过几分钟再刷又对了。

我把日志级别调到 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 也跟着变了——这种"对象被意外修改"的问题更难查。

解决方式

  1. 查询方法上配 flushCache="true",每次查询前先清缓存:
<select id="selectById" resultType="Goods" flushCache="true">
    SELECT * FROM t_goods WHERE id = #{id}
</select>
  1. 在 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 里解决的。

自查清单

复盘完之后,我给自己列了四条检查项,每次接手新项目过一遍:

  1. localCacheScope 是不是默认的 SESSION?是的话评估一下要不要改成 STATEMENT。
  2. cacheEnabled 开着的项目,搜一下有多少个 <cache/>@CacheNamespace,逐个确认合理性。
  3. 凡是开了二级缓存的 namespace,检查有没有跨表关联查询。
  4. 涉及缓存的实体类有没有实现 Serializable,没实现的话二级缓存根本写不进去,还会报 NotSerializableException。

MyBatis 的缓存设计确实有点鸡肋,好处没享到多少,排查成本倒是不低。现在我接手新项目的第一件事就是把这两级缓存的开关看一遍。

参考