一文詳解MySQL中的事務和 MVCC 原理

2022-03-09 13:00:24
本篇文章帶大家瞭解一下MySQL中的事務,並介紹一下MVCC 原理,希望能夠給大家提供幫助!

01 什麼是事務?

資料庫事務指的是一組資料操作,事務內的操作要麼就是全部成功,要麼就是全部失敗,什麼都不做,其實不是沒做,是可能做了一部分但是隻要有一步失敗,就要回滾所有操作,有點一不做二不休的意思。

在 MySQL 中,事務支援是在引擎層實現的。MySQL 是一個支援多引擎的系統,但並不是所有的引擎都支援事務。比如 MySQL 原生的 MyISAM 引擎就不支援事務,這也是 MyISAM 被 InnoDB 取代的重要原因之一

1.1 四大特性

  • 原子性(Atomicity):事務開始後所有操作,要麼全部做完,要麼全部不做,不可能停滯在中間環節。事務執行過程中出錯,會回滾到事務開始前的狀態,所有的操作就像沒有發生一樣。也就是說事務是一個不可分割的整體,就像化學中學過的原子,是物質構成的基本單位。
  • 一致性(Consistency):事務開始前和結束後,資料庫的完整性約束沒有被破壞 。比如 A 向 B 轉賬,不可能 A 扣了錢,B 卻沒收到。
  • 隔離性(Isolation):同一時間,只允許一個事務請求同一資料,不同的事務之間彼此沒有任何干擾。比如 A 正在從一張銀行卡中取錢,在 A 取錢的過程結束前,B 不能向這張卡轉賬。
  • 永續性(Durability):事務完成後,事務對資料庫的所有更新將被儲存到資料庫,不能回滾。

1.2 隔離級別

SQL 事務的四大特性中原子性、一致性、永續性都比較好理解。但事務的隔離級別確實比較難的,今天主要聊聊 MySQL 事務的隔離性。

SQL 標準的事務隔離從低到高階別依次是:讀未提交(read uncommitted)、讀提交(read committed)、可重複讀(repeatable read)和序列化(serializable )。級別越高,效率越低

  • 讀未提交:一個事務還沒提交時,它做的變更就能被別的事務看到。
  • 讀提交:一個事務提交之後,它做的變更才會被其他事務看到。
  • 可重複讀:一個事務執行過程中看到的資料,總是跟這個事務在啟動時看到的資料是一致的。當然在可重複讀隔離級別下,未提交變更對其他事務也是不可見的。
  • 序列化:顧名思義是對於同一行記錄,「寫」 會加 「寫鎖」,「讀」 會加 「讀鎖」。當出現讀寫鎖衝突的時候,後存取的事務必須等前一個事務執行完成,才能繼續執行。所以種隔離級別下所有的資料是最穩定的,但是效能也是最差的

1.3 解決的並行問題

SQL 事務隔離級別的設計就是為了能最大限度的解決並行問題:

  • 髒讀:事務 A 讀取了事務 B 更新的資料,然後 B 回滾操作,那麼 A 讀取到的資料是髒資料
  • 不可重複讀:事務 A 多次讀取同一資料,事務 B 在事務 A 多次讀取的過程中,對資料作了更新並提交,導致事務 A 多次讀取同一資料時,結果不一致。
  • 幻讀:系統管理員 A 將資料庫中所有學生的成績從具體分數改為 ABCDE 等級,但是系統管理員 B 就在這個時候插入了一條具體分數的記錄,當系統管理員 A 改結束後發現還有一條記錄沒有改過來,就好像發生了幻覺一樣,這就叫幻讀。

SQL 不同的事務隔離級別能解決的並行問題也不一樣,如下表所示:只有序列化的隔離級別解決了全部這 3 個問題,其他的 3 個隔離級別都有缺陷

事務隔離級別髒讀不可重複讀幻讀
讀未提交可能可能可能
讀已提交不可能可能可能
可重複讀不可能不可能可能
序列化不可能不可能不可能

PS:不可重複讀的和幻讀很容易混淆,不可重複讀側重於修改,幻讀側重於新增或刪除。解決不可重複讀的問題只需鎖住滿足條件的行,解決幻讀需要鎖表

1.4 舉個栗子

這麼說可能有點難以理解,舉個栗子。還是之前的表結構以及表資料

