介绍在业务中,经常会有分布式锁的需求。一种常见的解决方案是使用Redis作为中心节点,达到伪分布式的效果。因为有中心节点,所以我将其定义为伪分布式。言归正传,本文主要讨论基于Redis实现简单分布式锁的一些问题。Redis支持RedLock(红锁)等复杂的实现,在以后的文章中会讲到。基于SETNX命令实现分布式锁使用SETNX命令构建分布式锁是最常见的实现方式。具体:1.通过SETNX键值给Redis增加一个新的值。只有当键不存在时才会插入SETNX命令。取值并返回成功,否则返回失败,KEY可以作为分布式锁的锁名,通常根据业务来确定锁名;2.通过DEL键命令删除key,从而达到释放锁的效果,当锁被释放时,其他线程可以通过SETNX获得锁(同一个KEY);3、使用EXPIREkeytimeout为KEY设置超时时间,达到超时自动释放锁的效果,避免资源一直被占用。redis-py(https://github.com/redis/redis-py)该库基于这种形式实现了Redis分布式锁,在其源码中复制了相关代码,如下:#获取分布式锁defdo_acquire(self,token):#使用SETNX实现分布式锁ifself.redis.setnx(self.name,token):ifself.timeout:timeout=int(self.timeout*1000)#转换为毫秒#设置分布式超时时间self.redis.pexpire(self.name,timeout)returnTruereturnFalse#释放分布式锁defdo_release(self,expected_token):name=self.namedefexecute_release(pipe):lock_value=pipe.get(name)iflock_value!=expected_token:raiseLockError("Cannotreleasealockthat'slongerowned")#使用DEL值释放锁pipe.delete(name)self.redis.transaction(execute_release,name)这种方式存在一些问题,一个简单的分析如下。SETNX和EXPIRE非原子问题SETNX和EXPIRE是Redis中的两个非原子操作。如果SETNX成功(即获得锁),但是当通过EXPIRE设置锁超时,服务器挂掉,网络中断等导致EXPIRE执行成功,那么锁就变成了锁定没有超时。如果业务逻辑中锁的释放处理不好,很容易出现死锁。Redis官方考虑到这种情况,让SET命令直接设置Timeout,达到SETNX的效果。SET支持的语法变为:SETEXkeyvalueNXtimeout,这样就不用再通过EXPIRE来设置超时时间了,从而实现了原子性。当然,在Redis还没有正式实现这个功能的时候,很多开源库也考虑到了这个问题,然后使用Lua脚本来实现SETEX和EXPIRE这两个操作的原子性。由于用户希望自定义几条指令来完成特定的服务,Redis官方为这些用户提供了Lua脚本支持。用户可以将Lua脚本发送到Redis服务器执行自定义逻辑,Redis服务器将在单线程中原子地执行Lua脚本。Lockmisinterpretation锁定错误解释也是一种常见的情况。假设有两个线程A和B工作并竞争同一个锁。线程A获取锁,设置锁的超时时间为30s。但是线程A在处理业务逻辑的时候,因为数据库SQL超时,原本20s可以完成的任务,现在需要40s才能完成。当线程A花费30s后,锁会自动释放。这时候线程B就会获取到锁。当线程A处理完业务逻辑后,会通过DEL释放锁。在释放线程B的锁时,直观如下图所示:解决方法是加一个唯一标识。释放锁时,检查当前线程是否持有该KEY对应的唯一标识。在redis-py中,通过UUID生成当前线程的唯一标识token,在释放锁的时候判断当前线程是否有相同的token。相关代码如下(你会发现和上面复制的代码不一样,因为老文章中使用的redis-py版本是2.10.6,现在的redis-py版本是3.5.3,相关的修改了bug,老文章的代码就是出问题):classLock(object):def__init__(self,redis,name,timeout=None,sleep=0.1,blocking=True,blocking_timeout=None,thread_local=True):#线程本地存储self.local=threading.local()ifself.thread_localelsedummy()self.local.token=Nonedefacquire(self,blocking=None,blocking_timeout=None,token=None):sleep=self.sleepiftokenisNone:#根据UUID算法生成唯一的tokentoken=uuid.uuid1().hex.encode()#省略其余代码...defdo_acquire(self,token):如果自拍ut:timeout=int(self.timeout*1000)else:timeout=None#Token会通过set方法存入Redisifself.redis.set(self.name,token,nx=True,px=timeout):returnTruereturnFalseredis-py根据uuid库生成一个token,存放在当前线程的本地存储空间(独立于其他线程)。释放时判断当前线程的token与加锁时保存的token相同。Redis-py使用Lua来实现这个过程。相关代码如下:defrelease(self):"Releasesthealreadyacquiredlock"#从线程本地存储获取tokenexpected_token=self.local.tokenifexpected_tokenisNone:raiseLockError("Cannotreleaseanunlockedlock")self.local.token=Noneself.do_release(expected_token)defdo_release(self,expected_token):#使用Lua释放锁,实现判断token是否相同的逻辑ifnotbool(self.lua_release(keys=[self.name],args=[expected_token],client=self.redis)):raiseLockNotOwnedError("Cannotreleasealock""这不再是owned")其中lua_release变量的具体值是:LUA_RELEASE_SCRIPT="""localtoken=redis.call('get',KEYS[1])ifnottokenortoken~=ARGV[1]thenreturn0endredis.call('del',KEYS[1])return1"""在上面的lua代码中,通过get获取到KEY的值,这个值就是token,然后判断和传入的是否相同token不一样就不会执行del命令,也就是锁超时导致的并发不会释放,这种情况类似于释放锁的误读。同样假设有线程A和B,线程A获取锁,并设置过期时间为30s,当线程A执行超过30s,锁释放到期,此时线程B获取锁。如果线程A和线程B是顺序依赖业务,此时出现并发,会导致业务结果出错,直观图如下:线程sA和B同时进行我们不希望执行导致业务错误。针对这种情况,有两种解决方案:1、增加锁的过期时间,让业务逻辑有足够的执行时间;2.添加一个守护线程,当锁过期时,添加过期时间。推荐使用第一种方案,简单直接。另外可以增加单线程监控Rediskey,对持续时间特别长的key进行监控和报警。轮询和等待的效率还是线程A和B,当线程A获取锁的时候,线程B也想获取锁。这时候就需要等到线程A释放锁或者锁过期释放自己。看redis-py源码,它的等待逻辑是一个死循环,相关代码如下:defacquire(self,blocking=None,blocking_timeout=None,token=None):#...省略部分代码#无限循环等待获取锁whileTrue:ifself.do_acquire(token):self.local.token=tokenreturnTrueifnotblocking:returnFalsenext_try_at=mod_time.time()+sleepifstop_trying_atisnotNoneandnext_try_at>stop_trying_at:returnFalse#阻止睡眠一段时间mod_time.sleep(睡眠)简单来说,这个方法就是轮询客户端。当没有获取到锁时,等待一段时间再尝试获取锁,直到获取锁成功或者等待超时。这种方式实现简单,但是当并发量比较大的时候,轮询方式会消耗较多的资源,影响服务器性能。更好的方法是使用Redis的发布和订阅功能。当线程B获取锁失败时,订阅锁释放消息。当线程A完成业务释放锁后,会发送锁释放信息。去获取锁,这样就不用一直轮询,而只是休眠等待锁释放消息。Redis集群主从切换比较复杂。项目会使用多个Redis服务搭建集群。Redis集群采用主从方式部署。简单的说,Redis集群中的master节点是通过算法选出的,所有的写操作都会落到master节点上,主节点会将指令记录在缓冲区中,然后将缓冲区中的指令以异步的方式同步给其他从节点。如果从节点执行相同的指令,它们将获得与主节点相同的数据结构。我们在基于Redis集群搭建分布式锁的时候,可能会出现主从切换导致锁丢失的问题。还是用一个例子来说明,客户端A通过Redis集群成功加锁。这个操作首先会发生在master节点上,但是由于一些问题,Redis集群当前的master节点宕机了。此时,Redis集群会根据相应的算法,从从节点中选择新的主节点的过程对客户端A是透明的。但是如果发生主从切换,客户端A会将命令锁定在旧的主节点,它在同步之前就关闭了。那么新的master节点就不会有客户端A的加速信息了,这时候如果有新的客户端B要锁??,可以很容易的加入。Redisclustersplit-brain这次的脑裂确实比较抽象。简单的说,由于Redis集群中的网络问题,导致部分从节点无法感知到主节点。这时候这些从节点会认为主节点宕机了,它们会选择创建一个新的主节点,但是客户端可以连接到两个主节点,所以两个客户端会有同一个锁。归根结底,复杂分布式系统的锁问题一直是设计问题,学无止境。
