談談MySQL InnoDB儲存引擎事務的ACID特性

b10l07發表於2018-05-04

1、前言

相信工作了一段時間的同學肯定都用過事務,也都聽說過事務的4大特性ACID。ACID表示原子性、一致性、隔離性和永續性。一個很好的事務處理系統,必須具備這些標準特性:

  • 原子性(Atomicity):一個事務必須被視為一個不可分割的最小工作單元,整個事務中的所有操作要麼全部提交成功,要麼全部失敗回滾。
  • 一致性(consistency):資料庫總是從一個一致性的狀態轉換到另一個一致性的狀態。(其實原子性和隔離性間接的保證了一致性)
  • 隔離性(isolation):通常來說,一個事務所做的修改在最終提交以前,對其他事務是不可見的。
  • 永續性(durability):一旦事務提交,則其所做的修改就會永久儲存到資料庫中。

而我們最常說的隔離性其實有對應的隔離級別,MySQL規定的隔離級別有4種,分別是:

  • READ UNCOMMITTED(讀未提交):在此級別裡,事務的修改,即使沒有提交,對其他事務也都是可見的。事務可以讀取未提交的資料,也就是會產生髒讀,在實際應用中一般很少使用。
  • READ COMMITTED(讀已提交):大多數資料庫系統的預設隔離級別都是它,但是MySQL不是。它能夠避免髒讀問題,但是在一個事務裡對同一條資料的多次查詢可能會得到不同的結果,也就是會產生不可重複讀問題。
  • REPEATABLE READ(可重複讀):該隔離級別是MySQL預設的隔離級別,看名字就知道它能夠防止不可重複讀問題,但是在一個事務裡對一段資料的多次讀取可能會導致不同的結果,也就是會有幻讀的問題(注:這裡說的無法解決是MySQL定義層面,對於InnoDB引擎則完美的解決了幻讀的問題,如果你正在使用InnoDB引擎,可忽略)
  • SERIALIZABLE(可序列化):該隔離級別是級別最高的,它通過鎖來強制事務序列執行,避免了前面說的所有問題。在高併發下,可能導致大量的超時和鎖爭用問題。實際應用中也很少用到這個隔離級別,因為RR級別解決了所有問題。

可以看到隔離級別裡最重要的只有兩個隔離級別:RC和RR。那麼問題來了,我們知道上面說的ACID以及隔離級別的實現原理嗎?無論是平時工作還是面試,這部分的問題都重中之重,接下來,我會丟擲幾個問題,大家可以帶著問題來看此文:

ACID問題:

  • 為什麼InnoDB能夠保證原子性?用的什麼方式?
  • 為什麼InnoDB能夠保證一致性?用的什麼方式?
  • 為什麼InnoDB能夠保證永續性?用的什麼方式?

隔離性裡隔離級別的問題:

  • 為什麼RU級別會發生髒讀,而其他的隔離級別能夠避免?
  • 為什麼RC級別不能重複讀,而RR級別能夠避免?
  • 為什麼InnoDB的RR級別能夠防止幻讀?

解決這些問題之前,我們要首先知道Redo log、Undo log以及MVCC都是什麼。

2、Redo log

redo log(重做日誌)用來實現事務的永續性,即事務ACID中的D。其由兩部分組成,一是記憶體中的重做日誌緩衝(redo log buffer),其實易失的。二是重做日誌檔案(redo log file),其是持久的。

在一個事務中的每一次SQL操作之後都會寫入一個redo log到buffer中,在最後COMMIT的時候,必須先將該事務的所有日誌寫入到redo log file進行持久化(這裡的寫入是順序寫的),待事務的COMMIT操作完成才算完成。

999329-cd008fd152d2aaac.png
MySQL-Lock8

由於重做日誌檔案開啟沒有使用O_DIRECT選項,因此重做日誌緩衝先寫入檔案系統快取。為了確保重做日誌寫入磁碟,必須進行一次fsync操作。由於fsync的效率取決於磁碟的效能,因此磁碟的效能決定了事務提交的效能,也就是資料庫的效能。由此我們可以得出在進行批量操作的時候,不要for迴圈裡面巢狀事務。

引數 innodb_flush_log_at_trx_commit 用來控制重做日誌重新整理到磁碟的策略,該引數有3個值:0、1和2。

  • 0:表示事務提交時不進行寫redo log file的操作,這個操作僅在master thread中完成(master thread每隔1秒進行一次fsync操作)。
  • 1:預設值,表示每次事務提交時進行寫redo log file的操作。
  • 2:表示事務提交時將redo log寫入檔案,不過僅寫入檔案系統的快取中,不進行fsync操作。

我們可以看到0和2的設定都比1的效率要高,但是破壞了資料庫的ACID特性,不建議使用!

對比binlog

在MySQL資料庫中還有一種二進位制日誌(binlog),從表面上來看它和redo log很相似,都是記錄了對資料庫操作的日誌,但是,它們有著非常大的不同。

首先,redo log是在MySQL的InnoDB引擎層產生,而binlog則是在MySQL的上層產生,它不僅針對InnoDB引擎,其他任何引擎對於資料庫的更改都會產生binlog。

其次,兩種日誌記錄的內容形式不同,binlog是一種邏輯日誌,其記錄的是對應的SQL語句。而redo log則是記錄的物理格式日誌,其記錄的是對於每個頁的修改。

此外,兩種日誌記錄寫入磁碟的時間點不同,binlog只在事務提交完成後一次性寫入,而redo log在上面也說了是在事務進行中不斷被寫入,這表現為日誌並不是隨事務提交的順序進行寫入的。

999329-49e07742b1ccbac7.png
MySQL-Lock9

redo log block

在InnoDB引擎中,redo log都是以512位元組進行儲存的(和磁碟扇區的大小一樣,因此redo log寫入可以保證原子性,不需要double write),也就是重做日誌快取和檔案都是以塊的方式進行儲存的,稱為redo log block,每個block佔512位元組。

重做日誌除了日誌本身之外,還由日誌塊頭(log block header)及日誌塊尾(log block tailer)兩部分組成。

999329-258122ce6d50cd28.png
MySQL-Lock10

下面我來解釋一下組成Log Block header的4個部分各自的含義:

  • LOG_BLOCK_HDR_NO:它主要用來標記所處Redo Log Buffer中Log Block的位置。
  • LOG_BLOCK_HDR_DATA_LEN:它表示Log Block所佔用的大小。當Log Block被寫滿時,該值為0x200,表示使用全部Log Block空間,即佔用512位元組。
  • LOG_BLOCK_FIRST_REC_GROUP:表示Log Block中第一個日誌所在的偏移量,如果該值大小和LOG_BLOCK_HDR_DATA_LEN相同,則表示當前Log Block不包含新的日誌,如果事務的日誌大小超過一個Log Block的大小,剩餘的將會接著儲存到一個新的Log Block中。
  • LOG_BLOCK_CHECKPOINT_NO:表示該Log Block最後被寫入時的檢查點第4位元組的值。

Log Block tailer只包含一個LOG_BLOCK_TRL_NO,它的值和LOG_BLOCK_HDR_NO相同,並在函式log_block_init中被初始化。

crash recovery

前面提到了redo log是用來實現ACID的永續性的,也就是隻要事務提交成功後,事務內的所有修改都會儲存到資料庫,哪怕這時候資料庫crash了,也要有辦法來進行恢復。也就是Crash Recovery。

說到恢復,我們先來了解一個概念:什麼是LSN

LSN(log sequence number) 用於記錄日誌序號,它是一個不斷遞增的 unsigned long long 型別整數,佔用8位元組。它代表的含義有:

  • redo log寫入的總量。
  • checkpoint的位置。
  • 頁的版本,用來判斷是否需要進行恢復操作。

checkpoint:它是redo log中的一個檢查點,這個點之前的所有資料都已經重新整理回磁碟,當DB crash後,通過對checkpoint之後的redo log進行恢復就可以了。

我們可以通過命令show engine innodb status來觀察LSN的情況:

---
LOG
---
Log sequence number 33646077360
Log flushed up to   33646077360
Last checkpoint at  33646077360
0 pending log writes, 0 pending chkp writes
49687445 log i/o's done, 1.25 log i/o's/second

Log sequence number表示當前的LSN,Log flushed up to表示重新整理到redo log檔案的LSN,Last checkpoint at表示重新整理到磁碟的LSN。如果把它們三個簡寫為 A、B、C 的話,它們的值的大小肯定為 A>=B>=C

InnoDB引擎在啟動時不管上次資料庫執行時是否正常關閉,都會進行恢復操作。因為重做日誌記錄的是物理日誌,因此恢復的速度比邏輯日誌,如二進位制日誌要快很多。恢復的時候只需要找到redo log的checkpoint進行恢復即可。

999329-d7fc8b3203701a65.png
MySQL-Lock11

3、Undo log

重做日誌記錄了事務的行為,可以很好的通過其對頁進行“重做”操作。但是事務有時候還需要進行回滾操作,也就是ACID中的A(原子性),這時就需要Undo log了。因此在資料庫進行修改時,InnoDB儲存引擎不但會產生Redo,還會產生一定量的Undo。這樣如果使用者執行的事務或語句由於某種原因失敗了,又或者使用者一條ROLLBACK語句請求回滾,就可以利用這些Undo資訊將資料庫回滾到修改之前的樣子。

Undo log是InnoDB MVCC事務特性的重要組成部分。當我們對記錄做了變更操作時就會產生Undo記錄,Undo記錄預設被記錄到系統表空間(ibdata)中,但從5.6開始,也可以使用獨立的Undo 表空間。

Undo記錄中儲存的是老版本資料,當一箇舊的事務需要讀取資料時,為了能讀取到老版本的資料,需要順著undo鏈找到滿足其可見性的記錄。當版本鏈很長時,通常可以認為這是個比較耗時的操作。

基本檔案結構

為了保證事務併發操作時,在寫各自的undo log時不產生衝突,InnoDB採用回滾段(Rollback Segment,簡稱Rseg)的方式來維護undo log的併發寫入和持久化。回滾段實際上是一種 Undo 檔案組織方式,每個回滾段又有多個undo log slot。具體的檔案組織方式如下圖所示:

999329-8535652ce806035f.png
MySQL-Lock12

上圖展示了基本的Undo回滾段佈局結構,其中:

  • rseg0預留在系統表空間ibdata中。
  • rseg 1~rseg 32 這32個回滾段存放於臨時表的系統表空間中,用於臨時表的undo。
  • rseg33~rseg 128 則根據配置(InnoDB >= 1.1預設128,可通過引數 innodb_undo_logs 設定)存放到獨立undo表空間中(如果沒有開啟獨立Undo表空間,則存放於ibdata中,獨立表空間可以通過引數 innodb_undo_directory 設定),用於普通事務的undo。

如圖所示,每個回滾段維護了一個段頭頁,在該page中又劃分了1024個slot(TRX_RSEG_N_SLOTS),每個slot又對應到一個undo log物件,因此理論上InnoDB最多支援 96 * 1024個普通事務。

Undo log的格式

在InnoDB引擎中,undo log分為:

  • insert undo log
  • update undo log

insert undo log是指在insert操作中產生的undo log,因為insert操作的記錄,只對事務本身可見,對其他事務不可見(這是事務隔離性的要求),故該undo log可以在事務提交後直接刪除,不需要進行purge操作。而update undo log記錄的是delete和update操作產生的undo log。該undo log可能需要提供MVCC機制,因此不能在事務提交時就進行刪除,提交時放入undo log連結串列,等待purge執行緒進行最後的刪除。下面是兩種undo log的結構圖。

999329-f52aa201a237764c.png
MySQL-Lock13

purge

對於一條delete語句 delete from t where a = 1,如果列a有聚集索引,則不會進行真正的刪除,而只是在主鍵列等於1的記錄delete flag設定為1,即記錄還是存在在B+樹中。而對於update操作,不是直接對記錄進行更新,而是標識舊記錄為刪除狀態,然後新產生一條記錄。那這些舊版本標識位刪除的記錄何時真正的刪除?怎麼刪除?

其實InnoDB是通過undo日誌來進行舊版本的刪除操作的,在InnoDB內部,這個操作被稱之為purge操作,原來在srv_master_thread主執行緒中完成,後來進行優化,開闢了purge執行緒進行purge操作,並且可以設定purge執行緒的數量。purge操作每10s進行一次。

為了節省儲存空間,InnoDB儲存引擎的undo log設計是這樣的:一個頁上允許多個事務的undo log存在。雖然這不代表事務在全域性過程中提交的順序,但是後面的事務產生的undo log總在最後。此外,InnoDB儲存引擎還有一個history列表,它根據事務提交的順序,將undo log進行連線,如下面的一種情況:

999329-50f21af4015111d3.png
MySQL-Lock14

在執行purge過程中,InnoDB儲存引擎首先從history list中找到第一個需要被清理的記錄,這裡為trx1,清理之後InnoDB儲存引擎會在trx1所在的Undo page中繼續尋找是否存在可以被清理的記錄,這裡會找到事務trx3,接著找到trx5,但是發現trx5被其他事務所引用而不能清理,故再去history list中取查詢,發現最尾端的記錄時trx2,接著找到trx2所在的Undo page,依次把trx6、trx4清理,由於Undo page2中所有的記錄都被清理了,因此該Undo page可以進行重用。

InnoDB儲存引擎這種先從history list中找undo log,然後再從Undo page中找undo log的設計模式是為了避免大量隨機讀操作,從而提高purge的效率。

4、多版本控制MVCC

MVCC 多版本併發控制技術,用於多事務環境下,對資料讀寫在不加讀寫鎖的情況下實現互不干擾,從而實現資料庫的隔離性,在事務隔離級別為Read Commit 和 Repeatable Read中使用到,今天我們就用最簡單的方式,來分析下MVCC具體的原理,先解釋幾個概念。

InnoDB儲存引擎的行結構

InnoDB表資料的組織方式為主鍵聚簇索引,二級索引中採用的是(索引鍵值, 主鍵鍵值)的組合來唯一確定一條記錄。

InnoDB表資料為主鍵聚簇索引,mysql預設為每個索引行新增了4個隱藏的欄位,分別是:

  • DB_ROW_ID:InnoDB引擎中一個表只能有一個主鍵,用於聚簇索引,如果表沒有定義主鍵會選擇第一個非Null的唯一索引作為主鍵,如果還沒有,生成一個隱藏的DB_ROW_ID作為主鍵構造聚簇索引。
  • DB_TRX_ID:最近更改該行資料的事務ID。
  • DB_ROLL_PTR:undo log的指標,用於記錄之前歷史資料在undo log中的位置。
  • DELETE BIT:索引刪除標誌,如果DB刪除了一條資料,是優先通知索引將該標誌位設定為1,然後通過(purge)清除執行緒去非同步刪除真實的資料。
999329-93c151894ec951fa.png
MySQL-Lock15

整個MVCC的機制都是通過DB_TRX_ID,DB_ROLL_PTR這2個隱藏欄位來實現的。

事務連結串列

當一個事務開始的時候,會將當前資料庫中正在活躍的所有事務(執行begin,但是還沒有commit的事務)儲存到一個叫trx_sys的事務連結串列中,事務連結串列中儲存的都是未提交的事務,當事務提交之後會從其中刪除。

999329-e836b86aa4cd7630.png
MySQL-Lock16

ReadView

有了前面隱藏列和事務連結串列的基礎,接下去就可以構造MySQL實現MVCC的關鍵——ReadView。

ReadView說白了就是一個資料結構,在事務開始的時候會根據上面的事務連結串列構造一個ReadView,初始化方法如下:

// readview 初始化
// m_low_limit_id = trx_sys->max_trx_id; 
// m_up_limit_id = !m_ids.empty() ? m_ids.front() : m_low_limit_id;
ReadView::ReadView()
    :
    m_low_limit_id(),
    m_up_limit_id(),
    m_creator_trx_id(),
    m_ids(),
    m_low_limit_no()
{
    ut_d(::memset(&m_view_list, 0x0, sizeof(m_view_list)));
}

總共做了以下幾件事:

  1. 活躍事務連結串列(trx_sys)中事務id最大的值被賦值給m_low_limit_id
  2. 活躍事務連結串列中第一個值(也就是事務id最小)被賦值給m_up_limit_id
  3. m_ids 為事務連結串列。
999329-a67509d48416e3b1.png
MySQL-Lock17

通過該ReadView,新的事務可以根據查詢到的所有活躍事務記錄的事務ID來匹配能夠看見該記錄,從而實現資料庫的事務隔離,主要邏輯如下:

  1. 通過聚簇索引的行結構中DB_TRX_ID隱藏欄位可以知道最近被哪個事務ID修改過。
  2. 一個新的事務開始時會根據事務連結串列構造一個ReadView。
  3. 當前事務根據ReadView中的資料去跟檢索到的每一條資料去校驗,看看當前事務是不是能看到這條資料。

那麼問題來了,怎麼來判斷可見性呢?我們來通過原始碼一探究竟:

// 判斷資料對應的聚簇索引中的事務id在這個readview中是否可見
bool changes_visible(
        trx_id_t        id, // 記錄的id
    const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
    ut_ad(id > 0);
    // 如果當前記錄id < 事務連結串列的最小值或者等於建立該readview的id就是它自己,那麼是可見的
    if (id < m_up_limit_id || id == m_creator_trx_id) {
        return(true);
    }

    check_trx_id_sanity(id, name);
    // 如果該記錄的事務id大於事務連結串列中的最大值,那麼不可見
    if (id >= m_low_limit_id) {
        return(false);
        // 如果事務連結串列是空的,那也是可見的
    } else if (m_ids.empty()) {
        return(true);
    }

    const ids_t::value_type*    p = m_ids.data();

    //判斷是否在ReadView中,如果在說明在建立ReadView時 此條記錄還處於活躍狀態則不應該查詢到,否則說明建立ReadView是此條記錄已經是不活躍狀態則可以查詢到
    return(!std::binary_search(p, p + m_ids.size(), id));
}

總結一下可見性判斷邏輯:

  1. 當檢索到的資料的事務ID小於事務連結串列中的最小值(資料行的DB_TRX_ID < m_up_limit_id)表示這個資料在當前事務開啟前就已經被其他事務修改過了,所以是可見的。
  2. 當檢索到的資料的事務ID表示的是當前事務自己修改的資料(資料行的DB_TRX_ID = m_creator_trx_id) 時,資料可見。
  3. 當檢索到的資料的事務ID大於事務連結串列中的最大值(資料行的DB_TRX_ID >= m_low_limit_id) 表示這個資料在當前事務開啟後到下一次查詢之間又被其他的事務修改過,那麼就是不可見的。
  4. 如果事務連結串列為空,那麼也是可見的,也就是當前事務開始的時候,沒有其他任意一個事務在執行。
  5. 當檢索到的資料的事務ID在事務連結串列中的最小值和最大值之間,從m_low_limit_id到m_up_limit_id進行遍歷,取出DB_ROLL_PTR指標所指向的回滾段的事務ID,把它賦值給 trx_id_current ,然後從步驟1重新開始判斷,這樣總能最後找到一個可用的記錄。

RC和RR隔離級別ReadView的實現方式

我們知道,RC隔離級別是能看到其他事務提交後的修改記錄的,也就是不可重複讀,但是RR隔離級別完美的避免了,但是它們都是使用的MVCC機制,那又為何有兩種截然不同的結果呢?其實我們看一下他們建立ReadView的區別就知道了。

  • 在RC事務隔離級別下,每次語句執行都關閉ReadView,然後重新建立一份ReadView。
  • 在RR下,事務開始後第一個讀操作建立ReadView,一直到事務結束關閉。

上面的總結英文版為:With REPEATABLE READ isolation level, the snapshot is based on the time when the first read operation is performed. With READ COMMITTEDisolation level, the snapshot is reset to the time of each consistent read operation.

來源自MySQL官網:MySQL Glossary-glos_consistent_read

因為RC每次查詢語句都建立一個新的ReadView,所以活躍的事務列表一直在變,也就導致如果事務B update提交了後事務A才進行查詢,查詢的結果就是最新的行,也就是不可重複讀咯。而RR則一直用的事務開始時建立的ReadView。

5、總結

還記得開頭提到的問題嗎?現在應該能夠全部解決了。

為什麼InnoDB能夠保證原子性A?用的什麼方式?

其實這個在上面Undo log中已經提及了。在事務裡任何對資料的修改都會寫一個Undo log,然後進行資料的修改,如果出現錯誤或者使用者需要回滾的時候可以利用Undo log的備份資料恢復到事務開始之前的狀態。

為什麼InnoDB能夠保證永續性?用的什麼方式?

這個在上面Redo log中已經提及了。在一個事務中的每一次SQL操作之後都會寫入一個redo log到buffer中,在最後COMMIT的時候,必須先將該事務的所有日誌寫入到redo log file進行持久化(這裡的寫入是順序寫的),待事務的COMMIT操作完成才算完成。即使COMMIT後資料庫有任何的問題,在下次重啟後依然能夠通過redo log的checkpoint進行恢復。也就是上面提到的crash recovery。

為什麼InnoDB能夠保證一致性?用的什麼方式?

在事務處理的ACID屬性中,一致性是最基本的屬性,其它的三個屬性都為了保證一致性而存在的。

首先回顧一下一致性的定義。所謂一致性,指的是資料處於一種有意義的狀態,這種狀態是語義上的而不是語法上的。最常見的例子是轉帳。例如從帳戶A轉一筆錢到帳戶B上,如果帳戶A上的錢減少了,而帳戶B上的錢卻沒有增加,那麼我們認為此時資料處於不一致的狀態。

在資料庫實現的場景中,一致性可以分為資料庫外部的一致性和資料庫內部的一致性。前者由外部應用的編碼來保證,即某個應用在執行轉帳的資料庫操作時,必須在同一個事務內部呼叫對帳戶A和帳戶B的操作。如果在這個層次出現錯誤,這不是資料庫本身能夠解決的,也不屬於我們需要討論的範圍。後者由資料庫來保證,即在同一個事務內部的一組操作必須全部執行成功(或者全部失敗)。這就是事務處理的原子性。(上面說過了是用Undo log來保證的)

但是,原子性並不能完全保證一致性。在多個事務並行進行的情況下,即使保證了每一個事務的原子性,仍然可能導致資料不一致的結果,比如丟失更新問題。

為了保證併發情況下的一致性,引入了隔離性,即保證每一個事務能夠看到的資料總是一致的,就好象其它併發事務並不存在一樣。用術語來說,就是多個事務併發執行後的狀態,和它們序列執行後的狀態是等價的。

為什麼RU級別會發生髒讀,而其他的隔離級別能夠避免?

RU級別的操作其實就是對事務內的每一條更新語句對應的行記錄加上讀寫鎖來操作,而不把一個事務當成一個整體來加鎖,所以會導致髒讀。但是RC和RR能夠通過MVCC來保證記錄只有在最後COMMIT後才會讓別的事務看到。

為什麼RC級別不能重複讀,而RR級別能夠避免?

這個在上面的MVCC的最後說到了,在RC事務隔離級別下,每次語句執行都關閉ReadView,然後重新建立一份ReadView。而在RR下,事務開始後第一個讀操作建立ReadView,一直到事務結束關閉。

為什麼InnoDB的RR級別能夠防止幻讀?

這個是因為RR隔離級別使用了Next-key Lock這麼個東東,也就是Gap Lock+Record Lock的方式來進行間隙鎖定,具體原理本章不深入討論,可以參考我的另一篇文章。

相關文章