三種使用分布式鎖方案


一、背景單體架構中使用同步訪問解決多線程並發問題,分布式中需要有其他方案。

二、分布式鎖的考量

  1.可以保證在分布式部署的應用集群中,同一個方法在同一時間只能被一台機器-上的一個線程執行。
  2.這把鎖要是一把可重入鎖(避免死鎖)
  3.這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)
  4.這把鎖最好是一把公平鎖(根據業務需求考慮要不要這條)
  5.有高可用的獲取鎖和釋放鎖功能
   保證了只有鎖的持有者才能來解鎖,否則任何競爭者都能解鎖
  6.獲取鎖和釋放鎖的性能要好
    7.如果做的好一點,需要有監控的平台。

三、分布式鎖的三種實現方式

  1.基於數據庫實現排他鎖:利用version字段和for update操作獲取鎖。


    優點:易於理解
    問題:
      (1)鎖沒有失效時間,解鎖失敗時(宕機等原因),其他線程獲取不到鎖。
      解決:做一個定時任務實現自動釋放鎖。

      (2)鎖屬於非阻塞,因為獲取鎖的是insert操作,一旦獲取失敗就報錯,沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。
      解決:搞一個while循環,直到insert成功再返回成功。

      (3)不是可重入鎖。
      解決:加入鎖的機器字段,實現同一機器可重復加鎖。
      另外在解鎖時,必須是鎖的持有者來解鎖,其他競爭者無法解鎖

      (4)由於是數據庫,對性能要求高的應用不合適用此實現。
      解決:數據庫本身特性決定。
      (5)在 MySQL 數據庫中采用主鍵沖突防重,在大並發情況下有可能會造成鎖表現象。

      解決:比較好的辦法是在程序中生產主鍵進行防重

      (6)這把鎖是非公平鎖,所有等待鎖的線程憑運氣去爭奪鎖

      解決:再建一張中間表,將等待鎖的線程全記錄下來,並根據創建時間排序,只有最先創建的允許獲取鎖。

      (7)考慮到數據庫單點故障,需要實現數據庫的高可用。

      注意:InnoDB 引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖

    另外存在問題
    (1)行級鎖並不一定靠譜:雖然我們對方法字段名使用了唯一索引,並且顯示使用 for update 來使用行級鎖。
         但是,MySQL 會對查詢進行優化,即便在條件中使用了索引字段,但是否使用索引來檢索數據是由 MySQL 通過判斷不同執行計划的代價來決定的,
         如果MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。這種情況是致命的。

    (2)我們要使用排他鎖來進行分布式鎖的 lock,那么一個排他鎖長時間不提交,就會占用數據庫連接。
      一旦類似的連接變得多了,就可能把數據庫連接池撐爆


  2.基於redis實現(單機版):需要自己實現 一定要用 SET key value NX PX milliseconds 命令,而不要使用setnx 加expire


    優點: 性能高、超時失效比數據庫簡單。
    開源實現:Redis官方提出一種算法,叫Redlock,認為這種實現比普通的單實例實現更安全。
              RedLock有多種語言的實現包,其中Java版本:Redisson。
    缺點:
    (1)失效時間無法把控。可能設置過短或者過長的情況.如果設置過短,其他線程可能會獲取到鎖,無法保證情況。過長時其他線程獲取不到鎖。
         解決:Redisson的思路:客戶端起一個后台線程,快到期時自動續期,如果宕機了,后台線程也沒有了。

    (2)如果采用 Master-Slave 模式,如果 Master 節點故障了,發生主從切換,主從切換的一瞬間,可能出現鎖丟失的問題。
         解決:Redisson ,但存在爭議的,不過應該問題不大。

 

  3.基於zookeeper實現(推薦):可靠性好,使用最廣泛。實現:Curator

  4.基於etcd的實現:優於zookeeper實現,如果項目中應用了etcd,那么使用etcd。

    5.Spring Integration 實現了分布式鎖:
    Gemfire
    JDBC
    Redis
    Zookeeper

 

基於數據庫實現排他鎖


方案1


獲取鎖

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', 'methodName');

對method_name做了唯一性約束,這里如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功。

方案2

 1 DROP TABLE IF EXISTS `method_lock`;  2 CREATE TABLE `method_lock` (  3   `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',  4   `method_name` varchar(64) NOT NULL COMMENT '鎖定的方法名',  5   `state` tinyint NOT NULL COMMENT '1:未分配;2:已分配',  6   `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,  7   `version` int NOT NULL COMMENT '版本號',  8  `PRIMARY KEY (`id`),  9  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE 10 ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';

 

先獲取鎖的信息
     select id, method_name, state,version from method_lock where state=1 and method_name='methodName';

占有鎖
       update t_resoure set state=2, version=2, update_time=now() where method_name='methodName' and state=1 and version=2;

如果沒有更新影響到一行數據,則說明這個資源已經被別人占位了。

缺點:

    1、這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用。
    2、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖。
    3、這把鎖只能是非阻塞的,因為數據的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。
    4、這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因為數據中數據已經存在了。

解決方案:
     1、數據庫是單點?搞兩個數據庫,數據之前雙向同步。一旦掛掉快速切換到備庫上。
     2、沒有失效時間?只要做一個定時任務,每隔一定時間把數據庫中的超時數據清理一遍。
     3、非阻塞的?搞一個while循環,直到insert成功再返回成功。
     4、非重入的?在數據庫表中加個字段,記錄當前獲得鎖的機器的主機信息和線程信息,那么下次再獲取鎖的時候先查詢數據庫,如果當前機器的主機信息和線程信息在數據庫可以查到的話,直接把鎖分配給他就可以了。

基於redis實現

   獲取鎖:
        SET resource_name my_random_value NX PX 30000

      解鎖方式一:不可用
    if( (GET user_id) == "XXX" ){ //獲取到自己鎖后,進行取值判斷且判斷為真。此時,這把鎖恰好失效。
    DEL user_id
    }
    由於GET取值判斷和DEL刪除並非原子操作,當程序判通過該鎖的值判斷發現這把鎖是自己加上的,准備DEL。
    此時該鎖恰好失效,而另外一個請求恰好獲得key值為user_id的鎖。
    此時程序執行了了DEL user_id,刪除了別人加的鎖,尷尬!

  解鎖方式二(推薦):為了保證查詢和刪除的原子性操作,需要引入lua腳本支持。

    

    if redis.call('get',KEYS[1]) == ARGV[1] then 
           return redis.call('del',KEYS[1]) 
    else
      return 0 
    end

 

真實代碼:

  

 /** * 獲取Jedis實例 * * @return */ protected synchronized static Jedis getJedis() { try { if (jedisPool != null) { Jedis resource = (Jedis) jedisPool.getResource(); return resource; } else { return null; } } catch (Exception e) { e.printStackTrace(); return null; } } 

  

 /**
     * 嘗試獲取分布式鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖
     * @param requestId 請求唯一標識,可以用UUID
     *   目的:為了釋放鎖的時候,只能釋放自己的鎖而不能釋放別人的鎖。
     *   場景:A任務在超時時間內沒有完成任務,超時釋放鎖后B成功獲取了鎖,待A任務執行完成釋放鎖會釋放B已獲取的鎖。
     *   其實A還沒有處理完成,B已經獲取了鎖,此時A和B是同時執行的,已經是異常的了!不讓A釋放B的鎖,只是減少異常的進一步擴散。 
     *   所以,過期時間一定要大於正常業務處理時間。當然太長了也不行,因為怕宕機等情況一直長時間占用鎖影響業務。
     *   
     * @param expireSeconds 超期時間 :EX的單位是秒  
     * @return 是否獲取成功
     */
    public static boolean lock( String lockKey, String requestId, int expireSeconds) { String result = ""; if (jedisPool != null) { Jedis jedis = getJedis(); try { //NX,不存在才設置, EX表示單位秒 而 PX表示單位毫秒 result = jedis.set(lockKey, requestId, "NX", "EX", expireSeconds); }finally { returnResource(jedis); } }else{ result = jedisCluster.set(lockKey, requestId, "NX", "EX", expireSeconds); } if (LOCK_SUCCESS.equals(result)) { return true; } return false; }
    /**
     * 釋放分布式鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖
     * @param requestId 請求標識
     * @return 是否釋放成功
     */
    public static boolean releaseLock(String lockKey, String requestId) { Object result = null; String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; if (jedisPool != null) { Jedis jedis = getJedis(); try{ result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); } finally { returnResource(jedis); } }else{ result = jedisCluster.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); } if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } 

  



使用zookeeper實現分布式鎖

zookeeper分布式鎖應用了臨時順序節點

獲取鎖

首先,在Zookeeper當中創建一個持久節點ParentLock。當第一個客戶端想要獲得鎖時,需要在ParentLock這個節點下面創建一個臨時順序節點 Lock1。

 

之后,Client1查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock1是不是順序最靠前的一個。如果是第一個節點,則成功獲得鎖。

這時候,如果再有一個客戶端 Client2 前來獲取鎖,則在ParentLock下載再創建一個臨時順序節點Lock2。 

Client2查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock2是不是順序最靠前的一個,結果發現節點Lock2並不是最小的。

於是,Client2向排序僅比它靠前的節點Lock1注冊Watcher,用於監聽Lock1節點是否存在。這意味着Client2搶鎖失敗,進入了等待狀態。

這時候,如果又有一個客戶端Client3前來獲取鎖,則在ParentLock下載再創建一個臨時順序節點Lock3。 

 

Client3查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock3是不是順序最靠前的一個,結果同樣發現節點Lock3並不是最小的。

於是,Client3向排序僅比它靠前的節點Lock2注冊Watcher,用於監聽Lock2節點是否存在。這意味着Client3同樣搶鎖失敗,進入了等待狀態。

 

這樣一來,Client1得到了鎖,Client2監聽了Lock1,Client3監聽了Lock2。這恰恰形成了一個等待隊列,很像是Java當中ReentrantLock所依賴的AQS(AbstractQueuedSynchronizer)。

獲得鎖的過程大致就是這樣,那么Zookeeper如何釋放鎖呢?

釋放鎖的過程很簡單,只需要釋放對應的子節點就好。

釋放鎖

釋放鎖分為兩種情況:

1.任務完成,客戶端顯示釋放

當任務完成時,Client1會顯示調用刪除節點Lock1的指令。

 

2.任務執行過程中,客戶端崩潰

獲得鎖的Client1在任務執行過程中,如果Duang的一聲崩潰,則會斷開與Zookeeper服務端的鏈接。根據臨時節點的特性,相關聯的節點Lock1會隨之自動刪除。 

 

由於Client2一直監聽着Lock1的存在狀態,當Lock1節點被刪除,Client2會立刻收到通知。這時候Client2會再次查詢ParentLock下面的所有節點,確認自己創建的節點Lock2是不是目前最小的節點。如果是最小,則Client2順理成章獲得了鎖。 

 

同理,如果Client2也因為任務完成或者節點崩潰而刪除了節點Lock2,那么Client3就會接到通知。 

 

最終,Client3成功得到了鎖。 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM