淺談MySQL併發控制:隔離級別、鎖與MVCC

前言

如果數據庫中的事務都是串行執行的,這種方式可以保障事務的執行不會出現異常和錯誤,但帶來的問題是串行執行會帶來性能瓶頸;而事務併發執行,如果不加以控制則會引發諸多問題,包括死鎖、更新丟失等等。這就需要我們在性能和安全之間做出合理的權衡,使用適當的併發控制機制保障併發事務的執行。

併發事務帶來的問題

首先我們先來了解一下併發事務會帶來哪些問題。併發事務訪問相同記錄大致可歸納為以下3種情況:

  • 讀-讀:即併發事務相繼讀取同一記錄;
  • 寫-寫:即併發事務相繼對同一記錄做出修改;
  • 寫-讀讀-寫:即兩個併發事務對同一記錄分別進行讀操作和寫操作。
讀-讀

因為讀取記錄並不會對記錄造成任何影響,所以同個事務併發讀取同一記錄也就不存在任何安全問題,所以允許這種操作。

寫-寫

如果允許併發事務都讀取同一記錄,並相繼基於舊值對這一記錄做出修改,那麼就會出現前一個事務所做的修改被後面事務的修改覆蓋,即出現

提交覆蓋的問題。

另外一種情況,併發事務相繼對同一記錄做出修改,其中一個事務提交之後之後另一個事務發生回滾,這樣就會出現已提交的修改因為回滾而丟失的問題,即回滾覆蓋問題。

這兩種問題都造成丟失更新,其中回滾覆蓋稱為第一類丟失更新問題,提交覆蓋稱為第二類丟失更新問題。

寫-讀讀-寫

這種情況較為複雜,也最容易出現問題。

如果一個事務讀取了另一個事務尚未提交的修改記錄,那麼就出現了髒讀的問題;

如果我們加以控制使得一個事務只能讀取其他已提交事務的修改的數據,那麼這個事務在另一事物提交修改前後讀取到的數據是不一樣的,這就意味著發生了

不可重複讀

如果一個事務根據一些條件查詢到一些記錄,之後另一事物向表中插入了一些記錄,原先的事務以相同條件再次查詢時發現得到的結果跟第一次查詢得到的結果不一致,這就意味著發生了幻讀

事務的隔離級別

對於以上提到的併發事務執行過程中可能出現的問題,其嚴重性也是不一樣的,我們可以按照問題的嚴重程度排個序:

<code>丟失更新 > 髒讀 > 不可重複讀 > 幻讀/<code>

因此如果我們可以容忍一些嚴重程度較輕的問題,我們就能獲取一些性能上的提升。於是便有了事務的四種隔離級別:

  • 讀未提交(Read Uncommitted):允許讀取未提交的記錄,會發生髒讀、不可重複讀、幻讀;
  • 讀已提交(Read Committed):只允許讀物已提交的記錄,不會發生髒讀,但會出現重複讀、幻讀;
  • 可重複讀(Repeatable Read):不會發生髒讀和不可重複讀的問題,但會發生幻讀問題;但MySQL在此隔離級別下利用
    MVCC或者間隙鎖可以禁止幻讀問題的發生;
  • 可串行化(Serializable):即事務串行執行,以上各種問題自然也就都不會發生。

值得注意的是以上四種隔離級別都不會出現回滾覆蓋的問題,但是提交覆蓋的問題對於MySQL來說,在Read Uncommitted、Read Committed以及Repeatable Read這三種隔離級別下都會發生(標準的Repeatable Read隔離級別不允許出現提交覆蓋的問題),需要額外加鎖來避免此問題。

隔離級別的實現

SQL規範定義了以上四種隔離級別,但是並沒有給出如何實現四種隔離級別,因此不同數據庫的實現方式和使用方式也並不相同。而SQL隔離級別的標準是依據基於鎖的實現方式來制定的,因為有必要先了解一下傳統的基於鎖的隔離級別是如何實現的。

傳統隔離級別的實現

