分布式锁的实现方式解析

1,389 阅读11分钟

什么是分布式锁?

在应用服务为多机情况下,我们要对一个资源进行安全的处理,通常我们可以对这块资源进行同步访问或者用CAS的方式进行操作。而这里如果想要进行同步操作就无法再像单机一样使用synchronized关键字或者lock了,这个时候我们就需要分布式锁。

为什么需要分布式锁?

根据锁的用途可以分为两类:

  • 允许多个客户端操作共享资源

这种情况下,对共享资源的操作一定是幂等性操作,无论你操作多少次都不会出现不同结果。这里使用锁,就是为了避免重复操作共享资源从而提高效率。

  • 只允许一个客户端操作共享资源

这种情况下,对共享资源的操作一般是非幂等性操作。在这种情况下,如果出现多个客户端操作共享资源,就可能意味着数据不一致,数据丢失。这里就需要分布式锁。

如何实现分布式锁

分布式锁的主要实现思路就是要提供一个中间层来保证多个服务之间能正确的访问到这把锁,常用的实现有三种:

  • 基于关系型数据库
  • 基于Zookeeper
  • 基于Redis

基于关系型数据库

基于数据库的分布式锁有两种实现,这两种实现分别为利用主键唯一性原则和行锁来实现。

主键唯一性实现

加锁

在争抢锁的时候向DB中写一条记录,这条记录主要包含id、当前占用锁的线程名、重入的次数和创建时间等,如果插入成功表示当前线程获取到了锁,如果 插入失败 那么证明锁被其他人占用,等待一会儿继续争抢,直到争抢到或者超时为止。重入的话就在获取锁之前取当前锁的信息,如果锁的线程是当前线程,更新锁的count+1,并且执行锁之后的逻辑,如果不是当前锁,进行重试。

释放锁

如果持有锁,释放锁的时候,对锁的count-1,当重入的时候每释放一次都要减一。

存在的问题

  • 超时保护
  • 线程可能会花费很长时间,这个时候就会导致其他线程永远获取不到锁。如果可能,可以采用定时任务去扫描超过一定阈值的锁并删除。但如果锁住的任务执行时间很长,删除会导致并发问题。所以需要对超时时间有一个很好的预期。
  • 单点问题
  • 可以设置主从,但是为了一个锁来搞一个主从会有点浪费。同时主从切换的时候系统不可用。
  • 并发量大问题
  • 当并发量大时可以想象很多请求会一直访问数据库造成资源浪费甚至数据库扛不住,这个时候我们可以将获取锁的时间间隔调高,但这样也会对系统吞吐量降低。


行锁实现

加锁

首先在加锁时开启事务,利用for update加显式的行锁,这样就能利用这个行级的排他锁来实现分布式锁了

释放锁

commit这个事务,就能达到释放锁的目的。

存在的问题

  • 连接池爆满和事务超时问题
  • 单点问题
  • 行锁升级为表锁问题
  • 并发量大问题(同上)

基于Redis

基于Redis的分布式锁可以说是经历了很多演变,由最初的单机——》主从/哨兵——》集群。那么接下来就通过历史演进来看每个阶段的实现和出现的问题。

单机Redis

加锁

SET resource_name random_value NX PX 30000

解锁

这里使用lua脚本,因为lua脚本具有原子性,如果不使用lua,那么有可能线程1在释放锁的时候网络延迟导致锁已经超时释放,这个时候对于其他线程可以获取到这个锁,而当线程1的释放操作到达redis的时候就会误删其他线程的锁。当然,除了lua脚本,只要保证操作是原子性的即可,例如阿里的Tair也提供了CAD去释放锁。

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

存在的问题

  • 过期时间如何设置?有可能出现线程1获取锁之后网络问题导致规定时间内没执行完操作,然后锁释放并被线程2获取,这时线程1可能执行完成并误删线程2的锁。事实上,只要不是原子性操作,只要不能保证时间无延迟,那就会发生资源不安全。
  • 可能有人说可以在客户端操作完共享资源后,判断锁是否依然归该客户端所有,如果依然归当前客户端所有,则提交资源,释放锁。若不归客户端所有,则不提交资源啊。但实际上,在 判断锁是否归当前客户端所有 和 提交资源/不提交资源 这两步并不是原子性并且会存在延时,有可能判断锁所有的时候的确你持有锁,但提交资源资源的时候锁已经不是你的了。
  • 单机容灾处理弱。

主从/哨兵Redis

为了解决上述第二个问题,有一些redis的架构为主从/哨兵模式的架构。加锁解锁过程和上述过程一样。

存在的问题

因为redis的主从复制是有延时的,有可能线程1获取到了主节点的锁,但这个锁信息还没有到从节点,主节点宕机后,哨兵模式下从变为主之后就丢失了这个锁。这个问题在集群模式下可以通过RedLock解决。