CREATE TABLE `student`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `age` int(11) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 66 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

表資料

假設現在,我要同時啟動兩個食物,一個事務 A 查詢 id = 2 的學生的 age,一個事務 B 更新 id = 2 的學生的 age。流程如下,在四種隔離級別下的 X1、X2、X3 的值分別是怎樣的呢?

隔離級別舉例

  • 讀未提交:X1 的值是 23,因為事務 B 雖然沒提交但它的更改已被 A 看到。(如果 B 後面又回滾了 X1 的值就是髒的)。X2、X3 的值也是 23,這無可厚非。
  • 讀已提交:X1 的值是 22,因為 B 雖然改了,但 A 看不到。(如果 B 後面回滾了,X1 的值不變,解決了髒讀),X2、X3 的值是 23,沒毛病,B 提交了,A 才能看到。
  • 可重複讀:X1、X2 都是 22,A 開啟的時刻值是 22,那麼在 A 的整個過程中,它的值都是 22。(不管 B 在這期間怎麼修改,只要 A 還沒提交,都是看不見的,解決了不可重複讀),而 X3 的值是 23,因為 A 提交了,能看到 B 修改的值了。
  • 序列化:B 在執行更改期間會被鎖住,直至 A 提交。B 才能繼續執行。(A 在讀期間,B 不能寫。得保證此時資料是最新的。解決了幻讀)所以 X1、X2 都是 22,而最後的 X3 在 B 提交之後執行,它的值就是 23。

那為什麼會出現這樣的結果呢?事務隔離級別到底是怎麼實現的呢?

事務隔離級別是怎麼是實現的呢?我在極客時間丁奇老師的課上找到了答案:

實際上,資料庫裡面會建立一個檢視,存取的時候以檢視的邏輯結果為準。在 「可重複讀」 隔離級別下,這個檢視是在事務啟動時建立的,整個事務存在期間都用這個檢視。在 「讀提交」 隔離級別下,這個檢視是在每個 SQL 語句開始執行的時候建立的。這裡需要注意的是,「讀未提交」 隔離級別下直接返回記錄上的最新值,沒有檢視概念;而 「序列化」 隔離級別下直接用加鎖的方式來避免並行存取

1.5 設定事務隔離級別

不同的資料庫預設設定的事務隔離級別也大不一樣,Oracle 資料庫的預設隔離級別是讀提交,而 MySQL 是可重複讀。所以,當你的系統需要把資料庫從 Oracle 遷移到 MySQL 時,請把級別設定成與搬遷之前的(讀提交)一致,避免出現不可預測的問題

1.5.1 檢視事務隔離級別

# 檢視事務隔離級別
5.7.20 之前
SELECT @@transaction_isolation
show variables like 'transaction_isolation';

# 5.7.20 以及之後
SELECT @@tx_isolation
show variables like 'tx_isolation'

+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+

1.5.2 設定隔離級別

修改隔離級別語句格式是:set [作用域] transaction isolation level [事務隔離級別]

其中作用域可選:SESSION(對談)、GLOBAL(全域性);隔離級別就是上面提到的 4 種,不區分大小寫。

例如:設定全域性隔離級別為讀提交

set global transaction isolation level read committed;

1.6 事務的啟動

MySQL 的事務啟動有以下幾種方式:

  • 顯式啟動事務語句, begin 或 start transaction。配套的提交語句是 commit,或者回滾語句是 rollback。
# 更新學生名字
START TRANSACTION;
update student set name = '張三' where id = 2;
commit;
  • set autocommit = 0,這個命令會將執行緒的自動提交關掉。意味著如果你只執行一個 select 語句,這個事務就啟動了,而且並不會自動提交。這個事務持續存在直到你主動執行 commit 或 rollback 語句,或者斷開連線。
  • set autocommit = 1,表示 MySQL 自動開啟和提交事務。 比如執行一個 update 語句,語句只完成後就自動提交了。不需要顯示的使用 begin、commit 來開啟和提交事務。所以當我們執行多個語句的時候,就需要手動的用 begin、commit 來開啟和提交事務。
  • start transaction with consistent snapshot;上面提到的 begin/start transaction 命令並不是一個事務的起點,在執行到它們之後的第一個操作 InnoDB 表的語句,事務才真正啟動。如果你想要馬上啟動一個事務,可以使用 start transaction with consistent snapshot 命令。 第一種啟動方式,一致性檢視是在執行第一個快照讀語句時建立的; 第二種啟動方式,一致性檢視是在執行 start transaction with consistent snapshot 時建立的

