如何正确实现分布式锁

分布式锁几乎是分布式系统中必备的一个组件,通常用在两个场景:

  1. 保证耗时的操作同一时间只有一个执行者,减少重复执行的时间;
  2. 保证一个共享资源同时只有一个修改者,防止竞争条件;

我在某些公众号看过好多讨论分布式锁的文章(在公众号里面好像是一个很热门的话题),但遗憾的是,我看过的这些实现里,或多或少都有问题。

一个分布式锁要求满足两点基本的条件:

  1. 同一时刻只有一个锁的持有者,不能出现两个进程同时持有锁的情况(safety);
  2. 不能出现一个进程永远持有锁的情况(死锁)(liveness);

在使用场景上,第一种场景(减少重复执行)对锁是弱依赖的,假如锁失去 safety,那么影响就是某个操作被多执行了几次;而第二种情况对锁的要求是强依赖的,如果锁没有 safety,就会面临数据不一致的严重后果。

最基本的实现

实现分布式锁,最基本的是有一个唯一的资源,支持 CAS 操作,最简单的实现如下面这样:

这也是本地环境中我们保护一个共享资源的方法。但是在分布式系统中,我们会遇到很多复杂的问题,比如获得锁之后,实例宕机了,这样它永远不会把锁还回来,造成了死锁。本地环境基本不需要考虑这种情况,因为线程退出一般会进行清理操作,在 finally 中释放锁;如果是整个进程 crash 了,那么连通锁、共享变量等就一起没了。

于是对于分布式的系统中,这些锁一般都需要带有一个“有效时间”,超过了这个时间,锁自动又变成了可获得的了(注意这一步是锁的提供者将锁过期的)。这就是常见的租期机制。

租期机制

租期机制解决了 liveness 的问题,但是带来了潜在的 safety 的问题。

因为在没有租期机制的情况下,如果原来锁的持有者不释放,那么锁是永远不会被再次 acquire 的。但是现在有了租期的机制,即使原来的进程还持有锁,但租期已经到了,那么锁就可以重新被 acquire。这样就有多个实例同时获得锁的可能性存在。

这就要求锁的持有者必须在锁的租期内完成事务,将锁释放。如果一个租期处理不完,那必须自己延长租期。

延长租期这里有一个细节,非常重要。就是延长租期的时候必须确认自己依然是锁的持有者。举个例子:现在有 A 和 B 两个实例,A 获得了锁,然后出现了网络问题掉线了,A 的租期超时,这时候 B 又成功获得了锁。A 的网络问题解决,重新上线,那么这时候我们就有了两个锁的持有者,A 和 B 同时续期。

这个问题其实很有意思,我在实际就遇到过这种情况:我用的一个定时调度器(Redbeat)为了保证高可用,采用了多个实例的同时运行的方法,然后通过一个分布式锁来保证只有一个获得锁的实例真正工作,然后此实例不断续期。如果这个实例没有续期成功,那其他实例就会获得锁开始工作。

Redbeat 的续期操作没有考虑检查锁的持有者问题,就导致可能出现多个持有者的情况,造成一个任务被执行两次。我在这个 issue 里面详细描述了这种情况和复现方法。

这个问题的解决方法很简单,就是我们在锁上面标志出目前锁的持有者,每次续期操作的时候都检查是否依然是持有者,如果不是,就失败掉续期,并且认为自己不再是锁的持有者(具体的一个实现参考下面)。在 Redbeat 里我提了一个 PR,先检查是否是锁的持有者再进行续期操作。

这样,锁的 liveness 还是有保证的,不会出现锁的持有者已经挂了,但是锁没有释放的问题;续期的机制也阻止了竞争者重复获得锁的问题。

以上都是分布式锁的经典实现,这些实现问题最多的其实是在锁的释放上。

锁的释放

