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;
}

  http://localhost:8080/getUser?id=1&id2=2 最后打印的結果如下:

  太長了,拼接下:

  可以看出二級緩存只能緩存一個對象且 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框架使用可能更為合適。

 


免責聲明!

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



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