MyBatis缓存
# MyBatis缓存
# 一级缓存
- 在一个sqlSession中,对User表根据id进行两次查询,查看他们发出sql语句的情况。
public class MyBatisCacheTest {
private UserMapper userMapper;
private SqlSession sqlSession;
@Before
public void before() throws IOException {
// 加载核⼼配置⽂件
InputStream resourceAsStream = Resources.getResourceAsStream("SqlMapConfig.xml");
// 获得sqlSession⼯⼚对象
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);
// 获得sqlSession对象
sqlSession = sqlSessionFactory.openSession();
userMapper = sqlSession.getMapper(UserMapper.class);
}
@After
public void after() {
// 释放资源
sqlSession.close();
}
@Test
public void test1() {
// 第一次查询
User user1 = userMapper.findById(1);
System.out.println(user1);
// 第二次查询
User user2 = userMapper.findById(1);
System.out.println(user2);
}
}
log输出结果(关于日志输出的配置,参考官方文档 (opens new window))。:
DEBUG [main] - Logging initialized using 'class org.apache.ibatis.logging.log4j.Log4jImpl' adapter.
DEBUG [main] - Logging initialized using 'class org.apache.ibatis.logging.log4j.Log4jImpl' adapter.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - Opening JDBC Connection
DEBUG [main] - Created connection 2077742806.
DEBUG [main] - Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - ==> Preparing: select * from user where id = ?
DEBUG [main] - ==> Parameters: 1(Integer)
DEBUG [main] - <== Total: 1
User{id=1, username='jack', password='123456', orderList='null', roleList='null'}
User{id=1, username='jack', password='123456', orderList='null', roleList='null'}
DEBUG [main] - Resetting autocommit to true on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - Closing JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - Returned connection 2077742806 to pool.
- 同样是对user表进行两次查询,只不过两次查询之间进行了一次update操作。
@Test
public void test2() {
// 第一次查询
User user1 = userMapper.findById(1);
System.out.println(user1);
user1.setUsername("jack2");
userMapper.update(user1);
// 提交事务
sqlSession.commit();
// 第二次查询
User user2 = userMapper.findById(1);
System.out.println(user2);
}
控制台输出:
DEBUG [main] - Logging initialized using 'class org.apache.ibatis.logging.log4j.Log4jImpl' adapter.
DEBUG [main] - Logging initialized using 'class org.apache.ibatis.logging.log4j.Log4jImpl' adapter.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - Opening JDBC Connection
DEBUG [main] - Created connection 2077742806.
DEBUG [main] - Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - ==> Preparing: select * from user where id = ?
DEBUG [main] - ==> Parameters: 1(Integer)
DEBUG [main] - <== Total: 1
User{id=1, username='jack2', password='123456', orderList='null', roleList='null'}
DEBUG [main] - ==> Preparing: update user set username = ?, password = ? where id = ?
DEBUG [main] - ==> Parameters: jack2(String), 123456(String), 1(Integer)
DEBUG [main] - <== Updates: 1
DEBUG [main] - Committing JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - ==> Preparing: select * from user where id = ?
DEBUG [main] - ==> Parameters: 1(Integer)
DEBUG [main] - <== Total: 1
User{id=1, username='jack2', password='123456', orderList='null', roleList='null'}
DEBUG [main] - Resetting autocommit to true on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - Closing JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - Returned connection 2077742806 to pool.
总结:
- 第一次发起查询用户id为1的用户信息,先去找缓存中是否有id为1的用户信息,如果没有,从数据库查询用户信息,得到用户信息后,将用户信息存储到一级缓存中。
- 如果中间sqlSession去执行commit操作(执行插入、更新、删除),则会清空SqlSession中的 一级缓存,这样做的目的为了让缓存中存储的是最新的信息,避免脏读。
- 第二次发起查询用户id为1的用户信息,先去找缓存中是否有id为1的用户信息,缓存中有,直接从缓存中获取用户信息。
# 原理探究与源码分析
一级缓存到底是什么?一级缓存什么时候被创建、一级缓存的工作流程是怎样的?相信你现在应该会有 这几个疑问,那么我们本节就来研究一下一级缓存的本质。
大家可以这样想,上面我们一直提到一级缓存,那么提到一级缓存就绕不开SqlSession,所以索性我们就直接从SqlSession探究,看看有没有创建缓存或者与缓存有关的属性或者方法:
调研了一圈,发现上述所有方法中,好像只有 clearCache()
和缓存沾点关系,那么就直接从这个方法入手吧。在分析源码时,我们要了解此类以及它的子类、父类的关系,这样才会对这个类有更深的认识。分析了一圈,你可能会得到如下类图和流程:
流程走到 Perpetualcache
中的 clear
方法之后,会调用其 cache.clear()
方法,这个cache其实是 private Map<Object, Object> cache = new HashMap<Object, Object>(),
也就是一个Map实例对象,所以说 cache.clear()
其实就是 map.clear()
,即缓存其实就是本地存放的一个map对象。
每一个SqISession都会存放一个map对象的引用,那么这个cache是何时创建的呢?你觉得最有可能创建缓存的地方是哪里呢?
我觉得是Executor,为什么?因为Executor是执行器,用来执行SQL请求,而且清除缓存的方法也在Executor中执行,所以很可能缓存的创建也很 有可能在Executor中。在查看Executor类的方法我们可以发现有一个createCacheKey方法,这个方法很像是创建缓存的方法,跟进去看看,你发现 createCacheKey
方法是由 BaseExecutor
执行的,代码如下:
CacheKey cacheKey = new CacheKey();
// id就是Sql语句的所在位置包名+类名+SQL名称, MappedStatement的id
cacheKey.update(ms.getId());
// offset 就是 0
cacheKey.update(rowBounds.getOffset());
// limit 就是 Integer.MAXVALUE
cacheKey.update(rowBounds.getLimit());
// 具体sql语句
cacheKey.update(boundSql.getSql());
...
// sql参数
cacheKey.update(value);
...
if (configuration.getEnvironment() != null) {
// issue #176 , 定义在 mybatis-config.xml 中的 environments -> environment id
cacheKey.update(configuration.getEnvironment().getId());
}
创建缓存key会经过一系列的update方法,udate方法由一个CacheKey这个对象来执行的,这个update方法最终把六个值存进updateList(ArrayList),对照上面的代码和下面的图示,你应该能理解这六个值都是什么了:
回归正题,那么创建完缓存之后该用在何处呢?经过我们对一级缓存的探究之后,我们发现一级缓存更多是用于查询操作,毕竟一级缓存也叫做查询缓存,为什么叫查询缓存我们一会儿说。我们先来看一下这个缓存到底用在哪了,我们跟踪到query方法如下:
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
// 创建缓存
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
// ......
// 优先从缓存取
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
// 主要是处理存储过程
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
// 从数据库查询
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
// ......
}
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
// 缓存
localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
如果缓存中查不到的话,就从数据库查,在 queryFromDatabase
中,会对 localcache
进行写入。localcache
对象的put方法最终交给Map进行存放。
private Map<Object, Object> cache = new HashMap<Object, Object>();
public void putObject(Object key, Object value) {
cache.put(key, value);
}
# 二级缓存
二级缓存的原理和一级缓存原理一样:第一次查询,会将数据放入缓存中,然后第二次查询则会直接去缓存中取。但是一级缓存是基于sqlSession的,而二级缓存是基于mapper文件的namespace的,也就是说多个sqlSession可以共享一个mapper中的二级缓存区域,并且如果两个mapper的namespace相同,即使是两个mapper,那么这两个mapper中执行sql查询到的数据也将存在相同的二级缓存区域中。
# 开启二级缓存
和一级缓存默认开启不一样,二级缓存需要我们手动开启,首先在全局配置文件 sqlMapConfig.xml
文件中加入如下代码:
<configuration>
<settings>
<!--开启二级缓存-->
<setting name="cacheEnabled" value="true"/>
</settings>
<!--.....-->
</configuration>
其次在对应的Mapper文件开启缓存,例如UserMapper.xml文件中:
<!--开启二级缓存-->
<cache></cache>
或者在对应的Mapper类加上 @CacheNamespace(blocking = true)
注解,如UserMapper:
@CacheNamespace(blocking = true)
public interface UserMapper { ... }
**注意,配置文件和接口注释是不能够配合使用的 (二选一)。**我们主要以配置文件的方式为例,同时会讲对应的注解写法。
我们可以看到mapper.xml文件中就这么一个空标签,其实这里可以配置的(后面会说到)。PerpetualCache
这个类是 mybatis
默认实现缓存功能的类。我们不写 type
就使用 mybatis
默认的缓存,也可以去实现Cache接口来自定义缓存:
也就是说如果我们不指定二级缓存的实现,那么二级缓存底层还是HashMap结构。注意的是,开启了二级缓存后,还需要将要缓存的pojo实现Serializable接口,为了将缓存数据取出执行反序列化操作,因为二级缓存数据存储介质多种多样,不一定只存在内存中,有可能存在硬盘中,如果我们要再取这个缓存的话,就需要反序列化了。所以mybatis中的pojo都去实现Serializable接口。例如,User类:
public class User implements Serializable {
private int id;
private String username;
private String password;
//...
}
# 测试用例
测试二级缓存和sqlSession无关
@Test public void testTwoCache() { // 根据sqlSessionFactory 产生 session SqlSession sqlSession1 = sqlSessionFactory.openSession(); SqlSession sqlSession2 = sqlSessionFactory.openSession(); UserMapper userMapper1 = sqlSession1.getMapper(UserMapper.class); UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class); // 第一次查询,发出sql语句,并将查询的结果放入缓存中 User u1 = userMapper1.findById(1); System.out.println(u1); // 第一次查询完后关闭 sqlSession sqlSession1.close(); // 第二次查询,即使sqlSession1已经关闭了,这次查询依然不发出sql语句 User u2 = userMapper2.findById(1); System.out.println(u2); sqlSession2.close(); }
从执行结果可知,上面两个不同的sqlSession,第一个关闭了,第二次查询依然不发出sql查询语句。
测试执行commit()操作,二级缓存数据清空
@Test public void testTwoCache2() { // 根据sqlSessionFactory 产生 session SqlSession sqlSession1 = sqlSessionFactory.openSession(); SqlSession sqlSession2 = sqlSessionFactory.openSession(); SqlSession sqlSession3 = sqlSessionFactory.openSession(); UserMapper userMapper1 = sqlSession1.getMapper(UserMapper.class); UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class); UserMapper userMapper3 = sqlSession3.getMapper(UserMapper.class); // 第一次查询,发出sql语句,并将查询的结果放入缓存中 User u1 = userMapper1.findById(1); System.out.println(u1); // 第一次查询完后关闭 sqlSession sqlSession1.close(); // 执行更新操作,commit() u1.setUsername( "jack3" ); userMapper3.update(u1); sqlSession3.commit(); // 第二次查询,由于上次更新操作,缓存数据已经清空(防止数据脏读),这里必须再次发出sql语句 User u2 = userMapper2.findById(1); System.out.println(u2); sqlSession2.close(); }
第二次查询,由于上次更新操作,缓存数据已经清空(防止数据脏读),这里必须再次发出sql语句。
# <cache/>
配置
mapper文件中配置cache的标签,有以下配置选项(@CacheNamespace
同理):
evication:缓存回收策略
- LRU,最近最少使用的,删除最长时间不用的(默认);
- FIFO,先进先出,按对象进入缓存的顺序来移除他们;
- SOFT,软引用,移除基于垃圾回收器状态和软引用规则的对象;
- WEAK,弱引用,更积极的移除基于垃圾回收器状态和弱引用规则的对象。
flushInterval:刷新间隔时间,单位为毫秒,不配置就是当sql被执行时才刷新
size:引用数目,一个正整数,代表缓存最多可以存储多少个对象,设置过大会导致内存溢出
readonly:只读,可以快速的读取缓存,但没办法修改缓存,默认为false。
<cache blocking="true"/>
@CacheNamespace(blocking = true)
# statement配置
除了mapper的cache标签可配置外,还可以指定映射文件中具体的SQL语句(statement)进行配置(@Options
同理):
- userCache:是否启用二级缓存,默认情况是true。针对每次查询都需要最新的数据sql,建议设置成false,禁用二级缓存,直接从数 据库中获取。
- flushCache:是否刷新二级缓存,默认情况是true。一般下执行完commit操作都需要刷新缓存,默认刷新缓存,这样可以避免数据库脏读。建议默认即可。
<select id="findById" resultType="org.example.hello.mybatis.entity.User" flushCache="false">
select * from user where id = #{id}
</select>
@Select("select * from user where id = #{id}")
@Options(useCache = false)
User findById(int id);
# 二级缓存整合redis
上面我们介绍了MyBatis自带的二级缓存,但是这个缓存是单服务器本地工作,无法实现分布式缓存。 分布式缓存简单来说就是找一个服务器(或集群),专⻔用来存储缓存数据的,不同的服务器的缓存数据都往它那存和取,取缓存数据也从它那里取。目前,市面上的缓存框架有Redis、Memcached、Ehcache等等。这里我们介绍MyBatis与Redis的整合。
刚刚提到过,MyBatis提供了一个 Cache
接口,如果要实现自己的缓存逻辑,实现 Cache
接口开发即可。MybBatis提供了一个针对 Cache
接口的Redis实现类,该类存在 mybatis-redis
包中:
POM文件引入jar包
<dependency> <groupId>org.mybatis.caches</groupId> <artifactId>mybatis-redis</artifactId> <version>1.0.0-beta2</version> </dependency>
映射文件
<cache>
指定 "type" 为具体实现类(注解@CacheNamespace
指定"implementation"):<cache type="org.mybatis.caches.redis.RedisCache" />
@CacheNamespace(blocking = true, implementation = org.mybatis.caches.redis.RedisCache.class)
增加
redis.properties
配置文件:host=localhost port=6379 connectionTimeout=5000 password= database=0
测试用例:
@Test public void testTwoCache4Redis() { SqlSession sqlSession1 = sqlSessionFactory.openSession(); SqlSession sqlSession2 = sqlSessionFactory.openSession(); SqlSession sqlSession3 = sqlSessionFactory.openSession(); UserMapper mapper1 = sqlSession1.getMapper(UserMapper.class); UserMapper mapper2 = sqlSession2.getMapper(UserMapper.class); UserMapper mapper3 = sqlSession3.getMapper(UserMapper.class); User user1 = mapper1.findById(1); System.out.println(user1); sqlSession1.close(); // 清空缓存 User user = new User(); user.setId(1); user.setUsername("jack4"); mapper3.update(user); sqlSession3.commit(); User user2 = mapper2.findById(1); System.out.println(user2); sqlSession2.close(); }
测试结果:
# 源码分析
RedisCache和普遍实现MyBatis的缓存方案大同小异,无非是实现 Cache
接口,并使用 jedis
操作Redis缓存,不过该项目在设计细节上有一些区别:
RedisCache在MyBatis启动的时候,由MyBatis的CacheBuilder创建,创建的方式很简单,就是调用RedisCache的带有String参数的构造方法,即 RedisCache(String id)
,而在RedisCache的构造方法中, 调用了 RedisConfigurationBuilder
来创建 RedisConfig
对象,并由 RedisConfig
来创建 JedisPool
。
public final class RedisCache implements Cache {
// ...
public RedisCache(final String id) {
if (id == null) {
throw new IllegalArgumentException("Cache instances require an ID");
}
this.id = id;
RedisConfig redisConfig = RedisConfigurationBuilder.getInstance().parseConfiguration();
pool = new JedisPool(redisConfig, redisConfig.getHost(), redisConfig.getPort(),
redisConfig.getConnectionTimeout(), redisConfig.getSoTimeout(), redisConfig.getPassword(),
redisConfig.getDatabase(), redisConfig.getClientName());
}
// ...
}
RedisConfig
类继承了 JedisPoolConfig
,并提供了 host
、port
等属性的包装,简单看一下 RedisConfig
的属性:
public class RedisConfig extends JedisPoolConfig {
private String host = Protocol.DEFAULT_HOST;
private int port = Protocol.DEFAULT_PORT;
private int connectionTimeout = Protocol.DEFAULT_TIMEOUT;
private int soTimeout = Protocol.DEFAULT_TIMEOUT;
private String password;
private int database = Protocol.DEFAULT_DATABASE;
private String clientName;
// ...
}
RedisConfig
对象是由 RedisConfigurationBuilder
创建的,简单看下这个类的主要方法:
private static final String SYSTEM_PROPERTY_REDIS_PROPERTIES_FILENAME = "redis.properties.filename";
private static final String REDIS_RESOURCE = "redis.properties";
private RedisConfigurationBuilder() {
redisPropertiesFilename = System.getProperty(SYSTEM_PROPERTY_REDIS_PROPERTIES_FILENAME, REDIS_RESOURCE);
}
public RedisConfig parseConfiguration(ClassLoader classLoader) {
Properties config = new Properties();
InputStream input = classLoader.getResourceAsStream(redisPropertiesFilename);
if (input != null) {
try {
config.load(input);
} catch (IOException e) {
throw new RuntimeException(
"An error occurred while reading classpath property '"
+ redisPropertiesFilename
+ "', see nested exceptions", e);
} finally {
try {
input.close();
} catch (IOException e) {
// close quietly
}
}
}
RedisConfig jedisConfig = new RedisConfig();
setConfigProperties(config, jedisConfig);
return jedisConfig;
}
核心的方法就是 parseConfiguration
方法,该方法从 classpath
中读取一个 redis.properties
文件,并将该配置文件中的内容设置到RedisConfig对象中,并返回。接下来,就是 RedisCache
使用 RedisConfig
类创建完成 JedisPool
的创建,最后在 RedisCache
中实现了一个简单的模板方法,用来操作Redis:
private Object execute(RedisCallback callback) {
Jedis jedis = pool.getResource();
try {
return callback.doWithRedis(jedis);
} finally {
jedis.close();
}
}
模板接口为 RedisCallback
,这个接口中就只需要实现了一个 doWithRedis
方法而已:
public interface RedisCallback {
Object doWithRedis(Jedis jedis);
}
接下来看看 Cache
中最重要的两个方法:putObject
和 getObject
,通过这两个方法来查看 mybatis-redis
储存数据的格式:
@Override
public void putObject(final Object key, final Object value) {
execute(new RedisCallback() {
@Override
public Object doWithRedis(Jedis jedis) {
jedis.hset(id.toString().getBytes(), key.toString().getBytes(), SerializeUtil.serialize(value));
return null;
}
});
}
@Override
public Object getObject(final Object key) {
return execute(new RedisCallback() {
@Override
public Object doWithRedis(Jedis jedis) {
return SerializeUtil.unserialize(jedis.hget(id.toString().getBytes(), key.toString().getBytes()));
}
});
}
可以很清楚的看到,mybatis-redis
在存储数据的时候,使用的是hash结构:
- 把
cache
的id作为这个hash的key (cache的id就是在MyBatis中mapper的namespace,如org.example.hello.mybatis.mapper.UserMapper
)。 - 把mapper中的查询sql语句、查询参数等组装的key对象数据作为 hash 的field。
- 把此次查询的结果直接使用SerializeUtil序列化,作为 hash 的value。(SerializeUtil和其他的序列化类差不多,负责对象的序列化和反序列化);