小議 MySQL InnoDB 事務隔離 和 MyBatis 一級緩存

相關文章:

昨天跟一個同學探討了一個問題,雖然是個小問題,但是牽扯的內容還是很多的,這裏做一下總結。

他的代碼簡化如下:

    @Autowired
    private UserMapper userMapper;

    @Transactional
    public void  test(){
        //此時數據庫是沒有 name 爲 "test..." 的 User
        String name = "test...";
        User user1 = userMapper.selectByName(name);

        User insertUser = new User(name,10);
        int insert = userMapper.insert(insertUser);
        
        //能否查詢出結果?
        User user2 = userMapper.selectByName(name);
    }

問題就是在同一個事務中能否讀到它自己未提交的數據,而由於持久層使用了 MyBatis,這裏也要注意 MyBatis 的特性,如一級緩存。

MySQL InnoDB 事務隔離

InnoDB 存儲引擎中的事務完全符合 ACID 的特性。在《MySQL技術內幕 InnoDB存儲引擎》是這麼描述隔離性的:

I(isolation),隔離性。隔離性還有其他的稱呼,如併發控制、可串行化、鎖。事務的隔離性要求每個讀寫事務的對象與其他事務的操作對象能互相分離,即該事務提交前對其他事務都不可見,這通常使用鎖來實現。數據庫系統中提供了一種粒度鎖的策略,允許事務僅鎖住一個實體對象的子集,以此來提高事務之間的併發度。(如果是全表鎖,事務之間基本就無法實現併發,但是如果只鎖住表中處理的行,可以提高事務的併發度)。

這裏要注意的是隔離性指的是事務與事務之間數據的隔離,所以文章開頭的問題不能跟事務的隔離性搞混了。

爲了排除框架的干擾,先將上面的代碼使用 SQL 語句實現:

BEGIN;
-- 第一次查詢
select * from tag where name = 'mmmmmmmmm';

INSERT INTO tag ( create_miliao, create_time, update_miliao, update_time, NAME, region, version )
VALUES
	(10000, '2020-01-01 09:00:00', null, '2020-01-01 09:00:00', 'mmmmmmmmm', 1, 1);
	
select * from tag where name = 'mmmmmmmmm';

執行結果:

在這裏插入圖片描述

第一次查詢結果:

在這裏插入圖片描述

第二次查詢結果:

在這裏插入圖片描述

在事務未提交的情況下,在另一個事務中查詢:

在這裏插入圖片描述

根據這個測試結果,可以得出以下結論:

  • 在同一個事務內,可以查詢到它未提交的內容;
  • 當前事務無法讀到其他事務未提交的內容;

那麼問題來了,爲什麼其他的事務無法看到這個事務未提交的記錄,但是當前事務可以看到自己未提交的記錄。再來看看多版本併發控制,即 MVCC。

MVCC

百度百科對 MVCC 的描述非常的詳細,這裏引入百度百科的描述:

Multi-Version Concurrency Control 多版本併發控制,MVCC 是一種併發控制的方法,一般在數據庫管理系統中,實現對數據庫的併發訪問;在編程語言中實現事務內存。

說白了,就是併發訪問(讀或寫)數據庫時,對正在事務內處理的數據做多版本的管理。以達到⽤來避免寫操作的堵塞,從⽽引發讀操作的併發問題。

InnoDB:通過爲每一行記錄添加兩個額外的隱藏的值來實現MVCC,這兩個值一個記錄這行數據何時被創建,另外一個記錄這行數據何時過期(或者被刪除)。但是InnoDB並不存儲這些事件發生時的實際時間,相反它只存儲這些事件發生時的系統版本號。這是一個隨着事務的創建而不斷增長的數字。每個事務在事務開始時會記錄它自己的系統版本號。每個查詢必須去檢查每行數據的版本號與事務的版本號是否相同。

隔離級別是 REPEATABLE READ 時,SELECT InnoDB 必須每行數據來保證它符合兩個條件:

1、InnoDB 必須找到一個行的版本,它至少要和事務的版本一樣老(也即它的版本號不大於事務的版本號)。這保證了不管是事務開始之前,或者事務創建時,或者修改了這行數據的時候,這行數據是存在的。

2、這行數據的刪除版本必須是未定義的或者比事務版本要大。這可以保證在事務開始之前這行數據沒有被刪除。這裏的不是真正的刪除數據,而是標誌出來的刪除。真正意義的刪除是在 commit 的時候。

符合這兩個條件的行可能會被當作查詢結果而返回。

INSERT:InnoDB 爲這個新行記錄當前的系統版本號。

DELETE:InnoDB 將當前的系統版本號設置爲這一行的刪除ID。

UPDATE:InnoDB 會寫一個這行數據的新拷貝,這個拷貝的版本爲當前的系統版本號。它同時也會將這個版本號寫到舊行的刪除版本里。

這種額外的記錄所帶來的結果就是對於大多數查詢來說根本就不需要獲得一個鎖。他們只是簡單地以最快的速度來讀取數據,確保只選擇符合條件的行。這個方案的缺點在於存儲引擎必須爲每一行存儲更多的數據,做更多的檢查工作,處理更多的善後操作。

