分散式鎖概念及實現方式

?醬發表於2019-01-06

分散式鎖概念

什麼是鎖?

  • 在單程式的系統中,當存在多個執行緒可以同時改變某個變數(可變共享變數)時,就需要對變數或程式碼塊做同步,使其在修改這種變數時能夠線性執行,以防止併發修改變數帶來不可控的結果。
  • 同步的本質是通過鎖來實現的。為了實現多個執行緒在一個時刻同一個程式碼塊只能有一個執行緒可執行,那麼需要在某個地方做個標記,這個標記必須每個執行緒都能看到,當標記不存在時可以設定該標記,其餘後續執行緒發現已經有標記了則等待擁有標記的執行緒結束同步程式碼塊取消標記後再去嘗試設定標記。這個標記可以理解為鎖。
  • 不同地方實現鎖的方式也不一樣,只要能滿足所有執行緒都能看得到標記即可。如 Java 中 synchronize 是在物件頭設定標記,Lock 介面的實現類基本上都只是某一個 volitile 修飾的 int 型變數其保證每個執行緒都能擁有對該 int 的可見性和原子修改,linux 核心中也是利用互斥量或訊號量等記憶體資料做標記。
  • 除了利用記憶體資料做鎖其實任何互斥的都能做鎖(只考慮互斥情況),如流水錶中流水號與時間結合做冪等校驗可以看作是一個不會釋放的鎖,或者使用某個檔案是否存在作為鎖等。只需要滿足在對標記進行修改能保證原子性和記憶體可見性即可。

什麼是分散式鎖?

分散式鎖是控制分散式系統同步訪問共享資源的一種方式。

分散式鎖應該有什麼特性?

1、在分散式系統環境下,一個方法在同一時間只能被一個機器的一個執行緒執行; 2、高可用的獲取鎖與釋放鎖; 3、高效能的獲取鎖與釋放鎖; 4、具備可重入特性; 5、具備鎖失效機制,防止死鎖; 6、具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。

分散式鎖的幾種實現方式

目前分散式鎖的實現方式主要採用以下三種:

  1. 基於資料庫實現分散式鎖
  2. 基於快取(Redis等)實現分散式鎖
  3. 基於Zookeeper實現分散式鎖

儘管有這三種方案,但是不同的業務也要根據自己的情況進行選型,他們之間沒有最好只有更適合!

基於資料庫實現分散式鎖:

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

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='鎖定中的方法';
複製程式碼

2.如果要執行某個方法,則使用這個方法名向資料庫總插入資料:

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '測試的methodName');
複製程式碼

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

3.成功插入則獲取鎖,執行完成後刪除對應的行資料釋放鎖:

delete from method_lock where method_name ='methodName';
複製程式碼

注意:這只是使用基於資料庫的一種方法,使用資料庫實現分散式鎖還有很多其他的玩法!

使用基於資料庫的這種實現方式很簡單,但是對於分散式鎖應該具備的條件來說,它有一些問題需要解決及優化:

1、因為是基於資料庫實現的,資料庫的可用性和效能將直接影響分散式鎖的可用性及效能,所以,資料庫需要雙機部署、資料同步、主備切換;

2、不具備可重入的特性,因為同一個執行緒在釋放鎖之前,行資料一直存在,無法再次成功插入資料,所以,需要在表中新增一列,用於記錄當前獲取到鎖的機器和執行緒資訊,在再次獲取鎖的時候,先查詢表中機器和執行緒資訊是否和當前機器和執行緒相同,若相同則直接獲取鎖;

3、沒有鎖失效機制,因為有可能出現成功插入資料後,伺服器當機了,對應的資料沒有被刪除,當服務恢復後一直獲取不到鎖,所以,需要在表中新增一列,用於記錄失效時間,並且需要有定時任務清除這些失效的資料;

4、不具備阻塞鎖特性,獲取不到鎖直接返回失敗,所以需要優化獲取邏輯,迴圈多次去獲取。

5、在實施的過程中會遇到各種不同的問題,為了解決這些問題,實現方式將會越來越複雜;依賴資料庫需要一定的資源開銷,效能問題需要考慮。

基於redis實現分散式鎖:

1、選用Redis實現分散式鎖原因:

(1)Redis有很高的效能; (2)Redis命令對此支援較好,實現起來比較方便

2、實現思想:

(1)獲取鎖的時候,使用setnx加鎖,並使用expire命令為鎖新增一個超時時間,超過該時間則自動釋放鎖,鎖的value值為一個隨機生成的UUID,通過此在釋放鎖的時候進行判斷。

(2)獲取鎖的時候還設定一個獲取的超時時間,若超過這個時間則放棄獲取鎖。

(3)釋放鎖的時候,通過UUID判斷是不是該鎖,若是該鎖,則執行delete進行鎖釋放。

3、使用命令介紹:

SETNX:

SETNX key val:#當且僅當key不存在時,set一個key為val的字串,返回1;若key存在,則什麼都不做,返回0。
複製程式碼

EXPIRE:

expire key timeout:#為key設定一個超時時間,單位為second,超過這個時間鎖會自動釋放,避免死鎖。
複製程式碼

DELETE:

delete key:#刪除key
複製程式碼

如果在 setnx 和 expire 之間伺服器程式突然掛掉了,可能是因為機器掉電或者是被人為殺掉的,就會導致 expire 得不到執行,也會造成死鎖。所以可以使用以下指令使得setnx和expire在同一條指令中執行:

set lock:codehole value ex 5 nx
複製程式碼

4、實現程式碼:

//可重入鎖
public class RedisWithReentrantLock {

  private ThreadLocal<Map<String, Integer>> lockers = new ThreadLocal<>();

  private Jedis jedis;

  public RedisWithReentrantLock(Jedis jedis) {
    this.jedis = jedis;
  }

  private boolean _lock(String key) {
    return jedis.set(key, "", "nx", "ex", 5L) != null;
  }

  private void _unlock(String key) {
    jedis.del(key);
  }

  private Map<String, Integer> currentLockers() {
    Map<String, Integer> refs = lockers.get();
    if (refs != null) {
      return refs;
    }
    lockers.set(new HashMap<>());
    return lockers.get();
  }

  public boolean lock(String key) {
    Map<String, Integer> refs = currentLockers();
    Integer refCnt = refs.get(key);
    if (refCnt != null) {
      refs.put(key, refCnt + 1);
      return true;
    }
    boolean ok = this._lock(key);
    if (!ok) {
      return false;
    }
    refs.put(key, 1);
    return true;
  }

  public boolean unlock(String key) {
    Map<String, Integer> refs = currentLockers();
    Integer refCnt = refs.get(key);
    if (refCnt == null) {
      return false;
    }
    refCnt -= 1;
    if (refCnt > 0) {
      refs.put(key, refCnt);
    } else {
      refs.remove(key);
      this._unlock(key);
    }
    return true;
  }

  public static void main(String[] args) {
    Jedis jedis = new Jedis();
    RedisWithReentrantLock redis = new RedisWithReentrantLock(jedis);
    System.out.println(redis.lock("codehole"));
    System.out.println(redis.lock("codehole"));
    System.out.println(redis.unlock("codehole"));
    System.out.println(redis.unlock("codehole"));
  }

}

/**
 * 分散式鎖的簡單實現程式碼
 * Created by liuyang on 2017/4/20.
 */
public class DistributedLock {

    private final JedisPool jedisPool;

    public DistributedLock(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
    }

    /**
     * 加鎖
     * @param lockName       鎖的key
     * @param acquireTimeout 獲取超時時間
     * @param timeout        鎖的超時時間
     * @return 鎖標識
     */
    public String lockWithTimeout(String lockName, long acquireTimeout, long timeout) {
        Jedis conn = null;
        String retIdentifier = null;
        try {
            // 獲取連線
            conn = jedisPool.getResource();
            // 隨機生成一個value
            String identifier = UUID.randomUUID().toString();
            // 鎖名,即key值
            String lockKey = "lock:" + lockName;
            // 超時時間,上鎖後超過此時間則自動釋放鎖
            int lockExpire = (int) (timeout / 1000);

            // 獲取鎖的超時時間,超過這個時間則放棄獲取鎖
            long end = System.currentTimeMillis() + acquireTimeout;
            while (System.currentTimeMillis() < end) {
                if (jedis.set(key, "", "nx", "ex", 5L) != null) {
                    retIdentifier = identifier;
                    return retIdentifier;
                }
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
        return retIdentifier;
    }

    /**
     * 釋放鎖
     * @param lockName   鎖的key
     * @param identifier 釋放鎖的標識
     * @return
     */
    public boolean releaseLock(String lockName, String identifier) {
        Jedis conn = null;
        String lockKey = "lock:" + lockName;
        boolean retFlag = false;
        try {
            conn = jedisPool.getResource();
            while (true) {
                // 監視lock,準備開始事務
                conn.watch(lockKey);
                // 通過前面返回的value值判斷是不是該鎖,若是該鎖,則刪除,釋放鎖
                if (identifier.equals(conn.get(lockKey))) {
                    Transaction transaction = conn.multi();
                    transaction.del(lockKey);
                    List<Object> results = transaction.exec();
                    if (results == null) {
                        continue;
                    }
                    retFlag = true;
                }
                conn.unwatch();
                break;
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
        return retFlag;
    }
}
複製程式碼

使用這種方式實現分散式鎖在叢集模式下會有一定的問題,比如在 Sentinel 叢集中,主節點掛掉時,從節點會取而代之,客戶端上卻並沒有明顯感知。原先第一個客戶端在主節點中申請成功了一把鎖,但是這把鎖還沒有來得及同步到從節點,主節點突然掛掉了。然後從節點變成了主節點,這個新的節點內部沒有這個鎖,所以當另一個客戶端過來請求加鎖時,立即就批准了。這樣就會導致系統中同樣一把鎖被兩個客戶端同時持有,不安全性由此產生。

為了解決這個問題,Antirez 發明了 Redlock 演算法,加鎖時,它會向過半節點傳送 set(key, value, nx=True, ex=xxx) 指令,只要過半節點 set 成功,那就認為加鎖成功。釋放鎖時,需要向所有節點傳送 del 指令。不過 Redlock 演算法還需要考慮出錯重試、時鐘漂移等很多細節問題,同時因為 Redlock 需要向多個節點進行讀寫,意味著相比單例項 Redis 效能會下降一些。

基於zookeeper實現的分散式鎖:

在使用zookeeper實現分散式鎖的之前,需要先了解zookeeper的兩個特性,第一個是zookeeper的節點型別,第二就是zookeeper的watch機制:

zookeeper的節點型別:

PERSISTENT 持久化節點

PERSISTENT_SEQUENTIAL 順序自動編號持久化節點,這種節點會根據當前已存在的節點數自動加 1

EPHEMERAL 臨時節點, 客戶端session超時這類節點就會被自動刪除

EPHEMERAL_SEQUENTIAL 臨時自動編號節點

zookeeper的watch機制:

Znode發生變化(Znode本身的增加,刪除,修改,以及子Znode的變化)可以通過Watch機制通知到客戶端。那麼要實現Watch,就必須實現org.apache.zookeeper.Watcher介面,並且將實現類的物件傳入到可以Watch的方法中。Zookeeper中所有讀操作(getData(),getChildren(),exists())都可以設定Watch選項。Watch事件具有one-time trigger(一次性觸發)的特性,如果Watch監視的Znode有變化,那麼就會通知設定該Watch的客戶端。

zookeeper實現排他鎖:

定義鎖:

在通常的java併發程式設計中,有兩種常見的方式可以用來定義鎖,分別是synchronized機制和JDK5提供的ReetrantLock。然而,在zookeeper中,沒有類似於這樣的API可以直接使用,而是通過Zookeeper上的資料節點來表示一個鎖,例如/exclusive_lock/lock節點就可以定義為一個鎖。

獲取鎖:

在需要獲取排他鎖的時候,所有的客戶端都會試圖通過呼叫create()介面,在/exclusive_lock節點下建立臨時子節點/exclusive_lock/lock。zookeeper會保證在所有客戶端中,最終只有一個客戶端能夠建立成功,那麼就可以認為該客戶端獲得了鎖。同時,所有沒有獲得鎖的客戶端就需要到/exclusive_lock節點上註冊一個子節點變更的Watcher監聽,以便實時監聽到lock節點的變更情況。

釋放鎖:

在定義鎖部分,我們已經提到,/exclusive_lock/lock是一個臨時節點,因此在以下兩種情況下,都有可能釋放鎖。

1.當前獲取鎖的客戶端發生當機,那麼Zookeeper上的這個臨時節點就會被移除。

2.正常執行完業務邏輯之後,客戶端就會主動將自己建立的臨時節點刪除

無論在什麼情況下移除了lock節點,Zookeeper都會通知所有在/exclusive_lock節點上註冊了子節點變更Watcher監聽的客戶端。這些客戶端在接收到通知後,再次重新發起分散式鎖獲取,即重複“獲取鎖”的過程:

分散式鎖概念及實現方式

zookeeper實現共享鎖:

定義鎖:

和排他鎖一樣,同樣是通過zookeeper上的資料節點來表示一個鎖,是一個類似於"/shared_lock/[hostname]-請求型別-序號"的臨時順序節點,例如/shared_lock/192.168.0.1-R-000000001,那麼這個節點就代表了一個共享鎖。

獲取鎖:

1.客戶端呼叫create()方法建立一個類似於"/shared_lock/[hostname]-請求型別-序號"的臨時順序節點。

2.客戶端呼叫getChildren()介面來獲取所有已經建立的子節點列表。

3.確定自己的節點序號在所有子節點中的順序

​ 對於讀請求:

​ 如果沒有比自己序號小的子節點,或是所有比自己序號小的 子節點都是讀請求,那麼表明已經成功獲取到了共享鎖,同時開始執行讀取邏輯。

​ 如果比自己序號小的子節點中有寫請求,那麼就需要進入等待。向比自己序號小的最後一個寫請求節點註冊Watcher監聽。

​ 對於寫請求:

​ 如果自己不是序號最小的節點,那麼就需要進入等待。向比自己序號小的最後一個節點註冊Watcher監聽

4.等待Watcher通知,繼續進入步驟2

釋放鎖:

在定義鎖部分,我們已經提到,/exclusive_lock/lock是一個臨時節點,因此在以下兩種情況下,都有可能釋放鎖。

1.當前獲取鎖的客戶端發生當機,那麼Zookeeper上的這個臨時節點就會被移除。

2.正常執行完業務邏輯之後,客戶端就會主動將自己建立的臨時節點刪除

分散式鎖概念及實現方式

常用的分散式鎖元件:

mykit-lock

mykit架構中獨立出來的mykit-lock元件,旨在提供高併發架構下分散式系統的分散式鎖架構。

GitHub地址:github.com/sunshinelyz…

參考資料:

從PAXOS到ZOOKEEPER分散式一致性原理和實踐

blog.csdn.net/xlgen157387…

掘金小冊:Redis深度探險:核心原理與應用實踐

blog.csdn.net/tzs_1041218…

相關文章