捲起來!!!看了這篇文章我才知道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

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

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

轉載請註明出處!