一、前言
-
在瞭解悲觀鎖和樂觀鎖之前,我們先了解一下什麼是鎖,為什麼要用到鎖?
-
技術來源於生活,鎖不僅在程式中存在,在現實中我們也隨處可見,例如我們上下班打卡的指紋鎖,保險櫃上的密碼鎖,以及我們我們登入的使用者名稱和密碼也是一種鎖,生活中用到鎖可以保護我們人身安全(指紋鎖)、財產安全(保險櫃密碼鎖)、資訊保安(使用者名稱密碼鎖),讓我們更放心的去使用和生活,因為有鎖,我們不用去擔心個人的財產和資訊洩露。
-
而程式中的鎖,則是用來保證我們資料安全的機制和手段,例如當我們有多個執行緒去訪問修改共享變數的時候,我們可以給修改操作加鎖(syncronized)。當多個使用者修改表中同一資料時,我們可以給該行資料上鎖(行鎖)。因此,當程式中可能出現併發的情況時,我們就需要通過一定的手段來保證在併發情況下資料的準確性,通過這種手段保證了當前使用者和其他使用者一起操作時,所得到的結果和他單獨操作時的結果是一樣的
-
沒有做好併發控制,就可能導致髒讀、幻讀和不可重複讀等問題,如下圖所示:
由於併發操作,如果沒有加鎖進行併發控制,資料庫的最終的一條資料可能為3也有可能為5,導致數值不準確
二、悲觀鎖和樂觀鎖
首先我們需要清楚的一點就是無論是悲觀鎖還是樂觀鎖,都是人們定義出來的概念,可以認為是一種思想。
2.1、悲觀鎖
悲觀鎖(Pessimistic Lock): 就是很悲觀,每次去拿資料的時候都認為別人會修改。所以每次在拿資料的時候都會上鎖。這樣別人想拿資料就被擋住,直到悲觀鎖被釋放,悲觀鎖中的共享資源每次只給一個執行緒使用,其它執行緒阻塞,用完後再把資源轉讓給其它執行緒
但是在效率方面,處理加鎖的機制會產生額外的開銷,還有增加產生死鎖的機會。另外還會降低並行性,如果已經鎖定了一個執行緒A,其他執行緒就必須等待該執行緒A處理完才可以處理
資料庫中的行鎖,表鎖,讀鎖(共享鎖),寫鎖(排他鎖),以及syncronized實現的鎖均為悲觀鎖
悲觀併發控制實際上是“先取鎖再訪問”的保守策略,為資料處理的安全提供了保證,
2.2、樂觀鎖
樂觀鎖(Optimistic Lock): 就是很樂觀,每次去拿資料的時候都認為別人不會修改。所以不會上鎖,但是如果想要更新資料,則會在更新前檢查在讀取至更新這段時間別人有沒有修改過這個資料。如果修改過,則重新讀取,再次嘗試更新,迴圈上述步驟直到更新成功(當然也允許更新失敗的執行緒放棄操作),樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量
相對於悲觀鎖,在對資料庫進行處理的時候,樂觀鎖並不會使用資料庫提供的鎖機制。一般的實現樂觀鎖的方式就是記錄資料版本(version)或者是時間戳來實現,不過使用版本記錄是最常用的。
樂觀控制相信事務之間的資料競爭(data race)的概率是比較小的,因此儘可能直接做下去,直到提交的時候才去鎖定,所以不會產生任何鎖和死鎖。
三、鎖的實現
悲觀鎖阻塞事務、樂觀鎖回滾重試:它們各有優缺點,不要認為一種一定好於另一種。像樂觀鎖適用於寫比較少的情況下,即衝突真的很少發生的時候,這樣可以省去鎖的開銷,加大了系統的整個吞吐量。但如果經常產生衝突,上層應用會不斷的進行重試,這樣反倒是降低了效能,所以這種情況下用悲觀鎖就比較合適。
3.1 悲觀鎖的實現方式
場景:
有使用者A和使用者B,在同一家店鋪去購買同一個商品,但是商品的可購買數量只有一個
下面是這個店鋪的商品表t_goods結構和表中的資料:
在不加鎖的情況下,如果使用者A和使用者B同時下單,就會報錯。
悲觀鎖的實現,往往依靠資料庫提供的鎖機制,在資料庫中,我們如何用悲觀鎖去解決這個事情呢?
- 加入當使用者A對下單購買商品(臭豆腐)的時候,先去嘗試對該資料(臭豆腐)加上悲觀鎖
- 加鎖失敗:說明商品(臭豆腐)正在被其他事務進行修改,當前查詢需要等待或者丟擲異常,具體返回的方式需要由開發者根據具體情況去定義
- 加鎖成功:對商品(臭豆腐)進行修改,也就是隻有使用者A能買,使用者B想買(臭豆腐)就必須一直等待。當使用者A買好後,使用者B再想去買(臭豆腐)的時候會發現數量已經為0,那麼B看到後就會放棄購買
- 在此期間如果有其他對該資料(臭豆腐)做修改或加鎖的操作,都會等待我們解鎖後或者直接丟擲異常
那麼如何加上悲觀鎖呢?我們可以通過以下語句給id=2的這行資料加上悲觀鎖,首先關閉MySQL資料庫的自動提交屬性。因為MySQL預設使用autocommit模式,也就是說,當我們執行一個更新操作後,MySQL會立刻將結果進行提交,(sql語句:set autocommit=0)
悲觀鎖加鎖sql語句: select num from t_goods where id = 2 for update
我們通過開啟mysql的兩個會話,也就是兩個命令列來演示:
事務A:
我們可以看到資料是立刻馬上就可以查詢出來,num=1
事務B:
我們是可以看到,事務B會一直等待事務A釋放鎖。如果事務A長期不釋放鎖,那麼最終事務B將會報錯,報錯如下:Lock wait timeout exceeded; try restarting transaction
,表示語句已被鎖住
現在我們讓事務A執行命令去修改資料,讓臭豆腐的數量減一,然後檢視修改後的資料,最後commit,結束事務
我們可以看到當我們事務A執行完成之後,臭豆腐的庫存只有0個了,這個時候我們使用者B再來購買這個臭豆腐的時候就會發現,最後一個臭豆腐已經被使用者A購買完了,那麼使用者B只能放棄購買臭豆腐了。
通過悲觀鎖我們可以解決因為商品庫存不足,導致的商品超出庫存的售賣。
3.1 樂觀鎖的實現方式
對於上面的應用場景,我們應該怎麼用樂觀鎖去解決呢?在上面的樂觀鎖中,我們有提到使用版本號(version)來解決,所以我們需要在t_goods加上版本號,調整後的sql表結構如下:
具體操作步驟如下:
1、首先使用者A和使用者B同時將臭豆腐(id=2)的資料查出來
2、然後使用者A先買,使用者A將(id=1和version=0)作為條件進行資料更新,將數量-1,並且將版本號+1。此時版本號變為1。使用者A此時就完成了商品的購買
3、 使用者B開始買,使用者B也將(id=1和version=0)作為條件進行資料更新
4、更新完後,發現更新的資料行數為0,此時就說明已經有人改動過資料,此時就應該提示使用者B重新檢視最新資料購買
1、首先我們開啟兩個會話視窗,輸入查詢語句:select num from t_goods where id = 2
事務A:
事務B:
這個時候事務A和事務B同時獲取相同的資料
2、此時事務A進行更新資料的操作,然後在查詢更新後的資料
這個時候我們可以看到事務A更新成功,並且庫存-1 版本號+1成功
2、此時事務B進行更新資料的操作,然後在查詢更新後的資料
可以看到最終修改的時候失敗,資料沒有改變。此時就需要我們告知使用者B重新處理
3.1.1 CAS
說到樂觀鎖,就必須提到一個概念:CAS
什麼是CAS呢?Compare-and-Swap,即比較並替換,也有叫做Compare-and-Set的,比較並設定。
1、比較:讀取到了一個值A,在將其更新為B之前,檢查原值是否仍為A(未被其他執行緒改動)。
2、設定:如果是,將A更新為B,結束。[1]如果不是,則什麼都不做。
上面的兩步操作是原子性的,可以簡單地理解為瞬間完成,在CPU看來就是一步操作。
有了CAS,就可以實現一個樂觀鎖,允許多個執行緒同時讀取(因為根本沒有加鎖操作),但是隻有一個執行緒可以成功更新資料,並導致其他要更新資料的執行緒回滾重試。 CAS利用CPU指令,從硬體層面保證了操作的原子性,以達到類似於鎖的效果。
Java中真正的CAS操作呼叫的native方法
因為整個過程中並沒有“加鎖”和“解鎖”操作,因此樂觀鎖策略也被稱為無鎖程式設計。換句話說,樂觀鎖其實不是“鎖”,它僅僅是一個迴圈重試CAS的演算法而已,但是CAS有一個問題那就是會產生ABA問題,什麼是ABA問題,以及如何解決呢?
ABA 問題:
如果一個變數V初次讀取的時候是A值,並且在準備賦值的時候檢查到它仍然是A值,那我們就能說明它的值沒有被其他執行緒修改過了嗎?很明顯是不能的,因為在這段時間它的值可能被改為其他值,然後又改回A,那CAS操作就會誤認為它從來沒有被修改過。這個問題被稱為CAS操作的 "ABA"問題。
ABA 問題解決:
我們需要加上一個版本號(Version),在每次提交的時候將版本號+1操作,那麼下個執行緒去提交修改的時候,會帶上版本號去判斷,如果版本修改了,那麼執行緒重試或者提示錯誤資訊~
四、如何選擇
悲觀鎖阻塞事務,樂觀鎖回滾重試,它們各有優缺點,不要認為一種一定好於另一種。像樂觀鎖適用於寫比較少的情況下,即衝突真的很少發生的時候,這樣可以省去鎖的開銷,加大了系統的整個吞吐量。
但如果經常產生衝突,上層應用會不斷的進行重試,這樣反倒是降低了效能,所以這種情況下用悲觀鎖就比較合適。
注意點:
1、樂觀鎖並未真正加鎖,所以效率高。一旦鎖的粒度掌握不好,更新失敗的概率就會比較高,容易發生業務失敗。
2、悲觀鎖依賴資料庫鎖,效率低。更新失敗的概率比較低。
五、總結
這篇文章講解了悲觀鎖與樂觀鎖的區別,以及實現場景,不管是悲觀鎖還是樂觀鎖都是人們定義出來的概念,是一種思想,如何有有疑問或者問題的小夥伴可以在下面進行留言,小農看到了會第一時間回覆大家,謝謝,大家加油~