捲起來!!!看了這篇文章我才知道MySQL事務&MVCC到底是啥?

語言: CN / TW / HK

事務基礎

  1. 事務

事務 是一組操作的集合,它是一個不可分割的工作單位,事務會把所有的操作作為一個整體一起向系統提交或撤銷操作請求,即這些操作要麼同時成功,要麼同時失敗。

  1. 特性
  • 原子性(Atomicity):事務是不可分割的最小操作單元,要麼全部成功,要麼全部失敗。
  • 一致性(Consistency):事務完成時,必須使所有的資料都保持一致狀態。
  • 隔離性(Isolation):資料庫系統提供的隔離機制,保證事務在不受外部併發操作影響的獨立環境下執行。
  • 永續性(Durability):事務一旦提交或回滾,它對資料庫中的資料的改變就是永久的。

那實際上,我們研究事務的原理,就是研究MySQL的InnoDB引擎是如何保證事務的這四大特性的。

file

而對於這四大特性,實際上分為兩個部分。 其中的原子性、一致性、持久化,實際上是由InnoDB中的兩份日誌來保證的,一份是redo log日誌,一份是undo log日誌。 而永續性是通過資料庫的鎖,加上MVCC來保證的。

file

接下來主要就是來研究一下redolog,undolog以及MVCC。

redo log

重做日誌,記錄的是事務提交時資料頁的物理修改,是用來實現事務的永續性。

該日誌檔案由兩部分組成:重做日誌緩衝(redo log buffer)以及重做日誌檔案(redo log file),前者是在記憶體中,後者在磁碟中。當事務提交之後會把所有修改資訊都存到該日誌檔案中, 用於在重新整理髒頁到磁碟,發生錯誤時, 進行資料恢復使用。

如果沒有redolog,可能會存在什麼問題的? 我們一起來分析一下。

我們知道,在InnoDB引擎中的記憶體結構中,主要的記憶體區域就是緩衝池,在緩衝池中快取了很多的資料頁。 當我們在一個事務中,執行多個增刪改的操作時,InnoDB引擎會先操作緩衝池中的資料,如果緩衝區沒有對應的資料,會通過後臺執行緒將磁碟中的資料加載出來,存放在緩衝區中,然後將緩衝池中的資料修改,修改後的資料頁我們稱為髒頁。 而髒頁則會在一定的時機,通過後臺執行緒重新整理到磁碟中,從而保證緩衝區與磁碟的資料一致。 而緩衝區的髒頁資料並不是實時重新整理的,而是一段時間之後將緩衝區的資料重新整理到磁碟中,假如重新整理到磁碟的過程出錯了,而提示給使用者事務提交成功,而資料卻沒有持久化下來,這就出現問題了,沒有保證事務的永續性。

file

那麼,如何解決上述的問題呢? 在InnoDB中提供了一份日誌 redo log,接下來我們再來分析一下,通過redolog如何解決這個問題。

file

有了redolog之後,當對緩衝區的資料進行增刪改之後,會首先將操作的資料頁的變化,記錄在redo log buffer中。在事務提交時,會將redo log buffer中的資料重新整理到redo log磁碟檔案中。過一段時間之後,如果重新整理緩衝區的髒頁到磁碟時,發生錯誤,此時就可以藉助於redo log進行資料恢復,這樣就保證了事務的永續性。 而如果髒頁成功重新整理到磁碟 或 或者涉及到的資料已經落盤,此時redolog就沒有作用了,就可以刪除了,所以存在的兩個redolog檔案是迴圈寫的。

那為什麼每一次提交事務,要重新整理redo log 到磁碟中呢,而不是直接將buffer pool中的髒頁重新整理到磁碟呢 ?

因為在業務操作中,我們操作資料一般都是隨機讀寫磁碟的,而不是順序讀寫磁碟。 而redo log在往磁碟檔案中寫入資料,由於是日誌檔案,所以都是順序寫的。順序寫的效率,要遠大於隨機寫。 這種先寫日誌的方式,稱之為 WAL(Write-Ahead Logging)。

undo log

