在单实例JVM中,有很多常用的方法来处理并发问题,比如访问控制的synchronized关键字,volatile关键字,ReentrantLock等常用方法。但是,在分布式环境下,上述方法无法处理跨JVM场景下的并发问题。当业务场景需要处理分布式环境下的并发问题时,就必须使用分布式锁。分布式锁是指在分布式部署环境中,多个客户端可以通过锁机制互斥访问共享资源。目前比较常见的分布式锁实现方案有以下几种:基于数据库,比如基于缓存的MySQL,比如基于Zookeeper的Redis,etcd等。下面介绍如何使用缓存(Redis)实现分布式锁。使用Redis实现分布式锁最简单的方法是使用命令SETNX。SETNX(SETifNoteXist)的使用方法是:SETNXkeyvalue,只有当keykey不存在时,才将keykey的值设置为value,如果keykey存在,SETNX不做任何动作。SETNX在设置成功时返回,设置失败时返回0。获取锁时直接使用SETNX获取锁,释放锁时使用DEL命令删除对应的key。上述方案有一个致命的问题,就是某个线程获取到锁后,由于某些异常因素(比如宕机)导致无法正常进行解锁操作,那么锁就永远不会释放了。为此,我们可以给这个锁加一个超时时间。***时间我们会想到Redis的EXPIRE命令(EXPIRE键秒)。但是这里不能用EXPIRE来实现分布式锁,因为它和SETNX是两个操作,这两个操作之间可能会出现异常,所以还是达不到预期的结果。例子如下://STEP1SETNXkeyvalue//如果程序突然死机到这里(STEP1和STEP2之间),过期时间不能设置,可能不会释放锁//STEP2EXPIREkeyexpireTime对此,正确的姿势应该是使用“SETkeyvalue[EXseconds][PXmilliseconds][NX|XX]”命令。从Redis版本2.6.12开始,可以通过一系列参数修改SET命令的行为:EXseconds:将key的过期时间设置为seconds秒。执行SETkeyvalueEXseconds相当于执行SETEXkeysecondsvalue。PXmilliseconds:设置key的过期时间为milliseconds毫秒。执行SETkeyvaluePXmilliseconds相当于执行PSETEXkeymillisecondsvalue。NX:仅当密钥不存在时才设置密钥。执行SETkeyvalueNX的效果等同于执行SETNXkeyvalue。XX:仅当密钥已存在时才设置密钥。比如我们需要创建一个分布式锁,设置过期时间为10s,那么我们可以执行如下命令:SETlockKeylockValueEX10NX或者SETlockKeylockValuePX10000NX注意EX和PX不能同时使用,否则会报错:ERR语法错误。解锁时,使用DEL命令解锁。修改后的方案看起来很完美,但实际上还是有问题。想象一下,线程A获取了一把锁,设置过期时间为10s,然后用15s执行业务逻辑。此时线程A获取的锁已经被Redis的过期机制自动释放了。线程A获取到锁后10s过去了,锁可能已经被其他线程获取到。当线程A执行完业务逻辑准备解锁(DEL键)时,有可能删除其他线程已经获取的锁。所以最好的办法就是在开锁的时候判断这把锁是不是自己的。我们可以在设置key的时候将value设置为唯一值uniqueValue(可以是随机值,UUID,也可以是机器号+线程号,签名等的组合)。解锁时,即删除key时,先判断key对应的值是否等于之前设置的值。如果相等,则可以删除密钥。伪代码示例如下:ifuniqueKey==GET(key){DELkey}这里我们一目了然看到问题:GET和DEL是两个独立的操作,在GET执行之前的间隙可能会出现异常删除执行。如果我们只需要保证解锁代码是原子的,问题就可以解决。这里介绍一种新的方式,就是Lua脚本,例子如下:ifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("del",KEYS[1])elsereturn0end其中,ARGV[1]表示设置key时指定的唯一值。由于Lua脚本的原子性,在Redis执行脚本的过程中,其他客户端的命令需要等待Lua脚本执行完毕后才能执行。下面我们使用Jedis来演示锁的获取和解锁的实现,如下:lockKey,uniqueValue,params);if("OK".equals(result)){returntrue;}returnfalse;}publicbooleanunlock(StringlockKey,StringuniqueValue){Stringscript="ifredis.call('get',KEYS[1])==ARGV[1]"+"thenreturnredis.call('del',KEYS[1])elsereturn0end";Objectresult=jedis.eval(script,Collections.singletonList(lockKey),Collections.singletonList(uniqueValue));if(result.equals(1)){returntrue;}returnfalse;}这是万无一失的吗?很明显不是!表面上看,这个方法似乎行得通,但是这里有一个问题:我们的系统架构存在单点故障,如果Redis主节点宕机了怎么办?可能有人会说:加个从节点吧!master宕机的时候就用slave吧!但实际上,这种方案显然不可行,因为Redis的复制是异步的。例如:线程A获得了master节点上的锁。主节点在将A创建的密钥写入从节点之前宕机了。从节点成为主节点。线程B也获得了A仍然持有的同一个锁。(因为原slave中没有A持有锁的信息)当然这种方案在某些场景下是没有问题的,比如业务模型允许同时持有锁的情况,所以就是使用此解决方案是个不错的主意。比如一个服务有两个服务实例:A和B,一开始A获取锁然后操作资源(可以假设这个操作是非常耗资源的),B不获取锁不执行任何操作。这时B就可以看作是A的双机热备,当A异常时,B可以“转正”。当锁异常时,比如Redismaster挂了,那么B可能会持有锁同时操作资源。如果运算结果是幂等的(或者其他情况),那么也可以使用这个方案。这里引入分布式锁可以让服务避免正常情况下因为重复计算造成的资源浪费。为了应对这种情况,Antriez提出了Redlock算法。Redlock算法的主要思想是:假设我们有N个Redismaster节点,这些节点是完全独立的,我们可以使用前面的方案获取和解锁之前的单个Redismaster节点,如果我们能够在一个合理的范围内range或者N/2+1锁,那么我们可以认为已经成功获取到锁,否则就没有获取到锁(类似于Quorum模型)。Redlock的原理虽然简单易懂,但其内部实现细节却非常复杂。有很多因素需要考虑。Redlock算法不是“银弹”。除了条件苛刻之外,其算法本身也受到了质疑。关于Redis分布式锁的安全性,曾有分布式系统专家MartinKleppmann和Redis作者antirez的争论。