集群Redis

为了应对哨兵模式下出现的主从复制延迟的情况,redis作者antirez提出了RedLock算法。流程如下:

  • 获取当前时间(单位是毫秒)。
  • 轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
  • 客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(至少N/2 + 1),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
  • 如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
  • 如果锁获取失败了,不管是因为获取成功的锁不超过一半还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。

存在的问题

对于RedLock存在的问题与解答,可以看一下Martin和Antirez的文章

下面说一下我对他们的论点的理解,首先我认为RedLock是存在问题的,但是这个问题能否通过业务和使用者操作去忽略这是可以讨论的。

首先是Martin的质疑,我认为主要分为以下两点:

  • 第一点也是Redis分布式锁存在的问题,即没办法保证超时时间的设置。Martin给出的解决方法类似于获取锁的时候同时获取一个版本号,类似于CAS那样去加锁解锁。
  • 第二点是过期时间的问题,如果某个Redis Master系统时间发生错误导致锁提前释放。实际上这个问题的本质是对于分布式系统来说不能把时间作为安全保障,分布式系统中可能出现各种问题:程序暂停、网络延迟、系统时间错误等。

然后是Antirez对与Martin问题的回应:

  • 首先对于第一点(这里体现在RedLock加锁的第五步,即客户端持有锁去访问共享资源超时无法判断超时),如果要保证超时时间的设置,需要设置一个版本号,那么根本就不需要分布式锁了,直接通过版本号去CAS就可以了(实际上我不这么认为,分布式锁可以在前面做一次拦截,它可以拦截掉大部分的客户端并不去执行对应操作,而版本号是对最后分布式锁存在问题的兜底补充)。最后超时导致锁失效这个问题其他分布式锁都无法解决。
  • 对于第二点,就是时间问题:
  • 问题原因,例如有五个节点A,B,C,D,E,客户端1获取了A、B、C,这个时候C节点的时钟发生向前跳跃,导致维护的锁过期,那么这个时候有可能客户端2就会获取到C、D、E造成同时持有锁。
  • 对于时间跳跃引发的锁失效问题,Antirez给出的解决方法是禁止人为修改系统时间,使用一个不会进行“跳跃”式的调整系统时钟的ntpd程序。
  • 最后还有一个问题就是节点崩溃重启后,会出现多个客户端持有锁,Antirez的解决办法是延迟重启,即一个节点崩溃后,先不立即重启它,而是等待一段时间,等待的时间大于锁的有效时间。

基于Zookeeper

集群

加锁与释放

利用Zookeeper不能重复创建一个节点的特性实现一个分布式锁

  • 查看目标节点是否已经创建,已经创建,那么等待锁。
  • 如果未创建,创建一个瞬时Node,表示已经占有锁。
  • 如果创建失败,那么证明锁已经被其他线程占有了,那么同样等待锁。
  • 当释放锁,或者当前Session超时的时候,节点被删除,唤醒之前等待锁的线程去争抢锁。

存在的问题

  • 在大量锁的情况下会出现惊群,就是在一个节点进行删除的时候,大量对这个节点的watcher线程会进行回调。
    • 关于惊群,这里可以用临时顺序节点解决,加锁解锁大概流程如下:
    1. 首先客户端1想要加锁先创建临时顺序文件file_0001,这时判断自己是否为最小的文件,是就获取到锁
    2. 客户端2想要加锁就创建临时文件file_0002,这时判断自己是否为最小的文件,不是,此时客户端2会通过ZK的API对他的顺序节点上一个顺序节点加一个监听器,监听上一个顺序节点是否被删除
    3. 客户端1释放锁后,唤醒file_0002的监视器,这时客户端2完成加锁
    • 同样会出现多个客户端操作共享资源。
    • 因为zookeeper如果长时间检测不到客户端的心跳,会认为Session过期了,那么Session创建的临时节点会被自动删除。
最后,有一个Curator框架是基于zookeeper的,他的使用很简单,原理如临时顺序节点的那样。

关于分布式锁的选择

一般来说分布式锁都会使用Redis或者Zookeeper实现,关于redis和cookeeper的比较如下:

  • redis比zookeeper的读写性能强很多,高并发的场景中,使用zookeeper作为分布式锁,会出现获取锁失败的情况,存在性能瓶颈
  • zookeeper的可靠性比redis的可靠性强很多
  • zookeeper可以实现读写锁,redis不行
  • zookeeper的watch机制,客户端试图创建节点的时候,发现它已经存在了,这时候创建失败,那么进入一种等待状态,当节点被删除的时候,zookeeper通过watch机制通知它,这样它就可以继续完成创建操作(获取锁)。这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。这套机制,redis无法实现。