Mybatis一级缓存,二级缓存的实现就是这么简单
介紹
又到了一年面試季,所以打算寫一點面試常問的東西,爭取說的通俗易懂。面試高級崗,如果你說熟悉Mybatis,下面這些問題基本上都會問
Mybatis插件的實現原理?
如何寫一個分頁插件?
Mybaits只寫了接口為什么能運行?
Mybatis的一級緩存和二級緩存的工作原理,會遇到什么問題?
一級緩存和二級緩存的生命周期分別是?
Mybatis和Spring整合后,一級緩存為什么會失效?
同時配置一級緩存和二級緩存后,先查詢哪個緩存?
今天就來聊一下Mybatis一級緩存和二級緩存
我們知道Mybatis有一級緩存和二級緩存,底層都是用HashMap實現的
key為CacheKey對象(后續說原因),value為從數據庫中查出來的值。
Mybatis的二級緩存模塊是裝飾器的典型實現,不清楚裝飾者模式的看如下文章
裝飾者模式在JDK和Mybatis中是怎么應用的?
畫一個簡易的裝飾者模式類圖
Component(組件):組件接口或抽象類定義了全部組件實現類以及所有裝飾器實現的行為。
ConcreteComponent(具體組件實現類):具體組件實現類實現了Component接口或抽象類。通常情況下,具體組件實現類就是被裝飾器裝飾的原始對象,該類提供了Component接口中定義的最基本的功能,其他高級功能或后序添加的新功能,都是通過裝飾器的方式添加到該類的對象之上的。
ConcreteDecorator(具體的裝飾器):該實現類要向被裝飾對象添加某些功能
mybatis中caceh模塊的類圖
其中只有PerpetualCache是具組件實現類,提供了Cache接口的基本實現。而FifoCache
,LoggingCache等都是具體裝飾者,在具體實現上加額外功能
測試一級緩存
測試的具體過程引用自參考博客
github地址:https://github.com/kailuncen/mybatis-cache-demo
接下來通過實驗,了解MyBatis一級緩存的效果,每個單元測試后都請恢復被修改的數據。
首先是創建示例表student,創建對應的POJO類和增改的方法,具體可以在entity包和mapper包中查看。
CREATE?TABLE?`student`?(`id`?int(11)?unsigned?NOT?NULL?AUTO_INCREMENT,`name`?varchar(200)?COLLATE?utf8_bin?DEFAULT?NULL,`age`?tinyint(3)?unsigned?DEFAULT?NULL,PRIMARY?KEY?(`id`) )?ENGINE=InnoDB?AUTO_INCREMENT=4?DEFAULT?CHARSET=utf8?COLLATE=utf8_bin;在以下實驗中,id為1的學生名稱是凱倫
實驗1
開啟一級緩存,范圍為會話級別,調用三次getStudentById,代碼如下所示:
public?void?getStudentById()?throws?Exception?{SqlSession?sqlSession?=?factory.openSession(true);?//?自動提交事務StudentMapper?studentMapper?=?sqlSession.getMapper(StudentMapper.class);System.out.println(studentMapper.getStudentById(1));System.out.println(studentMapper.getStudentById(1));System.out.println(studentMapper.getStudentById(1));}執行結果:
我們可以看到,只有第一次真正查詢了數據庫,后續的查詢使用了一級緩存。
實驗2
增加了對數據庫的修改操作,驗證在一次數據庫會話中,如果對數據庫發生了修改操作,一級緩存是否會失效。
@Test public?void?addStudent()?throws?Exception?{SqlSession?sqlSession?=?factory.openSession(true);?//?自動提交事務StudentMapper?studentMapper?=?sqlSession.getMapper(StudentMapper.class);System.out.println(studentMapper.getStudentById(1));System.out.println("增加了"?+?studentMapper.addStudent(buildStudent())?+?"個學生");System.out.println(studentMapper.getStudentById(1));sqlSession.close(); }執行結果:
我們可以看到,在修改操作后執行的相同查詢,查詢了數據庫,一級緩存失效。
實驗3
開啟兩個SqlSession,在sqlSession1中查詢數據,使一級緩存生效,在sqlSession2中更新數據庫,驗證一級緩存只在數據庫會話內部共享。(這個實驗在原文上略有修改)
@Test public?void?testLocalCacheScope()?throws?Exception?{SqlSession?sqlSession1?=?factory.openSession(true);?//?自動提交事務SqlSession?sqlSession2?=?factory.openSession(true);?//?自動提交事務StudentMapper?studentMapper1?=?sqlSession1.getMapper(StudentMapper.class);StudentMapper?studentMapper2?=?sqlSession2.getMapper(StudentMapper.class);System.out.println("studentMapper1讀取數據:?"?+?studentMapper1.getStudentById(1));System.out.println("studentMapper2讀取數據:?"?+?studentMapper2.getStudentById(1));System.out.println("studentMapper2更新了"?+?studentMapper2.updateStudentName("小岑",1)?+?"個學生的數據");System.out.println("studentMapper1讀取數據:?"?+?studentMapper1.getStudentById(1));System.out.println("studentMapper2讀取數據:?"?+?studentMapper2.getStudentById(1));}輸出如下
DEBUG?[main]?-?Cache?Hit?Ratio?[mapper.StudentMapper]:?0.0 DEBUG?[main]?-?==>??Preparing:?SELECT?id,name,age?FROM?student?WHERE?id?=??? DEBUG?[main]?-?==>?Parameters:?1(Integer) TRACE?[main]?-?<==????Columns:?id,?name,?age TRACE?[main]?-?<==????????Row:?1,?凱倫,?16 DEBUG?[main]?-?<==??????Total:?1 studentMapper1讀取數據:?StudentEntity{id=1,?name='凱倫',?age=16,?className='null'} DEBUG?[main]?-?Cache?Hit?Ratio?[mapper.StudentMapper]:?0.0 DEBUG?[main]?-?==>??Preparing:?SELECT?id,name,age?FROM?student?WHERE?id?=??? DEBUG?[main]?-?==>?Parameters:?1(Integer) TRACE?[main]?-?<==????Columns:?id,?name,?age TRACE?[main]?-?<==????????Row:?1,?凱倫,?16 DEBUG?[main]?-?<==??????Total:?1 studentMapper2讀取數據:?StudentEntity{id=1,?name='凱倫',?age=16,?className='null'} DEBUG?[main]?-?==>??Preparing:?UPDATE?student?SET?name?=???WHERE?id?=??? DEBUG?[main]?-?==>?Parameters:?小岑(String),?1(Integer) DEBUG?[main]?-?<==????Updates:?1 studentMapper2更新了1個學生的數據 DEBUG?[main]?-?Cache?Hit?Ratio?[mapper.StudentMapper]:?0.0 studentMapper1讀取數據:?StudentEntity{id=1,?name='凱倫',?age=16,?className='null'} DEBUG?[main]?-?Cache?Hit?Ratio?[mapper.StudentMapper]:?0.0 DEBUG?[main]?-?==>??Preparing:?SELECT?id,name,age?FROM?student?WHERE?id?=??? DEBUG?[main]?-?==>?Parameters:?1(Integer) TRACE?[main]?-?<==????Columns:?id,?name,?age TRACE?[main]?-?<==????????Row:?1,?小岑,?16 DEBUG?[main]?-?<==??????Total:?1 studentMapper2讀取數據:?StudentEntity{id=1,?name='小岑',?age=16,?className='null'}sqlSession1和sqlSession2讀的時相同的數據,但是都查詢了數據庫,說明了一級緩存只在數據庫會話層面共享
sqlSession2更新了id為1的學生的姓名,從凱倫改為了小岑,但sqlSession1之后的查詢中,id為1的學生的名字還是凱倫,出現了臟數據,也證明了之前的設想,一級緩存只在數據庫會話層面共享
一級緩存
一級緩存的生命周期與SqlSession相同,如果你對SqlSession不熟悉,你可以把它類比為JDBC編程中的Connection,即數據庫的一次會話。
要想了解緩存,就必須得了解一下Executor,這個Executor是干嘛的呢?你可以理解為要執行的SQL都會經過這個類的方法,在這個類的方法中調用StatementHandler最終執行SQL
Executor的實現也是一個典型的裝飾者模式
我相信你已經看出來,SimpleExecutor,BatchExecutor是具體組件實現類,而CachingExecutor是具體的裝飾器。可以看到具體組件實現類有一個父類BaseExecutor,而這個父類是一個模板模式的典型應用,操作一級緩存的操作都在這個類中,而具體的操作數據庫的功能則讓子類去實現。
至此終于搞明白了,一級緩存的所有操作都在BaseExecutor這個類中啊,看看具體操作
query方法
??@Overridepublic?<E>?List<E>?query(MappedStatement?ms,?Object?parameter,?RowBounds?rowBounds,?ResultHandler?resultHandler)?throws?SQLException?{BoundSql?boundSql?=?ms.getBoundSql(parameter);CacheKey?key?=?createCacheKey(ms,?parameter,?rowBounds,?boundSql);return?query(ms,?parameter,?rowBounds,?resultHandler,?key,?boundSql);}當執行select操作,會先生成一個CacheKey,如果根據CacheKey能從HashMap中拿到值則放回,如果拿不到值則先查詢數據庫,從數據庫中查出來后再放到HashMap中。追一下
query方法就知道了,代碼就不貼了,比較簡單
update方法
??@Overridepublic?int?update(MappedStatement?ms,?Object?parameter)?throws?SQLException?{ErrorContext.instance().resource(ms.getResource()).activity("executing?an?update").object(ms.getId());if?(closed)?{throw?new?ExecutorException("Executor?was?closed.");}clearLocalCache();return?doUpdate(ms,?parameter);}當執行update操作時,可以看到會調用clearLocalCache()方法,而這個方法則會清空一級緩存,即清空HashMap
總結
MyBatis一級緩存的生命周期和SqlSession一致。
MyBatis一級緩存內部設計簡單,只是一個沒有容量限定的HashMap,在緩存的功能性上有所欠缺。
MyBatis的一級緩存最大范圍是SqlSession內部,有多個SqlSession或者分布式的環境下,數據庫寫操作會引起臟數據,建議設定緩存級別為Statement,即進行如下配置
原因也很簡單,看BaseExecutor的query()方法,當配置成STATEMENT時,每次查詢完都會清空緩存
???if?(configuration.getLocalCacheScope()?==?LocalCacheScope.STATEMENT)?{//?issue?#482clearLocalCache();}mybatis和spring整合的一些注意事項
在未開啟事物的情況之下,每次查詢,spring都會關閉舊的sqlSession而創建新的sqlSession,因此此時的一級緩存是沒有起作用的
在開啟事物的情況之下,spring使用threadLocal獲取當前資源綁定同一個sqlSession,因此此時一級緩存是有效的
CacheKey
前面說到緩存的key是CacheKey對象,因為Mybatis中涉及動態SQL等多方面的因素,緩存的key不能僅僅通過String來表示,而是通過一個updateList,只有updateList的元素完全相同,則認為這2個CacheKey相同
public?class?CacheKey?implements?Cloneable,?Serializable?{//?參與hash計算的乘數private?final?int?multiplier;//?CacheKey的hash值,在update函數中實時運算出來的,這些值都是為了方便更快的比較,具體可以看equals函數private?int?hashcode;//?校驗和,hash值的和private?long?checksum;//?updateList中的元素個數private?int?count;//?將判等的對象放到這個list中private?List<Object>?updateList; }CacheKey的其他屬性都是為了加快比較的速度,具體可以看這個類的equals函數
CacheKey的updateList放置了如下幾個對象
mappedStatment的id
指定查詢結構集的范圍
查詢所使用SQL語句
用戶傳遞給SQL語句的實際參數值
怎么知道CacheKey是這些對象呢?你可以參考BaseExecutor的createCacheKey方法
測試二級緩存
測試的具體過程引用自參考博客
二級緩存是基于namespace實現的,即一個mapper映射文件用一個緩存,當然你可以配成多個mapper映射文件用一個緩存
在本實驗中,id為1的學生名稱初始化為點點。
實驗1
測試二級緩存效果,不提交事務,sqlSession1查詢完數據后,sqlSession2相同的查詢是否會從緩存中獲取數據。
@Test public?void?testCacheWithoutCommitOrClose()?throws?Exception?{SqlSession?sqlSession1?=?factory.openSession(true);?SqlSession?sqlSession2?=?factory.openSession(true);?StudentMapper?studentMapper?=?sqlSession1.getMapper(StudentMapper.class);StudentMapper?studentMapper2?=?sqlSession2.getMapper(StudentMapper.class);System.out.println("studentMapper讀取數據:?"?+?studentMapper.getStudentById(1));System.out.println("studentMapper2讀取數據:?"?+?studentMapper2.getStudentById(1)); }執行結果:
我們可以看到,當sqlsession沒有調用commit()方法時,二級緩存并沒有起到作用。
實驗2
測試二級緩存效果,當提交事務時,sqlSession1查詢完數據后,sqlSession2相同的查詢是否會從緩存中獲取數據。
@Test public?void?testCacheWithCommitOrClose()?throws?Exception?{SqlSession?sqlSession1?=?factory.openSession(true);?SqlSession?sqlSession2?=?factory.openSession(true);?StudentMapper?studentMapper?=?sqlSession1.getMapper(StudentMapper.class);StudentMapper?studentMapper2?=?sqlSession2.getMapper(StudentMapper.class);System.out.println("studentMapper讀取數據:?"?+?studentMapper.getStudentById(1));sqlSession1.commit();System.out.println("studentMapper2讀取數據:?"?+?studentMapper2.getStudentById(1)); }執行結果:
從圖上可知,sqlsession2的查詢,使用了緩存,緩存的命中率是0.5。
實驗3
測試update操作是否會刷新該namespace下的二級緩存。
@Test public?void?testCacheWithUpdate()?throws?Exception?{SqlSession?sqlSession1?=?factory.openSession(true);?SqlSession?sqlSession2?=?factory.openSession(true);?SqlSession?sqlSession3?=?factory.openSession(true);?StudentMapper?studentMapper?=?sqlSession1.getMapper(StudentMapper.class);StudentMapper?studentMapper2?=?sqlSession2.getMapper(StudentMapper.class);StudentMapper?studentMapper3?=?sqlSession3.getMapper(StudentMapper.class);System.out.println("studentMapper讀取數據:?"?+?studentMapper.getStudentById(1));sqlSession1.commit();System.out.println("studentMapper2讀取數據:?"?+?studentMapper2.getStudentById(1));studentMapper3.updateStudentName("方方",1);sqlSession3.commit();System.out.println("studentMapper2讀取數據:?"?+?studentMapper2.getStudentById(1)); }執行結果:
我們可以看到,在sqlSession3更新數據庫,并提交事務后,sqlsession2的StudentMapper namespace下的查詢走了數據庫,沒有走Cache。
實驗4
驗證MyBatis的二級緩存不適應用于映射文件中存在多表查詢的情況。
CREATE?TABLE?`student`?(`id`?int(11)?unsigned?NOT?NULL?AUTO_INCREMENT,`name`?varchar(200)?COLLATE?utf8_bin?DEFAULT?NULL,`age`?tinyint(3)?unsigned?DEFAULT?NULL,PRIMARY?KEY?(`id`) )?ENGINE=InnoDB?AUTO_INCREMENT=8?DEFAULT?CHARSET=utf8?COLLATE=utf8_bin;INSERT?INTO?`student`?(`id`,?`name`,?`age`)?VALUES?(1,'點點',16),(2,'平平',16),(3,'美美',16),(4,'團團',16);CREATE?TABLE?`class`?(`id`?int(11)?unsigned?NOT?NULL?AUTO_INCREMENT,`name`?varchar(20)?COLLATE?utf8_bin?DEFAULT?NULL,PRIMARY?KEY?(`id`) )?ENGINE=InnoDB?AUTO_INCREMENT=3?DEFAULT?CHARSET=utf8?COLLATE=utf8_bin;INSERT?INTO?`class`?(`id`,?`name`)?VALUES?(1,'一班'),(2,'二班');CREATE?TABLE?`classroom`?(`id`?int(11)?unsigned?NOT?NULL?AUTO_INCREMENT,`class_id`?int(11)?DEFAULT?NULL,`student_id`?int(11)?DEFAULT?NULL,PRIMARY?KEY?(`id`) )?ENGINE=InnoDB?AUTO_INCREMENT=5?DEFAULT?CHARSET=utf8?COLLATE=utf8_bin;INSERT?INTO?`classroom`?(`id`,?`class_id`,?`student_id`)?VALUES?(1,1,1),(2,1,2),(3,2,3),(4,2,4);getStudentByIdWithClassInfo的定義如下
<select?id="getStudentByIdWithClassInfo"?parameterType="int"?resultType="entity.StudentEntity">SELECT??s.id,s.name,s.age,class.name?as?classNameFROM?classroom?cJOIN?student?s?ON?c.student_id?=?s.idJOIN?class?ON?c.class_id?=?class.idWHERE?s.id?=?#{id}; </select>通常我們會為每個單表創建單獨的映射文件,由于MyBatis的二級緩存是基于namespace的,多表查詢語句所在的namspace無法感應到其他namespace中的語句對多表查詢中涉及的表進行的修改,引發臟數據問題。
@Test public?void?testCacheWithDiffererntNamespace()?throws?Exception?{SqlSession?sqlSession1?=?factory.openSession(true);?SqlSession?sqlSession2?=?factory.openSession(true);?SqlSession?sqlSession3?=?factory.openSession(true);?StudentMapper?studentMapper?=?sqlSession1.getMapper(StudentMapper.class);StudentMapper?studentMapper2?=?sqlSession2.getMapper(StudentMapper.class);ClassMapper?classMapper?=?sqlSession3.getMapper(ClassMapper.class);System.out.println("studentMapper讀取數據:?"?+?studentMapper.getStudentByIdWithClassInfo(1));sqlSession1.close();System.out.println("studentMapper2讀取數據:?"?+?studentMapper2.getStudentByIdWithClassInfo(1));classMapper.updateClassName("特色一班",1);sqlSession3.commit();System.out.println("studentMapper2讀取數據:?"?+?studentMapper2.getStudentByIdWithClassInfo(1)); }執行結果:
在這個實驗中,我們引入了兩張新的表,一張class,一張classroom。class中保存了班級的id和班級名,classroom中保存了班級id和學生id。我們在StudentMapper中增加了一個查詢方法getStudentByIdWithClassInfo,用于查詢學生所在的班級,涉及到多表查詢。在ClassMapper中添加了updateClassName,根據班級id更新班級名的操作。
當sqlsession1的studentmapper查詢數據后,二級緩存生效。保存在StudentMapper的namespace下的cache中。當sqlSession3的classMapper的updateClassName方法對class表進行更新時,updateClassName不屬于StudentMapper的namespace,所以StudentMapper下的cache沒有感應到變化,沒有刷新緩存。當StudentMapper中同樣的查詢再次發起時,從緩存中讀取了臟數據。
實驗5
為了解決實驗4的問題呢,可以使用Cache ref,讓ClassMapper引用StudenMapper命名空間,這樣兩個映射文件對應的SQL操作都使用的是同一塊緩存了。
mapper文件中的配置如下
<cache-ref?namespace="mapper.StudentMapper"/>執行結果:
不過這樣做的后果是,緩存的粒度變粗了,多個Mapper namespace下的所有操作都會對緩存使用造成影響。
二級緩存的實現
前面說了一級緩存的實現在BaseExecutor中,那么二級緩存的實現在哪呢?提示一下,前面提到的Executor。沒錯,就是CachingExecutor。下面詳細介紹一下
二級緩存的相關配置有如下3個
1.mybatis-config.xml
<settings><setting?name="cacheEnabled"?value="true"/> </settings>這個是二級緩存的總開關,只有當該配置項設置為true時,后面兩項的配置才會有效果
從Configuration類的newExecutor方法可以看到,當cacheEnabled為true,就用緩存裝飾器裝飾一下具體組件實現類,從而讓二級緩存生效
//?開啟二級緩存,用裝飾器模式裝飾一下 if?(cacheEnabled)?{executor?=?new?CachingExecutor(executor); }2.mapper映射文件中
mapper映射文件中如果配置了<cache\>和<cache-ref\>中的任意一個標簽,則表示開啟了二級緩存功能,沒有的話表示不開啟
二級緩存的部分配置如上,type就是填寫一個全類名,你看我上面畫的圖,二級緩存是用Cache表示的,一級緩存是用HashMap表示的。這就說明二級緩存的實現類你可以可以自己提供的,不一定得用默認的HashMap(對,二級緩存默認是用HashMap實現的),Mybatis能和Redis,ehcache整合的原因就在這
這個eviction表示緩存清空策略,可填選項如下
| LRU | 最近最少使用的:移除最長時間不被使用的對象 | LruCache |
| FIFO | 先進先出:按對象進入緩存的順序來移除它們 | FifoCache |
| SOFT | 軟引用:移除基于垃圾回收器狀態和軟引用規則的對象 | SoftCache |
| WEAK | 弱引用:更積極地移除基于垃圾收集器狀態和弱引用規則的對象 | WeakCache |
可以看到在Mybatis中換緩存清空策略就是換裝飾器。還有就是如果面試官讓你寫一個FIFO算法或者LRU算法,這不就是現成的實現嗎?
3.<select\>節點中的useCache屬性
該屬性表示查詢產生的結果是否要保存的二級緩存中,useCache屬性的默認值為true,這個配置可以將二級緩存細分到語句級別
CachingExecutor利用了2個組件TransactionalCacheManager和TransactionalCache來管理二級緩存,為什么要多這2個組件呢?因為二級緩存不像一級緩存那樣查詢完直接放入一級緩存,而是要等事務提交時才會將查詢出來的數據放到二級緩存中。
因為如果事務1查出來直接放到二級緩存,此時事務2從二級緩存中拿到了事務1緩存的數據,但是事務1回滾了,此時事務2不就發生了臟讀了嗎?
二級緩存的具體實現也不難,追一下CachingExecutor,TransactionalCacheManager,TransactionalCache就明白了,可以參考《Mybatis技術內幕一書》
總結
MyBatis的二級緩存相對于一級緩存來說,實現了SqlSession之間緩存數據的共享
MyBatis在多表查詢時,極大可能會出現臟數據,有設計上的缺陷,安全使用二級緩存的條件比較苛刻
在分布式環境下,由于默認的MyBatis Cache實現都是基于本地的,分布式環境下必然會出現讀取到臟數據,需要使用集中式緩存將MyBatis的Cache接口實現,有一定的開發成本,直接使用Redis、Memcached等分布式緩存可能成本更低,安全性也更高。
問題回答
一級緩存和二級緩存的生命周期分別是?
一級緩存的生命周期是會話級別,因為一級緩存是存在Sqlsession的成員變量Executor的成員變量localCache中的。而二級緩存的生命周期是整個應用級別,因為二級緩存是存在Configuration對象中,而這個對象在應用啟動后一直存在
同時配置一級緩存和二級緩存后,先查詢哪個緩存?
當然是先查詢二級緩存再查詢一級緩存啊,因為一級緩存的實現在BaseExecutor,而二級緩存的實現在CachingExecutor,CachingExecutor是BaseExecutor的裝飾器
參考博客
[1]https://tech.meituan.com/2018/01/19/mybatis-cache.html
最后,再附上我歷時三個月總結的?Java 面試 + Java 后端技術學習指南,筆者這幾年及春招的總結,github 1.4k star,拿去不謝!
下載方式
1.?首先掃描下方二維碼
2.?后臺回復「Java面試」即可獲取
總結
以上是生活随笔為你收集整理的Mybatis一级缓存,二级缓存的实现就是这么简单的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 一些恶心的代码片段,你看了就知道!
- 下一篇: 这么写注释,老板会不会开除我?