并发事务正确性的准则 可串行化_从0到1理解数据库事务(上):并发问题与隔离级别...
最近準備寫一篇關于Spanner事務的分享,所以先分享一些基礎知識,涉及ACID、隔離級別、MVCC、鎖,由于太長,只好拆分成上下兩篇:
- 上:并發問題與隔離級別
主要講事務所要解決的問題、思路,先理解為什么需要事務以及事務并發控制中面臨的問題。
- 下:隔離級別實現——MVCC與鎖
隔離性是為了更好地做到并發控制,事務的并發表現會對業務有直接影響,所以這篇會詳細講如何實現隔離,主要是講兩種主流技術方案——MVCC與鎖,理解了MVCC與鎖,就可以舉一反三地看各種數據庫并發控制方案,并理解每種實現能解決的問題以及需要開發者自己注意的并發問題,以更好支撐業務開發。
文章開始前先給一個小思考,考慮一個情況:像下面這樣實現User提現100元,是否一定不會出問題?
Start Transaction
SELECT balance FROM users WHERE user_name=x; (此次讀取在Transaction中)
在代碼中判斷balance是否大于等于100
如果小于100元,End Transaction并且返回余額不足提現失敗
如果大于等于100元,則 UPDATE users SET balance = balance - 100 WHERE user_name=x; 然后Commit Transaction,返回提現成功
如果你已經很理解數據庫事務了,一定知道什么情況有問題,以及為什么出現這個問題,這篇文章對你太入門,不用繼續看。如果不太清楚,那希望你看完上、下兩篇就非常理解了,否則就是我寫得太爛。
一、重新理解 ACID
1. 數據操作中面臨的問題
技術中的所有方案必定是為了解決特定問題,先理解問題再看方案,學起來更簡單、理解更深入,所以先從數據庫面臨的問題說起。
首先,要理解為什么數據庫會有事務的需求,先理解數據庫要解決的根本問題不是存儲,存儲問題已經被文件系統解決了,數據庫的目的是如何幫助開發者更可靠、更快速、更便利地使用存儲,更好地幫助開發者完成業務,業務中一個高頻需求是:有一批連續的操作,這一批操作要么全部成功,要么就可以像沒有發生過一樣,不要由于部分未成功而導致臟數據產生。如果沒有事務,我們處理用戶下單的業務場景,就要超級多的代碼去handle各種錯誤、清理各種臟數據、避免可能的bug,比如下單成功卻由于數據庫宕機導致沒有扣款。為了提高開發效率、降低開發成本,就需要數據庫能提供一種保證:將一組操作看作一個單元,這一單元可以全部成功,在部分失敗的情況下,可以完全回滾,就像沒有發生,這一組操作稱為事務(Transaction)。
但是僅僅做到上面那一點是不夠的,因為這一個簡單需求,其實引入了另一個問題,請注意重點——“一組操作”,事務中可能存在著多個獨立操作,他們組合為一組操作,理解多線程編程的同學一定會馬上想到,這就會出現經典的并發問題,多個事務間如果不進行并發控制,就會產生各種意外結果,這不是使用者想要的。
總結一下,數據操作中面臨的問題:如何將一組操作看作一個整體,要么全部成功,要么全部回滾。
如何在滿足上一條需求的情況下,能夠對它進行并發控制,保證不要出現意外結果。
2. 我們需要什么:ACID
ACID 是為了解決上述問題所總結出,為保證事務是正確可靠的,所必須具備的四個特性:
1. 原子性(Atomicity)
事務中的原子性是一個常常被大家誤解的特性,因為這個原子性的意思和我們通常語境下的原子性不太一樣,大多數時候原子性是指一條不可再分割、不會被中斷影響的指令,比如讀取一個內存地址的值、將值寫回內存地址、redis的SETNX(set if not exists),這些操作都符合我們常說的原子性。
可是事務中的原子性,并不是指事務具有不被中斷影響的特點,它僅僅是指,事務中的所有操作應該被看作不可分割的一組指令,任何一個指令不能獨立存在,要么全部成功執行,要么全部不發生(也就是回滾)。
還有很多同學對這里所說的“成功執行”有誤解,成功執行是指數據庫層面的,而不是業務層面的,舉個例子,客戶購買商品A,可是在購買時,商家剛好下架了商品,那么此時執行 update products 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標準中有對于各個隔離級別所允許出現的問題作出規定:
除了以上4個問題外,下面還有3個問題,更偏向業務層面,不過也是由于隔離不足引起的:
讀偏斜(Read Skew)
Skew可以理解為不一致,因此讀偏斜可以理解為讀結果違反業務一致性,比如X、Y兩個賬戶余額都為50,他們總和為100,事務A讀X余額為50,然后事務B從X轉賬50到Y然后提交,事務A在B提交后讀Y發現余額為100,那么它們總和變成了150,此時違反業務一致性。
寫偏斜(Write Skew)
寫偏斜可以理解為事務commit之前寫前提被破壞,導致寫入了違反業務一致性的數據,網上有個很好的簡稱為寫前提困境,也就是讀出某些數據,作為另一些寫入的前提條件,但是在提交前,讀入的數據就已被別的事務修改并提交,這個事務并不知道,然后commit了自己的另一些寫入,寫前提在commit前就被修改,導致寫入結果違反業務一致性。
寫偏斜發生在寫前提與寫入目標不相同的情境下。
這是業務開發中最容易出錯地方,如果開發者不太理解隔離級別,也不知道目前使用的是哪個隔離級別,很可能寫出有寫偏斜的代碼,造成業務不一致。
舉個例子:
信用卡系統對不同等級的會員有積分加成,3級會員則每次都3倍積分,同時,會有定時任務檢查當積分不滿足要求時,就會降級。
首先,會員進行了刷卡消費,此時要計算積分,開啟了事務A,讀到會員等級為3,與此同時定時任務也開始了,讀到會員積分為2800,已經不滿足3000分應該降級為2級,然后將會員等級降級為2并且commit,由于事務A讀到的等級為3,它還是按照3倍積分為會員增加了積分,會員賺了,多虧那個程序員不理解他使用的事務隔離級別,出現了業務不一致。
丟失更新(Lost Updates)
由于未提交事務之間看不到對方的修改,因此都以一個舊前提去更新同一個數據,導致最后的提交結果是錯誤值。
假設有支付寶賬戶X,余額100元,事務A、B同時向X分別充值10元、20元,最后結果應該為130元,但是由于丟失更新,最后是110元。
丟失更新與寫偏斜很相似,都是由于寫前提被改變,他們區別是,丟失更新是在同一個數據的最終不一致,而寫偏斜的沖突不在同一個數據,在不同數據中的最終不一致。
這一篇講到的所有問題都會在下一篇講隔離級別實現中得到解決,理解隔離級別實現,有助于選擇合適的隔離級別,或者在代碼層面有意識地避免隔離級別不足所帶來的問題。
參考資料
- 《MySQL 是怎樣運行的:從根兒上理解 MySQL》
- 《數據庫事務處理的藝術:事務管理與并發控制》
- 《數據庫事務、隔離級別和鎖》(博客)
- 《SQL隔離級別》(博客)
- 《開發者都應該了解的數據庫隔離級別》(博客)
- 《A beginner’s guide to read and write skew phenomena》(博客)
總結
以上是生活随笔為你收集整理的并发事务正确性的准则 可串行化_从0到1理解数据库事务(上):并发问题与隔离级别...的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: 离线仿真调试,加快项目进度!
- 下一篇: robocode java_IBM Ro
