MyBatis 二級緩存全詳解


我們在上一篇文章 ( https://mp.weixin.qq.com/s/4Puee_pPCNArkgnFaYlIjg ) 介紹了 MyBatis 的一級緩存的作用,如何開啟,一級緩存的本質是什么,一級緩存失效的原因是什么? MyBatis 只有一級緩存嗎?來找找答案吧!

MyBatis 二級緩存介紹

上一篇文章中我們介紹到了 MyBatis 一級緩存其實就是 SqlSession 級別的緩存,什么是 SqlSession 級別的緩存呢?一級緩存的本質是什么呢? 以及一級緩存失效的原因?我希望你在看下文之前能夠回想起來這些內容。

MyBatis 一級緩存最大的共享范圍就是一個SqlSession內部,那么如果多個 SqlSession 需要共享緩存,則需要開啟二級緩存,開啟二級緩存后,會使用 CachingExecutor 裝飾 Executor,進入一級緩存的查詢流程前,先在CachingExecutor 進行二級緩存的查詢,具體的工作流程如下所示

當二級緩存開啟后,同一個命名空間(namespace) 所有的操作語句,都影響着一個共同的 cache,也就是二級緩存被多個 SqlSession 共享,是一個全局的變量。當開啟緩存后,數據的查詢執行的流程就是 二級緩存 -> 一級緩存 -> 數據庫。

二級緩存開啟條件

二級緩存默認是不開啟的,需要手動開啟二級緩存,實現二級緩存的時候,MyBatis要求返回的POJO必須是可序列化的。開啟二級緩存的條件也是比較簡單,通過直接在 MyBatis 配置文件中通過

<settings>
	<setting name = "cacheEnabled" value = "true" />
</settings>

來開啟二級緩存,還需要在 Mapper 的xml 配置文件中加入 <cache> 標簽

設置 cache 標簽的屬性

cache 標簽有多個屬性,一起來看一些這些屬性分別代表什么意義

  • eviction: 緩存回收策略,有這幾種回收策略
    • LRU - 最近最少回收,移除最長時間不被使用的對象
    • FIFO - 先進先出,按照緩存進入的順序來移除它們
    • SOFT - 軟引用,移除基於垃圾回收器狀態和軟引用規則的對象
    • WEAK - 弱引用,更積極的移除基於垃圾收集器和弱引用規則的對象

默認是 LRU 最近最少回收策略

  • flushinterval 緩存刷新間隔,緩存多長時間刷新一次,默認不清空,設置一個毫秒值
  • readOnly: 是否只讀;true 只讀,MyBatis 認為所有從緩存中獲取數據的操作都是只讀操作,不會修改數據。MyBatis 為了加快獲取數據,直接就會將數據在緩存中的引用交給用戶。不安全,速度快。讀寫(默認):MyBatis 覺得數據可能會被修改
  • size : 緩存存放多少個元素
  • type: 指定自定義緩存的全類名(實現Cache 接口即可)
  • blocking: 若緩存中找不到對應的key,是否會一直blocking,直到有對應的數據進入緩存。

探究二級緩存

我們繼續以 MyBatis 一級緩存文章中的例子為基礎,搭建一個滿足二級緩存的例子,來對二級緩存進行探究,例子如下(對 一級緩存的例子部分源碼進行修改):

Dept.java

//存放在共享緩存中數據進行序列化操作和反序列化操作
//因此數據對應實體類必須實現【序列化接口】
public class Dept implements Serializable {

    private Integer deptNo;
    private String  dname;
    private String  loc;

    public Dept() {}
    public Dept(Integer deptNo, String dname, String loc) {
        this.deptNo = deptNo;
        this.dname = dname;
        this.loc = loc;
    }

   get and set...
    @Override
    public String toString() {
        return "Dept{" +
                "deptNo=" + deptNo +
                ", dname='" + dname + '\'' +
                ", loc='" + loc + '\'' +
                '}';
    }
}

myBatis-config.xml

在myBatis-config 中添加開啟二級緩存的條件

<!-- 通知 MyBatis 框架開啟二級緩存 -->
<settings>
  <setting name="cacheEnabled" value="true"/>
</settings>

DeptDao.xml

還需要在 Mapper 對應的xml中添加 cache 標簽,表示對哪個mapper 開啟緩存

<!-- 表示DEPT表查詢結果保存到二級緩存(共享緩存) -->
<cache/>

對應的二級緩存測試類如下:

public class MyBatisSecondCacheTest {

    private SqlSession sqlSession;
    SqlSessionFactory factory;
    @Before
    public void start() throws IOException {
        InputStream is = Resources.getResourceAsStream("myBatis-config.xml");
        SqlSessionFactoryBuilder builderObj = new SqlSessionFactoryBuilder();
        factory = builderObj.build(is);
        sqlSession = factory.openSession();
    }
    @After
    public void destory(){
        if(sqlSession!=null){
            sqlSession.close();
        }
    }

    @Test
    public void testSecondCache(){
        //會話過程中第一次發送請求,從數據庫中得到結果
        //得到結果之后,mybatis自動將這個查詢結果放入到當前用戶的一級緩存
        DeptDao dao =  sqlSession.getMapper(DeptDao.class);
        Dept dept = dao.findByDeptNo(1);
        System.out.println("第一次查詢得到部門對象 = "+dept);
        //觸發MyBatis框架從當前一級緩存中將Dept對象保存到二級緩存

        sqlSession.commit();
      	// 改成 sqlSession.close(); 效果相同

        SqlSession session2 = factory.openSession();
        DeptDao dao2 = session2.getMapper(DeptDao.class);
        Dept dept2 = dao2.findByDeptNo(1);
        System.out.println("第二次查詢得到部門對象 = "+dept2);
    }
}

測試二級緩存效果,提交事務,sqlSession查詢完數據后,sqlSession2相同的查詢是否會從緩存中獲取數據。

測試結果如下:

![image-20190720161244429](/Users/mr.l/Library/Application Support/typora-user-images/image-20190720161244429.png)

通過結果可以得知,首次執行的SQL語句是從數據庫中查詢得到的結果,然后第一個 SqlSession 執行提交,第二個 SqlSession 執行相同的查詢后是從緩存中查取的。

用一下這幅圖能夠比較直觀的反映兩次 SqlSession 的緩存命中

二級緩存失效的條件

與一級緩存一樣,二級緩存也會存在失效的條件的,下面我們就來探究一下哪些情況會造成二級緩存失效

第一次SqlSession 未提交

SqlSession 在未提交的時候,SQL 語句產生的查詢結果還沒有放入二級緩存中,這個時候 SqlSession2 在查詢的時候是感受不到二級緩存的存在的,修改對應的測試類,結果如下:

@Test
public void testSqlSessionUnCommit(){
  //會話過程中第一次發送請求,從數據庫中得到結果
  //得到結果之后,mybatis自動將這個查詢結果放入到當前用戶的一級緩存
  DeptDao dao =  sqlSession.getMapper(DeptDao.class);
  Dept dept = dao.findByDeptNo(1);
  System.out.println("第一次查詢得到部門對象 = "+dept);
  //觸發MyBatis框架從當前一級緩存中將Dept對象保存到二級緩存

  SqlSession session2 = factory.openSession();
  DeptDao dao2 = session2.getMapper(DeptDao.class);
  Dept dept2 = dao2.findByDeptNo(1);
  System.out.println("第二次查詢得到部門對象 = "+dept2);
}

產生的輸出結果:

更新對二級緩存影響

與一級緩存一樣,更新操作很可能對二級緩存造成影響,下面用三個 SqlSession來進行模擬,第一個 SqlSession 只是單純的提交,第二個 SqlSession 用於檢驗二級緩存所產生的影響,第三個 SqlSession 用於執行更新操作,測試如下:

@Test
public void testSqlSessionUpdate(){
  SqlSession sqlSession = factory.openSession();
  SqlSession sqlSession2 = factory.openSession();
  SqlSession sqlSession3 = factory.openSession();

  // 第一個 SqlSession 執行更新操作
  DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
  Dept dept = deptDao.findByDeptNo(1);
  System.out.println("dept = " + dept);
  sqlSession.commit();

  // 判斷第二個 SqlSession 是否從緩存中讀取
  DeptDao deptDao2 = sqlSession2.getMapper(DeptDao.class);
  Dept dept2 = deptDao2.findByDeptNo(1);
  System.out.println("dept2 = " + dept2);

  // 第三個 SqlSession 執行更新操作
  DeptDao deptDao3 = sqlSession3.getMapper(DeptDao.class);
  deptDao3.updateDept(new Dept(1,"ali","hz"));
  sqlSession3.commit();

  // 判斷第二個 SqlSession 是否從緩存中讀取
  dept2 = deptDao2.findByDeptNo(1);
  System.out.println("dept2 = " + dept2);
}

對應的輸出結果如下

探究多表操作對二級緩存的影響

現有這樣一個場景,有兩個表,部門表dept(deptNo,dname,loc)和 部門數量表deptNum(id,name,num),其中部門表的名稱和部門數量表的名稱相同,通過名稱能夠聯查兩個表可以知道其坐標(loc)和數量(num),現在我要對部門數量表的 num 進行更新,然后我再次關聯dept 和 deptNum 進行查詢,你認為這個 SQL 語句能夠查詢到的 num 的數量是多少?來看一下代碼探究一下

DeptNum.java

public class DeptNum {

    private int id;
    private String name;
    private int num;

    get and set...
}

DeptVo.java

public class DeptVo {

    private Integer deptNo;
    private String  dname;
    private String  loc;
    private Integer num;

    public DeptVo(Integer deptNo, String dname, String loc, Integer num) {
        this.deptNo = deptNo;
        this.dname = dname;
        this.loc = loc;
        this.num = num;
    }

    public DeptVo(String dname, Integer num) {
        this.dname = dname;
        this.num = num;
    }

    get and set

    @Override
    public String toString() {
        return "DeptVo{" +
                "deptNo=" + deptNo +
                ", dname='" + dname + '\'' +
                ", loc='" + loc + '\'' +
                ", num=" + num +
                '}';
    }
}

DeptDao.java

public interface DeptDao {

    ...

    DeptVo selectByDeptVo(String name);

    DeptVo selectByDeptVoName(String name);

    int updateDeptVoNum(DeptVo deptVo);
}

DeptDao.xml

<select id="selectByDeptVo" resultType="com.mybatis.beans.DeptVo">
  select d.deptno,d.dname,d.loc,dn.num from dept d,deptNum dn where dn.name = d.dname
  and d.dname = #{name}
</select>

<select id="selectByDeptVoName" resultType="com.mybatis.beans.DeptVo">
  select * from deptNum where name = #{name}
</select>

<update id="updateDeptVoNum" parameterType="com.mybatis.beans.DeptVo">
  update deptNum set num = #{num} where name = #{dname}
</update>

DeptNum 數據庫初始值:

測試類對應如下:

/**
     * 探究多表操作對二級緩存的影響
     */
@Test
public void testOtherMapper(){

  // 第一個mapper 先執行聯查操作
  SqlSession sqlSession = factory.openSession();
  DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
  DeptVo deptVo = deptDao.selectByDeptVo("ali");
  System.out.println("deptVo = " + deptVo);
  // 第二個mapper 執行更新操作 並提交
  SqlSession sqlSession2 = factory.openSession();
  DeptDao deptDao2 = sqlSession2.getMapper(DeptDao.class);
  deptDao2.updateDeptVoNum(new DeptVo("ali",1000));
  sqlSession2.commit();
  sqlSession2.close();
  // 第一個mapper 再次進行查詢,觀察查詢結果
  deptVo = deptDao.selectByDeptVo("ali");
  System.out.println("deptVo = " + deptVo);
}

測試結果如下:

在對DeptNum 表執行了一次更新后,再次進行聯查,發現數據庫中查詢出的還是 num 為 1050 的值,也就是說,實際上 1050 -> 1000 ,最后一次聯查實際上查詢的是第一次查詢結果的緩存,而不是從數據庫中查詢得到的值,這樣就讀到了臟數據。

解決辦法

如果是兩個mapper命名空間的話,可以使用 <cache-ref>來把一個命名空間指向另外一個命名空間,從而消除上述的影響,再次執行,就可以查詢到正確的數據

二級緩存源碼解析

源碼模塊主要分為兩個部分:二級緩存的創建和二級緩存的使用,首先先對二級緩存的創建進行分析:

二級緩存的創建

二級緩存的創建是使用 Resource 讀取 XML 配置文件開始的

InputStream is = Resources.getResourceAsStream("myBatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
factory = builder.build(is);

讀取配置文件后,需要對XML創建 Configuration並初始化

XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
return build(parser.parse());

調用 parser.parse() 解析根目錄 /configuration 下面的標簽,依次進行解析

public Configuration parse() {
  if (parsed) {
    throw new BuilderException("Each XMLConfigBuilder can only be used once.");
  }
  parsed = true;
  parseConfiguration(parser.evalNode("/configuration"));
  return configuration;
}
private void parseConfiguration(XNode root) {
  try {
    //issue #117 read properties first
    propertiesElement(root.evalNode("properties"));
    Properties settings = settingsAsProperties(root.evalNode("settings"));
    loadCustomVfs(settings);
    typeAliasesElement(root.evalNode("typeAliases"));
    pluginElement(root.evalNode("plugins"));
    objectFactoryElement(root.evalNode("objectFactory"));
    objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
    reflectorFactoryElement(root.evalNode("reflectorFactory"));
    settingsElement(settings);
    // read it after objectFactory and objectWrapperFactory issue #631
    environmentsElement(root.evalNode("environments"));
    databaseIdProviderElement(root.evalNode("databaseIdProvider"));
    typeHandlerElement(root.evalNode("typeHandlers"));
    mapperElement(root.evalNode("mappers"));
  } catch (Exception e) {
    throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
  }
}

其中有一個二級緩存的解析就是

mapperElement(root.evalNode("mappers"));

然后進去 mapperElement 方法中

XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
            mapperParser.parse();

繼續跟 mapperParser.parse() 方法

public void parse() {
  if (!configuration.isResourceLoaded(resource)) {
    configurationElement(parser.evalNode("/mapper"));
    configuration.addLoadedResource(resource);
    bindMapperForNamespace();
  }

  parsePendingResultMaps();
  parsePendingCacheRefs();
  parsePendingStatements();
}

這其中有一個 configurationElement 方法,它是對二級緩存進行創建,如下

private void configurationElement(XNode context) {
  try {
    String namespace = context.getStringAttribute("namespace");
    if (namespace == null || namespace.equals("")) {
      throw new BuilderException("Mapper's namespace cannot be empty");
    }
    builderAssistant.setCurrentNamespace(namespace);
    cacheRefElement(context.evalNode("cache-ref"));
    cacheElement(context.evalNode("cache"));
    parameterMapElement(context.evalNodes("/mapper/parameterMap"));
    resultMapElements(context.evalNodes("/mapper/resultMap"));
    sqlElement(context.evalNodes("/mapper/sql"));
    buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
  } catch (Exception e) {
    throw new BuilderException("Error parsing Mapper XML. Cause: " + e, e);
  }
}

有兩個二級緩存的關鍵點

cacheRefElement(context.evalNode("cache-ref"));
cacheElement(context.evalNode("cache"));

也就是說,mybatis 首先進行解析的是 cache-ref 標簽,其次進行解析的是 cache 標簽。

根據上面我們的 — 多表操作對二級緩存的影響 一節中提到的解決辦法,采用 cache-ref 來進行命名空間的依賴能夠避免二級緩存,但是總不能每次寫一個 XML 配置都會采用這種方式吧,最有效的方式還是避免多表操作使用二級緩存

然后我們再來看一下cacheElement(context.evalNode("cache")) 這個方法

private void cacheElement(XNode context) throws Exception {
  if (context != null) {
    String type = context.getStringAttribute("type", "PERPETUAL");
    Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
    String eviction = context.getStringAttribute("eviction", "LRU");
    Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
    Long flushInterval = context.getLongAttribute("flushInterval");
    Integer size = context.getIntAttribute("size");
    boolean readWrite = !context.getBooleanAttribute("readOnly", false);
    boolean blocking = context.getBooleanAttribute("blocking", false);
    Properties props = context.getChildrenAsProperties();
    builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
  }
}

認真看一下其中的屬性的解析,是不是感覺很熟悉?這不就是對 cache 標簽屬性的解析嗎?!!!

上述最后一句代碼

builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
public Cache useNewCache(Class<? extends Cache> typeClass,
      Class<? extends Cache> evictionClass,
      Long flushInterval,
      Integer size,
      boolean readWrite,
      boolean blocking,
      Properties props) {
    Cache cache = new CacheBuilder(currentNamespace)
        .implementation(valueOrDefault(typeClass, PerpetualCache.class))
        .addDecorator(valueOrDefault(evictionClass, LruCache.class))
        .clearInterval(flushInterval)
        .size(size)
        .readWrite(readWrite)
        .blocking(blocking)
        .properties(props)
        .build();
    configuration.addCache(cache);
    currentCache = cache;
    return cache;
  }

這段代碼使用了構建器模式,一步一步構建Cache 標簽的所有屬性,最終把 cache 返回。

二級緩存的使用

在 mybatis 中,使用 Cache 的地方在 CachingExecutor中,來看一下 CachingExecutor 中緩存做了什么工作,我們以查詢為例

@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
  throws SQLException {
  // 得到緩存
  Cache cache = ms.getCache();
  if (cache != null) {
    // 如果需要的話刷新緩存
    flushCacheIfRequired(ms);
    if (ms.isUseCache() && resultHandler == null) {
      ensureNoOutParams(ms, parameterObject, boundSql);
      @SuppressWarnings("unchecked")
      List<E> list = (List<E>) tcm.getObject(cache, key);
      if (list == null) {
        list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
        tcm.putObject(cache, key, list); // issue #578 and #116
      }
      return list;
    }
  }
  // 委托模式,交給SimpleExecutor等實現類去實現方法。
  return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

其中,先從 MapperStatement 取出緩存。只有通過<cache/>,<cache-ref/>@CacheNamespace,@CacheNamespaceRef標記使用緩存的Mapper.xml或Mapper接口(同一個namespace,不能同時使用)才會有二級緩存。

如果緩存不為空,說明是存在緩存。如果cache存在,那么會根據sql配置(<insert>,<select>,<update>,<delete>flushCache屬性來確定是否清空緩存。

flushCacheIfRequired(ms);

然后根據xml配置的屬性useCache來判斷是否使用緩存(resultHandler一般使用的默認值,很少會null)。

if (ms.isUseCache() && resultHandler == null)

確保方法沒有Out類型的參數,mybatis不支持存儲過程的緩存,所以如果是存儲過程,這里就會報錯。

private void ensureNoOutParams(MappedStatement ms, Object parameter, BoundSql boundSql) {
  if (ms.getStatementType() == StatementType.CALLABLE) {
    for (ParameterMapping parameterMapping : boundSql.getParameterMappings()) {
      if (parameterMapping.getMode() != ParameterMode.IN) {
        throw new ExecutorException("Caching stored procedures with OUT params is not supported.  Please configure useCache=false in " + ms.getId() + " statement.");
      }
    }
  }
}

然后根據在 TransactionalCacheManager 中根據 key 取出緩存,如果沒有緩存,就會執行查詢,並且將查詢結果放到緩存中並返回取出結果,否則就執行真正的查詢方法。

List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
  list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;

是否應該使用二級緩存?

那么究竟應該不應該使用二級緩存呢?先來看一下二級緩存的注意事項:

  1. 緩存是以namespace為單位的,不同namespace下的操作互不影響。
  2. insert,update,delete操作會清空所在namespace下的全部緩存。
  3. 通常使用MyBatis Generator生成的代碼中,都是各個表獨立的,每個表都有自己的namespace
  4. 多表操作一定不要使用二級緩存,因為多表操作進行更新操作,一定會產生臟數據。

如果你遵守二級緩存的注意事項,那么你就可以使用二級緩存。

但是,如果不能使用多表操作,二級緩存不就可以用一級緩存來替換掉嗎?而且二級緩存是表級緩存,開銷大,沒有一級緩存直接使用 HashMap 來存儲的效率更高,所以二級緩存並不推薦使用


免責聲明!

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



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