一文說透事務隔離性——理論篇

JerryTse發表於2023-02-03

事務作為傳統關係型資料庫支援的重要特性,誠然一部分NoSQL資料庫為了在分散式場景下可用性和效能的考慮都不在支援事務,但是代表未來趨勢的NewSQL資料庫在分散式場景下依然保留事務並支援ACID特性,這說明事務在資料庫實現中依然是一個重要的功能特性。作為簡化業務開發的重要模型工具,事務依然值得我們深入的研究、借鑑。

事務的ACID屬性,即原子性、一致性、隔離性和永續性是保證事務正確可靠執行的基礎。本文不會涉及事務的方方面面,而是重點關注ACID中的隔離性特徵。內容較多我會分別用兩篇文章講解。第一篇我會深入淺出的說清楚什麼是事務的隔離性?隔離性的目的是什麼?資料庫為什麼有多種的事務隔離級別的實現?各種的隔離級別有什麼不同?不同的隔離級別會帶來哪些問題?這些問題的又如何解決?下一篇文章中我會以常見的資料庫為例,針對上篇中提到的各種問題場景和相應解決方案做具體示範,在實踐中加深理解。透過本文,我希望你不僅能在理論層面詳細理解事務隔離性的來龍去脈,做到心中有數,也希望你能舉一反三,結合文中場景用例解決現實開發中的遇到的問題。文章有略長,希望你能耐心讀完。

什麼是事務

我們將一組對資料的操作封裝為一個原子單元。事務中的操作要麼全部成功(事務提交),要麼全部失敗(事務中止或回滾),不存在部分成功部分失敗的情況。 為什麼要有這樣的定義呢?資料庫作為一個軟體系統和其他軟體一樣也會面臨諸多異常場景:資料庫的程式隨時可能崩潰,資料庫所在機器隨時可能當機,網路連線也隨時可能延時或失效(隨時不等於任何時刻,只是強調故障發生的可能性)。如果沒有事務性的保障,上層應用的開發人員需要自己處理異常情況。假如我們希望一次性向資料庫插入5條資料,插入過程正資料庫出現了異常,在沒有事務保證的情況下我們不知道哪些資料插入成功哪些失敗,就沒有辦法透過簡單的重試進行處理異常。

事務的ACID特性

上面的定義只側重了事務的原子性保證,完整的事務特性就是人們常說的ACID屬性。ACID就是原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)、永續性(Durability)的首字母簡寫,ACID是保證事務正確可靠的必備條件。

原子性

如事務定義所述,原子性將事務中多個操作組成一個原子單元,不可被拆分,所有操作要麼全部成功,要麼全部失敗,不存在部分成功部分失敗的狀態。

隔離性

通常情況下資料庫為多個客戶端提供服務,當不同的客戶端同時訪問相同的資料的時候就可能產生併發問題。當然併發訪問不一定都會導致併發問題,關鍵還要看是否觸發了特定的邊界條件。有時這種不確定性恰恰就是導致我們上層應用程式碼bug的原因。隔離性的目的就是消除這種不確定性,給我們一個簡化的程式設計模型方便開發人員實現業務邏輯。隔離性的目的是為了解決多個事務併發訪問相同資料相互影響的問題。嚴格的隔離性保證多個事務執行的結果像事務序列化一個一個執行一樣(不是不允許底層實現併發執行,而是執行結果要符合序列化要求)。這種序列化的確定性保證有助於開發人員預判併發事務執行順序和結果,方便開發人員正確的實現自己的業務需求。

嚴格的隔離性保證雖好,但是一味阻止事務並行執行對效能的影響我們也不能小窺。所以資料庫在實現隔離性的時候除了嚴格的隔離性之外還提供了不同的級別,就是我們常說的事務隔離級別。不同的事務隔離級別允許一定的併發操作,也帶來了一定程度的效能提升,但是也引入了特定邊界條件下的併發問題。實際落地中我們需要再在事務隔離級別、效能要求和業務邏輯實現難易程度等各個方面權衡利弊。正確決策的前提是我們要充分理解不同隔離級別下特定的邊界條件和他它所引發的併發問題並能正確處理,這也是本文的重點。

一致性

一致性是指資料庫處於應用程式所期望的預期狀態。 更進一步說明,事務執行前是一個有效的狀態,事務的操作如果沒有違背約束,那麼事務執行之後也應該是一個有效的狀態。這裡的關鍵是一致性是應用程式所期望的狀態,所以理應由應用程式自行保證。資料庫只能保證很少的通用約束:例如外來鍵約束、唯一主鍵約束、唯一性約束等。