回滾日誌,用於記錄資料被修改前的資訊 , 作用包含兩個 : 提供回滾(保證事務的原子性) 和MVCC(多版本併發控制) 。

undo log和redo log記錄物理日誌不一樣,它是邏輯日誌。可以認為當delete一條記錄時,undolog中會記錄一條對應的insert記錄,反之亦然,當update一條記錄時,它記錄一條對應相反的update記錄。當執行rollback時,就可以從undo log中的邏輯記錄讀取到相應的內容並進行回滾。

Undo log銷燬:undo log在事務執行時產生,事務提交時,並不會立即刪除undo log,因為這些日誌可能還用於MVCC。

Undo log儲存:undo log採用段的方式進行管理和記錄,存放在前面介紹的 rollback segment回滾段中,內部包含1024個undo log segment。

MVCC

基本概念

  1. 當前讀

讀取的是記錄的最新版本,讀取時還要保證其他併發事務不能修改當前記錄,會對讀取的記錄進行加鎖。對於我們日常的操作,如:select ... lock in share mode(共享鎖),select ...for updateupdateinsertdelete(排他鎖)都是一種當前讀。

測試:

file

在測試中我們可以看到,即使是在預設的RR隔離級別下,事務A中依然可以讀取到事務B最新提交的內容,因為在查詢語句後面加上了 lock in share mode 共享鎖,此時是當前讀操作。當然,當我們加排他鎖的時候,也是當前讀操作。

  1. 快照讀

簡單的select(不加鎖)就是快照讀,快照讀,讀取的是記錄資料的可見版本,有可能是歷史資料,不加鎖,是非阻塞讀。

  • Read Committed:每次select,都生成一個快照讀。
  • Repeatable Read:開啟事務後第一個select語句才是快照讀的地方。
  • Serializable:快照讀會退化為當前讀。

測試:

file 在測試中,我們看到即使事務B提交了資料,事務A中也查詢不到。 原因就是因為普通的select是快照讀,而在當前預設的RR隔離級別下,開啟事務後第一個select語句才是快照讀的地方,後面執行相同的select語句都是從快照中獲取資料,可能不是當前的最新資料,這樣也就保證了可重複讀。

  1. MVCC

全稱 Multi-Version Concurrency Control,多版本併發控制。指維護一個數據的多個版本,使得讀寫操作沒有衝突,快照讀為MySQL實現MVCC提供了一個非阻塞讀功能。MVCC的具體實現,還需要依賴於資料庫記錄中的三個隱式欄位、undo log日誌、readView。

接下來,我們再來介紹一下InnoDB引擎的表中涉及到的隱藏欄位 、undolog 以及 readview,從而來介紹一下MVCC的原理。

隱藏欄位

介紹

file

當我們建立了上面的這張表,我們在查看錶結構的時候,就可以顯式的看到這三個欄位。 實際上除了這三個欄位以外,InnoDB還會自動的給我們新增三個隱藏欄位及其含義分別是:

隱藏欄位 含義
DB_TRX_ID 最近修改事務ID,記錄插入這條記錄或最後一次修改該記錄的事務ID。
DB_ROLL_PTR 回滾指標,指向這條記錄的上一個版本,用於配合undo log,指向上一個版本。
DB_ROW_ID 隱藏主鍵,如果表結構沒有指定主鍵,將會生成該隱藏欄位。

而上述的前兩個欄位是肯定會新增的, 是否新增最後一個欄位DB_ROW_ID,得看當前表有沒有主鍵,如果有主鍵,則不會新增該隱藏欄位。

測試

  1. 檢視有主鍵的表 stu

進入伺服器中的 /var/lib/mysql/MySQL_Advanced/ , 檢視stu的表結構資訊, 通過如下指令:

ibd2sdi stu.ibd

