Mybatis一級快取,二級快取的實現就是這麼簡單

  • 2020 年 3 月 15 日
  • 筆記

介紹

又到了一年面試季,所以打算寫一點面試常問的東西,爭取說的通俗易懂。面試高級崗,如果你說熟悉Mybatis,下面這些問題基本上都會問

  1. Mybatis插件的實現原理?
  2. 如何寫一個分頁插件?
  3. Mybaits只寫了介面為什麼能運行?
  4. Mybatis的一級快取和二級快取的工作原理,會遇到什麼問題?
  5. 一級快取和二級快取的生命周期分別是?
  6. Mybatis和Spring整合後,一級快取為什麼會失效?
  7. 同時配置一級快取和二級快取後,先查詢哪個快取?

今天就來聊一下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方法

  @Override    public <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方法

  @Override    public 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

總結

  1. MyBatis一級快取的生命周期和SqlSession一致。
  2. MyBatis一級快取內部設計簡單,只是一個沒有容量限定的HashMap,在快取的功能性上有所欠缺。
  3. MyBatis的一級快取最大範圍是SqlSession內部,有多個SqlSession或者分散式的環境下,資料庫寫操作會引起臟數據,建議設定快取級別為Statement,即進行如下配置
<setting name="localCacheScope" value="STATEMENT"/>

原因也很簡單,看BaseExecutor的query()方法,當配置成STATEMENT時,每次查詢完都會清空快取

   if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {      // issue #482      clearLocalCache();    }

mybatis和spring整合的一些注意事項

  1. 在未開啟事物的情況之下,每次查詢,spring都會關閉舊的sqlSession而創建新的sqlSession,因此此時的一級快取是沒有起作用的
  2. 在開啟事物的情況之下,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放置了如下幾個對象

  1. mappedStatment的id
  2. 指定查詢結構集的範圍
  3. 查詢所使用SQL語句
  4. 用戶傳遞給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 className      FROM classroom c      JOIN student s ON c.student_id = s.id      JOIN class ON c.class_id = class.id      WHERE 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>中的任意一個標籤,則表示開啟了二級快取功能,沒有的話表示不開啟

<cache type="" eviction="FIFO" size="512"></cache>

二級快取的部分配置如上,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技術內幕一書》

總結

  1. MyBatis的二級快取相對於一級快取來說,實現了SqlSession之間快取數據的共享
  2. MyBatis在多表查詢時,極大可能會出現臟數據,有設計上的缺陷,安全使用二級快取的條件比較苛刻
  3. 在分散式環境下,由於默認的MyBatis Cache實現都是基於本地的,分散式環境下必然會出現讀取到臟數據,需要使用集中式快取將MyBatis的Cache介面實現,有一定的開發成本,直接使用Redis、Memcached等分散式快取可能成本更低,安全性也更高。

問題回答

  1. 一級快取和二級快取的生命周期分別是? 一級快取的生命周期是會話級別,因為一級快取是存在Sqlsession的成員變數Executor的成員變數localCache中的。而二級快取的生命周期是整個應用級別,因為二級快取是存在Configuration對象中,而這個對象在應用啟動後一直存在
  2. 同時配置一級快取和二級快取後,先查詢哪個快取? 當然是先查詢二級快取再查詢一級快取啊,因為一級快取的實現在BaseExecutor,而二級快取的實現在CachingExecutor,CachingExecutor是BaseExecutor的裝飾器

參考部落格

[1]https://tech.meituan.com/2018/01/19/mybatis-cache.html