既然說到傳統的隔離級別是基於鎖實現的,我們先來了解一下鎖。

傳統的鎖有兩種:

  • 共享鎖(Shared Locks):簡稱S鎖,事務對一條記錄進行讀操作時,需要先獲取該記錄的共享鎖。
  • 排他鎖(Exclusive Locks):簡稱X鎖,事務對一條記錄進行寫操作時,需要先獲取該記錄的排他鎖。

需要注意的是,加了共享鎖的記錄,其他事務也可以獲得該記錄的共享鎖,但是無法獲取該記錄的排他鎖,即S鎖和S鎖是兼容的,S鎖和X鎖是不兼容的;而加了排他鎖的記錄,其他事務既無法獲取該記錄的共享鎖也無法獲取排他鎖,即X鎖和X鎖也是不兼容的。

另外,剛剛說到事務對一條記錄進行讀操作時,需要先獲取該記錄的S鎖,但有時事務在讀取記錄時需要阻止其他事務訪問該記錄,這時就需要獲取該記錄的X鎖。以MySQL為例,有以下兩種鎖定讀的方式:

  • 讀取時對記錄加S鎖:
<code>SELECT ... LOCK IN SHARE MODE;/<code>

如果事務執行了該語句,則會在讀取的記錄上加S鎖,這樣就允許其他事務也能獲取到該記錄的S鎖;而如果其他事務需要獲取該記錄的X鎖,那麼就需要等待當前事務提交後釋放掉S鎖。

  • 讀取時對記錄加X鎖:
<code>SELECT ... FOR UPDATE;/<code>

如果事務執行了該語句,則會在讀取的記錄上加X鎖,這樣其他事務想要說去該記錄的S鎖或X鎖,那麼需要等待當前事務提交後釋放掉X鎖。

對於鎖的粒度而言,鎖又可以分為兩種:

  • 行鎖:只鎖住某一行記錄,其他行的記錄不受影響。
  • 表鎖:鎖住整個表,所有對於該表的操作都會受影響。
基於鎖實現隔離級別

在基於鎖的實現方式下,四種隔離級別的區別就在於加鎖方式的區別:

  • 讀未提交:讀操作不加鎖,讀讀,讀寫,寫讀並行;寫操作加X鎖且直到事務提交後才釋放。
  • 讀已提交:讀操作加S鎖,寫操作加X鎖且直到事務提交後才釋放;讀操作不會阻塞其他事務讀或寫,寫操作會阻塞其他事務寫和讀,因此可以防止髒讀問題。
  • 可重複讀:讀操作加S鎖且直到事務提交後才釋放,寫操作加X鎖且直到事務提交後才釋放;讀操作不會阻塞其他事務讀但會阻塞其他事務寫,寫操作會阻塞其他事務讀和寫,因此可以防止髒讀、不可重複讀。
  • 串行化:讀操作和寫操作都加X鎖且直到事務提交後才釋放,粒度為表鎖,也就是嚴格串行。

這裡面有一些細節值得注意:

  • 如果鎖獲取之後直到事務提交後才釋放,這種鎖稱為長鎖;如果鎖在操作完成之後就被釋放,這種鎖稱為短鎖。例如,在讀已提交隔離級別下,讀操作所加S鎖為短鎖,寫操作所加X鎖為長鎖。
  • 對於可重複讀和串行化隔離級別,讀操作所加S鎖和寫操作所加X鎖均為長鎖,即事務獲取鎖之後直到事務提交後才能釋放,這種把獲取鎖和釋放鎖分為兩個不同的階段的協議稱為
    兩階段鎖協議(2-phase locking)。兩階段鎖協議規定在加鎖階段,一個事務可以獲得鎖但是不能釋放鎖;而在解鎖階段事務只可以釋放鎖,並不能獲得新的鎖。兩階段鎖協議能夠保證事務串行化執行,解決事務併發問題,但也會導致死鎖發生的概率大大提升。

MySQL隔離級別的實現

