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 的话就认定加锁成功,否则失败
相关推荐
摇滚侠6 小时前
Redis 秒杀功能 超卖问题 一人一单问题 分布式锁 精彩!精彩!
redis·分布式·bootstrap
Emily呀11 小时前
【无标题】
redis
愈努力俞幸运12 小时前
function calling与mcp
android·数据库·redis
IronMurphy12 小时前
Redis拷打第一讲
数据库·redis·缓存
楠枬13 小时前
Redis 事务
数据库·redis·缓存
摇滚侠14 小时前
Redis 查询接口加缓存 缓存雪崩 缓存穿透 缓存击穿 精彩!精彩!
redis·缓存
Mr. zhihao15 小时前
[特殊字符] 从 Redis 缓存穿透到布隆过滤器,再到布谷鸟过滤器:一次穿透防护的进化之旅
数据库·redis·缓存
@小匠15 小时前
Redis 7 持久化机制
数据库·redis·缓存
phltxy15 小时前
Redis 核心数据类型之 String 详解
数据库·redis·bootstrap
码哥字节16 小时前
开多个 Agent 后 Claude Code 账单翻了 4 倍,一个配置解决了
redis·性能