MyBatis的緩存機制
在日常工作中,開發人員多數情況下是使用MyBatis的默認緩存配置,但是MyBatis緩存機制有一些不足之處,在使用中容易引起髒數據,形成一些潛在的隱患。
Mybatis的一級緩存
Mybatis的一級緩存是SqlSession級別的。一級緩存的作用域是一個SqlSession。Mybatis默認開啓一級緩存。
在同一個SqlSession中,執行相同的查詢SQL,第一次會去查詢數據庫,並寫到緩存中;第二次直接從緩存中取。當執行SQL時兩次查詢中間發生了增刪改操作,則SqlSession的緩存清空。
一級緩存示意圖
一級緩存區域是根據SqlSession爲單位劃分的。
每次查詢會先去緩存中找,如果找不到,再去數據庫查詢,然後把結果寫到緩存中。Mybatis的內部緩存使用一個HashMap,key爲hashcode+statementId+sql語句。Value爲查詢出來的結果集映射成的java對象。
SqlSession執行insert、update、delete等操作commit後會清空該SQLSession緩存。
Mybatis的一級緩存帶來的髒數據問題
開啓兩個SqlSession,在sqlSession1中查詢數據,使一級緩存生效,在sqlSession2中更新數據庫,驗證一級緩存只在數據庫會話內部共享。
@Test
public void testLocalCacheScope() 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("studentMapper讀取數據: " + studentMapper.getStudentById(1));
System.out.println("studentMapper2更新了" + studentMapper2.updateStudentName("小岑",1) + "個學生的數據");
System.out.println("studentMapper讀取數據: " + studentMapper.getStudentById(1));
System.out.println("studentMapper2讀取數據: " + studentMapper2.getStudentById(1));
}
執行結果
sqlSession2更新了id爲1的學生的姓名,從凱倫改爲了小岑,但session1之後的查詢中,id爲1的學生的名字還是凱倫,出現了髒數據,也證明了之前的設想,一級緩存只在數據庫會話內部共享。
Mybatis的二級緩存
Mybatis的二級緩存是指mapper映射文件。二級緩存的作用域是同一個namespace下的mapper映射文件內容,多個SqlSession共享。Mybatis需要手動設置啓動二級緩存。
在同一個namespace下的mapper文件中,執行相同的查詢SQL,第一次會去查詢數據庫,並寫到緩存中;第二次直接從緩存中取。當執行SQL時兩次查詢中間發生了增刪改操作,則二級緩存清空。
使用二級緩存時,由於二級緩存的數據不一定都是存儲到內存中,它的存儲介質多種多樣,所以需要給緩存的對象執行序列化。
二級緩存示意圖
二級緩存作用域是同一個namespace下的mapper映射文件內容。
第一次調用mapper下的SQL去查詢用戶信息。查詢到的信息會存到該mapper對應的二級緩存區域內。
第二次調用相同namespace下的mapper映射文件中相同的SQL去查詢用戶信息。會去對應的二級緩存內取結果。
如果調用相同namespace下的mapper映射文件中的增刪改SQL,並執行了commit操作。此時會清空該namespace下的二級緩存。
如何開啓二級緩存?
在覈心配置文件SqlMapConfig.xml中加入以下內容(開啓二級緩存總開關):
<settings>
<!-- 打開延遲加載的開關 -->
<setting name="lazyLoadingEnabled" value="true"/>
<!-- 將積極加載改爲消極加載,即延遲加載 -->
<setting name="aggressiveLazyLoading" value="false"/>
</settings>
在映射文件中,加入<cache/>節點,開啓二級緩存:
在<cache/>中可以自定義以下配置。
type:cache使用的類型,默認是PerpetualCache,這在一級緩存中提到過。
eviction: 定義回收的策略,常見的有FIFO,LRU。
flushInterval: 配置一定時間自動刷新緩存,單位是毫秒。
size: 最多緩存對象的個數。
readOnly: 是否只讀,若配置可讀寫,則需要對應的實體類能夠序列化。
blocking: 若緩存中找不到對應的key,是否會一直blocking,直到有對應的數據進入緩存。
實驗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));
}
執行結果
我們可以看到,當sqlsession1沒有調用commit()方法時,二級緩存並沒有起到作用。如果sqlsession1調用了commit()方法,那麼二級緩存將起到作用。
實驗2
驗證MyBatis的二級緩存不適應用於映射文件中存在多表查詢的情況。
通常我們會爲每個單表創建單獨的映射文件,由於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中同樣的查詢再次發起時,從緩存中讀取了髒數據。
上面的解決方法就是,使用Cache ref,讓ClassMapper引用StudenMapper命名空間,這樣兩個映射文件對應的Sql操作都使用的是同一塊緩存了。
不過這樣做的後果是,緩存的粒度變粗了,多個Mapper namespace下的所有操作都會對緩存使用造成影響。
總結
MyBatis的二級緩存相對於一級緩存來說,實現了SqlSession之間緩存數據的共享,同時粒度更加的細,能夠到namespace級別,通過Cache接口實現類不同的組合,對Cache的可控性也更強。
MyBatis在多表查詢時,極大可能會出現髒數據,有設計上的缺陷,安全使用二級緩存的條件比較苛刻。
在分佈式環境下,由於默認的MyBatis Cache實現都是基於本地的,分佈式環境下必然會出現讀取到髒數據,需要使用集中式緩存將MyBatis的Cache接口實現,有一定的開發成本,直接使用Redis,Memcached等分佈式緩存可能成本更低,安全性也更高。
參考:
https://tech.meituan.com/mybatis_cache.html
https://github.com/kailuncen/mybatis-cache-demo