天天看點

Redis如何實作分布式鎖?前言實作思路總結

文章已收錄Github精選,歡迎Star: https://github.com/yehongzhi

前言

如果在一個分布式系統中,我們從資料庫中讀取一個資料,然後修改儲存,這種情況很容易遇到并發問題。因為讀取和更新儲存不是一個原子操作,在并發時就會導緻資料的不正确。這種場景其實并不少見,比如電商秒殺活動,庫存數量的更新就會遇到。如果是單機應用,直接使用本地鎖就可以避免。如果是分布式應用,本地鎖派不上用場,這時就需要引入分布式鎖來解決。

由此可見分布式鎖的目的其實很簡單,就是為了保證多台伺服器在執行某一段代碼時保證隻有一台伺服器執行。

為了保證分布式鎖的可用性,至少要確定鎖的實作要同時滿足以下幾點:

  • 互斥性。在任何時刻,保證隻有一個用戶端持有鎖。
  • 不能出現死鎖。如果在一個用戶端持有鎖的期間,這個用戶端崩潰了,也要保證後續的其他用戶端可以上鎖。
  • 保證上鎖和解鎖都是同一個用戶端。

一般來說,實作分布式鎖的方式有以下幾種:

  • 使用MySQL,基于唯一索引。
  • 使用ZooKeeper,基于臨時有序節點。
  • 使用Redis,基于setnx指令。

本篇文章主要講解Redis的實作方式。

實作思路

Redis實作分布式鎖主要利用Redis的

setnx

指令。

setnx

SET if not exists

(如果不存在,則 SET)的簡寫。

127.0.0.1:6379> setnx lock value1 #在鍵lock不存在的情況下,将鍵key的值設定為value1
(integer) 1
127.0.0.1:6379> setnx lock value2 #試圖覆寫lock的值,傳回0表示失敗
(integer) 0
127.0.0.1:6379> get lock #擷取lock的值,驗證沒有被覆寫
"value1"
127.0.0.1:6379> del lock #删除lock的值,删除成功
(integer) 1
127.0.0.1:6379> setnx lock value2 #再使用setnx指令設定,傳回0表示成功
(integer) 1
127.0.0.1:6379> get lock #擷取lock的值,驗證設定成功
"value2"           

上面這幾個指令就是最基本的用來完成分布式鎖的指令。

加鎖:使用

setnx key value

指令,如果key不存在,設定value(加鎖成功)。如果已經存在lock(也就是有用戶端持有鎖了),則設定失敗(加鎖失敗)。

解鎖:使用

del

指令,通過删除鍵值釋放鎖。釋放鎖之後,其他用戶端可以通過

setnx

指令進行加鎖。

key的值可以根據業務設定,比如是使用者中心使用的,可以指令為

USER_REDIS_LOCK

,value可以使用uuid保證唯一,用于辨別加鎖的用戶端。保證加鎖和解鎖都是同一個用戶端。

那麼接下來就可以寫一段很簡單的加鎖代碼:

private static Jedis jedis = new Jedis("127.0.0.1");

private static final Long SUCCESS = 1L;

/**
  * 加鎖
  */
public boolean tryLock(String key, String requestId) {
    //使用setnx指令。
    //不存在則儲存傳回1,加鎖成功。如果已經存在則傳回0,加鎖失敗。
    return SUCCESS.equals(jedis.setnx(key, requestId));
}

//删除key的lua腳本,先比較requestId是否相等,相等則删除
private static final String DEL_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

/**
  * 解鎖
  */
public boolean unLock(String key, String requestId) {
    //删除成功表示解鎖成功
    Long result = (Long) jedis.eval(DEL_SCRIPT, Collections.singletonList(key), Collections.singletonList(requestId));
    return SUCCESS.equals(result);
}           
Redis如何實作分布式鎖?前言實作思路總結

問題一

這僅僅滿足上述的第一個條件和第三個條件,保證上鎖和解鎖都是同一個用戶端,也保證隻有一個用戶端持有鎖。

但是第二點沒法保證,因為如果一個用戶端持有鎖的期間突然崩潰了,就會導緻無法解鎖,最後導緻出現死鎖的現象。

Redis如何實作分布式鎖?前言實作思路總結

是以要有個逾時的機制,在設定key的值時,需要加上有效時間,如果有效時間過期了,就會自動失效,就不會出現死鎖。然後加鎖的代碼就會變成這樣。

public boolean tryLock(String key, String requestId, int expireTime) {
    //使用jedis的api,保證原子性
    //NX 不存在則操作 EX 設定有效期,機關是秒
    String result = jedis.set(key, requestId, "NX", "EX", expireTime);
    //傳回OK則表示加鎖成功
    return "OK".equals(result);
}           
Redis如何實作分布式鎖?前言實作思路總結

但是聰明的同學肯定會問,有效時間設定多長,假如我的業務操作比有效時間長,我的業務代碼還沒執行完就自動給我解鎖了,不就完蛋了嗎。

這個問題就有點棘手了,在網上也有很多讨論,第一種解決方法就是靠程式員自己去把握,預估一下業務代碼需要執行的時間,然後設定有效期時間比執行時間長一些,保證不會因為自動解鎖影響到用戶端業務代碼的執行。

但是這并不是萬全之策,比如網絡抖動這種情況是無法預測的,也有可能導緻業務代碼執行的時間變長,是以并不安全。

有一種方法比較靠譜一點,就是給鎖續期。在Redisson架構實作分布式鎖的思路,就使用watchDog機制實作鎖的續期。當加鎖成功後,同時開啟守護線程,預設有效期是30秒,每隔10秒就會給鎖續期到30秒,隻要持有鎖的用戶端沒有當機,就能保證一直持有鎖,直到業務代碼執行完畢由用戶端自己解鎖,如果當機了自然就在有效期失效後自動解鎖。

Redis如何實作分布式鎖?前言實作思路總結

問題二

但是聰明的同學可能又會問,你這個鎖隻能加一次,不可重入。可重入鎖意思是在外層使用鎖之後,内層仍然可以使用,那麼可重入鎖的實作思路又是怎麼樣的呢?

在Redisson實作可重入鎖的思路,使用Redis的哈希表存儲可重入次數,當加鎖成功後,使用

hset

指令,value(重入次數)則是1。

"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; "           

如果同一個用戶端再次加鎖成功,則使用

hincrby

自增加一。

"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"           
Redis如何實作分布式鎖?前言實作思路總結

解鎖時,先判斷可重複次數是否大于0,大于0則減一,否則删除鍵值,釋放鎖資源。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}           
Redis如何實作分布式鎖?前言實作思路總結

為了保證操作原子性,加鎖和解鎖操作都是使用lua腳本執行。

問題三

上面的加鎖方法是加鎖後立即傳回加鎖結果,如果加鎖失敗的情況下,總不可能一直輪詢嘗試加鎖,直到加鎖成功為止,這樣太過耗費性能。是以需要利用釋出訂閱的機制進行優化。

步驟如下:

當加鎖失敗後,訂閱鎖釋放的消息,自身進入阻塞狀态。

當持有鎖的用戶端釋放鎖的時候,釋出鎖釋放的消息。

當進入阻塞等待的其他用戶端收到鎖釋放的消息後,解除阻塞等待狀态,再次嘗試加鎖。

Redis如何實作分布式鎖?前言實作思路總結

總結

以上的實作思路僅僅考慮在單機版Redis上,如果是叢集版Redis需要考慮的問題還要再多一點。Redis由于他的高性能讀寫能力,是以在并發高的場景下使用Redis分布式鎖會多一點。

問題一,二,三其實就是redis分布式鎖不斷改良發展的過程,第一個問題是設定有效期防止死鎖,并且引入守護線程給鎖續期,第二個問題是支援可重入鎖,第三個問題是加鎖失敗後阻塞等待,等鎖釋放後再次嘗試加鎖。Redisson架構解決這三個問題的思路也非常值得學習。

這篇文章就寫到這裡了,非常感謝大家的閱讀,希望看完之後能得到一些啟發和收獲。

Redis如何實作分布式鎖?前言實作思路總結

覺得有用就點個贊吧,你的點贊是我創作的最大動力~

我是一個努力讓大家記住的程式員。我們下期再見!!!

能力有限,如果有什麼錯誤或者不當之處,請大家批評指正,一起學習交流!