寫給小白看的Mysql事務

1 爲何須要事務

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

2 什麼是事務

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

2.1 原子性

首先可能有人會混淆這裏的原子性和多線程編程中的原子操做,多線程編程中的原子操做是指若是一個線程作了一個原子操做,其餘線程沒法看到這個操做的中間狀態,這是一個有關併發的概念。而事務的原子性指的是一個事務中的多個操做,要麼所有成功,要麼所有失敗,不會出現部分紅功、部分失敗的狀況。redis

2.2 一致性

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

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

那什麼叫一致性狀態,其實就是你對數據庫裏的數據有一些約束,例如主鍵必須是惟一的、外鍵約束這些(還記得數據庫的完整性約束嗎),固然,更多的是業務層面的一致性約束,好比在轉帳場景中,咱們要求事務執行後全部人的錢總和沒有改變。數據庫能夠幫咱們保證外鍵約束等數據庫層面的一致性,可是對咱們業務層面的一致性是一無所知的,好比你能夠給每一個人的錢都加100塊,數據庫並不會阻止你,這時你就輕鬆的違反了業務層面的一致性。因此咱們能夠發現,一致性對咱們來講是一個有點無關痛癢的屬性,咱們實際上是經過事務提供的原子性和隔離性來保證事務的一致性,甚至你能夠認爲一致性不屬於事務的屬性,也有人說一致性之因此存在,只是爲了讓ACID這個縮略詞更加順口而已數據庫

2.3 隔離性

若是多個事務同時操做了相同的數據,那麼就會有併發的問題出現,好比說多個事務同時給一個計數器(counter)加1,假設counter初始值爲0,那麼可能會出現這樣的狀況:編程

咱們作了兩次加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將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

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

3.1.2 隔離級別

因爲髒寫致使不能回滾,嚴重影響原子性,因此無論是什麼隔離級別,都要阻止這種問題,所以能夠認爲最弱的隔離級別「未提交讀」須要阻止髒寫

3.1.3 實現

那怎麼防止髒寫呢,很簡單,就是加鎖,通常會在更新以前加行級鎖(X鎖),那何時釋放鎖呢,更新操做執行完以後釋放鎖顯然不行,必須等到事務提交以後再釋放鎖,這樣纔不會出現髒寫的狀況,即寫操做加長鎖(X鎖)

3.2 髒讀(Dirty Read)

一個事務對數據進行寫操做以後,尚未提交,被另外一個事務讀取

3.2.1 問題

髒讀會致使什麼問題呢?咱們看兩個例子

第一個:

x初始值爲100

能夠看到因爲事務A回滾,事務B讀到的x值變成了髒數據

那若是事務A不回滾,事務B讀到的不就不是髒數據了嗎?

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

假設x=50 y=50,x要給y轉帳40,那咱們的一致性要求就是x+y在事務執行後仍然爲100

能夠看到事務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第二次讀取x的值發生了變化,影響了一致性

那麼若是我沒有對相同數據作第二次讀取呢?

咱們看第二個例子:

x初始值爲50,y初始值爲50,x給y轉帳40,咱們的一致性要求時事務執行後x和y的總和不變

能夠發現,事務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第二次讀取全部學生列表,多了一個學生出來,影響了一致性

那麼,從新問一下在不可重複讀中問過的問題,若是我不作第二次讀取呢?

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

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

此次咱們沒有讀取兩次全部學生列表,可是能夠看到兩個有關聯的數據發生了不一致,明明讀學生列表後咱們計算出的總數是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

咱們發現事務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發現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

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

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

問題2:

假設咱們要作一個註冊用戶的功能,要求用戶名惟一,而且沒有給用戶名加惟一索引,也就是說惟一性咱們本身來保證

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

咱們發現,兩個事務提交以後,用戶名d有了兩個,違反了惟一性約束

這個問題因爲是幻讀引起的,因此有人把它歸類在Write Skew裏,也有人把它歸類在幻讀裏,你能夠按照本身的理解來分類

