基于redis的分布式锁实现

1.背景介绍

随着微服务的发展,越来越多的系统架构由原来的单体结构拆分出多个小型的组件,部署在分布式环境中,分布式环境中的数据一致性是微服务架构需要关注和解决的问题,因此分布式锁应运而生,常用的分布式锁的实现方式有redis、zookeeper等,本篇博客主要讲述基于redis的分布式锁,主要介绍不同版本redis分布式锁的实现方式,以及它们的弊端

2.各个版本redis分布式锁的实现

1)基本版本

tryLock(){
    if(SETNX Key value) {
        EXPIRE Key Seconds
        return 1; 
    }
    return 0;
}

release(){
    DELETE Key
}

这个版本的redis分布式锁是最简单,也是最常用的版本,主要利用setnx命令的特性:key不存在时将key设置为value并返回1,当key存在时setnx什么都不做并返回0,为了防止持有锁的服务宕机,造成锁无法释放,给key加上了过期时间。

缺点:a)在redis中不能保证setnx和expire命令的原子性,如果执行完setnx命令后应用异常,将会导致锁无法过期。redis2.6版本增加了lua脚本,可以通过lua脚本保证多条命令的原子性,以此进行解决;b)如果有解锁操作,可能会存在一个线程删除另一个线程的锁,比如:C1成功获取到了锁,之后C1由于任务执行过长,没有在锁失效前主动释放锁,C2在C1锁超时后获得了锁,如果C1相对于C2先执行完,则会释放C2的锁;c)有数据不一致问题,如果C1在过期之前没有执行完任务,C2在C1过期之后获取了锁,这个时候C1和C2就会同时执行,会因重复导致数据不一致问题

2)基于getset命令的版本

tryLock(){  
    long currentTime = System.currentTimeMillis();
    newExpireTime = currentTime + expireSeconds
    if(SETNX Key newExpireTime == 1){
        reture 1;
    } else {
         oldExpireTime = GET(Key)
         if(oldExpireTime < currentTime){
              currentExpireTime = GETSET(Key,newExpireTime)
              if(currentExpireTime == oldExpireTime){
                return 1;
              }else{
                return 0;
              }
          }
    }
}
release(){  
    long currentTime = System.currentTimeMillis();
    oldExpireTime = GET(Key)
    if(oldExpireTime > currentTime)
        DELETE key
}

这个版本的分布式锁主要利用getset(key, value)命令的特性:获取key的旧值,并将value设置成新值,当key不存在,返回nil。

加锁的具体过程:先通过setnx(key, expireTime)获取锁,如果获取锁失败,通过get(key)返回的时间戳检测锁是否过期,如果锁过期,通过getset(key, newExpireTime)将key设置新的过期时间,检测getset返回的旧值是否等于get返回的值,相等则认为获取锁成功

缺点:这个版本的分布式锁解决了加锁和设置过期时间原子性问题,但是并没有解决误删锁和数据一致性问题,同时在锁竞争比较强烈的情况,会出现key的value不断被覆盖,设想client1和client2竞争锁,client1获得了锁,client2锁执行getset操作加长了client1锁的过期时间,如果client1没有正确释放锁,其他client就需要等待更长的时间 

3)基于2.6版本redis的分布式锁

//获取锁(unique_value可以是UUID等)
SET key unique_value px 30000 NX

//释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

redis2.6版本增加了对lua脚本的支持,同时redis2.6.12新增了命令: SET key value [EX seconds] [PX milliseconds] [NX|XX]  该命令提供的NX参数,等同于SETNX命令,这样使得set key unique_value px 3000 nx替代setnx + expire需要分两次执行命令的方式,保证了原子性。

加锁的注意事项:a)使用该命令进行加锁时,需要保证value的唯一性,可以使用UUID.randomUUID().toString方法生成,用来标识这把锁是属于哪个客户端的,在解锁时就可以根据这个uuid进行删除可以; b)释放锁时需要验证value,防止误解锁 ;c)通过Lua脚本进行CAS操作,因为在解锁时涉及多个redis操作,保证操作的原子性

