一文搞定mybatis的快取體系
- 前言
- 一級快取
- 命中場景
- 原始碼分析
- spring集成時一級快取失效問題
- 二級快取
- 快取的完整方案
- Mybatis二級快取結構以及實作
- 二級快取命中場景
- 為什么需要提交后才能命中快取?
- 二級快取執行流程
- 查詢 query
- 更新 update
- 提交 commit
- 原始碼閱讀
- 總結
前言
之前小撰寫了mybatis中的執行器,今天來講一下mybatis的快取,大家都知道mybatis有二級快取,一級快取是默認開啟的,而二級快取是可以配置的,其實如果看完小編上次的執行器,大家可以知道,一級快取是在BaseExecutor中實作的,而二級快取是在CachingExecutor中,二級快取開啟可以配置在xml中也可以在介面上加入@CacheNamespace注解,不了解的小伙伴可以看精通Mybatis之Executor執行器這篇文章,那我們接下來詳細講解一下mybatis的一級和二級快取,他們的命中場景,原始碼分析,和spring集成時快取失效的原因等,進入正題,
一級快取
這次小編先寫結論然后通過代碼示例證明,
一級快取資料結構:
通過底層原始碼可以知道快取的資料結構就是一個Map而且是HashMap,
命中場景
先看下圖:

關于一級快取的命中可大致分為兩個場景,滿足所有運行引數,第二不觸發或不配置清空快取方法,
上面圖上很清楚就是得滿足上面兩個場景才可以的,
下面小編用示例代碼來說明,運行引數相關的代碼:
public class SqlSessionTest {
private SqlSessionFactory factory;
private SqlSession sqlSession;
@Before
public void init() throws SQLException {
// 獲取構建器
SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
// 決議XML 并構造會話工廠
factory = factoryBuilder.build(ExecutorTest.class.getResourceAsStream("/mybatis-config.xml"));
sqlSession = factory.openSession();
}
//不同會話
@Test
public void firstCacheTest() {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
UserMapper mapper2 = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectByid(10);
User user2 = mapper2.selectByid(10);
System.out.println(user == user2);
}
//相同sql相同引數
@Test
public void firstCacheTest1() {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectByid(10);
User user2 = mapper.selectByid(10);
System.out.println(user == user2);
}
//不同的statementId
@Test
public void firstCacheTest2() {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
//xxx.xxx.xxx.UserMapper.selectByid
User user = mapper.selectByid(10);
//xxx.xxx.xxx.UserMapper.selectByid3
User user2 = mapper.selectByid3(10);
System.out.println(user == user2);
}
//不同的RowBounds
@Test
public void firstCacheTest3() {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectByid(10);
RowBounds rowBounds = RowBounds.DEFAULT;
List<User> userList = sqlSession.selectList("xxx.xxx.xxx.UserMapper.selectByid", 10, rowBounds);
System.out.println(user == userList.get(0));
rowBounds =new RowBounds(0,10);
List<User> userList2 =sqlSession.selectList("xxx.xxx.xxx.UserMapper.selectByid",10,rowBounds);
System.out.println(user == userList2.get(0));
}
}
上面執行結果分別是:
//firstCacheTest
false
//firstCacheTest1
true
//firstCacheTest2
false
//firstCacheTest3
true
false
是不是很簡單,上面引數如果查詢的id不同當然命中不了快取了,這個小編就省略了
操作配置相關代碼示例
@Test
public void firstCacheConfigTest() {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectByid(10);
sqlSession.clearCache();
User user2 = mapper.selectByid(10);
System.out.println(user == user2);
}
@Test
public void firstCacheConfigTest1() {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectByid3(10);
User user2 = mapper.selectByid3(10);
System.out.println(user == user2);
}
@Test
public void firstCacheConfigTest2() {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectByid(10);
//無論update是哪個id都會清空
mapper.setName(11,"ok");
User user2 = mapper.selectByid(10);
System.out.println(user == user2);
}
//上面firstCacheConfigTest1時加入了Options
@Select({" select * from users where id=#{1}"})
@Options(flushCache = Options.FlushCachePolicy.TRUE)
User selectByid3(Integer id);
上面執行結果分別是:
//firstCacheConfigTest
false
//firstCacheConfigTest1
false
//firstCacheConfigTest2
false
還有一個是全域的配置localCacheScope的配置STATEMENT注意這里需要大小寫,這樣快取也就失效了
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>
好了講完了一級快取的命中場景,咱們分析一下原始碼吧,
原始碼分析
前言中小編闡明了一級快取中BaseExecutor里面,下面小編先畫個快取邏輯操作的流程圖:

