關於樂觀鎖與悲觀鎖的實際應用

廣州蘆葦科技Java開發團隊發表於2018-11-29

開門見山,先聊一聊我實際遇到的業務問題:

在專案中有一個競猜下注的功能,它的賠率是根據A隊和B隊兩邊的下注總金額來計算的。於是當有使用者下注某一邊時,兩邊的賠率都會進行相應的變化。

反應到資料庫裡就是(簡化版本),一個人下注,會更改資料庫盤口表的幾個欄位:A隊賠率,A隊下注金額、B隊賠率,B隊下注金額 等等。

​ 如果使用預設事務方式,就加個@Transactional 註解,會導致更新丟失的問題。(何為丟失更新:就是一個事務的更新覆蓋了其它事務的更新結果。舉個例子,A讀到的資料為下注金額1000,對他進行計算,這時B讀到的資料也是1000。A再把計算完的1200寫進資料庫。最後B把計算完的1100寫進資料庫。最終表裡的下注金額就只有1100,發生了丟失更新)。如果真有高併發的情況,每秒鐘幾十上百個人下注的話,就必須解決此問題。

​ 預設事務無法解決,當然就得尋求解決方案。這裡可以採用樂觀鎖或悲觀鎖的方式。

一、悲觀鎖解決方案

重點:每次讀資料都加行鎖(也稱寫鎖、X鎖),修改完後事務結束才釋放。

注意:mysql使用InonDB引擎時,預設增刪改時都會加行鎖。讀不加行鎖。

  • 實現如下(select 語句最後面加 for update 即可):
# 第一步 查的時候加行鎖 (注意:InnoDB只有通過索引條件檢索資料才使用行級鎖,否則,InnoDB將使用表鎖, 也就是說,InnoDB的行鎖是基於索引的!)
SELECT * FROM table_name WHERE xxx FOR UPDATE;
# 第二步 邏輯處理完後更新資料
UPDATE xxx...
複製程式碼
@Query(value = "SELECT * FROM guessing_handicap WHERE handicap_id = ?1 FOR UPDATE", nativeQuery = true)
GuessingHandicap getBet(Integer id);
複製程式碼

實現起來十分簡單,概念的理解放在後面寫。

二、樂觀鎖解決方案

樂觀鎖的實現一般會使用版本號機制或CAS演算法

  • 版本號機制:在資料表中加上一個資料版本號version欄位,表示資料被修改的次數,當資料被修改時,version值會加1。在讀取資料的同時也會讀取version值,在提交更新時,若剛才讀取到的version值為當前資料庫中的version值相等時才更新,否則重試更新操作,直到更新成功。
int count = 0; // 計數重複次數,暫定10次
while (count < 10) {
    count++;

    // 先讀取資料,儲存版本號
    GuessingHandicap handicap = guessingHandicapDao.getBet(id);
    Integer version = handicap.getVersion();
    // 進行資料的處理
    // ...

    // 將處理完的結果寫回資料庫
    Integer rows = guessingHandicapDao.updateHandicap(...);
    if (rows == 0) {
        continue;
    }
    // ...
}
throw new ValidationException("下注失敗");
複製程式碼
  • CAS演算法:即compare and swap(比較與交換),是一種有名的無鎖演算法。

CAS概念略複雜,舉個簡單的實現方式:還是盤口表,我讀資料的時候讀到了該條記錄的下注金額,賠率,將其資料暫時儲存。處理完邏輯寫回去時,可用 update xxx set odds = 新賠率 where odds = 原來賠率

- CAS演算法也有缺點,最明顯且容易理解的就是,會導致 ABA 問題。

- 如果一個變數V初次讀取的時候是A值,並且在準備賦值的時候檢查到它仍然是A值,
那我們就能說明它的值沒有被其他執行緒修改過了嗎?很明顯是不能的,因為在這段時間它的值可能被改為其他值,
然後又改回A,那CAS操作就會誤認為它從來沒有被修改過。這個問題被稱為CAS操作的 "ABA"問題。
複製程式碼

所以樂觀鎖建議使用版本號機制。就加個欄位,簡單輕鬆。

兩種鎖的使用場景

​ 從上面對兩種鎖的介紹,我們知道兩種鎖各有優缺點,不可認為一種好於另一種,像樂觀鎖適用於寫比較少的情況下(多讀場景),即衝突真的很少發生的時候,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。但如果是多寫的情況,一般會經常產生衝突,這就會導致上層應用會不斷的進行retry,這樣反倒是降低了效能,所以一般多寫的場景下用悲觀鎖就比較合適。

