Mybatis 緩存機制詳解


轉載申明:  

  原文:https://www.cnblogs.com/wuzhenzhao/p/11103043.html  

  作者:吳振照

MyBatis 緩存詳解

  緩存是一般的ORM 框架都會提供的功能,目的就是提升查詢的效率和減少數據庫的壓力。跟Hibernate 一樣,MyBatis 也有一級緩存和二級緩存,並且預留了集成第三方緩存的接口。

  緩存體系結構:

  MyBatis 跟緩存相關的類都在cache 包里面,其中有一個Cache 接口,只有一個默認的實現類 PerpetualCache,它是用HashMap 實現的。我們可以通過 以下類找到這個緩存的廬山真面目

DefaultSqlSession

  ->  BaseExecutor

    ->  PerpetualCache localCache

      ->private Map<Object, Object> cache = new HashMap();

  除此之外,還有很多的裝飾器,通過這些裝飾器可以額外實現很多的功能:回收策略、日志記錄、定時刷新等等。但是無論怎么裝飾,經過多少層裝飾,最后使用的還是基本的實現類(默認PerpetualCache)。可以通過 CachingExecutor 類 Debug 去查看。

  所有的緩存實現類總體上可分為三類:基本緩存、淘汰算法緩存、裝飾器緩存。

一級緩存(本地緩存):

  一級緩存也叫本地緩存,MyBatis 的一級緩存是在會話(SqlSession)層面進行緩存的。MyBatis 的一級緩存是默認開啟的,不需要任何的配置。首先我們必須去弄清楚一個問題,在MyBatis 執行的流程里面,涉及到這么多的對象,那么緩存PerpetualCache 應該放在哪個對象里面去維護?如果要在同一個會話里面共享一級緩存,這個對象肯定是在SqlSession 里面創建的,作為SqlSession 的一個屬性。

  DefaultSqlSession 里面只有兩個屬性,Configuration 是全局的,所以緩存只可能放在Executor 里面維護——SimpleExecutor/ReuseExecutor/BatchExecutor 的父類BaseExecutor 的構造函數中持有了PerpetualCache。在同一個會話里面,多次執行相同的SQL 語句,會直接從內存取到緩存的結果,不會再發送SQL 到數據庫。但是不同的會話里面,即使執行的SQL 一模一樣(通過一個Mapper 的同一個方法的相同參數調用),也不能使用到一級緩存。

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

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

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

  如下圖所示,MyBatis會在一次會話的表示----一個SqlSession對象中創建一個本地緩存(local cache),對於每一次查詢,都會嘗試根據查詢的條件去本地緩存中查找是否在緩存中,如果在緩存中,就直接從緩存中取出,然后返回給用戶;否則,從數據庫讀取數據,將查詢結果存入緩存並返回給用戶。

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

  1. MyBatis在開啟一個數據庫會話時,會 創建一個新的SqlSession對象,SqlSession對象中會有一個新的Executor對象,Executor對象中持有一個新的PerpetualCache對象;當會話結束時,SqlSession對象及其內部的Executor對象還有PerpetualCache對象也一並釋放掉。
  2. 如果SqlSession調用了close()方法,會釋放掉一級緩存PerpetualCache對象,一級緩存將不可用;
  3. 如果SqlSession調用了clearCache(),會清空PerpetualCache對象中的數據,但是該對象仍可使用;
  4. SqlSession中執行了任何一個update操作(update()、delete()、insert()) ,都會清空PerpetualCache對象的數據,但是該對象可以繼續使用;

SqlSession 一級緩存的工作流程:

  1. 對於某個查詢,根據statementId,params,rowBounds來構建一個key值,根據這個key值去緩存Cache中取出對應的key值存儲的緩存結果​
  2. 判斷從Cache中根據特定的key值取的數據數據是否為空,即是否命中;​
  3. 如果命中,則直接將緩存結果返回;​
  4. 如果沒命中:
  • 去數據庫中查詢數據,得到查詢結果;
  • 將key和查詢到的結果分別作為key,value對存儲到Cache中;
  • 將查詢結果返回;

  接下來我們來驗證一下,MyBatis 的一級緩存到底是不是只能在一個會話里面共享,以及跨會話(不同session)操作相同的數據會產生什么問題。判斷是否命中緩存:如果再次發送SQL 到數據庫執行,說明沒有命中緩存;如果直接打印對象,說明是從內存緩存中取到了結果。

1、在同一個session 中共享(不同session 不能共享)

