微服务中常用分布式锁原理及执行流程
1.什么是分布式锁
分布式锁是一种在分布式系统环境下实现的锁机制,它主要用于解决,多个分布式节点之间对共享资源的互斥访问问题,确保在分布式系统中,即使存在有多个不同节点上的进程或线程,同一时刻也只有一个节点可以获得锁并对共享资源进行操作,从而维护数据的一致性和完整性。
2.分布式锁的特点
当然要实现一个分布式锁还需要考虑一些东西,比如Redis的健壮性,它不能随便挂掉,这里总结一下分布式锁的一些要素,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性:同一时间只能一个节点获取到锁,其他节点需要等待获取到锁的节点释放了锁才可以获取到锁,而这里的等待一般是通过阻塞,和自旋两种方式
- 安全性:解铃还须系铃人,只能释放自己的锁不能误删别人的锁
- 死锁:比如在节点宕机时最容易出现锁没被释放的问题,然后出现死锁,所以做锁的过期
- 容错:当Redis宕机,客户端仍然可以释放锁
- 可重入:获取锁失败可以重新尝试获取锁
3.分布式锁常用的三种方案
基于数据库实现:通常基于主键,或者唯一索引来实现分布式锁,但是性能比较差,一般不建议使用
基于Redis :可以使用setnx来加锁 ,但是需要设置锁的自动删除来防止死锁,所以要结合expire使用.为了保证setnx和expire两个命令的原子性,可以使用set命令组合。
另外释放锁在finallly中调用del删除锁,而删除锁前需要判断该锁是否是当前线程加的锁以免误删除锁,需要通过get获取锁然后进行判断,但是需要保证get判断或和del删除锁的原子性,可以使用LUA脚本实现。
基于zookeeper : 使用临时顺序节点实现,线程进来都去创建临时顺序节点,第一个节点的创建线程获取到锁,后面的节点监听自己的上一个节点的删除事件,如果第一个节点被删除,释放锁第二个节点就成为第一个节点,获取到锁。
在项目中可以使用curator,这个是Apache封装好的基于zookeeper的分布式锁方案。
4.zookeeper存储结构
Zookeeper会维护一个具有层次关系的树状的数据结构,它非常类似于一个标准的文件系统,如下图所示:同一个目录下不能有相同名称的目录节点
ZooKeeper 节点是有生命周期的这取决于节点的类型,在 ZooKeeper 中,节点类型可以分为持久节点(PERSISTENT )、临时节点(EPHEMERAL),以及时序节点(SEQUENTIAL ),具体在节点创建过程中,一般是组合使用,可以生成以下 4 种节点类型。
- 持久节点(PERSISTENT)所谓持久节点,是指在节点创建后,就一直存在,直到有删除操作来主动清除这个节点——不会因为创建该节点的客户端会话失效而消失。
- 持久顺序节点(PERSISTENT_SEQUENTIAL)这类节点的基本特性和上面的节点类型是一致的。额外的特性是,在ZK中,每个父节点会为他的第一级子节点维护一份时序,会记录每个子节点创建的先后顺序。基于这个特性,在创建子节点的时候,可以设置这个属性,那么在创建节点过程中,ZK会自动为给定节点名加上一个数字后缀,作为新的节点名。这个数字后缀的范围是整型的最大值。
- 临时节点(EPHEMERAL)和持久节点不同的是,临时节点的生命周期和客户端会话绑定。也就是说,如果客户端会话失效,那么这个节点就会自动被清除掉。注意,这里提到的是会话失效,而非连接断开。另外,在临时节点下面不能创建子节点。
- 临时顺序节点(EPHEMERAL_SEQUENTIAL)在临时几点的基础上增加了顺序,可以用来实现分布式锁
顺序节点可以用来为所有的事件进行全局排序,这样客户端可以通过序号推断事件的顺序。
5.zookeeper分布式锁原理
分布式锁就是基于zk的 临时顺序节点+watch监听机制完成的。临时顺序节点特点是客户端断开节点释放,且自己维护节点顺序值,当多个线程同时创建节点我们就可以按照顺序创建N个顺序临时节点,然后依次从第一个往后获取锁。只不过能拿到锁的只能是第一个节点的线程,所以后面的线程需要监听自己上一个节点的节点释放。轮到谁,谁就拿到锁。
6.Redis如何实现分布式锁,用什么命令
可以使用setnx来加锁 ,但是需要设置锁的自动删除来防止死锁,所以要结合expire使用.为了保证setnx和expire两个命令的原子性,可以使用set命令组合。
setnx命令,只会在key不存在时,将将键的key设置为value。若已经存在则不做操作。
例如:果三个服务同时抢锁,服务A抢先一步执行setnx(lock_stock,1)加上锁,那么当服务B在执行setnx(lock_stock,1)加锁的时候就会失败,服务C也一样,服务A抢到锁执行完业务逻辑后就会释放锁,可以使用del(lock_stock)删除锁,其他服务就可以执行setnx(lock_stock,1)加锁了
if(jedis.setnx(lock_stock,1) == 1){ //获取锁
expire(lock_stock,5) //设置锁超时
try {
业务代码
} finally {
jedis.del(lock_stock) //释放锁
}
}
7.Redis实现分布式锁可能会出现什么问题,如何解决
- 锁超时问题,加锁和释放锁的原子性问题,锁的误删除问题,get获取锁和删除锁的原子性问题,集群模式中redis节点宕机问题
- 添加锁和设置过期时间可以使用set命令进行组合,达到原子性加锁
- 需要用lua解决删除和判断锁的原子性,否则可能会删除掉别人的锁。
- Redis集群环境中,redis节点挂掉可能会导致加锁失败,可以使用Redisson的红锁来解决。
7.1锁超时问题
这里有一个问题,如果获取到锁的服务在释放锁的时候宕机了,那么Redis中lock-stock不就永远存在,那锁不就释放不了么,别的服务也就没办法获取到锁,就造成了死锁,为了解决这个问题,我们需要设置锁的自动超时也就是Key的超时自动删除,即使服务宕机没有调用del释放锁,那么锁本身也有超时时间,可以自动删除锁,别的服务就可以获取锁了,Redis中Key的过期时间可以使用Redis的 expire(lock_stock,30)命令实现,这里给出伪代码如下
if(jedis.setnx(lock_stock,1) == 1){ //获取锁
expire(lock_stock,5) //设置锁超时
try {
业务代码
} finally {
jedis.del(lock_stock) //释放锁
}
}
7.2setnx和expire操作的原子性问题
上面的代码依然有问题,就是setnx获取锁和expire不是原子性操作,假设有一极端情况,当线程通过setnx(lock_stock,1)获取到锁,还没来得及执行expire(lock_stock,30)设置锁的过期时间,服务就宕机了,那是不是锁也永远得不到释放呢???又变成了死锁,这个问题可以使用set命令解决,我们先来看一下这个命令的语法
SET key value [EX seconds] [PX milliseconds] [NX|XX]
- EX seconds:设置时间单位为秒
- PX milliseconds:设置时间单位为毫秒
- NX:即setnx中的nx,就是key值不存在时才去执行
- XX : 只在键已经存在时, 才对键进行设置操作。
也就是说该命令可以当做setnx和expire的组合命令来使用,而且是原子性的,改造代码如
if(set(lock_stock,1,"NX","EX",5) == 1){ //获取锁并设置超时
try {
业务代码
} finally {
del(lock_stock) //释放锁
}
}
7.3锁的误删除问题
上面的方案依然有问题,就是在del释放锁的时候可能会误删除别人加的锁,例如服务A获取到锁lock_stock,过期时间为 5s,如果在服务A执行业务逻辑的这一段时间内,锁到期自动删除,且别的服务获取到了锁lock_stock,那么服务A业务执行完成执行del(lock_stock)有可能会把别人的锁给删除掉
解决方案: 我们可以在删除锁的时候先判断一下要删除的锁是不是自己上的锁,比如可以把锁的值使用一个UUID,在释放锁的时候先获取一下锁的值和当前业务中创建的UUID是不是同一个,如果是才执行·del删除锁,当然也可以使用线程的ID替代UUID,代码如:
String uuid = UUID.randomUUID().toString();
if(jedis.set(lock_stock,uuid,"NX","EX",5) == 1){ //获取锁并设置超时
try {
业务代码
} finally {
String lockValue = jedis.get(lock_stock); //获取锁的值
if(lockValue.equals(uuid)){ //判断是不是自己的锁
jedis.del(lock_stock) //释放锁
}
}
}
7.4lua脚本保证操作的原子性
但是上面的代码依然有问题,就是判断锁的代码和删除锁的代码也不是原子性的,依然可能会导致锁的误删除问题,比如服务A在判断锁成功准备删除锁时,锁自动过期,别的服务B获取到了锁,然后服务A执行DEL就可能会把服务B的锁给删除掉,所以,我们必须保证 获取锁 -> 判断锁 -> 删除锁 的操作是原子性的才可以,解决方案可以使用Redis+Lua脚本来解决一致性问题
String script = "if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1]) else return 0 end";
- redis.call(‘get’, KEYS[1]) :是调用redis的get命令,key可以通过参数传入
- == ARGV[1] :意思是是否和 某个值相等,这里的值也可以参数传入
- then return redis.call(‘del’, KEYS[1]) :如果相等就执行 redis.call('del', KEYS[1]) 删除操作
- else return 0 end :否则就返回 0
如果我们把数据带入KEYS[1]的值为“lock_stock”,ARGV[1]的值为UUID如“xoxoxo”,所以大概的含义是如果调用get(“lock_stock”)获取到的值 等于 “xoxoxo” ,那就调用 del(“lock_stock”),否则就返回 0 。 说白了就是把我们上面的判断锁和删除锁的动作使用Lua脚本去执行而已,现在代码可以这样写了
String uuid = UUID.randomUUID().toString();
if(jedis.set(lock_stock,uuid,"NX","EX",5) == 1){ //获取锁并设置超时
try {
业务代码
} finally {
//lua脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//执行脚本
jedis.eval(script, Collections.singletonList("lock_stock"),Collections.singletonList(uuid));
}
}
8.使用Redissoin分布式锁
自己封装Redis的分布式锁是很麻烦的,我们可以使用Redissoin来实现分布式锁,Redissoin已经封装好了。
- 可重入锁;reentrantLock,同一个线程重复获取锁
- 公平锁:按照请求顺序获取锁
- 读写锁:共享锁和排他锁
- 红锁:解决集群模式中脑裂问题,需要一半以上主节点加锁成功才能获取到锁
- 联锁:将多个锁连到一起,获取到所有锁,才能获取到真正的锁
- 闭锁:多个任务,都可以看到执行的结果,当都完成了,才释放锁
- 信号量:可以维护一个整数,通常用来做数据的记录
- 可过期信号量:在信号量的基础上,多了一个过期时间
扩展:
- Redisson加锁自动有过期时间30s,监控锁的看门狗发现业务没执行完,会自动进行锁的续期(重回30s),这样做的好处是防止在程序执行期间锁自动过期被删除问题
- 当业务执行完成不再给锁续期,即使没有手动释放锁,锁的过期时间到了也会自动释放锁
8.1Redisson执行流程
如果没有设置过期时间,Redisson以 30s 作为锁的默认过期时间,获取锁成功后(底层也用到了Lua脚本保证原子性)会开启一个定时任务定时进行锁过期时间续约,即每次都把过期时间设置成 30s,定时任务 10s执行一次(看门狗)
如果设置了过期时间,直接把设定的过期时间作为锁的过期时间,然后使用Lua脚本获取锁,没获取到锁的线程会while自旋重入不停地尝试获取锁
这里需要注意,rLock.lock(10, TimeUnit.SECONDS)指定了解锁时间,Redisson就不会再自动续期,那么如果在线程A业务还没执行完就自动解锁了,这时候线程B获取到锁,继续执行业务,那么等线程A业务执行完释放锁就可能会把线程B的锁删除,当然这种情况Redisson会报异常,但是这种情况是没有把所有线程都锁住的,所以如果要手动设定过期时间需要让过期时间比业务逻辑执行的时间长才对
8.2Redisson集成
8.2.1导入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
8.2.2编写配置类
@Configuration
public class RedissonConfig {
//创建客户端
@Bean
public RedissonClient redissonClient(RedisProperties redisProperties){
Config config = new Config();
config.useSingleServer().setAddress("redis://"+redisProperties.getHost()+":"+redisProperties.getPort()).setPassword(redisProperties.getPassword());
return Redisson.create(config);
}
}
8.2.3可重入锁(Reentrant Lock)
@Autowired
private RedissonClient redissonClient;
@Test
public void testLock1(){
RLock rLock = redissonClient.getLock("lock_stock");
rLock.lock(); //阻塞式等待,过期时间30s
try{
System.out.println("加锁成功....");
System.out.println("执行业务....");
}finally {
rLock.unlock();
System.out.println("释放锁....");
}
}
8.3红锁(RedLock)
Redis常用的方式有单节点、主从模式、哨兵模式、集群模式,在后三种模式中可能会出现 ,异步数据丢失,脑裂问题,Redis官方提供了解决方案:RedLock,RedLock是基于redis实现的分布式
锁,它能够保证以下特性:
- 容错性:只要多数节点的redis实例正常运行就能够对外提供服务,加锁释放锁
- 互斥性:只能有一个客户端能获取锁,即使发生了网络分区或者客户端宕机,也不会发生死锁
基于Redis的Redisson红锁RedissonRedLock对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个RLock对象关联为一个红锁,每个RLock对象实例可以来自于不同的Redisson实例
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
lock.unlock();