​ 記住結論,即:樂觀鎖適用於寫比較少的情況(多讀場景);悲觀鎖適用於多寫場景。

三、何謂悲觀鎖與樂觀鎖(概念的理解)

​ 樂觀鎖對應於生活中樂觀的人總是想著事情往好的方向發展,悲觀鎖對應於生活中悲觀的人總是想著事情往壞的方向發展。這兩種人各有優缺點,不能不以場景而定說一種人好於另外一種人。

悲觀鎖

​ 總是假設最壞的情況,每次去拿資料的時候都認為別人會修改,所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會阻塞直到它拿到鎖(共享資源每次只給一個執行緒使用,其它執行緒阻塞,用完後再把資源轉讓給其它執行緒)。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。Java中synchronized和ReentrantLock等獨佔鎖就是悲觀鎖思想的實現。

樂觀鎖

​ 總是假設最好的情況,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號機制和CAS演算法實現。樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量,像資料庫提供的類似於write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變數類就是使用了樂觀鎖的一種實現方式CAS實現的。

另外補充一些易混淆概念

  • 從鎖的粒度,我們可以將資料庫的鎖分成兩大類: 表鎖行鎖

  • 表鎖又分為表讀鎖和表寫鎖,

  • 行鎖又分為共享鎖排他鎖,而共享鎖、排他所又有其他別名,其實只是叫法的不同而已

    • 共享鎖--讀鎖--S鎖
    • 排它鎖--寫鎖--X鎖
  • 為了允許行鎖和表鎖共存,實現多粒度鎖機制,InnoDB還有兩種內部使用的意向鎖(Intention Locks),這兩種意向鎖都是表鎖

    • 意向共享鎖(IS):事務打算給資料行加行共享鎖,事務在給一個資料行加共享鎖前必須先取得該表的IS鎖。
    • 意向排他鎖(IX):事務打算給資料行加行排他鎖,事務在給一個資料行加排他鎖前必須先取得該表的IX鎖。
    • 認真梳理一遍,概念還是挺清晰的,而且意向鎖也是資料庫隱式幫我們做了,不需要我們關心!

四、 對於只讀事務的理解

網上的各種資料裡眾說紛紜:
​ “只讀事務”並不是一個強制選項,它只是一個“暗示”,提示資料庫驅動程式和資料庫系統,這個事務並不包含更改資料的操作,那麼JDBC驅動程式和資料庫就有可能根據這種情況對該事務進行一些特定的優化,比方說不安排相應的資料庫鎖,以減輕事務對資料庫的壓力,畢竟事務也是要消耗資料庫的資源的。 因此,“只讀事務”僅僅是一個效能優化的推薦配置而已,並非強制你要這樣做不可。

@Transactional(readOnly = true)
複製程式碼

只讀事務的注意點:

  • 只讀事務內,不能增加、修改、刪除內容,否則報Cannot execute statement in a READ ONLY transaction。
  • 只讀事務內,只能讀取到執行時間點前的內容,期間修改的內容不能讀取到。
  • 只讀事務作為ORM框架優化執行的一個暗號,比如放棄加鎖,或者flush never。
  • 只讀事務也有缺點,使用了事務,會動態生成代理類,增加開銷。

應用場景總結:

  1. 如果一次執行單條查詢語句,則沒有必要啟用事務支援,資料庫預設支援SQL執行期間的讀一致性;
  2. 如果一次執行多條查詢語句,例如統計查詢,報表查詢,在這種場景下,多條查詢SQL必須保證整體的讀一致性,否則,在前條SQL查詢之後,後條SQL查詢之前,資料被其他使用者改變,則該次整體的統計查詢將會出現讀資料不一致的狀態,此時,應該啟用事務支援。
  3. 若要將程式碼寫的精緻,可按照前兩點來新增只讀事務,若嫌麻煩,全部加上註解也行 (捂臉.jpg)

隆鵬
蘆葦科技Java開發工程師

蘆葦科技-廣州專業軟體外包服務公司

提供微信小程式、APP應用研發、UI設計等專業服務,專注於網際網路產品諮詢、品牌設計、技術研發等領域、

訪問 www.talkmoney.cn 瞭解更多

萬能說明書 | 早起日記Lite | 凹凸桌布 | 言財


相關文章