分布式锁

为什么需要分布式锁?

分布式系统中,同一系统的不同主机共享同一资源,在访问的时候需要添加互斥语义以保护资源。这种情况下就需要使用分布式锁,锁是保存在一个共享存储系统中的,所有进程都可以去该系统上申请加锁和释放锁

分布式锁实现

用于存储“锁”的共享存储系统,可以是 MySQLRedisZookeeper 以及 Etcd 等。对应的,每种共享存储系统都可以实现分布式锁。

基于数据库实现

用于实现分布锁的数据表结构定义如下:

1
2
3
4
5
6
7
create table TDistributedLock (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT ' 主键 ',
`lock_key` varchar(64) NOT NULL DEFAULT '' COMMENT ' 锁的键值 ',
`lock_timeout` datetime NOT NULL DEFAULT NOW() COMMENT ' 锁的超时时间 '
PRIMARY KEY(`id`),
UNIQUE KEY `uidx_lock_key` (`lock_key`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT=' 分布式锁表 ';

当进程申请加锁时,只需要插入一条数据即可:

1
INSERT INTO TDistributedLock(lock_key, lock_timeout) values('order_lock_key', '2022-01-07 20:30:00');

当对共享资源的操作完毕后,可以释放锁:

1
DELETE FROM TDistributedLock where lock_key='order_lock_key';

基于数据库实现的方案简单、方便,核心点是利用数据库表的唯一索引约束,保证多个进程同时申请加锁时,只有一个能获得锁。

虽然基于数据库实现的方案简单,但是存在一些问题。下面我们对问题进行说明,并给出解决方式。

  • 获得锁的进程意外 crash ,来不及释放锁。

    在插入锁记录时,同时设置了锁的过期时间 lock_timeout ,可以启动一个扫描清理线程 lock_cleaner,将超时的锁记录删除。

  • 如何支持可重入锁

    可以在锁记录表中增加一个字段,记录当前获取锁的主机信息和进程信息,在获取锁时先判断是否是重入锁。如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给它就可以了。此时需要考虑业务的复杂程度,判断是否要延长锁的更新时间。

  • 锁的可靠性怎么保证

    数据库支持主从、一主多从、多主多从等复制方案,可保证一个数据库实例宕机,其它实例可以接管过来继续提供服务。但是,有些复制方案是异步的,可能会导致锁丢失,进而导致分布锁失效。

基于 Zookeeper 实现

Zookeeper 是一个分布式协调框架,以目录结构的形式存储数据。基于 Zookeeper 的一些特性,实现分布式锁的逻辑如下:

  1. 使用 Zookeeper 的临时有序节点,每个进程获取锁需要在 Zookeeper 上创建一个临时有序节点,如在 /lock/ 目录下;
  2. 创建节点成功后,获取 /lock 目录下的所有临时节点,再判断当前进程创建的节点是否是所有的节点中序号最小的节点;
  3. 如果当前进程创建的节点是所有节点序号最小的节点,则获取锁成功。
  4. 如果当前进程创建的节点不是所有节点序号最小的节点,则对节点序号的前一个节点添加一个事件监听。 比如当前进程获取到的节点序号为/lock/003,然后所有的节点列表为 [/lock/001,/lock/002,/lock/003],则对 /lock/002 这个节点添加一个事件监听器。

当进程处理完共享资源后,就可以释放锁了,也就是删除它创建的临时节点。Zookeeper 添加其上的监听器会捕捉到移除事件,然后唤醒下一个序号的节点,然后执行第 3 步,继续抢锁。比如/lock/001 被删除了,/lock/002 监听到事件,此时节点集合为[/lock/002,/lock/003],则 /lock/002 为最小序号节点,获取到锁。

注意:不使用 Zookeeper 的持久节点,是避免加锁成功后出现异常,节点来不及删除,导致后面的节点会一直等待节点删除,从而出现死锁,临时节点因为会随着客户端的下线被删除,可以避免死锁的问题。

安全性

Zookeeper 无需考虑锁的过期时间问题,它采用的是临时节点和主动删除策略。客户端获取到锁后,只要连接不断开,除非主动删除临时节点,否则一直持有锁。即使客户端异常宕机,因为是临时节点,因此会自动删除,避免了死锁。

没有锁过期的问题,而且还能在异常时自动释放锁。一切看起来很安全,但是我们考虑下客户端获取到锁后,连接断开的情况。

我们知道,客户端和 Zookeeper 之间的连接是通过客户端定时心跳来维持的,如果 Zookeeper 长时间收不到客户端的心跳,就认为这个连接过期了,会把这个临时节点删除。对于长时间的 GC ,客户端应用程序就无法给 Zookeeper 发送心跳,一旦超时 Zookeeper 就会把锁节点删除,GC 结束后其它客户端也来获取锁,也获取到了。此时,同时有两个客户端持有锁,这是有问题的。

可以知道,Zookeeper 在进程 GC、网络延迟异常场景下的安全性得不到保证

特点

优点

  • 数据一致性得到保证
  • 不需要考虑锁的过期时间
  • 使用 watch 机制,避免了等候锁的客户端不停地轮循锁是否可用,当锁的状态发生变化时可以自动得到通知。

劣势

  • 性能问题,体现在读写和数据同步上
  • 客户端与 Zookeeper 长时间失联,锁被释放问题
  • 羊群效应,要尽量避免大量节点监控一个节点的行为,做到按需监听

基于单个 Redis 节点实现

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁。而且 Redis 的读写性能高,可以应对高并发的锁操作场景。

SETNX 实现

SETNX 命令在执行时会判断键是否存在,如果不存在,就设置键值对,如果存在,就不做任何设置。

我们可以用 SETNX 和 DEL 命令组合来实现加锁和释放锁操作。下面的伪代码示例显示了锁操作的过程:

1
2
3
4
5
6
// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key

不难看出,上述方案存在很多问题,如下:

  • 没有过期时间,进程异常退出会导致死锁
  • 不能区分来自不同客户端的锁操作,容易导致误删锁

避免死锁的一个最直接的方法就是设置一个过期时间,也就是租期。假设业务逻辑不会超过 3s,那么在加锁时,可以设置 3s 过期时间:

1
2
3
4
5
6
7
8
// 加锁
SETNX lock_key 1
// 设置 3s 租期
EXPIRE lock_key 3
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key

设置了租期后,也不能保证不会死锁。因为加锁、设置过期是 2 个操作,可能只执行了第一个操作,第二个操作没有执行,这种情况就有潜在的风险,死锁仍然可能发生。

好在 Redis 扩展了 SET 命令,可以使用一条命令替换上述存在问题的两条命令:

1
2
3
4
5
6
// 加锁并设置 3s 租期
SET lock_key 1 EX 3 NX
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key

这样就解决了死锁问题。

SET 实现

为了能达到和 SETNX 命令一样的效果,Redis 扩展了 SET 命令:

1
SET key value [EX seconds | PX milliseconds]  [NX]

虽然 SET 命令可以解决 SETNX 命令中存在的死锁问题,但是没有解决误删锁问题。这个问题的主要原因是,每个客户端在释放锁时,都是直接操作,没有检查锁是否还是自己持有。如以下场景:

  1. 客户端 A 加锁成功,开始操作共享资源;
  2. 客户端 A 操作共享资源的时间超过了锁的过期时间,执行还没有完成,锁就自动释放了;
  3. 客户端 B 加锁成功,开始操作共享资源;
  4. 客户端 A 执行完成,释放锁,此时释放的事客户端 B 的锁。

导致以上问题的关键有两个:

  • 锁过期:客户端 A 执行时间过长,导致锁提前释放了,之后被客户端 B 持有。
  • 误释放锁:客户端 A 执行完成后,以为还是自己的锁,结果释放了客户端 B 的锁。

下面我们对以上两个潜在问题进行分析并解决。

解决锁被被人释放问题

在加锁操作中,每个客户端都使用一个唯一标识,如下所示:

1
2
// $uuid 是当前客户端的唯一标识
127.0.0.1:6379> SET lock_key $uuid EX 3 NX

在释放锁操作时,我们需要判断锁变量的值,是否等于执行释放锁操作的客户端的唯一标识,如下:

1
2
3
4
5
6
//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

上面是使用 Lua 脚本(unlock.script)实现的释放锁操作的伪代码,其中,KEYS[1]表示 lock_key,ARGV[1]是当前客户端的唯一标识,这两个值都是我们在执行 Lua 脚本时作为参数传入的。

在释放锁操作中,我们使用了 Lua 脚本,这是因为,释放锁操作的逻辑也包含了读取锁变量、判断值、删除锁变量的多个操作,而 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

锁提前过期问题

锁的过期时间如果评估不好,那么就会有提前释放的风险。但是,面对不同的业务场景过期时间很难精确预估。这个时候,我们可以使用续租的方式,延续锁的过期时间。

在加锁时,先设置一个过期时间,然后启动一个后台线程,定时检测锁的实效时间,如果锁快要过期了,但操作共享资源还没有处理完成,那么就对锁进行续期,重新设置过期时间。Java 中的 Redission 在使用分布式锁时,就采用了自动续期的方式来避免锁提前过期,这个后台线程一般称做看门狗线程

Q & A

下面对基于 Redis 实现的分布式锁出现的问题以及解决方案进行梳理:

  • 针对死锁问题,可以通过设置过期时间来解决;
  • 针对锁提前释放,可以使用自动续期来解决;
  • 针对锁被误删除,可以通过检查锁的唯一标识来决定是否可以释放。

基于多个 Redis 节点实现

在使用 Redis 时,为了可靠性,一般会采用哨兵或集群的方式部署。那这种可靠性对于分布式锁有什么影响呢?我们以哨兵模式为例,分析主从切换对分布式锁的影响。

  1. 客户端 A 在主库上执行 SET 命令申请加锁成功;
  2. 主库异常宕机,申请加锁的 SET 命令还未同步到从库上;
  3. 从库被提升为新主库,此时锁的数据在新的主库上丢失了;
  4. 其它客户端向主库申请加锁也会成功,此时分布式锁失效了。

可以看到,因为 Redis 的主从复制是异步的,高可用机制不能保证锁的可靠性。因此,Redis 的作者提出了 Redlock 方案。

Redlock

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,并且总耗时不超过锁的有效时间,那么就认为客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,还有其它实例,锁的可靠性得到了保障。

可以看出,Redlock 的特点如下:

  • 不是部署主从库,而是只部署主库;
  • 主库要部署多个,官方推荐至少 5 个实例;

也就是说,Redlock 要求至少部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

算法步骤

Redlock 算法的步骤一共分为 5 步:

  1. 客户端获取当前时间戳。
  2. 客户端按顺序依次向 N 个 Redis 实例执行加锁操作。这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。为了保证某个实例加锁失败(实例宕机、网络超时、锁被其它客户端持有) Redlock 算法能够继续运行,需要给加锁操作设置一个超时时间。如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
  3. 客户端只有在满足下面的这两个条件时,才能认为是加锁成功。
    • 客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;
    • 计算客户端获取锁的总耗时,必须没有超过锁的有效时间。
  4. 加锁成功,操作共享资源。
  5. 加锁失败,向全部节点发起释放锁的请求。和在单实例上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。

关键步骤分析

Redlock 算法的关键如下:

  • 必须是大多数节点加锁成功;

    为了实现容错功能。

  • 大多数节点加锁的总耗时要小于锁设置的过期时间;

    即使大多数节点加锁成功,但如果加锁的累计耗时已经超过了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁也就没有意义了。

  • 释放锁要向全部节点发起释放锁请求。

    可能存在实例上加锁成功了,但是获取响应结果时是失败的,如网络问题导致超时。

Redlock 存在问题

效率

为了效率可以使用单节点 Redis ,即使偶尔发生锁失效(宕机、主从切换),有些业务不会产生严重后果,最差可以做幂等。毕竟,使用 Redlock 太重了。

安全性问题

在进程暂停、时钟跳跃、节点奔溃恢复等情况下,Redlock 是不安全的。

进程暂停

在 Java 中进行 GC 时,会使进程暂停,时间序列如下:

  1. 客户端 1 依次向节点 A、B、C、D、E 请求加锁;
  2. 客户端 1 获得锁后,进入 GC ,这个时间假设很长;
  3. 大多数或全部 Redis 节点上的锁都过期了;
  4. 客户端 2 依次向节点 A、B、C、D、E 请求加锁,获取锁成功;
  5. 客户端 1 和客户端 2 都获取到了锁,发生了冲突。
时钟跳跃

当多个 Redis 节点时钟发生问题时,也会导致 Redlock 锁失效。

  1. 客户端 1 依次向节点 A、B、C、D、E 请求加锁,获取到了节点 A、B、C 上的锁,但由于网络问题,无法访问 D 和 E;
  2. 节点 C 上的时钟向前跳跃,导致锁过期了。
  3. 客户端 2 依次向节点 A、B、C、D、E 请求加锁,获取节点 C、D、E 上的锁,由于网络问题,无法访问 A 和 B;
  4. 客户端 1 和客户端 2 都获取到了锁,发生了冲突。

机器的时钟发生错误,是很有可能发生的,比如:

  • 系统管理员手动修改了机器时钟
  • 机器时钟在同步 NTP 时间时,发生了大的跳跃

Redis 节点奔溃重启,如果锁信息没有持久化,那么也会造成和时钟跳跃一样的问题。

解决 Redlock 问题

时钟跳跃问题

针对时钟问题,Redlock 并不需要完全一致的时钟,只需要大体一致就可以了。避免手动修改机器时钟,并通过正确的运维保证机器时钟不会大幅度跳跃。

进程暂停问题

对于 Redlock 来说,如果进程暂停、网络延迟发生在获得锁之前,那么 Redlock 是可以检测出来的,如果超出了锁设置的过期时间,就认为加锁失败,之后释放所有节点的锁;如果发生在获得锁之后,也就是在客户端操作共享资源的过程发生问题导致锁失效,那 Redlock 确实没有办法了,但是这不仅仅是 Redlock 的问题,其它分布式锁实现也有类似问题,比如 Zookeeper 实现的分布式锁也会因一定时间没有保持心跳而断开连接,导致分布式锁失效。

总的来说,Redlock 在保证时钟正确的基础上,是可以保证正确性的。

Redlock 实践

追求性能,并能容忍一定的可靠性和安全,可以直接使用单节点的 Redis;对可靠性有追求,可以考虑使用 Redis 的可靠性机制。毕竟,Redlock 较重,而且部署成本高,时钟跳跃问题以及节点宕机也不是那么容易解决或避免的。

基于 Etcd 实现

Etcd 是 CoreOS 团队于 2013 年 6 月发起的开源项目,它的目标是构建一个高可用的分布式键值 (key-value) 数据库。Etcd 内部基于 raft 一致性算法,使用 Go 语言实现。

Etcd 分布式锁的逻辑如下:

  • Lease 机制:即租约机制(TTL,Time To Live),etcd 可以为存储的 KV 对设置租约,当租约到期,KV 将失效删除;同时也支持续约, 即 KeepAlive。
  • Revision 机制:每个 key 带有一个 Revision 属性值,etcd 每进行一次事务对应的全局 Revision 值都会加一,因此每个 key 对应的 Revision 属性值都是全局唯一的。通过比较 Revision 的大小就可以知道进行写操作的顺序。
  • 在实现分布式锁时,多个程序同时抢锁,根据 Revision 值大小依次获得锁,可以避免 “羊群效应” (也称 “惊群效应”),实现公平 锁。
  • Prefix 机制:即前缀机制,也称目录机制。可以根据前缀(目录)获取该目录下所有的 key 及对应的属性(包括 key, value 以及 revision 等)。
  • Watch 机制:即监听机制,Watch 机制支持 Watch 某个固定的 key,也支持 Watch 一个目录(前缀机制),当被 Watch 的 key 或目录 发生变化,客户端将收到通知。

小结

本篇文章对分布式锁的几种实现方式进行了介绍,重点分析了 Redis 分布式锁的实现方式,通过不断演进,最终出现了 Redlock ,并对 Redlock 存在的问题进行了说明。