定义

分布式锁是一种用于在分布式系统环境中控制并发访问共享资源的机制。在分布式系统中的多个进程或节点可能会竞争访问同一个资源时,分布式锁可以确保每次只有一个进程能够访问该资源,从而避免出现数据不一致或冲突。

实现方式

基于数据库

利用数据库进行锁管理是最简单的一种方式。一种常见的方法是利用数据库表行锁,以某一特定表记录作为锁标志。例如:

  • 插入唯一键记录,两个不同进程同时插入记录时,只有一个能成功。
  • 更新记录中的标志位来表示锁的状态。

但这种方式存在性能瓶颈,不适合并发量高的场景

基于 Redis

SET 命令很常见,那 NX 选项是干嘛的呢?

NX 选项要求设置 Key 时:

  • 如果 Key 不存在,那么设置 Key 为 Value,并返回 OK
  • 如果 Key 存在,那么不修改 Key,并返回 nil

基于这个特性,可以利用 NX 选项设置分布式锁:

获取锁

一个进程想要获取锁,只需要执行 SETNX 即可:

SET Lock-0 value123 NX

# 或者直接使用 SETNX
SETNX Lock-0 value123

如果返回 OK,说明成功获取锁,可以访问接下来的临界资源

但要考虑一种情况:如果进程挂了,就永远无法释放这个锁了,因此,需要给锁设置一个合理的超时时间:

SET Lock-0 value123 NX EX 10 # 设置 10s 的过期时间

释放锁

一个进程想要释放已有的锁,只需要执行 DELETE 即可:

DELETE Lock-0

但是这种方式存在一个潜在的问题,来看看这个场景:

  • 进程 1 获取了 Lock0,设置了超时时间为 10s
  • 进程 1 执行业务逻辑,但整体时间超过了 10s
  • 由于超时,锁自动释放
  • 进程 2 此时可以成功获取锁 Lock0,并执行业务逻辑
  • 此时,进程 1 业务逻辑执行完毕,于是 释放锁 Lock0

可以发现问题所在:进程 1 错误地释放了不属于自己的锁,该锁由进程 2 持有

那么如何解决这个问题呢?很简单,就是在设置锁时,Value 设置成一个随机值:

SET Lock-0 <random_value> NX EX 10 # 设置 10s 的过期时间

然后在释放锁时,检查一下当前这个锁是否还属于自己:

value := redis.Get("Lock-0")
// 如果 value 与自己设置的 value 相同,执行释放操作
if value == random_value {
    redis.Delete("Lock-0")
}

但是这种方式还存在一个问题:Get 与 Delete 整体不是一个原子操作

如果进程 1 判断 Lock-0 还属于自己,进入 if 分支并准备释放的同时,锁恰好过期并被另外一个进程获取了,那么还是会出现错误释放的问题

怎么解决?肯定要将这两个操作封装成一个原子操作

如果释放锁的逻辑在我们的业务服务器上执行,那还是回到了分布式的问题,又要依赖分布式锁才能实现原子操作,形成闭环

解决方案:使用 Lua,将释放锁的逻辑封装成一个 Lua 脚本,然后在 Redis Server 上串行执行

集群模式下的 Redis

如果 Redis 是单节点部署,那上面描述的操作完全没有问题

但是,Redis 还提供了:主从(哨兵)模式和集群模式

如果 Redis 是多节点部署,那么还是会产生问题:

  • 假设进程 1 成功执行 SETNX 命令
  • 在 Redis 主节点同步数据给从节点时,主节点宕机了,没有成功将 SETNX 这一条命令同步给从节点
  • 哨兵(或者集群模式下的其他主节点)发现了主节点宕机,于是在从节点中选出新的主节点

新的从节点上是不包含 SETNX 命令产生的结果的,也就是说,我们的服务进程会认为锁已经被释放(或者压根就不存在),这样造成了重复获取锁

解决方案呢?

基于 Zookeeper

前面提到了集群模式下的 Redis,分布式锁会有一定问题

原因就出现在:Redis 本身不是强一致的,在同步数据给从节点前,就告诉客户端命令执行完成了

那么如何解决?

配置 min-replicas-to-write 和 min-replicas-max-lag

Redis 提供了两个配置参数来确保写操作在一定数量的从节点确认后才返回客户端:

  • min-replicas-to-write:指定写操作至少需要多少个从节点确认。
  • min-replicas-max-lag:指定从节点的最大延迟(以秒为单位),超过这个延迟的从节点将不被视为有效确认。

例如:

min-replicas-to-write 2
min-replicas-max-lag 10

这表示至少需要有 2 个从节点在 10 秒内确认写操作,Redis 才会返回客户端。如果条件不满足,写操作将被拒绝,并返回错误信息。

使用 Zookeeper 而不是 Redis 作为分布式锁解决方案

使用 ZK 来实现分布式锁,因为 ZAB 协议是强一致的(类似 Raft),不会出现上面描述的问题

使用 ZooKeeper 实现非扩展锁

func Lock() {
    for {
        // 尝试创建锁文件,该文件与客户端会话绑定
        if Create("lock", data, O_Ephemeral) == true {
            // 如果自己是第一个创建的,会返回 true,说明拿到了锁
            return
        }
        // 设置 watch,如果此时存在 lock,那么等待 ZK 通知 lock 文件被删除
        if Exist("lock", true) == true {
            wait()
        }
        // 否则说明在 Create 到 Exist 调用期间,锁被释放,重新尝试
    }
}

func Unlock(version int) {
    Delete("lock", version)
}

注意 Exist 的调用,除了设置 watch 以外,还相当于双重判断了 lock 到底存不存在

但这种实现方式存在「惊群效应」,因此被叫做「非扩展锁」

为什么会有「惊群效应」?因为当一个客户端释放锁时,ZK 会通知所有 watch 了这个 lock file 的客户端

也就是说,剩余的客户端被 几乎同时唤醒,重试获取锁

使用 ZooKeeper 实现扩展锁

为了解决上面提到的「惊群效应」,这里提供另一种实现:

// 以 Ephemeral、Sequential 模式创建锁文件
// 这里假设文件名为 lock-6
Create("lock", data, O_Ephemeral | O_Sequential)
for {
    // 获取以 lock 开头的所有文件
    List("lock*")

    // 如果没有比自己创建的文件名更小的(即 lock-1 ... lock-5)
    // 说明自己是第一个创建的,获取成功
    if no lower-file {
        break
    }

    // 如果存在比自己小的下一个文件(即 lock-5)
    // 等待,直到 ZK 通知
    if Exist(next-lower-file, true) {
        wait()
    }

    // continue
}

上面的代码,理想情况下,最多循环两次即可获取锁,因为 watch 的对象仅仅是一个,因此避免了「惊群效应」

这里讲一下 List:

List 得到了文件的列表,我们就知道了比自己序列号更小的下一个锁文件。Zookeeper 可以确保,一旦一个序列号,比如说 27,被使用了,那么之后创建的 Sequential 文件不会使用更小的序列号。所以,我们可以确定第一次 LIST 之后,不会有序列号低于 27 的锁文件被创建,那为什么在重试的时候要再次 LIST 文件?为什么不直接跳过?

答案是,持有更低序列号 Sequential 文件的客户端,可能在我们没有注意的时候就释放了锁,也可能已经挂了。比如说,我们是排在第 27 的客户端,但是排在第 26 的客户端在它获得锁之前就挂了。因为它挂了,Zookeeper 会自动的删除它的锁文件(因为创建锁文件时,同时也指定了 ephemeral=TRUE)。所以这时,我们要等待的是序列号 25 的锁文件释放。所以,尽管不可能再创建序列号更小的锁文件,但是排在前面的锁文件可能会有变化,所以我们需要在循环的最开始再次调用 LIST,以防在等待锁的队列里排在我们前面的客户端挂了。

只要不存在比自己序号更低的锁文件,就成功获取到了锁

这种实现方式感觉实现了一种「排序」等待机制,客户端获取锁的顺序,与第一次创建锁文件的顺序是一致的