redis分散式鎖,setnx+lua指令碼的java實現

2023-08-29 15:18:17

1 前言

在現在工作中,為保障服務的高可用,應對單點故障、負載量過大等單機部署帶來的問題,生產環境常用多機部署。為解決多機房部署導致的資料不一致問題,我們常會選擇用分散式鎖。

目前其他比較常見的實現方案我列舉在下面:

  1. 基於快取實現分散式鎖(本文主要使用redis實現)
  2. 基於資料庫實現分散式鎖
  3. 基於zookeeper實現分散式鎖

本文是基於redis快取實現分散式鎖,其中使用了setnx命令加鎖,expire命令設定過期時間並lua指令碼保證事務一致性。Java實現部分基於JIMDB提供的介面。JIMDB是京東自主研發的基於Redis的分散式快取與高速鍵值儲存服務。

2 SETNX

基本語法:SETNX KEY VALUE

SETNX 是表示 SET ifNot eXists, 即命令在指定的 key 不存在時,為 key 設定指定的值。

KEY 是表示待設定的key名

VALUE是設定key的對應值

若設定成功,則返回1;若設定失敗(key存在),則返回0。

由此,我們會選擇用SETNX來進行分散式鎖的實現,當Key存在時,會返回加鎖失敗的資訊。

SET 與 SETNX 區別:

SET 如果key已經存在,則會覆蓋原值,且無視型別

SETNX 如果key已經存在,則會返回0,表示設定key失敗

Redis 2.6.12版本前後對比:

2.6.12版本前:分散式鎖並不能只用SETNX實現,需要搭配EXPIRE命令設定過期時間,否則,key將永遠有效。其中,為保證SETNX和EXPIRE在同一個事務裡,我們需要藉助LUA指令碼來完成事務實現。(由於在寫這篇文章時,JIMDB還未支援SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]語法,故本文依然用lua事務)

2.6.12版本後:SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL] 語法糖可用於分散式鎖並支援原子操作,無需EXPIRE命令設定過期時間。

3 LUA指令碼

什麼是LUA指令碼?

Lua是一種輕量小巧的指令碼語言,用標準C語言編寫並以原始碼形式開放,其設計目的是為了嵌入應用程式種,從而為程式提供靈活的擴充套件和客製化功能。

為什麼需要用到LUA指令碼?

本文的鎖實現是基於兩個Redis命令 - SETNXEXPIRE。 為保證命令的原子性,我們將這兩個命令寫入LUA指令碼,並上傳至Redis伺服器。Redis伺服器會單執行緒執行LUA指令碼,以確保兩個命令在執行期間不被其他請求打斷。

LUA指令碼的優勢

  • 減少網路開銷。若干命令的多次請求,可組合成一個指令碼進行一次請求
  • 高複用性。指令碼編輯一次後,相同程式碼邏輯可多處使用,只需將不同的引數傳入即可。
  • 原子性。若期望多個命令執行期間不被其他請求打斷,或出現競爭狀態,可以用LUA指令碼實現,同時保證了事務的一致性。

分散式鎖LUA指令碼的實現

假設在同一時刻只能建立一個訂單,我們可以將orderId作為key值,uuid作為value值。過期時間設定為3秒。

LUA指令碼如下,通過Redis的eval/evalsha命令實現:

-- lua加鎖指令碼
-- KEYS[1],ARGV[1],ARGV[2]分別對應了orderId,uuid,3
-- 如果setnx成功,則繼續expire命令邏輯
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 
    then 
      -- 則給同一個key設定過期時間
       redis.call('expire',KEYS[1],ARGV[2]) 
       return 1 
    else 
      -- 如果setnx失敗,則返回0
       return 0 
end


-- lua解鎖指令碼
-- KEYS[1],ARGV[1]分別對應了orderId,uuid
-- 若無法獲取orderId快取,則認為已經解鎖
if redis.call('get',KEYS[1]) == false 
    then 
        return 1 
    -- 若獲取到orderId,並value值對應了uuid,則執行刪除命令
    elseif redis.call('get',KEYS[1]) == ARGV[1] 
    then 
        -- 刪除快取中的key
    	return redis.call('del',KEYS[1]) 
    else 
        -- 若獲取到orderId,且value值與存入時不一致,則返回特殊值,方便進行後續邏輯
        return 2 
end


【注】根據Redis的版本,在LUA指令碼中,當使用redis.call('get',key)判定快取key不存在時,需要注意對比值為布林型別的false,還是null。

根據 官方檔案 :Lua Boolean -> RESP3 Boolean reply (note that this is a change compared to the RESP2, in which returning a Boolean Lua true returned the number 1 to the Redis client, and returning a false used to return a null .

在RESP3中,redis cli返回的是空值時,lua會用布林型別false來代替。

RESP3簡介

RESP3是Redis6的新特性,是RESP v2的新版本。該協定用於使用者端和伺服器之間的請求響應通訊。由於該協定可以不對稱的使用,即使用者端傳送一個簡單的請求,伺服器可以將更復雜的並擴充後的相關資訊返回到使用者端。升級後的協定,引入了13種資料型別,使之更適用於資料庫的互動場景。

4 基於JIMDB的Java分散式鎖實現

呼叫類實現程式碼

SoRedisLock soJimLock = null;
try{
    soJimLock = new SoRedisLock("orderId", jimClient);
    if (!soJimLock.lock(3)) {
        log.error("訂單建立加鎖失敗");
        throw new BPLException("訂單建立加鎖失敗");
    }
} catch(Exception e) {
    throw e;
} finally {
    if (null != soJimLock) {
        soJimLock.unlock();
    }
}


分散式鎖實現類程式碼

public class SoRedisLock{

    /** 加鎖標誌 */
    public static final String LOCKED = "TRUE";
    /** 鎖的關鍵詞 */
    private String key;
    private Cluster jimClient;
    
    /**
     * lock的建構函式
     * 
     * @param key
     *            key+"_lock" (key使用唯一的業務單號)
     * @param
     *
     */
    public SoRedisLock(String key, Cluster jimClient)
    {
        this.key = key + "_LOCK";
        this.jimClient = jimClient;
    }
    
    /**
     * 加鎖
     *
     * @param expire
     *            鎖的持續時間(秒),過期刪除
     * @return 成功或失敗標誌
     */
    public boolean lock(int expire)
    {
        try
        {
            log.info("分散式事務加鎖,key:{}", this.key);   
            String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then " +
            		"redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
            String sha = jimClient.scriptLoad(lua_scripts);
            List<String> keys = new ArrayList<>();
            List<String> values = new ArrayList<>();
            keys.add(this.key);
            values.add(LOCKED);
            values.add(String.valueOf(expire));
            this.locked = jimClient.evalsha(sha, keys, values, false).equals(1L);
            return this.locked;
        } catch (Exception e){
        	throw new RuntimeException("Locking error", e);
        }
    }

    /**
     * 解鎖 無論是否加鎖成功,都需要呼叫unlock 建議放在finally 方法塊中
     */
    public void unlock()
    {
        if (this.jimClient == null || !this.locked) {
            return ;
        }
        try {
        String luaScript = "if redis.call('get',KEYS[1]) == false then return 1 " +
        		"elseif redis.call('get',KEYS[1]) == ARGV[1] then " +
        		"return redis.call('del',KEYS[1]) else return 2 end";
        String sha = jimClient.scriptLoad(luaScript);
        if(!jimClient.evalsha(sha, Collections.singletonList(this.key), Collections.singletonList(LOCKED), false).equals(1L)){
        	throw new RuntimeException("解鎖失敗,key:"+this.key);
        }
        } catch (Exception e) {
                log.error("unLocking error, key:{}", this.key, e);
        	throw new RuntimeException("unLocking error, key:"+this.key);
        }
    }
}


由於我們只是使用key-value做一個加鎖動作,value並無意義。故,本文key對應的value給定固定值。Jimdb提供了上傳指令碼的API,我們通過scriptLoad()方法將lua指令碼上傳至redis伺服器中。並利用evalsha()方法來進行指令碼的執行。evalsha()返回值即為指令碼中的設定的return的返回值。

我們通過list將引數傳入指令碼中,並對應指令碼中的標記位。例如上方的程式碼中:

orderId_LOCK」對應了指令碼中的KEYS[1]

TRUE」對應了指令碼中的ARGV[1]

3」對應了指令碼中的ARGV[2]

【注】若在一個指令碼中存在多個key,需要確保redis中的hashtag被啟用,以防分片導致的key不處於同一分片,進而出現「Only support single key or use same hashTag」異常。當然,hashtag啟用需要謹慎,否則分片不均導致流量的集中,造成伺服器壓力過大。

實際使用中的紀錄檔截圖

5 總結

通過上述介紹我們瞭解到如何保證Redis多個命令的原子性。當然,Redis事務一致性,也可以選擇Redis的事務(Transaction)操作來實現。Jimdb也有API支援事務的multi,discard,exec,watch和unwatch命令。本文之所以選擇使用LUA指令碼來進行實現,主要是考慮到目前Jimdb在執行事務時,流量只會打到主範例,多範例的負載均衡會失效。更多的可行方案等待大家的探索,我們下個檔案見。

6 參考資料

Redis分散式鎖: https://www.cnblogs.com/niceyoo/p/13711149.html

Redis中使用Lua指令碼:https://zhuanlan.zhihu.com/p/77484377

Redis Eval命令: https://www.redis.net.cn/order/3643.html

LUA API: https://redis.io/docs/interact/programmability/lua-api/

作者:京東物流 牟佳義

來源:京東雲開發者社群 自猿其說Tech 轉載請註明來源