基於redis的分散式鎖實現

aoho發表於2018-01-06

關於分散式鎖

很久之前有講過併發程式設計中的鎖併發程式設計的鎖機制:synchronized和lock。在單程式的系統中,當存在多個執行緒可以同時改變某個變數時,就需要對變數或程式碼塊做同步,使其在修改這種變數時能夠線性執行消除併發修改變數。而同步的本質是通過鎖來實現的。為了實現多個執行緒在一個時刻同一個程式碼塊只能有一個執行緒可執行,那麼需要在某個地方做個標記,這個標記必須每個執行緒都能看到,當標記不存在時可以設定該標記,其餘後續執行緒發現已經有標記了則等待擁有標記的執行緒結束同步程式碼塊取消標記後再去嘗試設定標記。

分散式環境下,資料一致性問題一直是一個比較重要的話題,而又不同於單程式的情況。分散式與單機情況下最大的不同在於其不是多執行緒而是多程式。多執行緒由於可以共享堆記憶體,因此可以簡單的採取記憶體作為標記儲存位置。而程式之間甚至可能都不在同一臺物理機上,因此需要將標記儲存在一個所有程式都能看到的地方。

常見的是秒殺場景,訂單服務部署了多個例項。如秒殺商品有4個,第一個使用者購買3個,第二個使用者購買2個,理想狀態下第一個使用者能購買成功,第二個使用者提示購買失敗,反之亦可。而實際可能出現的情況是,兩個使用者都得到庫存為4,第一個使用者買到了3個,更新庫存之前,第二個使用者下了2個商品的訂單,更新庫存為2,導致出錯。

在上面的場景中,商品的庫存是共享變數,面對高併發情形,需要保證對資源的訪問互斥。在單機環境中,Java中其實提供了很多併發處理相關的API,但是這些API在分散式場景中就無能為力了。也就是說單純的Java Api並不能提供分散式鎖的能力。分散式系統中,由於分散式系統的分佈性,即多執行緒和多程式並且分佈在不同機器中,synchronized和lock這兩種鎖將失去原有鎖的效果,需要我們自己實現分散式鎖。

常見的鎖方案如下:

  • 基於資料庫實現分散式鎖
  • 基於快取,實現分散式鎖,如redis
  • 基於Zookeeper實現分散式鎖

下面我們簡單介紹下這幾種鎖的實現。

基於資料庫

基於資料庫的鎖實現也有兩種方式,一是基於資料庫表,另一種是基於資料庫排他鎖。

基於資料庫表的增刪

基於資料庫表增刪是最簡單的方式,首先建立一張鎖的表主要包含下列欄位:方法名,時間戳等欄位。

具體使用的方法,當需要鎖住某個方法時,往該表中插入一條相關的記錄。這邊需要注意,方法名是有唯一性約束的,如果有多個請求同時提交到資料庫的話,資料庫會保證只有一個操作可以成功,那麼我們就可以認為操作成功的那個執行緒獲得了該方法的鎖,可以執行方法體內容。

執行完畢,需要delete該記錄。

當然,筆者這邊只是簡單介紹一下。對於上述方案可以進行優化,如應用主從資料庫,資料之間雙向同步。一旦掛掉快速切換到備庫上;做一個定時任務,每隔一定時間把資料庫中的超時資料清理一遍;使用while迴圈,直到insert成功再返回成功,雖然並不推薦這樣做;還可以記錄當前獲得鎖的機器的主機資訊和執行緒資訊,那麼下次再獲取鎖的時候先查詢資料庫,如果當前機器的主機資訊和執行緒資訊在資料庫可以查到的話,直接把鎖分配給他就可以了,實現可重入鎖。

基於資料庫排他鎖

我們還可以通過資料庫的排他鎖來實現分散式鎖。基於MySql的InnoDB引擎,可以使用以下方法來實現加鎖操作:

public void lock(){
    connection.setAutoCommit(false)
    int count = 0;
    while(count < 4){
        try{
            select * from lock where lock_name=xxx for update;
            if(結果不為空){
                //代表獲取到鎖
                return;
            }
        }catch(Exception e){

        }
        //為空或者拋異常的話都表示沒有獲取到鎖
        sleep(1000);
        count++;
    }
    throw new LockException();
}
複製程式碼

在查詢語句後面增加for update,資料庫會在查詢過程中給資料庫表增加排他鎖。當某條記錄被加上排他鎖之後,其他執行緒無法再在該行記錄上增加排他鎖。其他沒有獲取到鎖的就會阻塞在上述select語句上,可能的結果有2種,在超時之前獲取到了鎖,在超時之前仍未獲取到鎖。

獲得排它鎖的執行緒即可獲得分散式鎖,當獲取到鎖之後,可以執行方法的業務邏輯,執行完方法之後,釋放鎖connection.commit()

存在的問題主要是效能不高和sql超時的異常。

基於資料庫鎖的優缺點

上面兩種方式都是依賴資料庫的一張表,一種是通過表中的記錄的存在情況確定當前是否有鎖存在,另外一種是通過資料庫的排他鎖來實現分散式鎖。

  • 優點是直接藉助資料庫,簡單容易理解。
  • 缺點是運算元據庫需要一定的開銷,效能問題需要考慮。

基於Zookeeper

基於zookeeper臨時有序節點可以實現的分散式鎖。每個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個唯一的瞬時有序節點。 判斷是否獲取鎖的方式很簡單,只需要判斷有序節點中序號最小的一個。 當釋放鎖的時候,只需將這個瞬時節點刪除即可。同時,其可以避免服務當機導致的鎖無法釋放,而產生的死鎖問題。

提供的第三方庫有curator,具體使用讀者可以自行去看一下。Curator提供的InterProcessMutex是分散式鎖的實現。acquire方法獲取鎖,release方法釋放鎖。另外,鎖釋放、阻塞鎖、可重入鎖等問題都可以有有效解決。講下阻塞鎖的實現,客戶端可以通過在ZK中建立順序節點,並且在節點上繫結監聽器,一旦節點有變化,Zookeeper會通知客戶端,客戶端可以檢查自己建立的節點是不是當前所有節點中序號最小的,如果是就獲取到鎖,便可以執行業務邏輯。

最後,Zookeeper實現的分散式鎖其實存在一個缺點,那就是效能上可能並沒有快取服務那麼高。因為每次在建立鎖和釋放鎖的過程中,都要動態建立、銷燬瞬時節點來實現鎖功能。ZK中建立和刪除節點只能通過Leader伺服器來執行,然後將資料同不到所有的Follower機器上。併發問題,可能存在網路抖動,客戶端和ZK叢集的session連線斷了,zk叢集以為客戶端掛了,就會刪除臨時節點,這時候其他客戶端就可以獲取到分散式鎖了。

基於快取

相對於基於資料庫實現分散式鎖的方案來說,基於快取來實現在效能方面會表現的更好一點,存取速度快很多。而且很多快取是可以叢集部署的,可以解決單點問題。基於快取的鎖有好幾種,如memcached、redis、本文下面主要講解基於redis的分散式實現。

基於redis的分散式鎖實現

SETNX

使用redis的SETNX實現分散式鎖,多個程式執行以下Redis命令:

SETNX lock.id <current Unix time + lock timeout + 1>
複製程式碼

SETNX是將 key 的值設為 value,當且僅當 key 不存在。若給定的 key 已經存在,則 SETNX 不做任何動作。

  • 返回1,說明該程式獲得鎖,SETNX將鍵 lock.id 的值設定為鎖的超時時間,當前時間 +加上鎖的有效時間。
  • 返回0,說明其他程式已經獲得了鎖,程式不能進入臨界區。程式可以在一個迴圈中不斷地嘗試 SETNX 操作,以獲得鎖。

存在死鎖的問題

SETNX實現分散式鎖,可能會存在死鎖的情況。與單機模式下的鎖相比,分散式環境下不僅需要保證程式可見,還需要考慮程式與鎖之間的網路問題。某個執行緒獲取了鎖之後,斷開了與Redis 的連線,鎖沒有及時釋放,競爭該鎖的其他執行緒都會hung,產生死鎖的情況。

在使用 SETNX 獲得鎖時,我們將鍵 lock.id 的值設定為鎖的有效時間,執行緒獲得鎖後,其他執行緒還會不斷的檢測鎖是否已超時,如果超時,等待的執行緒也將有機會獲得鎖。然而,鎖超時,我們不能簡單地使用 DEL 命令刪除鍵 lock.id 以釋放鎖。

考慮以下情況:

  1. A已經首先獲得了鎖 lock.id,然後線A斷線。B,C都在等待競爭該鎖;
  2. B,C讀取lock.id的值,比較當前時間和鍵 lock.id 的值來判斷是否超時,發現超時;
  3. B執行 DEL lock.id命令,並執行 SETNX lock.id 命令,並返回1,B獲得鎖;
  4. C由於各剛剛檢測到鎖已超時,執行 DEL lock.id命令,將B剛剛設定的鍵 lock.id 刪除,執行 SETNX lock.id命令,並返回1,即C獲得鎖。

上面的步驟很明顯出現了問題,導致B,C同時獲取了鎖。在檢測到鎖超時後,執行緒不能直接簡單地執行 DEL 刪除鍵的操作以獲得鎖。

對於上面的步驟進行改進,問題是出在刪除鍵的操作上面,那麼獲取鎖之後應該怎麼改進呢? 首先看一下redis的GETSET這個操作,GETSET key value,將給定 key 的值設為 value ,並返回 key 的舊值(old value)。利用這個操作指令,我們改進一下上述的步驟。

  1. A已經首先獲得了鎖 lock.id,然後線A斷線。B,C都在等待競爭該鎖;
  2. B,C讀取lock.id的值,比較當前時間和鍵 lock.id 的值來判斷是否超時,發現超時;
  3. B檢測到鎖已超時,即當前的時間大於鍵 lock.id 的值,B會執行 GETSET lock.id <current Unix timestamp + lock timeout + 1>設定時間戳,通過比較鍵 lock.id 的舊值是否小於當前時間,判斷程式是否已獲得鎖;
  4. B發現GETSET返回的值小於當前時間,則執行 DEL lock.id命令,並執行 SETNX lock.id 命令,並返回1,B獲得鎖;
  5. C執行GETSET得到的時間大於當前時間,則繼續等待。

線上程釋放鎖,即執行 DEL lock.id 操作前,需要先判斷鎖是否已超時。如果鎖已超時,那麼鎖可能已由其他執行緒獲得,這時直接執行 DEL lock.id 操作會導致把其他執行緒已獲得的鎖釋放掉。

一種實現方式

獲取鎖

public boolean lock(long acquireTimeout, TimeUnit timeUnit) throws InterruptedException {
    acquireTimeout = timeUnit.toMillis(acquireTimeout);
    long acquireTime = acquireTimeout + System.currentTimeMillis();
    //使用J.U.C的ReentrantLock
    threadLock.tryLock(acquireTimeout, timeUnit);
    try {
    	//迴圈嘗試
        while (true) {
        	//呼叫tryLock
            boolean hasLock = tryLock();
            if (hasLock) {
                //獲取鎖成功
                return true;
            } else if (acquireTime < System.currentTimeMillis()) {
                break;
            }
            Thread.sleep(sleepTime);
        }
    } finally {
        if (threadLock.isHeldByCurrentThread()) {
            threadLock.unlock();
        }
    }

    return false;
}

public boolean tryLock() {

    long currentTime = System.currentTimeMillis();
    String expires = String.valueOf(timeout + currentTime);
    //設定互斥量
    if (redisHelper.setNx(mutex, expires) > 0) {
    	//獲取鎖,設定超時時間
        setLockStatus(expires);
        return true;
    } else {
        String currentLockTime = redisUtil.get(mutex);
        //檢查鎖是否超時
        if (Objects.nonNull(currentLockTime) && Long.parseLong(currentLockTime) < currentTime) {
            //獲取舊的鎖時間並設定互斥量
            String oldLockTime = redisHelper.getSet(mutex, expires);
            //舊值與當前時間比較
            if (Objects.nonNull(oldLockTime) && Objects.equals(oldLockTime, currentLockTime)) {
            	//獲取鎖,設定超時時間
                setLockStatus(expires);
                return true;
            }
        }

        return false;
    }
}
複製程式碼

lock呼叫tryLock方法,引數為獲取的超時時間與單位,執行緒在超時時間內,獲取鎖操作將自旋在那裡,直到該自旋鎖的保持者釋放了鎖。

tryLock方法中,主要邏輯如下:

  • setnx(lockkey, 當前時間+過期超時時間) ,如果返回1,則獲取鎖成功;如果返回0則沒有獲取到鎖
  • get(lockkey)獲取值oldExpireTime ,並將這個value值與當前的系統時間進行比較,如果小於當前系統時間,則認為這個鎖已經超時,可以允許別的請求重新獲取
  • 計算newExpireTime=當前時間+過期超時時間,然後getset(lockkey, newExpireTime) 會返回當前lockkey的值currentExpireTime
  • 判斷currentExpireTime與oldExpireTime 是否相等,如果相等,說明當前getset設定成功,獲取到了鎖。如果不相等,說明這個鎖又被別的請求獲取走了,那麼當前請求可以直接返回失敗,或者繼續重試

釋放鎖

    public boolean unlock() {
        //只有鎖的持有執行緒才能解鎖
        if (lockHolder == Thread.currentThread()) {
            //判斷鎖是否超時,沒有超時才將互斥量刪除
            if (lockExpiresTime > System.currentTimeMillis()) {
                redisHelper.del(mutex);
                logger.info("刪除互斥量[{}]", mutex);
            }
            lockHolder = null;
            logger.info("釋放[{}]鎖成功", mutex);

            return true;
        } else {
            throw new IllegalMonitorStateException("沒有獲取到鎖的執行緒無法執行解鎖操作");
        }
    }
複製程式碼

在上面獲取鎖的實現下,其實此處的釋放鎖函式可以不需要了,有興趣的讀者可以結合上面的程式碼看下為什麼?有想法可以留言哦!

總結

本文主要講解了基於redis分散式鎖的實現,在分散式環境下,資料一致性問題一直是一個比較重要的話題,而synchronized和lock鎖在分散式環境已經失去了作用。常見的鎖的方案有基於資料庫實現分散式鎖、基於快取實現分散式鎖、基於Zookeeper實現分散式鎖,簡單介紹了每種鎖的實現特點;然後,文中探索了一下redis鎖的實現方案;最後,本文給出了基於Java實現的redis分散式鎖,讀者可以自行驗證一下。

訂閱最新文章,歡迎關注我的公眾號

微信公眾號

參考

  1. 分散式鎖的一點理解
  2. 分散式鎖1 Java常用技術方案
  3. 分散式鎖的幾種實現方式

相關文章