1. 程式人生 > >mysql事務原理及MVCC

mysql事務原理及MVCC

mysql事務原理及MVCC

事務是資料庫最為重要的機制之一,凡是使用過資料庫的人,都瞭解資料庫的事務機制,也對ACID四個
基本特性如數家珍。但是聊起事務或者ACID的底層實現原理,往往言之不詳,不明所以。在MySQL中
的事務是由儲存引擎實現的,而且支援事務的儲存引擎不多,我們主要講解InnoDB儲存引擎中的事
務。所以,今天我們就一起來分析和探討InnoDB的事務機制,希望能建立起對事務底層實現原理的具
體瞭解。

事務的特性

  • 原子性:事務最小工作單元,事務開始要不全部成功,要不全部失敗.
  • 一致性:事務的開始和結束後,資料庫的完整性不會被破壞
  • 隔離性:不同事務之間互不影響,四種隔離級別為RU(讀未提交)、RC(讀已提
    交)、RR(可重複讀)、SERIALIZABLE (序列化)。
  • 永續性:事務提交後,對資料的修改是永久性的,即使系統故障也不會丟失 。

隔離級別

有一張表,結構如下:

  • 未提交讀(RU)

    • 一個事務讀取到另一個事務尚未提交的資料,稱之為髒讀
    發生時間編號 session A session B
    1 begin;
    2 begin;
    3 update t set c="關羽" where id = 1;
    4 select * from t where id = 1;

時間編號為4時,AB兩個session均未提交事務,select語句讀取到的值為關羽,讀取到了B尚未提交的事務,此為髒讀,這種隔離級別是最不安全的一種.

  • 已提交讀(RC)

    • 一個事務讀取到另一個事務已提交的資料,導致對同一條記錄讀取兩次以上的結果不一致,稱之為不可重複讀
    發生時間編號 session A session B
    1 begin;
    2 begin;
    3 update t set c="關羽" where id = 1;
    4 select * from t where id = 1;
    5 commit;
    6 select * from t where id = 1;

時間編號為4時,B尚未提交,此時讀取到的資料依然是劉備,時間編號為5,B事務提交,時間編號為6時再次讀取到的資料變成了關羽.這種情況是可以被理解的,因為B事務已經提交了.

  • 可重複讀(RR)

    • 一個事務讀取到另一個事務已經提交的delete或者insert資料,導致對同一張表讀取兩次以上結果不一致,稱之為幻讀
    • 幻讀可以通過序列化或者間隙鎖來解決
    發生時間編號 session A session B
    1 begin;
    2 begin;
    3 update t set c="關羽" where id = 1;
    4 select * from t where id = 1;
    5 commit;
    6 select * from t where id = 1;

    時間編號為4時,B尚未提交,此時讀取到的資料依然是劉備,時間編號為5,B事務提交,時間編號為6時再次讀取到的資料依然是劉備.同一個事務中讀取到的資料永遠是一致的.

  • 序列化

    • 簡單來說就是加鎖,這種隔離級別是最安全的,可以解決其他隔離級別所產生的問題,但是效率較低.
    發生時間編號 session A session B
    1 begin;
    2 begin;
    3 update t set c="關羽" where id = 1;
    4 select * from t where id = 1;
    5 commit;
    6 select * from t where id = 1;

    時間編號為4時,B尚未提交,此時讀取時,將會被阻塞,處於等待中直到B事務提交釋放鎖,時間編號為5,B事務提交釋放鎖,時間編號為6時再次讀取到的資料是關羽.

    • 丟失更新,兩個事務同時對一條資料進行修改時,會存在丟失更新問題.

      時間 取款事務A 取款事務B
      1 開始事務
      2 開始事務
      3 查詢餘額為1000元
      4 查詢餘額為1000元
      5 匯入100元,餘額變為1100
      6 提交事務
      7 取出100元,餘額變為900元
      8 回滾事務
      9 餘額恢復為1000元,丟失更新

mysql的預設隔離級別為RR

資料庫的事務併發問題需要使用併發控制機制去解決,資料庫的併發控制機制有很多,最為常見
的就是鎖機制。鎖機制一般會給競爭資源加鎖,阻塞讀或者寫操作來解決事務之間的競爭條件,
最終保證事務的可序列化。

而MVCC則引入了另外一種併發控制,它讓讀寫操作互不阻塞,每一個寫操作都會建立一個新版
本的資料,讀操作會從有限多個版本的資料中挑選一個最合適的結果直接返回,由此解決了事務
的競爭條件。

MVCC

mvcc也是多版本併發控制,mysql中引入了這種併發機制.我們接下來就聊聊mvcc

版本鏈
回滾段/undo log
  • insert undo log

    1. 是在 insert 操作中產生的 undo log。

    2. 因為 insert 操作的記錄只對事務本身可見,對於其它事務此記錄是不可見的,所以 insert undo
      log 可以在事務提交後直接刪除而不需要進行 purge 操作。

  • update undo log

    1. 是 update 或 delete 操作中產生的 undo log
    2. 因為會對已經存在的記錄產生影響,為了提供 MVCC機制,因此 update undo log 不能在事務提交時就進行刪除,而是將事務提交時放到入 history list 上,等待 purge 執行緒進行最後的刪除操作

為了保證事務併發操作時,在寫各自的undo log時不產生衝突,InnoDB採用回滾段的方式來維護undo
log的併發寫入和持久化。回滾段實際上是一種 Undo 檔案組織方式。

InnoDB行記錄有三個隱藏欄位:分別對應該行的rowid、事務號db_trx_id和回滾指標db_roll_ptr,其
中db_trx_id表示最近修改的事務的id,db_roll_ptr指向回滾段中的undo log。

對於使用 InnoDB 儲存引擎的表來說,它的聚簇索引記錄中都包含兩個必要的隱藏列( row_id 並不是
必要的,我們建立的表中有主鍵或者非NULL唯一鍵時都不會包含 row_id 列):

  • trx_id :每次對某條聚簇索引記錄進行改動時,都會把對應的事務id賦值給 trx_id 隱藏列。
  • roll_pointer :每次對某條聚簇索引記錄進行改動時,都會把舊的版本寫入到 undo日誌 中,然
    後這個隱藏列就相當於一個指標,可以通過它來找到該記錄修改前的資訊。
    我們有一張表
create table user(
    id int,
    name varchar,
    primary key (id)
)

insert into user values(1,'張三');

我們此時插入這條資料,假設事務id為80.

ps:咳咳~~理解意思就好,捂臉.jpg

每次對記錄進行改動,都會記錄一條 undo日誌 ,每條 undo日誌 也都有一個 roll_pointer 屬性
( INSERT 操作對應的 undo日誌 沒有該屬性,因為該記錄並沒有更早的版本),可以將這些 undo日誌
都連起來,串成一個連結串列,所以現在的情況就像下圖一樣:

對該記錄每次更新後,都會將舊值放到一條 undo日誌 中,就算是該記錄的一箇舊版本,隨著更新次數
的增多,所有的版本都會被 roll_pointer 屬性連線成一個連結串列,我們把這個連結串列稱之為 版本鏈 ,版本
鏈的頭節點就是當前記錄最新的值。另外,每個版本中還包含生成該版本時對應的事務id,這個資訊很
重要,我們稍後就會用到。

如下圖所示(初始狀態):

當事務2使用UPDATE語句修改該行資料時,會首先使用排他鎖鎖定改行,將該行當前的值複製到undo
log中,然後再真正地修改當前行的值,最後填寫事務ID,使用回滾指標指向undo log中修改前的行。
如下圖所示(第一次修改):

當事務3進行修改與事務2的處理過程類似,如下圖所示(第二次修改):

REPEATABLE READ隔離級別下事務開始後使用MVCC機制進行讀取時,會將當時活動的事務id記錄下
來,記錄到Read View中。READ COMMITTED隔離級別下則是每次讀取時都建立一個新的Read View。

ReadView

對於使用 READ UNCOMMITTED 隔離級別的事務來說,直接讀取記錄的最新版本就好了,對於使用
SERIALIZABLE 隔離級別的事務來說,使用加鎖的方式來訪問記錄。對於使用 READ COMMITTED 和
REPEATABLE READ 隔離級別的事務來說,就需要用到我們上邊所說的 版本鏈 了,核心問題就是:需要
判斷一下版本鏈中的哪個版本是當前事務可見的。所以設計 InnoDB 的大叔提出了一個 ReadView 的概
念,這個 ReadView 中主要包含當前系統中還有哪些活躍的讀寫事務,把它們的事務id放到一個列表
中,我們把這個列表命名為為 m_ids 。這樣在訪問某條記錄時,只需要按照下邊的步驟判斷記錄的某個
版本是否可見:

  • 如果被訪問版本的 trx_id 屬性值小於 m_ids 列表中最小的事務id,表明生成該版本的事務在生成
    ReadView 前已經提交,所以該版本可以被當前事務訪問。
  • 如果被訪問版本的 trx_id 屬性值大於 m_ids 列表中最大的事務id,表明生成該版本的事務在生成
    ReadView 後才生成,所以該版本不可以被當前事務訪問。
  • 如果被訪問版本的 trx_id 屬性值在 m_ids 列表中最大的事務id和最小事務id之間,那就需要判斷
    一下 trx_id 屬性值是不是在 m_ids 列表中,如果在,說明建立 ReadView 時生成該版本的事務還
    是活躍的,該版本不可以被訪問;如果不在,說明建立 ReadView 時生成該版本的事務已經被提
    交,該版本可以被訪問。

如果某個版本的資料對當前事務不可見的話,那就順著版本鏈找到下一個版本的資料,繼續按照上邊的
步驟判斷可見性,依此類推,直到版本鏈中的最後一個版本,如果最後一個版本也不可見的話,那麼就
意味著該條記錄對該事務不可見,查詢結果就不包含該記錄。

在 MySQL 中, READ COMMITTED 和 REPEATABLE READ 隔離級別的的一個非常大的區別就是它們生成
ReadView 的時機不同,我們來看一下。

RC隔離級別和RR隔離級別區別
  • 每次讀取資料前都生成一個ReadView

    比方說現在系統裡有兩個 id 分別為 100 、 200 的事務在執行:

    # Transaction 100
    BEGIN;
    UPDATE user SET name = '張三' WHERE id = 1;
    UPDATE user SET name = '李四' WHERE id = 1;
    複製程式碼
    # Transaction 200
    BEGIN;
    # 更新了一些別的表的記錄
    ...

    假設現在有一個使用 READ COMMITTED 隔離級別的事務開始執行:

    # 使用READ COMMITTED隔離級別的事務
    BEGIN;
    # SELECT1:Transaction 100、200未提交
    SELECT * FROM user WHERE id = 1; # 得到的列name的值為'王五'

    這個 SELECT1 的執行過程如下:

    • 在執行 SELECT 語句時會先生成一個 ReadView , ReadView 的 m_ids 列表的內容就是 [100,
      200] 。
    • 然後從版本鏈中挑選可見的記錄,最新版本的列name 的內容是 '張三' ,該版本的trx_id 值為 100 ,在 m_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
    • 下一個版本的列 name 的內容是 '李四' ,該版本的 trx_id 值也為 100 ,也在 m_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
    • 下一個版本的列 name 的內容是 '王五' ,該版本的 trx_id 值為 80 ,小於 m_ids 列表中最小的事務id 100 ,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列 name 為 '王五' 的記錄。

    之後,我們把事務id為 100 的事務提交一下,就像這樣:

    # Transaction 100
    BEGIN;
    UPDATE user SET name = '關羽' WHERE id = 1;
    UPDATE user SET name = '張飛' WHERE id = 1;
    COMMIT;

    然後再到事務id為 200 的事務中更新一下表 user 中 id 為1的記錄:

    # Transaction 200
    BEGIN;
    # 更新了一些別的表的記錄
    ...
    UPDATE user SET name = '雲六' WHERE id = 1;
    UPDATE user SET name = '王麻子' WHERE id = 1;

    然後再到剛才使用 READ COMMITTED 隔離級別的事務中繼續查詢這個id為 1 的記錄,如下:

    # 使用READ COMMITTED隔離級別的事務
    BEGIN;
    # SELECT1:Transaction 100、200均未提交
    SELECT * FROM user WHERE id = 1; # 得到的列name的值為'李四'
    # SELECT2:Transaction 100提交,Transaction 200未提交
    SELECT * FROM user WHERE id = 1; # 得到的列name的值為'張三'

    這個 SELECT2 的執行過程如下:

    • 在執行 SELECT 語句時會先生成一個 ReadView , ReadView 的 m_ids 列表的內容就是 [200] (事務id為 100 的那個事務已經提交了,所以生成快照時就沒有它了)。
    • 然後從版本鏈中挑選可見的記錄,最新版本的列 name 的內容是 '王麻子' ,該版本的 trx_id 值為 200 ,在 m_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
    • 下一個版本的列 name 的內容是 '雲六' ,該版本的 trx_id 值為 200 ,也在 m_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
    • 下一個版本的列 name 的內容是 '張三' ,該版本的 trx_id 值為 100 ,比 m_ids 列表中最小的事務
      id 200 還要小,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列name 為 '張三' 的記錄。

    以此類推,如果之後事務id為 200 的記錄也提交了,再此在使用 READ COMMITTED 隔離級別的事務中查詢表user 中 id 值為 1 的記錄時,得到的結果就是 '王麻子' 了,具體流程我們就不分析了。總結一下就
    是:使用READ COMMITTED隔離級別的事務在每次查詢開始時都會生成一個獨立的ReadView。

  • 只在第一次讀取資料生成一個ReadView

    對於使用 REPEATABLE READ 隔離級別的事務來說,只會在第一次執行查詢語句時生成一個
    ReadView ,之後的查詢就不會重複生成了。我們還是用例子看一下是什麼效果。

    比方說現在系統裡有兩個 id 分別為 100 、 200 的事務在執行:

    # Transaction 100
    BEGIN;
    UPDATE user SET name = '張三' WHERE id = 1;
    UPDATE user SET name = '李四' WHERE id = 1;
    複製程式碼
    # Transaction 200
    BEGIN;
    # 更新了一些別的表的記錄
    ...

    假設現在有一個使用 REPEATABLE READ 隔離級別的事務開始執行:

    # 使用REPEATABLE READ隔離級別的事務
    BEGIN;
    # SELECT1:Transaction 100、200未提交
    SELECT * FROM user WHERE id = 1; # 得到的列name的值為'王五'

    這個 SELECT1 的執行過程如下:

    • 在執行 SELECT 語句時會先生成一個 ReadView , ReadView 的 m_ids 列表的內容就是 [100,
      200] 。
    • 然後從版本鏈中挑選可見的記錄,最新版本的列 name 的內容是 '張三' ,該版本的trx_id 值為 100 ,在 m_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版
      本。
    • 下一個版本的列name 的內容是 '李四' ,該版本的 trx_id 值也為 100 ,也在 m_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
    • 下一個版本的列name 的內容是 '王五' ,該版本的 trx_id 值為 80 ,小於 m_ids 列表中最小的事務id 100 ,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列 name 為 '王五' 的記錄。

    之後,我們把事務id為 100 的事務提交一下,就像這樣:

    # Transaction 100
    BEGIN;
    UPDATE user SET name = '李四' WHERE id = 1;
    UPDATE user SET name = '張三' WHERE id = 1;
    COMMIT;

    然後再到事務id為 200 的事務中更新一下表user 中 id 為1的記錄:

    # Transaction 200
    BEGIN;
    # 更新了一些別的表的記錄
    ...
    UPDATE user SET name = '雲六' WHERE id = 1;
    UPDATE user SET name = '王麻子' WHERE id = 1;

    然後再到剛才使用 REPEATABLE READ 隔離級別的事務中繼續查詢這個id為 1 的記錄,如下:

    # 使用REPEATABLE READ隔離級別的事務
    BEGIN;
    # SELECT1:Transaction 100、200均未提交
    SELECT * FROM user WHERE id = 1; # 得到的列name的值為'李四'
    # SELECT2:Transaction 100提交,Transaction 200未提交
    SELECT * FROM user WHERE id = 1; # 得到的列name的值仍為'李四'

    這個 SELECT2 的執行過程如下:

    • 因為之前已經生成過 ReadView 了,所以此時直接複用之前的 ReadView ,之前的 ReadView 中的
      m_ids 列表就是 [100, 200] 。
    • 然後從版本鏈中挑選可見的記錄,最新版本的列 name 的內容是 '王麻子' ,該版本的 trx_id 值為 200 ,在 m_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
    • 下一個版本的列 name的內容是 '雲六' ,該版本的 trx_id 值為 200 ,也在 m_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
    • 下一個版本的列 name 的內容是 '張三' ,該版本的 trx_id 值為 100 ,而 m_ids 列表中是包含值為
      100 的事務id的,所以該版本也不符合要求,同理下一個列 name的內容是 '關羽' 的版本也不符合要求。繼續跳到下一個版本。
    • 下一個版本的列 name 的內容是 '李四' ,該版本的 trx_id 值為 80 , 80 小於 m_ids 列表中最小的事務id 100 ,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列 name 為 '李四' 的記錄。

    也就是說兩次 SELECT 查詢得到的結果是重複的,記錄的列 name 值都是 '李四' ,這就是 可重複讀 的含義。如果我們之後再把事務id為 200 的記錄提交了,之後再到剛才使用 REPEATABLE READ 隔離級別的事務中繼續查詢這個id為 1 的記錄,得到的結果還是 '李四' ,具體執行過程大家可以自己分析一下。

InnoDB的MVCC實現

我們首先來看一下wiki上對MVCC的定義:

Multiversion concurrency control (MCC or MVCC), is a concurrency control
method commonly used by database management systems to provide
concurrent access to the database and in programming languages to
implement transactional memory.

由定義可知,MVCC是用於資料庫提供併發訪問控制的併發控制技術。與MVCC相對的,是基於鎖的並
發控制, Lock-Based Concurrency Control 。MVCC最大的好處,相信也是耳熟能詳:讀不加鎖,讀
寫不衝突。在讀多寫少的OLTP應用中,讀寫不衝突是非常重要的,極大的增加了系統的併發效能,這
也是為什麼現階段,幾乎所有的RDBMS,都支援了MVCC。

多版本併發控制僅僅是一種技術概念,並沒有統一的實現標準, 其核心理念就是資料快照,不同的事務
訪問不同版本的資料快照,從而實現不同的事務隔離級別。雖然字面上是說具有多個版本的資料快照,
但這並不意味著資料庫必須拷貝資料,儲存多份資料檔案,這樣會浪費大量的儲存空間。InnoDB通過
事務的undo日誌巧妙地實現了多版本的資料快照。

資料庫的事務有時需要進行回滾操作,這時就需要對之前的操作進行undo。因此,在對資料進行修改
時,InnoDB會產生undo log。當事務需要進行回滾時,InnoDB可以利用這些undo log將資料回滾到修
改之前的樣子。

以上就是本篇部落格分享的內容,歡迎提出問題,討論交流.

聯絡方式:sx_wuyj@163.