Mysql事務 101


1 為什么需要事務

在網上的很多資料里,其實沒有很好的解釋為什么我們需要事務。其實我們去學習一個東西之前,還是應該了解清楚這個東西為什么有用,硬生生的去記住事務的ACID特性、各種隔離級別個人認為沒有太大意義。設想一下,如果沒有事務,可能會遇到什么問題,假設你要對x和y兩個值進行修改,在修改x完成之后,由於硬件、軟件或者網絡問題,修改y失敗,這時候就出現了“部分失敗”的現象,x修改成功,y修改失敗,這個時候需要你自己在應用代碼里去處理,你可以重試修改y,也可以把x設置成之前的值(回滾),不管你怎么做,這些由於底層系統的各種錯誤導致的問題,都需要你自己寫應用代碼去處理,而如果有了事務,你完全沒必要關心這些底層的問題,只要提交成功了,所有的修改都是成功的,如果有失敗的,事務會自動回滾回之前的狀態;另外,在並發修改的場景中,如果沒有事務,你需要自己去實現各種加鎖的邏輯,繁瑣而且容易出錯,而如果有了事務,你可以通過選擇事務的一個隔離級別,來假裝某些並發問題不會出現,因為數據庫已經幫你處理好了。總之,事務是數據庫為我們提供的一層抽象,讓我們假裝底層的故障和某些並發問題並不存在,從而更加舒服的編寫業務代碼

2 什么是事務

眾所周知,事務有着ACID屬性,分別是原子性(atomicity),一致性(consistency),隔離性(isolation)和持久性(durability),我們分別展開說一下

2.1 原子性

首先可能有人會混淆這里的原子性和多線程編程中的原子操作,多線程編程中的原子操作是指如果一個線程做了一個原子操作,其他線程無法看到這個操作的中間狀態,這是一個有關並發的概念。而事務的原子性指的是一個事務中的多個操作,要么全部成功,要么全部失敗,不會出現部分成功、部分失敗的情況。

2.2 一致性

如果說原子性是一個有點容易混淆的概念,一致性這個概念就更加模糊了,可能很多人看到這個詞都不知道他在說啥。一致性是什么,我們看一下《數據庫系統概論》這本書給的定義:

(一致性是指)事務執行的結果必須是使數據庫從一個一致性狀態變到另一個一致性狀態

那什么叫一致性狀態,其實就是你對數據庫里的數據有一些約束,例如主鍵必須是唯一的、外鍵約束這些(還記得數據庫的完整性約束嗎),當然,更多的是業務層面的一致性約束,比如在轉賬場景中,我們要求事務執行后所有人的錢總和沒有改變。數據庫可以幫我們保證外鍵約束等數據庫層面的一致性,但是對我們業務層面的一致性是一無所知的,比如你可以給每個人的錢都加100塊,數據庫並不會阻止你,這時你就輕松的違反了業務層面的一致性。所以我們可以發現,一致性對我們來說是一個有點無關痛癢的屬性,我們其實是通過事務提供的原子性和隔離性來保證事務的一致性,甚至你可以認為一致性不屬於事務的屬性,也有人說一致性之所以存在,只是為了讓ACID這個縮略詞更加順口而已

2.3 隔離性

如果多個事務同時操作了相同的數據,那么就會有並發的問題出現,比如說多個事務同時給一個計數器(counter)加1,假設counter初始值為0,那么可能會出現這樣的情況:

時間 事務A 事務B
T1 讀計數器
counter = 0
T2 讀計數器
counter = 0
T3 寫計數器
counter = 1
T4 寫計數器
couter = 1

我們做了兩次加1操作,結果本應是2,但是最終可能會是1。當然,還會有其他的並發問題,隔離性就是為了屏蔽掉一些並發問題的處理,讓我們編寫應用代碼更加簡單。我們再來看一下《數據庫系統概論》給隔離性的定義:

一個事務的執行不能被其他事務干擾。即一個事務的內部操作及使用的數據對其他並發事務是隔離的,並發執行的各個事務之間不能相互干擾

課本上的定義是根據“可串行化”這個隔離級別來表述隔離性,就是說你可以認為事務之間完全隔離,就好像並發的事務是順序執行的。但是,我們實際用的時候,為了更好的並發性能,基本不會把事務完全隔離,所以就有了隔離級別的概念,sql 92標准定義了四種隔離級別:未提交讀、提交讀、可重復讀、可串行化,大家一般會使用較弱的隔離級別,例如“可重復讀”。關於各種隔離級別,我們放到第三部分和第四部分再說

2.4 持久性

持久性是指一旦事務提交,即使系統崩潰了,事務執行的結果也不會丟失。為了實現持久性,數據庫會把數據寫入非易失存儲,比如磁盤。當然持久性也是有個度的,例如假設保存數據的磁盤都壞了,那持久性顯然無法保證

3 並發問題

首先,事務既然提供了隔離級別的抽象,那么含義就是在使用的時候,不需要自己去加鎖處理某一類的並發問題,所以很多資料在通過自己手動加鎖做了一些實驗之后,就得出Mysql的可重復讀隔離級別能夠防止丟失更新、幻讀等結論顯然是不正確的,至於Mysql能提供什么保證,我們放到第五部分再說

我們前面提到,為了更好的並發性能,我們搞出了各種弱隔離級別,那么隔離級別是怎么定義的呢?隔離級別是通過可能遇到的並發問題(異象)來定義的,選定一個隔離級別后,就不會出現某一類並發問題,那么我們就來看看會有哪些並發問題,在每一小節,我們會先講講這個並發問題是什么,然后討論阻止他的隔離級別,最后說說實現這個隔離級別的方法,這里我們只討論加鎖的實現,其他實現我們放到第四章來講,所以我們先簡單說一下鎖

  • S鎖和X鎖大家應該都很熟悉,S鎖即共享鎖,X鎖即互斥鎖
  • 根據鎖持有的時間,我們把鎖分為Short Duration Lock和Long Duration Lock,本文就簡稱為短鎖和長鎖,短鎖即語句執行前加鎖,執行完成后就釋放;長鎖則是語句執行前加鎖,而到事務提交后才釋放
  • 另外根據鎖的作用對象,我們把鎖分為記錄鎖(record lock)和謂詞鎖(predicate lock),謂詞鎖顧名思義鎖住了一個謂詞,而不是具體的數據記錄,比如select * from table where id > 10,如果加謂詞鎖,就鎖住了10到無限大這個范圍,不管表里是否真的存在大於10的記錄

