在一些分布式環(huán)境下、多線程并發(fā)編程中,如果對同一資源進行讀寫操作,避免不了的一個就是資源競爭問題,通過引入分布式鎖這一概念,可以解決數(shù)據(jù)一致性問題。
作者簡介:五月君,Nodejs Developer,慕課網(wǎng)認證作者,熱愛技術(shù)、喜歡分享的 90 后青年,歡迎關(guān)注 Nodejs技術(shù)棧 和 Github 開源項目 https://www.nodejs.red
認識線程、進程、分布式鎖
線程鎖:單線程編程模式下請求是順序的,一個好處是不需要考慮線程安全、資源競爭問題,因此當你進行 Node.js 編程時,也不會去考慮線程安全問題。那么多線程編程模式下,例如 Java 你可能很熟悉一個詞 synchronized,通常也是 Java 中解決并發(fā)編程最簡單的一種方式,synchronized 可以保證在同一時刻僅有一個線程去執(zhí)行某個方法或某塊代碼。
進程鎖:一個服務部署于一臺服務器,同時開啟多個進程,Node.js 編程中為了利用操作系統(tǒng)資源,根據(jù) CPU 的核心數(shù)可以開啟多進程模式,這個時候如果對一個共享資源操作還是會遇到資源競爭問題,另外每一個進程都是相互獨立的,擁有自己獨立的內(nèi)存空間。關(guān)于進程鎖通過 Java 中的 synchronized 也很難去解決,synchronized 僅局限于在同一個 JVM 中有效。
分布式鎖:一個服務無論是單線程還是多進程模式,當多機部署、處于分布式環(huán)境下對同一共享資源進行操作還是會面臨同樣的問題。此時就要去引入一個概念分布式鎖。如下圖所示,由于先讀數(shù)據(jù)在通過業(yè)務邏輯修改之后進行 SET 操作,這并不是一個原子操作,當多個客戶端對同一資源進行先讀后寫操作就會引發(fā)并發(fā)問題,這時就要引入分布式鎖去解決,通常也是一個很廣泛的解決方案。
基于 Redis 的分布式鎖實現(xiàn)思路
實現(xiàn)分布式鎖的方式有很多:數(shù)據(jù)庫、Redis、Zookeeper。這里主要介紹的是通過 Redis 來實現(xiàn)一個分布式鎖,至少要保證三個特性:安全性、死鎖、容錯。
安全性:所謂一個蘿卜一個坑,第一點要做的是上鎖,在任意時刻要保證僅有一個客戶端持有該鎖。
死鎖:造成死鎖可能是由于某種原因,本該釋放的鎖沒有被釋放,因此在上鎖的時候可以同步的設置過期時間,如果由于客戶端自己的原因沒有被釋放,也要保證鎖能夠自動釋放。
容錯:容錯是在多節(jié)點的模式下需要考慮的,只要能保證 N/2+1 節(jié)點可用,客戶端就可以成功獲取、釋放鎖。
Redis 單實例分布式鎖實現(xiàn)
在 Redis 的單節(jié)點實例下實現(xiàn)一個簡單的分布式鎖,這里會借助一些簡單的 Lua 腳本來實現(xiàn)原子性,不了解可以參考之前的文章 Node.js 中實踐 Redis Lua 腳本
上鎖
上鎖的第一步就是先通過 setnx 命令占坑,為了防止死鎖,通常在占坑之后還會設置一個過期時間 expire,如下所示:
setnx key value
expire key seconds
以上命令不是一個原子性操作,所謂原子性操作是指命令在執(zhí)行過程中并不會被其它的線程或者請求打斷,以上如果 setnx 執(zhí)行成功之后,出現(xiàn)網(wǎng)絡閃斷 expire 命令便不會得到執(zhí)行,會導致死鎖出現(xiàn)。
也許你會想到使用事物來解決,但是事物有個特點,要么成功要么失敗,都是一口氣執(zhí)行完成的,在我們上面的例子中,expire 是需要先根據(jù) setnx 的結(jié)果來判斷是否需要進行設置,顯然事物在這里是行不通的,社區(qū)也有很多庫來解決這個問題,現(xiàn)在 Redis 官方 2.8 版本之后支持 set 命令傳入 setnx、expire 擴展參數(shù),這樣就可以一條命令一口氣執(zhí)行,避免了上面的問題,如下所示:
- value:建議設置為一個隨機值,在釋放鎖的時候會進一步講解
- EX seconds:設置的過期時間
- PX milliseconds:也是設置過期時間,單位不一樣
- NX|XX:NX 同 setnx 效果是一樣的
set key value [EX seconds] [PX milliseconds] [NX|XX]
釋放鎖
釋放鎖的過程就是將原本占有的坑給刪除掉,但是也并不能僅僅使用 del key 刪除掉就萬事大吉了,這樣很容易刪除掉別人的鎖,為什么呢?舉一個例子客戶端 A 獲取到一把 key = name1 的鎖(2 秒中),緊接著處理自己的業(yè)務邏輯,但是在業(yè)務邏輯處理這塊阻塞了耗時超過了鎖的時間,鎖是會自動被釋放的,這期間該資源又被客戶端 B 獲取了 key = name1 的鎖,那么客戶端 A 在自己的業(yè)務處理結(jié)束之后直接使用 del key 命令刪除會把客戶端 B 的鎖給釋放掉了,所以釋放鎖的時候要做到僅釋放自己占有的鎖。
加鎖的過程中建議把 value 設置為一個隨機值,主要是為了更安全的釋放鎖,在 del key 之前先判斷這個 key 存在且 value 等于自己指定的值才執(zhí)行刪除操作。判斷和刪除不是一個原子性的操作,此處仍需借助 Lua 腳本實現(xiàn)。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
Redis 單實例分布式鎖 Node.js 實踐
使用 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; // 默認鎖過期時間 2 秒
this.lockTimeout = options.lockTimeout || 5; // 默認鎖超時時間 5 秒
this.expiryMode = options.expiryMode || 'EX';
this.setMode = options.setMode || 'NX';
this.client = client;
}
}
上鎖
通過 set 命令傳入 setnx、expire 擴展參數(shù)開始上鎖占坑,上鎖成功返回,上鎖失敗進行重試,在 lockTimeout 指定時間內(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;
}
// 鎖超時
if (Math.floor((Date.now() - start) / 1000) > self.lockTimeout) {
console.log(`${key} ${val} 上鎖重試超時結(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,這個隨機數(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');
同時調(diào)用了兩次 test 方法進行上鎖,只有第一個是成功的,第二個 name1 26e02970-0532-11ea-b978-2160dffafa30 上鎖的時候發(fā)現(xiàn) key = name1 已被占坑,開始重試,由于以上測試中設置了 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 分布式鎖的一個簡單實現(xiàn),在單實例中是可用的,當我們對 Redis 節(jié)點做一個擴展,在 Sentinel、Redis Cluster 下會怎么樣呢?
以下是一個 Redis Sentinel 的故障自動轉(zhuǎn)移示例圖,假設我們客戶端 A 在主節(jié)點 192.168.6.128 獲取到鎖之后,主節(jié)點還未來得及同步信息到從節(jié)點就掛掉了,這時候 Sentinel 會選舉另外一個從節(jié)點做為主節(jié)點,那么客戶端 B 此時也來申請相同的鎖,就會出現(xiàn)同樣一把鎖被多個客戶端持有,對數(shù)據(jù)的最終一致性有很高的要求還是不行的。
Redlock 介紹
鑒于這些問題,Redis 官網(wǎng) redis.io/topics/distlock 提供了一個使用 Redis 實現(xiàn)分布式鎖的規(guī)范算法 Redlock,中文翻譯版參考 http://redis.cn/topics/distlock.html
Redlock 在上述文檔也有描述,這里簡單做個總結(jié):Redlock 在 Redis 單實例或多實例中提供了強有力的保障,本身具備容錯能力,它會從 N 個實例使用相同的 key、隨機值嘗試 set key value [EX seconds] [PX milliseconds] [NX|XX] 命令去獲取鎖,在有效時間內(nèi)至少 N/2+1 個 Redis 實例取到鎖,此時就認為取鎖成功,否則取鎖失敗,失敗情況下客戶端應該在所有的 Redis 實例上進行解鎖。
Node.js 中應用 Redlock
github.com/mike-marcacci/node-redlock 是 Node.js 版的 Redlock 實現(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 實例
// 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 次之后報錯 LockError: Exceeded 5 attempts to lock the resource "name1".