【分布式】分布式锁都有哪些实现方案?

一、业务场景

同一个jvm里多个线程操作同一个有状态的变量,可以通过JVM内的锁保证线程安全。

如果是多个JVM操作同一个有状态的变量,如何保证线程安全呢?

这时候就需要分布式锁来发挥它的作用了

二、特点

分布式系统往往业务流量比较大、并发较高,对分布式锁的高可用和高性能有较高的要求。一般分布式锁的方案需要满足如下要求:

  • 有高可用的获取锁和释放锁功能
  • 获取锁和释放锁的性能要好
  • 这把锁要是一把可重入锁(避免死锁)
  • 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
  • 这把锁最好是一把公平锁(根据业务需求考虑要不要这条)

三、基于数据库的分布式锁方案

1、基于表主键唯一做分布式锁

利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个插入操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可

1.1、缺点

  • 数据库单点
  • 没有锁超时机制
  • 不可重入
  • 非公平锁
  • 非阻塞锁

1.2、优化点

  • 数据库主从备份,解决单点问题。因为主从同步有延迟,可能导致数据不一致
  • 定时任务检测锁超时自动释放或者通过connection.commit()操作来释放锁
  • 加锁加上机器和线程信息,加锁之前先查询,支持可重入
  • 中间表,记录加锁失败的机器线程,按照创建时间排序
  • 自旋实现阻塞效果

1.3、原理

一般数据库使用innodb存储引擎,在插入数据的时候会加行级锁。从而达到是并发请求按顺序执行的效果

2、通过数据库mvcc实现乐观锁

更新数据的时候带上指定版本号,如果被其他线程提前更新的版本号,则此次更新失败

2.1、缺点

对数据库表侵入较大,每个表需要增加version字段

高并发下存在很多更新失败

3、数据库的局限

  • 使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。
  • 数据库写入是磁盘io,性能方面差一些
  • 数据库能支持的最大qps也有限制,很难满足高并发的需要

四、基于redis实现分布式锁

1、原理

1.1、加锁

原子命令:SET key value NX PX milliseconds

PX milliseconds 过期时间,防止加锁线程死掉不能解锁。过期时间设置太短,可能加锁线程还没有执行完正常逻辑,就到了过期时间

NX 如果没有这个key则设置,存在key返回失败

value 随机值(一般用UUID),用来实现只能由加锁线程解锁

1.2、解锁

lua脚本实现get value,delete的操作。加锁的时候设置的value是不会重复的随机值,解锁的时候必须UUID一致才能解锁

2、缺点

  • 获取锁是非阻塞
  • 非公平锁,不支持需要公平锁的场景
  • redis主从存在延迟,在master宕机发生主从切换时,可能会导致锁失效

五、基于Redlock算法实现分布式锁。redisson对Redlock算法进行了封装

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.3.2</version>
</dependency>

1、原理

在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。

1.1、加锁

假设有cluster-1,cluster-2,cluster-3总计3个cluster模式集群。如果要获取分布式锁,那么需要向这3个cluster集群通过EVAL命令执行LUA脚本,需要3/2+1=2,即至少2个cluster集群响应成功。set的value要具有唯一性,redisson的value通过UUID+threadId保证value的唯一性

1.获取当前时间(单位是毫秒)。

2.轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。

3.客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。

4.如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。

5.如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。

1.2、释放锁

需要在所有节点都释放锁就行,不管之前有没有在该节点获取锁成功。

客户端如果没有在多数节点获取到锁,一定要尽快在获取锁成功的节点上释放锁,这样就没必要等到key超时后才能重新获取这个锁

2、安全性论证

 开始之前,让我们假设客户端可以在大多数节点都获取到锁,这样所有的节点都会包含一个有相同存活时间的key。但是需要注意的是,这个key是在不同时间点设置的,所以这些key也会在不同的时间超时,但是我们假设最坏情况下第一个key是在T1时间设置的(客户端连接到第一个服务器时的时间),最后一个key是在T2时间设置的(客户端收到最后一个服务器返回结果的时间),从T2时间开始,我们可以确认最早超时的key至少也会存在的时间为MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT,TTL是锁超时时间、(T2-T1)是最晚获取到的锁的耗时,CLOCK_DRIFT是不同进程间时钟差异,这个是用来补偿前面的(T2-T1)。其他的key都会在这个时间点之后才会超时,所以我们可以确定这些key在这个时间点之前至少都是同时存在的。

如果一个客户端获取大多数节点锁的耗时接近甚至超过锁的最大有效时间时(就是我们为SET操作设置的TTL值),那么系统会认为这个锁是无效的同时会释放这些节点上的锁,所以我们仅仅需要考虑获取大多数节点锁的耗时小于有效时间的情况。在这种情况下,根据我们前面的证明,在MIN_VALIDITY时间内,没有客户端能重新获取锁成功,所以多个客户端都能同时成功获取锁的结果,只会发生在多数节点获取锁的时间都大大超过TTL时间的情况下,实际上这种情况下这些锁都会失效

六、基于zookeeper实现分布式锁

1、基本排他锁(非公平锁)

1.1、原理

利用临时节点与 watch 机制。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch/lock 节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。

1.2、缺点

所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。

2、优化后的排他锁(公平锁)

2.1、原理

上锁改为创建临时有序节点,每个上锁的节点均能创建节点成功,只是其序号不同。只有序号最小的可以拥有锁,如果这个节点序号不是最小的则 watch 序号比本身小的前一个节点 (公平锁)。

3、共享锁

3.1、原理

在锁节点下创建临时顺序节点。读节点为R+序号,写节点为W+序号。创建完节点后,获取所有子节点,对锁节点注册子节点变更的watcher监听,确定自己的序号在所有子节点中的位置。对于读请求,没有比自己序号小的写节点,就表示获得了共享锁,执行读取逻辑。对于写请求,如果自己不是序号最小的子节点,就需要进入等待。接收到watcher通知后,重复获取锁。

3.2、缺点

共享锁羊群效应。大量的watcher通知和子节点列表获取,两个操作重复运行。集群规模比较大的情况下,会对zookeeper服务器造成巨大的性能影响和网络冲击

3.3、优化

读请求,监听比自己小的写节点。写请求,监听比自己小的最后一个节点。

4、zookeeper局限

  • 性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。
  • ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同步到所有的 Follower 机器上。
  • 并发度支持不如redis
点赞