1. 程式人生 > 程式設計 >Redis的複合SET命令和簡易的分散式鎖優化

Redis的複合SET命令和簡易的分散式鎖優化

前提

最近在跟進一個比較老的系統的時候,發現了所有排程任務使用了spring-context裡面的@Scheduled註解和自行基於Redis封裝的簡易分散式鎖控制任務不併發執行。為了不引入其他框架的情況下做一些簡單優化,筆者花點時間去研讀了一下RedisSET命令的相關檔案。

場景還原

使用@Scheduled註解實現定時任務,使用spring-data-redis提供的API實現簡易的Redis分散式鎖的虛擬碼如下:

// 每30分鐘跑一次
@Scheduled(cron = "* */30 * * * ? ")
public void scheduledMethod(){
    // 判斷KEY存在性並且設定KEY,帶超時時間5分鐘
    if (StringRedisTemplate#opsForValue()#hasKey("定時任務唯一字串標識")){
        StringRedisTemplate#opsForValue()#set("定時任務唯一字串標識","1[這裡暫時可以使用任何值]",5,TimeUnit.MINUTES);
    }
    // 這裡做排程正常業務邏輯
    doBusiness();
    // 刪除KEY
    StringRedisTemplate#opsForValue()#delete("定時任務唯一字串標識");
}
複製程式碼

上面的程式碼存在如下顯然的缺陷:

  1. 如果應用部署多個節點,由於判斷KEY的存在性和SET操作是兩個操作(非原子操作),該定時任務有可能在同一個時刻併發執行多次。
  2. 如果業務邏輯執行方法doBusiness()丟擲了異常,會導致刪除KEY的操作無法執行,KEY會到達超時時間後被刪除,這個時候相當於加鎖時間長達5分鐘,顯然是無法接受的。

但是實際上,以上兩個問題在生產環境中並沒有出現過,分析一下具體原因是:

  • 對於第1點,該應用在生產環境只部署了2個節點,節點的重啟時間並不相同,所以從天然上避免了重複執行的問題,如果CRON表示式設計為0 */30 * * * ?(0秒開始每30分鐘執行一次)就有可能出現併發問題。
  • 對於第2點,開發者在處理業務方法裡面全域性捕獲異常並且沒有外拋,所以排程方法總是會執行到刪除KEY的邏輯。

以上僅僅是巧合的情況下規避了問題出現的因子,但是從編碼規範的角度來看顯然是存在問題。基於Redis實現的分散式鎖的方案在Redis官方檔案中有一篇文章做了詳細的分析-Distributed locks with Redis,對於Java語言來說,有現成的類庫Redisson提供對應的實現。但是在解決這個問題的時候,為了簡易起見並沒有引入Redisson,而是想辦法通過原來的SETDEL兩個操作的相關思路進行優化。

SET複合命令

自從Redis2.6.12版本起,SET命令已經提供了可選的複合操作符:

SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
複製程式碼
  • 時間複雜度:O(1)

可選引數:

  • EX:設定超時時間,單位是秒。
  • PX:設定超時時間,單位是毫秒。
  • NXIF NOT EXIST的縮寫,只有KEY不存在的前提下才會設定值。
  • XXIF EXIST的縮寫,只有在KEY存在的前提下才會設定值。

列舉一些等價的命令:

原始命令 等價命令
SETEX KEY_1 1 SET KEY_1 EX 1
SETNX KEY_1 SET KEY_1 NX
SETNX KEY_1 && EXPIRE KEY_1 1 SET KEY_1 EX 1 NX
SETNX KEY_1 && PEXPIRE KEY_1 1000 SET KEY_1 PX 1000 NX

對比一下,發現SET複合命令十分簡便,可以把兩個命令合併成一個原子命令。不過注意一下,spring-data-redis裡面的封裝做得不太好,ValueOperations並沒有提供相關的方法,因此最好還是使用Redis的Java客戶端Jedis

簡易的分散式鎖實現

其實官方檔案裡面已經有很詳細的Redis分散式鎖方案(儘管這個方案在某些論文裡面被熱烈討論它存在的問題,但是生產中它已經被廣泛使用),獲取鎖的虛擬碼如下:

SET RESOURCE_NAME RANDOM_VALUE NX PX 30000
複製程式碼

釋放鎖的虛擬碼(Lua指令碼)如下:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
複製程式碼

改造前文中提及到的例子:

// 每30分鐘跑一次
@Scheduled(cron = "* */30 * * * ? ")
public void scheduledMethod(){
    try (Jedis jedis = getJedis()){
        SetParams params = new SetParams().ex(300).nx();
        String code = jedis.set("定時任務唯一字串標識","1",params);
        // 加鎖成功
        if ("OK".equals(code)){ 
           // 這裡做排程正常業務邏輯
           doBusiness();
        }
    }finally{
        jedis.del("定時任務唯一字串標識");
    }
}
複製程式碼

這裡直接在finally程式碼塊中進行KEY的刪除,實際上,我們不需要關注這個刪除動作是否成功(假如在最後階段刪除KEY出現Redis服務故障,無論使用Lua還是直接刪除導致的結果都是一樣的)。為了避免多餘的DEL操作,可以簡單優化為:

// 每30分鐘跑一次
@Scheduled(cron = "* */30 * * * ? ")
public void scheduledMethod(){
    boolean lock = false;
    try (Jedis jedis = getJedis()){
        SetParams params = new SetParams().ex(300).nx();
        String code = jedis.set("定時任務唯一字串標識",params);
        // 加鎖成功
        if ("OK".equals(code)){ 
            lock = true;
           // 這裡做排程正常業務邏輯
           doBusiness();
        }
    }finally{
        if (lock){
           jedis.del("定時任務唯一字串標識");
        }
    }
}
複製程式碼

通過SET RESOURCE_NAME RANDOM_VALUE NX PX 30000Redis單執行緒處理的特性,就能避免定時任務重複執行。其實這裡還存在一些隱患:

  • 如果一個執行緒加鎖時候指定的超時時間很長,並且在跑到finally程式碼塊之前由於不可抗因素(例如很多人喜歡提到的斷電)中斷導致鎖沒有釋放,那麼這個鎖就相當於一個殭屍鎖。
  • 鎖的持有和鎖的釋放應該由同一個操作者進行,否則操作者A進行了加鎖,如果有惡意操作者B進行解鎖,那麼會導致鎖並不安全。

上面這些隱患在Redisson中都有對應的解決方案,遲點分析一下Redisson的原始碼實現。

小結

本文在改造一個老系統的時候嘗試使用改動最小的方式進行簡易的基於Redis實現的分散式鎖優化,實際生產環境中應該儘量使用主流的可靠的類庫,如Redisson(編寫本文的時候Github的星星數已經超過10200,提交和Issue都比較活躍,遇到坑了比較容易找到解決方案,值得信賴)。如果需要造輪子,那麼就需要熟練使用中介軟體提供的API,同時注意一下編碼規範,儘可能避免因為規範和使用方式不當帶來的問題。

附件

(本文完 c-1-d e-a-20190815)