3.1 臟寫(Dirty Write)

一個事務對數據進行寫操作之后,還沒有提交,被另一個事務對相同數據的寫操作覆蓋(你可能會看到有的資料稱之為“第一類丟失更新”)

舉個例子:(x初始值為0)

時間 事務A 事務B
T1 begin begin
T2 寫x
x = 1
T3 寫x
x = 2
T4 commit
T5 commit

這里事務A將x寫為1之后還沒有提交,就被事務B覆蓋為2。

3.1.1 問題

臟寫會導致什么問題呢?

第一個問題是無法回滾,假設在T4時刻事務A要回滾,這個時候x的值已經變成了2,如果把x回滾為事務A修改之前的值,也就是0,那么事務B的修改就丟失了;如果不回滾,那么當T5時刻,事務B也要回滾時,你還是不能回滾x的值,因為事務B修改之前,x的值是1,由於事務A回滾,這個值已經變成了臟數據。這就導致事務沒辦法回滾,影響了事務的原子性

第二個問題是影響一致性,假如說我們同時對x和y進行修改,要求x和y始終是相等的,看下面的例子

初始值 x=y=1

時間 事務A 事務B
T1 begin begin
T2 寫x
x = 2
T3 寫x
x = 3
T4 寫y
y = 3
T5 寫y
y = 2
T6 commit commit

可以看到,最終x變成了3,y變成了2,違反了一致性

3.1.2 隔離級別

由於臟寫導致不能回滾,嚴重影響原子性,所以不管是什么隔離級別,都要阻止這種問題,因此可以認為最弱的隔離級別“未提交讀”需要阻止臟寫

3.1.3 實現

那怎么防止臟寫呢,很簡單,就是加鎖,一般會在更新之前加行級鎖(X鎖),那什么時候釋放鎖呢,更新操作執行完之后釋放鎖顯然不行,必須等到事務提交之后再釋放鎖,這樣才不會出現臟寫的情況,即寫操作加長鎖(X鎖)

3.2 臟讀(Dirty Read)

一個事務對數據進行寫操作之后,還沒有提交,被另一個事務讀取

3.2.1 問題

臟讀會導致什么問題呢?我們看兩個例子

第一個:

x初始值為100

時間 事務A 事務B
T1 begin begin
T2 寫x
x = 200
T3 讀x
x = 200
T4 rollback ...

可以看到由於事務A回滾,事務B讀到的x值變成了臟數據

那如果事務A不回滾,事務B讀到的不就不是臟數據了嗎?

其實同樣可能有問題,我們看第二個例子:

假設x=50 y=50,x要給y轉賬40,那我們的一致性要求就是x+y在事務執行后仍然為100

時間 事務A 事務B
T1 begin begin
T2 寫x
x = 10
T3 讀x
x = 10
T4 讀y
y = 50
T5 commit
T6 寫y
y = 90
T7 commit

可以看到事務B讀到的結果是x+y=60,違反了我們要求的一致性

3.1.2 隔離級別

SQL-92定義了“提交讀”隔離級別來阻止臟讀,不過SQL-92只提到了第一種情況,而其實不管有沒有回滾,只要讀到了其他事務未提交的數據,都應該認為是臟讀,都可能會出現問題

3.1.3 實現

提交讀如何實現呢?我們為了防止臟寫,已經對寫操作加了長鎖,那么在此基礎上,只要給讀操作加短鎖(S鎖)就能解決臟讀的問題,即讀之前申請鎖,讀完后立即釋放,注意,這里不僅要給數據記錄加短鎖,還要加謂詞鎖,為什么呢,試想假如只加記錄鎖,如果我們做了一個范圍查詢,而在查詢過程中,正好另外一個事務在這個范圍插入了一條數據,我們的范圍查詢仍然能夠讀到,即讀到了其他事務未提交的數據,因此還需要加謂詞鎖(短鎖,S鎖)。總之,實現提交讀,需要寫操作加長鎖,讀操作加短鎖(記錄鎖和謂詞鎖)

3.3 不可重復讀

不可重復讀(Non-Repeatable Read)也叫Fuzzy Read,指一個事務對數據進行讀操作后,該數據被另一個事務修改,再次讀取數據和原來不一致(其實不讀第二次也可能會有問題)

3.3.1 問題

我們還是看兩個例子:

第一個:

x初始值為1

時間 事務A 事務B
T1 begin begin
T2 讀x
x = 1
T3 寫x
x = 2
T4 commit
T5 讀x
x = 2
T6 commit

可以看到事務A第二次讀取x的值發生了變化,影響了一致性

那么如果我沒有對相同數據做第二次讀取呢?

我們看第二個例子:

x初始值為50,y初始值為50,x給y轉賬40,我們的一致性要求時事務執行后x和y的總和不變

時間 事務A 事務B
T1 begin begin
T2 讀x
x = 50
T3 寫x
x = 10
T4 寫y
y = 90
T5 commit;
T6 讀y
y = 90
T7 commit

可以發現,事務A沒有對任何數據讀第二次,但是在事務A看來,x+y=140,而不是100,違反了一致性

3.3.2 隔離級別

SQL-92定義了“可重復讀”隔離級別來阻止不可重復讀的問題,但是它只提到了第一種情況,但是從第二個例子我們可以發現,不管有沒有做第二次讀取,其實都可能會有問題,因此要想阻止不可重復讀,事務讀完數據后,就要阻止其他事務對該數據的寫操作

3.3.3 實現

在“提交讀”中,我們已經對寫操作加了長鎖,對讀操作加了短鎖(記錄鎖,謂詞鎖),為了阻止不可重復讀的問題,需要給讀操作中的記錄鎖也加長鎖(S鎖),因此“可重復讀”隔離級別的實現就是讀操作中記錄鎖加長鎖(S鎖),謂詞鎖加短鎖(S鎖),寫操作加長鎖(X鎖)

