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

前言

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

併發事務帶來的問題

首先咱們先來了解一下併發事務會帶來哪些問題。併發事務訪問相同記錄大體可概括爲如下3種狀況:web

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

由於讀取記錄並不會對記錄形成任何影響,因此同個事務併發讀取同一記錄也就不存在任何安全問題,因此容許這種操做。算法

寫-寫

若是容許併發事務都讀取同一記錄,並相繼基於舊值對這一記錄作出修改,那麼就會出現前一個事務所作的修改被後面事務的修改覆蓋,即出現提交覆蓋的問題。sql

另一種狀況,併發事務相繼對同一記錄作出修改,其中一個事務提交以後以後另外一個事務發生回滾,這樣就會出現已提交的修改由於回滾而丟失的問題,即回滾覆蓋問題。數據庫

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

寫-讀讀-寫

這種狀況較爲複雜,也最容易出現問題。安全

若是一個事務讀取了另外一個事務還沒有提交的修改記錄,那麼就出現了髒讀的問題;ruby

若是咱們加以控制使得一個事務只能讀取其餘已提交事務的修改的數據,那麼這個事務在另外一事物提交修改先後讀取到的數據是不同的,這就意味着發生了不可重複讀數據結構

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

事務的隔離級別

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

丟失更新 > 髒讀 > 不可重複讀 > 幻讀
複製代碼

所以若是咱們能夠容忍一些嚴重程度較輕的問題,咱們就能獲取一些性能上的提高。因而便有了事務的四種隔離級別:

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

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

隔離級別的實現

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

傳統隔離級別的實現

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

傳統的鎖有兩種:

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

須要注意的是,加了共享鎖的記錄,其餘事務也能夠得到該記錄的共享鎖,可是沒法獲取該記錄的排他鎖,即S鎖S鎖是兼容的,S鎖X鎖是不兼容的;而加了排他鎖的記錄,其餘事務既沒法獲取該記錄的共享鎖也沒法獲取排他鎖,即X鎖X鎖也是不兼容的。

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

  • 讀取時對記錄加S鎖
SELECT ... LOCK IN SHARE MODE;
複製代碼

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

  • 讀取時對記錄加X鎖
SELECT ... FOR UPDATE;
複製代碼

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

對於鎖的粒度而言,鎖又能夠分爲兩種:

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

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

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

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

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

MySQL隔離級別的實現

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

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

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

MVCC的實現原理

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

INSERT INTO book VALUES(1'數據結構'100);
INSERT INTO book VALUES(2'C++指南'100);
INSERT INTO book VALUES(3'精通Java'100);
複製代碼
版本鏈

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

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

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

例如,咱們在表book中作如下修改:

BEGIN;

UPDATE book SET stock = 200 WHERE id = 1;

UPDATE book SET stock = 300 WHERE id = 1;
複製代碼

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

ReadView

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

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

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

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

一、首先判斷版本記錄的DB_TRX_ID字段與生成ReadView的事務對應的事務ID是否相等。若是相等,那就說明該版本的記錄是在當前事務中生成的,天然也就可以被當前事務讀取;不然進行第2步。

二、若是版本記錄的DB_TRX_ID字段小於範圍ACTIVE_TRX_ID_RANGE,代表該版本記錄是已提交事務修改的記錄,即對當前事務可見;不然進行下一步。

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

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

MySQL中,Read CommittedRepeatable Read隔離級別下的區別就是它們生成ReadView的時機不一樣。

MVCC實現不一樣隔離級別

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

下面咱們經過例子來看看Read CommittedRepeatable Read隔離級別下MVCC的不一樣表現。咱們繼續以表book爲例進行演示。

Read Committed隔離級別分析

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

BEGIN; // 開啓Transaction 10

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;
複製代碼

此時該事務還沒有提交,id爲2的記錄版本鏈以下圖所示:

而後咱們開啓一個事務對id爲2的記錄進行查詢:

BEGIN;

SELECT * FROM book WHERE id = 2;
複製代碼

當執行SELECT語句時會生成一個ReadView,該ReadView中的ACTIVE_TRX_ID_RANGE[10, 11),當前事務IDcreator_trx_id0(由於事務中當執行寫操做時纔會分配一個單獨的事務id,不然事務id0)。按照咱們以前所述ReadView的工做原理,咱們查詢到的版本記錄爲

+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    |  100  |
+----------+-----------+-------+
複製代碼

而後咱們將事務id爲10的事務提交:

BEGIN; // 開啓Transaction 10

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;

COMMIT;
複製代碼

同時開啓執行另外一事務id11的事務,但不提交:

BEGIN; // 開啓Transaction 11

UPDATE book SET stock = 400 WHERE id = 2;
複製代碼

此時id爲2的記錄版本鏈以下圖所示:

而後咱們回到剛纔的查詢事務中再次查詢id爲2的記錄:

BEGIN;

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

SELECT * FROM book WHERE id = 2; // 此時Transaction 10 已提交
複製代碼

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

+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    | 300   |
+----------+-----------+-------+
複製代碼

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

Repeatable Read隔離級別分析

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

BEGIN; // 開啓Transaction 20

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;
複製代碼

此時該事務還沒有提交,而後咱們開啓一個事務對id爲2的記錄進行查詢:

BEGIN;

SELECT * FROM book WHERE id = 2;
複製代碼

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

+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    |  100  |
+----------+-----------+-------+
複製代碼

而後咱們將事務id爲20的事務提交:

BEGIN; // 開啓Transaction 20

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;

COMMIT;
複製代碼

同時開啓執行另外一事務id爲21的事務,但不提交:

BEGIN; // 開啓Transaction 21

UPDATE book SET stock = 400 WHERE id = 2;
複製代碼

而後咱們回到剛纔的查詢事務中再次查詢id爲2的記錄:

BEGIN;

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

SELECT * FROM book WHERE id = 2; // 此時Transaction 10 已提交
複製代碼

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

+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    |  100  |
+----------+-----------+-------+
複製代碼

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

快照讀和當前讀
快照讀

Read CommittedRepeatable Read隔離級別下,普通的SELECT查詢都是讀取MVCC版本鏈中的一個版本,至關於讀取一個快照,所以稱爲快照讀。這種讀取方式不會加鎖,所以讀操做時非阻塞的,所以也叫非阻塞讀

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

當前讀

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

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

SELECT ... FOR UPDATE; // 讀取時對記錄加X鎖,直到事務結束
複製代碼

這種讀取方式讀取的是記錄的當前最新版本,稱爲當前讀。另外對於DELETEUPDATE操做,也是須要先讀取記錄,獲取記錄的X鎖,這個過程也是一個當前讀。因爲須要對記錄進行加鎖,會阻塞其餘事務的寫操做,所以也叫加鎖讀阻塞讀

當前讀不只會對當前記錄加行記錄鎖,還會對查詢範圍空間的數據加間隙鎖GAP LOCK),所以能夠阻止幻讀問題的出現。

總結

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

參考資料

一、MySQL-InnoDB-MVCC多版本併發控制

二、MySQL 是怎樣運行的:從根兒上理解 MySQL

相關文章
相關標籤/搜索