1. 程式人生 > >分布式鎖的幾種實現方式

分布式鎖的幾種實現方式

article nod process 過程 快速 details 一段時間 detail 領域

一、為什麽要使用分布式鎖

為了保證一個方法或屬性在高並發情況下的同一時間只能被同一個線程執行,在傳統單體應用單機部署的情況下,可以使用Java並發處理相關的API(如ReentrantLock或Synchronized)進行互斥控制。但是,隨著業務發展的需要,原單體單機部署的系統被演化成分布式集群系統後,由於分布式系統多線程、多進程並且分布在不同機器上,這將使原單機部署情況下的並發控制鎖策略失效,單純的Java API並不能提供分布式鎖的能力。為了解決這個問題就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分布式鎖要解決的問題!

二、分布式鎖應該具備哪些條件

在分析分布式鎖的三種實現方式之前,先了解一下分布式鎖應該具備哪些條件:
1、在分布式系統環境下,一個方法在同一時間只能被一個機器的一個線程執行;
2、高可用的獲取鎖與釋放鎖;
3、高性能的獲取鎖與釋放鎖;
4、具備可重入特性;
5、具備鎖失效機制,防止死鎖;
6、具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。

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

目前幾乎很多大型網站及應用都是分布式部署的,分布式場景中的數據一致性問題一直是一個比較重要的話題。分布式的CAP理論告訴我們“任何一個分布式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多只能同時滿足兩項。”所以,很多系統在設計之初就要對這三者做出取舍。在互聯網領域的絕大多數的場景中,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證“最終一致性”,只要這個最終時間是在用戶可以接受的範圍內即可。

在很多場景中,我們為了保證數據的最終一致性,需要很多的技術方案來支持,比如分布式事務、分布式鎖等。有的時候,我們需要保證一個方法在同一時間內只能被同一個線程執行。
基於數據庫實現分布式鎖;
基於緩存(Redis等)實現分布式鎖;
基於Zookeeper實現分布式鎖;
盡管有這三種方案,但是不同的業務也要根據自己的情況進行選型,他們之間沒有最好只有更適合!

1. 基於數據庫的實現方式

基於數據庫的實現方式的核心思想是:在數據庫中創建一個表,表中包含方法名等字段,並在方法名字段上創建唯一索引,想要執行某個方法,就使用這個方法名向表中插入數據,成功插入則獲取鎖,執行完成後刪除對應的行數據釋放鎖。

1.1 創建一個表:

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT ‘主鍵‘,
  `method_name` varchar(64) NOT NULL COMMENT
‘鎖定的方法名‘, `desc` varchar(255) NOT NULL COMMENT ‘備註信息‘, `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT=‘鎖定中的方法‘;

1.2 想要執行某個方法,就使用這個方法名向表中插入數據:

INSERT INTO method_lock (method_name, desc) VALUES (‘methodName‘, ‘測試的methodName‘);

因為我們對method_name做了唯一性約束,這裏如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,那麽我們就可以認為操作成功的那個線程獲得了該方法的鎖,可以執行方法體內容。

1.3 成功插入則獲取鎖,執行完成後刪除對應的行數據釋放鎖:

delete from method_lock where method_name =‘methodName‘;

註意:這只是使用基於數據庫的一種方法,使用數據庫實現分布式鎖還有很多其他的玩法!
上面這種簡單的實現有以下幾個問題:

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

當然,我們也可以有其他方式解決上面的問題。

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

數據庫實現分布式鎖的優點:直接借助數據庫,容易理解。

數據庫實現分布式鎖的缺點:

  1. 會有各種各樣的問題,在解決問題的過程中會使整個方案變得越來越復雜。
  2. 操作數據庫需要一定的開銷,性能問題需要考慮。
  3. 使用數據庫的行級鎖並不一定靠譜,尤其是當我們的鎖表並不大的時候。

2. 基於Redis的實現方式

選用Redis實現分布式鎖原因:
(1)Redis有很高的性能;
(2)Redis命令對此支持較好,實現起來比較方便

緩存系統在實現的時候跟數據庫的模式差不多,但是因為數據都是在緩存中,所以加鎖和解鎖都會比數據庫快很多。

下面舉例看看基於 Redis 的分布式鎖實現。Redis 的分布式鎖都是基於一個命令 -- SETNX,也就是 SET IF NOT EXIST,如果不存在就寫入。從 Redis 2.6.12 版本開始,Redis 的 SET 命令直接直接設置 NX 和 EX 屬性,NX 即附帶了 SETNX 數據,key 存在就無法插入,EX 是過期屬性,可以設置過期時間。這樣一個命令就能原子的完成加鎖和設置過期時間。

pom文件是這樣。

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
    <type>jar</type>
    <scope>compile</scope>
</dependency>
public class RedisManager {
   public static JedisPool jedisPool;
   private static final String LOCK_SUCCESS = "OK";
   private static final String SET_IF_NOT_EXIST = "NX";
   private static final String SET_WITH_EXPIRE_TIME = "PX";
   private static final Long RELEASE_SUCCESS = 1L;

   /**
    *
    * 過期時間設置
    * EX second :設置鍵的過期時間為 second 秒。 SET key value EX second 效果等同於 SETEX key second value 。
    * PX millisecond :設置鍵的過期時間為 millisecond 毫秒。 SET key value PX millisecond 效果等同於 PSETEX key millisecond value 。
    *
    * 執行條件設置
    * NX :只在鍵不存在時,才對鍵進行設置操作。 SET key value NX 效果等同於 SETNX key value 。
    * XX :只在鍵已經存在時,才對鍵進行設置操作。
    */
   static {
      //讀取相關的配置
      ResourceBundle resourceBundle = ResourceBundle.getBundle("redis");
      int maxActive = Integer.parseInt(resourceBundle.getString("redis.pool.maxActive"));
      int maxIdle = Integer.parseInt(resourceBundle.getString("redis.pool.maxIdle"));
      int maxWait = Integer.parseInt(resourceBundle.getString("redis.pool.maxWait"));

      String ip = resourceBundle.getString("redis.ip");
      int port = Integer.parseInt(resourceBundle.getString("redis.port"));

      JedisPoolConfig config = new JedisPoolConfig();
      //設置最大連接數
      config.setMaxTotal(maxActive);
      //設置最大空閑數
      config.setMaxIdle(maxIdle);
      //設置超時時間
      config.setMaxWaitMillis(maxWait);
      //初始化連接池
      jedisPool = new JedisPool(config, ip, port);
   }


   public static boolean tryLock(String key,String value,int expireSecond){
      Jedis jedis = jedisPool.getResource();
      if(jedis == null){
         return false;
      }

      String result = jedis.set(key, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireSecond);

      if (LOCK_SUCCESS.equals(result)) {
         return true;
      }
      return false;
   }

   public static boolean releaseDistributedLock(String key,String value) {
      Jedis jedis = jedisPool.getResource();
      if(jedis == null){
         return false;
      }

      String script = "if redis.call(‘get‘, KEYS[1]) == ARGV[1] then return redis.call(‘del‘, KEYS[1]) else return 0 end";
      Object result = jedis.eval(script, Collections.singletonList(key), Collections.singletonList(value));

      if (RELEASE_SUCCESS.equals(result)) {
         return true;
      }
      return false;
   }


   public static void main(String[] args){
      Printer.println(tryLock("A","B",100));
      Printer.println(releaseDistributedLock("A","B"));
   }
}

除此之外,Redis 的作者還實現了一個分布式鎖算法,叫Redlock
以上實現方式同樣存在幾個問題:

  1. 這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在tair中,其他線程無法再獲得到鎖。
  2. 這把鎖只能是非阻塞的,無論成功還是失敗都直接返回。
  3. 這把鎖是非重入的,一個線程獲得鎖之後,在釋放鎖之前,無法再次獲得該鎖,因為使用到的key在tair中已經存在。無法再執行put操作。

當然,同樣有方式可以解決。

  1. 沒有失效時間?tair的put方法支持傳入失效時間,到達時間之後數據會自動刪除。
  2. 非阻塞?while重復執行。
  3. 非可重入?在一個線程獲取到鎖之後,把當前主機信息和線程信息保存起來,下次再獲取之前先檢查自己是不是當前鎖的擁有者。

但是,失效時間我設置多長時間為好?如何設置的失效時間太短,方法沒等執行完,鎖就自動釋放了,那麽就會產生並發問題。如果設置的時間太長,其他獲取鎖的線程就可能要平白的多等一段時間。這個問題使用數據庫實現分布式鎖同樣存在

總結
可以使用緩存來代替數據庫來實現分布式鎖,這個可以提供更好的性能,同時,很多緩存服務都是集群部署的,可以避免單點問題。並且很多緩存服務都提供了可以用來實現分布式鎖的方法,比如Tair的put方法,redis的setnx方法等。並且,這些緩存服務也都提供了對數據的過期自動刪除的支持,可以直接設置超時時間來控制鎖的釋放。

使用緩存實現分布式鎖的優點

  1. 性能好,實現起來較為方便。
  2. 使用緩存實現分布式鎖的缺點
  3. 通過超時時間來控制鎖的失效時間並不是十分的靠譜。

3. 基於ZooKeeper的實現方式

ZooKeeper是一個為分布式應用提供一致性服務的開源組件,它內部是一個分層的文件系統目錄樹結構,規定同一個目錄下只能有一個唯一文件名。基於ZooKeeper實現分布式鎖的步驟如下:

  1. 創建一個目錄mylock;
  2. 線程A想獲取鎖就在mylock目錄下創建臨時順序節點;
  3. 獲取mylock目錄下所有的子節點,然後獲取比自己小的兄弟節點,如果不存在,則說明當前線程順序號最小,獲得鎖;
  4. 線程B獲取所有節點,判斷自己不是最小節點,設置監聽比自己次小的節點;
  5. 線程A處理完,刪除自己的節點,線程B監聽到變更事件,判斷自己是不是最小的節點,如果是則獲得鎖。

這裏推薦一個Apache的開源庫Curator,它是一個ZooKeeper客戶端,Curator提供的InterProcessMutex是分布式鎖的實現,acquire方法用於獲取鎖,release方法用於釋放鎖。
優點:具備高可用、可重入、阻塞鎖特性,可解決失效死鎖問題。
缺點:因為需要頻繁的創建和刪除節點,性能上不如Redis方式。

4. 總結

上面的三種實現方式,沒有在所有場合都是完美的,所以,應根據不同的應用場景選擇最適合的實現方式。

在分布式環境中,對資源進行上鎖有時候是很重要的,比如搶購某一資源,這時候使用分布式鎖就可以很好地控制資源。
當然,在具體使用中,還需要考慮很多因素,比如超時時間的選取,獲取鎖時間的選取對並發量都有很大的影響

參考:
http://www.hollischuang.com/archives/1716
https://blog.csdn.net/xlgen157387/article/details/79036337
https://toutiao.io/posts/8vnqlo/preview

分布式鎖的幾種實現方式