在单实例JVM中,有许多常见的方法可以处理并发问题,例如访问控制的synchronized关键字,volatile关键字和ReentrantLock。但是,在分布式环境中,上述方法不能用于处理跨JVM方案中的并发问题。当业务场景需要处理分布式环境中的并发问题时,需要使用分布式锁来实现。
分布式锁是指多个客户端通过分布式部署环境中的锁机制共享资源的相互访问。
目前,更常见的分布式锁实现方案如下:
基于MySQL等数据库
基于缓存,例如Redis
基于Zookeeper,etcd等
以下是使用Cache(Redis)实现分布式锁的方法。
使用Redis实现分布式锁的最简单方法是使用命令SETNX。 SETNX(SET if Not eXist)按以下方式使用:SETNX键值,仅当键不存在时键值的值才设置为值,如果键存在,则SETNX不执行任何操作。 SETNX在设置成功时返回,并在设置失败时返回0。如果要获取锁定,请使用SETNX获取锁定。如果要释放锁定,请使用DEL命令删除相应的密钥。
上述方案存在致命问题,即某个线程在获取锁定后由于某些异常因素(如停机时间)而无法正常执行解锁操作,并且永远不会释放锁定。为此,我们可以为此锁添加超时。我们第一次想到Redis的EXPIRE命令(EXPIRE键秒)。但是在这里我们不能使用EXPIRE来实现分布式锁,因为它是用SETNX进行的两个操作,两个操作之间可能会发生异常,而且仍然没有达到预期的效果,例子如下:
//步骤1SETNX键值
//如果程序在STEP1和STEP2之间突然崩溃,则无法设置到期时间并且可能无法释放锁定。
//第2步
EXPIRE键expireTime
在这方面,正确的姿势应该是使用“SET键值[EX秒] [PX毫秒] [NX | XX]”命令。
从Redis 2.6.12开始,可以使用一系列参数修改SET命令的行为:
EX秒:将密钥的到期时间设置为秒秒。执行SET键值EX秒相当于执行SETEX键秒值。
PX毫秒:将密钥的到期时间设置为毫秒毫秒。执行SET键值PX毫秒相当于执行PSETEX键毫秒值。
NX:仅在密钥不存在时设置密钥。执行SET键值NX相当于执行SETNX键值。
XX:仅在密钥已存在时设置密钥。
例如,我们需要创建一个分布式锁并将到期时间设置为10秒,然后您可以执行以下命令:
SET lockKey lockValue EX 10 NX
要么
SET lockKey lockValue PX 10000 NX
请注意,EX和PX不能同时使用,否则将报告错误:ERR语法错误。
解锁时使用DEL命令解锁。
修改后的解决方案看起来很完美,但仍然存在问题。想象一下,线程A获取锁并将到期时间设置为10秒,然后在执行业务逻辑时花费15秒。此时,线程A获取的锁已被Redis的到期机制自动释放。在线程A获得锁定并经过10秒后,其他线程可能已获取锁定。当线程A完成执行业务逻辑解锁(DEL键)时,可以删除其他线程已获取的锁。
因此,最好的方法是在解锁时确定锁是否是您自己的锁。我们可以在设置密钥时将值设置为唯一值uniqueValue(可以是随机值,UUID,或机器编号+线程编号,签名等的组合)。当解锁时,即,当删除键时,首先确定对应于该键的值是否等于先前设置的值。如果删除密钥,则伪代码示例如下:如果uniqueKey==GET(key){
DEL键
}
在这里我们可以一目了然地看到问题:GET和DEL是两个独立的操作,并且在执行GET之后和执行DEL之前可能发生异常。如果我们只是保证解锁的代码是原子的,它将解决问题。这里我们介绍一种新的方式,Lua脚本,示例如下:
如果redis.call('get',KEYS [1])==ARGV [1]那么
返回redis.call('del',KEYS [1])
其他
返回0
结束
其中ARGV [1]表示设置密钥时指定的唯一值。
由于Lua脚本的原子性,在Redis执行脚本的过程中,其他客户端命令需要等待Lua脚本在执行之前执行。
下面我们使用Jedis来演示获取锁和解锁的实现,如下所示:
公共布尔锁(String lockKey,String uniqueValue,int seconds){
SetParams params=new SetParams();
。Params.nx()EX(秒);
字符串结果=jedis.set(lockKey,uniqueValue,params);
如果('OK'.equals(result)){
回归真实;
}
返回false;
}
公共布尔解锁(String lockKey,String uniqueValue){
String script='if redis.call('get',KEYS [1])==ARGV [1]'+
'然后返回redis.call('del',KEYS [1])否则返回0结束';
对象结果=jedis.eval(脚本,
Collections.singletonList(lockKey)
Collections.singletonList(uniqueValue));如果(result.equals(1)){
回归真实;
}
返回false;
}
它是如此万无一失吗?显然不是!
从表面上看,这种方法似乎有效,但这里存在一个问题:我们的系统架构中存在单点故障,如果Redis主节点发生故障怎么办?有人可能会说:添加一个从节点!只需在主机关闭时使用从机!
但实际上这个解决方案显然不可行,因为Redis复制是异步的。例如:
线程A获得主节点上的锁定。
在将A创建的密钥写入从站之前,主节点崩溃。
从站成为主节点。
线程B也获得了A仍然保持的锁。 (因为没有关于A在原始奴隶中持有的锁的信息)
当然,在某些情况下,此解决方案没有问题。例如,如果业务模型允许同时保持锁定,那么使用此方案并不是一个坏主意。
例如,服务有两个服务实例:A和B.在初始情况下,A获取锁定然后对资源进行操作(可以假设此操作非常耗费资源),并且B不获取锁定不执行任何操作。当B可以看作是A的热备用。当A异常时,B可以“转为正”。当锁(例如Redis主机)发生异常时,B可以保持锁并对资源进行操作。如果操作的结果是幂等的(或其他),则也可以使用该方案。这里引入分布式锁可以使服务避免冗余计算,并在正常情况下造成资源浪费。
针对这种情况,antriez提出了Redlock算法。 Redlock算法的主要思想是:假设我们有N个Redis主节点,这些节点完全独立,我们可以使用前面的方案获取前一个Redis主节点的锁定和解锁,如果我们可以在一个合理的范围内范围或N/2 + 1锁,然后我们可以认为锁已成功获得,否则无法获取锁(可以与Quorum模型进行比较)。虽然Redlock的原理很好理解,但内部实现细节很复杂,需要考虑很多因素。
Redlock算法不是“银弹”。除了恶劣的条件外,算法本身也受到质疑。关于Redis分布式锁的安全性,分布式系统专家Martin Kleppmann和Redis作者antirez之间存在争议。