高併發場景下鎖的使用技巧

如何確保一個方法,或者一塊代碼在高併發情況下,同一時間只能被一個線程執行,單體應用可以使用併發處理相關的 API 進行控制,但單體應用架構演變爲分佈式微服務架構後,跨進程的實例部署,顯然就沒辦法通過應用層鎖的機制來控制併發了。那麼鎖都有哪些類型,爲什麼要使用鎖,鎖的使用場景有哪些?今天我們來聊一聊高併發場景下鎖的使用技巧。

鎖類別

  不同的應用場景對鎖的要求各不相同,我們先來看下鎖都有哪些類別,這些鎖之間有什麼區別。

  • 悲觀鎖(synchronize)
    • Java 中的重量級鎖 synchronize
    • 數據庫行鎖
  • 樂觀鎖
    • Java 中的輕量級鎖 volatile 和 CAS
    • 數據庫版本號
  • 分佈式鎖(Redis鎖)

樂觀鎖

  就好比說是你是一個生活態度樂觀積極向上的人,總是往最好的情況去想,比如你每次去獲取共享數據的時候會認爲別人不會修改,所以不會上鎖,但是在更新的時候你會判斷這期間有沒有人去更新這個數據。

  樂觀鎖使用在前,判斷在後。我們看下僞代碼:

reduce()
{
    select total_amount from table_1
    if(total_amount < amount ){
          return failed.  
    }  
    //其他業務邏輯
    update total_amount = total_amount - amount where total_amount > amount; 
}
  • 數據庫的版本號屬於樂觀鎖;
  • 通過CAS算法實現的類屬於樂觀鎖。

悲觀鎖

  悲觀鎖是怎麼理解呢?相對樂觀鎖剛好反過來,總是假設最壞的情況,假設你每次拿數據的時候會被其他人修改,所以你在每次共享數據的時候會對他加一把鎖,等你使用完了再釋放鎖,再給別人使用數據。

  悲觀鎖判斷在前,使用在後。我們也看下僞代碼:

reduce()
{
    //其他業務邏輯
    int num = update total_amount = total_amount - amount where total_amount > amount; 
   if(num ==1 ){
          //業務邏輯.  
    } 
}
  • Java中的的synchronize是重量級鎖 ,屬於悲觀鎖;
  • 數據庫行鎖屬於悲觀鎖;

扣減操作案例

  這裏舉一個非常常見的例子,在高併發情況下餘額扣減,或者類似商品庫存扣減,也可以是資金賬戶的餘額扣減。扣減操作會發生什麼問題呢?很容易可以看到,可能會發生的問題是扣減導致的超賣,也就是扣減成了負數。

  舉個例子,比如我的庫存數據只有100個。併發情況下第1筆請求賣出100個,第2批賣出100元,導致當前的庫存數量爲負數。遇到這種場景應該如何破解呢?這裏列舉四種方案。

方案1:同步排它鎖

  這時候很容易想到最簡單的方案:同步排它鎖(synchronize)。但是排他鎖的缺點很明顯:

  • 其中一個缺點是,線程串行導致的性能問題,性能消耗比較大。
  • 另一個缺點是無法解決分佈式部署情況下跨進程問題;

方案2:數據庫行鎖

  第二我們可能會想到,那用數據庫行鎖來鎖住這條數據,這種方案相比排它鎖解決了跨進程的問題,但是依然有缺點。

  • 其中一個缺點就是性能問題,在數據庫層面會一直阻塞,直到事務提交,這裏也是串行執行;
  • 第二個需要注意設置事務的隔離級別是Read Committed,否則併發情況下,另外的事務無法看到提交的數據,依然會導致超賣問題;
  • 缺點三是容易打滿數據庫連接,如果事務中有第三方接口交互(存在超時的可能性),會導致這個事務的連接一直阻塞,打滿數據庫連接。
  • 最後一個缺點,容易產生交叉死鎖,如果多個業務的加鎖控制不好,就會發生AB兩條記錄的交叉死鎖。

方案3:redis分佈式鎖

  前面的方案本質上是把數據庫當作分佈式鎖來使用,所以同樣的道理,redis,zookeeper都相當於數據庫的一種鎖,其實當遇到加鎖問題,代碼本身無論是synchronize或者各種lock使用起來都比較複雜,所以思路是把代碼處理一致性的問難題交給一個能夠幫助你處理一致性的問題的專業組件,比如數據庫,比如redis,比如zookeeper等。

  這裏我們分析下分佈式鎖的優缺點:

  • 優點:
    • 可以避免大量對數據庫排他鎖的徵用,提高系統的響應能力
  • 缺點:
    • 設置鎖和設置超時時間的原子性;
    • 不設置超時時間的缺點;
    • 服務宕機或線程阻塞超時的情況;
    • 超時時間設置不合理的情況;