用我們最常見的轉賬場景舉例,小明和小紅兩個賬戶進行轉賬操作,轉賬前小明、小紅的賬戶金額總和要和轉賬後兩人的賬戶金額總和一致。顯然這樣業務邏輯資料庫無法保證,只能透過業務程式碼有由開發人員自己實現,實現的方法就是將轉出和轉入兩個操作放入同一個事務中。雖然事務無法直接提供業務邏輯一致性,但是我們可以透過事務的原子性和隔離性保證在異常場景和併發場景下事情依然像我們想象的方式進行,前提是我們處理好異常並正確運用隔離性。

永續性

永續性保證一旦事務提交成功,即使發生軟體或者硬體故障,事務寫入的任何資料也不會丟失。這是一個針對資料庫的基本功能的可用性保障,畢竟資料庫就是用來儲存資料的,我們都不希望已經存入的資料丟失。

總結來說,事務就是將多條操作組成一個原子單元,事務的原子性、隔離性和永續性分別從事務異常處理、事務併發執行和資料儲存保證三個方面給出了一個邏輯簡化的確定性的模型,應用這些特性開發人員可以簡單、方便的實現各種業務場景中的一致性需求。

雖然原子性和永續性底層實現可能頗為複雜的,但是它們的定義很好理解。但是隔離性理解起來就有一定難度,應用上需要多加小心,否則一不留神就會出問題。

標準事務隔離級別

接下來我們就開始本文的重點內容。ANSI/ISO SQL92定義的事務隔離級別有四種,他們分別為讀未提交(Read uncommitted)、讀已提交(Read committed)、可重複讀(Repeatable read)和序列化(Serializable)。資料庫廠商也參照標註做了自己的實現,但並不是所有資料庫都支援上文所述四種隔離級別,例如Oracle只支援讀已提交和序列化兩種。MySQL和PostgreSQL雖然支援這四種隔離級別,但是由於底層實現方式差異,相同隔離級別的行為也略有不同,這點我們在實踐篇在深入研究。所以,在一個新資料庫使用之前一定要詳細閱讀相關的說明文件,切勿將以往資料庫的使用經驗平移到不同資料庫。這也從另一個角度說明了事務隔離性的複雜性。雖然資料庫有不同的實現方式,但是都是以標準隔離級別的定義作為理論基礎和實現依據,萬變不離其宗,一旦我們徹底理解了標準隔離級別,其他的具體實現也就不難理解了。

如果按照允許併發的程度給事務隔離級別排序的話,大致順序如下:讀未提交>讀已提交>可重複讀>序列化。前三個隔離級別都是弱隔離級別,允許一定程度的併發行為,當然也會出現各種邊界條件下的併發問題,而最後一個是嚴格隔離級別,遵循序列化要求。下面我們將按照這個順序講解不同隔離級別在什麼樣的邊界條件下產生哪些併發問題。

讀未提交隔離級別

隔離性保證:一個事務可以讀取另一個事務未提交的資料。看似什麼都沒有保證,其實也做了基本保證,我們後面介紹。

髒讀

在當前隔離級別下,引入了我們第一個併發問題”髒讀“,髒讀的定義其實就是”一個事務讀取到另一個事務還未提交的資料“。髒讀聽上去就有一種“壞味道”,感覺上我們不應該讀取到事務執行過程中的臨時資料,一來這種資料有可能在事務回滾的時候全盤作廢,二來即使事務正常提交,這樣的資料也有可能破壞應用希望的一致性需求。

我們用經典的轉賬場景說明,小明賬戶金額100元,小紅賬戶金額100元,小明給小紅轉賬50元,轉賬操作的一致性要求任何時刻小明和小紅賬戶總金額保持一致均是200元。

假設有兩個轉賬操作事務事務分別按照如下順序並行操作:

時間線1234
事務1小明賬戶轉出50元 小紅賬戶轉入50元
事務2 讀取小明賬戶餘額為50元讀取小紅賬戶餘額為100元

注:示例中省略了事務開始和提交標記,所有事務的均在第一條語句執行前開啟事務並在最後一條語句執行完成後提交事務,後續示例也做同樣的處理。

從事務2角度來說,小明和小紅賬戶總金額為150元(小紅100元,小明50元),違反了應用希望的一致性要求。問題的關鍵在於事務2在時間線2的操作(加粗)讀取到了事務1尚未提交的資料,發生了髒讀。

這裡我們再深入思考幾點:

  • 髒讀是誘因,是它導致在兩個事務以某些特定順序(邊界條件)併發執行的時候產生了超出預期的結果(兩人賬戶總金額不等於200元)
  • 破壞一致性的邊界條件不只有示例中的一種情況,只要允許髒讀,我們就能找到某種兩個事務的併發執行的順序(邊界條件),從而導致執行結果超出預期。
  • 允許髒讀也不一定總會導致破壞一致性的結果,關鍵還要看兩個事務執行的順序是否達到了邊界條件。

以上這些思考也可以擴充套件到後續的隔離級別、併發問題和邊界條件的分析上。

如果我們不允許髒讀,那麼因它而生的各種破壞不一致性的邊界條件(事務間特定的併發執行順序)就不復存在了,事務再以相同的邊界條件執行也不會產生破壞一致性的情況了。事務隔離級別就是採用這個思路來解決併發問題並減少事務併發執行過程中各種邊界條件。理論上邊界條件越少上層應用處理邏輯就越簡單,越不容易出問題,但是效能影響就越大,反之亦然。

讀已提交隔離級別

隔離性保證:一個事務只能讀取另一個事務已經提交的資料。 相較於讀未提交隔離級別,讀已提交隔離級別只能讀取已經提交的資料,這就從根本上杜絕了髒讀問題。我們用上文中的髒讀的邊界條件示例驗證一下。

時間線1234
事務1小明賬戶轉出50元 小紅賬戶轉入50元
事務2 讀取小明賬戶餘額為100元讀取小紅賬戶餘額為100元

因為修改小明賬戶的事務1未提交,所以事務2在讀取的小明賬戶餘額的時候不能看到最新的值而是舊值100元,兩人賬戶總金額為200元,符合一致性需求。你也可以用你自己的髒讀邊界條件驗證一下,看問題是否解決了(如果你的邊界條件下依然有問題。別擔心,那可能是你找的不是髒讀的邊界條件而是其他併發問題的邊界條件,這些邊界問題我們會在後面講解)。

你可能好奇這種延時讀取能力是如何實現的。因為這已經超出了本文的重點,我不做過多講解,只給出簡單的思路,感興趣的同學可以自行研究。

  • 透過資料加鎖方式實現:在修改資料的時候加鎖,一個事務修改的資料如果其他事務想讀取,必須等到該事務提交之後,這樣其他事務讀取的資料一定是該事務提交之後的資料。顯然這種方法寫操作會阻塞讀操作,本質上是阻止併發發生,效能損失較大,和弱隔離級別實現初衷相悖,故大多數資料庫不採用這種方式。
  • 透過寫時複製方式實現:事務修改資料的時候產生一個副本,副本即為資料最新版本。該事務提交之前,其他事務讀取舊資料值,該事務提交之後用副本資料替換舊資料。這樣就可以在不阻塞其他事務讀操作的情況下,實現只能讀取到已提交資料的要求。本質上這種方法是一種空間換時間的思路,也是大多數資料庫採用的方式。

不可重複讀

有了讀已提交這個隔離性保證,我們解決了髒讀問題,看似萬事大吉了,但不要高興的太早,接下來我們介紹一個新的併發問題——不可重複讀。顧名思義不可重複讀的表現就是事務進行的過程中,如果對同一個值多次讀取,每次讀取的不一樣,像不能重複讀取一樣。為什麼多次每次讀取的資料不同呢,顯然就是在這個事務多次讀取資料的過程中,有其他事務對相同資料進行了修改並且完成了提交,其實這也是不可重複度的邊界條件。

舉例說明:\
假設有一張使用者表表中有一列使用者名稱資料,兩個事務按照如下順序執行:

時間線123
事務1讀取某條資料的使用者名稱值為小明 再次讀取該條資料的使用者名稱值為小紅
事務2 修改該條資料(和事務1讀取資料相同)使用者名稱值為小紅

示例中對使用者名稱兩次讀取行為完全符合讀已提交事務隔離級別的要求。事務1的第二次讀取時因為事務2已經提交了,所以讀取到了最新的值。你是不是覺得這樣也沒什麼問題,的確,現實業務很少有對一條資料讀取兩遍的情況,即使有的話也不一定會出什麼問題。所以這裡又說明了一個問題,即使程式碼遇到了邊界條件上的併發問題,也不一定就是bug,關鍵要看業務邏輯,程式碼的執行邏輯是否符合我們的業務預期。

接下來我們繼續使用轉賬場景來舉例說明,背景與前文相同,為了方便你閱讀我貼上過來:小明賬戶金額100元,小紅賬戶金額100元,小明給小紅轉賬50元,轉賬操作的一致性要求任何時刻小明和小紅賬戶總金額應該一致,均是200元。

兩個事務按照如下順序執行:

時間線1234
事務1讀取小明賬戶餘額為100元 讀取小紅賬戶餘額為150元
事務2 小明賬戶轉出50元小紅賬戶轉入50元

從事務1的角度看兩使用者賬戶的餘額總和為250元,不符合一致性需求。看似我們可以透過再讀一次小明的賬戶餘額來解決:

時間線12345
事務1讀取小明賬戶餘額為100元 讀取小紅賬戶餘額為150元再次讀取小明賬戶餘額為50元
事務2 小明賬戶轉出50元小紅賬戶轉入50元

但是我們犯了因果錯誤,兩個事務是的併發順序是隨機的、不可預測的,我們無法預判每次事務執行的併發順序,我們也無法確定哪條資料需要重讀?重讀的位置在哪?我們只是站在了上帝視角透過回溯的方式解決問題,但是我們只是解決了過去已經發生的問題而不是未來將要發生的問題。

在實際業務場景中,在一個事務中對一張大表的全表掃描或者聚合操作在備份和統計場景中比較普遍。這種需要一次讀取大量資料的事務(只讀長事務)會形成一個較長的視窗期,如果其他事務在這個視窗期內對讀取的資料進行了修改就很有可能觸發不可重複讀的邊界條件,產生資料不一致的問題。

可重複讀隔離級別

隔離性保證:首先繼承了讀已提交隔離級別的隔離性保證(也就是說可重複讀不存在髒讀問題)。然後增加了自身保證,一個事務對一條資料進行多次讀取,無論是否有其他事務對相同資料更新並提交,每次讀取的值都一樣。

可重複讀的隔離性保證似乎就是為不可重複讀問題量身製造的。我們再重放上文中的示例:

時間線1234
事務1讀取小明賬戶餘額為100元 讀取小紅賬戶餘額為100元
事務2 小明賬戶轉出50元小紅賬戶轉入50元

按照可重複讀的保證,即使在事務2已經完成了對小紅的轉賬操作並提交,但是小紅的賬戶餘額依然為事務開始之初的100元,符合一致性要求。

從表面上看小紅的賬戶金額就像是在事務1開始的時候就被打了快照一般,無論其他事務如何更改,在事務1任何時刻讀取到的都是同一個值。其實這也是大部分資料庫對可重複讀隔離級別的實現方式,資料庫對資料的每次修改保留快照,每個資料都從資料庫的一致性快照讀取,開始的時候讀最新提交的快照,即使事務執行過程中資料被另一個事務更改,但是事務只能看到特定時間點(事務開始的時間點)生成的快照資料(舊資料)。以上就是大名鼎鼎的多版本併發控制(MVCC)的實現原理。同讀已提交隔離級別的實現原因相同,之所以放棄加鎖的方式而採用資料副本的方式,也是一種空間換時間的思路,既提供了隔離性保證又允許一定程度的併發以降低對效能的影響。

講到這裡細心的同學可能發現了一個問題,上面提到的所有示例中有一個規律,都是一個只讀資料事務搭配一個修改資料事務,我們所有的併發問題都出現在資料讀取事務和資料寫入事務操作相同資料的場景下。那麼多個資料寫入事務操作相同資料的時候會不會產生併發問題呢?答案是的肯定的,我們繼續講解。

覆蓋更新和寫傾斜

如果你熟悉多執行緒程式設計,你一定會對接下來的介紹似曾相識。類似於a+=1、a++等操作並非原子操作(多執行緒中的原子性和事務機制中的隔離性類似,為了防止多個執行緒同時操作共享變數而造成操作結果與多執行緒序列執行不同),而是由讀取-設定-寫回這三個操作組合構成。多執行緒遞增計數器的程式碼示例經常作為用來演示多執行緒併發問題的場景,產生併發問題的根本原因就是多個執行緒在執行共享變數的讀取-設定-寫回操作時任意穿插組合。資料庫中的資料可以類比為共享變數,而併發執行的事務可以類比為多個執行緒,所以讀取-設定-寫回模式的事務同樣可能出現併發問題。

還用轉賬場景作為示例,小明賬戶金額200元,小紅賬戶金額100元,小明給小紅分兩次轉賬,每次轉出50元,轉出操作的一致性要求兩次轉賬後小明的賬戶餘額應該為200元-50元✖️2=100元。

時間線12345
事務1(第一次轉賬操作)讀取小明賬戶餘額為200元 計算出轉出後小明賬戶金額為200元-50元=150元,將小明賬戶金額設定為150元...
事務2(第二次轉賬操作) 讀取小明賬戶餘額為200元計算出轉出後小明賬戶金額為200元-50元=150元,將小明賬戶金額設定為150元 ...

注:1.通常轉賬操作都不會採用讀取再寫回的方式實現,示例只是用於說明併發問題。2.兩個事務都省略的給小紅轉入的操作。

如果按照上面是事務執行順序,兩次轉賬完成後,小明賬戶的餘額為150元而不是200元,似乎有一次轉賬行為沒有發生,它的結果丟失了。造成這個問題的關鍵是轉賬操作要先讀取小明賬戶的金額後,根據這個金額計算出小明轉賬後的金額再寫回。從事務1讀取資料到寫回資料的時間視窗內,事務2已經完成同樣的轉賬行為,更改了小明的賬戶餘額。這樣事務1中小明的賬戶餘額資料已經不是最新了,基於過期的條件去計算並寫回的資料就破壞了應用的一致性,造成其事務2的轉賬行為被覆蓋的結果。

我們在舉一個示例進一步說明,這次我們用建立賬戶場景舉例說明,假設銀行允許一個使用者有多個賬戶,小明在銀行裡已經有了兩個賬戶——賬戶1和賬戶2,小明想刪除賬戶,但是銀行規定一個使用者最少保留一個賬戶。所以刪除賬號操作的一致性要求就是無論小明如何操作,小明都有一個賬戶保留。假設小明同時執行了兩次刪除操作,事務執行順序如下:

時間線1234
事務1(刪除賬戶1)讀取小明賬戶總數為2條 賬戶數量大於1,符合刪除條件,刪除賬戶1
事務2(刪除賬戶2) 讀取小明賬戶總數為2條賬戶數量大於1,符合刪除條件,刪除賬戶2

兩個刪除操作最終的執行結果是小明的賬戶1、2均被刪除,違背了至少保留一個賬戶的一致性原則。原因和上一個示例相同,事務首先要讀取資料最為下一步操作的前提條件,然後根據前提條件執行下一步操作並將資料寫回資料庫,而在資料讀取和寫回的時間視窗期,首次讀取的資料已經被其他事務修改,作為判斷條件的資料已經改變了,所以後續進行的操作就有可能破壞了應用的一致性保證。與上第一個示例不同的是,第一個示例兩個事務操作的是相同的資料(小明賬戶金額),而本示例兩個事務操作的是不同的資料(賬戶1和賬戶2)。

本節第一個示例展現的併發問題和邊界條件我們稱之為覆蓋更新,第二個示例我們稱之為寫傾斜。其實他們都是由幻讀這種現象引起的,我們把一個事務的寫入改變了另一個事務的查詢結果的現象稱之為幻讀。更具體一點,如果兩個事務讀取相同的一組資料,然後更新其中的一部分(此時在特定邊界條件下可能發生幻讀現象),如果兩個事務更新不同的資料,有可能發生寫傾斜(第二個示例),如果兩個事務更新相同的資料,有可能發生覆蓋更新(第一個示例)。這麼看來,可重複隔離級別只能應對只讀事務的幻讀現象,碰到讀-寫事務就無能為力了。

這樣解釋可能過於理論化,不方便日常應用,在這裡我簡化一下。如果發現事務中出現讀取-寫回的模式,具體就是說我們遇到先讀取資料並據此做出判斷後再將相關資料寫回的場景就要加倍小心,考慮是否會發生覆蓋更新或者寫傾斜問題了。如果按照這個原則去判斷,日常的業務場景中還是比較常見的,例如新建使用者時使用者名稱唯一、會議室預定(同一時段同一個會議室只能被一個人預定)、電影票選座(同一場電影同一個座位只能被售賣一次)、銀行建立使用者賬戶(大部分銀行只允許一個使用者有一個一類賬戶)等場景。

解決方法

既然當前隔離級別無法解決這些問題。那我們如何應對呢?在這裡我們只做一個簡單介紹,細節部分交給實踐篇。

  • 原子寫入操作

    既然對於讀-寫事務問題出在讀寫操作不能保證原子性上,那如果將這兩個操作合併為一個不能被分割的原子單元,就可以解決問題了。資料庫提供類似的語法支援,如MySQL中執行update ... set value=value+1操作,因為寫操作鎖定的緣故(寫操作加鎖原因可以參照後面髒寫的內容),value的讀取和寫入操作就變成一個原子單元,其他事務無法並行打攪了。Redis也提供incrby、decrby、setnx等方法支援原子遞增、原子遞減和原子賦值等操作。但是這種方式只適用於覆蓋更新問題,對於寫傾斜就無能為力了。

  • 顯示加鎖

    與原子寫入的解決思路相同,都是透過阻讀-寫事務的併發來解決問題,不同的資料庫提供不同的加鎖機制,我們將在實踐篇詳細說明。我這裡要補充一句,加鎖不僅影響效能還有死鎖的風險,需要小心處理。

  • 原子比較和設定

    作為一種無鎖化程式設計的方式,Compare And Swap可以提供一種樂觀的方式應對併發。我們可以在MySQL上採用如下方式模擬CAS操作:update ... set value='new value' where value='old value',只有當value沒有變化才能執行更新出操作,如果在讀取資料和寫入資料的視窗期資料發生變化,更新操作會失效,我們可以選擇給客戶端丟擲異常或者自己重試處理。

  • 資料庫自動檢測覆蓋更新

    類似於死鎖檢測一樣,某些資料庫可以檢測出覆蓋更新問題並通知客戶端處理。但是暫時還沒有資料庫可以檢測出寫傾斜異常。

  • 使用序列化的事務隔離級別

    序列化隔離級別作為事務隔離性的最強保證,完全滿足事務隔離性定義——保證多個事務的執行結果同事務序列化一個個執行一致。序列化執行當然就覆蓋更新和寫傾斜問題。

序列化隔離級別

隔離性保證:保證多個事務的執行結果像序列執行一樣。

作為理論最強事務隔離級別,可以規避一切併發問題,但是序列化帶來的效能損失我們也不能小覷。不同的資料庫有不同的序列化實現方式,像Redis真的採用單執行緒序列化執行事務的方式來實現(最新版本的Redis已經支援多執行緒),有些資料庫採用悲觀的方式透過加鎖來阻止事務併發,而另一些採用樂觀的方式實現,他們可以在事務執行過程中檢測並發現破壞序列化隔離性的情況並報告給客戶端處理。雖然不是所有的實現都採用嚴格序列化而不允許併發的方式,但是相較於其他弱隔離級別,效能損失還是無法忽視的,而且我們也不會為了解決業務需求中少量的併發問題就採用這個隔離級別使其他事務全部序列化,通常我們會使用其他弱隔離級別對有可能出現問題的事務進行特殊處理(見覆蓋更新和寫傾斜的解決方法)。

在當前隔離級別下覆蓋更新和寫傾斜兩個併發問題的邊界條件執行演示我們放在實踐篇中,你現在只要認為兩個事務是序列執行的,一個事務提交完才可以執行下一個事務就可以了。

隔離級別小結

我們來總結一下不同隔離級別下不同併發問題的發生情況:

隔離級別/併發問題髒寫髒讀不可重複讀覆蓋更新寫傾斜
讀未提交
讀已提交
可重複讀
序列化

隨著隔離級別對併發能力的限制逐步增強,併發問題在逐步減少。雖然文中我們只介紹了讀未提交下髒讀問題,但是顯然讀已提交下的不可重複讀問題也會存在於讀未提交下,文中並沒有列出每個隔離級別所有的併發問題的邊界條件示例,只列出了每個隔離級別比較典型的併發問題,同學們可以按照文中思路自行補全。

髒寫

這裡我們看到了一個新的併發問題——髒寫,所有隔離級別都不會允許出現髒寫情況。因為髒寫是一個共性的問題,我放在最後講解。

和髒讀類似髒寫的定義就是一個事務修改了另一個事物沒有提交的資料,髒寫會造成什麼樣的影響呢,我們舉例說明,假設有兩張表,表1和表2,兩張表都有一列姓名欄位,我們將修改表1中某一條資料姓名的操作和修改表2中某一條資料姓名操作放入一個事務中,那麼當前的一致性期望就是無論如何修改表1和表2的被修改資料的名稱一定相同,但是如果兩個事務以下面的順序執行:

時間線1234
事務1修改表1中某條資料的姓名為小明 修改表2中某條資料的姓名為小明
事務2 修改表1中某條資料(和事務1修改資料相同)姓名為小紅修改表2中某條資料(和事務1修改資料相同)姓名為小紅

執行結果是表1中某條資料的姓名為小紅,表2中某條資料的姓名為小明,兩者姓名不同,不符合一致性要求。之所以會發生這樣的情況,是因為髒寫允許一個事務對另一個事務未提交的資料進行修改,這樣事務並行執行的結果就是以同一條資料最後一次修改為準,表1的最後一次修改來自事務2修改為小紅,而表2的最後一次修改為事務1修改為小明。顯然只是一個非常嚴重的問題,故所有事務隔離級別都不允許發生。資料庫通常都是用加鎖的方式來避免髒讀:保證一個事務不能修改另一個事務尚未提交的相同資料,修改行為將發生在另一個事務提交之後。所以上面執行順序不可能發生,下表才是事務實際的執行的順序。

時間線1234
事務1修改表1中某條資料的姓名為小明修改表2中某條資料的姓名為小明
事務2 修改表1中某條資料(和事務1修改資料相同)姓名為小紅修改表2中某條資料(和事務1修改資料相同)姓名為小紅

事務1修改表1中的某條資料之後就鎖定了這條資料,事務2對相同資料的修改只能等到事務1提交之後(事務1解鎖了資料),這就從根本上避免的髒寫的行為,所有隔離級別都提供了這個保證。

事務思想擴充套件

關於事務尤其是事務隔離性的內容我們已經介紹完了。相信你對事務的瞭解又豐富了不少。其實事務思想不只存在於資料庫領域,在日常業務場景中也會有所涉及,接下來我介紹一個現實中業務場景應用事務思想的案例。

首先我先介紹一下業務背景和需求,我們借用一個公有云服務資源開通場景,相信大部分開發人員都很熟悉,如果不瞭解可以去某某雲體驗一下。通常我們開通資源流程是這樣的,登入某某雲網站,選擇要購買的雲資源,這裡假設是雲主機,選擇雲資源配置-下訂單-支付-等待資源交付,資源交付完成就可以使用相應資源了。這就是一個最基本的資源開通流程。同時我們在選配頁面可以選擇開通雲主機的數量,也就是說我們可以在一個訂單內批次開通多個雲主機資源,如果將一個訂單中多個資源的交付操作組合進一個原子單元中,這就形成了最基本的事務的思想了。

接下來我們要思考的是有沒有必要在這裡應用事務的概念並提供事務保障。這裡我們的依據是業務場景,使用者是否需要事務性保障?或者說使用者可從保障中得到什麼好處?

首先,我們可以想象使用者既然選擇在一個訂單開通多臺主機,那多臺主機就是使用者的業務場景需求,例如5臺主機部署資料庫,3臺主機部署一個負載均衡的微服務叢集。使用者希望批次開通後部署相應業務,如果只開通了部分雲主機,使用者業務也無法部署,還需要根據上次開通情況二次開通。增加了使用者對於資源開通異常場景的處理複雜度。其次,資源下單支付的過程中涉及各種優惠策略和代金券邏輯,某些邏輯和訂單維度繫結,如果資源開通有事務性保證,退款邏輯比較簡單整體退款即可,優惠政策也做整體回退。而如果沒有事務性保證,就必須處理部分退款和優惠政策拆分邏輯,對於不可拆分的政策,要麼使用者損失要麼雲服務廠商損失。從這兩點考慮我們需要保證批次交付雲資源的事務原子性,對系統內要求批次交付的訂單中所有云資源必須全部開通成功才算交付完成,如果一個雲資源開通失敗,就停止後續資源開通並將已經開通資源釋放後給使用者返回交付異常。對使用者呈現的就是一個訂單中所有資源要麼全部開通要麼全部失敗的場景。這樣我們就保證了批次訂單資源開通的事務原子性。既降低了使用者異常處理難度又解決了退款邏輯互斥性問題。

除了原子性,事務還需要提供隔離性保證。對照我們示例中,批次的交付中雲資源是一個一個開通的,也就是說和可能第1個開通了,第99個還是開通中狀態,那麼客戶能否看到一個批次資源開透過程中的資源狀態就是一個隔離性問題。假設使用者可以看到這些已開通資源,使用者就可以使用,但是不到最後一刻這些資源是否回滾還未可知,一旦交付過程發生異常已開通資源回滾,會給使用者造成很大困擾。這場景就如同前面介紹髒讀一般。所以我們的批次開通雲資源場景的也需要提供隔離性保證,要求就是使用者不可見還未交付完成的批次開通訂單內資源的狀態,這些資源只能在交付完成的時刻整體可見。

我們透過一個批次雲資源交付的場景將事務的原子性和隔離性延伸了一下。只要你用心思考,事務的概念和思想可以應用到很多地方。

重點回顧

最後我總結一下本文內容,我們先從事務的基本概念講起。事務將多條操作組成一個原子單元,事務支援原子性、隔離性、一致性和永續性。原子性保證了多條操作共進退,隔離性保證了多個事務併發執行而不相互干擾,永續性作為資料儲存資料的基本保證,而一致性由應用程式定義並保證,但是需要事務其他特性的支援。相對其他特性,原子性和隔離性我們需要更多關注,原子性在異常場景下給我們提供了一個確定的、簡化的模型方便我們進行異常處理,而隔離性則解決了多個事務併發執行過程中可能出現的併發問題。

接下來我們重點介紹了標準定義的四種事務隔離級別和每個隔離級別中併發問題和邊界條件。因為讀未提交隔離級別併發問題較多而序列化隔離級別效能損耗較大所以現實場景中很少使用,讀已提交和可重複讀兩個隔離級別在效能上相差不大(在基於資料快照實現的前提下),但可重複讀可以解決只讀事務的幻讀問題,應該是我們大多數場景下的最優選(MySQL InnoDB引擎的預設隔離級別)。在可重複讀事務隔離級別中,我們介紹了幻讀所引發的併發問題及其邊界條件,日常開發中需要認真對待讀-寫事務場景,判斷當發生覆蓋更新和寫傾斜問題的時候是否破壞業務邏輯定義的一致性,如果有潛在問題需要選擇合適的方式加以處理。兩個事務在不同的隔離級別下併發執行的時候就有可能發生併發問題,而事務中所包含操作不同,發生的併發問題也不同,下面我將各種模式事務併發執行可能發生的問題總結出來。

事務模式只讀事務只寫事務讀-寫事務
只讀事務不可重複讀不可重複讀
只寫事務/髒寫、覆蓋更新、寫傾斜髒寫、不可重複讀、覆蓋更新、寫傾斜
讀-寫事務不可重複讀/髒寫、不可重複讀、覆蓋更新、寫傾斜

注:只讀事務表示事務中只有資料讀取操作,只寫事務表示資料中只有資料寫入操作,讀-寫事務表明事務中先讀取資料然後再寫回資料。

這裡要說明幾點:

  • 表中表示的是兩種模式事務併發執行下可能出現的併發問題。
  • 表中的併發問題不是在一種隔離級別而是在多種隔離級別情況下發生的。
  • 髒寫雖然在所有隔離級別下都被禁止,但是為了場景完整,也列在其中。
  • 之所以只寫事務和只寫事務之間也會發生覆更新和寫傾斜問題是考慮了這樣一個特殊場景,因為業務需要將一個讀-寫事務分成讀事務和寫事務兩個事務分步完成,讀事務和寫事務前後關聯。例如更新文章場景,使用者先點選文章詳情(讀操作),隨後線上編輯文章後提交更改(寫操作),雖然第二個更新文章的操作是隻寫事務,但是多個使用者同時對同一篇文章進行編輯的時候就有可能發生丟失更新問題(後一個使用者操作覆蓋了前一個使用者對文章的更改的內容)。所以排查事務間是否會出現併發問題,不能只看當前事務操作,還要結合業務流程捋清楚整個操作步驟流程。不過你也不用擔心,因為關聯場景造成併發問題的情況並不多,本例是最常見的。
  • 先執行只讀事務然後執行只寫事務和先執行只寫事務然後執行只讀事務可能產生的併發問題相同,所以效果相同的排列順序使用"/"表示。

最後我以一個事務思想應用的示例收尾,藉以拋磚引玉,引起思考。關於事務隔離級別的全部理論內容就介紹完了,雖然本文的標題是一文說透,但是我也知道事務所涉及的知識點浩如星海,我也只是挑選其中最重要的、最常用的內容講解。雖然不一定是一文說透,但是希望你能一文讀懂。如果有問題,歡迎給我留言,我們一起交流探討。本文的姊妹篇更加偏向於實踐,教你在不同資料庫下解決本文中各種併發問題,敬請期待。

最後,為了幫你鞏固本文的學習內容,我特意整理了導圖。

參考文件

  1. Martin Kleppmann.資料密集型應用系統設計[M].北京.中國電力出版社.2018

相關文章