常見的分佈式鎖

前言

實現分佈式鎖有兩個關鍵點:

  • 鎖的排他性:同一個鎖在被持有的時間段內只能被一個線程持有。
  • 鎖超時機制:保證持有鎖的線程出現異常時(Client失效、服務重啓、宕機等)鎖不會被永久佔用。

分佈式鎖一般有三種實現方式:

  • 數據庫鎖
    • 數據庫表記錄:
      • 鎖的唯一性:由數據庫的唯一索引保證。加鎖時插入一條數據,插入成功表示加鎖成功;解鎖時刪除對應的數據,刪除成功表示鎖釋放成功。
    • 樂觀鎖:
      • 鎖的唯一性:根據數據表中版本號字段來保證。查詢數據時將版本號一併查出,每次更新時,在更新過程中判斷之前查詢到的版本號與數據庫中的版本號是否相同(sql的where條件),如果相同則更新成功,同時版本號加1;如果不相同,則更新失敗,說明發生了併發。
    • 悲觀鎖:
      • 鎖的唯一性:依賴數據庫中自帶的鎖。
  • 基於Redis的分佈式鎖:
    • 場景:滿足高性能的場景。
    • 舉例:Jedis、Redisson
  • 基於ZooKeeper的分佈式鎖:
    • 場景:滿足強一致、高可用的場景。
    • 舉例:Netflix的Curator

 

基於Redis的分佈式鎖實現

方案一:代碼中直接使用 setnx命令 + expire命令 + del命令 來實現[加鎖]、[設置鎖超時時間]、[解鎖] 操作。

存在的問題:

  • 鎖無法釋放:當setnx命令執行成功,但是expire命令沒有執行或執行失敗(服務重啓或網絡問題)時,鎖就會因爲沒有設置超時時間導致永遠無法釋放,造成死鎖。

    • 解決:
      • 方法一:使用lua腳本來保證 加鎖和設置超時時間 這兩個操作的原子性。
      • 方法二:Redis 2.8 之後支持 nx + ex 作爲原子操作來執行:set key value ex expireTime nx
  • 鎖被其它線程釋放:
    • 解決:加鎖(key)時取當前線程的一個標識(每個線程都創建一個uuid作爲加鎖的標識)作爲value,線程在執行 解鎖 操作前 先判斷鎖(key)對應的標識(value)是否爲當前線程持有,當線程持有鎖的標識時纔可以操作解鎖。

 

方案二:代碼中使用 lua腳本[setnx命令 + expire命令] + lua腳本[del命令] 來實現 [加鎖並且同時設置鎖超時時間]、[解鎖] 操作。

存在的問題:

  • 超時自動解鎖導致併發:當線程a獲取鎖後,線程a的執行時間超過鎖的超時時間(eg:JVM出現了STW)時,鎖會自動釋放。在線程a執行完成前,線程b獲取鎖並執行,造成線程a和線程b併發執行。
    • 解決:
      • 將鎖的超時時間設置的足夠大,保證鎖超時自動釋放。
        • 但是這樣做仍然存在問題:當client端獲取鎖後還沒來的急釋放就掛掉時,鎖不能及時被釋放,導致其他client一段時間內不能獲取到鎖從而無法相應的執行任務。
      • 爲獲取鎖的線程增加守護線程(看門狗),將要過期但未釋放的鎖的超時時間延長。
        • 當看門狗線程出現異常時也可能導致鎖長時間未釋放或出現併發:此時就需要人工介入,通過平臺配置或登錄機器將鎖直接釋放。
  • 鎖不可重入:
    •  使用 Redis Map 數據結構來實現分佈式鎖,既存鎖的標識也對重入次數進行計數

 

方案一和方案二共同存在的問題:

主備切換:

  • 線程a獲取鎖成功後,mater還沒來的及將鎖被獲取的指令同步給slave就掛掉了,slave成爲新的master,新的master中沒有鎖的數據,故其它線程可以再次加鎖成功。

集羣腦裂:

  • 集羣腦裂是指在哨兵模式集羣模式下,因爲網絡問題導致 master 節點跟 slave 節點和 sentinel集羣(集羣模式下持有槽的主節點擔任sentinel) 處於不同的網絡分區,因爲 sentinel集羣無法感知到 master 的存在,所以將 slave 節點提升爲 master 節點,此時存在兩個不同的 master 節點。

 

方案三:Redisson

Redisson分佈式鎖_java小兵-CSDN博客

 

 

 

 

 

基於ZooKeeper實現的分佈式鎖

舉例:Netflix的Curator

  • 說明:Curator是ZooKeeper客戶端的封裝。
  • 原理:
    • ZooKeeper 分佈式鎖是基於 臨時順序節點(EPHEMERAL_SEQUENTIAL) 來實現的,鎖可理解爲 ZooKeeper 上的一個節點,當需要獲取鎖時,就在這個鎖節點下創建一個臨時順序節點。
    • 當存在多個客戶端同時來獲取鎖,就按順序依次創建多個臨時順序節點,但只有排列序號是第一的那個節點能獲取鎖成功,其他節點則按順序分別監聽前一個節點的變化,當被監聽者釋放鎖時,監聽者就可以馬上獲得鎖。
    • 節點的臨時性特性保證了鎖持有者與ZooKeeper斷開時強制釋放鎖。
  • 優點:節點的SEQUENTIAL特性避免了鎖釋放時出現的驚羣效應。

 

基於數據庫鎖實現的分佈式鎖

  • 數據庫表記錄:
    • 鎖的唯一性:
      • 由數據庫的唯一索引保證。加鎖時插入一條數據,插入成功表示加鎖成功;解鎖時刪除對應的數據,刪除成功表示鎖釋放成功。
    • 鎖超時機制:定時任務,定時清理創建時間在指定範圍內的數據。
    • 缺點:如果持有鎖的時間超過定時任務的清理週期,則鎖會被誤刪,導致鎖被提前釋放。
    • 說明:我們一般不會採用這種方案。
  • 樂觀鎖:
    • 鎖的唯一性:
      • 根據數據表中版本號字段來保證。查詢數據時將版本號一併查出,每次更新時,在更新過程中判斷之前查詢到的版本號與數據庫中的版本號是否相同(sql的where條件),如果相同則更新成功,同時版本號加1;如果不相同,則更新失敗,說明發生了併發。
    • 缺點:當應用併發量高的時候,version值在頻繁變化,則會導致大量請求失敗,影響系統的可用性。
    • 場景:適合併發量不高,並且寫操作不頻繁的場景。
  • 悲觀鎖:
    • 鎖的唯一性:
      • 依賴數據庫中自帶的鎖。
    • 缺點:每次請求都會額外產生加鎖的開銷且未獲取到鎖的請求將會阻塞等待鎖的獲取,在高併發環境下,容易造成大量請求阻塞,影響系統可用性。
    • 場景:適合併發量不高,並且寫操作不頻繁的場景。

 

數據庫鎖應用案例

  • 庫存的初始化、增加、扣減

庫存的初始化、增加、扣減:

	// 初始化庫存
	try {

	    insertInventoryData(inventoryDataMaintainDto);

	} catch (DuplicateKeyException e) {
	    log.info("新增庫存記錄發生併發,插入操作變爲更新操作,key=[{}]", inventoryDataMaintainDto.getKey());
	    updateInventoryData(inventoryDataMaintainDto);
	}



    // 增加庫存
    update inventory
        sku_count = sku_count + #{skuCount, jdbcType=BIGINT}, 
        update_time = #{updateTime, jdbcType=TIMESTAMP}
    where id=xx


    // 扣減庫存 - case when:保證不會有負庫存
    update inventory
        sku_count = (
        CASE
        	WHEN (sku_count - #{skuCount, jdbcType=BIGINT} >= 0) THEN
        		sku_count - #{skuCount, jdbcType=BIGINT}
        	ELSE
        		'更新sku庫存異常,庫存不足'
        	END
        ),
        update_time = #{updateTime, jdbcType=TIMESTAMP}
    where id=xx



	// 更新商品信息 - 樂觀鎖:保證商品信息的正確更新
    update sku
        version = (
        CASE
        	WHEN (version = #{item.version, jdbcType=BIGINT}) THEN
        		version + 1
        	ELSE
        		'更新sku信息異常'
        END),
        name = #{name, jdbcType=BIGINT}
    where id=xx

 

 

 

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