MyBatis一级、二级查询缓存

       想提高查询效率,除了优化查询语句外,还可以从数据库设计、数据存储结构等方面入手。对于一些查询频率非常高的数据,如果每次查询都去访问数据库,无疑是很耗费资源的,为了解决频繁查询数据库的问题,MyBatis的做法是使用自己的缓存结构,把一些查询频率较高的数据放入到自己的缓存中,用户多次查询该数据时,直接从缓存中获取,而不是去访问数据库。这种方式是不是很熟悉?没错,为了提高CPU的效率,我们的做法也是这样的!在看MyBatis的缓存结构前,先来回顾一下操作系统的知识。

 

高速缓存中数据的存取方式

       为了提高CPU的效率,做法是把例如局部变量循环自增自减的操作,将数据从主存读入到CPU的内存中来,每次自增自减访问的是内存中的数据,待一系列操作完成后,再把数据写回主存中去。

       在CPU的高速缓存中,数据的读写单位是“缓存行”,一行的大小通常为32到256个字节,这是从主存复制到高速缓存的最小单位。假设缓存大小为32个字节,我们遍历一个int型数组,每次从中读取4个字节,那么后面的28个字节也会一起被加载到内存中去,从而加快了后面数组的遍历,因为CPU访问高速缓存的速度远快于访问主存的速度,你可以非常快地访问到被额外加载进缓存行中的数据。

       在MyBatis中,为了提高查询效率,也同样支持类似的缓存结构,用来减轻数据库被频繁查询的次数。其内部有两种缓存结构,一级缓存和二级缓存,在默认情况下开启的是一级缓存,想要使用二级缓存则要手动在全局配置文件中配置。使用缓存结构,一条SQL语句被执行后,便会被缓存,之后再次执行这条SQL语句,会直接去缓存中查询数据,不用再去访问数据库。下面就来看看这两种缓存的使用。

 

一级缓存:SqlSession

生命周期和缓存结构

一级缓存的作用域为SqlSession,这个缓存结构在哪里呢?在开启一个数据库对话时,需要SqlSession实例对象接收数据库连接,在SqlSession对象中,包含一个成员变量Executor对象,该对象里有一个PerpetuaCache对象,这个对象里保存着一级缓存结构,来看看PerpetualCache的源码:

public class PerpetualCache implements Cache {
    private final String id;
    private Map<Object, Object> cache = new HashMap<Object, Object>();

    public PerpetualCache(String id) {
        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }

    @Override
    public int getSize() {
        return cache.size();
    }

    @Override
    public void putObject(Object key, Object value) {
        // 存储键值对
        cache.put(key, value);
    }

    @Override
    public Object getObject(Object key) {
        // 查找缓存项
        return cache.get(key);
    }

    @Override
    public Object removeObject(Object key) {
        // 移除缓存项
        return cache.remove(key);
    }

    @Override
    public void clear() {
        cache.clear();
    }
    
    // 省略部分代码
}

可以看到,在SqlSession中的缓存结构是HashMap。

       得益于缓存结构,对于同一个SqlSession实例,多次调用同一个Mapper内的某一个方法,且传入同一个参数(如果有参数传递),之后数据会被保存到内部缓存中,下一次执行同样的操作,则直接从缓存中取出数据。显然,一级缓存的对象是SqlSession,每一个SqlSession实例对象都有自己的缓存数据结构HashMap,各个对象之间互不干扰,即便同一个Mapper内同一个方法,被不同的SqlSession对象调用的话,也还是要重新去访问数据库的。

那么一级缓存的生命周期有多长呢,我了解的有三种:

  1. SqlSession实例对象执行了增、删,改(update()、delete(),insert())等任意一个方法。都会清除对象中的PerpetualCache内的数据,也就是情况缓存。之后这个PerpetualCache对象仍然可以被使用,等下一次缓存。
  2. SqlSession实例对象调用了close()方法。会将里面的PerpetualCache对象释放掉,从而导致一级缓存也被释放,不再可用。
  3. SqlSession实例对象调用了clearCache()方法。会把PerpetualCache对象里的数据清空,等于把缓存清空,之后一级缓存仍然可以被下次使用。

       总的来说,一级缓存的作用域在SqlSession内,因为一级缓存结构HashMap保存在SqlSession中(具体在SqlSession对象中的Executor对象里,Executor对象中又有PerpetualCache对象,它里面保存着一级缓存结构HashMap)。生命周期,如果SqlSession调用了增删改操作,或者clearCache()方法,都会清空里面的一级缓存,以保证缓存区里的数据是最新的。但只要会话没有结束,SqlSession实例对象没有被释放,没有调用close()方法,里面的一级缓存都是可以继续使用的。下面来看一个一级缓存的示例。

