在分布式系统中,数据的一致性是个重要的问题,为了实现数据一致性,需要很多技术方案来支持,比如分布式锁等。分布式锁又有多种实现方式,如数据库锁,redis分布式锁,还有zookeeper分布式锁。
基于数据库实现分布式锁
要实现分布式锁,最简单的方式是直接创建一张锁表,然后通过操作该表中的数据来实现。当要锁住某个方法或资源时,就在表中增加一条记录,想要释放锁的时候就删除这条记录,以悲观锁的方式实现分布式锁。
但是与redis或zookeeper分布式锁相比,数据库分布式锁实现复杂度高,性能低,可靠性低。一般情况下基本不会使用。
从实现的复杂度角度讲,zookeeper和redis分布式锁差不多;从性能角度讲,redis分布式锁较好;从可靠性角度讲,zookeeper分布式锁较好
Redis分布式锁
单节点redis分布式锁实现
这里介绍一下redis分布式锁的实现,直接看代码。注意:这是基于redis2.6.2之前的版本实现的,2.6.2之后的版本会更简单。原因后面会介绍。
/**
* 获取分布式锁
* @param lockName 竞争获取锁key
* @param acquireTimeoutInMS 获取锁超时时间
* @param lockTimeoutInMS 锁的超时时间
* @return 获取锁标识
*/
public String acquireLockWithTimeout(String lockName,
long acquireTimeoutInMS, long lockTimeoutInMS) {
logger.info("尝试获取锁,锁名:{}", lockName);
String retIdentifier = null;
String identifier = UUID.randomUUID().toString();
String lockKey = lockPrefix + lockName;
long end = System.currentTimeMillis() + acquireTimeoutInMS;
try {
BoundValueOperations<String, String> boundValueOperations = stringRedisTemplate.boundValueOps(lockKey);
while (System.currentTimeMillis() < end) {
boolean success = boundValueOperations.setIfAbsent(identifier);
if (success) {
//如果获得锁
boundValueOperations.expire(lockTimeoutInMS, TimeUnit.MILLISECONDS);
retIdentifier = identifier;
return retIdentifier;
}
//未设置锁的过期时间(可能是设置过期时间时发生异常导致)
if (boundValueOperations.getExpire() == -1) {
boundValueOperations.expire(lockTimeoutInMS, TimeUnit.MILLISECONDS);
}
try {
Thread.sleep(10);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
} catch (Exception e) {
throw new BusinessException(ErrorCodeDefinition.ERROR_REDIS_GET,"获取分布式锁异常");
//在外层捕获到该异常时一般继续做业务处理
}
return retIdentifier;
}
/**
* 释放锁
* @param lockName 竞争获取锁key
* @param identifier 释放锁标识
* @return
*/
public boolean releaseLock(String lockName, String identifier) {
logger.info("尝试释放锁,锁名:{}", lockName);
String lockKey = lockPrefix + lockName;
boolean retFlag = true;
try {
BoundValueOperations<String, String> boundValueOperations = stringRedisTemplate.boundValueOps(lockKey);
if (identifier.equals(boundValueOperations.get())) {
stringRedisTemplate.delete(lockKey);
retFlag = true;
}
} catch (Exception e) {
retFlag = false;
}
return retFlag;
}
在业务执行前后,分别获取分布式锁和释放分布式锁。当无法获取分布锁时,即表明已有其他进程/线程正在处理该业务。只有当锁被释放或者锁过期时才能进行新的业务调用。还有另外一种实现方式:
分布式锁实现方式2
public boolean tryLock(String lockKey) {
try{
Long time_stamp = System.currentTimeMillis();
BoundValueOperations<String, Long> boundValueOperations = redisTemplate.boundValueOps(lockKey);
boolean flag = boundValueOperations.setIfAbsent(time_stamp);
if(flag){//获得锁
redisTemplate.expire(lockKey, DEAD_LOCK_EXPIRED_SECONDS, TimeUnit.SECONDS);
return true;
} else {
//没有获得锁
//查看锁的时间戳进行死锁检查(可能存在锁一直未释放也没有过期时间的情况)
Long val = boundValueOperations.get();
if(val==null){
//如果key读取期间被删(释放锁),直接返回 false;
return false;
} else if((time_stamp - val) > DEAD_LOCK_REMOVE_SECONDS) { //已经超过时间
//当前锁已过期(可能是设置过期时间时出现异常导致)
//设置锁的新时间戳,并返回原时间戳(即锁的原标识)。
Long last_value = boundValueOperations.getAndSet(time_stamp);
if(val.equals(last_value) ){
//相等则抢占到锁
//重新设置过期时间
redisTemplate.expire(lockKey, DEAD_LOCK_EXPIRED_SECONDS, TimeUnit.SECONDS);
return true;
} else{
//时间戳已被其他线程更改
return false;
}
} else { //未超时
return false;
}
}
} catch(Exception e){
LOG.error("获取分布式锁异常:{}",e);
//当redis异常时,业务流程正常处理
return true;
}
}
public boolean unlock(String lockKey) {
try{
redisTemplate.delete(lockKey);
}catch(Exception e){
LOG.error("释放分布式锁异常:{}",e);
return false;
}
return true;
}
当然,分布式锁也可以通过aop的方式进行加锁和释放:在需要加锁的地方添加分布式锁注解,然后在业务处理方法之前进行前置通知,执行加锁,在业务处理方法之后后置通知释放锁。
Redis分布式锁存在的问题
上文中介绍的分布式锁是基于单个Redis节点的。首先要说明的是,对于大多数业务场景,单节点redis分布式锁应用起来并没有太大问题。
事实上深究起来的话,基于单个redis节点的分布式锁并不完全可靠。下面通过伪代码来进行分析。
首先看下redis获取锁的语句:
Set resource_name my_random_value NX PX 30000
此命令如果执行成功,则客户端成功获取了锁,接下来就可以访问共享资源了,执行失败则说明获取锁失败。在这个命令中,NX表示只有当resource_name对应的key值不存在的时候才能set成功,这保证了只有第一个请求的客户端才能获得锁,而其他客户端在锁被释放前都无法获取锁。PX 3000表示这个锁有一个30s的自动过期时间,当然这里只是一个例子,客户端可以选择合适的过期时间。当客户端完成了对共享资源的操作之后对锁进行释放。
但事实上这里存在几个问题:
-
第一个问题是:这个锁必须要设置一个过期时间,否则的话当一个客户端获取锁成功之后崩溃了,或者发生了网络分隔导致它再也无法和redis节点通信了,那么它就会一直持有这个锁(因为没有释放锁)。这个问题只要有合适的锁过期时间就可以解决。
-
第二个问题是:获取到锁之后设置锁的过期时间,这并不是一个原子操作。如果客户端在执行完SETNX后崩溃了,那么就没有机会执行EXPIRE了,导致它一直持有这个锁。
-
第三个问题:设置随机字符串my_random_value是很有必要的,它保证了一个客户端释放的锁是自己持有的那个锁。假如获取锁时SET的是一个固定值,那么可能发生下面的问题:客户端1获取锁成功之后阻塞了一段时间直到锁过期,然后客户端2获取到了同一个资源的锁。接着客户端1从阻塞中恢复过来,然后释放掉了客户端2持有的锁。这样客户端2在访问共享资源的时候就没有锁为它提供保护了。
-
第四个问题:释放锁的操作必须使用Lua脚本来实现,Lua脚本如下:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
释放锁实际上包含三步操作,get,判断和del,用Lua脚本能保证这三步的原子性,如果放到客户端逻辑来执行的话就可能发生与第三个问题类似的执行序列:客户端1判断随机字符串的值与预期值相等之后阻塞了一段时间,过期时间到了导致锁自动释放。此时客户端2获取到同一个资源,客户端1从阻塞中恢复过来执行del操作释放掉了客户端2持有的锁。
后两个问题的实质是客户端释放掉了不属于自己的锁,出现这两个问题的前提是客户端在特定时刻阻塞了或者网络延迟。实际上这个概率比较小,尤其是问题四,只有在del缓存key时判断了get(key) == my_random_value
之后而又没有真正del时阻塞了才会出现。
上述四个问题只要在实现分布式锁的时候加以注意就能够被正确处理。
可以看到,在上文提供的两个分布式锁实现方法中,方法一处理了前三个问题,方法二处理了前两个问题。在不追求绝对可靠同时要求较高效率时也够用了。
但还有个问题是单节点redis分布式锁无法解决的,称为failover,具体说就是为了提高可用性使用了从主架构的redis,当主服务器宕机时,slave切换成master,但由于Redis的主从复制是异步的,导致会出现原来的key还没有复制到新的master上的这段时间内(failover)丧失锁的安全性(即同时存在两个线程都可以获取到锁)。
考虑到这个问题,Redis的作者Antirez给出了一个更好的实现,称为红锁(RedLock),算是Redis官方对于实现分布式锁的指导规范,具体实现这里就不说了。
红锁是基于多个Redis节点(这些节点都是Master)的一种实现,可以解决单节点redis分布式锁存在的一些问题,但红锁的可靠性也是存疑的,分布式系统专家Martin Kleppmann就和Antirez之间发生过一场争论,前者曾写过一篇文章,分析过红锁的安全性,并指出:由于RedLock本质上是建立在一个同步模型之上,对于系统的计时假设有很强的要求,因此本身的安全性是不够的。他得出的最终的结论是:
(1) 如果是为了效率而使用分布式锁,允许锁的偶尔失效,那么使用单Redis节点的锁方案就足够了,简单而且效率高。红锁则是一个过重的实现。
(2) 如果为了正确性而在很严肃的场合使用分布式锁,那么不要使用RedLock。它不是建立在异步模型上的一个足够强的算法,在对于系统模型的假设中包含着很多危险的成分。应该考虑类型zookeeper的方案,或者支持事务的数据库。
随后Antirez也对此进行了回复和反驳,具体这里就不详细展开了,感兴趣的可以看下本文的参考资料1和2。这场讨论引在Twitter和Hacker News引起了激烈的讨论,见仁见智,并没有一个明确的孰对孰错的结论。但仍然可以得出一个大致的结论:如果要求效率高,而不要求100%的正确性,可以使用redis分布式锁;如果要求正确性或者说可靠性,需要慎重考虑,当前最可靠的分布式锁还是Zookeeper分布式锁。
分布式锁优化
在上文中,详细说明了分布式锁存在的问题,主要是死锁和释放掉不属于自己的锁的情况,并且给出了在redis 2.6.2版本之前的两种分布式锁的实现方式。可以看到,为了规避死锁等问题,这两种实现方式其实都比较复杂。
而在redis 2.6.2之后,官方推出了set ex
, set px
等命令,可以在设置锁的同时设置过期时间,保证了上锁和设置锁过期时间的原子性,这样就不必通过复杂的代码来解决死锁问题了,而是直接通过set命令就能实现。
注意: 在2.6.2之后,SET加上参数就能实现SETNX,SETEX,PSETEX的功能,因此SETNX, SETEX, PSETEX在后续的版本中可能会移除。
因此,redis 2.6.2之后的版本中分布式锁的实现就比较简单了,类似于这种(当然,不同的redis客户端略有差别):
Jedis redis = getJedis();
String lockResult = redis.set(lockKey, lockValue, "NX", "EX", EXPIRE_SECS);
其中lockValue是为了保证每个线程在释放锁的时候确实是释放的自己的锁。
上文说过,如果不追求100%的可靠性,其实到这一步已经差不多够用了。最多就是将释放锁的语句通过luna脚本来实现,保证释放锁操作的原子性。
redis客户端选择
jedis
Jedis作为java实现的客户端,属于比较成熟的redis客户端。支持全面的redis操作特性,但是对于一些集群、分布式锁、异步、reactive等的支持一般。
Jedis 客户端实例不是线程安全的,所以需要通过连接池来使用。如果想使用jedis客户端,推荐使用spring-data-redis中jedis客户端,通过spring对jedis的支持来扩展jedis的功能。
lettuce
lettuce是一个可扩展的线程安全的redis客户端,支持同步、异步、reactive使用,多个线程可共享一个连接来避免阻塞和事务性操作。
spring-boot 2.x 默认使用Lettuce。目前也是spring-data-redis中默认使用的redis客户端。
redisson
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务,例如,分布式锁,分布式集合,可通过Redis支持延迟队列等。
它将常用的redis指令进行封装,促使使用者从redis的关注中分离,从而让使用者能够将精力更集中地放在处理业务逻辑上。
在实际生产中,一般是采用组合方式来搭配使用:
Jedis + Redisson
或 Lettuce + Redisson
redisson分布式锁通过luna脚本来加锁和解锁,保证了上锁和解锁操作的原子性。并且它是一个可重入锁,同一个线程是可以重复进入的。
redisson分布式锁仍然存在failover问题,可以使用其可靠性更高的RedLock。
Zookeeper分布式锁
Zookeeper分布式锁性能不如Redis,但可靠性比Redis高。在对可靠性要求很高的业务场景中,建议使用zookeeper分布式锁。Zookeeper客户端的实现偏底层,如果想要实现锁或其他功能都需要自己封装,实现一些简单功能还可以,但是分布式锁不建议自己封装。可以使用Curator框架,该框架是对Zookeeper的高阶封装,与操作原生的Zookeeper相比,简化了对集群的连接,错误的处理。实现了一些经典“模式”,如分布式锁,Leader选举等。
实现原理
Zookeeper分布式锁实现原理描述如下:
- 创建一个锁的根节点,比如locks。
- 想要获取锁的客户端在锁的根节点locks下创建临时子节点znode,节点的类型选择CreateMode.PERSISTENT_SEQUENTIAL,节点的名字最好用uuid(目的是为了防止某些场景下的死锁)。假设有3个客户端想要获得锁,那么locks目录下应该有3个子节点:xxx-lock-0000000001, xxx-lock-0000000002, xxx-lock-0000000003。其中xxx为uuid,后面的序号为zookeeper服务端自动生成的自增数字。
- 当前客户端通过getChildren获取根节点下所有子节点列表并根据自增数字排序,然后判断自己创建的节点是否是最小节点,如果是则获取锁,否则获取自己的前一个节点并设置监听。
- 释放锁,当前获得锁的客户端在操作完成后删除自己创建的节点,这样会触发zookeeper的事件,其他监听者接收到之后重新开始执行步骤3。
举例:假设客户端0000000001获取到锁,然后客户端0000000002进来获取锁,发现自己不是编号最小的,就监听它前面节点0000000001的事件。当客户端0000000001操作完成后删除自己的节点,0000000002就监听到这一事件,然后从步骤3开始执行直到获取到锁。
上面的步骤实现了一个有序锁,即先进入等待所的客户端在锁可用时先获取锁。这也是一种公平锁。如果想实现非公平锁,即所有等待锁的客户端在锁可用时有相同的机会获取到锁,可以将PERSISTENT_SEQUENTIAL换成一个随机数。具体实现这里就不给出了,网上有很多资料。
参考资料
(1) https://blog.csdn.net/matt8/article/details/64442064
(2) https://blog.csdn.net/matt8/article/details/64442116
(3) https://blog.csdn.net/nimasike/article/details/51567653