上圖流程非常簡單,無法就是查詢的時候是否有快取有就回傳,沒有就使用子類查詢,查詢完畢后封裝進快取然后回傳結果,當然看原始碼的時候其實還有各種判斷,比方說會話是否關閉,請求的結果是否需要處理,包括是否要清除快取和請求引數快取等等,
原始碼閱讀以及關鍵注釋
public abstract class BaseExecutor implements Executor {
protected int queryStack;
private boolean closed;
protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
protected PerpetualCache localCache;
protected PerpetualCache localOutputParameterCache;
@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
if (closed) {
throw new ExecutorException("Executor was closed.");
}
if (queryStack == 0 && ms.isFlushCacheRequired()) {
//清空快取 條件第一次查詢并且配置了flushCache=true,對子查詢不受影響
clearLocalCache();
}
List<E> list;
try {
queryStack++;
//從快取中取值
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
if (queryStack == 0) {
for (DeferredLoad deferredLoad : deferredLoads) {
deferredLoad.load();
}
// issue #601
deferredLoads.clear();
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
//情況快取 組態檔里快取作用域為STATEMENT 同樣對子查詢不受影響
clearLocalCache();
}
}
return list;
}
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
//放入快取
localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
@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);
}
@Override
public void rollback(boolean required) throws SQLException {
if (!closed) {
try {
//回滾清空快取
clearLocalCache();
flushStatements(true);
} finally {
if (required) {
transaction.rollback();
}
}
}
}
@Override
public void commit(boolean required) throws SQLException {
if (closed) {
throw new ExecutorException("Cannot commit, transaction is already closed");
}
//提交情況快取
clearLocalCache();
flushStatements();
if (required) {
transaction.commit();
}
}
}
下面是快取key的結構,這里就能明白為什么命中引數有那么多條件,這邊小編稍微說明一下,會話是不用再次說明的,環境引數在使用的時候一般不會多套,可以忽略

這樣原始碼就和結論對起來了,注意clearLocalCache()清空所有一級快取,
spring集成時一級快取失效問題
很多人發現,mybatis集成spring一級快取后會話失效了,以為是spring Bug ,真正原因是Spring 對SqlSession進行了封裝,通過SqlSessionTemplae ,使得每次呼叫Sql,都會重新構建一個SqlSession,具體參見SqlSessionInterceptor,而根據前面所說的命中場景,一級快取必須是同一會話才能命中,所以在這些場景當中不能命中,
怎么解決呢,給Spring 添加事務即可,添加事務之后,SqlSessionInterceptor(會話攔截器)就會去判斷兩次請求是否在同一事務當中,如果是就會共用同一個SqlSession會話來解決,

@Test
public void testBySpring(){
ClassPathXmlApplicationContext context=new ClassPathXmlApplicationContext("spring.xml");
UserMapper mapper = context.getBean(UserMapper.class);
// mapper ->SqlSessionTemplate --> SqlSessionInterceptor-->SqlSessionFactory
DataSourceTransactionManager transactionManager =
(DataSourceTransactionManager) context.getBean("txManager");
// 手動開啟事務
TransactionStatus status = transactionManager
.getTransaction(new DefaultTransactionDefinition());
// 每次都會構造一個新會話 發起呼叫
User user = mapper.selectByid(10);
// 每次都會構造一個新會話 發起呼叫
User user1 =mapper.selectByid(10);
System.out.println(user == user1);
}
上面如果沒有開啟事務,結果為false,開啟事務就為true
大家如果除錯代碼的話記得打斷點在
org.mybatis.spring.SqlSessionUtils#getSqlSession方法,下面是小編斷點的堆疊圖大家有空可以看一下
這邊插一嘴大家還記得mybatis和spring的集成原理嗎?可以看小編之前寫的文章

二級快取
二級快取也稱作是應用級快取,與一級快取不同的,是它的作用范圍是整個應用,而且可以跨執行緒使用,所以二級快取有更高的命中率,適合快取一些修改較少的資料,在流程上是先訪問二級快取,再訪問一級快取,
快取的完整方案
核心功能包括存盤方案和溢位淘汰演算法
存盤方案:
- 記憶體:最簡單就是在記憶體當中,不僅實作簡單,而且速度快,記憶體弊端就是不能持久化,且存盤有限,
- 硬碟:可以持久化,容量大,但訪問速度不如記憶體,一般會結合記憶體一起使用,
- 第三方集成:在分布式情況,如果想和其它節點共享快取,只能第三方軟體進行集成,比如Redis.
溢位淘汰
- FIFO:先進先出
- LRU:最近最少使用
- WeakReference: 弱參考,將快取物件進行弱參考包裝,當Java進行gc的時候,不論當前的記憶體空間是否足夠,這個物件都會被回收
- SoftReference:軟參考,與弱參考類似,不同在于只有當空間不足時GC才才回收軟參考物件,
非核心功能:
- 過期清理:指清理存放資料過久的資料
- 執行緒安全:保證快取可以被多個執行緒同時使用
- 寫安全:當拿到快取資料后,可對其進行修改,而不影響原本的快取資料,通常采取做法是對快取物件進行深拷貝,
還有其他一些需求這邊小編就不一一舉例了,這個主要是對大家以后設計功能的時候的多重考慮,
Mybatis二級快取結構以及實作
上面小編說了設計快取需要一套完整的解決方案,那咱們來看一下Mybatis的二級快取是在如何完成以上功能的情況下還有很好的擴展和設計模式,首先我們來看下二級快取的結構圖(mybatis不止這些cache,大家有空自己研究一下,小編只是大致羅列):

上面每一個功能都會對應一個組件類,并基于裝飾者加責任鏈的模式,將各個組件進行串聯,在執行快取的基本功能時,其它的快取邏輯會沿著這個責任鏈依次往下傳遞,
設計優點
1、職責單一:各個節點只負責自己的邏輯,不需要關心其它節點,
2、擴展性強:可根據需要擴展節點、洗掉節點,還可以調換順序保證靈活性,(PerpetualCache里面沒有delegate屬性)
3、松耦合:各節點之間不沒強制依賴其它節點,而是通過頂層的Cache介面進行間接依賴,
代碼示例
public class SecondCacheTest {
private SqlSessionFactory factory;
private SqlSession sqlSession;
private Configuration configuration;
@Before
public void init() throws SQLException {
// 獲取構建器
SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
// 決議XML 并構造會話工廠
factory = factoryBuilder.build(ExecutorTest.class.getResourceAsStream("/mybatis-config.xml"));
sqlSession = factory.openSession();
configuration = factory.getConfiguration();
}
@Test
public void secondCacheTest(){
Cache cache = configuration.getCache("xxx.xxx.xxx.UserMapper");
cache.putObject("user",new User());
cache.getObject("user");
}
}
斷點除錯:

這邊大家是否和小編一樣,那mybatis對這些快取的組裝是在哪兒的,然后各個快取組件做了什么功能?看原始碼:
首先是組件的實作以上面斷點除錯為例:(其他小伙伴自己看啊)
SynchronizedCache
//加入執行緒同步
public synchronized void putObject(Object key, Object object) {
delegate.putObject(key, object);
}
LoggingCache
//啥都沒做
@Override
public void putObject(Object key, Object object) {
delegate.putObject(key, object);
}
//取出來的時候做了命中率
@Override
public Object getObject(Object key) {
requests++;
final Object value = delegate.getObject(key);
if (value != null) {
hits++;
}
if (log.isDebugEnabled()) {
log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());
}
return value;
}
SerializedCache(跨執行緒遠程呼叫的時候需要序列化,保證安全性同時序列化哈反序列話是需要時間,效率就會變慢)
@Override
public void putObject(Object key, Object object) {
if (object == null || object instanceof Serializable) {
delegate.putObject(key, serialize((Serializable) object));
} else {
throw new CacheException("SharedCache failed to make a copy of a non-serializable object: " + object);
}
}
@Override
public Object getObject(Object key) {
Object object = delegate.getObject(key);
return object == null ? null : deserialize((byte[]) object);
}
LruCache(默認溢位淘汰快取 最久沒用的淘汰)
public void setSize(final int size) {
//使用linkedHashMap每次放入是最新的,當到達最大的數量時,將最久的移出即可
//為什么使用LinkedHashMap,洗掉和添加的效率比較高
keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
private static final long serialVersionUID = 4267176411845948333L;
@Override
protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
boolean tooBig = size() > size;
if (tooBig) {
eldestKey = eldest.getKey();
}
return tooBig;
}
};
}
@Override
public void putObject(Object key, Object value) {
delegate.putObject(key, value);
cycleKeyList(key);
}
@Override
public Object getObject(Object key) {
//訪問后原本的順序就修改了
keyMap.get(key); //touch
return delegate.getObject(key);
}
private void cycleKeyList(Object key) {
keyMap.put(key, key);
if (eldestKey != null) {
delegate.removeObject(eldestKey);
eldestKey = null;
}
}
PerpetualCache
private Map<Object, Object> cache = new HashMap<>();
@Override
public void putObject(Object key, Object value) {
cache.put(key, value);
}
然后配置快取策略
二級默認快取默認是不開啟的,需要為其宣告快取空間才可以使用,通過@CacheNamespace 或為指定的MappedStatement宣告,宣告之后該快取為該Mapper所獨有,其它Mapper不能訪問,如需要多個Mapper共享一個快取空間可通過@CacheNamespaceRef 或進行參考同一個快取空間,@CacheNamespace 詳細配置見下表:
| 配置 | 說明 |
|---|---|
| implementation | 指定快取的存盤實作類,默認是用HashMap存盤在記憶體當中(PerpetualCache) |
| eviction | 指定快取溢位淘汰實作類,默認LRU ,清除最少使用 |
| flushInterval | 設定快取定時全部清空時間,默認不清空, |
| size | 指定快取容量,超出后就會按eviction指定演算法進行淘汰 |
| readWrite | true即通過序列化復制,來保證快取物件是可讀寫的,默認true |
| blocking | 為每個Key的訪問添加阻塞鎖,防止快取擊穿 |
| properties | 為上述組件,配置額外引數,key對應組件中的欄位名,Property values for a implementation object. |
注:Cache中責任鏈條的組成即通過@CacheNamespace 指導生成,具體邏輯詳見CacheBuilder
大家可以對快取做擴展,在快取策略中修改@CacheNamespace指定的引數后,比方說將implementation 指定為第三方存盤(需要實作Cache介面)等,其實在呼叫的時候完全沒有影響,大家可以試著做一下修改,這邊小編其實在學習程序中做了一系列改動的,包括改動淘汰溢位策略等等,這邊就沒貼出原始碼了,希望各位小伙伴試一下,來增加印象,
快取其他配置
除@CacheNamespace 還可以通過其它引數來控制二級快取()
| 欄位 | 配置域 | 說明 |
|---|---|---|
| cacheEnabled | 二級快取全域開關,默認開啟 | |
| useCache | <select/update/insert/delete> | 指定的statement是否開啟,默認開啟 |
| flushCache | <select/update/insert/delete> | 執行sql前是否清空當前二級快取空間,update默認true,query默認false |
| < cache/> | 快取空間與@CacheNamespace類似,如果xml和mapper同時配置會報錯 | |
| < cache-ref/> | 參考快取空間 與@CacheNamespaceRef類似 |
@CacheNamespace和@CacheNamespaceRef的區別以及使用
注意:< cache/>與@CacheNamespace是不能同時用的會報錯(用在相同的namespace里面),如果介面里面的方法查詢走的是xml則@CacheNamespace不起作用,那就需要使用到< cache-ref/>配置了可能這么說大家不明白,那小編下面給了代碼示例,或者反一些也行,即介面里面用@CacheNamespaceRef 注解xml中用 < cache/>,同時注意CacheNamespaceRef 必須指定name或value屬性
@CacheNamespace
public interface UserMapper {
@Select({" select * from users where id=#{1}"})
User selectByid(Integer id);
//這個不會被二級快取
List<User> selectByUser(User user);
}
在xml中配置
<cache-ref namespace="xxx.xxx.xxx.UserMapper"/>
<select id="selectByUser" resultMap="result_user" parameterMap="paramter_user">
select * from users where 1=1
<if test="id!=null">
and id=#{id}
</if>
<if test="name!=null">
and name=#{name}
</if>
<if test="age!=null">
and age=#{age}
</if>
</select>
二級快取命中場景
二級快取命中條件先看下圖(除了一個條件與一級快取不同其他都差不多):

