当前位置: 首页 > 科技观察

要理解“分布式锁”,看这篇文章就对了

时间:2023-03-20 02:15:18 科技观察

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