3.7.2 隔離級別

SQL-92也沒有說起Write Skew,咱們上面提到了兩個問題,一個是嚴格意義上的Write Skew,一個是幻讀引起的Write Skew,若是是嚴格意義上的Write Skew,咱們上面的「可重複讀」隔離級別加鎖實現能夠阻止(寫操做會被讀鎖阻塞);而因爲幻讀引起的Write Skew,本質上已是幻讀問題,因此只有「可串行化」隔離級別可以阻止(上面的例子中,因爲謂詞鎖的存在,後面的插入操做被阻塞)

3.8 隔離級別彙總

咱們最後對各類隔離級別的加鎖實現彙總一下:

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

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 維護多版本數據

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

若是我把年齡改成20,表中會添加一個不一樣版本的數據(實際的存儲結構多是B+樹,不過爲了簡單,咱們把數據的存儲結構簡化爲一個表格來描述)

也就是說,即便其餘列的值沒有變化,也會原樣複製一份

那麼,怎麼實現MVCC呢?

首先,事務開始時數據庫會分配一個事務id,咱們這裏記做txid,數據庫保證這個id是單調遞增的(咱們這裏不考慮整數迴繞的狀況)

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

  • create_by 表示建立這行數據的事務id

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

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

4.2.1.1 插入

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

該數據的create_by爲3,delete_by爲null

4.2.1.2 更新

更新操做能夠轉換爲:刪除原數據+插入新數據

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

4.2.1.3 刪除

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

如上將delete_by修改成5

4.2.1.4 可見性規則

對於當前事務可以「看到」哪些數據,咱們用可見性規則來定義

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

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

  • 若是對數據作更改的事務id在活躍事務id列表中,那麼這個更改不可見

  • 若是對數據作更改的事務id大於當前分配的最大事務id,說明是後續的事務,更改不可見

  • 若是對數據作更改的事務是回滾狀態,更改不可見

上面咱們說的更改包括建立和刪除(更新能夠轉化爲刪除+建立),更改是建立的話含義就是看不到這個數據,更改時刪除的話含義就是仍然能看到這個數據

經過這樣的可見性規則咱們能夠保證事務永遠從一個「一致性快照」中讀取數據

4.2.2 維護回滾日誌(undo log)

這種方式保留了數據的回滾日誌,而非全部版本的完整數據,須要查詢舊版本數據時,經過在最新數據上應用回滾日誌中的修改,構造出歷史版本的完整數據,主要思想仍是和第一種方式同樣,只是採用了另一種實現方式,其實理解了第一種方式也就理解了MVCC,所以這裏咱們只簡單介紹維護回滾日誌的方式

例如這樣一行數據

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

同時在回滾日誌中會記錄相似「把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的用戶(下面用綠色表示插入對應的回滾日誌)

如圖,事務id設置爲3,刪除標記爲0,同時在回滾日誌中記錄該數據的主鍵值,咱們這裏主鍵是id,所以記錄1就好,而且將回滾指針指向該回滾日誌,這裏記錄主鍵值是爲了回滾時經過主鍵值刪除相關數據和索引

4.2.1.2 刪除

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

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

4.2.1.3 更新

更新要分爲不更新主鍵和更新主鍵兩種狀況(咱們這裏假設主鍵是id)

4.2.1.3.1 不更新主鍵

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

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

咱們會把原來的age直接更新成20,而且txid改成4,同時在回滾日誌中記錄更新列的信息,這裏是age: 10,表示更新前age的舊版本數據是10,另外咱們也記錄了原來的事務id和回滾指針,最終回滾日誌中的數據會經過回滾指針造成一個鏈表,從而查找舊版本數據,好比若是事務id爲5的事務接着把gender更新爲female:

4.2.1.3.2 更新主鍵

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

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

爲何這個時候和不更新主鍵不同呢,是由於更新主鍵時,數據的位置已經發生變化了,好比數據存儲的結構是B+樹,若是主鍵更新了,那麼數據在B+樹中的位置確定會變化,若是還在舊版本的數據上直接修改主鍵,那麼查找的時候是找不到的(由於是根據主鍵值作查找),因此這個時候要轉換爲刪除+插入

例如事務id爲4的事務,將以前咱們插入的liming的id更新爲2

4.2.1.4 可見性規則

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

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

一樣遵循如下規則:

  • 若是對數據作更改的事務id在活躍事務id列表中,那麼這個更改不可見

  • 若是對數據作更改的事務id大於當前分配的最大事務id,說明是後續的事務,更改不可見

  • 若是對數據作更改的事務是回滾狀態,更改不可見

只是咱們以前使用create_by和delete_by來表示數據是由哪一個事務建立,被哪一個事務刪除,如今變成了由txid和delete_mark來表示,當delete_mark爲1是,表示數據由txid建立,當delete_mark爲0時,表示數據被txid刪除,同時經過rollback_pointer造成的鏈表來跟蹤舊版本的數據,查找數據時,會在這條鏈表上向前追溯,直到數據的txid知足可見性規則。而且,由於咱們沒有在回滾日誌中保留所有的信息,因此在鏈表上追溯時,要依次應用回滾日誌中記錄的修改,好比咱們在更新操做中提到的,將年齡改成20,又將性別改成female

這時若是事務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
這倆鎖的兼容性也很顯而易見:

5.3.1.1 意向鎖

Mysql支持多粒度封鎖,既能夠鎖表,也能夠鎖定某一行。那咱們若是要加表鎖,就要檢查全部的數據上是否有行鎖,爲了不這種開銷,Mysql也引入了意向鎖,要加行鎖時,須要先在表上加意向鎖,這樣鎖表時直接判斷是否和意向鎖衝突便可,不須要再檢測全部數據上的行鎖

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

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

5.3.1.1 自增鎖(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這種情形,而且鎖住的行有交集)下面第一列表示已經存在的鎖,第一行表示正在請求的鎖

注意這個矩陣不是徹底對稱的:

  • Gap lock只會阻塞插入意向鎖,不會和其餘的鎖衝突

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

簡單討論一下各類操做會加的鎖:

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

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列有非惟一索引

5.3.3.1 丟失更新

好比下面的「丟失更新」的例子:

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

這裏咱們給讀加鎖就能夠避免丟失更新:

事務B會等到事務A提交

固然也能夠經過樂觀鎖的方式:

事務B由於where條件不知足,不會更新成功,能夠本身在應用代碼裏重試

5.3.3.2 幻讀

咱們以前討論過,只讀事務不會有幻讀的問題,這裏取幻讀致使Write Skew的例子來討論:

假設咱們要求users表中name惟一,而且name上沒有惟一索引

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

一樣,給讀操做加鎖就能夠避免

這裏事務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有非惟一索引

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

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

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

讀操做加鎖以後,事務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之間的關係能夠這樣表示:

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

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

  • 寫寫依賴(ww-dependencies):T1爲數據寫入新版本,T2用更新的版本替換了T1,則T1和T2構成寫寫依賴:

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

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

  • 由於讀讀併發沒有任何問題,因此咱們這裏沒有讀讀依賴

由事務爲頂點,上面三種依賴關係爲邊,能夠構成一個有向圖,若是這個有向圖存在環,則事務不可串行化,所以能夠經過檢測環的方式,來回滾相關事務,作到可串行化。可是這樣作開銷比較大,所以學術界提了一條定理:若是存在環,則圖中必然存在這樣的結構:

所以能夠經過檢測這種「危險結構」來實現,固然,這樣實現的話可能會錯誤的回滾一些事務,這裏關於定理的證實以及危險結構的檢測算法咱們就再也不介紹了,感興趣的話能夠看看相關資料

6 參考文獻

【1】Designing Data-Intensive Applications

【2】A Critique of ANSI SQL Isolation Levels

【3】Mysql Reference Manual

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

相關文章
相關標籤/搜索