分布式锁的多种实现方式,你了解吗?

为什么要使用分布式锁?

在互联网中很多场景下,我们为了保证数据的一致性,需要保证同一个方法,在同一时间,只能有一个线程在执行。这在单机环境中,我们有很多办法实现,在java.util.concurrent包下,java提供了很多并发相关API,但这些API在分布式场景下就无能为力了。

常见的几种方案?

基于数据库的锁

基于缓存的锁(Redis、Memcached)

基于分布式算法的锁(Zookeeper)

使用Mysql实现:

可以使用Mysql中的悲观锁或者排他锁来实现,具体步骤如下:

1、创建一张数据库表,用于保存锁记录

2、方法开始执行时,执行一条insert语句插入到锁记录表中,将要锁定的资源作为主键或者唯一性索引插入,这个主键或者唯一性索引可以保证,同一时刻,只有一个线程执行成功

3、方法执行完毕后,删除这条数据

使用上面Mysql实现,有如下几个问题:

1、数据库是单点的,当数据库挂掉,会造成服务不可用

2、不能设置锁的超时时间

3、这把锁是不可重入的,同一个线程在没有释放锁之前,无法再获取该锁

使用Zookeeper实现:

此种方式不太常用,性能也比较低,但理论上也是最安全的,可以使用Curator框架实现,使用其中InterProcessMutex类可以非常方便的实现分布式锁

使用Redis实现:

这种方式最为常见,具体步骤如下:

1、方法开始执行时,通过如下命令,向Redis获取一个锁

SET resource_name my_random_value NX PX 30000

NX表示,只有当resource_name对应的key不存在时,才能SET成功,这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。

PX 30000 是一个自动过期时间,客户端可以根据自己的业务常见,选择合适的过期时间。

上面的命令如果执行成功,则客户端成功获取到了锁;而如果上面的命令执行失败,则说明获取锁失败。

2、方法执行完毕后,可以通过如下lua脚本删除锁

local v = redis.call('GET', KEYS[1]);

local r= 0;

if v == ARGV[1] then

   r =redis.call('DEL',KEYS[1]);

end

return r

使用上面Redis实现,有如下几个问题:

1、必须设置超时时间,假如没有设置超时时间,当一个程序获取到锁后,他崩溃了,或者因为网络问题,导致它再也无法和Redis通讯了,那么它会一直持有这个锁,而其他的客户端永远无法获取到这个锁

2、这个my_random_value是很有必要的,它保证了一个客户端释放的锁,一定是自己的锁

2、加锁的过程,依然不支持可重入性,如果想实现可重入性,可以将 MAC地址 + jvm进程ID + 线程ID 作为my_random_value设置进缓存

3、依然是单点的,当redis挂掉,会导致服务不可用,假如给这个Redis挂一个Slave,但由于Redis的服务是异步的,会丧失锁的安全性

4、释放锁的过程使用lua脚本,是为了保证原子性,网上有人将加锁过程分为两步执行,先使用SETNX命令加锁,再使用PEXPIRE命令设置锁的超时时间,将这两步放在lua脚本中,也是为了保证原子性

5、超时时间应该设置成多少呢?假如方法执行时间过长,超过了设置的超时时间,当Redis已经自动删除的key,而方法依然在执行,这可能会导致程序出现发生不一致性,出现严重BUG,这看起来是个两难的问题

使用Redlock算法解决单点问题:

针对于上面第3个问题,Redis的作者antirez提出了一个更安全的实现,称为Redlock,算是对于实现分布式锁的官方指导规范,因为在此之前,很多人使用Redis锁,都是基于单节点的,而Redlock是基于多节点(都是Master)的一种实现。

Redlock简单直白的说,就是采用N(通常是5)个独立的redis节点,同时setnx,如果多数节点成功,就拿到了锁,这样就可以允许少数(2个或以下)的节点挂掉了。释放锁的过程则比较简单,向所有节点发起释放锁的请求,无论这个节点之前有没有成功加锁,这一点很重要,因为可能这个几点已经成功加锁,但是在返回给客户端的时候,响应包丢失了。

在Redis官网上,有Redlock算法的详细过程。

在实现开发过程中,我们可以使用redisson框架中的RedissionClient类来实现Redlock算法的分布式锁。

Redlock看似完美,假如我们有5个节点(A、B、C、D、E),假设发生了如下情况:

1、service1成功锁住了其中的3个节点A、B、C,而D和E没有锁住

2、节点C突然间发生了崩溃,所有的缓存全部丢失,没有持久化下来

3、将C节点进行重启

4、service2成功锁住了其中的3个节点C、D、E,获取锁成功

这样,service1和service2就获取到了同一把锁。

当然我们可以通过修改redis配置,将每次修改数据都进行fsync,持久化起来,但这会严重降低性能。

为了解决这一问题,Redis作者antirez又提出了延迟重启(delayed restarts)的概念。办法很简单,也就是说,当其中一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,等里面的缓存全部都自动过期。这样的话,这个节点在重启前,所参与的锁全部都会过期,它在重启后就不会对以前的锁造成影响。

超时时间应该如何设置?

大多数情况下,我们应该根据业务场景给出一个超时时间,这个超时时间可能是一个经验值,也可能是经过严格测试计算出来的。

但假如我们的方法执行时间过长,担心超时时间小于方法的执行时间,在加锁时,不想设置超时时间,而是让程序自动检测,当方法执行完毕后,再释放锁,应该怎么做呢?

我们下次再来分析这个问题,有不同意见和想法的欢迎进群交流:236283328

猜你喜欢

转载自blog.csdn.net/Gupaoxueyuan/article/details/82500549