MyBatis實戰快取機制設計與原理解析
資料快取設計結構
一級快取
Session會話級別的快取,位於表示一次資料庫會話的 SqlSession
物件之中,又被稱之為 本地快取
一級快取是MyBatis內部實現的一個特性, 使用者不能配置
, 預設情況下自動支援的快取
,一般使用者沒有定製它的權利
二級快取
Application應用級別的快取,生命週期長,跟 Application 的生命週期一樣,即作用範圍為整個 Application 應用
快取架構
2工作機制
一級快取的工作機制
一級快取是 Session 會話級別的,一般而言,一個 SqlSession 物件會使用一個 Executor 物件來完成會話操作, Executor 物件會維護一個 Cache 快取,以提高查詢效能
二級快取的工作機制
如上所言,一個 SqlSession 物件會使用一個 Executor 物件來完成會話操作, MyBatis 的二級快取機制的關鍵就是對這個 Executor 物件做文章
如果使用者配置了 cacheEnabled=true
,那麼在為 SqlSession 物件建立 Executor 物件時,會對 Executor 物件加上一個裝飾者 CachingExecutor
,這時 SqlSession 使用 CachingExecutor
物件來完成操作請求 CachingExecutor 對於查詢請求,會先判斷該查詢請求在 Application 級別的二級快取中是否有快取結果
-
如果有查詢結果,則直接返回快取結果
-
如果快取未命中,再交給真正的 Executor 物件來完成查詢操作,之後 CachingExecutor 會將真正 Executor 返回的查詢結果放置到快取中,然後再返回給使用者
MyBatis的二級快取設計得比較靈活,可以使用 MyBatis 自己定義的二級快取實現
也可以通過實現 org.apache.ibatis.cache.Cache 介面自定義快取
也可以使用第三方記憶體快取庫,如 Memcached 等
一級快取原理解析
每當我們使用 MyBatis 開啟一次和資料庫的會話, MyBatis 會創建出 一個SqlSession物件表示一次資料庫會話
在對資料庫的一次會話中,我們有可能會反覆地執行完全相同的查詢語句,如果不採取一些措施的話,每一次查詢都會查詢一次資料庫,而我們在極短的時間內做了完全相同的查詢,那麼它們的結果極有可能完全相同,由於查詢一次資料庫的代價很大,這有可能造成很大的效能損失
為了解決這一問題,減少資源的浪費, MyBatis 會在表示會話的 SqlSession 物件中建立一個簡單的快取,將每次查詢到的結果結果快取起來,當下次查詢的時候,如果判斷先前有個完全一樣的查詢,會直接從快取中直接將結果取出,返回給使用者
如下所示,MyBatis會在一次會話的表示一個SqlSession物件中建立一個本地快取,對於每一次查詢,都會嘗試根據查詢的條件去本地快取中查詢是否在快取中,如果命中,就直接從快取中取出,然後返回給使用者;否則,從資料庫讀取資料,將查詢結果存入快取並返回給使用者
對於會話(Session)級別的資料快取,我們稱之為一級資料快取,簡稱一級快取
1一級快取是怎樣組織
由於 MyBatis 使用 SqlSession 物件表示一次資料庫的會話,那麼,對於會話級別的一級快取也應該是在SqlSession中控制的。
實際上, MyBatis 只是一個 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> 來實現的,沒有其他的任何限制
/**2
*Copyright 2009-2015 the original author or authors.
*
*Licensed under the Apache License, Version 2.0 (the "License");
*you may not use this file except in compliance with the License.
*You may obtain a copy of the License at
*
*http://www.apache.org/licenses/LICENSE-2.0
*
*Unless required by applicable law or agreed to in writing, software
*distributed under the License is distributed on an "AS IS" BASIS,
*WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*See the License for the specific language governing permissions and
*limitations under the License.
*/
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;
/**
* @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;
}
@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();
}
@Override
public ReadWriteLock getReadWriteLock() {
return null;
}
@Override
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());
}
@Override
public int hashCode() {
if (getId() == null) {
throw new CacheException("Cache instances require an ID.");
}
return getId().hashCode();
}
}
一級快取的生命週期
MyBatis在開啟一個數據庫會話時,會 建立一個新的 SqlSession 物件, SqlSession 物件中會有一個新的 Executor 物件, Executor 物件中持有一個新的 PerpetualCache 物件,當會話結束時, SqlSession 物件及其內部的 Executor 物件還有 PerpetualCache 物件也一併釋放掉
如果 SqlSession 呼叫了 close() 方法,會釋放掉一級快取 PerpetualCache 物件,一級快取將不可用
如果SqlSession 呼叫了 clearCache() ,會清空 PerpetualCache**物件中的資料,但是該物件仍可使用;
SqlSession中執行了任何一個 update 操作( update()、delete()、insert() ) ,都會清空 PerpetualCache 物件的資料,但是該物件可以繼續使用;
-
對於某個查詢,根據 statementId,params,rowBounds 來構建一個 key 值,根據這個 key 值去快取 Cache 中取出對應的 key 值儲存的快取結果
-
判斷從 Cache 中根據特定的 key 值取的資料資料是否為空,即是否命中;
-
如果命中,則直接將快取結果返回;
-
如果沒命中
-
去資料庫中查詢資料,得到查詢結果;
-
將key和查詢到的結果分別作為 key , value 對儲存到 Cache 中;
-
將查詢結果返回;
5. 結束
Cache介面的設計
MyBatis定義了一個 org.apache.ibatis.cache.Cache 介面作為其 Cache 提供者的 SPI(Service Provider Interface) ,所有的 MyBatis 內部的 Cache 快取,都應該實現這一介面
MyBatis定義了一個 PerpetualCache 實現類實現了 Cache 介面, 實際上,在SqlSession物件裡的Executor物件內維護的 Cache 型別例項物件,就是 PerpetualCache 子類建立的
Cache最核心的實現其實就是一個Map,將本次查詢使用的特徵值作為key,將查詢結果作為value儲存到 Map 中
現在最核心的問題出現了: 怎樣來確定一次查詢的特徵值?
換句話說就是: 怎樣判斷某兩次查詢是完全相同的查詢?
也可以這樣說: 如何確定****Cache****中的key值?
MyBatis認為,對於兩次查詢,如果以下條件都完全一樣,那麼就認為它們是完全相同的查詢
-
傳入的 statementId
-
查詢時要求的結果集中的結果範圍 (結果的範圍通過rowBounds.offset和rowBounds.limit表示)
-
這次查詢所產生的最終要傳遞給JDBC java.sql.Preparedstatement的Sql語句字串(boundSql.getSql())
-
傳遞給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
whereemployee_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
whereemployee_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的引數值
5CacheKey的建立
對於每次的查詢請求, Executor 都會根據傳遞的引數資訊以及動態生成的 SQL 語句,將上面的條件根據一定的計算規則,建立一個對應的 CacheKey 物件
建立 CacheKey 的目的,就兩個:
-
根據 CacheKey 作為 key ,去 Cache 快取 中查詢快取結果;
-
如果查詢快取命中失敗,則通過此 CacheKey 作為 key ,將 從資料庫查詢到的結果 作為 value ,組成 key , value 對儲存到 Cache 快取中
CacheKey的構建被放置到了 Executor 介面的實現類 BaseExecutor 中,定義如下:
功能 : 根據傳入資訊構建CacheKey
@Override
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();
// mimic DefaultParameterHandler logic
for (ParameterMapping parameterMapping : parameterMappings) {
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);
}
}
if (configuration.getEnvironment() != null) {
// issue #176
cacheKey.update(configuration.getEnvironment().getId());
}
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這樣設計也有它自己的理由
-
一般而言 SqlSession 的生存時間很短
一般情況下使用一個 SqlSession 物件執行的操作不會太多,執行完就會消亡
-
對於某一個 SqlSession 物件而言,只要執行 update 操作( update、insert、delete ),都會將這個 SqlSession 物件中對應的一級快取清空掉
所以一般情況下不會出現快取過大,影響JVM記憶體空間的問題
-
可以手動地釋放掉 SqlSession 物件中的快取
2. 一級快取是一個粗粒度的快取,沒有更新快取和快取過期的概念
MyBatis的一級快取就是使用了簡單的 HashMap , MyBatis 只負責將查詢資料庫的結果儲存到快取中去, 不會去判斷快取存放的時間是否過長、是否過期,因此也就沒有對快取的結果進行更新這一說了,根據一級快取的特性,在使用的過程中,我認為應該注意
-
對於資料變化頻率很大,並且需要高時效準確性的資料要求,我們使用 SqlSession 查詢的時候,要控制好 SqlSession 的生存時間, SqlSession 的生存時間越長,它其中快取的資料有可能就越舊,從而造成和真實資料庫的誤差;同時對於這種情況,使用者也可以手動地適時清空 SqlSession 中的快取;
-
對於只執行、並且頻繁執行大範圍的 select 操作的 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;
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多增加一些無關的值進去,然後再執行,看查詢結果
從結果上看,雖然第二次查詢時傳遞的params引數不一致,但還是從一級快取中取出了第一次查詢的快取。
MyBatis認為的完全相同的查詢,不是指使用sqlSession查詢時傳遞給算起來Session的所有引數值完完全全相同,你只要保證statementId,rowBounds,最後生成的SQL語句,以及這個SQL語句所需要的引數完全一致就可以了。
二級快取原理解析
MyBatis的二級快取是Application級別的快取,它可以提高對資料庫查詢的效率,以提高應用的效能
1MyBatis的快取機制整體設計以及二級快取的工作模式
當開一個會話時,一個 SqlSession 物件會使用一個 Executor 物件來完成會話操作, MyBatis 的二級快取機制的關鍵就是對這個 Executor 物件做文章
如果使用者配置了 cacheEnabled=true
,那麼 MyBatis 在為 SqlSession 物件建立 Executor 物件時,會對 Executor 物件加上一個裝飾者: CachingExecutor ,這時 SqlSession 使用 CachingExecutor 物件來完成操作請求
CachingExecutor對於查詢請求,會先判斷該查詢請求在 Application 級別的二級快取中是否有快取結果
-
如果有查詢結果,則直接返回快取結果
-
如果快取中沒有,再交給真正的 Executor 物件來完成查詢操作,之後 CachingExecutor 會將真正 Executor 返回的查詢結果放置到快取中,然後在返回給使用者
CachingExecutor是 Executor 的裝飾者,以增強 Executor 的功能,使其具有
快取查詢
功能,這裡用到了設計模式中的裝飾者模式,CachingExecutor和 Executor 的介面的關係如下類圖所示:

2 MyBatis二級快取的劃分
MyBatis並不是簡單地對整個 Application 就只有一個 Cache 快取物件,它將快取劃分的更細,即是 Mapper 級別的,即每一個Mapper都可以擁有一個 Cache 物件,具體如下:
a.為每一個Mapper分配一個Cache快取物件(使用<cache>節點配置)
b.多個Mapper共用一個Cache快取物件(使用<cache-ref>節點配置)
如果你想讓多個 Mapper 公用一個 Cache 的話,你可以使用 <cache-ref namespace=""> 節點,來指定你的這個 Mapper 使用到了哪一個 Mapper 的 Cache 快取
使用二級快取,必須要具備的條件
MyBatis對二級快取的支援粒度很細,它會指定某一條查詢語句是否使用二級快取
雖然在 Mapper 中配置了 <cache> ,並且為此 Mapper 分配了 Cache 物件,這並不表示我們使用 Mapper 中定義的查詢語句查到的結果都會放置到 Cache 物件之中
必須指定 Mapper 中的某條選擇語句是否支援快取,即如下所示,在 <select> 節點中配置 useCache="true" , Mapper 才會對此 Select 的查詢支援快取,否則,不會對此 Select 查詢,不會經過 Cache 快取
如下, Select 語句配置了 useCache="true" ,則表明這條 Select 語句的查詢會使用二級快取。
<select id="selectByMinSalary" resultMap="BaseResultMap" parameterType="java.util.Map" useCache="true">
總之,要想使某條 Select 查詢支援二級快取,你需要保證
-
MyBatis支援二級快取的總開關:全域性配置變數引數 cacheEnabled=true
-
該select語句所在的Mapper,配置了<cache> 或<cached-ref>節點,並且有效
-
該select語句的引數 useCache=true
一級快取和二級快取的使用順序
如果你的 MyBatis 使用了二級快取,並且 Mapper 和 select 語句也配置使用了二級快取,那麼在執行 select 查詢的時候, MyBatis 會先從二級快取中取輸入,其次才是一級快取,即 MyBatis 查詢資料的順序是:
二級快取 ———> 一級快取——> 資料庫
二級快取實現的選擇
MyBatis對二級快取的設計非常靈活,它自己內部實現了一系列的 Cache 快取實現類,並提供了各種快取重新整理策略如 LRU,FIFO 等等
另外, MyBatis 還允許使用者自定義 Cache 介面實現,使用者是需要實現 org.apache.ibatis.cache.Cache 介面,然後將 Cache 實現類配置在 <cache type=""> 節點的 type 屬性上即可
除此之外, MyBatis 還支援跟第三方記憶體快取庫如 Memecached 的整合,總之,使用 MyBatis 的二級快取有三個選擇
-
1.MyBatis自身提供的快取實現
-
2. 使用者自定義的Cache介面實現
-
3.跟第三方記憶體快取庫的整合
MyBatis自身提供的二級快取的實現
MyBatis自身提供了豐富的,並且功能強大的二級快取的實現,它擁有一系列的 Cache 介面裝飾者,可以滿足各種對快取操作和更新的策略。
MyBatis定義了大量的 Cache 的裝飾器來增強 Cache 快取的功能
對於每個 Cache 而言,都有一個容量限制, MyBatis 提供了各種策略來對 Cache 快取的容量進行控制,以及對 Cache 中的資料進行重新整理和置換
MyBatis主要提供了以下幾個重新整理和置換策略:
-
LRU:(Least Recently Used),最近最少使用演算法,即如果快取中容量已經滿了,會將快取中最近做少被使用的快取記錄清除掉,然後新增新的記錄;
-
FIFO:(First in first out),先進先出演算法,如果快取中的容量已經滿了,那麼會將最先進入快取中的資料清除掉;
-
Scheduled:指定時間間隔清空演算法,該演算法會以指定的某一個時間間隔將 Cache 快取中的資料清空;
寫在後面(關於涉及到的設計模式)
在二級快取的設計上,MyBatis大量地運用了裝飾者模式,如CachingExecutor, 以及各種Cache介面的裝飾器