一级缓存测试

既然一级缓存是在SqlSession层面上的,那么我们可以写个简单的测试用例,检验一下,对于同一个SqlSession对象,做多次查询调用,会不会多次访问数据库:

// 一级缓存示例
	@Test
	public void Level1CacheInstance() throws Exception {
		SqlSession sqlSession = dataConn.getSqlSession();
		User user1 = sqlSession.selectOne("SQLtest.findUserById", 1);
		StringBuffer result = new StringBuffer();
		result.append("用户名: " + user1.getUsername() + "\r\n");
		result.append("性别: " + user1.getGender() + "\r\n");
		result.append("电子邮箱: " + user1.getEmail() + "\r\n");
		result.append("城市: " + user1.getCity() + "\r\n");
		System.out.println(result.toString());
		
		result.setLength(0);

		User user2 = sqlSession.selectOne("SQLtest.findUserById", 1);
		result.append("用户名: " + user2.getUsername() + "\r\n");
		result.append("性别: " + user2.getGender() + "\r\n");
		result.append("电子邮箱: " + user2.getEmail() + "\r\n");
		result.append("城市: " + user2.getCity() + "\r\n");
		System.out.println(result.toString());
		
		sqlSession.close();
	}

示例中第4行和第14行都使用同一个sqlSession对象,执行相同的findUserById方法,且传入参数都为1,即做两次相同的查询。按照一级缓存的说法,第一次查询会访问数据库,从中获取数据,第二次查询则直接从一级缓存中读取数据,不会访问数据库,来看看执行结果:

执行,从执行输出的日志信息可以看到两次查询输出结果一致,且只进行了一次(也是第一次)数据库访问,第二次输出相同查询信息时,并没有访问数据库,仅打印出了输出结果,说明第二次输出的结果信息不是从数据库中查询出来的,而是从一级缓存中读取的。我们可以再验证一下,在第二次查询前,先把sqlSession对象里的缓存清空,看看这样,第二次查询时是不是会去访问数据库:

// 一级缓存示例
	@Test
	public void Level1CacheInstance() throws Exception {
		SqlSession sqlSession = dataConn.getSqlSession();
		User user1 = sqlSession.selectOne("SQLtest.findUserById", 1);
		StringBuffer result = new StringBuffer();
		result.append("用户名: " + user1.getUsername() + "\r\n");
		result.append("性别: " + user1.getGender() + "\r\n");
		result.append("电子邮箱: " + user1.getEmail() + "\r\n");
		result.append("城市: " + user1.getCity() + "\r\n");
		System.out.println(result.toString());
		
		result.setLength(0);
		sqlSession.clearCache(); //清除一级缓存
		
		User user2 = sqlSession.selectOne("SQLtest.findUserById", 1);
		result.append("用户名: " + user2.getUsername() + "\r\n");
		result.append("性别: " + user2.getGender() + "\r\n");
		result.append("电子邮箱: " + user2.getEmail() + "\r\n");
		result.append("城市: " + user2.getCity() + "\r\n");
		System.out.println(result.toString());
		
		sqlSession.close();
	}

第13行,在执行第二次同样的查询前,先调用clearCache()方法清空sqlSession中的缓存,然后在执行查询,看看结果和输出日志信息有什么不同:

从日志信息可以看到,两次查询都预编译了SQL语句,访问了数据库,对比可以证明第二次查询结果并不是从一级缓存中获取的,而是访问数据库获取的。

除了清空缓存外,对数据库做增删改操作,也一样会清空一级缓存,来看一个例子,假如我们在第一次查询某一用户信息后,第二次做相同查询前,执行一次对该用户的信息更新操作:

