MyBatis 二级缓存深度解析:Per Namespace 与缓存淘汰策略
各位朋友,大家好!今天我们来深入探讨 MyBatis 的二级缓存机制,重点关注 Per Namespace 缓存范围以及各种缓存淘汰策略的配置和优化。
MyBatis 一级缓存(也称为本地缓存)是基于 SqlSession 的,这意味着在一个 SqlSession 内,相同的查询语句只会执行一次,结果会被缓存起来,下次直接从缓存中获取。然而,一级缓存的生命周期很短,随着 SqlSession 的关闭而失效。
为了提高缓存命中率,减少数据库访问压力,MyBatis 提供了二级缓存。二级缓存是基于 SqlSessionFactory 的,这意味着它可以跨多个 SqlSession 共享缓存数据。理解并正确配置二级缓存,对于提升应用性能至关重要。
一、二级缓存的启用与基本配置
首先,我们需要在 MyBatis 的配置文件 mybatis-config.xml 中启用二级缓存。默认情况下,二级缓存是禁用的。
<configuration>
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
<!-- 其他配置 -->
</configuration>
启用二级缓存后,我们需要在对应的 Mapper 文件中配置缓存。
<mapper namespace="com.example.mapper.UserMapper">
<cache/>
<select id="selectUserById" resultType="com.example.entity.User">
SELECT * FROM users WHERE id = #{id}
</select>
<!-- 其他 SQL 语句 -->
</mapper>
<cache/> 标签表示该 Mapper 对应的 namespace 启用了二级缓存。当执行 selectUserById 查询时,如果缓存中没有对应的数据,MyBatis 会从数据库中查询,并将结果放入二级缓存。下次再执行相同的查询时,MyBatis 会直接从二级缓存中获取数据,而不会访问数据库。
二、Per Namespace 缓存范围
MyBatis 的二级缓存是基于 namespace 的。这意味着每个 Mapper 文件对应一个独立的缓存区域。不同的 Mapper 文件即使查询相同的数据,也会存储在不同的缓存区域中。
这种 Per Namespace 的缓存范围有以下优点:
- 隔离性: 不同的 Mapper 之间的数据不会互相干扰,保证了数据的独立性和安全性。
- 灵活性: 可以针对不同的 Mapper 配置不同的缓存策略,例如不同的缓存大小、不同的淘汰策略等。
例如,我们有两个 Mapper:UserMapper 和 OrderMapper。它们分别对应 com.example.mapper.UserMapper 和 com.example.mapper.OrderMapper 两个 namespace。即使 UserMapper 和 OrderMapper 都查询了 users 表中的相同数据,这些数据也会分别存储在 com.example.mapper.UserMapper 和 com.example.mapper.OrderMapper 两个独立的缓存区域中。
三、<cache> 标签的属性详解
<cache/> 标签提供了丰富的属性,用于配置缓存的行为。
| 属性 | 类型 | 描述 | 默认值 |
|---|---|---|---|
| type | 别名 | 指定缓存的实现类。MyBatis 提供了多种内置的缓存实现,例如 PERPETUAL、LRU、FIFO、EVICTION 等。也可以自定义缓存实现。 |
PERPETUAL |
| eviction | 别名 | 指定缓存的淘汰策略。MyBatis 提供了多种内置的淘汰策略,例如 LRU、FIFO、SOFT、WEAK 等。也可以自定义淘汰策略。 |
LRU |
| flushInterval | Long | 设置缓存刷新间隔,单位为毫秒。如果设置为 0,则表示不刷新缓存。 | 不设置,即没有刷新间隔 |
| size | Integer | 设置缓存最多可以存储的对象数量。 | 1024 |
| readOnly | Boolean | 设置缓存是否只读。如果设置为 true,则表示缓存中的对象是只读的,任何修改都会抛出异常。只读缓存可以提高并发性能。 |
false |
| blocking | Boolean | 设置是否使用阻塞缓存。如果设置为 true,则表示当缓存中没有数据时,会阻塞当前线程,直到从数据库中查询到数据并放入缓存。阻塞缓存可以防止缓存击穿。 |
false |
四、缓存淘汰策略
缓存淘汰策略决定了当缓存空间不足时,哪些数据会被移除。MyBatis 提供了多种内置的淘汰策略:
- LRU (Least Recently Used): 移除最近最少使用的对象。这是默认的淘汰策略。
- FIFO (First In First Out): 移除最先进入缓存的对象。
- SOFT (Soft Reference): 移除软引用的对象。软引用是指只有在 JVM 内存不足时才会被回收的对象。
- WEAK (Weak Reference): 移除弱引用的对象。弱引用是指在 JVM 垃圾回收时,无论内存是否充足都会被回收的对象。
选择合适的淘汰策略取决于具体的应用场景。
- 如果应用中的数据访问模式比较稳定,可以考虑使用 LRU 或 FIFO 策略。
- 如果应用中的数据量很大,并且有一些数据很少被访问,可以考虑使用 SOFT 或 WEAK 策略。
五、缓存刷新间隔 (flushInterval)
flushInterval 属性用于设置缓存的刷新间隔。如果设置为 0,则表示不刷新缓存。这意味着缓存中的数据会一直有效,直到应用程序重启。
设置刷新间隔的目的是为了保证缓存中的数据与数据库中的数据保持一致。如果数据库中的数据发生了变化,而缓存中的数据没有及时更新,就会导致读取到脏数据。
刷新间隔的设置需要根据具体的应用场景来决定。
- 如果应用中的数据变化比较频繁,可以设置较短的刷新间隔。
- 如果应用中的数据变化比较少,可以设置较长的刷新间隔,甚至不设置刷新间隔。
六、缓存大小 (size)
size 属性用于设置缓存最多可以存储的对象数量。当缓存中的对象数量达到 size 时,会触发缓存淘汰策略,移除一些对象以腾出空间。
缓存大小的设置需要根据具体的应用场景来决定。
- 如果应用中的数据量比较大,可以设置较大的缓存大小。
- 如果应用中的内存资源比较紧张,可以设置较小的缓存大小。
七、只读缓存 (readOnly)
readOnly 属性用于设置缓存是否只读。如果设置为 true,则表示缓存中的对象是只读的,任何修改都会抛出异常。
只读缓存可以提高并发性能,因为多个线程可以同时访问只读缓存,而不需要进行同步。
如果应用中的数据不会被修改,或者可以容忍一定的数据不一致性,可以考虑使用只读缓存。
八、阻塞缓存 (blocking)
blocking 属性用于设置是否使用阻塞缓存。如果设置为 true,则表示当缓存中没有数据时,会阻塞当前线程,直到从数据库中查询到数据并放入缓存。
阻塞缓存可以防止缓存击穿。缓存击穿是指大量的请求同时访问一个不存在于缓存中的数据,导致这些请求全部落到数据库上,造成数据库压力过大。
如果应用中存在热点数据,并且对数据一致性要求较高,可以考虑使用阻塞缓存。
九、自定义缓存实现
MyBatis 允许开发者自定义缓存实现。要自定义缓存实现,需要实现 org.apache.ibatis.cache.Cache 接口。
public interface Cache {
String getId();
void putObject(Object key, Object value);
Object getObject(Object key);
Object removeObject(Object key);
void clear();
int getSize();
ReadWriteLock getReadWriteLock();
}
Cache 接口定义了缓存的基本操作,例如 putObject、getObject、removeObject 和 clear。
自定义缓存实现可以灵活地控制缓存的行为,例如使用不同的数据结构存储缓存数据、使用不同的序列化方式序列化缓存数据等。
例如,我们可以使用 Redis 作为 MyBatis 的二级缓存。
-
添加 Redis 依赖: 在
pom.xml文件中添加 Redis 的依赖。<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>最新版本</version> </dependency> -
创建 Redis 缓存实现类: 创建一个类,例如
RedisCache,实现org.apache.ibatis.cache.Cache接口。import org.apache.ibatis.cache.Cache; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; public class RedisCache implements Cache { private final String id; private static JedisPool jedisPool; private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); public RedisCache(final String id) { if (id == null) { throw new IllegalArgumentException("Cache instances require an ID"); } this.id = id; // 初始化 JedisPool JedisPoolConfig poolConfig = new JedisPoolConfig(); poolConfig.setMaxTotal(100); // 设置最大连接数 poolConfig.setMaxIdle(50); // 设置最大空闲连接数 poolConfig.setMinIdle(10); // 设置最小空闲连接数 jedisPool = new JedisPool(poolConfig, "localhost", 6379); // 替换为你的 Redis 地址和端口 } @Override public String getId() { return this.id; } @Override public void putObject(Object key, Object value) { Jedis jedis = null; try { jedis = jedisPool.getResource(); jedis.set(key.toString().getBytes(), SerializationUtils.serialize(value)); // 使用序列化工具 } finally { if (jedis != null) { jedis.close(); } } } @Override public Object getObject(Object key) { Jedis jedis = null; Object value = null; try { jedis = jedisPool.getResource(); byte[] bytes = jedis.get(key.toString().getBytes()); if (bytes != null) { value = SerializationUtils.deserialize(bytes); // 使用序列化工具 } } finally { if (jedis != null) { jedis.close(); } } return value; } @Override public Object removeObject(Object key) { Jedis jedis = null; Object value = null; try { jedis = jedisPool.getResource(); value = jedis.del(key.toString().getBytes()); } finally { if (jedis != null) { jedis.close(); } } return value; } @Override public void clear() { Jedis jedis = null; try { jedis = jedisPool.getResource(); jedis.flushDB(); // 清空当前数据库,谨慎使用 } finally { if (jedis != null) { jedis.close(); } } } @Override public int getSize() { // 无法直接获取 Redis 中缓存的大小,可以考虑维护一个计数器 return 0; } @Override public ReadWriteLock getReadWriteLock() { return this.readWriteLock; } } // 序列化工具类 (SerializationUtils) import java.io.*; public class SerializationUtils { public static byte[] serialize(Object obj) { byte[] bytes = null; try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos)) { oos.writeObject(obj); bytes = bos.toByteArray(); } catch (IOException e) { e.printStackTrace(); } return bytes; } public static Object deserialize(byte[] bytes) { Object obj = null; try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes); ObjectInputStream ois = new ObjectInputStream(bis)) { obj = ois.readObject(); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } return obj; } } -
在 MyBatis 配置文件中使用自定义缓存:
<configuration> <settings> <setting name="cacheEnabled" value="true"/> </settings> <typeAliases> <typeAlias alias="RedisCache" type="com.example.cache.RedisCache"/> </typeAliases> <environments default="development"> <environment id="development"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <!-- 数据库配置 --> </dataSource> </environment> </environments> <mappers> <mapper resource="com/example/mapper/UserMapper.xml"/> </mappers> </configuration> -
在 Mapper 文件中配置缓存:
<mapper namespace="com.example.mapper.UserMapper"> <cache type="RedisCache"/> <select id="selectUserById" resultType="com.example.entity.User"> SELECT * FROM users WHERE id = #{id} </select> <!-- 其他 SQL 语句 --> </mapper>
重要提示:
- 序列化: 存储到 Redis 的对象必须是可序列化的(实现
java.io.Serializable接口)。 - 异常处理: 在 Redis 操作中添加适当的异常处理。
- 配置: 根据实际情况调整 Redis 连接池的配置。
- 清空缓存:
jedis.flushDB()会清空整个 Redis 数据库,请谨慎使用。可以考虑使用 namespace 作为 key 的前缀,只清空当前 namespace 下的缓存。 - Key 的设计: 合理设计 Redis 中 key 的结构,方便管理和查找。
- 事务: 如果需要保证多个 Redis 操作的原子性,可以使用 Redis 的事务功能。
十、缓存与更新操作
二级缓存与更新操作(INSERT、UPDATE、DELETE)之间存在一定的关系。默认情况下,当执行更新操作时,MyBatis 会自动清空对应 namespace 的二级缓存。
这种机制可以保证缓存中的数据与数据库中的数据保持一致。但是,如果应用中的更新操作比较频繁,频繁地清空缓存会导致缓存命中率降低,影响性能。
为了解决这个问题,可以考虑以下几种方法:
- 手动刷新缓存: 在执行更新操作后,手动刷新缓存中受影响的数据。
- 使用缓存框架: 使用专业的缓存框架,例如 Ehcache、Redis 等。这些框架提供了更灵活的缓存管理功能,例如可以设置缓存的过期时间、可以监听数据库的变化并自动刷新缓存等。
十一、缓存的配置与性能优化策略
在配置和优化 MyBatis 的二级缓存时,需要考虑以下几个方面:
- 缓存范围: 选择合适的缓存范围。通常情况下,Per Namespace 的缓存范围可以满足大多数应用的需求。但是,如果应用中存在多个 Mapper 文件查询相同的数据,可以考虑使用自定义的缓存实现,将这些数据存储在同一个缓存区域中。
- 缓存淘汰策略: 选择合适的缓存淘汰策略。LRU 是一个不错的选择,但是需要根据具体的应用场景进行调整。
- 缓存大小: 设置合适的缓存大小。缓存大小需要根据应用中的数据量和内存资源来决定。
- 刷新间隔: 设置合适的刷新间隔。刷新间隔需要根据应用中的数据变化频率来决定。
- 只读缓存: 如果应用中的数据不会被修改,可以考虑使用只读缓存。
- 阻塞缓存: 如果应用中存在热点数据,并且对数据一致性要求较高,可以考虑使用阻塞缓存。
- 序列化: 选择合适的序列化方式。序列化方式会影响缓存的性能和存储空间。
- 监控: 监控缓存的命中率和性能。通过监控可以及时发现缓存的问题并进行优化。
十二、二级缓存失效场景
虽然二级缓存能够提升性能,但需要注意以下场景会导致缓存失效,需要重新从数据库加载数据:
- Mapper 配置变更: 任何对Mapper XML文件的修改,即使是很小的改动(例如添加一个注释),都会导致该Namespace下的所有二级缓存失效。MyBatis会认为Mapper的定义已经改变,为了保证数据一致性,会清空缓存。
- 数据库结构变更: 如果数据库表结构发生了变化(例如添加、删除或修改了列),即使Mapper文件没有改变,也应该手动清空相关Namespace的二级缓存。因为缓存中的数据结构可能与新的数据库表结构不匹配,导致数据错误。
- 手动清空缓存: 使用
SqlSessionFactory.getConfiguration().getCache(namespace).clear()可以手动清空指定Namespace的二级缓存。这在某些需要强制刷新缓存的场景下很有用。 - 二级缓存配置变更: 修改了
<cache>标签的任何属性(例如size、eviction等),都会导致该Namespace下的二级缓存失效。 - 事务提交/回滚: 在某些特定的事务配置下,如果事务回滚,可能会导致二级缓存失效。这取决于具体的事务管理器和缓存同步策略。
- 关联表的更新: 如果多个Mapper关联了同一张表,并且其中一个Mapper执行了更新操作,但另一个Mapper没有配置flushCache=true,那么另一个Mapper的二级缓存可能会失效。需要合理配置flushCache属性,确保关联Mapper之间的缓存同步。
- 缓存服务器重启/故障 (对于使用第三方缓存): 如果二级缓存使用了Redis或Ehcache等外部缓存服务器,服务器重启或发生故障会导致缓存数据丢失,需要重新加载。
十三、总结:权衡利弊,合理使用缓存
二级缓存是 MyBatis 中一个强大的特性,可以显著提升应用的性能。但需要理解其工作原理、配置选项和潜在的风险,并根据具体的应用场景进行配置和优化。要时刻注意缓存一致性问题,并选择合适的缓存策略和实现方式。只有这样,才能充分发挥二级缓存的优势,提升应用的整体性能。