不同數據庫對於SQL標準中規定的隔離級別支持是不一樣的,數據庫引擎實現隔離級別的方式雖然都在儘可能地貼近標準的隔離級別規範,但和標準的預期還是有些不一樣的地方。

MySQL(InnoDB)支持的4種隔離級別,與標準的各級隔離級別允許出現的問題有些出入,比如MySQL在可重複讀隔離級別下可以防止幻讀的問題出現,但也會出現提交覆蓋的問題。

相對於傳統隔離級別基於鎖的實現方式,MySQL 是通過MVCC(多版本併發控制)來實現讀-寫併發控制,又是通過兩階段鎖來實現寫-寫併發控制的。MVCC是一種無鎖方案,用以解決事務讀-寫併發的問題,能夠極大提升讀-寫併發操作的性能。

MVCC的實現原理

為了方便描述,首先我們創建一個表book,就三個字段,分別是主鍵book_id, 名稱book_name, 庫存stock。然後向表中插入一些數據:

<code>INSERT INTO book VALUES(1, '數據結構', 100);
INSERT INTO book VALUES(2, 'C++指南', 100);
INSERT INTO book VALUES(3, '精通Java', 100);/<code>
版本鏈

對於使用InnoDB存儲引擎的表,其聚簇索引記錄中包含了兩個重要的隱藏列:

  • 事務ID(DB_TRX_ID):每當事務對聚簇索引中的記錄進行修改時,都會把當前事務的事務id記錄到DB_TRX_ID中。
  • 回滾指針(DB_ROLL_PTR):每當事務對聚簇索引中的記錄進行修改時,都會把該記錄的舊版本記錄到undo日誌中,通過DB_ROLL_PTR這個指針可以用來獲取該記錄舊版本的信息。

如果在一個事務中多次對記錄進行修改,則每次修改都會生成undo日誌,並且這些undo日誌通過DB_ROLL_PTR指針串聯成一個版本鏈,版本鏈的頭結點是該記錄最新的值,尾結點是事務開始時的初始值。

例如,我們在表book中做以下修改:

<code>BEGIN;

UPDATE book SET stock = 200 WHERE id = 1;

UPDATE book SET stock = 300 WHERE id = 1;/<code>

那麼id=1的記錄此時的版本鏈就如下圖所示:

淺談MySQL併發控制:隔離級別、鎖與MVCC

ReadView

對於使用Read Uncommitted隔離級別的事務來說,只需要讀取版本鏈上最新版本的記錄即可;對於使用Serializable隔離級別的事務來說,InnoDB使用加鎖的方式來訪問記錄。而Read Committed和Repeatable Read隔離級別來說,都需要讀取已經提交的事務所修改的記錄,也就是說如果版本鏈中某個版本的修改沒有提交,那麼該版本的記錄時不能被讀取的。所以需要確定在Read Committed和Repeatable Read隔離級別下,版本鏈中哪個版本是能被當前事務讀取的。於是ReadView的概念被提出以解決這個問題。

ReadView相當於某個時刻表記錄的一個快照,在這個快照中我們能獲取到與當前記錄相關的事務中,哪些事務是已提交的穩定事務,哪些是正在活躍的事務,哪些是生成快照之後才開啟的事務。由此我們就能根據可見性比較算法判斷出版本鏈中能被讀取的最新版本記錄。

可見性比較算法是基於事務ID的比較算法。首先我們需要知道的一個事實是:事務id是遞增分配的。從ReadView中我們能獲取到生成快照時刻系統中活躍的事務中最小和最大的事務id(最大的事務id實際上是系統中將要分配給下一個事務的id值),這樣我們就得到了一個活躍事務id的範圍,我們可稱之為ACTIVE_TRX_ID_RANGE。那麼小於這個範圍的事務id對應的事務都是已提交的穩定事務,大於這個範圍的事務都是在快照生成之後才開啟的事務,而在ACTIVE_TRX_ID_RANGE範圍內的事務中除了正在活躍的事務,也都是已提交的穩定事務。

