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

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

  • 上:併發問題與隔離級別 主要講事務所要解決的問題、思路,先理解爲何須要事務以及事務併發控制中面臨的問題。
  • 下:隔離級別實現——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,返回提現成功

若是你已經很理解數據庫事務了,必定知道什麼狀況有問題,以及爲何出現這個問題,這篇文章對你太入門,不用繼續看。若是不太清楚,那但願你看完上、下兩篇就很是理解了,不然就是我寫得太爛。github

1、從新理解 ACID

1. 數據操做中面臨的問題

技術中的全部方案一定是爲了解決特定問題,先理解問題再看方案,學起來更簡單、理解更深刻,因此先從數據庫面臨的問題提及。 首先,要理解爲何數據庫會有事務的需求,先理解數據庫要解決的根本問題不是存儲,存儲問題已經被文件系統解決了,數據庫的目的是如何幫助開發者更可靠、更快速、更便利地使用存儲,更好地幫助開發者完成業務,業務中一個高頻需求是:有一批連續的操做,這一批操做要麼所有成功,要麼就能夠像沒有發生過同樣,不要因爲部分未成功而致使髒數據產生。若是沒有事務,咱們處理用戶下單的業務場景,就要超級多的代碼去handle各類錯誤、清理各類髒數據、避免可能的bug,好比下單成功卻因爲數據庫宕機致使沒有扣款。爲了提升開發效率、下降開發成本,就須要數據庫能提供一種保證:將一組操做看做一個單元,這一單元能夠所有成功,在部分失敗的狀況下,能夠徹底回滾,就像沒有發生,這一組操做稱爲事務(Transaction)。 可是僅僅作到上面那一點是不夠的,由於這一個簡單需求,其實引入了另外一個問題,請注意重點——「一組操做」,事務中可能存在着多個獨立操做,他們組合爲一組操做,理解多線程編程的同窗必定會立刻想到,這就會出現經典的併發問題,多個事務間若是不進行併發控制,就會產生各類意外結果,這不是使用者想要的。redis

總結一下,數據操做中面臨的問題:數據庫

  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),可是讀寫都加鎖效率很是低,讀寫之間只能排隊執行,有時候爲了效率,原則是能夠妥協的,因而隔離性並不嚴格,它被分爲了多種級別,從高到低分別爲:ide

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

每個級別都只是指導標準,每一個數據庫對其的實現都有差別,有的數據庫在Read Committed級別時,就已經實現了Read Repeatable的效果,有的數據庫乾脆不提供Read Uncommitted級別。 在隔離級別爲Serializable時,就會感受到事務像一個完徹底全的原子操做,不被任何中斷、併發所影響。 不少開發者理解的事務可能就在Serializable級別,你們誤覺得事務都是可串行化的,其實並非,大多數的數據庫默認隔離級別都不是可串行化,大多數在Read Repeatable或者Read Committed,要是按照可串行化的思惟去編程,卻用着低於可串行化的隔離級別,就很容易寫出致使數據在業務層面不一致的代碼,因此開發者必定要理解各個隔離級別及其原理,更好地支撐業務開發,下面會仔細地講隔離級別及其實現。oop

4. 持久性(Duration) 這是ACID中最好理解的,即事務成功提交後,對數據的修改永久的,即便系統發生故障,也不會丟失,這裏所說的故障,也只是通常錯誤好比宕機、系統Bug、斷電,若是是硬盤損毀,那就沒辦法,數據必定會丟失。

2、併發問題與隔離級別

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

髒寫(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元。

丟失更新
丟失更新與寫誤差很類似,都是因爲寫前提被改變,他們區別是,丟失更新是在同一個數據的最終不一致,而寫誤差的衝突不在同一個數據,是在不一樣數據中的最終不一致

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

參考資料

相關文章
相關標籤/搜索