今天,我們來聊數(shù)據(jù)庫事務ACID、隔離級別、悲觀鎖和樂觀鎖。無論是在工作中,還是在筆試面試中,數(shù)據(jù)庫相關的問題,總是繞不開,不會的話,很容易歇菜,你懂的。數(shù)據(jù)庫事務場景
在銀行系統(tǒng)中,數(shù)據(jù)庫事務是必須的。在電商系統(tǒng)中,也是如此。
來看下A給B匯款100元的例子,可以看到,A賬戶扣款100元,此時如果進程崩潰或者機器掉電,那么這100元就沒有加到B的賬戶中,自然會導致用戶的強烈投訴:

如果先給B賬戶加錢,然后給A賬戶扣錢,會怎樣呢?可以看到,此時如果進程崩潰或者機器掉電,銀行白白給B加了100元,而沒有扣減A的100元,只怕銀行會虧得沒褲子穿:

墨菲定律說:凡是會出錯的事,一定會出錯。 而且,一旦發(fā)生,將造成較大危害。所以,在軟件設計上,有必要考慮這種異常。進程崩潰,機房掉電,網(wǎng)絡抖動,硬件損壞,都應該被視為常態(tài),都應該被考慮到。

如果要在應用層處理這些異常問題,將極為困難,甚至幾乎不可能。做過軟件開發(fā)的朋友應該知道,很多時候,如果異常問題處理得不妥當,將要投入大量時間分析和補救,且不一定能補救回來。
所以,有必要引入數(shù)據(jù)庫事務。所謂事務,就是一組SQL操作,它們不可分割,不能被打斷,要么都成功,要么都失敗。具體地說,就是要滿足ACID性質。
引入事務之后,應用層再也不用擔心上述異常了,因為數(shù)據(jù)庫已經(jīng)為我們處理得很好了。很多書籍把ACID放在一起敘述,我認為有點扯,因為他們并不正交。在我看來,C是AID的最終目的。下面,我們來看下ACID性質。
Atomicity(原子性)
古希臘哲學家德謨克利特認為,原子是構成世界萬物的單元,且不可分割:
所以,原子性這個詞的含義就是不可分割。以上述的步驟一和步驟二為例,它們是一個整體,不可分割,要么同時成功,要么同時失敗。那么具體怎樣去實現(xiàn)原子性呢?有興趣的朋友可以了解下undo log, 在此不展開敘述。我們不是DBA, 不需要精通數(shù)據(jù)庫的眾多具體細節(jié),但是,至少要知道大概的原理和可行性,這可以為我們解決類似問題提供思路和參考。Consistency(一致性)
一致性是我們最終的目的,籠統(tǒng)地說,一致性就是要確保數(shù)據(jù)是正確無誤的。所謂valid data, 其實就是正確無誤的data:原子性沒法完全保證一致性,因為在多個事務操作數(shù)據(jù)庫時,還需要涉及到隔離性。
Isolation(隔離性)
隔離性,就是要隔離不同事務,隔離性是本文的重點,我們會針對不同的隔離級別進行介紹,先來看一眼:
需要強調的是,每種存儲引擎的實現(xiàn)不盡一致,在可重復讀隔離級別下,有的朋友在進行驗證時,并未出現(xiàn)所謂的幻讀,這是因為:關于InnoDB是否存在幻讀問題,我們將在本文的實驗部分進行驗證。
Durability(持久性)
持久性的意思是,一旦事務提交,它對數(shù)據(jù)庫的變更是永久性的。實際上,事務提交后,最后不一定會落地到數(shù)據(jù)庫中(比如落地時機器斷電了),那怎么保證一定要落地成功呢?
這就涉及到redo log了,我們也不需要具體知道redo log的細節(jié),但是,我們從邏輯上可以縷清:redo log要記錄什么?redo log為什么能保證持久性?很多時候,就是這樣,對于不太相關的東西,可以不精通,但至少要了解大概邏輯和思路。這樣才能說服自己,才不會有一種玄乎其玄的感覺。接下來,我們看這個問題:客戶端A的事務,是否應該看到客戶端B的事務所作的修改?這就涉及到數(shù)據(jù)庫事務的隔離級別。
在本文中,如下圖示都是基于我的實際驗證。建議有興趣的朋友一起動手,感受一下。說明:事務A和事務B位于兩個不同的終端窗口,對應兩個不同的進程,在改變隔離級別時,僅改A的隔離級別來進行驗證。1.讀未提交
可見,設置讀未提交后,事務B在未提交時,事務A讀出了a=10, 這是臟數(shù)據(jù)(B事務被回滾了),這就是所謂的“臟讀”。
2.讀已提交
可見,設置讀已提交后,事務B在未提交時,事務A讀出了a=0, 在事務B提交后,又讀出了a=10, 出現(xiàn)了“不可重復讀”。3. 可重復讀
可以看到,看事務A內,讀取的值具有前后不變的特點,這就是“可重復讀”。只有當事務A提交后,才能讀出a=10. 在MySql中,默認的隔離級別就是可重復讀。在B事務提交后,A事務執(zhí)行select ... where a = 100時,發(fā)現(xiàn)還是無記錄,可見此時并未產(chǎn)生“幻讀”。但是,如果用select for update, 則出現(xiàn)了“幻讀”現(xiàn)象。可見,在InnoDB可重復讀的隔離級別中,并未完全解決“幻讀”問題,而是解決了讀數(shù)據(jù)情況下的“幻讀”問題,而對于修改的操作依然存在“幻讀”問題。4.串行化
可以看到,即使對于讀操作,也會加鎖,一個事務要等待另一個事務完成。串行化是完全的隔離級別,會導致大量超時和鎖競爭問題,在高并發(fā)場景中,較少用到串行化。在SQLite中,默認的隔離級別就是串行化。丟失更新問題
有了這些隔離級別,就萬事大吉了嗎? 當然不是。以MySql為例,在默認隔離級別下,會有丟失更新的問題。領導A給你加了30元的雞腿,領導B給你加了40元的雞腿,最終結果發(fā)現(xiàn),只有40元雞腿,顯然,這是不合理的:悲觀鎖
所謂悲觀鎖,就是持悲觀態(tài)度,認為一定會有沖突,所以提前加強保護。悲觀鎖可以用select for update來實現(xiàn),之前項目中就經(jīng)常這樣玩,但后來重構了代碼,統(tǒng)一優(yōu)化成了分布式鎖。使用分布式鎖, 代碼示意如下(如下使用方法有問題):func proc() {money := queryMoneyFromDb() begin lock begin transaction money += req.Money setToDb(money) end transaction end lock}
當兩個進程都讀取money=0后,進程A獲取鎖,并且執(zhí)行完畢后,money=30,然后進程B獲取鎖,執(zhí)行完畢后,顯然可知,最后的結果是money=40,仍然存在丟失更新的問題。曾經(jīng)在項目中,就出現(xiàn)過這種錯誤,導致了低概率的金額不匹配,比較難發(fā)現(xiàn)問題,最后還是通過對賬發(fā)現(xiàn)了,然后查出上述錯誤的用法。func proc() { begin lock begin transaction money := queryMoneyFromDb() money += req.Money setToDb(money) end transaction end lock}
樂觀鎖
所謂樂觀鎖,就是抱有很樂觀的態(tài)度,也就是假定不會存在數(shù)據(jù)沖突(即使有沖突也不怕,樂觀得很)。具體實現(xiàn)時,可以在數(shù)據(jù)上打一個version標記,基于version進行控制,代碼示意如下:func proc() { begin transaction select * from T where user_id = 123456 update T set money = xxx, version = version + 1 where user_id = 123456 and version = 100; end transaction}
分析一下:進程A和進程B都讀到了version=100的數(shù)據(jù),進程A在加完30元后,同時讓version變成了101;此時進程B去執(zhí)行,突然發(fā)現(xiàn)不滿足where version=100這個條件,所以更新失敗,這是合理的,符合預期,寧可執(zhí)行失敗,也不能產(chǎn)生數(shù)據(jù)錯誤。
這里有一個極為微妙的問題:在MySql可重復讀隔離級別下,當進程A的update執(zhí)行成功并且提交事務后,version變?yōu)榱?01, 但是在進程B看來,version還是100(可重復讀), 為什么B在執(zhí)行update的時候,在where version=100條件下又無法真正執(zhí)行update呢?
要注意,可重復讀是針對select而言的,而不是select for update或者update之類的操作,當A進程事務提交后,B進程事務看到的情況如下:mysql> select * from user;+----+-------+---------+| id | money | version |+----+-------+---------+| 1 | 0 | 100 |+----+-------+---------+1 row in set (0.00 sec)
mysql> select * from user for update;+----+-------+---------+| id | money | version |+----+-------+---------+| 1 | 30 | 101 |+----+-------+---------+1 row in set (0.25 sec)
mysql> select * from user;+----+-------+---------+| id | money | version |+----+-------+---------+| 1 | 0 | 100 |+----+-------+---------+1 row in set (0.00 sec)
可見,對B事務而言,用select看,看不到B事務的更新,這滿足事務的可重復讀。但是,當使用select for update時,能看到B事務的更新。所以,當B事務使用update嘗試更新where version=100的記錄時,發(fā)現(xiàn)更新失敗,這是我們期望的結果,寧可執(zhí)行失敗,也不能產(chǎn)生數(shù)據(jù)錯誤。針對這種失敗,可以采用多次重試。
至于悲觀鎖和樂觀鎖的選擇,還是要依賴于具體業(yè)務。數(shù)據(jù)的一致性如此重要,可千萬別把用戶的錢給算錯了。對于頻繁寫沖突的業(yè)務,用樂觀鎖肯定是不太好的,重試操作會增加各種開銷,此時可以考慮使用悲觀鎖。對于寫沖突較少發(fā)生的場景,那樂觀鎖就非常適合了。·················· END ··················
點擊關注公眾號,免費領學習資料