檢視到的表結構資訊中,有一欄 columns,在其中我們會看到處理我們建表時指定的欄位以外,還有額外的兩個欄位 分別是:DB_TRX_ID 、 DB_ROLL_PTR ,因為該表有主鍵,所以沒有DB_ROW_ID隱藏欄位。

            {
                "name": "DB_TRX_ID",
                "type": 10,
                "is_nullable": false,
                "is_zerofill": false,
                "is_unsigned": false,
                "is_auto_increment": false,
                "is_virtual": false,
                "hidden": 2,
                "ordinal_position": 4,
                "char_length": 6,
                "numeric_precision": 0,
                "numeric_scale": 0,
                "numeric_scale_null": true,
                "datetime_precision": 0,
                "datetime_precision_null": 1,
                "has_no_default": false,
                "default_value_null": true,
                "srs_id_null": true,
                "srs_id": 0,
                "default_value": "",
                "default_value_utf8_null": true,
                "default_value_utf8": "",
                "default_option": "",
                "update_option": "",
                "comment": "",
                "generation_expression": "",
                "generation_expression_utf8": "",
                "options": "",
                "se_private_data": "table_id=1074;",
                "engine_attribute": "",
                "secondary_engine_attribute": "",
                "column_key": 1,
                "column_type_utf8": "",
                "elements": [],
                "collation_id": 63,
                "is_explicit_collation": false
            },
            {
                "name": "DB_ROLL_PTR",
                "type": 9,
                "is_nullable": false,
                "is_zerofill": false,
                "is_unsigned": false,
                "is_auto_increment": false,
                "is_virtual": false,
                "hidden": 2,
                "ordinal_position": 5,
                "char_length": 7,
                "numeric_precision": 0,
                "numeric_scale": 0,
                "numeric_scale_null": true,
                "datetime_precision": 0,
                "datetime_precision_null": 1,
                "has_no_default": false,
                "default_value_null": true,
                "srs_id_null": true,
                "srs_id": 0,
                "default_value": "",
                "default_value_utf8_null": true,
                "default_value_utf8": "",
                "default_option": "",
                "update_option": "",
                "comment": "",
                "generation_expression": "",
                "generation_expression_utf8": "",
                "options": "",
                "se_private_data": "table_id=1074;",
                "engine_attribute": "",
                "secondary_engine_attribute": "",
                "column_key": 1,
                "column_type_utf8": "",
                "elements": [],
                "collation_id": 63,
                "is_explicit_collation": false
            }
  1. 檢視沒有主鍵的表 employee

建表語句:

create table employee (id int , name varchar(10));

此時,我們再通過以下指令來查看錶結構及其其中的欄位資訊:

ibd2sdi employee.ibd

