如何正确实现分布式锁

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

  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


如何正确实现分布式锁”已经有15条评论

    • 嗯嗯,如果不介意绑定一个 Redis 来使用 celery 的话,我感觉直接去修改 redis 里面的数据就可以了。redbeat 就是这么做的,直接往 Redis 存数据。

      这么做的另一个好处就是不用必须 import celery task 调用 .delay() 了。直接存数据即可。不知道你说的动态修改 task 是不是这个问题哈

  1. 作者您好,我最近在使用redbeat的过程中出现了一些问题,然后看到了这篇文章。所以想请教一下,redbeat版本是2.0.0。 就是在您文中提到的这一次PR涉及到的代码,当给锁进行续期时,报错raise LockNotOwnedError(“Cannot extend a lock that’s no longer owne”);然后就会抛出异常到外面的捕获,变成Cannot release a lock that’s no longer owned。事实就是这个锁突然消失了,我对这点感到很疑惑

    • 你好,这个问题看起来比较像是程序的代码问题,不是 redbeat 的问题。看错误描述是,你之前持有这个锁,释放的时候,锁的 owner 已经不是你了。可能的原因有:

      • redis 是否有除了 redbeat 的操作,去修改了 redis 的数据?
      • 锁是否有过期的机制,然后程序内部没有定期 renew 这个 lease,导致去释放的时候,锁已经不能存在了?
      • 程序是否有 bug,导致内存中存储的锁的 token 被其他代码修改了?
      • 编码错误,导致去释放了没有获得过的锁?
      • 我目前的代码没有对这个锁的操作,这一块儿异常仅仅是发生在redbeat的代码中。就是redbead为了确保仅有一个服务在工作而添加的分布式锁,在beat服务中无限轮询的时候,每次都会对这个锁进行续期,然后我没有修改任何配置,所以锁一开始的时间是25分钟,轮询时间默认5分钟一次,在大部分时间下,这个锁都是正常工作的,问题就是某一次给锁续期的时候突然报错了。具体代码在redbeat包中schedulers.py文件的第466行
        def tick(self, min=min, **kwargs):
        if self.lock:
        self.lock.extend(int(self.lock_timeout))
        我能确定的是编码中没有对该锁的redis key进行操作,我对于redis key是有统一管理的,redis服务也没有宕机重启的记录

        • 这样的话,看起来就是 redbeat 原先锁的持有者在某一次续期的时候没有续上(可能是网络问题?),然后其他 redbeat 进程抢到了锁开始运行。这样,原先锁的持有者再尝试续期旧锁的时候,就失败了。

          报错之后,你看到有其他的 redbeat 在运行的吗?如果有的话应该不是大问题。如果一个正常的 redbeat (持有锁的人)都没有了,那就是 bug 了。

          • 我仅仅只启动了一个redbeat服务pod,然后在beat服务报这个错导致挂掉的时候,我去查看redis发现这个锁的确消失了。虽然github有给出取消这个锁的配置办法,但是我更想弄清楚这一块的原因

          • 感谢答复,这个锁就是redbeat为了确保仅有一个beat进程在工作中而添加的分布式锁。在beat的每次轮询中,它都会给这个锁进行续期操作。在大部分时间中,它都是正常工作的,但是在某一次续期中,会突然报这个错。我能确定我的代码中没有对这个锁的redis key进行操作,我的redis key是统一管理的。然后redis服务也没有挂掉重启的日志记录

Leave a comment

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