Redis实现分布式锁
最近学习了Redis实现分布式锁,归纳整理一下
目录
什么是分布式锁
我们知道锁是多线程中的基础概念,是单进程中访问共享资源的一种同步机制,即所有线程都在同一个JVM进程里的时候,使用Java语言提供的锁机制可以起到对共享资源进行同步的作用。
如果分布式环境下多个不同线程需要对共享资源进行同步,那么用Java的锁机制就无法实现了,这个时候就必须借助分布式锁来解决分布式环境下共享资源的同步问题。
单机Redis实现分布式锁
Redis分布式锁方案一
使用Redis实现分布式锁最简单的方案是在获取锁之前先查询一下以该锁为key对应的value存不存在,如果存在,则说明该锁被其他线程获取;获取锁的逻辑很简单,只要以该锁为key,设置一个随机值就行了;比如,我们有一批任务需要由多个分布式线程处理,每个任务都有一个taskId,为了保证每个任务只被执行一次,在工作线程执行任务之前,先获取该任务的锁,锁的key可以为taskId。因此,获取锁的过程可以用如下伪代码实现:
上述就是最简单的获取锁的方案了,但是大家可以想想这个方案有什么问题呢?有没有什么潜在的坑?在分析这种方案的优缺点之前,先说一下获取锁后我们一般是怎么使用锁,并且又是如何释放锁的,以Java语言为例,我们一般获取锁后会将释放锁的代码放在finally块中,这样做的好处是即使在使用锁的过程中出现异常,也能顺利将锁释放掉。用伪代码描述如下:
其中,getLock方法的伪代码上文已经给出,releaseLock方法是释放锁的方法,在该方案中,只是简单地删除掉key,就不给出伪代码了。
上述使用锁的代码咋一看是没有什么问题的,学过Java的人都知道,在try…finally…代码块中,即使try代码块中抛出异常,最终也会执行finally代码块,然而这样就能保证锁一定会被释放吗?考虑这样一种情况:代码执行到doSomething()方法的时候,服务器宕机了,这个时候finally代码块就没法被执行了,因此在这种情况下,该锁不会被正常释放,在上述案例中,可能会导致任务漏算。因此,这种方案的第一个问题是会出现锁无法正常释放的风险,解决这个问题的方法也很简单,Redis设置key的时候可以指定一个过期时间,只要获取锁的时候设置一个合理的过期时间,那么即使服务器宕机了,也能保证锁被正确释放。
该方案的另外一个问题是,获取到的锁不一定是排他锁,也就是说同一把锁同一时间可能被不同客户端获取到。仔细分析一下getLock方法,该方法并不是原子性的,当一个客户端检查到某个锁不存在,并在执行setKey方法之前,别的客户端可能也会检查到该锁不存在,并也会执行setKey方法,这样一来,同一把锁就有可能被不同的客户端获取到了。
既然这种方案有以上缺点,那么该如何改进呢?
Redis分布式锁方案二
上一小节的方案有2个缺点,一个是获取的锁可能无法释放,另一个是同一把锁在同一时间可能被不同线程获取到。通过查看Redis文档,可以找到Redis提供了一个只有在某个key不存在的情况下才会设置key的值的原子命令,该命令也能设置key值过期时间,因此使用该命令,不存在上述方案出现的问题,该命令为:
SET my_key my_value NX PX milliseconds
其中,NX表示只有当键key不存在的时候才会设置key的值,PX表示设置键key的过期时间,单位是毫秒。
如此一来,获取锁的过程可以用如下伪代码描述:
其中,setKeyOnlyIfNotExists方法表示的是原子命令SET my_key my_value NX PX milliseconds。
如此一来,获取锁的代码应该就没什么问题了,但是这种方案还是会有其他问题。大家再仔细研究下释放锁的代码。因为现在我们设置key的时候也设置了过期时间,所以原来的释放锁的代码现在看来就有问题了。考虑这样一种情况:客户端A获取锁的时候设置了key的过期时间为2秒,然后客户端A在获取到锁之后,业务逻辑方法doSomething执行了3秒(大于2秒),当执行完业务逻辑方法的时候,客户端A获取的锁已经被Redis过期机制自动释放了,因此客户端A在获取锁经过2秒之后,该锁可能已经被其他客户端获取到了。当客户端A执行完doSomething方法之后接下来就是执行releaseLock方法释放锁了,由于前面说了,该锁可能已经被其他客户端获取到了,因此这个时候释放锁就有可能释放的是其他客户端获取到的锁。
Redis分布式锁方案三
既然方案二可能会出现释放了别的客户端申请的锁的问题,那么该如何进行改进呢?有一个很简单的方法是,我们设置key的时候,将value设置为一个随机值r,当释放锁,也就是删除key的时候,不是直接删除,而是先判断该key对应的value是否等于先前设置的随机值,只有当两者相等的时候才删除该key,由于每个客户端产生的随机值是不一样的,这样一来就不会误释放别的客户端申请的锁了。新的释放锁的方案用伪代码描述如下:
其中,getKey方法就是Redis的查询key值的方法,deleteKey就是Redis的删除key值的方法,在此不给出伪代码了。
那么这种方案就没有问题了吗?很遗憾地说,这种方案也是有问题的。原因在于上述释放锁的操作不是原子性的,不是原子性操作意味着当一个客户端执行完getKey方法并在执行deleteKey方法之前,也就是在这2个方法执行之间,其他客户端是可以执行其他命令的。考虑这样一种情况,在客户端A执行完getKey方法,并且该key对应的值也等于先前的随机值的时候,接下来客户端A将会执行deleteKey方法。假设由于网络或其他原因,客户端A执行getKey方法之后过了1秒钟才执行deleteKey方法,那么在这1秒钟里,该key有可能也会因为过期而被Redis清除了,这样一来另一个客户端,姑且称之为客户端B,就有可能在这期间获取到锁,然后接下来客户端A就执行到deleteKey方法了,如此一来就又出现误释放别的客户端申请的锁的问题了。
Redis分布式锁方案四
既然方案三的问题是因为释放锁的方法不是原子操作导致的,那么我们只要保证释放锁的代码是原子性的就能解决该问题了。很遗憾的是,查阅Redis开发文档,并没有发现相关的原子操作。不过幸运的是,在Redis中执行原子操作不止有通过官方提供的命令的方式,还有另外一种方式,就是Lua脚本。因此,方案三中的释放锁的代码可以用以下Lua脚本来实现:
其中ARGV[1]表示设置key时指定的随机值。
由于Lua脚本的原子性,在Redis执行该脚本的过程中,其他客户端的命令都需要等待该Lua脚本执行完才能执行,所以不会出现方案三所说的问题。至此,使用Redis实现分布式锁的方案就相对完善了
集群Redis实现分布式锁
单节点redis实现分布式锁时比较简单,大多数时候能满足需求;但单节点可能出现单点故障,可以采用集群的方式来避免单点故障问题
集群方式可能出现的问题:
有这样一种情况客户端A先在主机上获取到锁,主机还没来得及同步给从机 主机就挂了或者因为网络波动原因造成了主从切换 , 这时客户端B在从机上获取到锁, 这时两个客户端都会认为自己获取到锁了,可能会导致逻辑代码出现问题
Redlock实现分布式锁
正因为Redis集群的问题,Redis作者基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock
Redlock概念:
在Redis的分布式的环境中,我们假设有5个Redis实例,5个互相之间没有联系的单点,相互独立。不形成集群!不形成集群!不形成集群(我学到这里时卡了很久)。我们确保将在5个实例上使用与Redis单实例下相同方法获取和释放锁。
为了获取锁,客户端应该执行以下操作:
1. 获取当前Unix时间,以毫秒为单位。
2. 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
3. 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(5/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
使用
基于Redission实现Redlock
pom
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.3.2</version>
</dependency>
Configuration
@Component
public class RedisConfig {
@Autowired
RedisSentinelProperties properties;
@Bean
public RedissonClient redissonRed1(){
Config config = new Config();
config.useSingleServer().setAddress("127.0.0.1:6379").setDatabase(0);
return Redisson.create(config);
}
@Bean
public RedissonClient redissonRed2(){
Config config = new Config();
config.useSingleServer().setAddress("127.0.0.1:6380").setDatabase(0);
return Redisson.create(config);
}
@Bean
public RedissonClient redissonRed3(){
Config config = new Config();
config.useSingleServer().setAddress("127.0.0.1:6381").setDatabase(0);
return Redisson.create(config);
}
}
代码
public class ServiceImpl implements GrabService {
@Autowired
private RedissonClient redissonRed1;
@Autowired
private RedissonClient redissonRed2;
@Autowired
private RedissonClient redissonRed3;
@Autowired
OrderService orderService;
@Override
public ResponseResult grabOrder(int orderId , int driverId){
//生成key
String lockKey = (RedisKeyConstant.GRAB_LOCK_ORDER_KEY_PRE + orderId).intern();
//红锁
RLock rLock1 = redissonRed1.getLock(lockKey);
RLock rLock2 = redissonRed2.getLock(lockKey);
RLock rLock3 = redissonRed2.getLock(lockKey);
RedissonRedLock rLock = new RedissonRedLock(rLock1,rLock2,rLock3);
rLock.lock();
BC
try {A
//这里是业务逻辑代码
// 此代码默认 设置key 超时时间30秒,过10秒,再延时
System.out.println("A:"+driverId+" 执行业务逻辑");
boolean b = orderService.grab(orderId, driverId);
if(b) {
System.out.println("B:"+driverId+" 加锁成功");
}else {
System.out.println("C:"+driverId+" 加锁失败");
}
} finally {
// 无论如何, 最后都要解锁
rLock.unlock();
}
return null;
}
}