MyBatis實戰緩存機制設計與原理解析——一級緩存

一級緩存

Session會話級別的緩存,位於表示一次數據庫會話的SqlSession對象之中,又被稱之爲本地緩存java

一級緩存是MyBatis內部實現的一個特性,用戶不能配置默認狀況下自動支持的緩存,通常用戶沒有定製它的權利算法

 二級緩存

Application應用級別的緩存,生命週期長,跟Application的生命週期同樣,即做用範圍爲整個Application應用sql

2工做機制

一級緩存的工做機制

一級緩存是Session會話級別的,通常而言,一個SqlSession對象會使用一個Executor對象來完成會話操做,Executor對象會維護一個Cache緩存,以提升查詢性能數據庫

 

二級緩存的工做機制

如上所言,一個SqlSession對象會使用一個Executor對象來完成會話操做,MyBatis的二級緩存機制的關鍵就是對這個Executor對象作文章;express

若是用戶配置了cacheEnabled=true,那麼在爲SqlSession對象建立Executor對象時,會對Executor對象加上一個裝飾者 CachingExecutor,這時SqlSession使用CachingExecutor對象來完成操做請求CachingExecutor對於查詢請求,會先判斷該查詢請求在Application級別的二級緩存中是否有緩存結果;apache

  • 若是有查詢結果,則直接返回緩存結果;緩存

  • 若是緩存未命中,再交給真正的Executor對象來完成查詢操做,以後CachingExecutor會將真正Executor返回的查詢結果放置到緩存中,而後再返回給用戶; session

MyBatis的二級緩存設計得比較靈活,可使用MyBatis本身定義的二級緩存實現
也能夠經過實現org.apache.ibatis.cache.Cache接口自定義緩存
也可使用第三方內存緩存庫,如Memcachedmybatis

           

一級緩存原理解析

每當咱們使用MyBatis開啓一次和數據庫的會話,MyBatis會建立出一個SqlSession對象表示一次數據庫會話; 架構

在對數據庫的一次會話中,咱們有可能會反覆地執行徹底相同的查詢語句,若是不採起一些措施的話,每一次查詢都會查詢一次數據庫,而咱們在極短的時間內作了徹底相同的查詢,那麼它們的結果極有可能徹底相同,因爲查詢一次數據庫的代價很大,這有可能形成很大的性能損失;

爲了解決這一問題,減小資源的浪費,MyBatis會在表示會話的SqlSession對象中創建一個簡單的緩存,將每次查詢到的結果結果緩存起來,當下次查詢的時候,若是判斷先前有個徹底同樣的查詢,會直接從緩存中直接將結果取出,返回給用戶;

以下所示,MyBatis會在一次會話的表示一個SqlSession對象中建立一個本地緩存,對於每一次查詢,都會嘗試根據查詢的條件去本地緩存中查找是否在緩存中,若是命中,就直接從緩存中取出,而後返回給用戶;不然,從數據庫讀取數據,將查詢結果存入緩存並返回給用戶;

對於會話(Session)級別的數據緩存,咱們稱之爲一級數據緩存,簡稱一級緩存; 

1一級緩存是怎樣組織

因爲MyBatis使用SqlSession對象表示一次數據庫的會話,那麼,對於會話級別的一級緩存也應該是在SqlSession中控制的。

實際上, MyBatis只是一個MyBatis對外的接口,SqlSession將它的工做交給了Executor執行器這個角色來完成,負責完成對數據庫的各類操做

當建立了一個SqlSession對象時,MyBatis會爲這個SqlSession對象建立一個新的Executor執行器,而緩存信息就被維護在這個Executor執行器中,MyBatis將緩存和對緩存相關的操做封裝成了Cache接口中。

SqlSessionExecutorCache之間的關係以下列類圖所示:

如上述的類圖所示,Executor接口的實現類BaseExecutor中擁有一個Cache接口的實現類PerpetualCache,則對於BaseExecutor對象而言,它將使用PerpetualCache對象維護緩存;

綜上,SqlSession對象、Executor對象、Cache對象之間的關係以下圖所示:

因爲Session級別的一級緩存實際上就是使用PerpetualCache維護的,那麼PerpetualCache是怎樣實現的呢?

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

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

}

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. 結束 

4Cache接口的設計

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的參數值也要徹底一致

  • 即三、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的一級緩存就是使用了簡單的HashMapMyBatis只負責將查詢數據庫的結果存儲到緩存中去, 不會去判斷緩存存放的時間是否過長、是否過時,所以也就沒有對緩存的結果進行更新這一說了,根據一級緩存的特性,在使用的過程當中,我認爲應該注意

  • 對於數據變化頻率很大,而且須要高時效準確性的數據要求,咱們使用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語句所須要的參數徹底一致就能夠了。

 

注:文章來源《雲時代架構公衆號》

原文章連接:https://www.jianshu.com/p/e4ff5a032c5a;  

相關文章
相關標籤/搜索