這里在記錄鎖的角度來看,我們其實已經在做兩階段鎖(Two Phase Locking: 2PL)了。我們簡單討論一下兩階段鎖:

顧名思義,兩階段鎖一定有兩個階段:

  • Expanding phase(也叫Growing phase),即加鎖階段,這個階段可以加鎖,但是不能釋放鎖
  • Shrinking phase(也叫Contracting phase),即解鎖階段,這個階段可以解鎖,但是不能再加鎖了

兩階段鎖有幾種變體,比較常見的就是兩種:

  • 保守兩階段鎖(Conservative two-phase locking),就是在開始之前一次性把要加的鎖加上,也就是一些資料說的“一次封鎖法”,可以防止死鎖
  • 嚴格兩階段鎖(Strict two-phase locking),X鎖在提交之后才能釋放,S鎖可以在解鎖階段釋放

我們這里,包括在很多資料提到的兩階段鎖,其實是指嚴格兩階段鎖

3.4 幻讀

幻讀是指一個事務通過一些條件進行了讀操作,比如select * from table where id > 1 and id < 10,然后另一個事務的寫操作改變了匹配該條件的數據(可能是插入了新數據,可能是刪除了匹配條件的數據,也可能是通過更新操作讓其他數據也變得匹配該條件)

3.4.1 問題

同樣看兩個例子:

假設學生表中有a,b,c三個學生

時間 事務A 事務B
T1 begin begin
T2 讀所有學生列表
學生列表為a,b,c
T3 添加學生d
學生列表為a,b,c,d
T4 commit;
T5 讀所有學生列表
學生列表為a,b,c,d
T6 commit

可以看到,事務A第二次讀取所有學生列表,多了一個學生出來,影響了一致性

那么,重新問一下在不可重復讀中問過的問題,如果我不做第二次讀取呢?

答案是同樣可能有問題,我們看第二個例子:

還是這個學生表,有a,b,c三個學生,同時為了避免直接計數的性能問題,我們還有一個count記錄學生的總數,count初始值為3

時間 事務A 事務B
T1 begin begin
T2 讀所有學生列表
學生列表為a,b,c,因此學生總數為3
T3 添加學生d
學生列表為a,b,c,d
T4 寫count
count = 4
T5 commit;
T6 讀count
count = 4 得到學生總數是4
T7 commit

這次我們沒有讀取兩次所有學生列表,但是可以看到兩個有關聯的數據發生了不一致,明明讀學生列表后我們計算出的總數是3,可是直接讀count得到的卻是4,違反了一致性

3.4.2 隔離級別

SQL-92定義了“可串行化”隔離級別來阻止幻讀的問題,不過對於幻讀問題只提及了第一種情況,而其實不管有沒有第二次讀取,只要其他事務的寫導致讀取的結果集發生變化,都可能會發生一致性的問題

3.4.3 實現

在“可重復讀”隔離級別中,我們已經給讀操作加了記錄鎖(長鎖)和謂詞鎖(短鎖),為了防止幻讀,謂詞鎖加短鎖已經不行了,我們需要把謂詞鎖也變成長鎖。因此可串行化隔離級別的實現就是讀操作加長鎖(記錄鎖,謂詞鎖),寫操作加長鎖,也就是通過兩階段鎖來實現可串行化。

3.5 丟失更新(Lost Update)

因為SQL-92對異象的定義不夠完整,后面要提到的三種異象可能稍微陌生一些

丟失更新是指一個事務的寫被另一個已提交事務覆蓋(有些資料把它稱為第二類丟失更新)

3.5.1 問題

我們看一個例子:

counter初始值為1,兩個事務分別給counter值加1,counter最后的值應該變成3

時間 事務A 事務B
T1 begin begin
T2 讀couter
counter = 1
讀couter
counter = 1
T3 寫counter
counter = 2
T4 commit
T5 寫counter
counter = 2
T6 commit

我們發現事務B提交之后counter值是2,也就是說即使事務A已經提交了,它對counter的更新卻“丟失”了

3.5.2 隔離級別

由於SQL-92沒有提及這種異象,所以對於哪種隔離級別應該阻止丟失更新沒有權威的定義,不過我們可以看到上面會出現丟失更新的問題,是因為事務B讀取counter后被事務A修改,這是上面的的“可重復讀”隔離級別加鎖實現所阻止的,因此我們對於可重復讀的加鎖實現能夠阻止“丟失更新”的發生,上面的例子中,由於對讀操作加了長鎖,所以兩個事務的寫操作會互相等待對方的讀鎖釋放,形成死鎖,如果有死鎖檢測機制,事務B會自動回滾,不會出現丟失更新的情況

3.6 Read Skew

最后兩個異象Read Skew和Write Skew都是違反了數據原有的一致性約束

Read Skew即讀違反一致性約束,原本多個數據存在一致性的約束,讀取發現違反了該一致性

3.6.1 問題

我們直接用不可重復讀中的第二個例子就好:

x初始值為50,y初始值為50,x給y轉賬40,我們的一致性要求時事務執行后x和y的總和不變

時間 事務A 事務B
T1 begin begin
T2 讀x
x = 50
T3 寫x
x = 10
T4 寫y
y = 90
T5 commit;
T6 讀y
y = 90
T7 commit

事務A發現x+y變成140了,這就出現了Read Skew,你可以把Read Skew當成不可重復讀的一種情況

3.6.2 隔離級別

SQL-92同樣沒有提及這種異象,由於Read Skew可以視為不可重復讀的一種情況,所以“可重復讀”隔離級別應該阻止Read Skew(我們對於可重復讀的加鎖實現能夠阻止“Read Skew”的發生,上面的例子中,事務B的寫操作會被事務A的讀鎖阻塞,因此事務A會讀到x=y=50,不會出現Read Skew)

3.7 Write Skew

write skew即寫違反一致性約束,通常發生在根據讀取的結果進行寫操作時,並發事務的寫操作導致最終結果違反了一致性約束,可能不好理解,我們看個例子

3.7.1 問題

第一個問題:

假設x和y是一個人的兩個信用卡賬戶,我們要求x + y不能小於0,而x或者y可以小於0,就是說你的一張信用卡可以是負的,但是全部加起來不能也是負的