有了以上信息之後,我們順著版本鏈從頭結點開始查找最新的可被讀取的版本記錄:

1、首先判斷版本記錄的DB_TRX_ID字段與生成ReadView的事務對應的事務ID是否相等。如果相等,那就說明該版本的記錄是在當前事務中生成的,自然也就能夠被當前事務讀取;否則進行第2步。

2、如果版本記錄的DB_TRX_ID字段小於範圍ACTIVE_TRX_ID_RANGE,表明該版本記錄是已提交事務修改的記錄,即對當前事務可見;否則進行下一步。

3、如果版本記錄的DB_TRX_ID字段位於範圍ACTIVE_TRX_ID_RANGE內,如果該事務ID對應的不是活躍事務,表明該版本記錄是已提交事務修改的記錄,即對當前事務可見;如果該事務ID對應的是活躍事務,那麼對當前事務不可見,則讀取版本鏈中下一個版本記錄,重複以上步驟,直到找到對當前事務可見的版本。

如果某個版本記錄經過以上步驟判斷確定其對當前事務可見,則查詢結果返回此版本記錄;否則讀取下一個版本記錄繼續按照上述步驟進行判斷,直到版本鏈的尾結點。如果遍歷完版本鏈沒有找到對當前事務可見的版本,則查詢結果為空。

在MySQL中,Read Committed和Repeatable Read隔離級別下的區別就是它們生成ReadView的時機不同。

MVCC實現不同隔離級別

之前說到ReadView的機制只在Read Committed和Repeatable Read隔離級別下生效,所以只有這兩種隔離級別才有MVCC。在Read Committed隔離級別下,每次讀取數據時都會生成ReadView;而在Repeatable Read隔離級別下只會在事務首次讀取數據時生成ReadView,之後的讀操作都會沿用此ReadView。

下面我們通過例子來看看Read Committed和Repeatable Read隔離級別下MVCC的不同表現。我們繼續以表book為例進行演示。

Read Committed隔離級別分析

假設在Read Committed隔離級別下,有如下事務在執行,事務id為10:

<code>BEGIN; // 開啟Transaction 10

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;/<code>

此時該事務尚未提交,id為2的記錄版本鏈如下圖所示:

淺談MySQL併發控制:隔離級別、鎖與MVCC

然後我們開啟一個事務對id為2的記錄進行查詢:

<code>BEGIN;

SELECT * FROM book WHERE id = 2;/<code>

當執行SELECT語句時會生成一個ReadView,該ReadView中的ACTIVE_TRX_ID_RANGE為[10, 11),當前事務IDcreator_trx_id為0(因為事務中當執行寫操作時才會分配一個單獨的事務id,否則事務id為0)。按照我們之前所述ReadView的工作原理,我們查詢到的版本記錄為

<code>+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    |  100  |
+----------+-----------+-------+/<code>

然後我們將事務id為10的事務提交:

<code>BEGIN; // 開啟Transaction 10

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;

COMMIT;/<code>

同時開啟執行另一事務id為11的事務,但不提交:

<code>BEGIN; // 開啟Transaction 11

UPDATE book SET stock = 400 WHERE id = 2;/<code>

此時id為2的記錄版本鏈如下圖所示:

淺談MySQL併發控制:隔離級別、鎖與MVCC

然後我們回到剛才的查詢事務中再次查詢id為2的記錄:

<code>BEGIN;

SELECT * FROM book WHERE id = 2; // 此時Transaction 10 未提交

SELECT * FROM book WHERE id = 2; // 此時Transaction 10 已提交/<code>

當第二次執行SELECT語句時會再次生成一個ReadView,該ReadView中的ACTIVE_TRX_ID_RANGE為[11, 12),當前事務IDcreator_trx_id依然為0。按照ReadView的工作原理進行分析,我們查詢到的版本記錄為

<code>+----------+-----------+-------+
| book_id  | book_name | stock |

+----------+-----------+-------+
| 2        | C++指南    | 300   |
+----------+-----------+-------+/<code>