//同Session
SqlSession session1 = sqlSessionFactory.openSession();
BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
System.out.println(mapper1.selectBlogById(1002));
System.out.println(mapper1.selectBlogById(1002));

  執行以上sql我們可以看到控制台打印如下信息(需配置mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl),會發現我們兩次的查詢就發送了一次查詢數據庫的操作,這說明了緩存在發生作用:

  PS:一級緩存在BaseExecutor 的query()——queryFromDatabase()中存入。在queryFromDatabase()之前會get()。

復制代碼
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
        ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
    。。。。。。try {
                ++this.queryStack;//從緩存中獲取
                list = resultHandler == null ? (List)this.localCache.getObject(key) : null;
                if (list != null) {
                    this.handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
                } else {//緩存中獲取不到,查詢數據庫
                    list = this.queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
                }
    。。。。。。
    }
復制代碼

2.同一個會話中,update(包括delete)會導致一級緩存被清空

復制代碼
//同Session
SqlSession session1 = sqlSessionFactory.openSession();
BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
System.out.println(mapper1.selectBlogById(1002));
Blog blog3 = new Blog();
blog3.setBid(1002);
blog3.setName("mybatis緩存機制修改");
mapper1.updateBlog(blog3);
session1.commit();// 注意要提交事務,否則不會清除緩存
System.out.println(mapper1.selectBlogById(1002));
復制代碼

  一級緩存是在BaseExecutor 中的update()方法中調用clearLocalCache()清空的(無條件),query 中會判斷。