02 事務隔離的實現

理解了隔離級別,那事務的隔離是怎麼實現的呢?要想理解事務隔離,先得了解 MVCC 多版本的並行控制這個概念。而 MVCC 又依賴於 undo log 和 read view 實現。

2.1 什麼是 MVCC?

百度上的解釋是這樣的:

MVCC,全稱 Multi-Version Concurrency Control,即多版本並行控制。MVCC 是一種並行控制的方法,一般在資料庫管理系統中,實現對資料庫的並行存取,在程式語言中實現事務記憶體。

MVCC 使得資料庫讀不會對資料加鎖,普通的 SELECT 請求不會加鎖,提高了資料庫的並行處理能力;資料庫寫才會加鎖。 藉助 MVCC,資料庫可以實現 READ COMMITTED,REPEATABLE READ 等隔離級別,使用者可以檢視當前資料的前一個或者前幾個歷史版本,保證了 ACID 中的 I 特性(隔離性)。

MVCC 只在 REPEATABLE READ 和 READ COMMITIED 兩個隔離級別下工作。其他兩個隔離級別都和 MVCC 不相容 ,因為 READ UNCOMMITIED 總是讀取最新的資料行,而不是符合當前事務版本的資料行。而 SERIALIZABLE 則會對所有讀取的行都加鎖。

2.1.1 InnDB 中的 MVCC

InnDB 中每個事務都有一個唯一的事務 ID,記為 transaction_id。它在事務開始時向 InnDB 申請,按照時間先後嚴格遞增。

而每行資料其實都有多個版本,這就依賴 undo log 來實現了。每次事務更新資料就會生成一個新的資料版本,並把 transaction_id 記為 row trx_id。同時舊的資料版本會保留在 undo log 中,而且新的版本會記錄舊版本的回滾指標,通過它直接拿到上一個版本。

所以,InnDB 中的 MVCC 其實是通過在每行記錄後面儲存兩個隱藏的列來實現的。一列是事務 ID:trx_id;另一列是回滾指標:roll_pt。

2.2 undo log

回滾紀錄檔儲存了事務發生之前的資料的一個版本,可以用於回滾,同時可以提供多版本並行控制下的讀(MVCC),也即非鎖定讀。

根據操作的不同,undo log 分為兩種: insert undo log 和 update undo log。

2.2.1 insert undo log

insert 操作產生的 undo log,因為 insert 操作記錄沒有歷史版本只對當前事務本身可見,對於其他事務此記錄不可見,所以 insert undo log 可以在事務提交後直接刪除而不需要進行 purge 操作。

purge 的主要任務是將資料庫中已經 mark del 的資料刪除,另外也會批次回收 undo pages

所以,插入資料時。它的初始狀態是這樣的:

insert undo log

2.2.2 update undo log

UPDATE 和 DELETE 操作產生的 Undo log 都屬於同一型別:update_undo。(update 可以視為 insert 新資料到原位置,delete 舊資料,undo log 暫時保留舊資料)。

事務提交時放到 history list 上,沒有事務要用到這些回滾紀錄檔,即系統中沒有比這個回滾紀錄檔更早的版本時,purge 執行緒將進行最後的刪除操作。

一個事務修改當前資料:

第二次事務

另一個事務修改資料:

第三次事務

這樣的同一條記錄在資料庫中存在多個版本,就是上面提到的多版本並行控制 MVCC。

另外,藉助 undo log 通過回滾可以回到上一個版本狀態。比如要回到 V1 只需要順序執行兩次回滾即可。

2.3 read-view

read view 是 InnDB 在實現 MVCC 時用到的一致性讀檢視,用於支援 RC(讀提交)以及 RR(可重複讀)隔離級別的實現

read view 不是真實存在的,只是一個概念,undo log 才是它的體現。它主要是通過版本和 undolog 計算出來的。作用是決定事務能看到哪些資料

每個事務或者語句有自己的一致性檢視。普通查詢語句是一致性讀,一致性讀會根據 row trx_id 和一致性檢視確定資料版本的可見性