這邊小撰寫了一個代碼示例(會話提交必須手動提交后才可以):
@Test
public void hitRateTest(){
//兩個會話
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectByid(10);
//需要提交,否則不會命中
sqlSession.commit();
UserMapper mapper2 = sqlSession.getMapper(UserMapper.class);
User user2 = mapper2.selectByid(10);
}
執行結果

這邊只能通過日志查看,不可能通過兩個user相同,因為里面會經過序列化快取,從上圖可以看出命中率,第一次查詢為0,第二次命中,那命中率的演算法就是命中次數除以請求數,所以為0.5,
為什么需要提交后才能命中快取?
二級快取命中與一級快取唯一不同的引數條件就是得提交,

如上圖兩個會話在修改同一資料,當會話二修改后,假如它實時填充到二級快取,而會話一就能過快取獲取修改之后的資料,但實質是修改的資料回滾了,并沒真正的提交到資料庫,這樣就產生了臟讀,所以為了保證資料一致性,二級快取必須是會話提交之才會真正填充,包括對快取的清空,也必須是會話正常提交之后才生效,
要滿足上面的條件,二級快取的結構設計又上升了一個難度,為了實作會話提交之后才變更二級快取,MyBatis對每個會話設立了若干個暫存區,當前的會話對指定快取空間的變更,都存放在對應的暫存區,當前會話提交之后才會提交到每個暫存區對應的快取空間,每個會話都有一個唯一的事務快取管理器,來統一管理這些暫存區,這里暫存區也可叫做事務快取,
下面小編使用一張圖來說明上面的文字:

證明:

二級快取執行流程
原本會話是通過Executor實作SQL呼叫,這里基于裝飾器模式使用CachingExecutor對SQL呼叫邏輯進行攔截,然后嵌入二級快取相關邏輯,流程圖如下

查詢 query
當會話呼叫query() 時,會基于查詢陳述句、引數等資料組成快取Key,然后嘗試從二級快取中讀取資料,讀到就直接回傳,沒有就呼叫被裝飾的Executor去查詢資料庫,然后填充至對應的暫存區,
更新 update
當執行update操作時,同樣會基于查詢的陳述句和引陣列成快取KEY,然后在執行update之前清空快取,這里清空只針對暫存區,同時記錄清空的標記,以便當會話提交之時,依據該標記去清空二級快取空間,
提交 commit
當會話執行commit操作后,會將該會話下所有暫存區的變更,更新到對應二級快取空間去,
原始碼閱讀
大家可以根據以下示例除錯,具體源代碼就不貼出來了:
@Test
public void hitRateTest3(){
//兩個會話
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
//同一個會話查詢的
User user = mapper.selectByid(10);
sqlSession.commit();
User user = mapper.selectByid(10);
System.out.println("第一個會話查詢提交==="+user);
mapper.setName(10,"bob");
User user = mapper.selectByid(10);
System.out.println("第一個會話沒提交update查詢==="+user);
UserMapper mapper2 = factory.openSession().getMapper(UserMapper.class);
User user1 = mapper2.selectByid(10);
System.out.println("第二個會話查詢第一個還沒提交update==="+user1);
sqlSession.commit();
User user2 = mapper2.selectByid(10);
System.out.println("第二個會話查詢第一個提交update的==="+user2);
}
大家一定要好好走一遍啊,會涉及到很多細節的,如果是口述還可以如果是文字的話小編不斷貼代碼反而會繞暈大家的,
總結
今天小編講mybatis的多級快取體系一網打盡了,文章有點長,如果看起來就枯燥乏味了,下次小編想著講這樣的文章分為幾篇講解,這樣會不會更好,好了今天就到這兒,如果你能堅持到最后,并且完全理解那你就是最棒的,
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/277829.html
標籤:其他
上一篇:架構師成長記_第六周_06_Redis 發布與訂閱 (與MQ類似)
下一篇:20面試官21-04-17
