Redis 实现分布式锁
在分布式的系统,分布式锁是绕不开的一个话题。通过客户端访问共享资源,来实现分布式锁是目前解决分布式锁的通过解决方案,不管是redis 还是 zk 或者其他的分布式锁解决方案。但是目前所采用的分布式一般是redis 或者 zk。
基于setnx
redis 的使用是基于redis setnx 命令去做的,当key值不存在时,将key的值设置为value。但是如果ke值已经存在,则setnx不做任何动作。
- 返回1说明获得该进程的锁
- 返回0说明有其他进程已经获得锁了,进程不能如临界区。
当线程执行完之后会执行 del命令释放锁命令,这样其他线程就可以继续执行该方法
这样有一个很大的问题,就是当如果获取线程拿到锁后,客户端挂了,或者线程挂了,没有执行del命令,这样就会永久占用这把锁。产生锁的情况。解决方案就是加上超时时间
设置一个超时时间,即使线程没有被显示的释放出来,这把锁也要在一段时间后自动释放。可以使用expire 命令。
假如setnx 和 expire 不是原子操作,就会出现,setnx之后线程就挂了,这时候没有设置超时时间,就会变成死锁。所以这是要将setnx 和 expire 进行原子操作。
设置 set <lock.key> <lock.value> nx ex expireTime
这时候也会存在问题,假如设置的时间是30s,如果A线程执行执行的很慢,这时候过了30s锁自动释放了。线程B得到锁。。
这时候线程B执行一半的时间,线程A执行完了,执行了del命令,这是A删除的实际上B的锁
这种情况可以在del释放锁之前,做一个判断,验证当前锁是不是自己的。加锁的时候把当前线程的ID作为value,并在删除前验证value是不是当前线程,但是这是两个操作,要保证其原子性,就要使用Lua脚本。
锁续期 上面的情况避免掉了误删的情况,但是同一时间会有两个进程在拿到锁。我们可以让一个锁的线程开启守护线程,给快要过期的锁来续期。
假设线程A执行了29秒后还没执行完,这时候守护线程会执行expire指令,为这把锁续期20秒。守护线程从地29秒后开始执行,每20秒执行一次
当线程A执行完成,会显示关掉守护线程
如果服务器挂掉,守护线程也会跟着挂掉,这时候不会再去增加续期时间也就自动释放掉了。
基于setnx、get、getset 的分布式锁
- setnx(lockkey,当前时间+过期超时时间),如果返回1,则获取锁成功;如果返回0则没有获取到锁,转向步骤(2)
- get(lockkey)获取值oldExpireTime,并将这个value值与当前的系统时间进行比较,如果小于当前系统,则认为这个锁已经超时,可以允许别的请求重新获取转向步骤(3)
- 计算重新新的过期时间 newExpireTime = 当前时间 + 锁超时时间,然后getset(lockkey,newExpireTime)会返回当前lockey的值,currentExpireTime
- 判断currentExpireTime与oldExpireTime是否一致,如果相等,则说明当前getset设置成功,获得到了锁。如果不相等,则这个锁又被别的线程获取走了,那么当前请求可以是失败,也可以继续获取。
- 在获取到了锁之后,当前线程可以开始处理自己的业务,当处理完毕后,比较自己的处理时间和对于所设置的超时时间,如果小于锁设置的超时时间,则直接执行del命令释放锁(释放锁之前需要判断持有锁的线程是不是当前线程);如果大于锁设置的超时时间,则不需要再锁进行处理。
public boolean lock(long acquireTimeout, TimeUnit timeUnit) throws InterruptedException {
acquireTimeout = timeUnit.toMillis(acquireTimeout);
long acquireTime = acquireTimeout + System.currentTimeMillis();
//使用J.U.C的ReentrantLock
threadLock.tryLock(acquireTimeout, timeUnit);
try {
//循环尝试
while (true) {
//调用tryLock
boolean hasLock = tryLock();
if (hasLock) {
//获取锁成功
return true;
} else if (acquireTime < System.currentTimeMillis()) {
break;
}
Thread.sleep(sleepTime);
}
} finally {
if (threadLock.isHeldByCurrentThread()) {
threadLock.unlock();
}
}
return false;
}
public boolean tryLock() {
long currentTime = System.currentTimeMillis();
String expires = String.valueOf(timeout + currentTime);
//设置互斥量
if (redisHelper.setNx(mutex, expires) > 0) {
//获取锁,设置超时时间
setLockStatus(expires);
return true;
} else {
String currentLockTime = redisUtil.get(mutex);
//检查锁是否超时
if (Objects.nonNull(currentLockTime) && Long.parseLong(currentLockTime) < currentTime) {
//获取旧的锁时间并设置互斥量
String oldLockTime = redisHelper.getSet(mutex, expires);
//旧值与当前时间比较
if (Objects.nonNull(oldLockTime) && Objects.equals(oldLockTime, currentLockTime)) {
//获取锁,设置超时时间
setLockStatus(expires);
return true;
}
}
return false;
}
}
tryLock方法中,主要逻辑如下:lock调用tryLock方法,参数为获取的超时时间与单位,线程在超时时间内,获取锁操作自旋在那里,直到自旋的保持保持者释放了锁。
释放锁:
public boolean unlock() {
//只有锁的持有线程才能解锁
if (lockHolder == Thread.currentThread()) {
//判断锁是否超时,没有超时才将互斥量删除
if (lockExpiresTime > System.currentTimeMillis()) {
redisHelper.del(mutex);
logger.info("删除互斥量[{}]", mutex);
}
lockHolder = null;
logger.info("释放[{}]锁成功", mutex);
return true;
} else {
throw new IllegalMonitorStateException("没有获取到锁的线程无法执行解锁操作");
}
}
存在问题:
1.这个锁是基于System.currentTimeMillis()如果多太服务器的时间不一致,就会出现问题,但是这个是可以从运维层面规避的
2.如果一台服务器锁超时的时候,刚好会有多台服务器请求锁,会同时出现执行redis.getset()而导致过期时间覆盖的问题
3.存在多个线程同时持有锁的情况,如果线程A执行任务的时间超过锁的过期时间,这时另外一个线程就可以获得锁了,造成多个线程同时持有一把锁的情况。类似于解决方案一,可以使用锁延期的方式来解决
前面两种redis解决方案,从高可用的层面来看都是有所欠缺的,也就是说当redis是单点的情况下,当发生故障时,则整个业务分布式锁都无法使用
为了提高可用性,我们可以使用主从模式或者哨兵模式,但在这种情况下任然存在问题,从主从模式或者哨兵模式下,正常情况下,如果加锁成功了,那么master节点会异步复制给对应的slave阶段,但是如果在这个过程中发生了宕机,主从切换的时候,这个slave阶段就会变成maste节点,但是这个锁并没有同步过来,这就发生了锁的丢失,会导致多个客户端可以同时持有一把锁的问题。
基于RedLock的分布式锁
redLock 是redis的作者Antirez在单Redis节段基础上引入的高可用的模式。RedLock的加锁要结合单点分布式锁算法共同实现,因为它是RedLock的基础。
实现原理
假设现在有5个redis主节点,这样基本保证不会他们同时宕机。
- 获取当前服务器时间,以毫秒为单位,并且设置超时时间TTL
- 依次从这5个实例中获取具有相同Key和具有唯一性vaule获取锁,当redis请求获取锁时,客户端应该设置一个网络连接和超时响应时间,这个时间应该小TTL,假设TTL是5秒,这个超时时间就应该是1秒。这样就可以避免客户端死等。从而放弃这个锁。
- 获取所有的锁有的时间减去第一步的时间,就得到锁的时间,所得获取时间要小于TTL。并且至少半数以上的 Redis节点获取到锁,才算锁成功。
- 如果成功获得锁,key的真正的有效时间 = TTL-锁的获取时间-时钟漂移时间。
- 如果因为某些原因,获取锁失败,客户端应该在所有的Redis实例上进行解锁,无论redis实例是否加锁成功。
这是因为在redis返回给客户端的数据包丢失了,这时候客户端认为没有获取到锁。但实际上redis里已经有这个客户端的锁了。这时候要释放所有的锁。
- 失败重试:当client不能获取锁时,应该在随机事件后重试获取锁;同时重试锁一定要有限制
防止过多的客户端多次重试获取锁,导致批次都获取锁失效得到问题。
redLock性能及崩溃恢复的相关解决方法
由于N个节点中大多数能正常工作就能够保证RedLock的正常工作,因此理论上它的可用性更高。前面我们说的主从架构的安全问题,在RedLock中已经不存在了,但如果有发生节点重启,还是会对锁的安全性有影响的。
- 如果redis没有持久化功能,在A客户端获取到锁后,重启redis,B客户端仍然能够获取到锁,这违背了锁的排他性。
- 如果启动AOF永久化存储,事情会好些,当redis重启后,redis机制是按照服务器时间过期的,所以重启后,然后会在规定的时间内过期,不会影响业务。但是由于AOF同步到磁盘方式默认是每一秒,如果在一秒内断电,就会导致数据丢失,立即重启就会导致互斥锁失效;但如果同步磁盘的方式是使用always,会造成性能急剧下降。所以在锁的完全有效性和性能方面要有取舍。
- 为了既有有效性,有保证性能,antirez提出了延迟重启的概念,redis还是以AOF的存储方式,但是redis在崩溃后,都先不立即重启它,而是在TTL时间后再重启,这样的话,这个节点重启前所参与的锁都会过期,它在重启后就不会出现对现有锁的影响,但是在TTL这段时间内服务相当于暂停的状态。
RedLock算法的安全性
基于Redisson看门狗机制
在某些情况下,还没有程序还没有执行完,锁因为还没超时就自动释放了,就会导致多个线程同时持有锁的现象出现,而为了解决这个问题,可以进行“续锁期”。
原理
redisson在获取锁之后,会维护一个看门狗线程,当锁即将过期没有释放的时候不断延长锁的key的生存时间。
加锁机制
线程获取锁,获取成功,执行lua脚本,保存数据到redis。
线程获取锁,获取失败,一直通过while循环尝试获取锁,获取成功后执行lua脚本,保存数据到redis数据库。
watch dog自动延时机制
看门狗开启后,会对整体的性能有一定影响,所以默认情况是不启动的,如果使用redisson进行加锁的同时设置了过期时间,也会导致看门狗机制失效
redisson在获取锁之后,会维护一个看门狗线程,在每一个锁设置的过期时间的1/3处,如果线程还没任务还没有执行完,则不断延长锁的有效期。看门狗的检查超时时间默认是30秒。
一旦业务机器宕机了,看门狗线程就执行不了了,锁到期自然就释放了。