mysql教程欄目介紹mysql相關的事務、隔離級別及mvcc。
MySQL 系列的第四篇,主要內容是事務,包括事務 ACID 特性,隔離級別,臟讀、不可重復讀、幻讀的理解以及多版本并發控制(MVCC)等內容。
事務(transaction)能夠保證一組不可分割的原子性操作集合要么都執行,要么都不執行。在MySQL 常用的存儲引擎中,InnoDB 是支持事務的,原生的 MyISAM 引擎則不支持事務。
在本文中,若未特殊說明,使用的數據表及數據如下所示:
CREATE table `user` ( `id` int(11) DEFAULT NULL, `name` varchar(12) DEFAULT NULL) ENGINE = InnoDB;insert into user values(1, '刺猬');復制代碼
1. ACID 四大特性
首先需要理解的是事務 ACID 四大特性,即原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)、持久性(Durability),這也是事務的四個基本要素。
為了詳細解釋 ACID 特性,在這里先設想一個場景:我向你轉賬100元。
假設這個操作可以分為以下幾步(假設我和你的賬戶余額均為100元):
- 查詢我的賬戶余額
- 我的賬戶扣款100元
- 100元開始轉移
- 查詢你的賬戶余額
- 你的賬戶到賬100元
1.1 原子性(Atomicity)
事務的原子性是指:一個事務必須是不可再分割的最小工作單元,一個事務中的操作要么都成功,要么都失敗,不可能存在只執行一個事務中部分操作的情況。
在上述的轉賬場景中,原子性就要求了這五個步驟要么都執行,要么都不執行,不可能存在我的賬戶扣款100元,而你的賬戶100元沒有到賬的情況。
1.2 一致性(Consistency)
事務的一致性是指:數據庫總是從一個一致性狀態轉換到另一個一致性狀態,一致性側重的是數據的可見性,數據的中間狀態對外是不可見的。
同時,事務的一致性要求符合開發人員定義的約束,如金額大于0、身高大于0等。
在上述的轉賬場景中,一致性能夠保證最終執行完整個轉賬操作后,我賬戶的扣款金額與你賬戶到賬金額是一致的,同時如果我和你的賬戶余額不滿足金額的約束(如小于0),整個事務會回滾。
1.3 隔離性(Isolation)
事務的隔離性是指:在一次狀態轉換過程中不會受到其他狀態轉換的影響。
假設我和你都有100元,我發起兩次轉賬,轉賬金額都是50元,下面使用偽代碼來表示的操作步驟:
- 查詢我的賬戶余額 read my
- 我的賬戶扣款50元 my=my-50
- 50元開始轉移
- 查詢你的賬戶余額 read yours
- 你的賬戶到賬50元 yours=yours+50
如果未保證隔離性就可能發生下面的情況:
時刻 | 第一次轉賬 | 第二次轉賬 | 我的賬戶余額 | 你的賬戶余額 |
---|---|---|---|---|
1 | read my(100) | my=100 | yours=100 | |
2 | read my(100) | my=100 | yours=100 | |
3 | my=my-50=100-50=50 | my=50 | yours=100 | |
4 | read yours(100) | my=my-50=100-50=50 | my=50 | yours=100 |
5 | yours=yours+50=100+50=150 | my=50 | yours=150 | |
6 | read yours(150) | my=50 | yours=150 | |
7 | yours=yours+50=150+50=200 | my=50 | yours=200 | |
7 | end | end | my=50 | yours=200 |
兩次轉賬后,最終的結果是我的賬戶余額為50元,你的賬戶余額為200元,這顯然是不對的。
而如果在保證事務隔離性的情況下,就不會發生上面的情況,損失的只是一定程度上的一致性。
1.4 持久性(Durability)
事務的持久性是指:事務在提交以后,它所做的修改就會被永久保存到數據庫。
在上述的轉賬場景中,持久性就保證了在轉賬成功之后,我的賬戶余額為0,你的賬戶余額為200。
2. 自動提交與隱式提交
2.1 自動提交
在 MySQL 中,我們可以通過 begin 或 start transaction 來開啟事務,通過 commit 來關閉事務,如果 SQL 語句中沒有這兩個命令,默認情況下每一條 SQL 都是一個獨立的事務,在執行完成后自動提交。
比如:
update user set name='重塑' where id=1;復制代碼
假設我只執行這一條更新語句,在我關閉 MySQL 客戶端然后重新打開一個新的客戶端后,可以看到 user 表中的 name 字段值全變成了「重塑」,這也印證了這條更新語句在執行后已被自動提交。
自動提交是 MySQL 的一個默認屬性,可以通過 SHOW VARIABLES LIKE ‘autocommit’ 語句來查看,當它的值為 ON 時,就代表開啟事務的自動提交。
mysql> SHOW VARIABLES LIKE 'autocommit'; +---------------+-------+| Variable_name | Value | +---------------+-------+| autocommit | ON | +---------------+-------+1 row in set (0.00 sec)復制代碼
我們可以通過 SET autocommit = OFF 來關閉事務的自動提交。
2.2 隱式提交
然而,即便我們已經將 autocommit 變量的值改為 OFF 關閉事務自動提交了,在執行某些 SQL 語句的時候,MySQL 還是會將事務自動提交掉,這被稱為隱式提交。
會觸發隱式提交的 SQL 語句有:
- DDL(Data definition language,數據定義語言),如 create, drop, alter, truncate
- 修改 MySQL 自帶表數據的語句,如 create/drop user, grant, set password
- 在一個事務中,開啟一個新的事務,會隱式提交上一個事務,如:
時刻 | 事務A | 事務B |
---|---|---|
1 | begin; | |
2 | update user set name=’重塑’ where id=1; | |
3 | select name from user where id=1;(N1) | |
4 | begin; | |
5 | select name from user where id=1;(N2) |
在事務B中有兩個查詢語句N1和N2,執行的結果是N1=刺猬,N2=重塑,由此可以證明。
- 其他還有一些管理語句就不一一舉例了,可自行百度。
3. 隔離級別
事務的隔離級別規定了一個事務中所做的修改,在事務內和事務間的可見性。較低級別的隔離通常可以執行更高的并發,系統開銷也更低。
在 SQL 標準中定義了四種事務的隔離級別,分別是讀未提交(Read Uncommitted)、讀已提交(Read Committed)、可重復讀(Repeatable Read)、可串行化(Serializable)。
為了詳細解釋這四種隔離級別及它們各自發生的現象,假設有兩個事務即將執行,執行內容如下表:
時刻 | 事務A | 事務B |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update user set name=’重塑’ where id=1; | |
4 | select name from user where id=1;(N1) | |
5 | commit; | |
6 | select name from user where id=1;(N2) | |
7 | commit; | |
8 | select name from user where id=1;(N3) |
在事務A和事務B執行的過程中,有三處查詢 N1,N2,N3,在每個隔離級別下,它們值的情況是不同的,下面分別討論。
3.1 讀未提交(Read Uncommitted)
在讀未提交的隔離級別下,事務中的修改,即便沒有提交,對其他事務也都是可見的。
在上述場景中,若數據庫的隔離級別為讀未提交,由于事務A可以讀取未提交事務B修改后的數據,即時刻3中事務B的修改對事務A可見,所以N1=重塑,N2=重塑,N3=重塑。
3.2 讀已提交(Read Committed)
在讀已提交的隔離級別下,事務中的修改只有在提交之后,才會對其他事務可見。
在上述場景中,若數據庫的隔離級別為讀已提交,由于事務A只能讀取事務B提交后的數據,即時刻3中事務B的修改對事務A不可見,N2處的查詢在事務B提交之后,故對事務A可見。所以N1=刺猬,N2=重塑,N3=重塑。
3.3 可重復讀(Repeatable Read)
可重復讀是 MySQL 的默認事務隔離級別。在可重復讀的隔離級別下,一個事務中多次查詢相同的記錄,結果總是一致的。
在上述場景中,若數據庫的隔離級別為可重復讀,由于查詢N1和N2在一個事務中,所以它們的值都是「刺猬」,而N3是在事務A提交以后再進行的查詢,對事務B的修改是可見的,所以N3=重塑。
3.4 可串行化(Serializable)
在可串行化的隔離級別下,事務都是串行執行的,讀會加讀鎖,寫會加寫鎖,事務不會并發執行,所以也就不會發生異常情況。
在上述場景中,若數據庫的隔離級別為可串行化,首先開啟事務A,在開啟事務B時被阻塞,直到事務A提交之后才會開啟事務B,所以N1=刺猬,N2=刺猬。而N3處的查詢會在事務B提交之后才執行(事務B先被阻塞,執行順序在N3查詢語句之前),所以N3=重塑。
4. 隔離級別導致的問題
在不同的事務隔離級別中,如果遇到事務并發執行,就會出現很多問題,如臟讀(Dirty Read)、不可重復讀(Non-Repeatable Read)、幻讀(Phantom Read)等,下面就分別用不同的例子來詳細說明這些問題。
4.1 臟讀(Dirty Read)
臟讀(Dirty Read)是指一個事務可以讀取另一個未提交事務修改的數據。
看下面的案例,假設隔離級別為讀未提交:
時刻 | 事務A | 事務B |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update user set name=’重塑’ where id=1; | |
4 | select name from user where id=1;(N1) | |
5 | rollback; | |
6 | select name from user where id=1;(N2) | |
7 | commit; |
在讀未提交的隔離級別下,N1的值是「重塑」,由于事務B的回滾,N2的值是「刺猬」。這里在N1處就發生了臟讀,顯然N1處的查詢結果是一個臟數據,會對正常業務產生影響。
臟讀會發生在讀未提交的隔離級別中。
4.2 不可重復讀(Non-Repeatable Read)
不可重復讀(Non-Repeatable Read)是指,兩次執行相同的查詢可能會得到不一樣的結果。
繼續使用介紹隔離級別時的AB事務案例,同時假設隔離級別為讀已提交:
時刻 | 事務A | 事務B |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update user set name=’重塑’ where id=1; | |
4 | select name from user where id=1;(N1) | |
5 | commit; | |
6 | select name from user where id=1;(N2) | |
7 | commit; | |
8 | select name from user where id=1;(N3) |
在讀已提交的隔離級別下,事務可以讀取到其他事務提交的數據。在上述案例中結果是N1=刺猬,N2=重塑,N3=重塑,在事務A中,有兩次相同的查詢N1和N2,但是這兩次查詢的結果并不相同,這就發生了不可重復讀。
不可重復讀會發生在讀未提交、讀已提交的隔離級別中。
4.3 幻讀(Phantom Read)
幻讀(Phantom Read)是指,一個事務在讀取某個范圍內記錄時,另外一個事務在該范圍內插入一條新記錄,當之前的事務再次讀取這個范圍的記錄時,會讀到這條新記錄。
看下面的案例,假設此時隔離級別為可重復讀:
時刻 | 事務A | 事務B |
---|---|---|
1 | begin; | |
2 | select name from user;(N1) | |
3 | begin; | |
4 | insert into user values(2, ‘五條人’); | |
5 | commit; | |
6 | select name from user;(N2) | |
7 | select name from user for update;(N3) | |
8 | commit; |
事務A有三次查詢,在N1和N2之間,事務B執行了一條 insert語句并提交,N3處的查詢使用的是 for update。
N1處的結果很顯然只有「刺猬」,N2處的結果由于事務A開啟在事務B之前,所以也是「刺猬」,而N3處的結果理論上在可重復讀的隔離級別中也應該只有「刺猬」,但實際上N2的結果是「刺猬」和「五條人」,這就發生了幻讀。
這就很奇怪了,不是說可重復讀的隔離級別能夠保證一個事務中多次查詢相同的記錄,結果總是一致的嗎?這種結果并不滿足可重復讀的定義。
事實上,在可重復讀的隔離級別下,如果使用的是當前讀,那么就可能發生幻讀現象。
當前讀和快照讀會在下文中介紹事務的實現原理及 MVCC 時討論,這里先給一個結論。
幻讀會發生在讀未提交、讀已提交、可重復讀的隔離級別中。
這里需要額外注意的是:幻讀和不可重復讀都是說在一個事務中的同一個查詢語句結果不同,但幻讀更側重于查詢到其他事務新插入的數據(insert)或其他事務刪除的數據(delete),而不可重復讀的范圍更廣,只要結果不同就可以認為是不可重復讀,但一般我們認為不可重復讀更側重于其他事務對數據的更新(update)。
4.4 小結
通過上面的描述,我們已經知道四種隔離級別的概念以及它們分別會遇到的問題,事務的隔離級別越高,隔離性就越強,所遇到的問題也就越少。但同時,隔離級別越高,并發能力就越弱。
下表是對隔離級別的概念不同隔離級別會發生的問題情況的小結:
隔離級別 | 臟讀 | 不可重復讀 | 幻讀 | 概念 |
---|---|---|---|---|
讀已提交 | √ | √ | √ | 事務中的修改,即便沒有提交,對其他事務也都是可見的 |
讀未提交 | √ | √ | 事務中的修改只有在提交之后,才會對其他事務可見 | |
可重復讀 | √ | 一個事務中多次查詢相同的記錄,結果總是一致的 | ||
可串行化 | 事務都是串行執行的,讀會加讀鎖,寫會加寫鎖 |
5. MVCC
MVCC(Multi-Version Concurrency Control)即多版本并發控制,這是 MySQL 為了提高數據庫并發性能而實現的。它可以在并發讀寫數據庫時,保證不同事務的讀-寫操作并發執行,同時也能解決臟讀、不可重復讀、幻讀等事務隔離問題。
在前文討論幻讀的時候提到過當前讀的概念,正是由于當前讀,才會在可重復讀的隔離級別下也會發生幻讀的情況。
在解釋可重復讀隔離級別下發生幻讀的原因之前,首先介紹 MVCC 的實現原理。
5.1 MVCC 的實現原理
首先我們需要知道,InnoDB 的數據頁中每一行的數據是有隱藏字段的:
- DB_ROW_ID: 隱式主鍵,若表結構中未定義主鍵,InnoDB 會自動生成該字段作為表的主鍵
- DB_TRX_ID: 事務ID,代表修改此行記錄的最后一次事務ID
- DB_ROLL_PTR: 回滾指針,指向此行記錄的上一個版本(上一個事務ID對應的記錄)
每一條修改語句都會相應地記錄一條回滾語句(undo log),如果把每一條回滾語句視為一條數據表中的記錄,那么通過事務ID和回滾指針就可以將對同一行的修改記錄看作一個鏈表,鏈表上的每一個節點就是一個快照版本,這就是 MVCC 中多版本的意思。
舉個例子,假設對 user 表中唯一的一行「刺猬」進行多次修改。
update user set name='重塑' where id=1;update user set name='木馬' where id=1;update user set name='達達' where id=1;復制代碼
那么這條記錄的版本鏈就是:
在這個版本鏈中,頭結點就是當前記錄的最新版本。DB_TRX_ID 事務ID 字段是非常重要的屬性,先 Mark 一下。
除此之外,在讀已提交(RC,Read Committed)和可重復讀(RR,Repeatable Read)的隔離級別中,事務在啟動的時候會創建一個讀視圖(Read View),用它來記錄當前系統的活躍事務信息,通過讀視圖來進行本事務之間的可見性判斷。
在讀視圖中有兩個重要的屬性:
- 當前事務ID:表示生成讀視圖的事務的事務ID
- 事務ID列表:表示在生成讀視圖時,當前系統中活躍著的事務ID列表
- 最小事務ID:表示在生成讀視圖時,當前系統中活躍著的最小事務ID
- 下一個事務ID:表示在生成讀視圖時,系統應該分配給下一個事務的事務ID
需要注意下一個事務I的值,并不是事務ID列表中的最大值+1,而是當前系統中已存在過的事務的最大值+1。例如當前數據庫中活躍的事務有(1,2),此時事務2提交,同時又開啟了新事務,在生成的讀視圖中,下一個事務ID的值為3。
我們通過將版本鏈與讀視圖兩者結合起來,來進行并發事務間可見性的判斷,判斷規則如下(假設現在要判斷事務A是否可以訪問到事務B的修改記錄):
- 若事務B的當前事務ID小于事務A的最小事務ID的值,代表事務B是在事務A生成讀視圖之前就已經提交了的,所以事務B對于事務A來說是可見的。
- 若事務B的當前事務ID大于或等于事務A下一個事務ID的值,代表事務B是在事務A生成讀視圖之后才開啟,所以事務B對于事務A來說是不可見的。
- 若事務B的當前事務ID在事務A的最小事務ID和下一個事務ID之間(左閉右開,[最小事務ID, 下一個事務ID)),需要分兩種情況討論:
- 若事務B的當前事務ID在事務A的事務ID列表中,代表創建事務A時事務B還是活躍的,未提交,所以事務B對于事務A來說是不可見的。
- 若事務B的當前事務ID不在事務A的事務ID列表中,代表創建事務A時事務B已經提交,所以事務B對于事務A來說是可見的。
如果事務B對于事務A來說是不可見的,就需要順著修改記錄的版本鏈,從回滾指針開始往前遍歷,直到找到第一個對于事務A來說是可見的事務ID,或者遍歷完版本鏈也未找到(表示這條記錄對事務A不可見)。
這就是 MVCC 的實現原理。
5.2 讀視圖的創建時機
這里需要注意的是讀視圖的創建時機,在上面的論述中我們已經知道事務在啟動時會創建一個讀視圖(Read View),而開啟一個事務有兩種方式,一是 begin/start transaction,二是start transaction with consistent snapshot,通過這兩種方式開啟事務,創建讀視圖的時機也是不同的:
- 如果是以 begin/start transaction 方式開啟事務,讀視圖會在執行第一個快照讀語句時創建
- 如果以 start transaction with consistent snapshot 方式開啟事務,同時便會創建讀視圖
5.3 MVCC 的運行過程
為了詳細說明 MVCC 的運行過程,下面舉個例子,假設當前存在有兩個事務(事務隔離級別為 MySQL 默認的可重復讀):
這里需要注意的是事務的啟動時機,在上面的論述中我們已經知道事務在啟動時會創建一個讀視圖(Read View),而開啟一個事務有兩種方式,一是 begin/start transaction,二是start transaction with consistent snapshot,通過這兩種方式開啟事務,創建讀視圖的時機也是不同的:
- 如果是以 begin/start transaction 方式開啟事務,讀視圖會在執行第一個快照讀語句時創建
- 如果以 start transaction with consistent snapshot 方式開啟事務,同時便會創建讀視圖
時刻 | 事務A | 事務B |
---|---|---|
1 | start transaction with consistent snapshot; | |
2 | start transaction with consistent snapshot; | |
3 | update user set name=’重塑’ where id=1; | |
4 | select name from user where id=1;(N1) | |
5 | commit; | |
6 | select name from user where id=1;(N2) | |
7 | commit; |
然后根據上面所描述的版本鏈以及兩個事務開啟時的讀視圖來分析 MVCC 的運行過程。
上圖是兩個事務開啟時的讀視圖,而當事務B的更新語句執行之后,id=1行的版本鏈如下所示。
先來看N1處的查詢語句,事務B的當前事務ID=2,其值等于事務A的下一個事務ID,所以按照上文中所論述的可見性判斷,事務B對于事務A來說是不可見的,需要循著當前行的版本鏈網上檢索。
于是循著版本鏈來到DB_TRX_ID=1事務ID=1的歷史版本,恰巧等于事務A的事務ID值,也就是事務A開啟時該行的版本,此版本對于事務A來說當然是可見的,所以讀取到了id=1行的name=’刺猬’,即最終N1=刺猬。
再來看N2處的查詢語句,此時事務B已提交,版本鏈還是如上圖所示,由于當前版本的事務ID等于事務A讀視圖中的下一個事務ID,所以當前版本的記錄對于事務A來說是不可見的,所以同樣N2=刺猬。
這里需要注意的是,若例子中事務A的時刻4語句變更為對該行的更新語句,那么事務A便會等待事務B提交之后再執行更新語句,這是因為事務B未提交,即事務B對于id=1行的寫鎖未釋放,而事務A也要更新該行,必須是更新當前的最新版本(當前讀)才可以,所以事務A就被阻塞了,必須等待事務B對該行的寫鎖釋放,才會繼續執行更新語句。
5.4 RC 與 RR 生成讀視圖的時機對比
上面所討論的 MVCC 運行過程都是針對可重復讀(RR, Repeatable Read)隔離級別的,如果是讀已提交(RC, Read Committed)級別呢?
上文中已經討論過讀已提交隔離級別中關于不可重復讀的情況了,這里就不再舉例,直接給出結論就可以了。
- 可重復讀(RR, Repeatable Read)隔離級別下生成讀視圖(Read View)的時機是開啟事務的時候
- 讀已提交(RC, Read Committed)隔離級別下生成讀視圖(Read View)的時機是每一條語句執行前
對于上文中描述 MVCC 執行過程中的例子,如果隔離級別是讀已提交(RC, Read Committed):
- N1處的查詢語句,由于事務B還未提交,事務A可見的版本依舊是事務ID=1的版本,所以N1=刺猬
- N2處的查詢語句,事務B已提交,N2處查詢語句執行時也會生成讀視圖,其當前事務ID=3,而在該記錄的版本鏈中,當前版本的事務ID DB_TRX_ID=2,在N2查詢語句事務ID之前,是可見的,所以N2=重塑
5.5 當前讀與快照讀
- 當前讀:讀取記錄的最新版本
- 快照讀:讀取記錄時會根據一定規則讀取事務可見版本的記錄
5.6 可重復讀發生幻讀的原因
在理解了 MVCC 之后,我們再來看在可重復讀隔離級別下發生幻讀的原因。上文中說到正是由于當前讀,才會在可重復讀的隔離級別下發生幻讀的情況,首先來回顧一下例子。
時刻 | 事務A | 事務B |
---|---|---|
1 | begin; | |
2 | select name from user;(N1) | |
3 | begin; | |
4 | insert into user values(2, ‘五條人’); | |
5 | commit; | |
6 | select name from user;(N2) | |
7 | select name from user for update;(N3) | |
8 | commit; |
N1,N2處的查詢想必已經十分明確都是「刺猬」了。而在N3處所使用的查詢語句是for update,使用它進行查詢就會對目標記錄添加一把「行級鎖」,行級鎖的意義以后再說,現在只需要知道for update能夠鎖住目標記錄就可以了。
加鎖自然是防止別人修改,那么理所當然,鎖住的當然也就是記錄的最新版本了。所以,在使用for update進行查詢的時候,會使用當前讀,讀到目標記錄的最新版本,所以在N3處的查詢語句就會把事務B中本對于事務A來說不可見的記錄也查詢出來,也就發生了幻讀。
使用當前讀的語句有:
- select … for update
- select … lock in share mode(共享讀鎖)
- update …
- insert …
- delete …
6. 溫故知新
- 事務的ACID四大特性
- 自動提交與隱式提交(隱式提交的語句)
- 事務的隔離級別
- 事務各個隔離級別可能出現的問題
- MVCC 的實現原理與過程
- RC 與 RR 生成讀視圖的時機
更多相關免費學習推薦:mysql教程(視頻)