缺点:这个版本的分布式锁解决了加锁和设置过期时间原子性问题,也解决了误删锁问题,但是没有解决数据一致性问题

上述三种实现方式都存在的风险:1)如果redis是集群部署,客户端A从master获得锁,在master将锁同步到slave之前,master宕机了,主从切换,slave节点被晋级成master节点之后,这个客户端A的锁就会丢失掉,这样其他客户端就能拿到这个锁 2)如果任务的执行时间大于过期时间它们都不能自行延长过期时间,如果能够自动增加延期时间,就可以保证任务执行的唯一性,进而保证数据的一致性

3.redission分布式锁原理

redision的源码中大量使用了lua脚本进行操作redis命令,保证复杂业务逻辑执行的原子性,同时redission中增加了自动延期机制,增加任务的执行时间,这样通过任务时间的自行延长,保证同一时刻只有一个客户端执行,进而保证数据的一致性

1)加锁机制

if (redis.call('exists', KEYS[1]) == 0) then 
        redis.call('hset', KEYS[1], ARGV[2], 1);
         redis.call('pexpire', KEYS[1], ARGV[1]); 
         return nil;
          end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
        redis.call('hincrby', KEYS[1], ARGV[2], 1);
        redis.call('pexpire', KEYS[1], ARGV[1]); 
        return nil;
        end;
return redis.call('pttl', KEYS[1]);

上面是加锁的lua代码,下面是lua字段的解释:

KEYS[1]:表示你加锁的那个key,比如RLock lock = redisson.getLock(“myLock”) ,这里的“myLock”就是你加锁的那个key
ARGV[1]:表示锁的有效期,默认30s
ARGV[2]:表示加锁的客户端ID,比如:8743c9c0-0795-4907-87fd-6c719a6b4586:1

代码解释:a)lua中的第一个if语句用来判断你要加锁的那个key是否存在,如果不存在你就可以直接加锁。加锁过程:首先通过hset命令:hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1给当前客户端加锁,接着通过pexpire设置过期时间,默认是30秒;b)第二条if语句会执行“exists mylock”,判断当前客户端是否是加锁状态,如果是加锁状态,则执行命令:hincrby myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1 对当前客户端的加锁次数累加1,满足重入锁的机制;c)如果上面两个if条件都没有满足,当前客户端会获取到“pttl mylock”返回的一个数字,这个数字代表了“mylock”这个锁key的剩余生存时间

2)解锁机制

if (redis.call('exists', KEYS[1]) == 0) then
       redis.call('publish', KEYS[2], ARGV[1]);
        return 1; 
        end;
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
     return nil;
     end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
if (counter > 0) then
     redis.call('pexpire', KEYS[1], ARGV[2]); 
     return 0; 
else redis.call('del', KEYS[1]); 
     redis.call('publish', KEYS[2], ARGV[1]); 
     return 1;
     end;
return nil;

参数解释:

KEYS[1]:和加锁机制的KEYS[1]是相同的意思,表示你要加锁的key
KEYS[2]:表示订阅频道channel,相当于mq中topic,用于消息的发布
ARGV[1]:表示消息信息,用于消息发布
ARGV[2]:表示过期时间,和加锁机制一致
ARGV[3]:表示客户端ID,和加锁机制一致

解锁机制的过程的业务逻辑也是非常简单的,就是每次对“mylock”的加锁次数减1,如果发现加锁次数为0,说明这个客户端已经不再持有这个锁了,此时就会执行“del mylock”命令,并同时向ARGV[1]表示的channel发送释放锁消息

3)watch dog(看门狗)自动延期机制

客户端持有锁的默认生存时间是30秒,客户端一旦持有锁成功,就会启动一个watch dog,它是一个后台线程,会每隔10秒钟检测客户端是否还持有锁,如果持有锁,那么就会重置生存时间为30秒。客户端的默认生存时间可以通过修改Config.lockWatchdogTimeout来另行指定,同时watch dog的自动检测时间也会根据这个默认生存时间的变化而变化。

缺点:redission分布式锁并没有解决redis集群环境下,master将锁同步到slave之前,master宕机带来的锁丢失问题 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值