2.3.1 資料版本的可見性規則

read view 中主要包含當前系統中還有哪些活躍的讀寫事務,在實現上 InnDB 為每個事務構造了一個陣列,用來儲存這個事務啟動瞬間,當前正活躍(還未提交)的事務

前面說了事務 ID 隨時間嚴格遞增的,把系統中已提交的事務 ID 的最大值記為陣列的低水位,已建立過的事務 ID + 1記為高水位

這個檢視陣列和高水位就組成了當前事務的一致性檢視(read view)

這個陣列畫個圖,長這樣:

資料版本的可見性規則

規則如下:

  • 1 如果 trx_id 在灰色區域,表明被存取版本的 trx_id 小於陣列中低水位的 id 值,也即生成該版本的事務在生成 read view 前已經提交,所以該版本可見,可以被當前事務存取。
  • 2 如果 trx_id 在橙色區域,表明被存取版本的 trx_id 大於陣列中高水位的 id 值,也即生成該版本的事務在生成 read view 後才生成,所以該版本不可見,不能被當前事務存取。
  • 3 如果在綠色區域,就會有兩種情況:

    • a) trx_id 在陣列中,證明這個版本是由還未提交的事務生成的,不可見
    • b) trx_id 不在陣列中,證明這個版本是由已提交的事務生成的,可見

第三點我在看教學的時候也有點疑惑,好在有熱心網友解答:

落在綠色區域意味著是事務 ID 在低水位和高水位這個範圍裡面,而真正是否可見,看綠色區域是否有這個值。如果綠色區域沒有這個事務 ID,則可見,如果有,則不可見。在這個範圍裡面並不意味著這個範圍就有這個值,比如 [1,2,3,5],4 在這個陣列 1-5 的範圍裡,卻沒在這個陣列裡面。

這樣說可能有點難以理解,我假設一個場景:三個事務對同一條資料進行查詢更新等操作,為此畫了張圖以方便理解:

三個事務操作

原始資料還是下圖這樣的,對 id = 2 的張三進行資訊的更新:

表資料

針對上圖,我想提個問題。分別在 RC(讀提交)以及 RR(可重複讀)隔離級別下,T4 和 T5 時間點的查詢 age 值分別是多少呢?T4 更新的值又是多少呢?思考片刻,相信大家都有自己的答案。答案在文末,希望大家能帶著自己的疑問繼續讀下去。

2.3.2 RR(可重複讀)下的結果

RR 級別下,查詢只承認在事務啟動前就已經提交完成的資料,一旦啟動事務就會建檢視。所以使用 start transaction with consistent snapshot 命令,馬上就會建檢視

現在假設:

  • 事務 A 開始前,只有一個活躍的事務,ID = 2,
  • 已提交的事務也就是插入資料的事務 ID = 1
  • 事務 A、B、C 的事務 ID 分別是 3、4、5

在這種隔離級別下,他們建立檢視的時刻如下:

RR級別結果

根據上圖得,事務 A 的檢視陣列是[2,3];事務 B 的檢視陣列是 [2,3,4];事務 C 的檢視陣列是[2,3,4,5]。分析一波:

  • T4 時刻,B 讀資料都是從當前版本讀起,過程是這樣的:

    • 讀到當前版本的 trx_id = 4,剛好是自己,可見
    • 所以 age = 24
  • T5 時刻,A 讀資料都是從當前版本讀起,過程是這樣的:

    • 讀到當前版本的 trx_id = 4,比自己檢視陣列的高水位大,不可見
    • 再往上讀到 trx_id = 5,比自己檢視陣列高水位大,不可見
    • 再往上讀到 trx_id = 1,比自己檢視陣列低水位小,可見
    • 所以 age = 22

這樣執行下來,雖然期間這一行資料被修改過,但是事務 A 不論在什麼時候查詢,看到這行資料的結果都是一致的,所以我們稱之為一致性讀

其實檢視是否可見主要看建立檢視和提交的時機,總結下規律:

  • 版本未提交,不可見
  • 版本已提交,但在檢視建立後提交,不可見
  • 版本已提交,但在檢視建立前提交,可見

2.3.2.1 快照讀和當前讀