檢視到的表結構資訊中,有一欄 columns,在其中我們會看到處理我們建表時指定的欄位以外,還有額外的三個欄位 分別是:DB_TRX_ID 、 DB_ROLL_PTR 、DB_ROW_ID,因為employee表是沒有指定主鍵的。

            {
                "name": "DB_ROW_ID",
                "type": 10,
                "is_nullable": false,
                "is_zerofill": false,
                "is_unsigned": false,
                "is_auto_increment": false,
                "is_virtual": false,
                "hidden": 2,
                "ordinal_position": 3,
                "char_length": 6,
                "numeric_precision": 0,
                "numeric_scale": 0,
                "numeric_scale_null": true,
                "datetime_precision": 0,
                "datetime_precision_null": 1,
                "has_no_default": false,
                "default_value_null": true,
                "srs_id_null": true,
                "srs_id": 0,
                "default_value": "",
                "default_value_utf8_null": true,
                "default_value_utf8": "",
                "default_option": "",
                "update_option": "",
                "comment": "",
                "generation_expression": "",
                "generation_expression_utf8": "",
                "options": "",
                "se_private_data": "table_id=1076;",
                "engine_attribute": "",
                "secondary_engine_attribute": "",
                "column_key": 1,
                "column_type_utf8": "",
                "elements": [],
                "collation_id": 63,
                "is_explicit_collation": false
            },
            {
                "name": "DB_TRX_ID",
                "type": 10,
                "is_nullable": false,
                "is_zerofill": false,
                "is_unsigned": false,
                "is_auto_increment": false,
                "is_virtual": false,
                "hidden": 2,
                "ordinal_position": 4,
                "char_length": 6,
                "numeric_precision": 0,
                "numeric_scale": 0,
                "numeric_scale_null": true,
                "datetime_precision": 0,
                "datetime_precision_null": 1,
                "has_no_default": false,
                "default_value_null": true,
                "srs_id_null": true,
                "srs_id": 0,
                "default_value": "",
                "default_value_utf8_null": true,
                "default_value_utf8": "",
                "default_option": "",
                "update_option": "",
                "comment": "",
                "generation_expression": "",
                "generation_expression_utf8": "",
                "options": "",
                "se_private_data": "table_id=1076;",
                "engine_attribute": "",
                "secondary_engine_attribute": "",
                "column_key": 1,
                "column_type_utf8": "",
                "elements": [],
                "collation_id": 63,
                "is_explicit_collation": false
            },
            {
                "name": "DB_ROLL_PTR",
                "type": 9,
                "is_nullable": false,
                "is_zerofill": false,
                "is_unsigned": false,
                "is_auto_increment": false,
                "is_virtual": false,
                "hidden": 2,
                "ordinal_position": 5,
                "char_length": 7,
                "numeric_precision": 0,
                "numeric_scale": 0,
                "numeric_scale_null": true,
                "datetime_precision": 0,
                "datetime_precision_null": 1,
                "has_no_default": false,
                "default_value_null": true,
                "srs_id_null": true,
                "srs_id": 0,
                "default_value": "",
                "default_value_utf8_null": true,
                "default_value_utf8": "",
                "default_option": "",
                "update_option": "",
                "comment": "",
                "generation_expression": "",
                "generation_expression_utf8": "",
                "options": "",
                "se_private_data": "table_id=1076;",
                "engine_attribute": "",
                "secondary_engine_attribute": "",
                "column_key": 1,
                "column_type_utf8": "",
                "elements": [],
                "collation_id": 63,
                "is_explicit_collation": false
            }
        ],

undolog

介紹

回滾日誌,在insert、update、delete的時候產生的便於資料回滾的日誌。

當insert的時候,產生的undo log日誌只在回滾時需要,在事務提交後,可被立即刪除。

而update、delete的時候,產生的undo log日誌不僅在回滾時需要,在快照讀時也需要,不會立即被刪除。

版本鏈

有一張表原始資料為:

file

DB_TRX_ID : 代表最近修改事務ID,記錄插入這條記錄或最後一次修改該記錄的事務ID,是自增的。

DB_ROLL_PTR: 由於這條資料是才插入的,沒有被更新過,所以該欄位值為null。

然後,有四個併發事務同時在訪問這張表。

A. 第一步

file

當事務2執行第一條修改語句時,會記錄undo log日誌,記錄資料變更之前的樣子; 然後更新記錄,並且記錄本次操作的事務ID,回滾指標,回滾指標用來指定如果發生回滾,回滾到哪一個版本。

file

B.第二步

file

當事務3執行第一條修改語句時,也會記錄undo log日誌,記錄資料變更之前的樣子; 然後更新記錄,並且記錄本次操作的事務ID,回滾指標,回滾指標用來指定如果發生回滾,回滾到哪一個版本。

file

C. 第三步

file

當事務4執行第一條修改語句時,也會記錄undo log日誌,記錄資料變更之前的樣子; 然後更新記錄,並且記錄本次操作的事務ID,回滾指標,回滾指標用來指定如果發生回滾,回滾到哪一個版本。

file

最終我們發現,不同事務或相同事務對同一條記錄進行修改,會導致該記錄的undolog生成一條記錄版本連結串列,連結串列的頭部是最新的舊記錄,連結串列尾部是最早的舊記錄。

readview

ReadView(讀檢視)是 快照讀 SQL執行時MVCC提取資料的依據,記錄並維護系統當前活躍的事務(未提交的)id。

ReadView中包含了四個核心欄位:

欄位 含義
m_ids 當前活躍的事務ID集合
min_trx_id 最小活躍事務ID
max_trx_id 預分配事務ID,當前最大事務ID+1(因為事務ID是自增的)
creator_trx_id ReadView建立者的事務ID

而在readview中就規定了版本鏈資料的訪問規則:

trx_id 代表當前undolog版本鏈對應事務ID。

條件 是否可以訪問 說明
trx_id == creator_trx_id 可以訪問該版本 成立,說明資料是當前這個事務更改的。
trx_id < min_trx_id 可以訪問該版本 成立,說明資料已經提交了。
trx_id > max_trx_id 不可以訪問該版本 成立,說明該事務是在ReadView生成後才開啟。
min_trx_id <= trx_id <= max_trx_id 如果trx_id不在m_ids中,是可以訪問該版本的 成立,說明資料已經提交。

不同的隔離級別,生成ReadView的時機不同:

  • READ COMMITTED :在事務中每一次執行快照讀時生成ReadView。
  • REPEATABLE READ:僅在事務中第一次執行快照讀時生成ReadView,後續複用該ReadView。

原理分析

RC隔離級別

RC隔離級別下,在事務中每一次執行快照讀時生成ReadView。

我們就來分析事務5中,兩次快照讀讀取資料,是如何獲取資料的?

在事務5中,查詢了兩次id為30的記錄,由於隔離級別為Read Committed,所以每一次進行快照讀都會生成一個ReadView,那麼兩次生成的ReadView如下。

file

那麼這兩次快照讀在獲取資料時,就需要根據所生成的ReadView以及ReadView的版本鏈訪問規則,到undolog版本鏈中匹配資料,最終決定此次快照讀返回的資料。

A. 先來看第一次快照讀具體的讀取過程:

file

在進行匹配時,會從undo log的版本鏈,從上到下進行挨個匹配:

  • 先匹配

file

這條記錄,這條記錄對應的trx_id為4,也就是將4帶入右側的匹配規則中。 ①不滿足 ②不滿足 ③不滿足 ④也不滿足 ,都不滿足,則繼續匹配undo log版本鏈的下一條。

  • 再匹配第二條

file

,這條記錄對應的trx_id為3,也就是將3帶入右側的匹配規則中。①不滿足 ②不滿足 ③不滿足 ④也不滿足 ,都不滿足,則繼續匹配undo log版本鏈的下一條。

  • 再匹配第三條

file

,這條記錄對應的trx_id為2,也就是將2帶入右側的匹配規則中。①不滿足 ②滿足 終止匹配,此次快照讀,返回的資料就是版本鏈中記錄的這條資料。

B. 再來看第二次快照讀具體的讀取過程:

file

在進行匹配時,會從undo log的版本鏈,從上到下進行挨個匹配:

  • 先匹配

file

這條記錄,這條記錄對應的trx_id為4,也就是將4帶入右側的匹配規則中。 ①不滿足 ②不滿足 ③不滿足 ④也不滿足 ,都不滿足,則繼續匹配undo log版本鏈的下一條。

  • 再匹配第二條

file

,這條記錄對應的trx_id為3,也就是將3帶入右側的匹配規則中。①不滿足 ②滿足 。終止匹配,此次快照讀,返回的資料就是版本鏈中記錄的這條資料。

RR隔離級別

RR隔離級別下,僅在事務中第一次執行快照讀時生成ReadView,後續複用該ReadView。 而RR 是可重複讀,在一個事務中,執行兩次相同的select語句,查詢到的結果是一樣的。

那MySQL是如何做到可重複讀的呢? 我們簡單分析一下就知道了

file

我們看到,在RR隔離級別下,只是在事務中第一次快照讀時生成ReadView,後續都是複用該ReadView,那麼既然ReadView都一樣, ReadView的版本鏈匹配規則也一樣, 那麼最終快照讀返回的結果也是一樣的。

所以呢,MVCC的實現原理就是通過 InnoDB表的隱藏欄位、UndoLog 版本鏈、ReadView來實現的。而MVCC + 鎖,則實現了事務的隔離性。 而一致性則是由redolog 與 undolog保證。

file

本文由傳智教育博學谷狂野架構師教研團隊釋出。

如果本文對您有幫助,歡迎關注點贊;如果您有任何建議也可留言評論私信,您的支援是我堅持創作的動力。

轉載請註明出處!