前言
分布式锁是软件系统开发中常见问题。在接收并发请求的时候,造成多个进程/ 线程 操作同一条数据,也就是资源竞争的问题,所以有了分布式锁的概念,解决数据一致性问题。本文介绍了两种基于nodejs 的解决方案。
单例分布锁
也就是基于单节点redis 的锁 本例使用的是 redis@4.6.10
,是写文章的时候的最新版。
实现思路
实现思路中要保证以下几个特性
- 使用set() "NX" 参数 加锁,用完删除锁
- 设置超时时长,避免持有锁的进程 假死/崩溃退出 ,造成无法解锁。在设定时间后自动解锁。
- 设置唯一标示,防止进程之间误删(例如A 进程假死 或者阻塞时间较长导致锁过期,B 进程拿到锁运行中的时候,A 恢复正常把B进程的锁删了)
实现代码
js
var redis = require("redis")
var crypto = require("crypto");
class RedisLock {
constructor() {
this.defaultLockMillTime = 2000//默认上锁超时时间,超过时间在自动解锁
this.defaultLockTimeout = 6000; //默认上锁重试时间,超过时间则放弃重试,返回错误
this.defaultWaitMillTime = 1000;//默认两次重试之间的间隔时间
}
async connect(url) {
let client = await redis.createClient({
url: url
})
.on('error', err => console.log('Redis Client Error', err))
.connect();
this.client = client
}
sleep(time) {
const that = this;
return new Promise((resolve) => {
setTimeout(function () {
resolve();
}, time || that.defaultWaitMillTime);
});
}
async setLock(key, expire_milliseconds) {
try {
let that = this
let PxTime = expire_milliseconds || that.defaultLockMillTime
const uniqueStr = crypto.randomBytes(15).toString('hex');
let ret = await that.client.set(key, uniqueStr, {
PX: PxTime,
NX: true
})
return {
key,
uniqueStr,
isOk: ret == "OK"
}
} catch (error) {
return {
key,
isOk: false,
error
}
}
}
async deleteLock(key, uniqueStr) {
try {
let that = this
const unlockScript = `if
redis.call("get", KEYS[1]) == ARGV[1]
then
return redis.call("del", KEYS[1])
else
return 0 end`;
let ret = await that.client.eval(unlockScript, {
keys: [key],
arguments: [uniqueStr]
})
return {
key,
uniqueStr,
isOk: ret == 1
}
} catch (error) {
return {
key,
isOk: false,
error
}
}
}
async attempLock(key, expire_milliseconds) {
const start = (new Date()).getTime();
const that = this;
return (async function tryLock() {
try {
const result = await that.setLock(key, expire_milliseconds)
if (result.isOk) {
console.log(`${key}上锁成功`);
return result
}
if (Math.floor((new Date()).getTime() - start) > that.defaultLockTimeout) {
console.log(`${key}上锁重试超时结束`);
return result
}
console.log(`${key}等待重试`);
await that.sleep()
console.log(`${key}开始重试`);
return tryLock();
} catch (error) {
throw error
}
})()
}
}
这个类主要有以下几个功能
setLock
设置锁(无重试),给定锁的key 和 超时时间 ,返回key,特定唯一字符串 和 是否成功的状态。这个可以用来做接口的流量限制,例如每个用户特定时间内(例如1秒)只能访问一次接口,超过频率(返回isOk:false)直接返回错误,不进行后续处理以减轻后续服务的压力。
attempLock
设置锁 (含重试机制),给定锁的key 和 超时时间,返回值与上面相同,不同的是会在设定的时间内重复尝试获取锁,超时未获取则返回错误。这个适合用于获取某个资源的控制,例如要读写某个表的数据,或者某一段业务逻辑。
deleteLock
删除锁,拿到key 和上面返回的唯一字符串,当key和唯一字符串都匹配上了的话,就会解锁成功。这里使用lua 脚本实现。
测试过程
现在来测试一下
js
async function main() {
let lockInstance = new RedisLock()
await lockInstance.connect("redis://127.0.0.1:6379")
let lockObj = await lockInstance.setLock("abc", 2000)
console.log("lockObj", lockObj)
let lockObj2 = await lockInstance.setLock("abc", 2000)
console.log("lockObj2", lockObj2)
let dLockObj = await lockInstance.deleteLock(lockObj.key, lockObj.uniqueStr)
console.log("dLockObj", dLockObj)
let lockObj3 = await lockInstance.setLock("abc", 2000)
console.log("lockObj3", lockObj3)
process.exit()
}
main()
结果是
shell
lockObj { key: 'abc', uniqueStr: '92903949667279b888b446d1acace2', isOk: true } //成功
lockObj2 { // 失败
key: 'abc',
uniqueStr: '56de14ea9c1a8639fa1ae18b299c2e',
isOk: false
}
dLockObj { key: 'abc', uniqueStr: '92903949667279b888b446d1acace2', isOk: true }//成功
lockObj3 { key: 'abc', uniqueStr: '926febe98fb264dd1a043cd1d91628', isOk: true }//成功
测试二
js
async function main() {
let lockInstance = new RedisLock()
await lockInstance.connect("redis://127.0.0.1:6379")
let lockObj = await lockInstance.setLock("abc", 2000)
console.log("lockObj", lockObj)
let lockObj2 = await lockInstance.attempLock("abc", 2000)
console.log("lockObj2", lockObj2)
process.exit()
}
main()
结果是
shell
lockObj { key: 'abc', uniqueStr: '0202f02c083f090f7b985122f2f709', isOk: true }
abc等待重试
abc开始重试
abc等待重试
abc开始重试
abc上锁成功
lockObj2 { key: 'abc', uniqueStr: 'b671f236097547f4ffe9f5f718eb75', isOk: true }
Redlock
作为单例分布锁的升级版,redlock 是基于 redis 分布式环境的。有N 个 实例 ,这些节点完全互相独立,不存在主从复制或者其他集群协调机制。它会从N个实例使用相同的key以及随机数,尝试加锁,在有效时间至少N/2+1个redis 实列取到锁就认为是加锁成功,否则加锁失败,失败的情况下应该在所以的Redis 实例上进行解锁.
使用方法
当前使用ioredis@5.3.2
redlock@v5.0.0-beta.2
js
const Redis = require("ioredis")
const { default: Redlock } = require("redlock")
const clientA = new Redis()
async function main() {
const redlock = new Redlock([clientA], {
retryCount: 10,
retryDelay: 200
})
let lock = await redlock.acquire(['key1'], 1000)
console.log("lock1",lock.value)
await lock.release()
let lock3 = await redlock.acquire(["key1"], 1000)
console.log("lock2",lock3.value)
process.exit()
}
main()
输出结果
shell
lock1 59f19810b6bc4f886ed3a6f4bd8335b1
lock2 f4f8ba6323eac8db31a65bba82ce8f46
源码浅析
看了下redlock npm 报的源码,挺少的,就一个js 500 行不到。以 acquire
为例理一下代码的走向
首先展示一下加锁的lua 脚本,主要功能就是循环Keys 数组,只要有任意存在的键就返回0,否则就会设置所有key ,ARGV[1]
是随机字符串 ARGV[2]
是超时时间
lua
const ACQUIRE_SCRIPT = `
-- Return 0 if an entry already exists.
for i, key in ipairs(KEYS) do
if redis.call("exists", key) == 1 then
return 0
end
end
-- Create an entry for each provided key.
for i, key in ipairs(KEYS) do
redis.call("set", key, ARGV[1], "PX", ARGV[2])
end
-- Return the number of entries added.
return #KEYS
`;
开始正文
js
async acquire(resources, duration, settings) {
var _a;
const start = Date.now();
const value = this._random();
try {
// 注意这个_execute 这个是一个核心函数,后面要讲
// 这里acquireScript 是一个lua 脚本,就是上面的脚本
// value 是唯一随机数
// resources 可以理解为key
// duration 是超时时间
const { attempts } = await this._execute(this.scripts.acquireScript, resources, [value, duration], settings);
// ----省略
}
catch (error) {
// 上锁失败要把所有节点的上锁数据清除
await this._execute(this.scripts.releaseScript, resources, [value], {
retryCount: 0,
}).catch(() => {
});
throw error;
}
}
js
async _execute(script, keys, args, _settings) {
const maxAttempts = settings.retryCount === -1 ? Infinity : settings.retryCount + 1;
const attempts = [];
while (true) {
// _attemptOperation 这里是重点
const { vote, stats } = await this._attemptOperation(script, keys, args);
// 每次重试都会推到数组里去,用于计算重试次数
attempts.push(stats);
// 执行成功 直接返回
if (vote === "for") {
return { attempts };
}
// 重试 --省略
}
}
js
async _attemptOperation(script, keys, args) {
return await new Promise((resolve) => {
const clientResults = [];
for (const client of this.clients) {
// 循环每个redis节点
//_attemptOperationOnClient 主要是这段
clientResults.push(this._attemptOperationOnClient(client, script, keys, args));
}
//... 省略当中的代码
const stats = {
membershipSize: clientResults.length,
quorumSize: Math.floor(clientResults.length / 2) + 1,
votesFor: new Set(),
votesAgainst: new Map(),
};
// 统计 clientResults 当中的vote="for"的个数满足N/2+1 的话就认定加锁成功
if (stats.votesFor.size === stats.quorumSize) {
resolve({
vote: "for",
stats: statsPromise,
});
}
});
}
js
async _attemptOperationOnClient(client, script, keys, args) {
try {
// 这里的 key 就是最上层acquire 的 resources
// 这里的 args 就是最上层的 aquire 的 [value, duration],
let result;
try {
// 小技巧 evalsha 执行服务器换存中的脚本,之前执行过就不用重新加载了
const shaResult = (await client.evalsha(script.hash, keys.length, [
...keys,
...args,
]));
result = shaResult;
}
catch (error) {
// 之前没运行过改代码则新载入代码运行,
const rawResult = (await client.eval(script.value, keys.length, [
...keys,
...args,
]));
result = rawResult;
}
// 这里keys是一个数组,lua 脚本会返回加锁成功的key数组的个数。如果两个数字不相等则说明部分加锁失败
if (result !== keys.length) {
throw new ResourceLockedError(`The operation was applied to: ${result} of the ${keys.length} requested resources.`);
}
return {
vote: "for",
client,
value: result,
};
}
catch (error) {
if (!(error instanceof Error)) {
throw new Error(`Unexpected type ${typeof error} thrown with value: ${error}`);
}
this.emit("error", error);
return {
vote: "against",
client,
error,
};
}
}
整理下具体的流程
- acquire 主函数执行
_execute
加锁,如失败则清除所有节点的锁 _execute
执行_attemptOperation
进行 循环重试,成功则直接返回,超过次数返回错误_attemptOperation
循环所有 redis 节点 进行加锁并统计成功节点个数 ,满足N/2+1 的话就认定加锁成功,否则失败