再有人问你分布式锁,这篇文章扔给他

1.背景

对于锁你们确定不会陌生,在Java中synchronized关键字和ReentrantLock可重入锁在咱们的代码中是常常见的,通常咱们用其在多线程环境中控制对资源的并发访问,可是随着分布式的快速发展,本地的加锁每每不能知足咱们的须要,在咱们的分布式环境中上面加锁的方法就会失去做用。因而人们为了在分布式环境中也能实现本地锁的效果,也是纷纷各出其招,今天让咱们来聊一聊通常分布式锁实现的套路。java

2.分布式锁

2.1为什么须要分布式锁

Martin Kleppmann是英国剑桥大学的分布式系统的研究员,以前和Redis之父Antirez进行过关于RedLock(红锁,后续有讲到)是否安全的激烈讨论。Martin认为通常咱们使用分布式锁有两个场景:node

  • 效率:使用分布式锁能够避免不一样节点重复相同的工做,这些工做会浪费资源。好比用户付了钱以后有可能不一样节点会发出多封短信。
  • 正确性:加分布式锁一样能够避免破坏正确性的发生,若是两个节点在同一条数据上面操做,好比多个节点机器对同一个订单操做不一样的流程有可能会致使该笔订单最后状态出现错误,形成损失。

2.2分布式锁的一些特色

当咱们肯定了在不一样节点上须要分布式锁,那么咱们须要了解分布式锁到底应该有哪些特色:mysql

  • 互斥性:和咱们本地锁同样互斥性是最基本,可是分布式锁须要保证在不一样节点的不一样线程的互斥。
  • 可重入性:同一个节点上的同一个线程若是获取了锁以后那么也能够再次获取这个锁。
  • 锁超时:和本地锁同样支持锁超时,防止死锁。
  • 高效,高可用:加锁和解锁须要高效,同时也须要保证高可用防止分布式锁失效,能够增长降级。
  • 支持阻塞和非阻塞:和ReentrantLock同样支持lock和trylock以及tryLock(long timeOut)。
  • 支持公平锁和非公平锁(可选):公平锁的意思是按照请求加锁的顺序得到锁,非公平锁就相反是无序的。这个通常来讲实现的比较少。

2.3常见的分布式锁

咱们了解了一些特色以后,咱们通常实现分布式锁有如下几个方式:git

  • MySql
  • Zk
  • Redis
  • 自研分布式锁:如谷歌的Chubby。

下面分开介绍一下这些分布式锁的实现原理。github

3Mysql分布式锁

首先来讲一下Mysql分布式锁的实现原理,相对来讲这个比较容易理解,毕竟数据库和咱们开发人员在平时的开发中息息相关。对于分布式锁咱们能够建立一个锁表:redis

前面咱们所说的lock(),trylock(long timeout),trylock()这几个方法能够用下面的伪代码实现。

3.1 lock()

lock通常是阻塞式的获取锁,意思就是不获取到锁誓不罢休,那么咱们能够写一个死循环来执行其操做: 算法

mysqlLock.lcok内部是一个sql,为了达到可重入锁的效果那么咱们应该先进行查询,若是有值,那么须要比较node_info是否一致,这里的node_info能够用机器IP和线程名字来表示,若是一致那么就加可重入锁count的值,若是不一致那么就返回false。若是没有值那么直接插入一条数据。伪代码以下: sql

须要注意的是这一段代码须要加事务,必需要保证这一系列操做的原子性。数据库

3.2tryLock()和tryLock(long timeout)

tryLock()是非阻塞获取锁,若是获取不到那么就会立刻返回,代码能够以下: 编程

tryLock(long timeout)实现以下:
mysqlLock.lock和上面同样,可是要注意的是select ... for update这个是阻塞的获取行锁,若是同一个资源并发量较大仍是有可能会退化成阻塞的获取锁。

3.3 unlock()

unlock的话若是这里的count为1那么能够删除,若是大于1那么须要减去1。

3.4 锁超时

咱们有可能会遇到咱们的机器节点挂了,那么这个锁就不会获得释放,咱们能够启动一个定时任务,经过计算通常咱们处理任务的通常的时间,好比是5ms,那么咱们能够稍微扩大一点,当这个锁超过20ms没有被释放咱们就能够认定是节点挂了而后将其直接释放。

3.5 Mysql小结

  • 适用场景: Mysql分布式锁通常适用于资源不存在数据库,若是数据库存在好比订单,那么能够直接对这条数据加行锁,不须要咱们上面多的繁琐的步骤,好比一个订单,那么咱们能够用select * from order_table where id = 'xxx' for update进行加行锁,那么其余的事务就不能对其进行修改。
  • 优势:理解起来简单,不须要维护额外的第三方中间件(好比Redis,Zk)。
  • 缺点:虽然容易理解可是实现起来较为繁琐,须要本身考虑锁超时,加事务等等。性能局限于数据库,通常对比缓存来讲性能较低。对于高并发的场景并非很适合。

3.6 乐观锁

前面咱们介绍的都是悲观锁,这里想额外提一下乐观锁,在咱们实际项目中也是常常实现乐观锁,由于咱们加行锁的性能消耗比较大,一般咱们会对于一些竞争不是那么激烈,可是其又须要保证咱们并发的顺序执行使用乐观锁进行处理,咱们能够对咱们的表加一个版本号字段,那么咱们查询出来一个版本号以后,update或者delete的时候须要依赖咱们查询出来的版本号,判断当前数据库和查询出来的版本号是否相等,若是相等那么就能够执行,若是不等那么就不能执行。这样的一个策略很像咱们的CAS(Compare And Swap),比较并交换是一个原子操做。这样咱们就能避免加select * for update行锁的开销。

4. ZooKeeper

ZooKeeper也是咱们常见的实现分布式锁方法,相比于数据库若是没了解过ZooKeeper可能上手比较难一些。ZooKeeper是以Paxos算法为基础分布式应用程序协调服务。Zk的数据节点和文件目录相似,因此咱们能够用此特性实现分布式锁。咱们以某个资源为目录,而后这个目录下面的节点就是咱们须要获取锁的客户端,未获取到锁的客户端注册须要注册Watcher到上一个客户端,能够用下图表示。

/lock是咱们用于加锁的目录,/resource_name是咱们锁定的资源,其下面的节点按照咱们加锁的顺序排列。

4.1Curator

Curator封装了Zookeeper底层的Api,使咱们更加容易方便的对Zookeeper进行操做,而且它封装了分布式锁的功能,这样咱们就不须要再本身实现了。

Curator实现了可重入锁(InterProcessMutex),也实现了不可重入锁(InterProcessSemaphoreMutex)。在可重入锁中还实现了读写锁。

4.2InterProcessMutex

InterProcessMutex是Curator实现的可重入锁,咱们能够经过下面的一段代码实现咱们的可重入锁:

咱们利用acuire进行加锁,release进行解锁。

加锁的流程具体以下:

  1. 首先进行可重入的断定:这里的可重入锁记录在ConcurrentMap<Thread, LockData> threadData这个Map里面,若是threadData.get(currentThread)是有值的那么就证实是可重入锁,而后记录就会加1。咱们以前的Mysql其实也能够经过这种方法去优化,能够不须要count字段的值,将这个维护在本地能够提升性能。
  2. 而后在咱们的资源目录下建立一个节点:好比这里建立一个/0000000002这个节点,这个节点须要设置为EPHEMERAL_SEQUENTIAL也就是临时节点而且有序。
  3. 获取当前目录下全部子节点,判断本身的节点是否位于子节点第一个。
  4. 若是是第一个,则获取到锁,那么能够返回。
  5. 若是不是第一个,则证实前面已经有人获取到锁了,那么须要获取本身节点的前一个节点。/0000000002的前一个节点是/0000000001,咱们获取到这个节点以后,再上面注册Watcher(这里的watcher其实调用的是object.notifyAll(),用来解除阻塞)。
  6. object.wait(timeout)或object.wait():进行阻塞等待这里和咱们第5步的watcher相对应。

解锁的具体流程:

  1. 首先进行可重入锁的断定:若是有可重入锁只须要次数减1便可,减1以后加锁次数为0的话继续下面步骤,不为0直接返回。
  2. 删除当前节点。
  3. 删除threadDataMap里面的可重入锁的数据。

4.3读写锁

Curator提供了读写锁,其实现类是InterProcessReadWriteLock,这里的每一个节点都会加上前缀:

private static final String READ_LOCK_NAME  = "__READ__";
private static final String WRITE_LOCK_NAME = "__WRIT__";
复制代码

根据不一样的前缀区分是读锁仍是写锁,对于读锁,若是发现前面有写锁,那么须要将watcher注册到和本身最近的写锁。写锁的逻辑和咱们以前4.2分析的依然保持不变。

4.4锁超时

Zookeeper不须要配置锁超时,因为咱们设置节点是临时节点,咱们的每一个机器维护着一个ZK的session,经过这个session,ZK能够判断机器是否宕机。若是咱们的机器挂掉的话,那么这个临时节点对应的就会被删除,因此咱们不须要关心锁超时。

4.5 ZK小结

  • 优势:ZK能够不须要关心锁超时时间,实现起来有现成的第三方包,比较方便,而且支持读写锁,ZK获取锁会按照加锁的顺序,因此其是公平锁。对于高可用利用ZK集群进行保证。
  • 缺点:ZK须要额外维护,增长维护成本,性能和Mysql相差不大,依然比较差。而且须要开发人员了解ZK是什么。

5.Redis

你们在网上搜索分布式锁,恐怕最多的实现就是Redis了,Redis由于其性能好,实现起来简单因此让不少人都对其十分青睐。

5.1Redis分布式锁简单实现

熟悉Redis的同窗那么确定对setNx(set if not exist)方法不陌生,若是不存在则更新,其能够很好的用来实现咱们的分布式锁。对于某个资源加锁咱们只须要

setNx resourceName value
复制代码

这里有个问题,加锁了以后若是机器宕机那么这个锁就不会获得释放因此会加入过时时间,加入过时时间须要和setNx同一个原子操做,在Redis2.8以前咱们须要使用Lua脚本达到咱们的目的,可是redis2.8以后redis支持nx和ex操做是同一原子操做。

set resourceName value ex 5 nx
复制代码

5.2Redission

Javaer都知道Jedis,Jedis是Redis的Java实现的客户端,其API提供了比较全面的Redis命令的支持。Redission也是Redis的客户端,相比于Jedis功能简单。Jedis简单使用阻塞的I/O和redis交互,Redission经过Netty支持非阻塞I/O。Jedis最新版本2.9.0是2016年的快3年了没有更新,而Redission最新版本是2018.10月更新。

Redission封装了锁的实现,其继承了java.util.concurrent.locks.Lock的接口,让咱们像操做咱们的本地Lock同样去操做Redission的Lock,下面介绍一下其如何实现分布式锁。

Redission不只提供了Java自带的一些方法(lock,tryLock),还提供了异步加锁,对于异步编程更加方便。 因为内部源码较多,就不贴源码了,这里用文字叙述来分析他是如何加锁的,这里分析一下tryLock方法:

  1. 尝试加锁:首先会尝试进行加锁,因为保证操做是原子性,那么就只能使用lua脚本,相关的lua脚本以下:
    能够看见他并无使用咱们的sexNx来进行操做,而是使用的hash结构,咱们的每个须要锁定的资源均可以看作是一个HashMap,锁定资源的节点信息是Key,锁定次数是value。经过这种方式能够很好的实现可重入的效果,只须要对value进行加1操做,就能进行可重入锁。固然这里也能够用以前咱们说的本地计数进行优化。
  2. 若是尝试加锁失败,判断是否超时,若是超时则返回false。
  3. 若是加锁失败以后,没有超时,那么须要在名字为redisson_lock__channel+lockName的channel上进行订阅,用于订阅解锁消息,而后一直阻塞直到超时,或者有解锁消息。
  4. 重试步骤1,2,3,直到最后获取到锁,或者某一步获取锁超时。

对于咱们的unlock方法比较简单也是经过lua脚本进行解锁,若是是可重入锁,只是减1。若是是非加锁线程解锁,那么解锁失败。

Redission还有公平锁的实现,对于公平锁其利用了list结构和hashset结构分别用来保存咱们排队的节点,和咱们节点的过时时间,用这两个数据结构帮助咱们实现公平锁,这里就不展开介绍了,有兴趣能够参考源码。

5.3RedLock

咱们想象一个这样的场景当机器A申请到一把锁以后,若是Redis主宕机了,这个时候从机并无同步到这一把锁,那么机器B再次申请的时候就会再次申请到这把锁,为了解决这个问题Redis做者提出了RedLock红锁的算法,在Redission中也对RedLock进行了实现。

经过上面的代码,咱们须要实现多个Redis集群,而后进行红锁的加锁,解锁。具体的步骤以下:

  1. 首先生成多个Redis集群的Rlock,并将其构形成RedLock。
  2. 依次循环对三个集群进行加锁,加锁的过程和5.2里面一致。
  3. 若是循环加锁的过程当中加锁失败,那么须要判断加锁失败的次数是否超出了最大值,这里的最大值是根据集群的个数,好比三个那么只容许失败一个,五个的话只容许失败两个,要保证多数成功。
  4. 加锁的过程当中须要判断是否加锁超时,有可能咱们设置加锁只能用3ms,第一个集群加锁已经消耗了3ms了。那么也算加锁失败。
  5. 3,4步里面加锁失败的话,那么就会进行解锁操做,解锁会对全部的集群在请求一次解锁。

能够看见RedLock基本原理是利用多个Redis集群,用多数的集群加锁成功,减小Redis某个集群出故障,形成分布式锁出现问题的几率。

5.4 Redis小结

  • 优势:对于Redis实现简单,性能对比ZK和Mysql较好。若是不须要特别复杂的要求,那么本身就能够利用setNx进行实现,若是本身须要复杂的需求的话那么能够利用或者借鉴Redission。对于一些要求比较严格的场景来讲的话可使用RedLock。
  • 缺点:须要维护Redis集群,若是要实现RedLock那么须要维护更多的集群。

6.分布式锁的安全问题

上面咱们介绍过红锁,可是Martin Kleppmann认为其依然不安全。有关于Martin反驳的几点,我认为其实不只仅局限于RedLock,前面说的算法基本都有这个问题,下面咱们来讨论一下这些问题:

  • 长时间的GC pause:熟悉Java的同窗确定对GC不陌生,在GC的时候会发生STW(stop-the-world),例如CMS垃圾回收器,他会有两个阶段进行STW防止引用继续进行变化。那么有可能会出现下面图(引用至Martin反驳Redlock的文章)中这个状况:
    client1获取了锁而且设置了锁的超时时间,可是client1以后出现了STW,这个STW时间比较长,致使分布式锁进行了释放,client2获取到了锁,这个时候client1恢复了锁,那么就会出现client1,2同时获取到锁,这个时候分布式锁不安全问题就出现了。这个其实不只仅局限于RedLock,对于咱们的ZK,Mysql同样的有一样的问题。
  • 时钟发生跳跃:对于Redis服务器若是其时间发生了向跳跃,那么确定会影响咱们锁的过时时间,那么咱们的锁过时时间就不是咱们预期的了,也会出现client1和client2获取到同一把锁,那么也会出现不安全,这个对于Mysql也会出现。可是ZK因为没有设置过时时间,那么发生跳跃也不会受影响。
  • 长时间的网络I/O:这个问题和咱们的GC的STW很像,也就是咱们这个获取了锁以后咱们进行网络调用,其调用时间由可能比咱们锁的过时时间都还长,那么也会出现不安全的问题,这个Mysql也会有,ZK也不会出现这个问题。

对于这三个问题,在网上包括Redis做者在内发起了不少讨论。

6.1 GC的STW

对于这个问题能够看见基本全部的都会出现问题,Martin给出了一个解法,对于ZK这种他会生成一个自增的序列,那么咱们真正进行对资源操做的时候,须要判断当前序列是不是最新,有点相似于咱们乐观锁。固然这个解法Redis做者进行了反驳,你既然都能生成一个自增的序列了那么你彻底不须要加锁了,也就是能够按照相似于Mysql乐观锁的解法去作。

我本身认为这种解法增长了复杂性,当咱们对资源操做的时候须要增长判断序列号是不是最新,不管用什么判断方法都会增长复杂度,后面会介绍谷歌的Chubby提出了一个更好的方案。

6.2 时钟发生跳跃

Martin以为RedLock不安全很大的缘由也是由于时钟的跳跃,由于锁过时强依赖于时间,可是ZK不须要依赖时间,依赖每一个节点的Session。Redis做者也给出了解答:对于时间跳跃分为人为调整和NTP自动调整。

  • 人为调整:人为调整影响的那么彻底能够人为不调整,这个是处于可控的。
  • NTP自动调整:这个能够经过必定的优化,把跳跃时间控制的可控范围内,虽然会跳跃,可是是彻底能够接受的。

6.3长时间的网络I/O

这一块不是他们讨论的重点,我本身以为,对于这个问题的优化能够控制网络调用的超时时间,把全部网络调用的超时时间相加,那么咱们锁过时时间其实应该大于这个时间,固然也能够经过优化网络调用好比串行改为并行,异步化等。能够参考个人两个文章: 并行化-你的高并发大杀器异步化-你的高并发大杀器

7.Chubby的一些优化

你们搜索ZK的时候,会发现他们都写了ZK是Chubby的开源实现,Chubby内部工做原理和ZK相似。可是Chubby的定位是分布式锁和ZK有点不一样。Chubby也是使用上面自增序列的方案用来解决分布式不安全的问题,可是他提供了多种校验方法:

  • CheckSequencer():调用Chubby的API检查此时这个序列号是否有效。
  • 访问资源服务器检查,判断当前资源服务器最新的序列号和咱们的序列号的大小。
  • lock-delay:为了防止咱们校验的逻辑入侵咱们的资源服务器,其提供了一种方法当客户端失联的时候,并不会当即释放锁,而是在必定的时间内(默认1min)阻止其余客户端拿去这个锁,那么也就是给予了必定的buffer等待STW恢复,而咱们的GC的STW时间若是比1min还长那么你应该检查你的程序,而不是怀疑你的分布式锁了。

8.小结

本文主要讲了多种分布式锁的实现方法,以及他们的一些优缺点。最后也说了一下有关于分布式锁的安全的问题,对于不一样的业务须要的安全程度彻底不一样,咱们须要根据本身的业务场景,经过不一样的维度分析,选取最适合本身的方案。

最后这篇文章被我收录于JGrowing,一个全面,优秀,由社区一块儿共建的Java学习路线,若是您想参与开源项目的维护,能够一块儿共建,github地址为:github.com/javagrowing… 麻烦给个小星星哟。

最后打个广告,若是你以为这篇文章对你有文章,能够关注个人技术公众号,也能够加入个人技术交流群进行更多的技术交流。你的关注和转发是对我最大的支持,O(∩_∩)O。

相关文章
相关标签/搜索