MyBatis是一個簡單,小巧但功能非常強大的ORM開源框架,它的功能強大也體現在它的緩存機制上。MyBatis提供了一級緩存、二級緩存 這兩個緩存機制,能夠很好地處理和維護緩存,以提高系統的性能。
什么是一級緩存? 為什么使用一級緩存?
每當我們使用MyBatis開啟一次和數據庫的會話,MyBatis會創建出一個SqlSession對象表示一次數據庫會話。
在對數據庫的一次會話中,我們有可能會反復地執行完全相同的查詢語句,如果不采取一些措施的話,每一次查詢都會查詢一次數據庫,而我們在極短的時間內做了完全相同的查詢,那么它們的結果極有可能完全相同,由于查詢一次數據庫的代價很大,這有可能造成很大的資源浪費.
為了解決這一問題,減少資源的浪費,MyBatis會在表示會話的SqlSession對象中建立一個簡單的緩存,將每次查詢到的結果結果緩存起來,當下次查詢的時候,如果判斷先前有個完全一樣的查詢,會直接從緩存中直接將結果取出,返回給用戶,不需要再進行一次數據庫查詢了。
如下圖所示,MyBatis會在一次會話的表示----一個SqlSession對象中創建一個本地緩存(local cache),對于每一次查詢,都會嘗試根據查詢的條件去本地緩存中查找是否在緩存中,如果在緩存中,就直接從緩存中取出,然后返回給用戶;否則,從數據庫讀取數據,將查詢結果存入緩存并返回給用戶。
對于會話(Session)級別的數據緩存,我們稱之為一級數據緩存,簡稱一級緩存。
MyBatis中的一級緩存是怎樣組織的?(即SqlSession中的緩存是怎樣組織的?)
由于MyBatis使用SqlSession對象表示一次數據庫的會話,那么,對于會話級別的一級緩存也應該是在SqlSession中控制的。
實際上, SqlSession只是一個MyBatis對外的接口,SqlSession將它的工作交給了Executor執行器這個角色來完成,負責完成對數據庫的各種操作。當創建了一個SqlSession對象時,MyBatis會為這個SqlSession對象創建一個新的Executor執行器,而緩存信息就被維護在這個Executor執行器中,MyBatis將緩存和對緩存相關的操作封裝成了Cache接口中。SqlSession、Executor、Cache之間的關系如下列類圖所示:
如上述的類圖所示,Executor接口的實現類BaseExecutor中擁有一個Cache接口的實現類PerpetualCache,則對于BaseExecutor對象而言,它將使用PerpetualCache對象維護緩存。
綜上,SqlSession對象、Executor對象、Cache對象之間的關系如下圖所示:
由于Session級別的一級緩存實際上就是使用PerpetualCache維護的,那么PerpetualCache是怎樣實現的呢?
PerpetualCache實現原理其實很簡單,其內部就是通過一個簡單的HashMap<k,v> 來實現的,沒有其他的任何限制。如下是PerpetualCache的實現代碼:
package org.apache.ibatis.cache.impl;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import org.apache.ibatis.cache.Cache;
import org.apache.ibatis.cache.CacheException;
/**
* 使用簡單的HashMap來維護緩存
* @author Clinton Begin
*/
public class PerpetualCache implements Cache {
private String id;
private Map<Object, Object> cache = new HashMap<Object, Object>();
public PerpetualCache(String id) {
this.id = id;
}
public String getId() {
return id;
}
public int getSize() {
return cache.size();
}
public void putObject(Object key, Object value) {
cache.put(key, value);
}
public Object getObject(Object key) {
return cache.get(key);
}
public Object removeObject(Object key) {
return cache.remove(key);
}
public void clear() {
cache.clear();
}
public ReadWriteLock getReadWriteLock() {
return null;
}
public boolean equals(Object o) {
if (getId() == null) throw new CacheException("Cache instances require an ID.");
if (this == o) return true;
if (!(o instanceof Cache)) return false;
Cache otherCache = (Cache) o;
return getId().equals(otherCache.getId());
}
public int hashCode() {
if (getId() == null) throw new CacheException("Cache instances require an ID.");
return getId().hashCode();
}
}
一級緩存的生命周期有多長?
a. MyBatis在開啟一個數據庫會話時,會 創建一個新的SqlSession對象,SqlSession對象中會有一個新的Executor對象,Executor對象中持有一個新的PerpetualCache對象;當會話結束時,SqlSession對象及其內部的Executor對象還有PerpetualCache對象也一并釋放掉。
b. 如果SqlSession調用了close()方法,會釋放掉一級緩存PerpetualCache對象,一級緩存將不可用;
c. 如果SqlSession調用了clearCache(),會清空PerpetualCache對象中的數據,但是該對象仍可使用;
d. SqlSession中執行了任何一個update操作(update()、delete()、insert()) ,都會清空PerpetualCache對象的數據,但是該對象可以繼續使用;
SqlSession 一級緩存的工作流程
對于某個查詢,根據statementId,params,rowBounds來構建一個key值,根據這個key值去緩存Cache中取出對應的key值存儲的緩存結果;
判斷從Cache中根據特定的key值取的數據數據是否為空,即是否命中;
如果命中,則直接將緩存結果返回;
如果沒命中:
4.1 去數據庫中查詢數據,得到查詢結果;
4.2 將key和查詢到的結果分別作為key,value對存儲到Cache中;
4.3. 將查詢結果返回;
Cache接口的設計以及CacheKey的定義(非常重要)
如下圖所示,MyBatis定義了一個org.apache.ibatis.cache.Cache接口作為其Cache提供者的SPI(Service Provider Interface) ,所有的MyBatis內部的Cache緩存,都應該實現這一接口。MyBatis定義了一個PerpetualCache實現類實現了Cache接口,實際上,在SqlSession對象里的Executor 對象內維護的Cache類型實例對象,就是PerpetualCache子類創建的。
MyBatis內部還有很多Cache接口的實現,一級緩存只會涉及到這一個PerpetualCache子類
我們知道,Cache最核心的實現其實就是一個Map,將本次查詢使用的特征值作為key,將查詢結果作為value存儲到Map中。
現在最核心的問題出現了:怎樣來確定一次查詢的特征值?換句話說就是:怎樣判斷某兩次查詢是完全相同的查詢?
也可以這樣說:如何確定Cache中的key值?
MyBatis認為,對于兩次查詢,如果以下條件都完全一樣,那么就認為它們是完全相同的兩次查詢:
1. 傳入的 statementId
2. 查詢時要求的結果集中的結果范圍 (結果的范圍通過rowBounds.offset和rowBounds.limit表示);
3. 這次查詢所產生的最終要傳遞給JDBC java.sql.Preparedstatement的Sql語句字符串(boundSql.getSql() )
4. 傳遞給java.sql.Statement要設置的參數值
現在分別解釋上述四個條件:
傳入的statementId,對于MyBatis而言,你要使用它,必須需要一個statementId,它代表著你將執行什么樣的Sql;
MyBatis自身提供的分頁功能是通過RowBounds來實現的,它通過rowBounds.offset和rowBounds.limit來過濾查詢出來的結果集,這種分頁功能是基于查詢結果的再過濾,而不是進行數據庫的物理分頁;
由于MyBatis底層還是依賴于JDBC實現的,那么,對于兩次完全一模一樣的查詢,MyBatis要保證對于底層JDBC而言,也是完全一致的查詢才行。而對于JDBC而言,兩次查詢,只要傳入給JDBC的SQL語句完全一致,傳入的參數也完全一致,就認為是兩次查詢是完全一致的。
上述的第3個條件正是要求保證傳遞給JDBC的SQL語句完全一致;第4條則是保證傳遞給JDBC的參數也完全一致;
舉一個例子
<select id="selectByCritiera" parameterType="java.util.Map" resultMap="BaseResultMap">
select employee_id,first_name,last_name,email,salary
from louis.employees
where employee_id = #{employeeId}
and first_name= #{firstName}
and last_name = #{lastName}
and email = #{email}
</select>
如果使用上述的"selectByCritiera"進行查詢,那么,MyBatis會將上述的SQL中的#{} 都替換成 ? 如下:
select employee_id,first_name,last_name,email,salary
from louis.employees
where employee_id = ?
and first_name= ?
and last_name = ?
and email = ?
MyBatis最終會使用上述的SQL字符串創建JDBC的java.sql.PreparedStatement對象,對于這個PreparedStatement對象,還需要對它設置參數,調用setXXX()來完成設值,第4條的條件,就是要求對設置JDBC的PreparedStatement的參數值也要完全一致。
即3、4兩條MyBatis最本質的要求就是:
調用JDBC的時候,傳入的SQL語句要完全相同,傳遞給JDBC的參數值也要完全相同
綜上所述,CacheKey由以下條件決定:
statementId + rowBounds + 傳遞給JDBC的SQL + 傳遞給JDBC的參數值
CacheKey的創建
對于每次的查詢請求,Executor都會根據傳遞的參數信息以及動態生成的SQL語句,將上面的條件根據一定的計算規則,創建一個對應的CacheKey對象。
我們知道創建CacheKey的目的,就兩個:
- 根據CacheKey作為key,去Cache緩存中查找緩存結果;
- 如果查找緩存命中失敗,則通過此CacheKey作為key,將從數據庫查詢到的結果作為value,組成key,value對存儲到Cache緩存中。
CacheKey的構建被放置到了Executor接口的實現類BaseExecutor中,定義如下:
/**
* 所屬類: org.apache.ibatis.executor.BaseExecutor
* 功能 : 根據傳入信息構建CacheKey
*/
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
if (closed) throw new ExecutorException("Executor was closed.");
CacheKey cacheKey = new CacheKey();
//1.statementId
cacheKey.update(ms.getId());
//2. rowBounds.offset
cacheKey.update(rowBounds.getOffset());
//3. rowBounds.limit
cacheKey.update(rowBounds.getLimit());
//4. SQL語句
cacheKey.update(boundSql.getSql());
//5. 將每一個要傳遞給JDBC的參數值也更新到CacheKey中
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
for (int i = 0; i < parameterMappings.size(); i++) { // mimic DefaultParameterHandler logic
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
if (boundSql.hasAdditionalParameter(propertyName)) {
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}
//將每一個要傳遞給JDBC的參數值也更新到CacheKey中
cacheKey.update(value);
}
}
return cacheKey;
}
CacheKey的hashcode生成算法
剛才已經提到,Cache接口的實現,本質上是使用的HashMap<k,v>,而構建CacheKey的目的就是為了作為HashMap<k,v>中的key值。而HashMap是通過key值的hashcode 來組織和存儲的,那么,構建CacheKey的過程實際上就是構造其hashCode的過程。下面的代碼就是CacheKey的核心hashcode生成算法
public void update(Object object) {
if (object != null && object.getClass().isArray()) {
int length = Array.getLength(object);
for (int i = 0; i < length; i++) {
Object element = Array.get(object, i);
doUpdate(element);
}
} else {
doUpdate(object);
}
}
private void doUpdate(Object object) {
//1. 得到對象的hashcode;
int baseHashCode = object == null ? 1 : object.hashCode();
//對象計數遞增
count++;
checksum += baseHashCode;
//2. 對象的hashcode 擴大count倍
baseHashCode *= count;
//3. hashCode * 拓展因子(默認37)+拓展擴大后的對象hashCode值
hashcode = multiplier * hashcode + baseHashCode;
updateList.add(object);
}
一級緩存的性能分析
1.MyBatis對會話(Session)級別的一級緩存設計的比較簡單,就簡單地使用了HashMap來維護,并沒有對HashMap的容量和大小進行限制。
讀者有可能就覺得不妥了:如果我一直使用某一個SqlSession對象查詢數據,這樣會不會導致HashMap太大,而導致 java.lang.OutOfMemoryError錯誤?。?大家這么考慮也不無道理,不過MyBatis的確是這樣設計的。
MyBatis這樣設計也有它自己的理由:
a. 一般而言SqlSession的生存時間很短。一般情況下使用一個SqlSession對象執行的操作不會太多,執行完就會消亡;
b. 對于某一個SqlSession對象而言,只要執行update操作(update、insert、delete),都會將這個SqlSession對象中對應的一級緩存清空掉,所以一般情況下不會出現緩存過大,影響JVM內存空間的問題;
c. 可以手動地釋放掉SqlSession對象中的緩存。
** 一級緩存是一個粗粒度的緩存,沒有更新緩存和緩存過期的概念**
MyBatis的一級緩存就是使用了簡單的HashMap,MyBatis只負責將查詢數據庫的結果存儲到緩存中去, 不會去判斷緩存存放的時間是否過長、是否過期,因此也就沒有對緩存的結果進行更新這一說了。
根據一級緩存的特性,在使用的過程中,我認為應該注意
- 對于數據變化頻率很大,并且需要高時效準確性的數據要求,我們使用SqlSession查詢的時候,要控制好SqlSession的生存時間,SqlSession的生存時間越長,它其中緩存的數據有可能就越舊,從而造成和真實數據庫的誤差;同時對于這種情況,用戶也可以手動地適時清空SqlSession中的緩存;
例子
看下面這個例子,下面的例子使用了同一個SqlSession指令了兩次完全一樣的查詢,將兩次查詢所耗的時間打印出來,結果如下:
package com.louis.mybatis.test;
import java.io.InputStream;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.ibatis.executor.BaseExecutor;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.log4j.Logger;
import com.louis.mybatis.model.Employee;
/**
* SqlSession 簡單查詢演示類
* @author louluan
*/
public class SelectDemo1 {
private static final Logger loger = Logger.getLogger(SelectDemo1.class);
public static void main(String[] args) throws Exception {
InputStream inputStream = Resources.getResourceAsStream("mybatisConfig.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(inputStream);
SqlSession sqlSession = factory.openSession();
//3.使用SqlSession查詢
Map<String,Object> params = new HashMap<String,Object>();
params.put("min_salary",10000);
//a.查詢工資低于10000的員工
Date first = new Date();
//第一次查詢
List<Employee> result = sqlSession.selectList("com.louis.mybatis.dao.EmployeesMapper.selectByMinSalary",params);
loger.info("first quest costs:"+ (new Date().getTime()-first.getTime()) +" ms");
Date second = new Date();
result = sqlSession.selectList("com.louis.mybatis.dao.EmployeesMapper.selectByMinSalary",params);
loger.info("second quest costs:"+ (new Date().getTime()-second.getTime()) +" ms");
}
}
由上面的結果你可以看到,第一次查詢耗時464ms,而第二次查詢耗時不足1ms,這是因為第一次查詢后,MyBatis會將查詢結果存儲到SqlSession對象的緩存中,當后來有完全相同的查詢時,直接從緩存中將結果取出。
對上面的例子做一下修改:在第二次調用查詢前,對參數 HashMap類型的params多增加一些無關的值進去,然后再執行,看查詢結果:
MyBatis認為的完全相同的查詢,不是指使用sqlSession查詢時傳遞給算起來Session的所有參數值完完全全相同,你只要保證statementId,rowBounds,最后生成的SQL語句,以及這個SQL語句所需要的參數完全一致就可以了。
小結
- MyBatis的一級查詢緩存(也叫作本地緩存)是基于org.apache.ibatis.cache.impl.PerpetualCache 類的 HashMap本地緩存,其作用域是SqlSession
- 在同一個SqlSession中兩次執行相同的 sql 查詢語句,第一次執行完畢后,會將查詢結果寫入到緩存中,第二次會從緩存中直接獲取數據,而不再到數據庫中進行查詢,這樣就減少了數據庫的訪問,從而提高查詢效率。
- 當一個 SqlSession 結束后,該 SqlSession 中的一級查詢緩存也就不存在了。
- myBatis 默認一級查詢緩存是開啟狀態,且不能關閉。
- 增刪改會清空緩存,無論是否commit
- 當SqlSession關閉和提交時,會清空一級緩存
可能你會有疑惑,我的mybatis bean是由spring 來管理的,已經屏蔽了sqlSession這個東西了?那怎么的一次操作才算是一次sqlSession呢?
spring整合mybatis后,非事務環境下,每次操作數據庫都使用新的sqlSession對象。因此mybatis的一級緩存無法使用(一級緩存針對同一個sqlsession有效)
在開啟事物的情況之下,spring使用threadLocal獲取當前資源綁定同一個sqlSession,因此此時一級緩存是有效的 在開啟以及緩存的時候查詢得到的對象是同一個對象。 這種情況下會出現一個問題。我們先看一下代碼。
public void listMybatisModel() {
List<MybatisModel> mybatisModels = mapper.listMybatisModel();
List<MybatisModel> mybatisModelsOther = mapper.listMybatisModel();
System.out.println(mybatisModels == mybatisModelsOther);
System.out.println("list count: " + mybatisModels.size());
}
System.out.println(mybatisModels == mybatisModelsOther);
輸出結果竟然是true,這樣說來是同一個對象。 會出現這種場景,第一次查出來的對象然后修改了,第二次查出來的就是修改后的對象。