对于提供 CAS 的系统,比如 Redis,我们一般用它的 SETNX 语义(如果 key 不存在则设置)来实现。释放锁的过程是删除这个 key,这样其他进程就可以获得锁了。

这种实现存在的典型问题是在释放的时候,没有检查自己是否仍是锁的持有者(本质上,和上面延长租期的时候要检查自己依然是锁的持有者,是一样的)。

考虑这样一种情况,存在进程A、B,A 获得了锁,然后出现网络问题,锁的租期过了之后 B 获得了锁。之后 A 重新上线,去释放锁,这时候 A 实际上释放了 B 的锁,造成可能有 C 重新获得锁,那么 B 和 C 就同时成为了锁的持有者。

锁的持有者标志方法

解决以上两个问题,就是标志出谁持有了锁,只有锁的当前持有者才能对锁做续期、释放等操作。

一种标致锁的持有者的方式是,设置的锁的时候将 value 设置为持有者的名字,比如 实例、进程、线程ID,不过考虑到 value 要唯一,实现起来可能有些复杂。

另一种方法是将锁的 value 设置成唯一的 token,获得锁的时候记住这个 token,释放、续期的时候检查 token 是否和自己的 token 一样。Redis-py 中的 lock 就是这样实现的。

但是 redis-py 中的 extend 操作(对我来说)很奇怪,这个 extend 10s 实际的语义是:将锁原来的租期还剩下的时间,加上 10 s。考虑上面 Redbeat 的场景,肯定是要在锁过期之前续期的,比如说我在使用锁第 9s 的时候续期,那么续期 10s 效果是锁的过期时间变成 11s。然后再运行 9s 的时候续期,锁的时间变成 12s。就导致运行的时间越长,锁的过期时间越长,假如进程挂了,要经过很长的时间(不可预期),其他进程才能 take over。

实际上在 Redbeat 中我们实现的 extend 是“Extend to”的语义。将锁的过期时间重置到 10s。这样就不会使得锁的过期时间越来越长。

Redis 的 SETNX 文档中描述了一种锁的实现模型。这个文档没有使用过期机制,而是将 timestamp 设置为锁的 value,然后用检查过期时间的方式来判断锁有没有释放。对于锁的 timestamp 过期,多个实例试图获得锁的时候,是通过 GETSET 保证只有一个实例拿到了锁的。其他实例需要拿到锁,发现自己不是第一个的话,要认为自己获得锁失败。

 

同一租期内的问题

以上,看起来不可能出现竞争条件了,但实际上这个实现依然是有问题的。DDIA 的作者在跟 antirez 有关 redlock 的争论中,举了这么一种情况:A获得了锁,在租期内发生了 GC stop the world,然后时间超过了租期,但是 A 并不知道。这时候 B 获得了锁去更新了资源,然后 A 过了暂停之后也去更新了资源。

Client 1 不可能做每一步操作都检查锁是否过期,这样太繁琐了,并且即使这样,依然可能在检查锁ok之后发生进程暂停。然后这时候过期(因为检查操作和写操作不是原子的)。

这个问题的解决方法是 fencing。获得锁的时候,锁服务为其分配一个只递增的值。然后进程在访问共享资源的时候会带上这个递增 token,当存储资源接受写操作的时候,会只接受更大的 token 的写,低于存储资源当前最大 token 的写请求会被拒绝。

注意:这里要求写操作是感知和记录下现在的最大的 token 的,实现的复杂度上也有所增加。

 

以上,我们讨论的锁都是基于一个安全可靠的、提供 CAS 机制的资源来实现我们的分布式锁的,假如要实现这样一种资源(比如 Redlock 的尝试),就要面对进程暂停、时钟偏移、网络延迟问题,要复杂的多。

推荐阅读:

  1. Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency 
  2. Database · 理论基础 · 关于一致性协议和分布式锁
  3. Is Redlock safe?
  4. How to do distributed locking

Leave a comment

电子邮件地址不会被公开。 必填项已用*标注