快照讀、當前讀

在隔離級別的實現上,MySQL 有一個一致性視圖(consistent read view)的概念,用來支持 RC(讀提交) 和 RR(可重複讀) 隔離級別的實現。

數據庫裏面會創建一個視圖,訪問的時候以視圖的邏輯結果爲準。在“可重複讀”隔離級別下,這個視圖是在事務啓動時創建的,整個事務存在期間都用這個視圖。在“讀提交”隔離級別下,這個視圖是在每個 SQL 語句開始執行的時候創建的。這裏需要注意的是,“讀未提交”隔離級別下直接返回記錄上的最新值,沒有視圖概念;而“串行化”隔離級別下直接用加鎖的方式來避免並行訪問。

這裏又會涉及到一個快照讀和當前讀的概念,快照讀就是指 SQL讀取的數據是快照版本,也就是歷史版本,普通的 SELECT 就是快照讀。InnoDB 快照讀,數據的讀取將由 cache(原本數據)+ undo(事務修改過的數據)兩部分組成 。當前讀就是指 SQL 讀取的數據是最新版本。通過鎖機制來保證讀取的數據無法通過其他事務進行修改,UPDATE、DELETE、INSERT、SELECT … LOCK IN SHARE MODE、SELECT … FOR UPDATE 都是當前讀 。

還有一個要注意的是事務的啓動時機:

begin/start transaction 命令並不是一個事務的起點,在執行到它們之後的第一個操作 InnoDB 表的語句,事務才真正啓動。如果你想要馬上啓動一個事務,可以使用 start transaction with consistent snapshot 這個命令。第一種啓動方式,一致性視圖是在執行第一個快照讀語句時創建的;第二種啓動方式,一致性視圖是在執行 start transaction with consistent snapshot 時創建的。

執行流程

InnoDB 通過爲每一行記錄添加兩個額外的隱藏的值來實現 MVCC,這兩個值一個記錄這行數據何時被創建,另外一個記錄這行數據何時過期(或者被刪除)。每個事務在事務開始時會記錄它自己的系統版本號。

INSERT 執行流程

執行 INSERT 之前的數據:

在這裏插入圖片描述
接下來執行如下操作:
在這裏插入圖片描述
執行完成後的數據爲:
在這裏插入圖片描述
再看一個流程,這裏啓動自動提交,執行 INSERT 之前的數據:
在這裏插入圖片描述
假如執行語句如下:
在這裏插入圖片描述
執行完成後的數據:
在這裏插入圖片描述

SELECT 執行流程

首先要知道數據查詢規則:

  • 查找數據行版本早於當前事務版本的數據行
    • 也就是說行的系統版本號小於等於事務的系統版本號,這樣可以確保事務讀取的行,要麼是在事務開始前已經存在的,要麼是事務自身插入或者修改過的
  • 查找刪除版本號要麼爲 null,要麼大於當前事務版本號的記錄
    • 確保取出來的行記錄在事務開啓之前沒有被刪除

假設執行 SELECT 之前的數據:
在這裏插入圖片描述
執行語句:
在這裏插入圖片描述
查詢結果:

在這裏插入圖片描述
通過這兩個流程的例子也就可以很容易解釋文章開頭提出的在同一個事務中能否讀到它自己未提交的數據。接下來再看看 MyBatis 一級緩存。

MyBatis 一級緩存

我們都知道 MyBatis 會通過 SqlSession 表示一次數據庫會話,而 MyBatis 的一級緩存存在於 SqlSession 的生命週期中,默認開啓。先看一個 MyBatis 與 Spring 整合後一級緩存使用的例子:

    @Autowired
    private TagMapper tagMapper;

    @Transactional
    public void  test(){
        String name = "test...";
        Tag tag1 = tagMapper.selectByName(name);
        Tag tag2 = tagMapper.selectByName(name);
    }

查看日誌:

[2020-06-17 13:59:40] [dev] [DEBUG] [main]  org.mybatis.spring.SqlSessionUtils-Creating a new SqlSession

[2020-06-17 13:59:40] [dev] [DEBUG] [main]  org.mybatis.spring.SqlSessionUtils-Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@29701f1f]

[2020-06-17 13:59:40] [dev] [DEBUG] [main]  org.mybatis.spring.transaction.SpringManagedTransaction-JDBC Connection [com.mysql.jdbc.JDBC4Connection@5441309e] will be managed by Spring

[2020-06-17 13:59:40] [dev] [DEBUG] [main]  mapper.TagMapper.selectByName-==>  Preparing: select * from tag where name = ? 

[2020-06-17 13:59:41] [dev] [DEBUG] [main]  mapper.TagMapper.selectByName-==> Parameters: test...(String)

[2020-06-17 13:59:41] [dev] [DEBUG] [main]  mapper.TagMapper.selectByName-<==      Total: 1