下面事務A和事務B是兩次並發的扣款,x初始值為20,y初始值為20

時間 事務A 事務B
T1 begin begin
T2 讀x
x = 20
y = 20
讀x
x = 20
y = 20
T3 發現還有40塊錢,扣款30
T4 寫x
x = -10
T5 發現還有40塊錢,扣款30
T6 寫y
y = -10
T7 commit
T8 commit

我們發現兩個事務提交之后,x+y變成了-20,違反了一致性約束

上面這個問題是嚴格意義上的Write Skew,另外還有由於幻讀產生的Write Skew

問題2:

假設我們要做一個注冊用戶的功能,要求用戶名唯一,並且沒有給用戶名加唯一索引,也就是說唯一性我們自己來保證

用戶表已有用戶名a,b,c,兩個用戶同時注冊用戶名d

時間 事務A 事務B
T1 begin begin
T2 讀所有用戶名
當前用戶名:a,b,c
讀所有用戶名
當前用戶名:a,b,c
T3 發現沒有d
T4 插入用戶名d
T5 commit
T6 發現沒有d
T7 插入用戶名d
T8 commit

我們發現,兩個事務提交之后,用戶名d有了兩個,違反了唯一性約束

這個問題由於是幻讀引發的,所以有人把它歸類在Write Skew里,也有人把它歸類在幻讀里,你可以按照自己的理解來分類

3.7.2 隔離級別

SQL-92也沒有提及Write Skew,我們上面提到了兩個問題,一個是嚴格意義上的Write Skew,一個是幻讀引發的Write Skew,如果是嚴格意義上的Write Skew,我們上面的“可重復讀”隔離級別加鎖實現可以阻止(寫操作會被讀鎖阻塞);而由於幻讀引發的Write Skew,本質上已經是幻讀問題,所以只有“可串行化”隔離級別能夠阻止(上面的例子中,由於謂詞鎖的存在,后面的插入操作被阻塞)

3.8 隔離級別匯總

我們最后對各種隔離級別的加鎖實現匯總一下:

隔離級別 讀操作(S鎖) 寫操作(X鎖)
未提交讀 不需要 加長鎖
提交讀 記錄鎖加短鎖
謂詞鎖加短鎖
加長鎖
可重復讀 記錄鎖加長鎖
謂詞鎖加短鎖
加長鎖
可串行化 記錄鎖加長鎖
謂詞鎖加長鎖
加長鎖

另外對於基於鎖實現的隔離級別,我們根據其避免的並發問題匯總一下

隔離級別 臟寫 臟讀 不可重復讀 幻讀 丟失更新 Read Skew Write Skew
未提交讀 不會
提交讀 不會 不會
可重復讀 不會 不會 不會 不會 不會 不會
可串行化 不會 不會 不會 不會 不會 不會 不會

4 其他隔離級別

在上面討論各種異象的過程中,我們也引入了一些隔離級別,包括:

  • 未提交讀
  • 提交讀
  • 可重復讀
  • 可串行化

我們也探討了相關隔離級別的基於鎖的實現,你會發現除了未提交讀,我們都需要對讀加鎖了,這可能會帶來性能問題,一個執行時間稍長的寫事務,會阻塞大量的讀操作。因此,為了提高性能,很多數據庫實現都是采用數據多版本的方式,即保留舊版本的數據,可以做到讀操作不必加鎖,因此讀不會阻塞寫,寫也不會阻塞讀,可以獲得很好的性能。因此就出現了另外一種隔離級別——快照隔離(Snapshot Isolation),由於SQL-92標准制定時,快照隔離還沒有出現,所以快照隔離沒有出現在標准中,一些實現快照隔離的廠商也是按照可重復讀來宣傳自己的數據庫產品。當然,除了快照隔離也有其他的隔離級別實現(例如Cursor Stability 游標穩定),我們不會在這里討論,感興趣的同學可以自己了解

4.1 快照隔離

快照隔離是指每個事務啟動時,數據庫就為這個事務提供了這時數據庫的狀態,即快照(好像把數據庫此時的數據都照下來了一樣),后續其他事務對數據的新增、修改、刪除操作,這個事務都看不到,它始終只能看到自己的一致性快照

4.2 快照隔離實現

快照隔離怎么實現呢,對於寫操作還是用長鎖來防止臟寫的問題,對於讀操作,主要思想就是維護多版本的數據,也就是所謂的MVCC(multi-version concurrency control),MVCC不止是用來實現快照隔離這個級別,很多數據庫也用它來實現“提交讀”隔離級別,區別於快照隔離在事務開始時得到一個一致性快照,在“提交讀”隔離級別,每個語句執行時,都會有一個快照。我們這里主要關注MVCC實現“快照隔離”的方式。

MVCC的實現方式主要有兩種:

  • 維護多版本數據,比較有代表性的是Postgresql
  • 維護回滾日志(undo log),比較有代表性的是Mysql InnoDB

我們后面會簡單介紹一下這兩種方式的實現思想,不過我們不會涉及具體的數據庫實現

4.2.1 維護多版本數據

這種方式是實實在在的保留了多個版本的數據,例如假如我有這樣一行數據:

id name age gender version
1 zhangsan 10 male xxx

如果我把年齡改為20,表中會添加一個不同版本的數據(實際的存儲結構可能是B+樹,不過為了簡單,我們把數據的存儲結構簡化為一個表格來描述)

id name age gender version
1 zhangsan 10 male xxx
1 zhangsan 20 male yyy

也就是說,即使其他列的值沒有變化,也會原樣復制一份

那么,怎么實現MVCC呢?

首先,事務開始時數據庫會分配一個事務id,我們這里記作txid,數據庫保證這個id是單調遞增的(我們這里不考慮整數回繞的情況)

另外,數據庫會在每行數據添加兩個隱藏字段:

  • create_by 表示創建這行數據的事務id
  • delete_by 表示刪除這行數據的事務id

我們分別看一下插入、更新和刪除的過程:(使用用戶表做例子)

4.2.1.1 插入

假設當前事務id為3,插入一個叫liming的用戶

id name age gender create_by delete_by
1 liming 10 male 3 null

該數據的create_by為3,delete_by為null

