漫谈分布式锁实现
分布式锁是控制分布式系统之间同步访问共享资源的一种方式,本文介绍了使用 Redlock、etcd 等常见分布式锁的实现方式与 Google Chubby 的设计思路,并探讨不同类型的分布式锁的适用场景。
在分布式系统与微服务架构中,不同的系统或同一个系统的不同节点之间共享同一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰,保证一致性。
分布式锁提供了分布式环境下共享资源的互斥访问,业务依赖分布式锁追求效率提升,或者依赖分布式锁追求访问的绝对互斥。同时,在接入分布式锁服务过程中,要考虑接入成本、服务可靠性、分布式锁切换精度以及正确性等问题,正确和合理的使用分布式锁,是需要持续思考并予以优化的。
概述
一般来说,分布式锁需要满足以下特性:
- 排他性:在任意时刻,只有一个客户端能持有锁;
- 无死锁:即使有一个客户端在持有锁期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁;
- 加锁和解锁必须是同一个客户端,客户端不能把其他客户端加的锁释放掉;
- 容错性:少数节点失效,锁服务仍可以对外提供加解锁服务。
其中分布式锁的死锁与编程语言提供锁的死锁概念不同,后者死锁描述的是多个线程由于相互等待而永远被阻塞的情况,分布式锁的死锁是指,如果请求执行因为某些原因意外退出了,导致创建了锁但是没有释放锁,那么这个锁将一直存在,以至于后续锁请求被阻塞住。为了防止死锁通常会给分布式锁设置 TTL(Time To Live),TTL 过期后被自动释放。TTL 策略也有一定的弊端,如果执行任务的进程还没有执行完,但是锁因为 TTL 过期被自动释放,可能被其它进程重新加锁,这就造成多个进程同时获取到了锁。
Redis 分布式锁
Redis 单机锁
Redis 单机锁的实现十分简单,只需要执行指令SET key random_value NX PX 60000
就可以获得一个 TTL 60s 的锁。如果这个 key 已经被强占了,那么客户端会获取锁失败。Redis 锁需要客户端实现算法保证所有获取锁请求生成随机的唯一 value,并将 value 保存下来,当客户端执行完代码释放锁时,需要先获取 Redis value 并与本地存储的 value 进行比较,只有两者一致时才会执行DEL
操作释放锁:
|
|
random value 是为了保证某个客户端持有的锁不会被其它客户端错误地释放掉,试想一种场景:客户端 A 拿到了 key1 的锁,但被某个耗时操作阻塞了很长时间,达到超时时间后 Redis 自动释放了这个锁;随后客户端 B 拿到了 key1 的锁,这时客户端 A 操作完成,尝试删除已经被客户端 B 持有的 key1 锁。使用上面的原子操作脚本可以保证每个客户端用一个随机字符串作为「签名」,保证每个锁只能被获得锁的客户端删除释放。
得益于 Redis 基于内存存储数据与优秀的程序设计,单机 Redis 能够支撑 10w+ QPS 的请求量,可以满足大多数场景。而单机锁的问题在于一是无法保障容错性,如果 Redis 发生单点故障,那么所有需要获取分布式锁的服务将全部阻塞,二是即使 Redis 采用主从复制架构,主节点崩溃时还未将最新的数据复制到从节点,使得从节点接替主节点时部分数据丢失,违反了锁的排他性。
Redlock 分布式锁
Redlock 是 Redis 的作者 antirez 给出的集群模式的 Redis 分布式锁,可以看作是单机锁实现的一种扩展。它基于 N 个完全独立的 Redis Master 节点实现,这些主节点间不会复制数据或使用任何隐含的分布式协调算法。一个客户端要获得锁,需要执行以下步骤:
- 客户端获取当前时间,单位是毫秒;
- 客户端用相同的 key 和 random_value 顺序地在 N 个节点上请求锁。在这一步中,客户端在每个 Master 上请求锁时,会有一个比总的锁 TTL 时长小的多的超时时间,例如如果锁自动释放时间是 10s,那每个节点锁请求的超时时间可能是 5~50ms 的范围。超时时间可以防止客户端在某个宕掉的 Master 节点上阻塞过长时间,如果一个 Master 节点不可用了,客户端会尽快尝试下一个 Master 节点;
- 客户端计算第二步中获取锁所花的时间,如果客户端在超过 N/2 +1 个 Master 节点上成功获取了锁,并且总消耗的时间不超过 TTL,那么这个锁就认为是获取成功了;
- 如果锁获取成功,那么锁的真正 TTL 为原有的 TTL - 总消耗时间;
- 如果锁获取失败,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会向每个 Master 节点释放锁,包括那些没有获取锁。
Redlock 获取锁失败后,会在随机延时后不断进行重试,直至最大次数,采用随机延时是为了避免不同客户端同时重试,导致谁都无法拿到锁的情况出现。
虽然 Redlock 采用过半写入策略来保障锁的互斥性,但是严重依赖于客户端反复请求锁服务。如果我们的节点没有开启数据持久化,假设一共有 5 个 Redis 节点:A、B、C、D、E,发生了如下的事件序列:
- Client1 成功锁住了 A、B、C,获取锁成功,但 D 和 E 没有锁住;
- 节点 C 崩溃宕机,Client1 在 C 上加的锁丢失;
- 节点 C 重启后,Client2 锁住了C、D、E,获取锁成功。
这样,Client1 和 Client2 在同一时刻都获得了锁,为了解决这个问题,Redis 的作者 antirez 提供了两个解决方案:
- 开启 AOF 持久化:因为 Redis key 的过期机制是基于时间戳的,在节点宕机期间时间依旧在流逝,重启之后锁状态不会受到污染。但是 AOF 数据刷回磁盘默认是每秒写一次磁盘,可能部分数据还未刷写到磁盘上数据就已经丢失了,因此需要我们配置策略为 fsnyc = always,但这会降低 Redis 的性能。
- 解决这个问题的另一个方法是,为 Redis 锁服务规定一个 Max TTL,当一个节点重启之后,这个节点在 Max TTL 期间是不可用的,这样它就不会干扰原本已经申请到的锁,等到它 crash 前的历史锁都过期了,这个节点才会重新加入集群。这个方案的缺点在于如果 Max TTL 设置得过长,那么会导致重启的节点在数个小时内不可用的,即使这个节点是正常的。
争论
Redlock 是一个没有使用共识算法、基于时间实现的分布式锁,这也让很多人怀疑它的可靠性。《DDIA》的作者 Martin Kleppmann 在 2016 年就发表了一篇文章*How to do distributed locking* ,从以下两个方面批判了 Redlock 的安全性:
- Redlock 构建在一个基于时间戳的系统模型之上,多台服务器难以保证时间一致,这使得锁的实际失效时间是不同的;
- 带有自动过期功能的分布式锁,必须提供某种 fencing token(唯一性约束)机制,例如单调递增的 ID,来保证对共享资源的互斥性,而 Redlock 没有提供这样一种机制。
关于第二点中提到的 fencing token 机制,我们将在下文 Chubby 一节中详细论述。
随后 antirez 在*Is Redlock safe?* 一文中也进行了回应。这里先讨论第一种情况:当时钟发生跳跃时,当前服务器的时间会突然变大或变小,这都会影响锁的过期时间。例如 Client1 成功锁住了 A、B、C,但是节点 C 的时间突然向前跳跃了 5 秒钟提前失效,这时 Client2 成功锁住了 C、D、E,这种情况下就违反了锁的互斥性。
antirez 认为第一种情况可以通过合理的运维手段来避免:将一次时钟同步过程中大范围的时钟跳跃改为多次小范围时钟跳跃,并尽可能地保证服务器间的时间差保持在较低的范围内。其实从 antirez 的回应可以看出 redlock 是无法解决服务器间时钟不同步问题的。
对于第二点,antirez 认为锁 ID 的大小顺序与操作真正执行的顺序是无关的,只需要保障互斥访问就即可。因此,锁的 ID 是递增的,还是一个随机字符串,自然也就不那么重要了。Redlock 虽然无法提供递增的 fencing token,但利用 Redlock 产生的 random value 可以达到同样的效果。这个随机字符串虽然不是递增的,但却是唯一的。所以 Redlock 是可以保证锁的唯一性约束的。
小结
综上所述,Redlock 是一种自旋式分布式锁实现,是基于异步复制的分布式系统,需要客户端反复请求锁服务来判断能否获取锁。
Redlock 通过 TTL 的机制承担细粒度的锁服务,适用于对时间很敏感,期望设置一个较短有效期,并且丢锁对业务影响相对可控的服务。
etcd 分布式锁
etcd 是一个基于 Raft 共识算法实现的分布式键值存储服务,并提供了分布式锁的功能。一个 etcd 集群包含若干个服务器节点,并通过『领导选举机制』选举出一个 Leader,所有的写请求都会被转发给 Leader,由它全权管理日志复制来实现一致性。其他的节点其实都是当前节点的副本,它们只是维护一个数据的拷贝并会在主节点更新时对它们持有的数据库进行更新,并只响应客户端的读请求。
基于共识算法的分布式系统,会内置一些措施来防止脑裂和过期的数据副本,进而实现线性化的数据存储,即整个集群表现得好像只有一个数据副本,且其上的所有操作都是原子的。客户端无论将请求发送到哪一个节点,最后都能得到相同的结果。
有关 etcd 的实现原理可参考文章*分布式键值存储 etcd 原理与实现*,本节主要讨论基于 etcd 的分布式锁实现。
etcd 锁的使用
etcd 可以为存储的键值对设置租约,当租约到期,键值对将失效删除。同时也支持续租,客户端可以在租约到期之前续约, 当一个客户端持有锁期间,其它客户端只能等待,为避免等待期间租约失效, 客户端需创建一个定时任务 KeepAlive 作为「心跳」不断进行续约,以避免处理还未完成而锁已经过期失效。
如果客户端在持有锁期间崩溃,心跳停止,key 将因租约到期而被删除,从而释放锁,避免死锁。
|
|
为了更方便理解 etcd 锁的使用,下面贴出了一个简单的示例程序,go1 与 go2 协程抢占同一个锁,即使 go1 协程只设置了 2s 的 TTL 而 5s 后才能释放锁,客户端也可以自动续约保证锁的独占:
|
|
etcd 锁机制
除了上文提到的租约机制,etcd 的还提供了以下三个特性来保障分布式锁的安全性。
统一前缀
在上面的示例程序中,两个协程争抢一个名为/test_lock
的锁,而 etcd 实际写入的 key 分别为 key1/test_lock/LeaseID1
和 key2/test_lock/LeaseID2
。其中,LeaseID 是一个经由 raft 协议广播生成的全局 UUID,确保两个 key 的唯一性。
统一前缀与 Redlock 中客户端生成的随机 value 的作用是一致的,保证锁不会被其它客户端错误地删除。
Revision
etcd 为每个 key 生成一个 64 位的 Revision 版本号,每进行一次数据的写操作就加一,因此 Revision 是全局唯一且递增的, 通过 Revision 的大小就可以知道 etcd Server 处理写操作的顺序。
在上面的程序示例中,这两个 key 都会写入成功,但他们的 Revision 信息是不同的。客户端需要通过区间查询获取所有前缀为/test_lock
的 key 的版本号,通过 Revision 大小可判断自己是否获得锁。
在实现分布式锁时,如果出现多个客户端同时抢锁,那么根据 Revision 号大小可以依次获得锁,避免「惊群效应」,实现公平锁。
Watch
Watch 机制支持 Watch 某个固定的 key,也支持 Watch 一个区间范围。当被 Watch 的 key 发生变化,客户端将收到通知。
在实现分布式锁时,如果抢锁失败,可通过区间查询返回的 Key-Value 列表获得 Revision 相差最小的 pre-key, 并对它进行监听,当 watch 到 pre-key 的 DELETE 事件, 说明 pre-key 已经释放,此时才能持有锁。
etcd 锁实现
了解了上述的四个机制的概念后,再来看 etcd 加锁解锁的过程就很简单了:
- 组装需要持有的锁名称和 LeaseID 为真正写入 etcd 的 key;
- 执行 put 操作,将创建的 key 绑定租约写入 etcd,客户端需记录 Revision 以便下一步判断自己是否获得锁;
- 通过前缀查询键值对列表,如果自己的 Revision 为当前列表中最小的则认为获得锁;否则监听列表中前一个 Revision 比自己小的 key 的删除事件,一旦监听到 pre-key 则自己获得锁;
- 完成业务流程后,删除对应的 key 释放锁。
|
|
可以看到,加锁时传入了一个 Context,这使得加锁过程中如果出现整体请求超时或者上层逻辑主动退出,那么 etcd 也会主动释放锁,减少锁的空占期。
Lock locks the mutex with a cancelable context. If the context is canceled while trying to acquire the lock, the mutex tries to clean its stale lock entry.
同样地,虽然 lock 时只 watch Revision 相差最小的 pre-key,但是如果 pre-key 的客户端主动释放了锁,而其它的客户端依然在持有锁,这也破坏了锁的排他性性。因此 waitDeletes() 函数在监听到 pre-key 的删除事件后,仍然会去访问 etcd 来判断前面是否还有其他客户端仍持有锁,并监听他们的删除事件。
|
|
小结
基于 ZooKeeper 或 etcd 实现的分布式锁属于监听类分布式锁,这类实现客户端只需 watch 某个 key,当锁可用时锁服务端会通知客户端,更复杂的共识逻辑由服务端来完成,无需客户端不停请求锁服务。
对比 redlock 与 etcd 锁的实现,可以看出 redis 本身并不具有共识过程,更多地是依赖于客户端不断地轮训请求,也无法解决不同机器间时钟同步与时钟跳跃问题。而 etcd 使用全局唯一且递增的 Revision 来排列获取锁的顺序,并利用共识算法来保障节点间的数据一致性,解决了 Redlock 不同节点间 key 失效时间不同的问题 。
分布式锁是 etcd 较为复杂的应用场景,其缺点在于 Grant(生成租约 ID)、Unlock、Expire 等操作都要经过一次 raft 协议共识,Lock 过程中可能要进行多次查询操作,成本较高,并且同时 watch 多个 key 也会影响集群的性能。在特定的测试环境下*Performance | etcd*,etcd 可以处理每秒 50,000 的写操作请求量,但是对于复杂的分布式锁实现,通常无法支撑 QPS 超过一万的应用场景。
综合来看,etcd 实现的等监听锁的安全性较高,但性能并不出众,这类锁往往通过租约/会话机制承担粗粒度的锁服务,适用于对安全性很敏感,希望长期持有锁,不期望发生丢锁现象的服务。
Chubby
Chubby 是 Google 设计的提供粗粒度锁的分布式锁服务,GFS 和 Bigtable 都使用了 Chubby 以解决主节点的选举等问题。由于 Chubby 是谷歌内部的服务,我们只能从这篇 2006 发表的论文*The Chubby lock service for loosely-coupled distributed systems*来窥探它的设计思路。
与 etcd 和 zookeeper 类似,Chubby 使用 Paxos 算法来保证数据一致性。在一个 Chubby 集群中,只有主节点会对外提供读写服务,客户端通过向副本发送请求获取主节点的位置,一旦它获取到了主节点的位置,就会向所有的读写请求发送给主节点,直到其不再响应为止。写请求都会通过一致性协议传播到所有的副本中,当集群中的多数节点都同步了请求时就会认为当前的写入已经被确认。
程序停滞
不同之处在于,Chubby 在他们之上做了更进一步的锁可靠性保证。
无论我们的程序是由那种编程语言编写的,都有可能由于 GC、系统调度、网络延时等原因,产生一个较长时间的程序停滞,造成已经持有的分布式锁的超时、自动释放,随后,该锁会被其他实例获取,再次进入临界区,使得锁的排他性的被破坏。为此 Martin 还给出了一个由客户端 GC pause 引发 Redlock 失效的例子:
- Client1 向 Redis 集群发起锁请求;
- 各个 Redis 节点已经把获得锁的结果返回给了 Client1,但 Client1 在收到请求结果前进入了长时间的 GC pause;
- 在所有的 Redis 节点上,锁过期了;
- Client2 在 获取到了锁;
- Client1 从 GC pause 中恢复,收到了第 2 步来自各个 Redis 节点的请求结果。Client1 认为自己成功获取到了锁;
- Client1 和 Client2 同时持有了同一个锁。
Martin 的例子不仅仅适用于 Redis,基于 zookeeper、etcd 等实现的分布式锁都会出现该问题。并且后者通过心跳消息来保证锁的有效性,如果由于网络延迟或 GC 导致心跳消息未能及时送达 etcd server,也会使得锁提前失效,导致多个客户端同时持有锁。
sequencer
针对该场景,为了保证锁最终可以被调度,Chubby 给出的用于缓解这一问题的机制称为 sequencer。锁的持有者可以随时请求一个由三部分组成的字节串 sequencer:
- 锁的名称;
- 锁的获取模式:排他锁或共享锁;
- lock generation number,一个 64 位的单调递增数字,相当于唯一标识 ID;
客户端拿到 sequencer 之后,在操作资源的时候把它传给资源服务器。然后,资源服务器负责对sequencer的有效性进行检查。检查可以有两种方式:
- 调用 Chubby 提供的 API
CheckSequencer()
,将 sequencer 传入 Chubby 进行有效性检查,保证客户端持有的锁在进行资源访问时仍然有效; - 将客户端传来的 sequencer 与资源服务器当前观察到的最新的 sequencer 进行大小比较,如果 lock generation number 较小则拒绝其对资源进行操作。
其中第二种方式与 Martin 描述的 fencing token 唯一性约束类似,人为地为客户端操作的顺序进行排序,并按照顺序获取锁。即使由于各种原因锁的排他性被破坏,如果版本号为 34 的客户端已经更新了资源,那么版本号比他小的任何操作都是不合法的。
Chubby 上述两种方案的缺点是对于被请求的资源系统有一定的侵入性,如果资源服务本身不容易修改,Chubby 还提供了 lock-delay 机制:Chubby 允许客户端为持有的锁指定一个 lock-delay 的时间值,当 Chubby 发现客户端被动失去联系的时候,并不会立即释放锁,而是会在 lock-delay 指定的时间内阻止其它客户端获得这个锁。
lock-delay 机制是为了在把锁分配给新的客户端之前,让之前持有锁的客户端有充分的时间完成对资源的操作。
小结
为了应对锁失效问题,Chubby 提供的三种处理方式:CheckSequencer() 校验、与上次处理时最新的 sequencer 对比、lock-delay 机制,这就允许资源服务器在需要的时候,利用它提供更强的安全性保障。
Chubby 的缺点也很明显,前两种方案需要资源服务器为其定制校验锁是否仍然有效的功能,除非系统要求及其高的互斥性,否则这样的改造对于很多系统来说是不必要的。
总结
本篇文章首先讨论了分布式锁必须具有的几个特性,随后介绍了 Redlock 与 etcd 分布式锁的具体实现,最后讨论了 Google Chuby 的由于程序停滞导致锁的排他性失效情况下的解决方案,并引用了 Martin 与 antirez 对于分布式锁的讨论。
目前为止,分布式锁并没有一个能够完全保证安全性的解决方案,即使是 Chubby 也需要第三方服务来二次校验锁的有效性。在 Martin 批判 Redlock 的那篇文章中,也提出了一个很有见地的观点,将锁的用途分为两种:
- 为了效率:协调各个客户端避免做重复的工作,即使锁偶尔失效了,只是把某些操作多做一遍而已,不会产生其它的不良后果,例如重复发送了一封相同的邮件;如果是为了效率而使用分布式锁,允许锁的偶尔失效,那么使用 Redis 单机锁就足够了,简单而且效率高,Redlock 则是个过重的实现(Redlock 还可以提高锁服务的可用性,Redis 单机锁无法避免单点故障 );
- 为了正确性:与常见的内存中的锁类似,在任何情况下都不允许锁失效的情况发生,因为一旦发生,就可能意味着数据不一致,数据丢失、文件损坏、或是其它严重的问题;如果是为了正确性,在很严格的场合使用分布式锁,那么不要使用 Redlock,它不是一个能够在异步系统中严格保证数据一致性的算法,应该考虑类似 Zookeeper/etcd 的方案。
References
- How to do distributed locking
- Distributed locks with Redis
- Is Redlock safe?
- Distributed Lock Manager
- 基于Redis的分布式锁到底安全吗(上)
- 基于Redis的分布式锁到底安全吗(下)
- The Chubby lock service for loosely-coupled distributed systems
- 有赞 Bond 分布式锁