事務 B 的 update 語句,如果按照上圖的一致性讀,好像結果不大對?

如下圖周明,B 的檢視陣列是先生成的,之後事務 C 才提交。那就應該看不見 C 修改的 age = 23 呀?最後 B 怎麼得出 24 了?

更新邏輯

沒錯,如果 B 在更新之前執行查詢語句,那返回的結果肯定是 age = 22。問題是更新就不能在歷史版本更新了呀,否則 C 的更新不就丟失了?

所以,更新有個規則:更新資料都是先讀後寫(讀是更新語句執行,不是我們手動執行),讀的就是當前版本的值,叫當前讀;而我們普通的查詢語句就叫快照讀

因此,在更新時,當前讀讀到的是 age = 23,更新之後就成 24 啦。

2.3.2.2 select 當前讀

除了更新語句,查詢語句如果加鎖也是當前讀。如果把事務 A 的查詢語句 select age from t where id = 2 改一下,加上鎖(lock in mode 或者 for update),也都可以得到當前版本 4 返回的 age = 24

下面就是加了鎖的 select 語句:

select age from t where id = 2 lock in mode;
 select age from t where id = 2 for update;

2.3.2.3 事務 C 不馬上提交

假設事務 C 不馬上提交,但是 age = 23 版本已生成。事務 B 的更新將會怎麼走呢?

事務 C 不馬上提交

事務 C 還沒提交,寫鎖還沒釋放,但是事務 B 的更新必須要當前讀且必須加鎖。所以事務 B 就阻塞了,必須等到事務 C 提交,釋放鎖才能繼續當前的讀。

被事務 C 鎖住

2.3.3 RC(讀提交)下的結果

在讀提交隔離級別下,查詢只承認在語句啟動前就已經提交完成的資料;每一個語句執行之前都會重新算出一個新的檢視

注意:在上圖的表格中用於啟動事務的是 start transaction with consistent snapshot 命令,它會建立一個持續整個事務的檢視。所以,在 RC 級別下,這命令其實不起作用。等效於普通的 start transaction(在執行 sql 語句之前才算是啟動了事務)。所以,事務 B 的更新其實是在事務 C 之後的,它還沒真正啟動事務,而 C 已提交

現在假設:

  • 事務 A 開始前,只有一個活躍的事務,ID = 2,
  • 已提交的事務也就是插入資料的事務 ID = 1
  • 事務 A、B、C 的事務 ID 分別是 3、4、5

在這種隔離級別下,他們建立檢視的時刻如下:

RC級別結果

根據上圖得,事務 A 的檢視陣列是[2,3,4],但它的高水位是 6或者更大(已建立事務 ID + 1);事務 B 的檢視陣列是 [2,4];事務 C 的檢視陣列是 [2,5]。分析一波:

  • T4 時刻,B 讀資料都是從當前版本讀起,過程是這樣的:

    • 讀到當前版本的 trx_id = 4,剛好是自己,可見
    • 所以 age = 24
  • T5 時刻,A 讀資料都是從當前版本讀起,過程是這樣的:

    • 讀到當前版本的 trx_id = 4,在自己一致性檢視範圍內但包含 4,不可見
    • 再往上讀到 trx_id = 5,在自己一致性檢視範圍內但不包含 5,可見
    • 所以 age = 23

03巨人的肩膀

  • cnblogs.com/wyaokai/p/10921323.html
  • time.geekbang.org/column/article/70562
  • zhuanlan.zhihu.com/p/117476959
  • cnblogs.com/xd502djj/p/6668632.html
  • blog.csdn.net/article/details/109044141
  • blog.csdn.net/u014078930/article/details/99659272

04 總結

本文詳細聊了事務的方方面面,比如:四大特性、隔離級別、解決的並行問題、如何設定、檢視隔離級別、如何啟動事務等。除此以外,還深入瞭解了 RR 和 RC 兩個級別的隔離是怎麼實現的?包括詳解 MVCC、undo log 和 read view 是怎麼配合實現 MVCC 的。最後還聊了快照讀、當前讀等等。可以說,事務相關的知識點都在這了。看完這一篇還不懂的話,你來捶我呀!

【相關推薦:】

以上就是一文詳解MySQL中的事務和 MVCC 原理的詳細內容,更多請關注TW511.COM其它相關文章!