SpringBoot 下 mybatis 的快取
背景:
說起 mybatis,作為 Java 程式設計師應該是無人不知,它是常用的資料庫訪問框架。與 Spring 和 Struts 組成了 Java Web 開發的三劍客--- SSM。當然隨著 Spring Boot 的發展,現在越來越多的企業採用的是 SpringBoot + mybatis 的模式開發,我們公司也不例外。而 mybatis 對於我也僅僅停留在會用而已,沒想過怎麼去了解它,更不知道它的快取機制了,直到那個生死難忘的 BUG。故事的背景比較長,但並不是囉嗦,只是讓讀者知道這個 BUG 觸發的場景,加深記憶。在遇到類似問題時,可以迅速定位。
先說下故事的前提,為了防止使用者在動態中輸入特殊字元,使用者的動態都是編碼後發到後臺,而後臺在存入到 DB 表之前會解碼以方便在 DB 中檢視以及上報到搜尋引擎。而在查詢使用者動態的時候先從 DB 表中讀取並在後臺做一次編碼再傳到前端,前端再解碼既可以正常展示了。流程如下圖:
有一天後端預發環境釋出完畢後,使用者的動態頁面有的動態顯示正常,而有的動態卻是被編碼過的。看到現象後的第一個反應就是部分被編碼了兩次,但是編碼操作只會在 service 層的 findById 中有。理論不會在上層犯這種低階錯誤,於是開始排查新增加的程式碼。發現只要進入了新增加程式碼中的某個 if 分支則被編碼了兩次。分支中除了再次呼叫 findById(必要性不討論),也無其他特殊程式碼了。百思不得其解後請教了旁邊的老司機,老司機說可能是 mybatis 快取。於是看了下我程式碼,將編碼的操作從 findById 中移出來後再次釋出到預發,正常了,心想老司機不愧是老司機。本次 BUG 觸發的有兩個條件需要注意:
- 整個操作過程都在一個函式中,而函式上面加了 @Transactional 的註解(對 mybatis 來說是在同一個 SESSION 中)
- 一般只會呼叫 findByIdy 一次,如果進入分支則會呼叫兩次 (第一次呼叫後做了編碼後被快取,第二次從快取讀後繼續被編碼)
於是,便開始谷歌 mybatis 的快取機制,搜到了一篇非常不錯的文章《聊聊 mybatis 的快取機制》,推薦大家看一下,特別是裡面的流程圖。同時關注下美團技術官方公眾號,上面有很多幹貨(這不是廣告)。但是這篇文章講到了原始碼,涉及的比較深。而且並沒講 SpringBoot 下 mybatis 下的一些快取知識點,遂作此篇,以作補充。
快取的配置
SpringBoot + mybatis 環境搭建很簡單而且網上一堆教程,這裡不班門弄斧了,記得在專案中將 mytatis 的原始碼下載下來即可。mybaits 一共有兩級快取:一級快取的配置 key 是 localCacheScope,而二級快取的配置 key 是 cacheEnabled,從名字上可以得出以下資訊:
- 一級快取是本地或者說區域性快取,它不能被關閉,只能配置快取範圍。SESSION 或者 STATEMENT。
- 二級快取才是 mybatis 的正統,功能應該會更強大些。
先來看下在 SpringBoot中 如何配置 mybatis 快取的相關資訊。預設情況下 SpringBoot 下的 mybatis 一級快取為 SESSION 級別,二級快取也是開啟的,可以在 mybatis 原始碼中的 org.apache.ibatis.session.Configuration.class 檔案中看到(idea中開啟),如下圖:
也可以通過以下測試程式檢視快取開啟情況
@RunWith(SpringRunner.class) @SpringBootTest public class LearnApplicationTests { private SqlSessionFactory factory; @Before public void setUp() throws Exception { InputStream inputStream = Resources.getResourceAsStream("mybatis/mybatis-config.xml"); factory = new SqlSessionFactoryBuilder().build(inputStream); } @Test public void showDefaultCacheConfiguration() { System.out.println("一級快取範圍: " + factory.getConfiguration().getLocalCacheScope()); System.out.println("二級快取是否被啟用: " + factory.getConfiguration().isCacheEnabled()); } }
如果要設定一級快取的快取級別和開關二級快取,在 mybatis-config.xml (當然也可以在 application.xml/yml 中配置)加入如下配置即可:
<settings> <setting name="cacheEnabled" value="true/false"/> <setting name="localCacheScope" value="SESSION/STATEMENT"/> </settings>
但需要注意的是二級快取 cacheEnabled 只是個總開關,如果要讓二級快取真正生效還需要在 mapper xml 檔案中加入 <cache /> 。一級快取只在同一 SESSION 或者 STATEMENT 之間共享,二級快取可以跨 SESSION,開啟後它們預設具有如下特性:
- 對映檔案中所有的select語句將被快取
- 對映檔案中所有的insert、update和delete語句將重新整理快取
一二級快取同時開啟的情況下,資料的查詢順序是 二級快取 -> 一級快取 -> 資料庫。一級快取比較簡單,而二級快取可以設定更多的屬性,只需要在 mapper 的 xml 檔案中的 <cache /> 配置即可,具體如下:
<cache type = "org.mybatis.caches.ehcache.LoggingEhcache"//指定使用的快取類,mybatis預設使用HashMap進行快取,可以指定第三方快取 eviction = "LRU"//預設是 LRU 淘汰快取的演算法,有如下幾種: //1.LRU – 最近最少使用的:移除最長時間不被使用的物件。 //2.FIFO – 先進先出:按物件進入快取的順序來移除它們。 //3.SOFT – 軟引用:移除基於垃圾回收器狀態和軟引用規則的物件。 //4.WEAK – 弱引用:更積極地移除基於垃圾收集器狀態和弱引用規則的物件 flushInterval = "1000"//清空快取的時間間隔,單位毫秒,可以被設定為任意的正整數。預設情況是不設定,也就是沒有重新整理間隔,快取僅僅呼叫語句時重新整理。 size = "100"//快取物件的個數,任意正整數,預設值是1024。 readOnly= "true"//快取是否只讀,提高讀取效率 blocking = "true"//是否使用阻塞快取,預設為false,當指定為true時將採用BlockingCache進行封裝,blocking, //阻塞的意思,使用BlockingCache會在查詢快取時鎖住對應的Key,如果快取命中了則會釋放對應的鎖, //否則會在查詢資料庫以後再釋放鎖這樣可以阻止併發情況下多個執行緒同時查詢資料,詳情可參考BlockingCache的原始碼。 />
觸發 mybatis 快取
(1)配置一級快取為 SESSION 級別
Controller 中做兩次呼叫,程式碼如下:
@RequestMapping("/getUser") public UserEntity getUser(Long id) { //第一次呼叫 UserEntity user1=userMapper.getOne(id); //第二次呼叫 UserEntity user2=userMapper.getOne(id); return user1; }呼叫: http://localhost:8080/getUser?id=1 ,列印結果如下:
從圖中的 1/2/3/4 可以看出每次 mapper 層的一次介面呼叫如 getOne 就會建立一個 session,並且在執行完畢後關閉 session。所以兩次呼叫並不在一個 session 中,一級快取並沒有發生作用。開啟事務,Controller 層程式碼如下:
@RequestMapping("/getUser") @Transactional(rollbackFor = Throwable.class) public UserEntity getUser(Long id) { //第一次呼叫 UserEntity user1=userMapper.getOne(id); //第二次呼叫 UserEntity user2=userMapper.getOne(id); return user1; }
列印結果如下:
由於在同一個事務中,雖然呼叫了 select 操作兩次但是隻執行了一次 sql ,快取發揮了作用。這就跟一開始我遇到的那個 BUG 場景一樣:同一 session 且 select 呼叫 > 1 次。如果在兩次呼叫中間插入 update 操作,快取會立即失效。只要 session 中有 insert、update 和 delete 語句,該 session 中的快取會立即被重新整理。但是注意這只是在同一 session 之間。不同 session 之間如 session1 和 session2,session1 裡的 insert/update/delete 並不會影響 session 2 下的快取,這在高併發或者分散式的情況下會產生髒資料。所以建議將一級快取級別調成 statement。
(2)配置一級快取為 STATEMENT 級別
再次將(1)中的無事務和有事務的程式碼分別執行一遍,列印結果始終如下:
配置成 SATEMENT 後,一級快取相當於被關閉了。STATEMENT 級別暫時不好模擬,但是我猜測 STATEMENT 級別即在同一執行 sql 的介面中(如上面的 getOne 中)快取,出了 getOne 快取即失效。
(3)二級快取,同時為了避免一級快取的干擾,將一級快取設定為 STATEMENT
Controller 中去掉 @Transactional 註解程式碼如下:
@RequestMapping("/getUser") public UserEntity getUser(Long id) { UserEntity user1=userMapper.getOne(id); UserEntity user2=userMapper.getOne(id); return user1; }
二級快取開關保證開啟,在 mapper xml 檔案中加入 <cache />,整個檔案程式碼如下:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace="com.binggle.learn.dao.mapper.UserMapper" > <resultMap id="BaseResultMap" type="com.binggle.learn.dao.entity.UserEntity" > <id column="id" property="id" jdbcType="BIGINT" /> <result column="name" property="name" jdbcType="VARCHAR" /> <result column="sex" property="sex"/> </resultMap> <sql id="Base_Column_List" > id, name, sex </sql> <select id="getOne" parameterType="java.lang.Long" resultMap="BaseResultMap" > SELECT <include refid="Base_Column_List" /> FROM users WHERE id = #{id}; </select> <cache /> </mapper>
執行 http://localhost:8080/getUser?id=1 ,列印結果如下:
從圖中紅框可以看出第二次查詢命中快取,0.5 是命中率,
再次執行 http://localhost:8080/getUser?id=1 列印結果如下:
這次一次 sql 也沒執行了,所以說二級快取全域性快取。但它的快取範圍也是有限的,一級快取在同一個 session 中。二級快取可以跨 session 但也只能在同一 namespace 中,所謂 namespace 即 mapper xml 檔案中。具體實驗請看《聊聊 mybatis 的快取機制》中的關於二級快取的實驗 4 和 5。再看下二級快取配置對二級快取的影響,為了明顯的看出效果,只改如下配置:
<cache size="1"//一次只能快取一個物件 flushInterval="5000" //重新整理時間為 5s />
controller 程式碼:
@RequestMapping("/getUser") public UserEntity getUser(Long id, Long id2) { //第一個物件 1 System.out.println("================快取物件 1================="); UserEntity user1 = userMapper.getOne(id); //另一個物件 2 System.out.println("========快取物件 2,剔除快取中的物件 1======="); UserEntity user2=userMapper.getOne(id2); user2 = userMapper.getOne(id2); //再次讀取第一個物件 System.out.println("==========快取被剔除,執行查詢 sql==========="); user1 = userMapper.getOne(id); //暫停 5s try { sleep(5000); }catch (Exception e){ e.printStackTrace(); } System.out.println("============5s 後再次查詢物件 2============="); user2 = userMapper.getOne(id2); return user1; }
最後列印的結果如下:
太長了,拼接下:
可以看出二級快取只能快取一個物件且 5s 後就失效了,快取失效。
總結:
我推薦的文章中總結的已經非常好了,直接引用下:
1、MyBatis一級快取的生命週期和SqlSession一致。 2、MyBatis一級快取內部設計簡單,只是一個沒有容量限定的HashMap,在快取的功能性上有所欠缺。 3、MyBatis的一級快取最大範圍是SqlSession內部,有多個SqlSession或者分散式的環境下,資料庫寫操作會引起髒資料,建議設定快取級別為Statement。 4、MyBatis的二級快取相對於一級快取來說,實現了SqlSession之間快取資料的共享,同時粒度更加的細,能夠到namespace級別,通過Cache介面實現類不同的組合,對Cache的可控性也更強。 5、MyBatis在多表查詢時,極大可能會出現髒資料,有設計上的缺陷,安全使用二級快取的條件比較苛刻。 6、在分散式環境下,由於預設的MyBatis Cache實現都是基於本地的,分散式環境下必然會出現讀取到髒資料,需要使用集中式快取將MyBatis的Cache介面實現,有一定的開發成本,直接使用Redis、Memcached等分散式快取可能成本更低,安全性也更高。 7. 個人建議MyBatis快取特性在生產環境中進行關閉,單純作為一個ORM框架使用可能更為合適。