加鎖和過期設置的原子性

  redis加鎖的命令setnx,設置鎖的過期時間是expire,解鎖的命令是del,但是2.6.12之前的版本中,加鎖和設置鎖過期命令是兩個操作,不具備原子性。如果setnx設置完key-value之後,還沒有來得及使用expire來設置過期時間,當前線程掛掉了或者線程阻塞,會導致當前線程設置的key一直有效,後續的線程無法正常使用setnx獲取鎖,導致死鎖。

  針對這個問題,redis2.6.12以上的版本增加了可選的參數,可以在加鎖的同時設置key的過期時間,保證了加鎖和過期操作原子性的。

  但是,即使解決了原子性的問題,業務上同樣會遇到一些極端的問題,比如分佈式環境下,A獲取到了鎖之後,因爲線程A的業務代碼耗時過長,導致鎖的超時時間,鎖自動失效。後續線程B就意外的持有了鎖,之後線程A再次恢復執行,直接用del命令釋放鎖,這樣就錯誤的將線程B同樣Key的鎖誤刪除了。代碼耗時過長還是比較常見的場景,假如你的代碼中有外部通訊接口調用,就容易產生這樣的場景。

設置合理的時長

  剛纔講到的線程超時阻塞的情況,那麼如果不設置時長呢,當然也不行,如果線程持有鎖的過程中突然服務宕機了,這樣鎖就永遠無法失效了。同樣的也存在鎖超時時間設置是否合理的問題,如果設置所持有時間過長會影響性能,如果設置時間過短,有可能業務阻塞沒有處理完成,是否可以合理的設置鎖的時間?

續命鎖

  這是一個很不容易解決的問題,不過有一個辦法能解決這個問題,那就是續命鎖,我們可以先給鎖設置一個超時時間,然後啓動一個守護線程,讓守護線程在一段時間之後重新去設置這個鎖的超時時間,續命鎖的實現過程就是寫一個守護線程,然後去判斷對象鎖的情況,快失效的時候,再次進行重新加鎖,但是一定要判斷鎖的對象是同一個,不能亂續。

  同樣,主線程業務執行完了,守護線程也需要銷燬,避免資源浪費,使用續命鎖的方案相對比較而言更復雜,所以如果業務比較簡單,可以根據經驗類比,合理的設置鎖的超時時間就行。

方案4:數據庫樂觀鎖

  數據庫樂觀鎖加鎖的一個原則就是儘量想辦法減少鎖的範圍。鎖的範圍越大,性能越差,數據庫的鎖就是把鎖的範圍減小到了最小。我們看下面的僞代碼

reduce()
{
    select total_amount from table_1
    if(total_amount < amount ){
          return failed.  
    }  
    //其他業務邏輯
    update total_amount = total_amount - amount;  
}

   我們可以看到修改前的代碼是沒有where條件的。修改後,再加where條件判斷:總庫存大於將被扣減的庫存。

update total_amount = total_amount - amount where total_amount > amount

  如果更新條數返回0,說明在執行過程中被其他線程搶先執行扣減,並且避免了扣減爲負數。

  但是這種方案還會涉及一個問題,如果在之前的update代碼中,以及其他的業務邏輯中還有一些其他的數據庫寫操作的話,那這部分數據如何回滾呢?

  我的建議是這樣的,你可以選擇下面這兩種寫法:

  • 利用事務回滾寫法:

  我們先給業務方法增加事務,方法在扣減庫存影響條數爲零的時候扔出一個異常,這樣對他之前的業務代碼也會回滾。

reduce()
{
    select total_amount from table_1
    if(total_amount < amount ){
          return failed.  
    }  
    //其他業務邏輯
    int num = update total_amount = total_amount - amount where total_amount > amount; 
  if(num==0) throw Exception;
}
  • 第二種寫法
reduce()
{
    //其他業務邏輯
    int num = update total_amount = total_amount - amount where total_amount > amount; 
  
if(num ==1 ){ //業務邏輯. } else{
    throw Exception;
  } }

  首先執行update業務邏輯,如果執行成功了再去執行邏輯操作,這種方案是我相對比較建議的方案。在併發情況下對共享資源扣減操作可以使用這種方法,但是這裏需要引出一個問題,比如說萬一其他業務邏輯中的業務,因爲特殊原因失敗了該怎麼辦呢?比如說在扣減過程中服務OOM了怎麼辦?

  我只能說這些非常極端的情況,比如突然宕機中間數據都丟了,這種極少數的情況下只能人工介入,如果所有的極端情況都考慮到,也不現實。我們討論的重點是併發情況下,共享資源的操作如何加鎖的問題。

總結

  最後我來給你總結一下,如果你可以非常熟練的解決這類問題,第一時間肯定想到的是:數據庫版本號解決方案或者分佈式鎖的解決方案;但是如果你是一個初學者,相信你一定會第一時間考慮到Java中提供的同步鎖或者數據庫行鎖。

  今天討論的目的就是希望把這幾種場景中的鎖放到一個具體的場景中,逐步去對比和分析,讓你能夠更加全面體系的瞭解使用鎖這個問題的來龍去脈。我是張飛洪,希望我的分享可以幫助到你。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章