1. 程式人生 > >mybatis快取那些事(一)

mybatis快取那些事(一)

前言

mybatis快取作為mybatis中的基礎知識還是很有必要研究透的,mybatis快取分為一級快取(本地快取)和二級快取。這裡主要和大家一起學習一下mybatis中的一級快取。一級快取是Session會話級別的快取,位於表示一次資料庫會話的SqlSession物件之中,又被稱之為本地快取。它是MyBatis內部實現的一個特性,使用者不能配置預設情況下自動支援的快取,一般使用者沒有定製它的權利。

一級快取的工作機制

一級快取是Session會話級別的,一般而言,一個SqlSession物件會使用一個Executor物件來完成會話操作,Executor物件會維護一個Cache

快取,以提高查詢效能。

一級快取原理解析 

每當我們使用MyBatis開啟一次和資料庫的會話,MyBatis會創建出一個SqlSession物件表示一次資料庫會話。在對資料庫的一次會話中,我們有可能會反覆地執行完全相同的查詢語句,如果不採取一些措施的話,每一次查詢都會查詢一次資料庫,而我們在極短的時間內做了完全相同的查詢,那麼它們的結果極有可能完全相同,由於查詢一次資料庫的代價很大,這有可能造成很大的效能損失。為了解決這一問題,減少資源的浪費,MyBatis會在表示會話的SqlSession物件中建立一個簡單的快取,將每次查詢到的結果結果快取起來,當下次查詢的時候,如果判斷先前有個完全一樣的查詢,會直接從快取中直接將結果取出,返回給使用者。

1. 一級快取是怎樣組織

由於MyBatis使用SqlSession物件表示一次資料庫的會話,那麼,對於會話級別的一級快取也應該是在SqlSession中控制的。實際上, SqlSession只是一個MyBatis對外的介面,SqlSession將它的工作交給了Executor執行器這個角色來完成,負責完成對資料庫的各種操作。當建立了一個SqlSession物件時,MyBatis會為這個SqlSession物件建立一個新的Executor執行器,而快取資訊就被維護在這個Executor執行器中,MyBatis將快取和對快取相關的操作封裝成了Cache介面中。

SqlSession

ExecutorCache之間的關係如下列類圖所示:

如上述的類圖所示,Executor介面的實現類BaseExecutor中擁有一個Cache介面的實現類PerpetualCache,則對於BaseExecutor物件而言,它將使用PerpetualCache物件維護快取

綜上,SqlSession物件、Executor物件、Cache物件之間的關係如下圖所示:

 由於Session級別的一級快取實際上就是使用PerpetualCache維護的,那麼PerpetualCache是怎樣實現的呢?

PerpetualCache實現原理其實很簡單,其內部就是通過一個簡單的HashMap<k,v>來實現的,沒有其他的任何限制

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();
 }

}

2. 一級快取的生命週期

MyBatis在開啟一個數據庫會話時,會 建立一個新的SqlSession物件,SqlSession物件中會有一個新的Executor物件,Executor物件中持有一個新的PerpetualCache物件,當會話結束時,SqlSession物件及其內部的Executor物件還有PerpetualCache物件也一併釋放掉。

如果SqlSession呼叫了close()方法,會釋放掉一級快取PerpetualCache物件,一級快取將不可用;

如果SqlSession呼叫了clearCache(),會清空PerpetualCache物件中的資料,但是該物件仍可使用;

SqlSession中執行了任何一個update操作(update()、delete()、insert()) ,都會清空PerpetualCache物件的資料,但是該物件可以繼續使用;

3. 一級快取的工作流程

  1. 對於某個查詢,根據statementId,params,rowBounds來構建一個key值,根據這個key值去快取Cache中取出對應的key值儲存的快取結果

  2. 判斷從Cache中根據特定的key值取的資料資料是否為空,即是否命中;

  3. 如果命中,則直接將快取結果返回;

  4. 如果沒命中  

  •  去資料庫中查詢資料,得到查詢結果;

  •  將key和查詢到的結果分別作為key,value對儲存到Cache中;

  •  將查詢結果返回;

    5. 結束 

4. 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.offsetrowBounds.limit來過濾查詢出來的結果集,這種分頁功能是基於查詢結果的再過濾,而不是進行資料庫的物理分頁

  • 由於MyBatis底層還是依賴於JDBC實現的,那麼,對於兩次完全一模一樣的查詢,MyBatis要保證對於底層JDBC而言,也是完全一致的查詢才行。而對於JDBC而言,兩次查詢,只要傳入給JDBCSQL語句完全一致,傳入的引數也完全一致,就認為是兩次查詢是完全一致的

上述的第3個條件正是要求保證傳遞給JDBCSQL語句完全一致,第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字串建立JDBCjava.sql.PreparedStatement物件,對於這個PreparedStatement物件,還需要對它設定引數,呼叫setXXX()來完成設值

第4條的條件,就是要求對設定JDBCPreparedStatement的引數值也要完全一致

  • 即3、4兩條MyBatis最本質的要求
    呼叫JDBC的時候,傳入的SQL語句要完全相同,傳遞給JDBC的引數值也要完全相同

綜上所述,CacheKey由以下條件決定:
statementId  + rowBounds  + 傳遞給JDBC的SQL  + 傳遞給JDBC的引數值

5. CacheKey的建立

對於每次的查詢請求,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的一級快取就是使用了簡單的HashMapMyBatis只負責將查詢資料庫的結果儲存到快取中去, 不會去判斷快取存放的時間是否過長、是否過期,因此也就沒有對快取的結果進行更新這一說了,根據一級快取的特性,在使用的過程中,我認為應該注意

  • 對於資料變化頻率很大,並且需要高時效準確性的資料要求,我們使用SqlSession查詢的時候,要控制好SqlSession的生存時間,SqlSession的生存時間越長,它其中快取的資料有可能就越舊,從而造成和真實資料庫的誤差;同時對於這種情況,使用者也可以手動地適時清空SqlSession中的快取;

  • 對於只執行、並且頻繁執行大範圍的select操作的SqlSession物件,SqlSession物件的生存時間不應過長。

舉例:
下面的例子使用了同一個SqlSession指令了兩次完全一樣的查詢,將兩次查詢所耗的時間打印出來,結果如下

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的一級快取原理和應用已經底層實現原理。開頭也說過一級快取一般不需要使用者去配置,是自帶的,我們只需要知道其原理,在日常開發中知道有這麼回事。後面會和大家一起來學習下mybatis的二級快取。對於二級快取才是我們應該注意和重視的點。

原文連結

https://www.jianshu.com/p/edb356ec0be3

作者

芥末無疆sss