寫給小白看的Mysql事務
- 2021 年 1 月 12 日
- 筆記
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,那麼可能會出現這樣的情況:
我們做了兩次加1操作,結果本應是2,但是最終可能會是1。當然,還會有其他的並發問題,隔離性就是為了屏蔽掉一些並發問題的處理,讓我們編寫應用程式碼更加簡單。我們再來看一下《資料庫系統概論》給隔離性的定義:
一個事務的執行不能被其他事務干擾。即一個事務的內部操作及使用的數據對其他並發事務是隔離的,並發執行的各個事務之間不能相互干擾
課本上的定義是根據「可串列化」這個隔離級別來表述隔離性,就是說你可以認為事務之間完全隔離,就好像並發的事務是順序執行的。但是,我們實際用的時候,為了更好的並發性能,基本不會把事務完全隔離,所以就有了隔離級別的概念,sql 92標準定義了四種隔離級別:未提交讀、提交讀、可重複讀、可串列化,大家一般會使用較弱的隔離級別,例如「可重複讀」。關於各種隔離級別,我們放到第三部分和第四部分再說
2.4 持久性
持久性是指一旦事務提交,即使系統崩潰了,事務執行的結果也不會丟失。為了實現持久性,資料庫會把數據寫入非易失存儲,比如磁碟。當然持久性也是有個度的,例如假設保存數據的磁碟都壞了,那持久性顯然無法保證
3 並發問題
我們前面提到,為了更好的並發性能,我們搞出了各種弱隔離級別,那麼隔離級別是怎麼定義的呢?隔離級別是通過可能遇到的並發問題(異象)來定義的,選定一個隔離級別後,就不會出現某一類並發問題,那麼我們就來看看會有哪些並發問題,在每一小節,我們會先講講這個並發問題是什麼,然後討論阻止他的隔離級別,最後說說實現這個隔離級別的方法,這裡我們只討論加鎖的實現,其他實現我們放到第四章來講,所以我們先簡單說一下鎖
-
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
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
update t set d = d + 1 where id = 1
那如果想避免這種情況怎麼辦,可以修改查詢,讓索引覆蓋不了;也可以把S鎖換成X鎖:
select c from t where c = 10 for update
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
【4】資料庫事務處理的藝術