4.2.1.2 更新

更新操作可以轉換為:刪除原數據+插入新數據

假設事務id為4的事務,將liming的年齡更新為20

id name age gender create_by delete_by
1 liming 10 male 3 4
1 liming 20 male 4 null
4.2.1.3 刪除

假設事務id為5的事務,將liming刪除

id name age gender create_by delete_by
1 liming 10 male 3 4
1 liming 20 male 4 5

如上將delete_by修改為5

4.2.1.4 可見性規則

對於當前事務能夠“看到”哪些數據,我們用可見性規則來定義

在事務開始時,數據庫會獲取當前活躍(未提交的)的事務id列表,以及當前分配的最大事務id

這個事務能看到哪些數據遵循以下規則:

  • 如果對數據做更改的事務id在活躍事務id列表中,那么這個更改不可見
  • 如果對數據做更改的事務id大於當前分配的最大事務id,說明是后續的事務,更改不可見
  • 如果對數據做更改的事務是回滾狀態,更改不可見

上面我們說的更改包括創建和刪除(更新可以轉化為刪除+創建),“創建”不可見意味着當前事務看不到其他事務新創建的數據,“刪除”不可見意味着當前事務仍然能看到其他事務已刪除的數據

通過這樣的可見性規則我們可以保證事務永遠從一個“一致性快照”中讀取數據

4.2.2 維護回滾日志(undo log)

這種方式保留了數據的回滾日志,而非所有版本的完整數據,需要查詢舊版本數據時,通過在最新數據上應用回滾日志中的修改,構造出歷史版本的完整數據,主要思想還是和第一種方式一樣,只是采用了另外一種實現方式,其實理解了第一種方式也就理解了MVCC,因此這里我們只簡單介紹維護回滾日志的方式

例如這樣一行數據

id name age gender version
1 zhangsan 10 male xxx

把年齡修改為20,則直接在數據中修改

id name age gender version
1 zhangsan 20 male xxx

同時在回滾日志中會記錄類似“把age從20改回10”的回滾操作

這種方式怎么實現MVCC呢,和上面一樣,事務開始時數據庫會分配一個事務id,我們這里記作txid,數據庫保證這個id是單調遞增的

類似上面的create_by和delete_by,數據中也會有一些隱藏字段(我們這里只討論和MVCC相關的隱藏字段)

  • txid,創建該數據的事務id
  • rollback_pointer,回滾指針,指向對應的回滾日志記錄
  • delete_mark,刪除標記,標記數據是否刪除(我們后面用1來表示已刪除,0來表示未刪除)

同樣,我們看一下插入、更新和刪除的過程

4.2.1.1 插入

假設當前事務id為3,插入一個叫liming的用戶(下面用綠色表示插入對應的回滾日志)

image-20210218175241726

如圖,事務id設置為3,刪除標記為0,同時在回滾日志中記錄該數據的主鍵值,我們這里主鍵是id,因此記錄1就好,並且將回滾指針指向該回滾日志,這里記錄主鍵值是為了回滾時通過主鍵值刪除相關數據和索引

4.2.1.2 刪除

假設事務4要刪除liming這條記錄(下面用紅色表示刪除對應的回滾日志)

image-20210218182234066

我們會把liming這條記錄的delete_mark設置為1,同時在回滾日志中記錄刪除前的事務id、回滾指針以及主鍵值

4.2.1.3 更新

更新要分為不更新主鍵和更新主鍵兩種情況(我們這里假設主鍵是id)

4.2.1.3.1 不更新主鍵

首先看不更新主鍵的情況:

假設id為4的事務將之前插入的liming的age更新為20(下面用藍色表示更新對應的回滾日志)

image-20210218182327902

我們會把原來的age直接更新成20,並且txid改為4,同時在回滾日志中記錄更新列的信息,這里是age: 10,表示更新前age的舊版本數據是10,另外我們也記錄了原來的事務id和回滾指針,最終回滾日志中的數據會通過回滾指針形成一個鏈表,從而查找舊版本數據,比如如果事務id為5的事務接着把gender更新為female:

image-20210218182418402

4.2.1.3.2 更新主鍵

接下來看看更新主鍵的情況:

更新主鍵時,和第一種實現MVCC的方式類似,轉換為刪除+插入

為什么這個時候和不更新主鍵不一樣呢,是因為更新主鍵時,數據的位置已經發生變化了,比如數據存儲的結構是B+樹,如果主鍵更新了,那么數據在B+樹中的位置肯定會變化,如果還在舊版本的數據上直接修改主鍵,那么查找的時候是找不到的(因為是根據主鍵值做查找),所以這個時候要轉換為刪除+插入

例如事務id為4的事務,將之前我們插入的liming的id更新為2

image-20210218182928146

4.2.1.4 可見性規則

這里的可見性規則其實和MVCC的第一種實現方式是類似的

同樣是在事務開始時,數據庫會獲取當前活躍(未提交的)的事務id列表,以及當前分配的最大事務id

同樣遵循以下規則:

  • 如果對數據做更改的事務id在活躍事務id列表中,那么這個更改不可見
  • 如果對數據做更改的事務id大於當前分配的最大事務id,說明是后續的事務,更改不可見
  • 如果對數據做更改的事務是回滾狀態,更改不可見

只是我們之前使用create_by和delete_by來表示數據是由哪個事務創建,被哪個事務刪除,現在變成了由txid和delete_mark來表示,當delete_mark為0時,表示數據由txid創建,當delete_mark為1時,表示數據被txid刪除,同時通過rollback_pointer形成的鏈表來跟蹤舊版本的數據,查找數據時,會在這條鏈表上向前追溯,直到數據的txid滿足可見性規則。並且,因為我們沒有在回滾日志中保留全部的信息,所以在鏈表上追溯時,要依次應用回滾日志中記錄的修改,比如我們在更新操作中提到的,將年齡改為20,又將性別改為female

image-20210218183006939

這時如果事務3想讀取liming這行數據,就要在最新數據上,先把gender改回male,再把age改回10,然后才是滿足事務3一致性快照的數據

4.3 快照隔離和並發問題

那么快照隔離能夠防止哪些並發問題呢?回顧一下我們之前提到的並發問題

  • 臟寫:我們之前提到了,快照隔離也是通過寫加長鎖來避免臟寫,所以“臟寫”不會出現
  • 臟讀:由於快照隔離的可見性規則限制了我們只能從已提交的數據中讀取數據,所以“臟讀”不會出現
  • 不可重復讀:由於快照隔離使得事務始終從一個一致性的快照中讀取數據,即使數據被其他事務修改了,也不會被讀取到,所以顯然是可以“重復讀”的,因此“不可重復讀”不會出現
  • 幻讀:在快照隔離中,假設當前事務做了一個條件讀取操作,即使其他事務的插入、更新和修改使得該條件下的數據發生了變化,由於可見性規則的作用,這些數據對當前事務也不可見,那么快照隔離是否能防止幻讀?對於嚴格意義上的幻讀,比如對於只讀事務來講,快照隔離是可以防止幻讀的。但是如果根據查詢結果做了寫操作,例如我們上面提到的幻讀導致的Write Skew,快照隔離是無法避免的,因為他並沒有阻止其他事務的寫操作,只是讓這些寫操作對當前事務不可見了
  • 丟失更新:快照隔離可以避免丟失更新,我們可以針對當前事務開始后到提交前這段時間提交的這些事務,記錄他們修改的數據,如果發現當前事務寫的數據和這些已提交事務修改的數據有沖突,那么當前事務應該回滾,從而避免丟失更新的現象,這種方法也叫First-commiter-wins,也就是說先提交的事務會修改數據成功。但是,實際的快照隔離是否能避免丟失更新取決於數據庫的實現,比如Postgresql的快照隔離是防止丟失更新的,而Mysql InnoDB的快照隔離不會阻止丟失更新
  • Read Skew:和不可重復讀一樣,快照隔離顯然可以避免Read Skew
  • Write Skew:可以回顧一下我們在Write Skew中的兩個例子,很明顯不管是嚴格意義上的Write Skew,還是幻讀導致的Write Skew,快照隔離都無法避免

5 Mysql隔離級別實現

下面我們來看看Mysql中的隔離級別,Mysql提供了四種隔離級別:

  • 未提交讀
  • 提交讀
  • 可重復讀
  • 可串行化

5.1 未提交讀

未提交讀很簡單,只是對寫操作加了長鎖,和我們上面說的基於鎖實現未提交讀隔離級別的方式是一致的,所以沒啥好說的,Mysql的“未提交讀”也是避免了臟寫,其他問題都有可能出現

5.2 提交讀

Mysql使用MVCC實現了快照隔離,這里的“提交讀”隔離級別也通過MVCC進行了實現,只不過在快照隔離中,我們是一個事務一個一致性快照,而在“提交讀”隔離級別下,是一條語句一個一致性快照

5.3 可重復讀

Mysql的“可重復讀”本質就是快照隔離,通過MVCC實現,具體的實現方式采用維護回滾日志的方式,即Mysql中的undo log

我們在前面提到了在快照隔離中,幻讀和Write Skew是無法避免的,另外由於Mysql的實現,丟失更新也無法避免,如果不想切換到“可串行化”隔離級別,我們就需要手動加鎖來解決這些問題,那么我們分別來看看如何避免這幾個問題

既然要手動加鎖,我們先了解一下Mysql中相關的鎖:(下面所有的討論都基於Mysql的“可重復讀”隔離級別)

5.3.1 表鎖

Mysql中的表鎖包括:

  • 普通的表鎖
  • 意向鎖
  • 自增鎖(AUTO-INC Locks)
  • MDL鎖(metadata lock)

我們這里討論前三種

5.3.1.1 普通的表鎖

表鎖就是對表上鎖,可以對表加S鎖:

LOCK TABLES ... READ

也可以對表加X鎖:

LOCK TABLES ... WRITE

這倆鎖的兼容性也很顯而易見:

X S
X 互斥 互斥
S 互斥 兼容
5.3.1.2 意向鎖

Mysql支持多粒度封鎖,既可以鎖表,也可以鎖定某一行。那我們如果要加表鎖,就要檢查所有的數據上是否有行鎖,為了避免這種開銷,Mysql也引入了意向鎖,要加行鎖時,需要先在表上加意向鎖,這樣鎖表時直接判斷是否和意向鎖沖突即可,不需要再檢測所有數據上的行鎖

意向鎖的規則也很簡單:IS和IX表示意向鎖,要給行加S鎖前,需要先加IS鎖,要給行加X鎖前,需要先加IX鎖,意向鎖之間不會相互阻塞

加上意向鎖之后,表鎖的兼容性其實也很簡單:

X IX S IS
X 互斥 互斥 互斥 互斥
IX 互斥 兼容 互斥 兼容
S 互斥 互斥 兼容 兼容
IS 互斥 兼容 兼容 兼容
5.3.1.3 自增鎖(AUTO-INC Locks)

自增鎖很顯然就是給自增id這種場景用的,也就是設置了AUTO_INCREMENT的列,插入數據時,通過加自增鎖申請id,然后立即釋放自增鎖。自增鎖跟事務關系不大,我們不再詳細討論

5.3.2 行鎖

首先,Mysql的行鎖並不一定是鎖住某一行,也可能是鎖住某個區間

Mysql中有四種行鎖

  • Next-Key Locks,也叫Ordinary locks,對索引項以及和上一個索引項之間的區間加鎖,比如索引中有數據1,4,9,(4, 9]就是一個Next-Key Locks,Next-Key Lock是Mysql加鎖的基本單位,會在一些情況下優化為下面的Record Locks或者Gap Locks
  • Record Locks,也叫rec-not-gap locks,就是Next-Key Locks優化去掉了區間鎖,只需要鎖索引項
  • Gap Locks,對兩個索引項的區間加鎖,比如索引中有數據1,4,9,(4, 9) 就是一個Gap Lock
  • Insert intention Locks,插入意向鎖,insert操作產生的Gap鎖,給要插入的索引區間加鎖,比如索引中有數據1,4,9,要插入5時,加插入意向鎖(4, 9)

我們給出兼容性矩陣:(S鎖和S鎖永遠是相互兼容的,下面的兼容或者互斥說的是S和X,X和S,X和X這種情形,並且鎖住的行有交集)下面第一列表示已經存在的鎖,第一行表示正在請求的鎖

Next-Key lock Record lock Gap lock Insert intention lock
Next-Key lock 不兼容 不兼容 兼容 不兼容
Record lock 不兼容 不兼容 兼容 兼容
Gap lock 兼容 兼容 兼容 不兼容
Insert intention lock 兼容 兼容 兼容 兼容

注意這個矩陣不是完全對稱的:

  • Gap lock只會阻塞插入意向鎖,不會和其他的鎖沖突
  • Next-key lock和Gap lock會阻塞插入意向鎖,相反插入意向鎖不會阻塞任何加鎖請求

簡單討論一下各種操作會加的鎖:

樣例數據:表t,id為主鍵,c為二級索引

id c d
1 10 20
3 10 20
5 15 30
5.3.2.1 insert

插入時加插入意向鎖,並在要插入的索引項上加Record Lock

5.3.2.2 delete/update/select ... for update

delete、update和select加X鎖的情況相似,下面以delete為例說明

  • 不加條件的delete/update/select ... for update:比如 delete from t 在表上加IX鎖,所有的主鍵索引記錄加Next-key lock,相當於鎖表了,其他無法使用索引的條件刪除都等同於這種情況
  • 主鍵等值條件delete/update/select ... for update:比如 delete from t where id = 1 在表上加IX鎖,id=1的索引記錄上加Record lock
  • 主鍵不等條件delete/update/select ... for update:比如 delete from t where id < 2 在表上加IX鎖,所有訪問到的索引記錄(直到第一個不滿足條件的值)加Next-key lock,這里就是在id=1和id=3上加Next-key lock,即鎖住了(-∞, 1]和(1, 3]
  • 二級索引等值條件delete/update/select ... for update:比如 delete from t where c = 10 在表上加IX鎖,所有訪問到的二級索引記錄(直到第一個不滿足條件的值)加Next-key lock,最后一個索引項優化為Gap lock,這里就是(-∞, 10]加Next-key lock,(10, 15)加Gap lock;對應的主鍵索引項加Record lock
  • 二級索引不等條件delete/update/select ... for update:比如 delete from t where c < 10 在表上加IX鎖,所有訪問到的二級索引記錄(直到第一個不滿足條件的值)加Next-key lock,這里就是(-∞, 10]和(10, 15]加Next-key lock
5.3.2.3 select ... lock in share mode

加S鎖時,覆蓋索引的情況比較特殊,其他都和加X鎖時相同

下面我們討論一下覆蓋索引的情況:(覆蓋索引是指,查詢只需要使用索引就可以查到所有數據,不必再去主鍵索引中查詢)

假設做如下查詢

select id from t where c = 10 lock in share mode

針對這個查詢,Mysql的加鎖規則和X鎖時一樣,(-∞, 10]加Next-key lock,(10, 15)加Gap lock,但是因為這個查詢只需要查二級索引就可以了,Mysql不會再去主鍵索引查詢,不查主鍵索引也就不會在主鍵索引上加鎖。如果簡單的把這種行鎖認為是鎖住了數據行,可能會出現意想不到的結果,比如在上面加S鎖的情況下,跑一下下面的查詢:

update t set d = d + 1 where id = 1

會發現Mysql並不會阻止你,因為這個查詢根本沒有用列c上的索引,又怎么會阻塞呢,但是他確實把我們之前貌似“鎖住”的數據修改了

那如果想避免這種情況怎么辦,可以修改查詢,讓索引覆蓋不了;也可以把S鎖換成X鎖:

select c from t where c = 10 for update

加X鎖的話,不管查詢有沒有索引覆蓋,Mysql都會回去主鍵索引查詢一下,給id=1和id=2的索引項加上鎖

5.3.3 手動加鎖避免並發問題

看完了鎖我們再討論一下如何通過加鎖避免在“可重復讀”隔離級別會出現的並發問題

我們一個用戶表users作為樣例數據:

id為主鍵,name列有非唯一索引

id name fans_cnt
1 liming 10
2 zhangsan 20
5.3.3.1 丟失更新

比如下面的“丟失更新”的例子:

兩個事務並發給liming的粉絲數加1

時間 事務A 事務B
T1 begin begin
T2 select fans_cnt from users where id = 1
fans_cnt = 10
select fans_cnt from users where id = 1
fans_cnt = 10
T3 update users set fans_cnt = 11 where id = 1
T4 commit
T5 update users set fans_cnt = 11 where id = 1
T6 commit

這里我們給讀加鎖就可以避免丟失更新:

時間 事務A 事務B
T1 begin begin
T2 select fans_cnt from users where id = 1 for update
fans_cnt = 10
select fans_cnt from users where id = 1 for update
T3 update users set fans_cnt = 11 where id = 1 wait
T4 commit wait
T5 fans_cnt = 11
T6 update users set fans_cnt = 12 where id = 1
commit

事務B會等到事務A提交

當然也可以通過樂觀鎖的方式:

時間 事務A 事務B
T1 begin begin
T2 select fans_cnt from users where id = 1
fans_cnt = 10
select fans_cnt from users where id = 1
fans_cnt = 10
T3 update users set fans_cnt = 11 where id = 1 and fans_cnt = 10
T4 commit
T5 update users set fans_cnt = 11 where id = 1 and fans_cnt = 10
T6 commit

事務B因為where條件不滿足,不會更新成功,可以自己在應用代碼里重試

5.3.3.2 幻讀

我們之前討論過,只讀事務不會有幻讀的問題,這里取幻讀導致Write Skew的例子來討論:

假設我們要求users表中name唯一,並且name上沒有唯一索引

時間 事務A 事務B
T1 begin begin
T2 select * from users where name='wangwu' select * from users where name='wangwu'
T3 發現沒有wangwu
T4 insert into users values(wangwu, 0)
T5 commit
T6 發現沒有wangwu
T7 insert into users values(wangwu, 0)
T8 commit

最終users表中會有兩條wangwu的記錄

同樣,給讀操作加鎖就可以避免

