天天看點

MyBatis - 一級緩存

MyBatis是一個簡單,小巧但功能非常強大的ORM開源架構,它的功能強大也展現在它的緩存機制上。MyBatis提供了一級緩存、二級緩存 這兩個緩存機制,能夠很好地處理和維護緩存,以提高系統的性能。本文的目的則是向讀者詳細介紹MyBatis的一級緩存,深入源碼,解析MyBatis一級緩存的實作原理,并且針對一級緩存的特點提出了在實際使用過程中應該注意的事項。

讀完本文,你将會學到

1、什麼是一級緩存?為什麼使用一級緩存?

2、MyBatis的一級緩存是怎樣組織的?(即SqlSession對象中的緩存是怎樣組織的?)

3、一級緩存的生命周期有多長?

4、Cache接口的設計以及CacheKey的定義。

5、一級緩存的性能分析以及應該注意的事項。

1、什麼是一級緩存? 為什麼使用一級緩存?

每當我們使用MyBatis開啟一次和資料庫的會話,MyBatis會建立出一個SqlSession對象表示一次資料庫會話。

在對資料庫的一次會話中,我們有可能會反複地執行完全相同的查詢語句,如果不采取一些措施的話,每一次查詢都會查詢一次資料庫,而我們在極短的時間内做了完全相同的查詢,那麼它們的結果極有可能完全相同,由于查詢一次資料庫的代價很大,這有可能造成很大的資源浪費。

為了解決這一問題,減少資源的浪費,MyBatis會在表示會話的SqlSession對象中建立一個簡單的緩存,将每次查詢到的結果結果緩存起來,當下次查詢的時候,如果判斷先前有個完全一樣的查詢,會直接從緩存中直接将結果取出,傳回給使用者,不需要再進行一次資料庫查詢了。

如下圖所示,MyBatis會在一次會話的表示----一個SqlSession對象中建立一個本地緩存(local cache),對于每一次查詢,都會嘗試根據查詢的條件去本地緩存中查找是否在緩存中,如果在緩存中,就直接從緩存中取出,然後傳回給使用者;否則,從資料庫讀取資料,将查詢結果存入緩存并傳回給使用者。

MyBatis - 一級緩存

對于會話(Session)級别的資料緩存,我們稱之為一級資料緩存,簡稱一級緩存。

2、MyBatis中的一級緩存是怎樣組織的?(即SqlSession中的緩存是怎樣組織的?)

由于MyBatis使用SqlSession對象表示一次資料庫的會話,那麼,對于會話級别的一級緩存也應該是在SqlSession中控制的。

實際上, MyBatis隻是一個MyBatis對外的接口,SqlSession将它的工作交給了Executor執行器這個角色來完成,負責完成對資料庫的各種操作。當建立了一個SqlSession對象時,MyBatis會為這個SqlSession對象建立一個新的Executor執行器,而緩存資訊就被維護在這個Executor執行器中,MyBatis将緩存和對緩存相關的操作封裝成了Cache接口中。SqlSession、Executor、Cache之間的關系如下列類圖所示:

MyBatis - 一級緩存

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

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

MyBatis - 一級緩存

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

3、一級緩存的生命周期有多長?

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對象的資料,但是該對象可以繼續使用;

MyBatis - 一級緩存

4、SqlSession 一級緩存的工作流程

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

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

3. 如果命中,則直接将緩存結果傳回;

4. 如果沒命中:

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

        4.2  将key和查詢到的結果分别作為key,value對存儲到Cache中;

        4.3. 将查詢結果傳回;

5. 結束。

[關于上述工作過程中 key值的建構,我們将在第下一節中重點探讨,這也是MyBatis緩存機制中非常重要的一個概念。]

MyBatis - 一級緩存

5、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的其他實作将會放到二級緩存中介紹)。

MyBatis - 一級緩存

我們知道,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要設定的參數值

現在分别解釋上述四個條件:

1. 傳入的statementId,對于MyBatis而言,你要使用它,必須需要一個statementId,它代表着你将執行什麼樣的Sql;

2. MyBatis自身提供的分頁功能是通過RowBounds來實作的,它通過rowBounds.offset和rowBounds.limit來過濾查詢出來的結果集,這種分頁功能是基于查詢結果的再過濾,而不是進行資料庫的實體分頁;

由于MyBatis底層還是依賴于JDBC實作的,那麼,對于兩次完全一模一樣的查詢,MyBatis要保證對于底層JDBC而言,也是完全一緻的查詢才行。而對于JDBC而言,兩次查詢,隻要傳入給JDBC的SQL語句完全一緻,傳入的參數也完全一緻,就認為是兩次查詢是完全一緻的。

上述的第3個條件正是要求保證傳遞給JDBC的SQL語句完全一緻;第4條則是保證傳遞給JDBC的參數也完全一緻;

3、4講的有可能比較含糊,舉一個例子:

<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的參數值

附1:CacheKey的建立

對于每次的查詢請求,Executor都會根據傳遞的參數資訊以及動态生成的SQL語句,将上面的條件根據一定的計算規則,建立一個對應的CacheKey對象。

我們知道建立CacheKey的目的,就兩個:

    1. 根據CacheKey作為key,去Cache緩存中查找緩存結果;

    2. 如果查找緩存命中失敗,則通過此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;
  }      

附2: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);
}      

6、一級緩存的性能分析以及應該注意的事項

我将從兩個 一級緩存的特性來讨論SqlSession的一級緩存性能問題:

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對象中的緩存。

2. 一級緩存是一個粗粒度的緩存,沒有更新緩存和緩存過期的概念

      MyBatis的一級緩存就是使用了簡單的HashMap,MyBatis隻負責将查詢資料庫的結果存儲到緩存中去, 不會去判斷緩存存放的時間是否過長、是否過期,是以也就沒有對緩存的結果進行更新這一說了。

附:根據一級緩存的特性,在使用的過程中,我認為應該注意:

(1)對于資料變化頻率很大,并且需要高時效準确性的資料要求,我們使用SqlSession查詢的時候,要控制好SqlSession的生存時間,SqlSession的生存時間越長,它其中緩存的資料有可能就越舊,進而造成和真實資料庫的誤差;同時對于這種情況,使用者也可以手動地适時清空SqlSession中的緩存。

(2)對于隻執行、并且頻繁執行大範圍的select操作的SqlSession對象,SqlSession對象的生存時間不應過長。

舉例:

例1、看下面這個例子,下面的例子使用了同一個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");
  }
 
}      

運作結果:

MyBatis - 一級緩存

由上面的結果你可以看到,第一次查詢耗時464ms,而第二次查詢耗時不足1ms,這是因為第一次查詢後,MyBatis會将查詢結果存儲到SqlSession對象的緩存中,當後來有完全相同的查詢時,直接從緩存中将結果取出。

例2、對上面的例子做一下修改:在第二次調用查詢前,對參數 HashMap類型的params多增加一些無關的值進去,然後再執行,看查詢結果

MyBatis - 一級緩存

從結果上看,雖然第二次查詢時傳遞的params參數不一緻,但還是從一級緩存中取出了第一次查詢的緩存。

繼續閱讀