Node.js 中實(shí)踐基于 Redis 的分布式鎖實(shí)現(xiàn)

在一些分布式環(huán)境下闰渔、多線程并發(fā)編程中,如果對同一資源進(jìn)行讀寫操作还绘,避免不了的一個就是資源競爭問題楚昭,通過引入分布式鎖這一概念,可以解決數(shù)據(jù)一致性問題拍顷。

作者簡介:五月君抚太,Nodejs Developer,慕課網(wǎng)認(rèn)證作者昔案,熱愛技術(shù)尿贫、喜歡分享的 90 后青年,歡迎關(guān)注 Nodejs技術(shù)棧 和 Github 開源項(xiàng)目 https://www.nodejs.red

認(rèn)識線程踏揣、進(jìn)程庆亡、分布式鎖

線程鎖:單線程編程模式下請求是順序的,一個好處是不需要考慮線程安全捞稿、資源競爭問題身冀,因此當(dāng)你進(jìn)行 Node.js 編程時(shí),也不會去考慮線程安全問題括享。那么多線程編程模式下搂根,例如 Java 你可能很熟悉一個詞 synchronized,通常也是 Java 中解決并發(fā)編程最簡單的一種方式铃辖,synchronized 可以保證在同一時(shí)刻僅有一個線程去執(zhí)行某個方法或某塊代碼剩愧。

進(jìn)程鎖:一個服務(wù)部署于一臺服務(wù)器,同時(shí)開啟多個進(jìn)程娇斩,Node.js 編程中為了利用操作系統(tǒng)資源仁卷,根據(jù) CPU 的核心數(shù)可以開啟多進(jìn)程模式穴翩,這個時(shí)候如果對一個共享資源操作還是會遇到資源競爭問題,另外每一個進(jìn)程都是相互獨(dú)立的锦积,擁有自己獨(dú)立的內(nèi)存空間芒帕。關(guān)于進(jìn)程鎖通過 Java 中的 synchronized 也很難去解決,synchronized 僅局限于在同一個 JVM 中有效丰介。

分布式鎖:一個服務(wù)無論是單線程還是多進(jìn)程模式背蟆,當(dāng)多機(jī)部署、處于分布式環(huán)境下對同一共享資源進(jìn)行操作還是會面臨同樣的問題哮幢。此時(shí)就要去引入一個概念分布式鎖带膀。如下圖所示,由于先讀數(shù)據(jù)在通過業(yè)務(wù)邏輯修改之后進(jìn)行 SET 操作橙垢,這并不是一個原子操作垛叨,當(dāng)多個客戶端對同一資源進(jìn)行先讀后寫操作就會引發(fā)并發(fā)問題,這時(shí)就要引入分布式鎖去解決柜某,通常也是一個很廣泛的解決方案嗽元。

圖片描述

基于 Redis 的分布式鎖實(shí)現(xiàn)思路

實(shí)現(xiàn)分布式鎖的方式有很多:數(shù)據(jù)庫、Redis喂击、Zookeeper还棱。這里主要介紹的是通過 Redis 來實(shí)現(xiàn)一個分布式鎖,至少要保證三個特性:安全性惭等、死鎖珍手、容錯。

安全性:所謂一個蘿卜一個坑辞做,第一點(diǎn)要做的是上鎖琳要,在任意時(shí)刻要保證僅有一個客戶端持有該鎖。

死鎖:造成死鎖可能是由于某種原因秤茅,本該釋放的鎖沒有被釋放稚补,因此在上鎖的時(shí)候可以同步的設(shè)置過期時(shí)間,如果由于客戶端自己的原因沒有被釋放框喳,也要保證鎖能夠自動釋放课幕。

容錯:容錯是在多節(jié)點(diǎn)的模式下需要考慮的,只要能保證 N/2+1 節(jié)點(diǎn)可用五垮,客戶端就可以成功獲取乍惊、釋放鎖。

Redis 單實(shí)例分布式鎖實(shí)現(xiàn)

在 Redis 的單節(jié)點(diǎn)實(shí)例下實(shí)現(xiàn)一個簡單的分布式鎖放仗,這里會借助一些簡單的 Lua 腳本來實(shí)現(xiàn)原子性润绎,不了解可以參考之前的文章 Node.js 中實(shí)踐 Redis Lua 腳本

上鎖

上鎖的第一步就是先通過 setnx 命令占坑,為了防止死鎖,通常在占坑之后還會設(shè)置一個過期時(shí)間 expire莉撇,如下所示:

setnx key value
expire key seconds

以上命令不是一個原子性操作呢蛤,所謂原子性操作是指命令在執(zhí)行過程中并不會被其它的線程或者請求打斷,以上如果 setnx 執(zhí)行成功之后棍郎,出現(xiàn)網(wǎng)絡(luò)閃斷 expire 命令便不會得到執(zhí)行其障,會導(dǎo)致死鎖出現(xiàn)。

也許你會想到使用事物來解決涂佃,但是事物有個特點(diǎn)励翼,要么成功要么失敗,都是一口氣執(zhí)行完成的巡李,在我們上面的例子中抚笔,expire 是需要先根據(jù) setnx 的結(jié)果來判斷是否需要進(jìn)行設(shè)置扶认,顯然事物在這里是行不通的侨拦,社區(qū)也有很多庫來解決這個問題,現(xiàn)在 Redis 官方 2.8 版本之后支持 set 命令傳入 setnx辐宾、expire 擴(kuò)展參數(shù)狱从,這樣就可以一條命令一口氣執(zhí)行,避免了上面的問題叠纹,如下所示:

  • value:建議設(shè)置為一個隨機(jī)值季研,在釋放鎖的時(shí)候會進(jìn)一步講解
  • EX seconds:設(shè)置的過期時(shí)間
  • PX milliseconds:也是設(shè)置過期時(shí)間,單位不一樣
  • NX|XX:NX 同 setnx 效果是一樣的
set key value [EX seconds] [PX milliseconds] [NX|XX]

釋放鎖

釋放鎖的過程就是將原本占有的坑給刪除掉誉察,但是也并不能僅僅使用 del key 刪除掉就萬事大吉了与涡,這樣很容易刪除掉別人的鎖,為什么呢持偏?舉一個例子客戶端 A 獲取到一把 key = name1 的鎖(2 秒中)驼卖,緊接著處理自己的業(yè)務(wù)邏輯,但是在業(yè)務(wù)邏輯處理這塊阻塞了耗時(shí)超過了鎖的時(shí)間鸿秆,鎖是會自動被釋放的酌畜,這期間該資源又被客戶端 B 獲取了 key = name1 的鎖,那么客戶端 A 在自己的業(yè)務(wù)處理結(jié)束之后直接使用 del key 命令刪除會把客戶端 B 的鎖給釋放掉了卿叽,所以釋放鎖的時(shí)候要做到僅釋放自己占有的鎖桥胞。

加鎖的過程中建議把 value 設(shè)置為一個隨機(jī)值,主要是為了更安全的釋放鎖考婴,在 del key 之前先判斷這個 key 存在且 value 等于自己指定的值才執(zhí)行刪除操作贩虾。判斷和刪除不是一個原子性的操作,此處仍需借助 Lua 腳本實(shí)現(xiàn)沥阱。

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

Redis 單實(shí)例分布式鎖 Node.js 實(shí)踐

使用 Node.js 的 Redis 客戶端為 ioredis整胃,npm install ioredis -S 先安裝該包。

初始化自定義 RedisLock

class RedisLock {
    /**
     * 初始化 RedisLock
     * @param {*} client 
     * @param {*} options 
     */
    constructor (client, options={}) {
        if (!client) {
            throw new Error('client 不存在');
        }

        if (client.status !== 'connecting') {
            throw new Error('client 未正常鏈接');
        }

        this.lockLeaseTime = options.lockLeaseTime || 2; // 默認(rèn)鎖過期時(shí)間 2 秒
        this.lockTimeout = options.lockTimeout || 5; // 默認(rèn)鎖超時(shí)時(shí)間 5 秒
        this.expiryMode = options.expiryMode || 'EX';
        this.setMode = options.setMode || 'NX';
        this.client = client;
    }
}

上鎖

通過 set 命令傳入 setnx、expire 擴(kuò)展參數(shù)開始上鎖占坑屁使,上鎖成功返回在岂,上鎖失敗進(jìn)行重試,在 lockTimeout 指定時(shí)間內(nèi)仍未獲取到鎖蛮寂,則獲取鎖失敗蔽午。

class RedisLock {
    
    /**
     * 上鎖
     * @param {*} key 
     * @param {*} val 
     * @param {*} expire 
     */
    async lock(key, val, expire) {
        const start = Date.now();
        const self = this;

        return (async function intranetLock() {
            try {
                const result = await self.client.set(key, val, self.expiryMode, expire || self.lockLeaseTime, self.setMode);
        
                // 上鎖成功
                if (result === 'OK') {
                    console.log(`${key} ${val} 上鎖成功`);
                    return true;
                }

                // 鎖超時(shí)
                if (Math.floor((Date.now() - start) / 1000) > self.lockTimeout) {
                    console.log(`${key} ${val} 上鎖重試超時(shí)結(jié)束`);
                    return false;
                }

                // 循環(huán)等待重試
                console.log(`${key} ${val} 等待重試`);
                await sleep(3000);
                console.log(`${key} ${val} 開始重試`);

                return intranetLock();
            } catch(err) {
                throw new Error(err);
            }
        })();
    }
}

釋放鎖

釋放鎖通過 redis.eval(script) 執(zhí)行我們定義的 redis lua 腳本。

class RedisLock {
    /**
     * 釋放鎖
     * @param {*} key 
     * @param {*} val 
     */
    async unLock(key, val) {
        const self = this;
        const script = "if redis.call('get',KEYS[1]) == ARGV[1] then" +
        "   return redis.call('del',KEYS[1]) " +
        "else" +
        "   return 0 " +
        "end";

        try {
            const result = await self.client.eval(script, 1, key, val);

            if (result === 1) {
                return true;
            }
            
            return false;
        } catch(err) {
            throw new Error(err);
        }
    }
}

測試

這里使用了 uuid 來生成唯一 ID酬蹋,這個隨機(jī)數(shù) id 只要保證唯一不管用哪種方式都可及老。

const Redis = require("ioredis");
const redis = new Redis(6379, "127.0.0.1");
const uuidv1 = require('uuid/v1');
const redisLock = new RedisLock(redis);

function sleep(time) {
    return new Promise((resolve) => {
        setTimeout(function() {
            resolve();
        }, time || 1000);
    });
}

async function test(key) {
    try {
        const id = uuidv1();
        await redisLock.lock(key, id, 20);
        await sleep(3000);
        
        const unLock = await redisLock.unLock(key, id);
        console.log('unLock: ', key, id, unLock);
    } catch (err) {
        console.log('上鎖失敗', err);
    }  
}

test('name1');
test('name1');

同時(shí)調(diào)用了兩次 test 方法進(jìn)行上鎖,只有第一個是成功的范抓,第二個 name1 26e02970-0532-11ea-b978-2160dffafa30 上鎖的時(shí)候發(fā)現(xiàn) key = name1 已被占坑骄恶,開始重試,由于以上測試中設(shè)置了 3 秒鐘之后自動釋放鎖匕垫,name1 26e02970-0532-11ea-b978-2160dffafa30 在經(jīng)過兩次重試之后上鎖成功僧鲁。

name1 26e00260-0532-11ea-b978-2160dffafa30 上鎖成功
name1 26e02970-0532-11ea-b978-2160dffafa30 等待重試
name1 26e02970-0532-11ea-b978-2160dffafa30 開始重試
name1 26e02970-0532-11ea-b978-2160dffafa30 等待重試
unLock:  name1 26e00260-0532-11ea-b978-2160dffafa30 true
name1 26e02970-0532-11ea-b978-2160dffafa30 開始重試
name1 26e02970-0532-11ea-b978-2160dffafa30 上鎖成功
unLock:  name1 26e02970-0532-11ea-b978-2160dffafa30 true

源碼地址

https://github.com/Q-Angelo/project-training/tree/master/redis/lock/redislock.js

Redlock 算法

以上是使用 Node.js 對 Redis 分布式鎖的一個簡單實(shí)現(xiàn),在單實(shí)例中是可用的象泵,當(dāng)我們對 Redis 節(jié)點(diǎn)做一個擴(kuò)展寞秃,在 Sentinel、Redis Cluster 下會怎么樣呢偶惠?

以下是一個 Redis Sentinel 的故障自動轉(zhuǎn)移示例圖春寿,假設(shè)我們客戶端 A 在主節(jié)點(diǎn) 192.168.6.128 獲取到鎖之后,主節(jié)點(diǎn)還未來得及同步信息到從節(jié)點(diǎn)就掛掉了忽孽,這時(shí)候 Sentinel 會選舉另外一個從節(jié)點(diǎn)做為主節(jié)點(diǎn)绑改,那么客戶端 B 此時(shí)也來申請相同的鎖,就會出現(xiàn)同樣一把鎖被多個客戶端持有兄一,對數(shù)據(jù)的最終一致性有很高的要求還是不行的厘线。

圖片描述

Redlock 介紹

鑒于這些問題,Redis 官網(wǎng) redis.io/topics/distlock 提供了一個使用 Redis 實(shí)現(xiàn)分布式鎖的規(guī)范算法 Redlock瘾腰,中文翻譯版參考 http://redis.cn/topics/distlock.html

Redlock 在上述文檔也有描述皆的,這里簡單做個總結(jié):Redlock 在 Redis 單實(shí)例或多實(shí)例中提供了強(qiáng)有力的保障,本身具備容錯能力蹋盆,它會從 N 個實(shí)例使用相同的 key费薄、隨機(jī)值嘗試 set key value [EX seconds] [PX milliseconds] [NX|XX] 命令去獲取鎖,在有效時(shí)間內(nèi)至少 N/2+1 個 Redis 實(shí)例取到鎖栖雾,此時(shí)就認(rèn)為取鎖成功楞抡,否則取鎖失敗,失敗情況下客戶端應(yīng)該在所有的 Redis 實(shí)例上進(jìn)行解鎖析藕。

Node.js 中應(yīng)用 Redlock

github.com/mike-marcacci/node-redlock 是 Node.js 版的 Redlock 實(shí)現(xiàn)召廷,使用起來也很簡單,開始之前先安裝 ioredis、redlock 包竞慢。

npm i ioredis -S
npm i redlock -S

編碼

const Redis = require("ioredis");
const client1 = new Redis(6379, "127.0.0.1");
const Redlock = require('redlock');
const redlock = new Redlock([client1], {
    retryDelay: 200, // time in ms
    retryCount: 5,
});

// 多個 Redis 實(shí)例
// const redlock = new Redlock(
//     [new Redis(6379, "127.0.0.1"), new Redis(6379, "127.0.0.2"), new Redis(6379, "127.0.0.3")],
// )

async function test(key, ttl, client) {
    try {
        const lock = await redlock.lock(key, ttl);

        console.log(client, lock.value);
        // do something ...

        // return lock.unlock();
    } catch(err) {
        console.error(client, err);
    }
}

test('name1', 10000, 'client1');
test('name1', 10000, 'client2');

測試

對同一個 key name1 兩次上鎖先紫,由于 client1 先取到了鎖,client2 無法獲取鎖筹煮,重試 5 次之后報(bào)錯 LockError: Exceeded 5 attempts to lock the resource "name1".

圖片描述
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末遮精,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子败潦,更是在濱河造成了極大的恐慌本冲,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,496評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件劫扒,死亡現(xiàn)場離奇詭異檬洞,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)沟饥,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,407評論 3 392
  • 文/潘曉璐 我一進(jìn)店門添怔,熙熙樓的掌柜王于貴愁眉苦臉地迎上來吊宋,“玉大人,你說我怎么就攤上這事瘟滨×蚵椋” “怎么了?”我有些...
    開封第一講書人閱讀 162,632評論 0 353
  • 文/不壞的土叔 我叫張陵谷扣,是天一觀的道長。 經(jīng)常有香客問我,道長县遣,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,180評論 1 292
  • 正文 為了忘掉前任汹族,我火速辦了婚禮萧求,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘顶瞒。我一直安慰自己夸政,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,198評論 6 388
  • 文/花漫 我一把揭開白布榴徐。 她就那樣靜靜地躺著守问,像睡著了一般。 火紅的嫁衣襯著肌膚如雪坑资。 梳的紋絲不亂的頭發(fā)上耗帕,一...
    開封第一講書人閱讀 51,165評論 1 299
  • 那天,我揣著相機(jī)與錄音袱贮,去河邊找鬼仿便。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的嗽仪。 我是一名探鬼主播荒勇,決...
    沈念sama閱讀 40,052評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼闻坚!你這毒婦竟也來了枕屉?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,910評論 0 274
  • 序言:老撾萬榮一對情侶失蹤鲤氢,失蹤者是張志新(化名)和其女友劉穎搀擂,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體卷玉,經(jīng)...
    沈念sama閱讀 45,324評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡哨颂,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,542評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了相种。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片威恼。...
    茶點(diǎn)故事閱讀 39,711評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖寝并,靈堂內(nèi)的尸體忽然破棺而出箫措,到底是詐尸還是另有隱情,我是刑警寧澤衬潦,帶...
    沈念sama閱讀 35,424評論 5 343
  • 正文 年R本政府宣布斤蔓,位于F島的核電站,受9級特大地震影響镀岛,放射性物質(zhì)發(fā)生泄漏弦牡。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,017評論 3 326
  • 文/蒙蒙 一漂羊、第九天 我趴在偏房一處隱蔽的房頂上張望驾锰。 院中可真熱鬧,春花似錦走越、人聲如沸椭豫。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,668評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽赏酥。三九已至,卻和暖如春淤毛,著一層夾襖步出監(jiān)牢的瞬間今缚,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,823評論 1 269
  • 我被黑心中介騙來泰國打工低淡, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留姓言,地道東北人瞬项。 一個月前我還...
    沈念sama閱讀 47,722評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像何荚,于是被迫代替她去往敵國和親囱淋。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,611評論 2 353

推薦閱讀更多精彩內(nèi)容