時間 事務A 事務B
T1 begin begin
T2 select * from users where name='wangwu' lock in share mode select * from users where name='wangwu'lock in share mode
T3 發現沒有wangwu
T4 insert into users values(wangwu, 0)
T5 wait
T6 wait
T7 wait insert into users values(wangwu, 0)
T8 success deadlock!!!

這里事務A和事務B在讀操作時會獲取Gap-lock,事務A插入時請求插入意向鎖被事務B的Gap lock阻塞,后面事務B插入時請求插入意向鎖又被事務A的Gap lock阻塞,Mysql死鎖檢測機制會自動發現死鎖,最終只有事務A能夠插入成功

5.3.3.3 Write Skew

Write Skew 我們還是用之前信用卡賬戶的例子:

cards表

id為主鍵,列name有非唯一索引

id name money
1 liming 20
2 liming 20
3 zhangsan 10

liming有兩張卡,總共40塊錢,事務A和事務B分別對這兩張卡扣款

時間 事務A 事務B
T1 begin begin
T2 select * from cards where name = 'liming' select * from cards where name = 'liming'
T3 發現還有40塊錢,扣款30
T4 update cards set money = money-30 where id = 1
T5 發現還有40塊錢,扣款30
T6 update cards set money = money-30 where id = 2
T7 commit
T8 commit

最后發現liming只有40塊錢,卻花出去60

解決方法同樣很簡單,給讀加鎖就好

時間 事務A 事務B
T1 begin begin
T2 select * from cards where name = 'liming' for update select * from cards where name = 'liming'for update
T3 發現還有40塊錢,扣款30 wait
T4 update cards set money = money-30 where id = 1 wait
T5 commit wait
T6 發現還有10塊錢,無法扣款30
T7 commit

讀操作加鎖之后,事務B需要阻塞到事務A提交才能完成讀取,並且讀到最新的數據,不會再出現liming超額花錢的情況

5.4 可串行化

Mysql的可串行化實現方式就是我們上面介紹的可串行化的加鎖實現方式(即兩階段鎖),可以避免上面所有的並發問題,不過兩階段鎖也存在下面的問題:

  • 性能差,這個很顯然,加鎖限制了並發,並且帶來了加鎖解鎖的開銷
  • 容易死鎖

另外,可串行化還有其他實現方式:

  • 串行執行
  • 可串行化快照隔離(Serializable Snapshot Isolation,SSI)

我們簡單介紹一下

5.4.1 串行執行

避免代碼bug最好的方式就是不寫代碼

避免並發問題最好的方式就是沒有並發

這種實現可串行化的方式就是真的讓事務串行執行,即在單線程中順序執行,因此以這種方式實現,本身就不會有並發問題,直接實現了可串行化

這種方式有時候會比並發的方式性能更好,因為避免了加鎖這種操作的開銷。比如redis的事務就采用這種方式實現

這種方式也有幾個明顯的問題:

  • 不支持交互式查詢:我們在使用事務時,很多場景都是發起查詢,然后根據查詢結果,發起下一次查詢,如果串行執行,系統執行事務的吞吐量(單位時間執行的事務數量)會受到很大影響,因為很多時間都消耗在查詢結果傳輸這種網絡IO上。因此,采用串行方式的數據庫都不支持這種交互式查詢的方式,如果需要在事務中實現一些業務邏輯,只能使用數據庫提供的存儲過程,比如在Redis中可以通過編寫Lua腳本來實現
  • 吞吐量受限於單核CPU:由於是單線程執行,系統的性能受限於單核CPU,不能很好地利用多核CPU
  • 對IO敏感:如果數據需要從磁盤中讀取,那么性能會因為磁盤IO受到很大影響

5.4.2 可串行化快照隔離

可串行化快照隔離就是在快照隔離的基礎上做到了可串行化,主要思想是在快照隔離基礎上增加了一種檢測機制,當發現當前事務可能會導致不可串行化時,會將事務回滾。Postgresql的“可串行化”隔離級別采用了這種實現方式

我們簡單介紹一下這種檢測機制:

事務之間的關系我們可以用圖結構來表示,圖中的頂點是事務,邊是事務之間的依賴關系,這就是多版本可序列化圖(Multi-Version Serialization Graph,MVSG)

其中邊是有向邊,通過事務對相同數據的讀寫操作來定義,比如T1將x修改為1之后,T2將x修改為2,T1和T2之間的關系可以這樣表示:

image-20210218183728882

邊的方向由T1指向T2表示T1在T2之前發生

事務之間可能發生沖突的依賴關系有三種,也就是圖中邊的種類有三種:

  • 寫寫依賴(ww-dependencies):T1為數據寫入新版本,T2用更新的版本替換了T1,則T1和T2構成寫寫依賴:\(T_1 \xrightarrow{ww} T_2\)

  • 寫讀依賴(wr-dependencies):T1為數據寫入新版本,T2讀取了這個版本的數據,或者通過謂詞讀(通過條件查詢)的方式讀取到這個數據,則T1和T2構成寫讀依賴:\(T_1 \xrightarrow{wr} T_2\)

  • 讀寫反依賴(rw-dependencies):T2為數據寫入新版本,而T1讀取了舊版本的數據,或者通過謂詞讀(通過條件查詢)的方式讀取到這個數據,則T1和T2構成讀寫反依賴:\(T_1 \xrightarrow{rw} T_2\)

  • 因為讀讀並發沒有任何問題,所以我們這里沒有讀讀依賴

由事務為頂點,上面三種依賴關系為邊,可以構成一個有向圖,如果這個有向圖存在環,則事務不可串行化,因此可以通過檢測環的方式,來回滾相關事務,做到可串行化。但是這樣做開銷比較大,因此學術界提了一條定理:如果存在環,則圖中必然存在這樣的結構:

\(T_1 \xrightarrow{rw} T_2 \xrightarrow{rw} T_3\),因此可以通過檢測這種“危險結構”來實現,當然,這樣實現的話可能會錯誤的回滾一些事務,這里關於定理的證明以及危險結構的檢測算法我們就不再介紹了,感興趣的話可以看看相關資料

6 參考文獻

【1】Designing Data-Intensive Applications

【2】A Critique of ANSI SQL Isolation Levels

【3】Mysql Reference Manual

【4】數據庫事務處理的藝術


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM