1. 程式人生 > 程式設計 >從0到1理解資料庫事務(上):併發問題與隔離級別

從0到1理解資料庫事務(上):併發問題與隔離級別

最近準備寫一篇關於Spanner事務的分享,所以先分享一些基礎知識,涉及ACID、隔離級別、MVCC、鎖,由於太長,只好拆分成上下兩篇:

  • 上:併發問題與隔離級別 主要講事務所要解決的問題、思路,先理解為什麼需要事務以及事務併發控制中面臨的問題。
  • 下:隔離級別實現——MVCC與鎖 隔離性是為了更好地做到併發控制,事務的並發表現會對業務有直接影響,所以這篇會詳細講如何實現隔離,主要是講兩種主流技術方案——MVCC與鎖,理解了MVCC與鎖,就可以舉一反三地看各種資料庫併發控制方案,並理解每種實現能解決的問題以及需要開發者自己注意的併發問題,以更好支撐業務開發。

文章開始前先給一個小思考,考慮一個情況: 像下面這樣實現User提現100元,是否一定不會出問題?

  1. Start Transaction
  2. SELECT balance FROM users WHERE user_name=x; (此次讀取在Transaction中)
  3. 在程式碼中判斷balance是否大於等於100
  4. 如果小於100元,End Transaction並且返回餘額不足提現失敗
  5. 如果大於等於100元,則 UPDATE users SET balance = balance - 100 WHERE user_name=x; 然後Commit Transaction,返回提現成功

如果你已經很理解資料庫事務了,一定知道什麼情況有問題,以及為什麼出現這個問題,這篇文章對你太入門,不用繼續看。如果不太清楚,那希望你看完上、下兩篇就非常理解了,否則就是我寫得太爛。

一、重新理解 ACID

1. 資料操作中面臨的問題

技術中的所有方案必定是為瞭解決特定問題,先理解問題再看方案,學起來更簡單、理解更深入,所以先從資料庫面臨的問題說起。 首先,要理解為什麼資料庫會有事務的需求,先理解資料庫要解決的根本問題不是儲存,儲存問題已經被檔案系統解決了,資料庫的目的是如何幫助開發者更可靠、更快速、更便利地使用儲存,更好地幫助開發者完成業務,業務中一個高頻需求是:有一批連續的操作,這一批操作要麼全部成功,要麼就可以像沒有發生過一樣,不要由於部分未成功而導致髒資料產生。如果沒有事務,我們處理使用者下單的業務場景,就要超級多的程式碼去handle各種錯誤、清理各種髒資料、避免可能的bug,比如下單成功卻由於資料庫宕機導致沒有扣款。為了提高開發效率、降低開發成本,就需要資料庫能提供一種保證:將一組操作看作一個單元,這一單元可以全部成功,在部分失敗的情況下,可以完全回滾,就像沒有發生,這一組操作稱為事務(Transaction)

。 但是僅僅做到上面那一點是不夠的,因為這一個簡單需求,其實引入了另一個問題,請注意重點——“一組操作”,事務中可能存在著多個獨立操作,他們組合為一組操作,理解多執行緒程式設計的同學一定會馬上想到,這就會出現經典的併發問題,多個事務間如果不進行併發控制,就會產生各種意外結果,這不是使用者想要的。

總結一下,資料操作中面臨的問題:

  1. 如何將一組操作看作一個整體,要麼全部成功,要麼全部回滾。
  2. 如何在滿足上一條需求的情況下,能夠對它進行併發控制,保證不要出現意外結果。

2. 我們需要什麼:ACID

ACID 是為瞭解決上述問題所總結出,為保證事務是正確可靠的,所必須具備的四個特性:

1. 原子性(Atomicity) 事務中的原子性是一個常常被大家誤解的特性,因為這個原子性的意思和我們通常語境下的原子性不太一樣,大多數時候原子性是指一條不可再分割、不會被中斷影響的指令,比如讀取一個記憶體地址的值、將值寫回記憶體地址、redis的SETNX(set if not exists),這些操作都符合我們常說的原子性。 可是事務中的原子性,並不是指事務具有不被中斷影響的特點,它僅僅是指,事務中的所有操作應該被看作不可分割的一組指令,任何一個指令不能獨立存在,要麼全部成功執行,要麼全部不發生(也就是回滾)。 還有很多同學對這裡所說的“成功執行”有誤解,成功執行是指資料庫層面的,而不是業務層面的,舉個例子,客戶購買商品A,可是在購買時,商家剛好下架了商品,那麼此時執行 update products set price=100 where product_id=A and status=銷售中 ,由於product的status已經變成“下架”,導致被更新的行數為0,這個算成功執行嗎?算!資料庫不報錯、不宕機、正常執行就是成功,更新行數為0是資料庫的正常返回結果,這在業務上是失敗,在資料庫層面是成功,這種情況資料庫不會執行回滾,需要程式設計師判斷更新行數,如果為0,手動回滾。 如果資料庫由於硬體或者系統問題發生宕機、報錯,這樣才算是指令執行失敗,此時資料庫會重試或者直接回滾,然後將錯誤返回給開發者。 原子性不止為開發者保證了事務的可靠性(不會因為資料庫出錯而產生髒資料),還能讓開發者手動回滾,提供了業務的便利性。

2. 一致性(Consistency) 這個名詞也是相當令人困惑,與資料庫主從複製中所說的“一致性”不同,主從複製的一致性是指多個副本間是否完成同步、資料相同,而這裡的一致性是指事務是否產生非預期中間狀態或結果。比如髒讀和不可重複讀,產生了非預期中間狀態,髒寫與丟失修改則產生了非預期結果。一致性實際上是由後面的隔離性去進一步保證的,隔離性達到要求,則可以滿足一致性。也就是說,隔離不足會導致事務不滿足一致性要求,所以務必理解各個隔離級別,才能少寫Bug。

3. 隔離性(Isolation) 簡單來說,隔離性就是多個事務互不影響,感覺不到對方存在,這個特性就是為了做併發控制。在多執行緒程式設計中,如果大家都讀寫同一塊資料,那麼久可能出現最終資料不一致,也就是每條執行緒都可能被別的執行緒影響了。按理說,最嚴格的隔離性實現就是完全感知不到其他併發事務的存在,多個併發事務無論如何排程,結果都與序列執行一樣。為了達到序列效果,目前採用的方式一般是兩階段加鎖(Two Phase Locking),但是讀寫都加鎖效率非常低,讀寫之間只能排隊執行,有時候為了效率,原則是可以妥協的,於是隔離性並不嚴格,它被分為了多種級別,從高到低分別為:

  • ⬇️可序列化(Serializable)
  • ⬇️可重複讀(Read Repeatable)
  • ⬇️已提交讀(Read Committed)
  • ⬇️未提交讀(Read Uncommitted)

每一個級別都只是指導標準,每個資料庫對其的實現都有差異,有的資料庫在Read Committed級別時,就已經實現了Read Repeatable的效果,有的資料庫乾脆不提供Read Uncommitted級別。 在隔離級別為Serializable時,就會感覺到事務像一個完完全全的原子操作,不被任何中斷、併發所影響。 很多開發者理解的事務可能就在Serializable級別,大家誤以為事務都是可序列化的,其實並不是,大多數的資料庫預設隔離級別都不是可序列化,大多數在Read Repeatable或者Read Committed,要是按照可序列化的思維去程式設計,卻用著低於可序列化的隔離級別,就很容易寫出導致資料在業務層面不一致的程式碼,所以開發者一定要理解各個隔離級別及其原理,更好地支撐業務開發,下面會仔細地講隔離級別及其實現。

4. 永續性(Duration) 這是ACID中最好理解的,即事務成功提交後,對資料的修改永久的,即使系統發生故障,也不會丟失,這裡所說的故障,也只是一般錯誤比如宕機、系統Bug、斷電,如果是硬碟損毀,那就沒辦法,資料一定會丟失。

二、併發問題與隔離級別

在討論各個隔離級別的實現之前,先看一下在事務併發執行時,隔離不足會導致的問題。

髒寫(Dirty Write)

還未提交的事務寫了另一個未提交事務所寫過的資料,稱為髒寫,比如: 兩個併發執行的事務A、B,A寫了x,在A還未提交前,B也寫了x,然後A提交,此時雖然B還沒有提交,但是A也會發現自己寫的x不見了。

髒寫

很多地方用“覆蓋”去形容髒寫,但是我覺得不太適合,因為覆蓋暗示了一種先後鏈條,某個事務寫了資料,在昨天就提交了,今天有事務來寫同一個資料,可以稱之為覆蓋,昨天的資料成為歷史,但這不是髒寫,所以更適合的形容可能是“擦除”,事務發現自己的提交被別人擦除,好像不存在。 髒寫是事務一定不允許發生的,所以不管是哪個隔離級別都一定不允許髒寫

髒讀(Dirty Read)

由於事務的可回滾特性,因此commit前的任何讀寫,都有被撤銷的可能,假如某個事物讀取了還未commit事務的寫資料,後來對方回滾了,那麼讀到的就是髒資料,因為它已經不存在了。

髒讀
避免髒讀可以採用加鎖或者快照讀的解決方案。在**已提交讀(Read Committed)**級別就可以避免髒讀,因為讀到的一定是已經Commit的資料。在業務開發中,雖然有未提交讀(Read Uncommitted),但是幾乎是沒有人會用的,讀到髒資料一般對業務是很大的傷害,所以有的資料庫乾脆都不支援未提交讀,比如PostgreSQL。

不可重複讀(Non-Repeatable Read)

事務A讀取一個值,但是沒有對它進行任何修改,另一個併發事務B修改了這個值並且提交了,事務A再去讀,發現已經不是自己第一次讀到的值了,是B修改後的值,就是不可重複讀。 簡單來說就是第一次讀的值,啥都沒做,下次讀它也有可能發生變化。

不可重複讀
一般資料庫使用MVCC,在事務的第一條語句開始時生成Read View,事務之後的所有讀取,都是基於同一個Read View,以此避免不可重複讀問題。

幻讀(Phantom)

與不可重複讀非常類似,事務A查詢一個範圍的值,另一個併發事務B往這個範圍中插入了資料並提交,然後事務A再查詢相同範圍,發現多了一條記錄,或者某條記錄被別的事務刪除,事務A發現少了一條記錄。

幻讀

幻讀容易與不可重複讀混淆,區別它們只需要記住不可重複讀面向的是“同一條記錄”,而幻讀面向的是“同一個範圍”。 MVCC雖然使用快照的方式解決了不可重複讀,但是還是不能避免幻讀,幻讀需要通過範圍鎖解決,可能大家會覺得很奇怪,為什麼快照讀無法避免幻讀,這個會在下一篇文章中詳細講。

SQL標準中有對於各個隔離級別所允許出現的問題作出規定:

SQL標準
除了以上4個問題外,下面還有3個問題,更偏向業務層面,不過也是由於隔離不足引起的:

讀偏差(Read Skew)

Skew可以理解為不一致,因此讀偏差可以理解為讀結果違反業務一致性,比如X、Y兩個賬戶餘額都為50,他們總和為100,事務A讀X餘額為50,然後事務B從X轉賬50到Y然後提交,事務A在B提交後讀Y發現餘額為100,那麼它們總和變成了150,此時違反業務一致性。

Read Skew

寫偏差(Write Skew)

寫偏差可以理解為事務commit之前寫前提被破壞,導致寫入了違反業務一致性的資料,網上有個很好的簡稱為寫前提困境,也就是讀出某些資料,作為另一些寫入的前提條件,但是在提交前,讀入的資料就已被別的事務修改並提交,這個事務並不知道,然後commit了自己的另一些寫入,寫前提在commit前就被修改,導致寫入結果違反業務一致性。 寫偏差發生在寫前提與寫入目標不相同的情境下。 這是業務開發中最容易出錯地方,如果開發者不太理解隔離級別,也不知道目前使用的是哪個隔離級別,很可能寫出有寫偏差的程式碼,造成業務不一致。 舉個例子: 信用卡系統對不同等級的會員有積分加成,3級會員則每次都3倍積分,同時,會有定時任務檢查當積分不滿足要求時,就會降級。 首先,會員進行了刷卡消費,此時要計算積分,開啟了事務A,讀到會員等級為3,與此同時定時任務也開始了,讀到會員積分為2800,已經不滿足3000分應該降級為2級,然後將會員等級降級為2並且commit,由於事務A讀到的等級為3,它還是按照3倍積分為會員增加了積分,會員賺了,多虧那個程式設計師不理解他使用的事務隔離級別,出現了業務不一致。

Write Skew

丟失更新(Lost Updates)

由於未提交事務之間看不到對方的修改,因此都以一箇舊前提去更新同一個資料,導致最後的提交結果是錯誤值。 假設有支付寶賬戶X,餘額100元,事務A、B同時向X分別充值10元、20元,最後結果應該為130元,但是由於丟失更新,最後是110元。

丟失更新
丟失更新與寫偏差很相似,都是由於寫前提被改變,他們區別是,丟失更新是在同一個資料的最終不一致,而寫偏差的衝突不在同一個資料,是在不同資料中的最終不一致

這一篇講到的所有問題都會在下一篇講隔離級別實現中得到解決,理解隔離級別實現,有助於選擇合適的隔離級別,或者在程式碼層面有意識地避免隔離級別不足所帶來的問題。

參考資料