分布式锁

749 阅读14分钟

引用:



      在单机应用的场景下,我们常使用的锁主要是synchronized与Lock;但是在分布式横行的大环境下,显然仅仅这两种锁已经无法满足我们的需求

一、分布式锁的解决方式:

1. 首先明确一点,有人可能会问是否可以考虑采用 ReentrantLock 来实现,但是实际上去实现的时候是有问题的,ReentrantLock的lock和unlock要求必须是在同一线程进行,而分布式应用中,lock和unlock是两次不相关的请求,因此肯定不是同一线程,因此导致无法使用ReentrantLock。

2. 基于数据库表做乐观锁,用于分布式锁。

3. 使用memcached的add()方法,用于分布式锁。

4. 使用memcached的cas()方法,用于分布式锁。(不常用)

5. 使用redis的setnx()、expire()方法,用于分布式锁。

6. 使用redis的setnx()、get()、getset()方法,用于分布式锁。

7. 使用redis的watch、multi、exec命令,用于分布式锁。(不常用)

8. 使用zookeeper,用于分布式锁。(不常用)

二、详细介绍

(一)、基于数据库表做乐观锁,用于分布式锁

1. 首先说明乐观锁的含义:

大多数是基于数据版本(version)的记录机制实现的。何谓数据版本号?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表添加一个 “version”字段来实现读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1。在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。

2.那么如何使用乐观锁呢

  • a. 先执行select操作查询当前数据的数据版本号,比如当前数据版本号是26:

select id, resource, state,version from t_resource where state=1 and id=5780;

  • b. 执行更新操作:

update t_resoure set state=2, version=27, update_time=now() where resource=xxxxxx and state=1 and version=26

  • c. 如果上述update语句真正更新影响到了一行数据,那就说明占位成功。如果没有更新影响到一行数据,则说明这个资源已经被别人占位了。

3. 通过2中的讲解,相信大家已经对如何基于数据库表做乐观锁有有了一定的了解了,但是这里还是需要说明一下基于数据库表做乐观锁的一些缺点:

  • (1). 这种操作方式,使原本一次的update操作,必须变为2次操作: select版本号一次;update一次。增加了数据库操作的次数。
  • (2). 如果业务场景中的一次业务流程中,多个资源都需要用保证数据一致性,那么如果全部使用基于数据库资源表的乐观锁,就要让每个资源都有一张资源表,这个在实际使用场景中肯定是无法满足的。而且这些都基于数据库操作,在高并发的要求下,对数据库连接的开销一定是无法忍受的。
  • (3). 乐观锁机制往往基于系统中的数据存储逻辑,因此可能会造成脏数据被更新到数据库中。在系统设计阶段,我们应该充分考虑到这些情况出现的可能性,并进行相应调整,如将乐观锁策略在数据库存储过程中实现,对外只开放基于此存储过程的数据更新途径,而不是将数据库表直接对外公开。

(二)、使用redis分布式锁

1、基于 REDIS 的 SETNX()、EXPIRE() 方法做分布式锁

setnx() 的含义就是 SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。

但是要注意的是setnx命令不能设置key的超时时间,只能通过expire()来对key设置过期时间。

具体的使用步骤如下:

  • 1. setnx(lockkey, 1) 如果返回0,则说明占位失败;如果返回1,则说明占位成功
  • 2. expire()命令对lockkey设置超时时间,为的是避免死锁问题。
  • 3. 执行完业务代码后,可以通过delete命令删除key。

这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,可能还有一些可以完善的地方。比如,如果在第一步setnx执行成功后,在expire()命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题,所以如果要对其进行完善的话,可以使用redis的setnx()、get()和getset()方法来实现分布式锁。

2、使用redis的setnx()、get()、getset()方法,用于分布式锁:

这个方案的背景主要是在setnx()和expire()的方案上针对可能存在的死锁问题,做了一版优化。那么先说明一下这三个命令,对于setnx()和get()这两个命令,相信不用再多说什么。那么getset()命令?

getset(key,newValue)。该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。假设key原来是不存在的,那么多次执行这个命令,会出现下边的效果:

  • 1. getset(key, "value1") 返回null 此时key的值会被设置为value1
  • 2. getset(key, "value2") 返回value1 此时key的值会被设置为value2
  • 3. 依次类推!

介绍完要使用的命令后,具体的使用步骤如下:

  1.  setnx(lockkey, 当前时间+过期超时时间) ,如果返回1,则获取锁成功;如果返回0则没有获取到锁,转向2。
  2.  get(lockkey)获取值oldExpireTime ,并将这个value值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向3。
  3. 计算newExpireTime=当前时间+过期超时时间,然后getset(lockkey, newExpireTime) 会返回当前lockkey的值currentExpireTime。
  4. 判断currentExpireTime与oldExpireTime 是否相等,如果相等,说明当前getset设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
  5. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行delete释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

       参考  https://www.jianshu.com/p/535efcab356d

3、 使用redis实现分布式锁:保证原子性

(1)、使用redis命令 set key value NX EX max-lock-time 实现加锁

public Boolean lock(String key,String value,Long timeOut){
String var1 = jedis.set(key,value,"NX","EX",timeOut);
if(LOCK_SUCCESS.equals(var1)){
   return true;
}
   return false;
}

加锁操作:jedis.set(key,value,"NX","EX",timeOut)【保证加锁的原子操作】

  • key就是redis的key值作为锁的标识,value在这里作为客户端的标识,只有key-value都比配才有删除锁的权利【保证安全性】
  • 通过timeOut设置过期时间保证不会出现死锁【避免死锁】
  • NX:意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
  • EX:设置key的过期时间为秒,具体时间由第5个参数决定

(2)、使用redis命令 EVAL 实现解锁

lua脚本内容如下

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

public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
  String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
  Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {

 return true;}return false;}

Script 这个字符串是个lua脚本,代表的意思是如果根据key拿到的value跟传入的value相同就执行del,否则就返回0【保证安全性】

jedis.eval(String,list,list);这个命令就是去执行lua脚本【保证解锁的原子操作】

可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。

那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。

参考:https://www.cnblogs.com/williamjie/p/9395659.html


(三)、zookeeper分布式锁

1、基于 ZooKeeper 做分布式锁

ZooKeeper是一个开源的分布式服务框架,它是ApacheHadoop项目的一个子项目,主要用来解决分布式应用场景中存在的一些问题,如:统一命名服务、状态同步服务、集群管理、分布式应用配置管理等,它支持Standalone模式和分布式模式,在分布式模式下,能够为分布式应用提供高性能和可靠地协调服务,而且使用ZooKeeper可以大大简化分布式协调服务的实现,为开发分布式应用极大地降低了成本。

2、文件系统

2.1  ZOOKEEPER 锁相关基础知识

· zk 一般由多个节点构成(单数),采用 zab 一致性协议。因此可以将 zk 看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务

· zk 的数据以目录树的形式,每个目录称为 znode, znode 中可存储数据(一般不超过 1M),还可以在其中增加子节点。

· 子节点有三种类型:

  • 序列化节点,每在该节点下增加一个节点自动给该节点的名称上自增。
  • 临时节点,一旦创建这个 znode 的客户端与服务器失去联系,这个 znode 也将自动删除。
  • 最后就是普通节点。

· Watch 机制,client 可以监控每个节点的变化,当产生变化会给 client 产生一个事件。

2.2  ZK 基本锁

  1. 原理:利用临时节点与 watch 机制。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch/lock 节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。
  2. 缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。

2.3  ZK 锁优化

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

  • 在 /lock 节点下创建一个有序临时节点 (EPHEMERAL_SEQUENTIAL)
  • 判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后 watch 序号比本身小的前一个节点
  • 当取锁失败,设置 watch 后则等待 watch 事件到来后,再次判断是否序号最小
  • 取锁成功则执行代码,最后释放锁(删除该节点)

2.4  获取锁

首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1。


之后,Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。


这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。


Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。

于是,Client2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态。


这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。


Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。

于是,Client3向排序仅比它靠前的节点Lock2注册Watcher,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。



这样一来,Client1得到了锁,Client2监听了Lock1,Client3监听了Lock2。这恰恰形成了一个等待队列,很像是Java当中ReentrantLock所依赖的

2.5  释放锁

释放锁分为两种情况:

1.任务完成,客户端显示释放

当任务完成时,Client1会显示调用删除节点Lock1的指令。


2.任务执行过程中,客户端崩溃

获得锁的Client1在任务执行过程中,如果Duang的一声崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之自动删除。


由于Client2一直监听着Lock1的存在状态,当Lock1节点被删除,Client2会立刻收到通知。这时候Client2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock2是不是目前最小的节点。如果是最小,则Client2顺理成章获得了锁。

同理,如果Client2也因为任务完成或者节点崩溃而删除了节点Lock2,那么Client3就会接到通知。

最终,Client3成功得到了锁。

zk分布式锁代码:剑下一篇文章  juejin.cn/post/684490…


三、方案的比较

从理解的难易程度角度(从低到高): 数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高): Zookeeper >= 缓存 > 数据库

从性能角度(从高到低): 缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低): Zookeeper > 缓存 > 数据库


分布式锁用Redis还是Zookeeper 各自的优缺点

摘自:分布式锁用Redis还是Zookeeper

(1)对于 Redis 的分布式锁而言,它有以下缺点:

  • 它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
  • 另外来说的话,Redis 的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮。
  • 即便使用 Redlock 算法来实现,在某些复杂场景下,也无法保证其实现 100% 没有问题,关于 Redlock 的讨论可以看 How to do distributed locking。
  • Redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。

但是另一方面使用 Redis 实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”。

所以使用 Redis 作为分布式锁也不失为一种好的方案,最重要的一点是 Redis 的性能很高,可以支撑高并发的获取、释放锁操作。

(2)对于 ZK 分布式锁而言:

  • ZK 天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁
  • 如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。

ZK 也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于 ZK 集群的压力会比较大