当前位置: 首页 > 后端技术 > Java

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

时间:2023-04-01 20:08:22 Java

1。背景你肯定对锁不陌生。在Java中,synchronized关键字和ReentrantLock可重入锁在我们的代码中很常见。一般我们在多线程环境下用它来控制对资源的并发访问,但是随着分布式的飞速发展,局部加锁往往不能满足我们的需求,而在我们的分布式环境下上面加锁的方法就会失效。因此,为了在分布式环境下实现本地锁的效果,人们也想出了自己的方法。今天,我们就来说说分布式锁的一般实现。2.分布式锁2.1为什么需要分布式锁MartinKleppmann是英国剑桥大学的分布式系统研究员。他与Redis之父Antirez就RedLock(红锁,后面会讲到)是否安全展开了激烈的讨论。Martin认为,我们使用分布式锁一般有两种场景:效率:使用分布式锁可以防止不同的节点重复相同的工作,造成资源浪费。例如,用户支付后,不同的节点可能会发送多条短信。正确性:加入分布式锁也可以避免破坏正确性。如果两个节点对同一条数据进行操作,比如多个节点机器对同一个订单进行不同的处理,可能会导致订单的最终状态出现错误,从而造成损失。2.2分布式锁的一些特性当我们确定不同节点上需要分布式锁时,那么我们需要了解分布式锁应该具备哪些特性:互斥:互斥和我们的本地锁一样是最基本的,但是分布式锁需要保证不同节点上不同线程的互斥。可重入性:如果同一个节点上的同一个线程获得了锁,它也可以再次获得锁。锁超时:和本地锁一样,支持锁超时,防止死锁。高效率和高可用性:加锁和解锁需要高效,同时也需要保证高可用性,防止分布式锁失效,增加降级。支持阻塞和非阻塞:支持lock和trylock以及类似ReentrantLock的tryLock(longtimeOut)。支持公平锁和非公平锁(可选):公平锁是指按照申请锁的顺序获取锁,非公平锁则相反是无序的。这个一般实现的比较少。2.3常见的分布式锁在了解了一些特性后,我们实现分布式锁一般有以下几种方式:MySqlZkRedis自研分布式锁:比如Google的Chubby。下面分别介绍这几种分布式锁的实现原理。3Mysql分布式锁首先说一下Mysql分布式锁的实现原理,比较容易理解。毕竟数据库与我们开发人员平时的开发息息相关。对于分布式锁,我们可以创建一个锁表:前面提到的lock()、trylock(longtimeout)、trylock()方法可以用下面的伪代码来实现。3.1lock()锁一般是阻塞获取锁,也就是说不拿到锁是不会放弃的,那么我们可以写一个死循环来执行它的操作:mysqlLock.lcok里面是一个sql,为了实现可重入锁如果有值,那么我们需要比较node_info是否一致。这里的node_info可以用机器IP和线程名来表示。如果一致,则加上可重入锁计数的值。如果不一致,则返回false。如果没有值,直接插入一条数据。伪代码如下:需要注意的是,这段代码需要添加事务,必须保证这一系列操作的原子性。3.2tryLock()和tryLock(longtimeout)tryLock()是一个非阻塞获取锁。如果无法获得,它将立即返回。代码可以如下:tryLock(longtimeout)实现如下:mysqlLock.lock同上,但是要注意最重要的是select...forupdate,它是阻塞获取行锁。如果同一个资源并发量很大,可能会退化为阻塞获取锁。3.3unlock()解锁,如果这里的计数为1,可以删除。如果大于1,则需要减1。3.4当锁超时的时候,可能会遇到我们的机器节点挂了,锁不会释放。我们可以启动一个定时任务。通过计算我们平时处理任务的大概时间,比如5ms,那么我们可以稍微扩展一下。当超过20ms没有释放锁,我们就可以判断节点挂了,直接释放。.3.5Mysql总结适用场景:Mysql分布式锁一般适用于资源不存在的数据库。如果数据库存在,比如一个订单,那么就可以直接给这个数据加行锁,不需要我们上面比较繁琐的步骤,比如一个订单,那么我们可以使用select*fromorder_tablewhereid='xxx'forupdate加一个行锁,那么其他事务就不能修改它。优点:简单易懂,不需要维护额外的第三方中间件(如Redis、Zk)。缺点:虽然容易理解,但是实现起来比较麻烦,需要考虑锁超时、添加事务等。性能受限于数据库,一般性能低于缓存。不太适合高并发场景。3.6乐观锁我们之前介绍过悲观锁。这里要提一下乐观锁。在我们实际的项目中,往往会实现乐观锁,因为我们行锁的性能消耗比较大,而我们通常不会关心一些竞争。Intense,但是也需要保证我们的并发顺序执行使用乐观锁进行处理。我们可以在我们的表中增加一个版本号字段,那么当我们查询到一个版本号之后,我们在更新或者删除版本号的时候需要依赖我们的查询,来判断当前数据库和查询到的版本号是否相等,如果是相等则可以执行,不相等则不能执行。这样的策略很像我们的CAS(CompareAndSwap),比较和交换是一个原子操作。这样我们就可以避免为更新行锁添加select*的开销。ZooKeeperZooKeeper也是我们常见的实现分布式锁的方法。与数据库相比,如果不了解ZooKeeper,上手可能会比较困难。ZooKeeper是基于Paxos算法的分布式应用协调服务。Zk的数据节点类似于文件目录,所以我们可以利用这个特性来实现分布式锁。我们用一个资源作为一个目录,这个目录下的节点就是我们需要获取锁的客户端。没有获得锁的客户端需要向之前的客户端注册Watcher,可以用下图来表示。/lock是我们用来加锁的目录,/resource_name是我们加锁的资源,下面的节点按照我们加锁的顺序排列。4.1CuratorCurator封装了Zookeeper底层的Api,方便我们操作Zookeeper,并且封装了分布式锁的功能,不需要我们自己去实现。Curator实现了一个可重入锁(InterProcessMutex)和一个不可重入锁(InterProcessSemaphoreMutex)。读写锁也是在可重入锁中实现的。4.2InterProcessMutexInterProcessMutex是Curator实现的可重入锁。我们可以通过下面一段代码来实现我们的可重入锁:我们使用acuire来加锁,release来解锁。加锁过程如下:首先判断可重入:这里的可重入锁记录在ConcurrentMapthreadData的Map中,如果threadData.get(currentThread)有值,则证明可以再入-进入锁,然后记录就会加1。其实我们之前的Mysql也可以通过这种方式进行优化。它不需要计数字段的值,在本地维护它可以提高性能。然后在我们的资源目录下创建一个节点:比如这里创建一个/0000000002节点,这个节点需要设置为EPHEMERAL_SEQUENTIAL,这是一个临时节点,是有序的。获取当前目录下的所有子节点,判断自己的节点是否为第一个子节点。如果是第一个,就获取到了锁,那么就可以返回了。如果不是第一个,证明之前已经有人获取过锁,所以需要获取自己节点的前一个节点。/0000000002的前一个节点是/0000000001。拿到这个节点后,我们在上面注册Watcher(这里的watcher其实是调用了object.notifyAll()来解除阻塞)。object.wait(timeout)orobject.wait():Blockwaiting这里对应我们第5步的watcher解锁的具体过程:首先判断可重入锁:如果有可重入锁,只需要将次数减1,如果减1后的锁数为0,则继续下面的步骤,如果不为0,则直接返回。删除当前节点。删除threadDataMap中的可重入锁数据。4.3读写锁Curator提供了一个读写锁,它的实现类是InterProcessReadWriteLock,这里的每个节点都会加上前缀:privatestaticfinalStringREAD_LOCK_NAME="__READ__";privatestaticfinalStringWRITE_LOCK_NAME="__WRIT__";不同的前缀区分是读锁还是写锁。对于一个读锁,如果你发现前面有一个写锁,你需要注册离你最近的写锁的watcher。写锁的逻辑和我们4.2分析的一样,没有变化。4.4锁超时Zookeeper不需要配置锁超时。由于我们将节点设置为临时节点,因此我们的每台机器都维护一个ZK会话。通过这个session,ZK可以判断机器是否宕机。如果我们的机器挂了,对应的临时节点会被删除,所以我们不需要关心锁超时。4.5ZK总结优点:ZK不需要关心锁超时时间,有现成的第三方包实现,比较方便,支持读写锁。ZK按照加锁的顺序获取锁,所以是公平锁。使用ZK集群保证高可用性。缺点:ZK需要额外维护,增加维护成本。性能和Mysql相差不大,但还是比较差。并且要求开发者了解什么是ZK。5、Redis大家都在网上搜索分布式锁。实现最多的恐怕就是Redis了。Redis因为其良好的性能和简单的实现而受到很多人的青睐。5.1Redis分布式锁的简单实现熟悉Redis的同学一定对setNx(setifnotexist)方法不陌生。如果不存在,请更新它。可以用来实现我们的分布式锁。要锁定一个资源,我们只需要设置Nx个resourceName值复制代码即可。这里有一个问题。加锁后,如果机器宕机,锁不会被释放,所以会加上过期时间。过期时间需要和setNx同一个原子操作,在Redis2.8之前我们需要使用Lua脚本来达到我们的目的,但是redis2.8之后redis支持nx和ex操作都是同一个原子操作。setresourceNamevalueex5nx复制代码5.2RedissionJava人都知道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.锁。让我们像本地锁一样操作Redission的锁。下面介绍一下如何实现分布式锁。Redission不仅提供了Java自带的一些方法(lock,tryLock),还提供了异步锁,更加方便异步编程。由于内部源码较多,就不贴源码了。这里我们通过文字描述来分析他是如何锁的。这里对tryLock方法进行分析:尝试加锁:先尝试加锁,因为保证操作是原子的,那么就只能用lua脚本了。相关的lua脚本如下:可以看到他并没有使用我们的sexNx来操作,而是使用了一个hash结构。我们每一个需要加锁的资源都可以看作是一个HashMap。Locking资源的节点信息是Key,锁的个数是value。这样就可以很好的实现重入的效果,只需要对值加1就可以进行重入锁。当然也可以用我们前面提到的局部计数来优化。如果尝试加锁失败,判断是否超时,超时返回false。如果加锁失败后没有超时,那么需要在名为redisson_lock__channel+lockName的频道上订阅解锁消息,然后阻塞直到超时,或者有解锁消息。重试步骤1、2、3,直到最终获取到锁,或者某个步骤获取到锁超时。对于我们的解锁方法,比较简单,也是通过lua脚本解锁。如果是可重入锁,则只负1。如果是非锁线程解锁,则解锁失败。Redission也实现了公平锁。对于公平锁,它使用list结构和hashset结构分别保存我们排队的节点和我们节点的过期时间。这两个数据结构帮助我们实现了公平锁。这里我们就不展开介绍了,有兴趣的可以参考源码。5.3RedLock我们设想一个场景,机器A申请了一把锁后,如果此时Redismaster宕机,slave机器没有同步到锁,那么机器B再次申请时会再次申请这把锁,在为了解决这个问题,Redis的作者提出了RedLock红锁算法,并在Redission中实现了RedLock。通过上面的代码,我们需要实现多个Redis集群,然后对红锁进行加锁和解锁。具体步骤如下:首先生成多个Redis集群的Rlocks,构造成RedLocks。依次锁定三个簇,锁定过程同5.2。如果循环加锁过程中加锁失败,需要判断加锁失败次数是否超过最大值。这里的最大值是基于集群的数量。例如,如果是三个,则只允许失败一次;如果是五个,则只允许失败一次。如果你失败了两个,你必须确保大多数成功。在加锁过程中,需要判断是否发生了加锁超时。有可能我们只能把锁设置成3ms,第一次集群加锁就已经消耗了3ms了。那么就认为是锁失败。如果第3、4步加锁失败,会进行解锁操作,解锁会请求对所有集群进行解锁。可以看出,RedLock的基本原理是使用多个Redis集群,并使用大部分集群加锁成功,减少某个Redis集群出现故障导致分布式锁出现问题的概率。5.4Redis总结优点:Redis实现简单,性能优于ZK和Mysql。如果不需要特别复杂的需求,可以使用setNx自己实现。如果需要复杂的需求,可以使用或者学习Redission。对于一些要求比较严格的场景,可以使用RedLock。缺点:需要维护Redis集群,如果要实现RedLock需要维护更多的集群。6、分布式锁的安全问题上面我们介绍了红锁,但是MartinKleppmann认为它仍然不安全。关于Martin的反驳,我觉得不限于RedLock。上面提到的算法基本上都有这个问题。下面我们来讨论一下这些问题:LongGCpause:熟悉Java的同学一定对GC不陌生,GC时会发生STW(stop-the-world),比如CMS的垃圾收集器,会有两阶段的STW来防止引用继续改变。那么可能会出现下图的情况(引用自Martin驳斥Redlock的文章):client1获取锁并设置了锁的超时时间,但是client1之后发生了STW,STW时间比较长,导致分布式锁被锁定。释放后,client2获得锁。此时client1恢复锁,client1和client2同时获取锁。这时候,分布式锁不安全的问题就出现了。其实这不仅限于RedLock,对于我们的ZK,Mysql也有同样的问题。时钟跳变:对于Redis服务器来说,如果时间往一个方向跳变,肯定会影响我们锁的过期时间,那么我们的锁过期时间不是我们预期的,client1和client2也会得到同样的锁。那么就会有不安全感,对于Mysql也会出现这种情况。但是由于ZK没有设置过期时间,所以不会影响跳转。LongnetworkI/O:这个问题和我们的GCSTW很像,就是我们获取到锁后,进行网络调用,调用时间可能比我们锁的过期时间还长,所以也会如果有不安全的问题,这个Mysql也会有,ZK不会有这个问题。关于这三个问题,网上发起了很多讨论,包括Redis的作者。6.1GC的STW对于这个问题,可以看到基本上所有的问题都会出现。Martin给出了一个解决方案。对于ZK,他会生成一个自增序列。那么我们在实际操作资源的时候,需要判断当前序列是否是最新的,有点类似于我们的乐观锁。当然,这个方案的作者Redis反驳了。既然可以生成自增序列,那么根本不需要加锁,也就是可以按照类似Mysql的乐观锁的方案解决。我个人认为这种方案增加了复杂度。我们在对资源进行操作的时候,需要增加序号是否最新的判断。无论我们使用什么判断方法,都会增加复杂度。Google的Chubby稍后会推出更好的解决方案。.6.2时钟跳转。Martin认为RedLock因为时钟跳变不安全,因为锁过期是强依赖时间的,而ZK不需要依赖时间,而是依赖各个节点的Session。Redis的作者也给出了答案:对于时间跳跃,分为手动调整和NTP自动调整。人为调整:人为调整的影响可以人为不调整。这是可控的。NTP自动调整:这个可以优化,把跳跃时间控制在可控范围内。虽然会跳,但是完全可以接受。6.3长期的网络I/O不是他们讨论的重点。我个人认为这个问题的优化可以控制网络调用的超时时间。如果加上所有网络调用的超时时间,那么我们的锁过期时间其实应该是如果时间大于这个,当然你也可以优化网络调用,比如串行改并行,异步等,你可以参考我的两篇文章:Parallelization-YourHighConcurrencyKiller,Asynchronization-YourHighConcurrencyKiller7.Chubby的一些优化当你搜索ZK的时候,你会发现他们都写着ZK是ChubbyChubby的一个开源实现,Chubby的内部工作原理与ZK类似。但是Chubby的定位是分布式锁和ZK有点区别。Chubby同样采用了上述自增序列的方案来解决分布式不安全的问题,但是它提供了多种验证方式:CheckSequencer():此时调用Chubby的API检查序列号是否有效。访问资源服务器查看判断当前资源服务器的最新序列号和我们序列号的大小。lock-delay:为了防止我们验证的逻辑侵入我们的资源服务器,它提供了一种方法,当客户端失去连接时,不会立即释放锁,而是在一定时间内阻塞(默认1min)如果其他client拿了这个锁,会给他们一定的buffer等待STW回收,如果我们GC的STW时间超过1min,那你就要检查你的程序了,不要怀疑你的分布式锁。8.小结本文主要讲一下各种分布式锁的实现方法,以及它们的一些优缺点。最后也讲了分布式锁的安全性。不同的业务需要完全不同的安全级别。我们需要根据自己的业务场景去分析不同的维度,选择最适合自己的方案。