// 一级缓存示例
	@Test
	public void Level1CacheInstance() throws Exception {
		SqlSession sqlSession = dataConn.getSqlSession();
		User user1 = sqlSession.selectOne("SQLtest.findUserById", 1);
		StringBuffer result = new StringBuffer();
		result.append("用户名: " + user1.getUsername() + "\r\n");
		result.append("性别: " + user1.getGender() + "\r\n");
		result.append("电子邮箱: " + user1.getEmail() + "\r\n");
		result.append("城市: " + user1.getCity() + "\r\n");
		System.out.println(result.toString());
		
		result.setLength(0);
		// 更新数据库操作
		User userUpdate = new User();
		userUpdate.setId(1);
		userUpdate.setGender("女");
		sqlSession.update("SQLtest.updateUser", userUpdate);
		sqlSession.commit();
		
		User user2 = sqlSession.selectOne("SQLtest.findUserById", 1);
		result.append("用户名: " + user2.getUsername() + "\r\n");
		result.append("性别: " + user2.getGender() + "\r\n");
		result.append("电子邮箱: " + user2.getEmail() + "\r\n");
		result.append("城市: " + user2.getCity() + "\r\n");
		System.out.println(result.toString());
		
		sqlSession.close();
	}

看看执行结果:

很显然,第二次查询也访问了数据库,因为SqlSession类执行了commit()方法后,会清空一级缓存,这样做是为了防止脏读,从上面的例子可以看到,执行完更新操作后,假如下一次查询还是从一级缓存中获取数据,那么这个数据一定是过时的,并不是状态最新的数据。

 

二级缓存:Mapper

二级缓存作用域是Mapper,也就是映射配置文件中,对于多个Mapper实例对象,如果它们加载了同一个Mapper文件,就会共享同一个Mapper二级缓存。不过二级缓存不是默认开启的,需要手动去全局配置文件中以及相应的Mapper文件中配置,下面就一边来配置二级缓存,一边看看它的一些特性。

缓存配置

首先在全局配置文件中开启二级缓存:

<!-- 配置二级缓存 -->
<setting name="cacheEnabled" value="true"/>

然后在相应的Mapper映射配置文件中配置,因为二级缓存是Mapper层面的:

<!-- 二级缓存配置 -->
	<cache eviction="LRU" flushInterval="60000" size="1024" readOnly="true"/>
	<!-- 
		1、eviction:缓存回收策略,有LRU最近最少使用法、FIFO先进先出等。
		2、flushInterval:缓存刷新间隔,单位为毫秒。
		3、size:缓存存储大小。
		4、readOnly:缓存只读。
	 -->

你可以直接配置一个<cache/>标签即可,也可以自定义里面的属性,例如缓存回收算法,缓存刷新时间和存储大小等。接下来看映射的Java实体类。

映射实体类

使用二级缓存的Java实体类,要实现序列化接口,在翻过几篇文章后,原因总结为:“因为二级缓存的存储介质不一定保存在内存中,有可能是硬盘或者服务器,所以要将缓存数据拿出来作反序列化操作。”

// 商品表查询包装类
public class ProductInstance extends Product implements Serializable {
	/**
	 * 
	 */
	private static final long serialVersionUID = 1L;
	private List<ProductAppraise> appraiseList; // 商品的顾客评价信息列表
	
	public ProductInstance() {
		super();
	}
	
	public ProductInstance(int productId, String productName, double productPrice, 
			List<ProductAppraise> productAppraiseList) {
		super(productId, productName, productPrice);
		this.appraiseList = productAppraiseList;
	}

	public List<ProductAppraise> getProductAppraiseList() {
		return appraiseList;
	}

	public void setProductAppraiseList(List<ProductAppraise> productAppraiseList) {
		this.appraiseList = productAppraiseList;
	}
}

最后到十分重要的测试用例。

测试用例

Mapper中的二级缓存区域是按照namespace划分的,如果两个Mapper映射配置文件的namespace相同,那么它们就会被缓存到同一个二级缓存中。对于不同的Mapper实例对象,只要它们加载的是同一个Mapper映射配置文件,那么它们就共享同一个二级缓存,下面来看一个示例,两个Mapper实例对象加载同一个Mapper后,做一样的查询:

// 二级缓存示例
	@Test
	public void Level2CacheInstance() throws Exception {
		SqlSession sqlSession = dataConn.getSqlSession();
		LevelCacheMapper levelCacheMapper = sqlSession.getMapper(LevelCacheMapper.class);
		List<ProductInstance> resultList = levelCacheMapper.queryProductInstance(230270);
		sqlSession.commit();
		StringBuffer result = new StringBuffer();
		
		System.out.println("商品名:" + resultList.get(0).getProductName());
		System.out.println("商品价格:" + resultList.get(0).getProductPrice() + "\r\n");
		
		for(int i=0; i<resultList.size(); i++) {
			List<ProductAppraise> appraiseList = resultList.get(i).getProductAppraiseList();
			ProductAppraise productAppraise = appraiseList.get(0);
			result.append("商品评分:" + productAppraise.getProductScore() + "\r\n");
			result.append("评价用户id:" + productAppraise.getUserId() + "\r\n");
			
			System.out.println(result.toString());
			result.setLength(0);
		}

		SqlSession sqlSession2 = sqlSession;
		LevelCacheMapper levelCacheMapper2 = sqlSession2.getMapper(LevelCacheMapper.class);
		List<ProductInstance> resultList2 = levelCacheMapper2.queryProductInstance(230270);
		result.setLength(0);
	
		System.out.println("商品名:" + resultList2.get(0).getProductName());
		System.out.println("商品价格:" + resultList2.get(0).getProductPrice() + "\r\n");
		
		for(int i=0; i<resultList2.size(); i++) {
			List<ProductAppraise> appraiseList2 = resultList2.get(i).getProductAppraiseList();
			ProductAppraise productAppraise = appraiseList2.get(0);
			result.append("商品评分:" + productAppraise.getProductScore() + "\r\n");
			result.append("评价用户id:" + productAppraise.getUserId() + "\r\n");
			
			System.out.println(result.toString());
			result.setLength(0);
		}

		sqlSession.close();
		sqlSession2.close();
	}

第3行实例化了Mapper对象levelCacheMapper,并加载了相应的Mapper文件,第4行让它调用queryProductInstance()方法做第一次数据库查询。有一点要特别注意,第5行查询方法调用完后,要执行commit()方法!否则二级缓存不会起作用!接下来第22行,实例化另一个Mapper对象levelCacheMapper2,并加载同一个Mapper文件,也让它调用queryProductInstance()方法做查询,如果它能命中二级缓存,那么就不需要再次访问数据库,来看看执行结果:

由输出日志可以看到,第二次查询并没有访问数据库,且Cache Hit Ratio缓存命中率=0.5,表明缓存名字,这次查询结果是从二级缓存中得到的。

 

二级缓存的一些问题

全局生存周期,容易内存溢出?

如果使用了二级缓存,那么二级缓存的读取会优先于一级缓存,因为二级缓存是在Mapper层面上的,所以二级缓存的生命周期是跟着整个程序走的,程序没结束,二级缓存就一直存在于内存中。如果大量的数据不断存储进二级缓存,容易导致内存溢出,为了解决这个问题,可以在二级缓存配置中配置相关的属性,如flushInterval,缓存的刷新间隔,size缓存大小和eviction缓存回收算法。或者在Mapper文件中的SQL语句标签里配置flushCache=true,那么每次执行完SQL语句后都会刷新二级缓存。

多表查询数据脏读问题

二级缓存在多表查询中有一个什么问题呢,二级缓存是在Mapper层面上的,通过namespace来区分,如果多个Mapper映射文件,它们的namespace相同,那么它们就共用一个二级缓存。但是一个Mapper文件里面的SQL语句,无法感知到其他Mapper文件中的SQL语句对数据表所做的操作。

举个例子,Mapper1文件中的SQL语句对数据表做了查询操作,得到的结果存储到二级缓存中,此时另一个Mapper2文件对同一个数据表做了更新操作,提交。最后Mapper1文件中的查询语句再次被执行时,由于Mapper1中,namespace和<select>标签对没有变,SQL查询语句没有变,也没有执行任何增删改操作,那么这次查询就会从二级缓存中直接返回结果,导致数据脏读现象,得到的查询结果是Mapper2做更新操作前的旧数据了。

解决方法是使用cache-ref标签,在Mapper2中配置:

<cache-ref namespace=”Mapper1”>

表示Mapper2引用Mapper1的Cache配置,使得两个Mapper文件内的SQL操作使用的是同一个Cache。

      不过我个人认为这是一个无可奈何的方法,其实我们应该把对同一个表的增删查改操作都放在同一个Mapper文件下,这样来保证数据的安全性,同时也是一种规范问题。

 

日志内所有实现代码已上传:

https://github.com/justinzengtm/SSM-Framework/tree/master/MyBatis

https://gitee.com/justinzeng/MyBatis/tree/master/MyBatisDemo

发布了97 篇原创文章 · 获赞 71 · 访问量 7万+

猜你喜欢

转载自blog.csdn.net/justinzengTM/article/details/100621946