復制代碼
public int update(MappedStatement ms, Object parameter) throws SQLException {
        ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
        if (this.closed) {
            throw new ExecutorException("Executor was closed.");
        } else {
       //清除本地緩存 this.clearLocalCache(); return this.doUpdate(ms, parameter); } }
復制代碼

3.其他會話更新了數據,導致讀取到臟數據(一級緩存不能跨會話共享)

復制代碼
SqlSession session1 = sqlSessionFactory.openSession();
BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
SqlSession session2 = sqlSessionFactory.openSession();
BlogMapper mapper2 = session2.getMapper(BlogMapper.class);
System.out.println(mapper2.selectBlogById(1002));
Blog blog3 = new Blog();
blog3.setBid(1002);
blog3.setName("mybatis緩存機制1");
mapper1.updateBlog(blog3);
session1.commit();
System.out.println(mapper2.selectBlogById(1002));
復制代碼

一級緩存的不足:

  使用一級緩存的時候,因為緩存不能跨會話共享,不同的會話之間對於相同的數據可能有不一樣的緩存。在有多個會話或者分布式環境下,會存在臟數據的問題。如果要解決這個問題,就要用到二級緩存。MyBatis 一級緩存(MyBaits 稱其為 Local Cache)無法關閉,但是有兩種級別可選:

  1. session 級別的緩存,在同一個 sqlSession 內,對同樣的查詢將不再查詢數據庫,直接從緩存中。
  2. statement 級別的緩存,避坑: 為了避免這個問題,可以將一級緩存的級別設為 statement 級別的,這樣每次查詢結束都會清掉一級緩存。

二級緩存:

  二級緩存是用來解決一級緩存不能跨會話共享的問題的,范圍是namespace 級別的,可以被多個SqlSession 共享(只要是同一個接口里面的相同方法,都可以共享),生命周期和應用同步。如果你的MyBatis使用了二級緩存,並且你的Mapper和select語句也配置使用了二級緩存,那么在執行select查詢的時候,MyBatis會先從二級緩存中取輸入,其次才是一級緩存,即MyBatis查詢數據的順序是:二級緩存   —> 一級緩存 —> 數據庫。

  作為一個作用范圍更廣的緩存,它肯定是在SqlSession 的外層,否則不可能被多個SqlSession 共享。而一級緩存是在SqlSession 內部的,所以第一個問題,肯定是工作在一級緩存之前,也就是只有取不到二級緩存的情況下才到一個會話中去取一級緩存。第二個問題,二級緩存放在哪個對象中維護呢? 要跨會話共享的話,SqlSession 本身和它里面的BaseExecutor 已經滿足不了需求了,那我們應該在BaseExecutor 之外創建一個對象。

  實際上MyBatis 用了一個裝飾器的類來維護,就是CachingExecutor。如果啟用了二級緩存,MyBatis 在創建Executor 對象的時候會對Executor 進行裝飾。CachingExecutor 對於查詢請求,會判斷二級緩存是否有緩存結果,如果有就直接返回,如果沒有委派交給真正的查詢器Executor 實現類,比如SimpleExecutor 來執行查詢,再走到一級緩存的流程。最后會把結果緩存起來,並且返回給用戶。

  開啟二級緩存的方法

第一步:配置 mybatis.configuration.cache-enabled=true,只要沒有顯式地設置cacheEnabled=false,都會用CachingExecutor 裝飾基本的執行器。

第二步:在Mapper.xml 中配置<cache/>標簽:

<cache type="org.apache.ibatis.cache.impl.PerpetualCache"
    size="1024"
eviction="LRU"
flushInterval="120000"
readOnly="false"/>

基本上就是這樣。這個簡單語句的效果如下:

  • 映射語句文件中的所有 select 語句的結果將會被緩存。
  • 映射語句文件中的所有 insert、update 和 delete 語句會刷新緩存。
  • 緩存會使用最近最少使用算法(LRU, Least Recently Used)算法來清除不需要的緩存。
  • 緩存不會定時進行刷新(也就是說,沒有刷新間隔)。
  • 緩存會保存列表或對象(無論查詢方法返回哪種)的 1024 個引用。
  • 緩存會被視為讀/寫緩存,這意味着獲取到的對象並不是共享的,可以安全地被調用者修改,而不干擾其他調用者或線程所做的潛在修改。

這個更高級的配置創建了一個 FIFO 緩存,每隔 60 秒刷新,最多可以存儲結果對象或列表的 512 個引用,而且返回的對象被認為是只讀的,因此對它們進行修改可能會在不同線程中的調用者產生沖突。可用的清除策略有:

  • LRU – 最近最少使用:移除最長時間不被使用的對象。
  • FIFO – 先進先出:按對象進入緩存的順序來移除它們。
  • SOFT – 軟引用:基於垃圾回收器狀態和軟引用規則移除對象。
  • WEAK – 弱引用:更積極地基於垃圾收集器狀態和弱引用規則移除對象。

默認的清除策略是 LRU。

flushInterval(刷新間隔)屬性可以被設置為任意的正整數,設置的值應該是一個以毫秒為單位的合理時間量。 默認情況是不設置,也就是沒有刷新間隔,緩存僅僅會在調用語句時刷新。

size(引用數目)屬性可以被設置為任意正整數,要注意欲緩存對象的大小和運行環境中可用的內存資源。默認值是 1024。

readOnly(只讀)屬性可以被設置為 true 或 false。只讀的緩存會給所有調用者返回緩存對象的相同實例。 因此這些對象不能被修改。這就提供了可觀的性能提升。而可讀寫的緩存會(通過序列化)返回緩存對象的拷貝。 速度上會慢一些,但是更安全,因此默認值是 false。

  注:二級緩存是事務性的。這意味着,當 SqlSession 完成並提交時,或是完成並回滾,但沒有執行 flushCache=true 的 insert/delete/update 語句時,緩存會獲得更新。

  Mapper.xml 配置了<cache>之后,select()會被緩存。update()、delete()、insert()會刷新緩存。:如果cacheEnabled=true,Mapper.xml 沒有配置標簽,還有二級緩存嗎?(沒有)還會出現CachingExecutor 包裝對象嗎?(會)

  只要cacheEnabled=true 基本執行器就會被裝飾。有沒有配置<cache>,決定了在啟動的時候會不會創建這個mapper 的Cache 對象,只是最終會影響到CachingExecutorquery 方法里面的判斷。如果某些查詢方法對數據的實時性要求很高,不需要二級緩存,怎么辦?我們可以在單個Statement ID 上顯式關閉二級緩存(默認是true):

<select id="selectBlog" resultMap="BaseResultMap" useCache="false">

  二級緩存驗證(驗證二級緩存需要先開啟二級緩存)

 1、事務不提交,二級緩存不存在

System.out.println(mapper1.selectBlogById(1002));
// 事務不提交的情況下,二級緩存不會寫入
// session1.commit();
System.out.println(mapper2.selectBlogById(1002));

  為什么事務不提交,二級緩存不生效?因為二級緩存使用TransactionalCacheManager(TCM)來管理,最后又調用了TransactionalCache 的getObject()、putObject 和commit()方法,TransactionalCache里面又持有了真正的Cache 對象,比如是經過層層裝飾的PerpetualCache。在putObject 的時候,只是添加到了entriesToAddOnCommit 里面,只有它的commit()方法被調用的時候才會調用flushPendingEntries()真正寫入緩存。它就是在DefaultSqlSession 調用commit()的時候被調用的。

2、使用不同的session 和mapper,驗證二級緩存可以跨session 存在取消以上commit()的注釋

3、在其他的session 中執行增刪改操作,驗證緩存會被刷新

復制代碼
System.out.println(mapper1.selectBlogById(1002));
//主鍵自增返回測試
Blog blog3 = new Blog();
blog3.setBid(1002);
blog3.setName("mybatis緩存機制");
mapper1.updateBlog(blog3);
session1.commit();
System.out.println(mapper2.selectBlogById(1002));
復制代碼

  為什么增刪改操作會清空緩存?在CachingExecutor 的update()方法里面會調用flushCacheIfRequired(ms),isFlushCacheRequired 就是從標簽里面渠道的flushCache 的值。而增刪改操作的flushCache 屬性默認為true。

什么時候開啟二級緩存?

一級緩存默認是打開的,二級緩存需要配置才可以開啟。那么我們必須思考一個問題,在什么情況下才有必要去開啟二級緩存?

  1. 因為所有的增刪改都會刷新二級緩存,導致二級緩存失效,所以適合在查詢為主的應用中使用,比如歷史交易、歷史訂單的查詢。否則緩存就失去了意義。
  2. 如果多個namespace 中有針對於同一個表的操作,比如Blog 表,如果在一個namespace 中刷新了緩存,另一個namespace 中沒有刷新,就會出現讀到臟數據的情況。所以,推薦在一個Mapper 里面只操作單表的情況使用。

  如果要讓多個namespace 共享一個二級緩存,應該怎么做?跨namespace 的緩存共享的問題,可以使用<cache-ref>來解決:

<cache-ref namespace="com.wuzz.crud.dao.DepartmentMapper" />

  cache-ref 代表引用別的命名空間的Cache 配置,兩個命名空間的操作使用的是同一個Cache。在關聯的表比較少,或者按照業務可以對表進行分組的時候可以使用。

  注意:在這種情況下,多個Mapper 的操作都會引起緩存刷新,緩存的意義已經不大了.

第三方緩存做二級緩存

  除了MyBatis 自帶的二級緩存之外,我們也可以通過實現Cache 接口來自定義二級緩存。MyBatis 官方提供了一些第三方緩存集成方式,比如ehcache 和redis:https://github.com/mybatis/redis-cache ,這里就不過多介紹了。當然,我們也可以使用獨立的緩存服務,不使用MyBatis 自帶的二級緩存。

自定義緩存:

  除了上述自定義緩存的方式,你也可以通過實現你自己的緩存,或為其他第三方緩存方案創建適配器,來完全覆蓋緩存行為。

<cache type="com.domain.something.MyCustomCache"/>

  這個示例展示了如何使用一個自定義的緩存實現。type 屬性指定的類必須實現 org.mybatis.cache.Cache 接口,且提供一個接受 String 參數作為 id 的構造器。 這個接口是 MyBatis 框架中許多復雜的接口之一,但是行為卻非常簡單。

復制代碼
public interface Cache { String getId(); int getSize(); void putObject(Object key, Object value); Object getObject(Object key); boolean hasKey(Object key); Object removeObject(Object key); void clear(); }
復制代碼

  為了對你的緩存進行配置,只需要簡單地在你的緩存實現中添加公有的 JavaBean 屬性,然后通過 cache 元素傳遞屬性值,例如,下面的例子將在你的緩存實現上調用一個名為 setCacheFile(String file) 的方法:

<cache type="com.domain.something.MyCustomCache"> <property name="cacheFile" value="/tmp/my-custom-cache.tmp"/> </cache>

  你可以使用所有簡單類型作為 JavaBean 屬性的類型,MyBatis 會進行轉換。 你也可以使用占位符(如 ${cache.file}),以便替換成在配置文件屬性中定義的值。從版本 3.4.2 開始,MyBatis 已經支持在所有屬性設置完畢之后,調用一個初始化方法。 如果想要使用這個特性,請在你的自定義緩存類里實現 org.apache.ibatis.builder.InitializingObject 接口。

public interface InitializingObject { void initialize() throws Exception; }

  請注意,緩存的配置和緩存實例會被綁定到 SQL 映射文件的命名空間中。 因此,同一命名空間中的所有語句和緩存將通過命名空間綁定在一起。 每條語句可以自定義與緩存交互的方式,或將它們完全排除於緩存之外,這可以通過在每條語句上使用兩個簡單屬性來達成。 默認情況下,語句會這樣來配置:

<select ... flushCache="false" useCache="true"/> <insert ... flushCache="true"/> <update ... flushCache="true"/> <delete ... flushCache="true"/>

  鑒於這是默認行為,顯然你永遠不應該以這樣的方式顯式配置一條語句。但如果你想改變默認的行為,只需要設置 flushCache 和 useCache 屬性。比如,某些情況下你可能希望特定 select 語句的結果排除於緩存之外,或希望一條 select 語句清空緩存。類似地,你可能希望某些 update 語句執行時不要刷新緩存。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM