为什么需要分布式锁?
分布式系统中,同一系统的不同主机共享同一资源,在访问的时候需要添加互斥语义以保护资源。这种情况下就需要使用分布式锁,锁是保存在一个共享存储系统中的,所有进程都可以去该系统上申请加锁和释放锁。
分布式锁实现
用于存储“锁”的共享存储系统,可以是 MySQL
、Redis
、Zookeeper
以及 Etcd
等。对应的,每种共享存储系统都可以实现分布式锁。
基于数据库实现
用于实现分布锁的数据表结构定义如下:
1 | create table TDistributedLock ( |
当进程申请加锁时,只需要插入一条数据即可:
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 的一些特性,实现分布式锁的逻辑如下:
- 使用 Zookeeper 的临时有序节点,每个进程获取锁需要在 Zookeeper 上创建一个临时有序节点,如在 /lock/ 目录下;
- 创建节点成功后,获取 /lock 目录下的所有临时节点,再判断当前进程创建的节点是否是所有的节点中序号最小的节点;
- 如果当前进程创建的节点是所有节点序号最小的节点,则获取锁成功。
- 如果当前进程创建的节点不是所有节点序号最小的节点,则对节点序号的前一个节点添加一个事件监听。 比如当前进程获取到的节点序号为/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 | // 加锁 |
不难看出,上述方案存在很多问题,如下:
- 没有过期时间,进程异常退出会导致死锁
- 不能区分来自不同客户端的锁操作,容易导致误删锁
避免死锁的一个最直接的方法就是设置一个过期时间,也就是租期。假设业务逻辑不会超过 3s,那么在加锁时,可以设置 3s 过期时间:
1 | // 加锁 |
设置了租期后,也不能保证不会死锁。因为加锁、设置过期是 2 个操作,可能只执行了第一个操作,第二个操作没有执行,这种情况就有潜在的风险,死锁仍然可能发生。
好在 Redis 扩展了 SET 命令,可以使用一条命令替换上述存在问题的两条命令:
1 | // 加锁并设置 3s 租期 |
这样就解决了死锁问题。
SET 实现
为了能达到和 SETNX 命令一样的效果,Redis 扩展了 SET 命令:
1 | SET key value [EX seconds | PX milliseconds] [NX] |
虽然 SET 命令可以解决 SETNX 命令中存在的死锁问题,但是没有解决误删锁问题。这个问题的主要原因是,每个客户端在释放锁时,都是直接操作,没有检查锁是否还是自己持有。如以下场景:
- 客户端 A 加锁成功,开始操作共享资源;
- 客户端 A 操作共享资源的时间超过了锁的过期时间,执行还没有完成,锁就自动释放了;
- 客户端 B 加锁成功,开始操作共享资源;
- 客户端 A 执行完成,释放锁,此时释放的事客户端 B 的锁。
导致以上问题的关键有两个:
- 锁过期:客户端 A 执行时间过长,导致锁提前释放了,之后被客户端 B 持有。
- 误释放锁:客户端 A 执行完成后,以为还是自己的锁,结果释放了客户端 B 的锁。
下面我们对以上两个潜在问题进行分析并解决。
解决锁被被人释放问题
在加锁操作中,每个客户端都使用一个唯一标识,如下所示:
1 | // $uuid 是当前客户端的唯一标识 |
在释放锁操作时,我们需要判断锁变量的值,是否等于执行释放锁操作的客户端的唯一标识,如下:
1 | //释放锁 比较unique_value是否相等,避免误释放 |
上面是使用 Lua 脚本(unlock.script)实现的释放锁操作的伪代码,其中,KEYS[1]表示 lock_key,ARGV[1]是当前客户端的唯一标识,这两个值都是我们在执行 Lua 脚本时作为参数传入的。
在释放锁操作中,我们使用了 Lua 脚本,这是因为,释放锁操作的逻辑也包含了读取锁变量、判断值、删除锁变量的多个操作,而 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
锁提前过期问题
锁的过期时间如果评估不好,那么就会有提前释放的风险。但是,面对不同的业务场景过期时间很难精确预估。这个时候,我们可以使用续租的方式,延续锁的过期时间。
在加锁时,先设置一个过期时间,然后启动一个后台线程,定时检测锁的实效时间,如果锁快要过期了,但操作共享资源还没有处理完成,那么就对锁进行续期,重新设置过期时间。Java 中的 Redission 在使用分布式锁时,就采用了自动续期的方式来避免锁提前过期,这个后台线程一般称做看门狗线程。
Q & A
下面对基于 Redis 实现的分布式锁出现的问题以及解决方案进行梳理:
- 针对死锁问题,可以通过设置过期时间来解决;
- 针对锁提前释放,可以使用自动续期来解决;
- 针对锁被误删除,可以通过检查锁的唯一标识来决定是否可以释放。
基于多个 Redis 节点实现
在使用 Redis 时,为了可靠性,一般会采用哨兵或集群的方式部署。那这种可靠性对于分布式锁有什么影响呢?我们以哨兵模式为例,分析主从切换对分布式锁的影响。
- 客户端 A 在主库上执行 SET 命令申请加锁成功;
- 主库异常宕机,申请加锁的 SET 命令还未同步到从库上;
- 从库被提升为新主库,此时锁的数据在新的主库上丢失了;
- 其它客户端向主库申请加锁也会成功,此时分布式锁失效了。
可以看到,因为 Redis 的主从复制是异步的,高可用机制不能保证锁的可靠性。因此,Redis 的作者提出了 Redlock 方案。
Redlock
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,并且总耗时不超过锁的有效时间,那么就认为客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,还有其它实例,锁的可靠性得到了保障。
可以看出,Redlock 的特点如下:
- 不是部署主从库,而是只部署主库;
- 主库要部署多个,官方推荐至少 5 个实例;
也就是说,Redlock 要求至少部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。
算法步骤
Redlock 算法的步骤一共分为 5 步:
- 客户端获取当前时间戳。
- 客户端按顺序依次向 N 个 Redis 实例执行加锁操作。这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。为了保证某个实例加锁失败(实例宕机、网络超时、锁被其它客户端持有) Redlock 算法能够继续运行,需要给加锁操作设置一个超时时间。如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
- 客户端只有在满足下面的这两个条件时,才能认为是加锁成功。
- 客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;
- 计算客户端获取锁的总耗时,必须没有超过锁的有效时间。
- 加锁成功,操作共享资源。
- 加锁失败,向全部节点发起释放锁的请求。和在单实例上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。
关键步骤分析
Redlock 算法的关键如下:
- 必须是大多数节点加锁成功;
为了实现容错功能。
- 大多数节点加锁的总耗时要小于锁设置的过期时间;
即使大多数节点加锁成功,但如果加锁的累计耗时已经超过了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁也就没有意义了。
- 释放锁要向全部节点发起释放锁请求。
可能存在实例上加锁成功了,但是获取响应结果时是失败的,如网络问题导致超时。
Redlock 存在问题
效率
为了效率可以使用单节点 Redis ,即使偶尔发生锁失效(宕机、主从切换),有些业务不会产生严重后果,最差可以做幂等。毕竟,使用 Redlock 太重了。
安全性问题
在进程暂停、时钟跳跃、节点奔溃恢复等情况下,Redlock 是不安全的。
进程暂停
在 Java 中进行 GC 时,会使进程暂停,时间序列如下:
- 客户端 1 依次向节点 A、B、C、D、E 请求加锁;
- 客户端 1 获得锁后,进入 GC ,这个时间假设很长;
- 大多数或全部 Redis 节点上的锁都过期了;
- 客户端 2 依次向节点 A、B、C、D、E 请求加锁,获取锁成功;
- 客户端 1 和客户端 2 都获取到了锁,发生了冲突。
时钟跳跃
当多个 Redis 节点时钟发生问题时,也会导致 Redlock 锁失效。
- 客户端 1 依次向节点 A、B、C、D、E 请求加锁,获取到了节点 A、B、C 上的锁,但由于网络问题,无法访问 D 和 E;
- 节点 C 上的时钟向前跳跃,导致锁过期了。
- 客户端 2 依次向节点 A、B、C、D、E 请求加锁,获取节点 C、D、E 上的锁,由于网络问题,无法访问 A 和 B;
- 客户端 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 存在的问题进行了说明。