實現分散式鎖

zhong0316發表於2019-02-24

Java中的鎖主要包括synchronized鎖和JUC包中的鎖,這些鎖都是針對單個JVM例項上的鎖,對於分散式環境如果我們需要加鎖就顯得無能為力。在單個JVM例項上,鎖的競爭者通常是一些不同的執行緒,而在分散式環境中,鎖的競爭者通常是一些不同的執行緒或者程式。如何實現在分散式環境中對一個物件進行加鎖呢?答案就是分散式鎖。

分散式鎖實現方案

目前分散式鎖的實現方案主要包括三種:

  1. 基於資料庫(唯一索引)
  2. 基於快取(Redis,memcached,tair)
  3. 基於Zookeeper

基於資料庫實現分散式鎖主要是利用資料庫的唯一索引來實現,唯一索引天然具有排他性,這剛好符合我們對鎖的要求:同一時刻只能允許一個競爭者獲取鎖。加鎖時我們在資料庫中插入一條鎖記錄,利用業務id進行防重。當第一個競爭者加鎖成功後,第二個競爭者再來加鎖就會丟擲唯一索引衝突,如果丟擲這個異常,我們就判定當前競爭者加鎖失敗。防重業務id需要我們自己來定義,例如我們的鎖物件是一個方法,則我們的業務防重id就是這個方法的名字,如果鎖定的物件是一個類,則業務防重id就是這個類名。

基於快取實現分散式鎖:理論上來說使用快取來實現分散式鎖的效率最高,加鎖速度最快,因為Redis幾乎都是純記憶體操作,而基於資料庫的方案和基於Zookeeper的方案都會涉及到磁碟檔案IO,效率相對低下。一般使用Redis來實現分散式鎖都是利用Redis的SETNX key value這個命令,只有當key不存在時才會執行成功,如果key已經存在則命令執行失敗。

基於Zookeeper:Zookeeper一般用作配置中心,其實現分散式鎖的原理和Redis類似,我們在Zookeeper中建立瞬時節點,利用節點不能重複建立的特性來保證排他性。

在實現分散式鎖的時候我們需要考慮一些問題,例如:分散式鎖是否可重入,分散式鎖的釋放時機,分散式鎖服務端是否有單點問題等。

基於資料庫實現分散式鎖

上面已經分析了基於資料庫實現分散式鎖的基本原理:通過唯一索引保持排他性,加鎖時插入一條記錄,解鎖是刪除這條記錄。下面我們就簡要實現一下基於資料庫的分散式鎖。

表設計

CREATE TABLE `distributed_lock` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `unique_mutex` varchar(255) NOT NULL COMMENT '業務防重id',
  `holder_id` varchar(255) NOT NULL COMMENT '鎖持有者id',
  `create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `mutex_index` (`unique_mutex`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

複製程式碼

id欄位是資料庫的自增id,unique_mutex欄位就是我們的防重id,也就是加鎖的物件,此物件唯一。在這張表上我們加了一個唯一索引,保證unique_mutex唯一性。holder_id代表競爭到鎖的持有者id。

加鎖

insert into distributed_lock(unique_mutex, holder_id) values ('unique_mutex', 'holder_id');
複製程式碼

如果當前sql執行成功代表加鎖成功,如果丟擲唯一索引異常(DuplicatedKeyException)則代表加鎖失敗,當前鎖已經被其他競爭者獲取。

解鎖

delete from methodLock where unique_mutex='unique_mutex' and holder_id='holder_id';
複製程式碼

解鎖很簡單,直接刪除此條記錄即可。

分析

是否可重入:就以上的方案來說,我們實現的分散式鎖是不可重入的,即是是同一個競爭者,在獲取鎖後未釋放鎖之前再來加鎖,一樣會加鎖失敗,因此是不可重入的。解決不可重入問題也很簡單:加鎖時判斷記錄中是否存在unique_mutex的記錄,如果存在且holder_id和當前競爭者id相同,則加鎖成功。這樣就可以解決不可重入問題。

鎖釋放時機:設想如果一個競爭者獲取鎖時候,程式掛了,此時distributed_lock表中的這條記錄就會一直存在,其他競爭者無法加鎖。為了解決這個問題,每次加鎖之前我們先判斷已經存在的記錄的建立時間和當前系統時間之間的差是否已經超過超時時間,如果已經超過則先刪除這條記錄,再插入新的記錄。另外在解鎖時,必須是鎖的持有者來解鎖,其他競爭者無法解鎖。這點可以通過holder_id欄位來判定。

資料庫單點問題:單個資料庫容易產生單點問題:如果資料庫掛了,我們的鎖服務就掛了。對於這個問題,可以考慮實現資料庫的高可用方案,例如MySQL的MHA高可用解決方案。

基於快取實現分散式鎖,以Redis為例

使用Jedis來和Redis通訊。

加鎖

public class RedisTool {

    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";

    /**
     * 加鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖的key
     * @param requestId 競爭者id
     * @param expireTime 鎖超時時間,超時之後鎖自動釋放
     * @return 
     */
    public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        return "OK".equals(result);
    }

}
複製程式碼

可以看到,我們加鎖就一行程式碼: jedis.set(String key, String value, String nxxx, String expx, int time); 這個set()方法一共五個形參: 第一個為key,我們使用key來當鎖,因為key是唯一的。 第二個為value,這裡寫的是鎖競爭者的id,在解鎖時,我們需要判斷當前解鎖的競爭者id是否為鎖持有者。 第三個為nxxx,這個引數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作。 第四個為expx,這個引數我們傳的是PX,意思是我們要給這個key加一個過期時間的設定,具體時間由第五個引數決定; 第五個引數為time,與第四個引數相呼應,代表key的過期時間。 總的來說,執行上面的set()方法就只會導致兩種結果:1.當前沒有鎖(key不存在),那麼久進行加鎖操作,並對鎖設定一個有效期,同時value表示加鎖的客戶端。2.已經有鎖存在,不做任何操作。 上述解鎖請求中,SET_IF_NOT_EXIST(不存在則執行)保證了加鎖請求的排他性,快取超時機制保證了即使一個競爭者加鎖之後掛了,也不會產生死鎖問題:超時之後其他競爭者依然可以獲取鎖。通過設定value為競爭者的id,保證了只有鎖的持有者才能來解鎖,否則任何競爭者都能解鎖,那豈不是亂套了。

解鎖

public class RedisTool {

    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 釋放分散式鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖
     * @param requestId 鎖持有者id
     * @return 是否釋放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        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(lockKey), Collections.singletonList(requestId));
        return RELEASE_SUCCESS.equals(result);
    }
}
複製程式碼

解鎖的步驟:

  1. 判斷當前解鎖的競爭者id是否為鎖的持有者,如果不是直接返回失敗,如果是則進入第2步。
  2. 刪除key,如果刪除成功,返回解鎖成功,否則解鎖失敗。

注意到這裡解鎖其實是分為2個步驟,涉及到解鎖操作的一個原子性操作問題。這也是為什麼我們解鎖的時候用Lua指令碼來實現,因為Lua指令碼可以保證操作的原子性。那麼這裡為什麼需要保證這兩個步驟的操作是原子操作呢? 設想:假設當前鎖的持有者是競爭者1,競爭者1來解鎖,成功執行第1步,判斷自己就是鎖持有者,這是還未執行第2步。這是鎖過期了,然後競爭者2對這個key進行了加鎖。加鎖完成後,競爭者1又來執行第2步,此時錯誤產生了:競爭者1解鎖了不屬於自己持有的鎖。可能會有人問為什麼競爭者1執行完第1步之後突然停止了呢?這個問題其實很好回答,例如競爭者1所在的JVM發生了GC停頓,導致競爭者1的執行緒停頓。這樣的情況發生的概率很低,但是請記住即使只有萬分之一的概率,線上上環境中完全可能發生。因此必須保證這兩個步驟的操作是原子操作。

分析

是否可重入:以上實現的鎖是不可重入的,如果需要實現可重入,在SET_IF_NOT_EXIST之後,再判斷key對應的value是否為當前競爭者id,如果是返回加鎖成功,否則失敗。

鎖釋放時機:加鎖時我們設定了key的超時,當超時後,如果還未解鎖,則自動刪除key達到解鎖的目的。如果一個競爭者獲取鎖之後掛了,我們的鎖服務最多也就在超時時間的這段時間之內不可用。

Redis單點問題:如果需要保證鎖服務的高可用,可以對Redis做高可用方案:Redis叢集+主從切換。目前都有比較成熟的解決方案。

基於Zookeeper實現分散式鎖

加鎖和解鎖流程

利用Zookeeper建立臨時有序節點來實現分散式鎖:

  1. 當一個客戶端來請求時,在鎖的空間下面建立一個臨時有序節點。
  2. 如果當前節點的序列是這個空間下面最小的,則代表加鎖成功,否則加鎖失敗,加鎖失敗後設定Watcher,等待前面節點的通知。
  3. 當前節點監聽其前面一個節點,如果前面一個節點刪除了就通知當前節點。
  4. 當解鎖時當前節點通知其後繼節點,並刪除當前節點。

其基本思想類似於AQS中的等待佇列,將請求排隊處理。其流程圖如下:

zookeeper分散式鎖

分析

解決不可重入:客戶端加鎖時將主機和執行緒資訊寫入鎖中,下一次再來加鎖時直接和序列最小的節點對比,如果相同,則加鎖成功,鎖重入。

鎖釋放時機:由於我們建立的節點是順序臨時節點,當客戶端獲取鎖成功之後突然session會話斷開,ZK會自動刪除這個臨時節點。

單點問題:ZK是叢集部署的,主要一半以上的機器存活,就可以保證服務可用性。

利用curator實現

Zookeeper第三方客戶端curator中已經實現了基於Zookeeper的分散式鎖。利用curator加鎖和解鎖的程式碼如下:

// 加鎖,支援超時,可重入
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
    try {
        return interProcessMutex.acquire(timeout, unit);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return true;
}
// 解鎖
public boolean unlock() {
    try {
        interProcessMutex.release();
    } catch (Throwable e) {
        log.error(e.getMessage(), e);
    } finally {
        executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
    }
    return true;
}
複製程式碼

三種方案比較

方案 理解難易程度 實現的複雜度 效能 可靠性
基於資料庫 容易 複雜 不可靠
基於快取(Redis) 一般 一般 可靠
基於Zookeeper 簡單 一般 一般

參考資料

www.hollischuang.com/archives/17…

相關文章