redis 分布式锁 的nodejs 实现

前言

分布式锁是软件系统开发中常见问题。在接收并发请求的时候,造成多个进程/ 线程 操作同一条数据,也就是资源竞争的问题,所以有了分布式锁的概念,解决数据一致性问题。本文介绍了两种基于nodejs 的解决方案。

单例分布锁

也就是基于单节点redis 的锁 本例使用的是 redis@4.6.10,是写文章的时候的最新版。

实现思路

实现思路中要保证以下几个特性

  1. 使用set() "NX" 参数 加锁,用完删除锁
  2. 设置超时时长,避免持有锁的进程 假死/崩溃退出 ,造成无法解锁。在设定时间后自动解锁。
  3. 设置唯一标示,防止进程之间误删(例如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 的话就认定加锁成功,否则失败
相关推荐
程楠楠&M8 小时前
node.js第三方Express 框架
前端·javascript·node.js·express
盛夏绽放8 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
fpcc9 小时前
redis6.0之后的多线程版本的问题
c++·redis
刘九灵9 小时前
Redis ⽀持哪⼏种数据类型?适⽤场景,底层结构
redis·缓存
Roc.Chang10 小时前
macos 使用 nvm 管理 node 并自定义安装目录
macos·node.js·nvm
登云时刻12 小时前
Kubernetes集群外连接redis集群和使用redis-shake工具迁移数据(一)
redis·kubernetes·bootstrap
怕冷的火焰(~杰)12 小时前
Node基本使用
node.js
_半夏曲14 小时前
node.js、nginx、iis、tomcat针对部署方面的简述
nginx·node.js·tomcat
生椰拿铁You14 小时前
09 —— Webpack搭建开发环境
前端·webpack·node.js
煎饼小狗17 小时前
Redis五大基本类型——Zset有序集合命令详解(命令用法详解+思维导图详解)
数据库·redis·缓存