Redis 实现分布式锁(Node.js)

4,461 阅读6分钟

分布式锁

在 course-se 的提交服务中,为了限制同一用户在规定时间(5秒)内,无法进行二次提交,开发人员实现了基于 Redis 的分布式锁。通常,我们称该业务场景为节流(Throttle)。

在阅读此部分代码时,我一开始寻思着完全可以使用一个 Map 维护各个用户及其剩余时间的关系,何必使用 Redis 。后来,经过了仔细思考,我忽略了服务端服务是以集群(Cluster) 的形式进行部署的,同一个用户的请求可能被转发至不同的 Node 进程,因此我们需要实现分布式锁服务,而不是进程内的锁服务。

为了提供分布式锁服务,我们需要确保以下几点:

  • 互斥访问:有且仅有一个客户端能获取到锁。
  • 避免死锁:对于任意客户端,其最终都能获取到锁,即使已拿到锁的客户端因各种问题而未解锁。

单节点锁

course-se 的现有实现,是建立 Redis 服务是单节点的前提上,我们分别探讨现有实现如何确保互斥访问避免死锁

在实现中,我们通过 Lock 类的 acquire 函数实现互斥操作,该函数调用了 getset 原子指令判断当前 key 是否已被设置:若未被设置,则设置值,表示可获取锁;否则,表示锁已被占用,不可获取锁。在对应 key 对应的值为空时,客户端获取锁,并设置值的过期时间避免出现因客户端未手动解锁造成的死锁问题,实现的核心代码如下。

async acquire() {
    const key = this.genKey();
    // 若对应值为空,表示可获取锁,则设置该值,获取锁;
    // 若对应值不为空,表示锁已被占用,不可获取。
    const val = await this.tryExec('getset', key, '1');
   	// 设置值的过期时间,避免出现因客户端未手动解锁造成的死锁问题。
    await this.tryExec('expire', key, this.expireAfter);
    return val === null;
}

genKey() {
    const { curUser: { user_id }, asgn: { asgn_id }, ce: { isExam } } = this.paramData;
    return `submit-asgn:${user_id}-${asgn_id}-${Number(isExam)}`;
}

其实,上述代码是存在问题的,我们假象以下的场景:现有服务节点 node1 和 node2 ,node1 接收了一个用户请求,成功调用 getset 获取到锁,但在准备设置过期时间(假设5s)时,node1 意外退出了,随后 node2 接收到同样的用户请求,在调用 getset 无法获取锁后,其执行了 expire 命令,为 node1 的锁进行续期,若在到期时间内,该用户一直发送同一请求,则导致该锁无法被释放,造成死锁。

为了解决上述的潜在问题,我们需要使用 SET key value NX PX expireAfter 原子指令,替换上述 getsetexpire 命令组合,我们可根据命令操作结果是否为空来判断锁的占用情况

async acquire() {
    const key = this.genKey();
    // 若 ret 不为空,表示已获取锁,否则表示锁已被占用。
    // px 参数保证了值在一定时间后会过期,避免了死锁。
    const ret = await this.tryExec('set', key, '1', 'nx', 'px', this.expireAfter);
    return ret === null;
}

函数 release 可用于锁的释放,具体代码如下。

async release() {
    return this.tryExec('del', this.genKey());
}

上述释放锁的代码也是存在潜在问题的,设想这样的场景:node1 已获取到锁(其过期时间为 5s),但由于各种原因,node1 花了 6s 的时间才完成业务,那么在第 5 秒时,锁已过期,若此时 node2 获取到了该锁,则在第 6 秒时,node1 手动调用的 release 将会释放 node2 获取到的锁,进而给其他节点提供了获取锁的可能性。

解决上述问题的方法是:在释放锁之前,判断该锁是否是自己获取的。至于具体的实现,我们可以在加锁的时候,把当前节点的唯一标识符设置为 key 对应的 value ,并在释放锁之前,检查 key 对应的 value 是否为自己的唯一标识符。

但在现有的 Redis 指令集中,我们并不能实现在 getdel 的原子操作,因此我们只能使用 Lua 脚本。

// 定义 Lua 脚本实现 get 和 del 的原子操作。
this.redis.defineCommand('lua_unlock', {
  numberOfKeys: 1,
  lua         : `
    local remote_value = redis.call("get",KEYS[1])

    if (not remote_value) then
      return 0
    elseif (remote_value == ARGV[1]) then
      return redis.call("del",KEYS[1])
    else
      return -1
    end`
});

多节点锁

对于单节点锁的实现,如果 Redis 是以主从方式进行部署的,会发生错误,这是因为主从同步是异步的,当主库发生异常时,从库还未获取到锁的信息,则可能导致多个进程持有锁,考虑以下场景:

  1. node1 在 Master 节点获取到了锁。
  2. Master 在 node1 创建的锁写入至 Slave 之前宕机了。
  3. 由于 Sentinel 机制,Slave 变成了 Master 节点,此时 Slave 没有 node1 持有锁的信息。
  4. node2 在 Slave 节点获取到了和 node1 还持有的相同的锁。

针对此情况,我们可使用 Redlock 算法,实现鲁棒性更优的分布式锁。假设现在有 N 个 Redis Master 节点,节点与节点之间完全独立,没有使用分布式协调算法,在这种情况下客户端获取锁的流程如下:

  1. 客户端获取当前时间(单位是毫秒)。
  2. 客户端轮流使用相同的 key 和随机值在 N 个节点上请求锁。在该步骤里,客户端在每个 Master 上请求锁时,会有一个和总的锁释放时间相比小得多的超时时间,如锁自动释放时间是10秒,则每个节点锁请求的超时时间在5-50毫秒的范围内。通过这种方式,可防止一个客户端在某个宕掉的 Master 节点上阻塞过长时间。
  3. 客户端计算第二步所花的总时间,只有当客户端在大多数 Master 节点上成功获取了锁(这里是3个),且总消耗时间不超过锁释放时间,则表示该锁获取成功。
  4. 若客户端成功获得锁,则锁的自动释放时间为最初的锁施放时间减去第二步所消耗的时间。
  5. 若客户端获取锁失败,不管是因为在第二步中获取的锁的数目不超过一半(\frac{N}{2} + 1),还是因为总消耗时间超过了锁的释放时间,客户端都会到每个 Master 节点释放锁,即便是那些它认为没有获取成功的锁。

初步来看,Redlock 算法流程还是很清晰的,在实际生产中,我们可使用 redlock 包提供强鲁棒的分布式锁服务。

npm install --save redlock

关于 redlock 的具体使用方法可查看其说明文档