JAVA 系统中 MyBatis 缓存失效?深度解析一级、二级缓存的坑与优化策略

MyBatis 缓存失效?深度解析一级、二级缓存的坑与优化策略

大家好,今天我们来聊聊 MyBatis 缓存这个话题。缓存机制是 MyBatis 中一个非常重要的特性,它可以显著提高数据访问速度,减轻数据库压力。但是,如果对 MyBatis 缓存的理解不够深入,使用不当,反而可能导致缓存失效,数据不一致等问题。因此,今天我们主要围绕 MyBatis 的一级缓存和二级缓存,深入分析它们的工作原理、常见问题以及优化策略。

一、 MyBatis 缓存体系概览

MyBatis 缓存分为两个级别:

  • 一级缓存 (Local Cache): 基于 SqlSession 的本地缓存,默认开启。
  • 二级缓存 (Second Level Cache): 基于 SqlSessionFactory 的全局缓存,需要手动配置开启。

它们之间的关系可以用下图简单表示:

[客户端] --> [SqlSessionFactory] --> [SqlSession 1] --> [Executor 1] --> [一级缓存1] --> [数据库]
                  |
                  |
                  --> [SqlSession 2] --> [Executor 2] --> [一级缓存2] --> [数据库]
                  |
                  --> [二级缓存]

每个 SqlSession 都有自己的一级缓存,SqlSessionFactory 只有一个二级缓存。

二、 一级缓存:SqlSession 级别的“私人订制”

2.1 工作原理

一级缓存是 MyBatis 默认开启的,它的生命周期与 SqlSession 一致。当 SqlSession 发起查询时,MyBatis 会先检查一级缓存中是否存在相同查询条件(SQL 语句和参数)的结果。如果存在,则直接返回缓存结果;如果不存在,则从数据库查询,并将结果存入一级缓存。当 SqlSession 关闭或者执行了任何修改数据库的操作(增删改),一级缓存会被清空。

2.2 代码示例

// 获取 SqlSession
SqlSession sqlSession = sqlSessionFactory.openSession();

try {
    UserMapper userMapper = sqlSession.getMapper(UserMapper.class);

    // 第一次查询,从数据库获取
    User user1 = userMapper.selectByPrimaryKey(1);
    System.out.println("第一次查询: " + user1.getName());

    // 第二次查询,从一级缓存获取
    User user2 = userMapper.selectByPrimaryKey(1);
    System.out.println("第二次查询: " + user2.getName());

    // 更新操作
    user1.setName("Updated Name");
    userMapper.updateByPrimaryKey(user1);
    sqlSession.commit(); // 提交事务会清空一级缓存

    // 第三次查询,从数据库获取,因为一级缓存已清空
    User user3 = userMapper.selectByPrimaryKey(1);
    System.out.println("第三次查询: " + user3.getName());

} finally {
    sqlSession.close();
}

在这个例子中,第一次查询会从数据库获取数据并放入一级缓存。第二次查询由于条件相同,会直接从一级缓存获取数据,而不会访问数据库。更新操作执行 sqlSession.commit() 之后,一级缓存会被清空,所以第三次查询会再次访问数据库。

2.3 常见问题与坑

  • 脏读问题: 如果在一个 SqlSession 中,先查询,然后进行更新操作,但更新操作尚未提交(未调用 sqlSession.commit()),此时如果再次查询,仍然会从一级缓存中获取数据,但这些数据可能是旧的。虽然最终会通过 commit 保证数据一致性,但如果存在并发操作,还是可能引发问题。
  • 并发问题: 一级缓存是 SqlSession 级别的,这意味着每个线程拥有自己的 SqlSession 和一级缓存。因此,不存在线程安全问题。
  • 生命周期短: 一级缓存的生命周期与 SqlSession 绑定,SqlSession 关闭后,缓存就会失效。因此,一级缓存只能在单个事务中提高查询效率,无法跨越多个事务。

2.4 优化策略

  • 合理控制 SqlSession 的生命周期: 尽量在一个事务中使用一个 SqlSession,避免长时间持有 SqlSession,防止脏读。
  • 理解缓存失效时机: 清楚哪些操作会清空一级缓存,避免不必要的数据库访问。
  • 避免过度依赖一级缓存: 对于需要跨事务共享的数据,应该考虑使用二级缓存。

三、 二级缓存:SessionFactory 级别的“共享资源”

3.1 工作原理

二级缓存是基于 SqlSessionFactory 的缓存,可以跨多个 SqlSession 共享数据。 当二级缓存开启后,MyBatis 会为每个 Mapper 配置一个 Cache 对象,存储该 Mapper 查询结果。当 SqlSession 关闭时,会将一级缓存中的数据转移到二级缓存中。后续的查询如果命中二级缓存,将直接从二级缓存获取数据,而无需访问数据库。

3.2 配置与开启

要开启二级缓存,需要在 MyBatis 的配置文件 (mybatis-config.xml) 中进行配置:

  1. 开启全局缓存开关:<settings> 标签中添加:

    <settings>
      <setting name="cacheEnabled" value="true"/>
    </settings>
  2. 在 Mapper XML 文件中配置 <cache> 标签: 在需要开启二级缓存的 Mapper XML 文件中添加 <cache> 标签。例如:

    <mapper namespace="com.example.UserMapper">
        <cache
            eviction="LRU"
            flushInterval="60000"
            size="512"
            readOnly="true"/>
    
        <select id="selectByPrimaryKey" parameterType="java.lang.Integer" resultType="com.example.User">
            SELECT * FROM users WHERE id = #{id}
        </select>
    </mapper>
    • eviction: 缓存回收策略,常见的有 LRU(最近最少使用)、FIFO(先进先出)、SOFT(软引用)、WEAK(弱引用)等。
    • flushInterval: 缓存刷新间隔,单位为毫秒。如果设置为 0,则表示没有刷新间隔,缓存不会自动刷新。
    • size: 缓存的最大存储对象个数。
    • readOnly: 是否只读。如果设置为 true,则所有从缓存中获取的对象都是只读的,MyBatis 不会对它们进行监控,因此可以提高性能。如果设置为 false,则从缓存中获取的对象是可修改的,MyBatis 会对它们进行监控,确保数据一致性。
  3. 实体类需要实现 Serializable 接口: 由于二级缓存需要将对象序列化到磁盘,因此需要确保实体类实现了 Serializable 接口。

    public class User implements Serializable {
        private static final long serialVersionUID = 1L;
        private Integer id;
        private String name;
        // ... 省略 getter/setter 方法
    }

3.3 代码示例

// 获取 SqlSession
SqlSession sqlSession1 = sqlSessionFactory.openSession();
SqlSession sqlSession2 = sqlSessionFactory.openSession();

try {
    UserMapper userMapper1 = sqlSession1.getMapper(UserMapper.class);
    UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class);

    // 第一次查询,从数据库获取,并放入一级缓存和二级缓存
    User user1 = userMapper1.selectByPrimaryKey(1);
    System.out.println("第一次查询 (Session 1): " + user1.getName());
    sqlSession1.commit(); // 提交事务,一级缓存数据会被同步到二级缓存

    // 第二次查询,从二级缓存获取
    User user2 = userMapper2.selectByPrimaryKey(1);
    System.out.println("第二次查询 (Session 2): " + user2.getName());

} finally {
    sqlSession1.close();
    sqlSession2.close();
}

在这个例子中,第一次查询在 sqlSession1 中执行,会从数据库获取数据,并放入 sqlSession1 的一级缓存。当 sqlSession1 提交事务时,一级缓存中的数据会被同步到二级缓存。第二次查询在 sqlSession2 中执行,由于二级缓存已经存在数据,所以会直接从二级缓存获取数据,而不会访问数据库。

3.4 常见问题与坑

  • 脏读问题: 二级缓存也存在脏读问题。如果在某个 SqlSession 中修改了数据,但尚未提交,其他 SqlSession 从二级缓存中获取到的数据仍然是旧的。
  • 序列化问题: 由于二级缓存需要将对象序列化到磁盘,因此需要确保实体类实现了 Serializable 接口,并且所有属性都是可序列化的。如果实体类中包含不可序列化的属性,会导致缓存失败。
  • 缓存穿透问题: 如果查询的 key 对应的数据在数据库中不存在,那么每次查询都会访问数据库,导致缓存失效。可以使用布隆过滤器或者缓存空对象来解决缓存穿透问题。
  • 缓存雪崩问题: 如果大量的缓存 key 同时失效,导致大量的请求直接访问数据库,可能会导致数据库压力过大。可以使用随机过期时间或者互斥锁来解决缓存雪崩问题。
  • 缓存击穿问题: 如果某个热点 key 失效,导致大量的请求同时访问数据库,可能会导致数据库压力过大。可以使用互斥锁或者设置永不过期来解决缓存击穿问题。
  • 数据一致性问题: 二级缓存是跨 SqlSession 共享的,因此需要确保数据一致性。如果某个 SqlSession 修改了数据,需要及时刷新二级缓存,避免其他 SqlSession 获取到旧数据。可以使用 flushCache="true" 属性来刷新缓存。
  • 过度缓存: 并非所有数据都适合放入二级缓存。对于频繁更新的数据,或者数据量较小的数据,使用二级缓存可能反而会降低性能。