從上述分析可以發現,因為每次執行查詢語句都會生成新的ReadView,所以在Read Committed隔離級別下的事務讀取到的是查詢時刻表中已提交事務修改之後的數據。

Repeatable Read隔離級別分析

我們在Repeatable Read隔離級別下重複上面的事務操作:

<code>BEGIN; // 開啟Transaction 20

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;/<code>

此時該事務尚未提交,然後我們開啟一個事務對id為2的記錄進行查詢:

<code>BEGIN;

SELECT * FROM book WHERE id = 2;/<code>

當事務第一次執行SELECT語句時會生成一個ReadView,該ReadView中的ACTIVE_TRX_ID_RANGE為[10, 11),當前事務IDcreator_trx_id為0。根據ReadView的工作原理,我們查詢到的版本記錄為

<code>+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    |  100  |
+----------+-----------+-------+/<code>

然後我們將事務id為20的事務提交:

<code>BEGIN; // 開啟Transaction 20

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;

COMMIT;/<code>

同時開啟執行另一事務id為21的事務,但不提交:

<code>BEGIN; // 開啟Transaction 21

UPDATE book SET stock = 400 WHERE id = 2;/<code>

然後我們回到剛才的查詢事務中再次查詢id為2的記錄:

<code>BEGIN;

SELECT * FROM book WHERE id = 2; // 此時Transaction 10 未提交

SELECT * FROM book WHERE id = 2; // 此時Transaction 10 已提交/<code>

當第二次執行SELECT語句時不會生成新的ReadView,依然會使用第一次查詢時生成ReadView。因此我們查詢到的版本記錄跟第一次查詢到的結果是一樣的:

<code>+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    |  100  |
+----------+-----------+-------+/<code>

從上述分析可以發現,因為在Repeatable Read隔離級別下的事務只會在第一次執行查詢時生成ReadView,該事務中後續的查詢操作都會沿用這個ReadView,因此此隔離級別下一個事務中多次執行同樣的查詢,其結果都是一樣的,這樣就實現了可重複讀。

快照讀和當前讀
快照讀

在Read Committed和Repeatable Read隔離級別下,普通的SELECT查詢都是讀取MVCC版本鏈中的一個版本,相當於讀取一個快照,因此稱為快照讀。這種讀取方式不會加鎖,因此讀操作時非阻塞的,因此也叫非阻塞讀

在標準的Repeatable Read隔離級別下讀操作會加S鎖,直到事務結束,因此可以阻止其他事務的寫操作;但在MySQL的Repeatable Read隔離級別下讀操作沒有加鎖,不會阻止其他事務對相同記錄的寫操作,因此在後續進行寫操作時就有可能寫入基於版本鏈中的舊數據計算得到的結果,這就導致了提交覆蓋的問題。想要避免此問題,就需要另外加鎖來實現。

當前讀

之前提到MySQL有兩種鎖定讀的方式:

<code>SELECT ... LOCK IN SHARE MODE; // 讀取時對記錄加S鎖,直到事務結束

SELECT ... FOR UPDATE; // 讀取時對記錄加X鎖,直到事務結束/<code>

這種讀取方式讀取的是記錄的當前最新版本,稱為

當前讀。另外對於DELETE、UPDATE操作,也是需要先讀取記錄,獲取記錄的X鎖,這個過程也是一個當前讀。由於需要對記錄進行加鎖,會阻塞其他事務的寫操作,因此也叫加鎖讀阻塞讀

當前讀不僅會對當前記錄加行記錄鎖,還會對查詢範圍空間的數據加間隙鎖(GAP LOCK),因此可以阻止幻讀問題的出現。

總結

本文介紹了事務的多種併發問題,以及用以避免不同程度問題的隔離級別,並較為詳細描述了傳統隔離級別的實現方式以及MySQL隔離級別的實現方式。但數據庫的併發機制較為複雜,本文也只是做了大致的描述和介紹,很多細節還需要讀者自己查詢相關資料進行更細緻的瞭解。


分享到:


相關文章: