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

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

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

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