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) 中进行配置:
-
开启全局缓存开关: 在
<settings>标签中添加:<settings> <setting name="cacheEnabled" value="true"/> </settings> -
在 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 会对它们进行监控,确保数据一致性。
-
实体类需要实现
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.properties 或 application.yml 文件中添加以下配置即可:
mybatis.configuration.cache-enabled=true
或者
mybatis:
configuration:
cache-enabled: true
然后,按照前面介绍的方法,在 Mapper XML 文件中配置 <cache> 标签即可。
七、 缓存失效的常见原因
- 手动清空缓存: 通过调用
sqlSession.clearCache()方法手动清空一级缓存。 - 执行更新操作: 执行任何修改数据库的操作(增删改)都会清空一级缓存。
- 事务提交或回滚:
sqlSession.commit()或sqlSession.rollback()会清空一级缓存。 - SqlSession 关闭:
sqlSession.close()会清空一级缓存。 - 缓存过期: 如果配置了缓存刷新间隔,当缓存达到刷新间隔时,会自动刷新缓存。
- 缓存淘汰: 当缓存达到最大容量时,会根据缓存回收策略淘汰部分缓存对象。
- 配置错误: 例如,
cacheEnabled设置为false,或者 Mapper XML 文件中没有配置<cache>标签。 - 数据库数据变更: 即使使用了二级缓存,如果数据库中的数据被其他程序修改了,缓存中的数据就会失效。需要使用一些机制来同步缓存和数据库的数据,例如,使用消息队列或者定时任务。
八、 一些补充说明
- MyBatis 的缓存是基于 key-value 存储的。 缓存的 key 是 SQL 语句和参数的组合,缓存的 value 是查询结果。
- MyBatis 的缓存是可插拔的。 可以自定义缓存实现,或者使用第三方缓存框架,例如,Ehcache、Redis、Memcached 等。
- 需要根据实际情况选择合适的缓存策略。 没有一种缓存策略是万能的。
九、 总结:理解缓存特性,合理应用优化
MyBatis 缓存机制能够有效提升系统性能,但同时也存在一些潜在的问题。充分理解一级缓存和二级缓存的工作原理、常见问题以及优化策略,能够帮助我们更好地使用 MyBatis 缓存,避免踩坑,从而构建更高效、更稳定的应用程序。