3.5 优化策略

  • 合理选择缓存回收策略: 根据应用场景选择合适的缓存回收策略。例如,对于访问频率较高的数据,可以使用 LRU 策略;对于数据量较小的数据,可以使用 FIFO 策略。

  • 设置合理的缓存刷新间隔: 根据数据更新频率设置合理的缓存刷新间隔。如果数据更新频率较高,可以设置较短的刷新间隔;如果数据更新频率较低,可以设置较长的刷新间隔。

  • 控制缓存大小: 根据服务器的内存大小和数据量,合理控制缓存大小。过大的缓存会导致内存溢出,过小的缓存会导致缓存命中率低。

  • 使用合适的缓存键: 使用唯一的缓存键来标识缓存对象。可以使用 SQL 语句和参数作为缓存键。

  • 避免缓存污染: 不要将不相关的数据放入同一个缓存中。

  • 使用分布式缓存: 对于高并发的应用,可以使用分布式缓存来提高缓存性能和可用性。

  • 细粒度缓存控制: 在Mapper.xml文件中,可以在增删改标签中添加 flushCache="true" 属性,用于指定在执行这些操作后是否刷新缓存。

    <insert id="insert" parameterType="com.example.User" flushCache="true">
        INSERT INTO users (name) VALUES (#{name})
    </insert>
    
    <update id="updateByPrimaryKey" parameterType="com.example.User" flushCache="true">
        UPDATE users SET name = #{name} WHERE id = #{id}
    </update>
    
    <delete id="deleteByPrimaryKey" parameterType="java.lang.Integer" flushCache="true">
        DELETE FROM users WHERE id = #{id}
    </delete>

    flushCache="true" 强制在语句执行后清空所有本地缓存和二级缓存。默认值为false。

  • 自定义缓存实现: MyBatis 允许开发者自定义缓存实现。可以通过实现 org.apache.ibatis.cache.Cache 接口来创建自定义缓存。例如,可以使用 Redis、Memcached 等作为 MyBatis 的二级缓存。

四、 MyBatis 缓存相关配置项总结

配置项 位置 说明
cacheEnabled mybatis-config.xml 全局缓存总开关。
<cache> Mapper XML 文件 Mapper 级别的二级缓存配置,包含 eviction, flushInterval, size, readOnly 等属性。
flushCache Mapper XML 文件 <insert>, <update>, <delete> 标签的属性,指定在执行这些操作后是否刷新缓存。
实体类实现 Serializable 实体类 使用二级缓存时,实体类必须实现 Serializable 接口。

五、 缓存选择建议

  • 一级缓存: 适用于单个事务中频繁访问相同数据的情况。默认开启,无需额外配置。
  • 二级缓存: 适用于多个事务之间共享数据,并且数据更新频率较低的情况。需要手动配置开启。
  • 不适合使用缓存的情况:
    • 数据更新频率非常高。
    • 数据量非常小,直接访问数据库的开销可以忽略不计。
    • 需要保证数据的强一致性。

六、 Spring Boot 集成 MyBatis 缓存

在 Spring Boot 中集成 MyBatis 缓存非常简单。只需要在 application.propertiesapplication.yml 文件中添加以下配置即可:

mybatis.configuration.cache-enabled=true

或者

mybatis:
  configuration:
    cache-enabled: true

然后,按照前面介绍的方法,在 Mapper XML 文件中配置 <cache> 标签即可。

七、 缓存失效的常见原因

  1. 手动清空缓存: 通过调用 sqlSession.clearCache() 方法手动清空一级缓存。
  2. 执行更新操作: 执行任何修改数据库的操作(增删改)都会清空一级缓存。
  3. 事务提交或回滚: sqlSession.commit()sqlSession.rollback() 会清空一级缓存。
  4. SqlSession 关闭: sqlSession.close() 会清空一级缓存。
  5. 缓存过期: 如果配置了缓存刷新间隔,当缓存达到刷新间隔时,会自动刷新缓存。
  6. 缓存淘汰: 当缓存达到最大容量时,会根据缓存回收策略淘汰部分缓存对象。
  7. 配置错误: 例如,cacheEnabled 设置为 false,或者 Mapper XML 文件中没有配置 <cache> 标签。
  8. 数据库数据变更: 即使使用了二级缓存,如果数据库中的数据被其他程序修改了,缓存中的数据就会失效。需要使用一些机制来同步缓存和数据库的数据,例如,使用消息队列或者定时任务。

八、 一些补充说明

  • MyBatis 的缓存是基于 key-value 存储的。 缓存的 key 是 SQL 语句和参数的组合,缓存的 value 是查询结果。
  • MyBatis 的缓存是可插拔的。 可以自定义缓存实现,或者使用第三方缓存框架,例如,Ehcache、Redis、Memcached 等。
  • 需要根据实际情况选择合适的缓存策略。 没有一种缓存策略是万能的。

九、 总结:理解缓存特性,合理应用优化

MyBatis 缓存机制能够有效提升系统性能,但同时也存在一些潜在的问题。充分理解一级缓存和二级缓存的工作原理、常见问题以及优化策略,能够帮助我们更好地使用 MyBatis 缓存,避免踩坑,从而构建更高效、更稳定的应用程序。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注