[2020-06-17 13:59:41] [dev] [DEBUG] [main]  org.mybatis.spring.SqlSessionUtils-Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@29701f1f]

[2020-06-17 13:59:43] [dev] [DEBUG] [main]  org.mybatis.spring.SqlSessionUtils-Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@29701f1f] from current transaction

[2020-06-17 13:59:43] [dev] [DEBUG] [main]  org.mybatis.spring.SqlSessionUtils-Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@29701f1f]

這時候兩次訪問數據庫使用的是同一個 SqlSession,發現只訪問了一次數據庫,這就是 MyBatis 的一級緩存。MyBatis 會在 SqlSession 中建立一個本地緩存,將每次查詢到結果結果緩存起來,當下次出現相同的查詢的時候,會直接將緩存中的結果返回,不需要訪問數據庫。

對於緩存一般主要關注於兩點:

  • Key 的生成策略;
  • 過期策略;

直接查看 org.apache.ibatis.executor.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);
 }

這裏首先會獲取 CacheKey,關於 CacheKey 的生成這裏不多做贅述,說白了就是執行相同的 SQL 、相同的結果集範圍和 statementId 通過一定算法生成。

如果從一級緩存中沒有拿到結果集,就會從數據庫查詢:

@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()) {
      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--;
    }
    ...
    return list;
  }

localCache 就是 PerpetualCache,它們之間是一種組合關係:

SqlSession -----> BaseExecutor -----> PerpetualCache

這也就是爲什麼一級緩存與 SqlSession 的生命週期綁定。

org.apache.ibatis.session.defaults.DefaultSqlSession#clearCache 方法可以清除一級緩存:

  @Override
  public void clearCache() {
    executor.clearLocalCache();
  }

當發生 UPDATE、DELETE、INSERT 操作則會清除一級緩存,org.apache.ibatis.executor.BaseExecutor#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);
  }

可以通過在 xml 文件中配置 flushCache =” true“ 屬性或者在 @Options 註解中配置是否啓用一級緩存:

@Select("select * from e_tag where name = #{name}")
@Options(flushCache= Options.FlushCachePolicy.TRUE)
Tag selectByName(@Param("name") String name);

再運行上面的測試代碼,查看日誌:

[2020-06-17 17:57:09] [dev] [DEBUG] [main]  mapper.TagMapper.selectByName-==>  Preparing: select * from e_tag where name = ? 

[2020-06-17 17:57:09] [dev] [DEBUG] [plan-executor-pool-1]  k.d.c.m.i.k.d.mapper.TaskCursorMapper.getPositionByTaskName-==> Parameters: updateAttributeRelationCertificateAssignTask(String)

[2020-06-17 17:57:09] [dev] [DEBUG] [main]  mapper.TagMapper.selectByName-==> Parameters: test...(String)

[2020-06-17 17:57:09] [dev] [DEBUG] [plan-executor-pool-1]  k.d.c.m.i.k.d.mapper.TaskCursorMapper.getPositionByTaskName-<==      Total: 1

[2020-06-17 17:57:09] [dev] [DEBUG] [plan-executor-pool-1]  org.mybatis.spring.SqlSessionUtils-Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@786c5a9b]

[2020-06-17 17:57:09] [dev] [DEBUG] [plan-executor-pool-1]  org.mybatis.spring.SqlSessionUtils-Creating a new SqlSession

[2020-06-17 17:57:09] [dev] [DEBUG] [plan-executor-pool-1]  org.mybatis.spring.SqlSessionUtils-SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@2636fb52] was not registered for synchronization because synchronization is not active

[2020-06-17 17:57:09] [dev] [DEBUG] [main]  mapper.TagMapper.selectByName-<==      Total: 1

[2020-06-17 17:57:09] [dev] [DEBUG] [main]  org.mybatis.spring.SqlSessionUtils-Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@55c3222d]

[2020-06-17 17:57:11] [dev] [DEBUG] [main]  org.mybatis.spring.SqlSessionUtils-Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@55c3222d] from current transaction

[2020-06-17 17:57:11] [dev] [DEBUG] [main]  mapper.TagMapper.selectByName-==>  Preparing: select * from e_tag where name = ? 

[2020-06-17 17:57:11] [dev] [DEBUG] [main]  mapper.TagMapper.selectByName-==> Parameters: test...(String)

[2020-06-17 17:57:11] [dev] [DEBUG] [main]  mapper.TagMapper.selectByName-<==      Total: 1

[2020-06-17 17:57:11] [dev] [DEBUG] [main]  org.mybatis.spring.SqlSessionUtils-Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@55c3222d]

可以看到,兩次都訪問了數據庫。

References

  • 《MySQL 技術內幕 InnoDB 存儲引擎》
  • https://baike.baidu.com/item/MVCC/6298019?fr=aladdin
  • https://time.geekbang.org/column/article/70562?utm_source=pinpaizhuanqu&utm_medium=geektime&utm_campaign=guanwang&utm_term=guanwang&utm_content=0511
  • https://blog.csdn.net/Dongguabai/article/details/82084958

歡迎關注公衆號
​​​​​​在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章