轉載申明:
原文: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),對於每一次查詢,都會嘗試根據查詢的條件去本地緩存中查找是否在緩存中,如果在緩存中,就直接從緩存中取出,然后返回給用戶;否則,從數據庫讀取數據,將查詢結果存入緩存並返回給用戶。
一級緩存的生命周期有多長?
- MyBatis在開啟一個數據庫會話時,會 創建一個新的SqlSession對象,SqlSession對象中會有一個新的Executor對象,Executor對象中持有一個新的PerpetualCache對象;當會話結束時,SqlSession對象及其內部的Executor對象還有PerpetualCache對象也一並釋放掉。
- 如果SqlSession調用了close()方法,會釋放掉一級緩存PerpetualCache對象,一級緩存將不可用;
- 如果SqlSession調用了clearCache(),會清空PerpetualCache對象中的數據,但是該對象仍可使用;
- SqlSession中執行了任何一個update操作(update()、delete()、insert()) ,都會清空PerpetualCache對象的數據,但是該對象可以繼續使用;
SqlSession 一級緩存的工作流程:
- 對於某個查詢,根據statementId,params,rowBounds來構建一個key值,根據這個key值去緩存Cache中取出對應的key值存儲的緩存結果
- 判斷從Cache中根據特定的key值取的數據數據是否為空,即是否命中;
- 如果命中,則直接將緩存結果返回;
- 如果沒命中:
- 去數據庫中查詢數據,得到查詢結果;
- 將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)無法關閉,但是有兩種級別可選:
- session 級別的緩存,在同一個 sqlSession 內,對同樣的查詢將不再查詢數據庫,直接從緩存中。
- 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。
什么時候開啟二級緩存?
一級緩存默認是打開的,二級緩存需要配置才可以開啟。那么我們必須思考一個問題,在什么情況下才有必要去開啟二級緩存?
- 因為所有的增刪改都會刷新二級緩存,導致二級緩存失效,所以適合在查詢為主的應用中使用,比如歷史交易、歷史訂單的查詢。否則緩存就失去了意義。
- 如果多個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 語句執行時不要刷新緩存。