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

一、前言

相信工做了一段時間的同窗確定都用過事務,也都據說過事務的4大特性ACID。ACID表示原子性、一致性、隔離性和持久性。一個很好的事務處理系統,必須具有這些標準特性:html

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

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

  • READ UNCOMMITTED(讀未提交):在此級別裏,事務的修改,即便沒有提交,對其餘事務也都是可見的。事務能夠讀取未提交的數據,也就是會產生髒讀,在實際應用中通常不多使用。
  • READ COMMITTED(讀已提交):大多數數據庫系統的默認隔離級別都是它,可是MySQL不是。它可以避免髒讀問題,可是在一個事務裏對同一條數據的屢次查詢可能會獲得不一樣的結果,也就是會產生不可重複讀問題。
  • REPEATABLE READ(可重複讀):該隔離級別是MySQL默認的隔離級別,看名字就知道它可以防止不可重複讀問題,可是在一個事務裏對一段數據的屢次讀取可能會致使不一樣的結果,也就是會有幻讀的問題(注:這裏說的沒法解決是MySQL定義層面,對於InnoDB引擎則完美的解決了幻讀的問題,若是你正在使用InnoDB引擎,可忽略)
  • SERIALIZABLE(可串行化):該隔離級別是級別最高的,它經過鎖來強制事務串行執行,避免了前面說的全部問題。在高併發下,可能致使大量的超時和鎖爭用問題。實際應用中也不多用到這個隔離級別,由於RR級別解決了全部問題。

能夠看到隔離級別裏最重要的只有兩個隔離級別:RC和RR。那麼問題來了,咱們知道上面說的ACID以及隔離級別的實現原理嗎?不管是平時工做仍是面試,這部分的問題都重中之重,接下來,我會拋出幾個問題,你們能夠帶着問題來看此文:面試

ACID問題:sql

  • 爲何InnoDB可以保證原子性?用的什麼方式?
  • 爲何InnoDB可以保證一致性?用的什麼方式?
  • 爲何InnoDB可以保證持久性?用的什麼方式?

隔離性裏隔離級別的問題:數據庫

  • 爲何RU級別會發生髒讀,而其餘的隔離級別可以避免?
  • 爲何RC級別不能重複讀,而RR級別可以避免?
  • 爲何InnoDB的RR級別可以防止幻讀?

解決這些問題以前,咱們要首先知道Redo log、Undo log以及MVCC都是什麼。設計模式

二、Redo log

redo log(重作日誌)用來實現事務的持久性,即事務ACID中的D。其由兩部分組成,一是內存中的重作日誌緩衝(redo log buffer),其實易失的。二是重作日誌文件(redo log file),其是持久的。緩存

在一個事務中的每一次SQL操做以後都會寫入一個redo log到buffer中,在最後COMMIT的時候,必須先將該事務的全部日誌寫入到redo log file進行持久化(這裏的寫入是順序寫的),待事務的COMMIT操做完成纔算完成。數據結構

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在上面也說了是在事務進行中不斷被寫入,這表現爲日誌並非隨事務提交的順序進行寫入的。

MySQL-Lock9

redo log block

在InnoDB引擎中,redo log都是以512字節進行存儲的(和磁盤扇區的大小同樣,所以redo log寫入能夠保證原子性,不須要double write),也就是重作日誌緩存和文件都是以塊的方式進行保存的,稱爲redo log block,每一個block佔512字節。

重作日誌除了日誌自己以外,還由日誌塊頭(log block header)及日誌塊尾(log block tailer)兩部分組成。

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進行恢復便可。

MySQL-Lock11

三、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。具體的文件組織方式以下圖所示:

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的結構圖。

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進行鏈接,以下面的一種狀況:

MySQL-Lock14

在執行purge過程當中,InnoDB存儲引擎首先從history list中找到第一個須要被清理的記錄,這裏爲trx1,清理以後InnoDB存儲引擎會在trx1所在的Undo page中繼續尋找是否存在能夠被清理的記錄,這裏會找到事務trx3,接着找到trx5,可是發現trx5被其餘事務所引用而不能清理,故再去history list中取查找,發現最尾端的記錄時trx2,接着找到trx2所在的Undo page,依次把trx六、trx4清理,因爲Undo page2中全部的記錄都被清理了,所以該Undo page能夠進行重用。

InnoDB存儲引擎這種先從history list中找undo log,而後再從Undo page中找undo log的設計模式是爲了不大量隨機讀操做,從而提升purge的效率。

四、多版本控制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)清除線程去異步刪除真實的數據。

MySQL-Lock15

整個MVCC的機制都是經過DB_TRX_ID,DB_ROLL_PTR這2個隱藏字段來實現的。

事務鏈表

當一個事務開始的時候,會將當前數據庫中正在活躍的全部事務(執行begin,可是尚未commit的事務)保存到一個叫trx_sys的事務鏈表中,事務鏈表中保存的都是未提交的事務,當事務提交以後會從其中刪除。

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 爲事務鏈表。

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。

五、總結

還記得開頭提到的問題嗎?如今應該可以所有解決了。

爲何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的方式來進行間隙鎖定,具體原理本章不深刻討論,能夠參考個人另外一篇文章。

相關文章
相關標籤/搜索