当前位置: 首页 > 科技观察

使用Redis搭建高性能锁

时间:2023-03-13 13:28:48 科技观察

背景:笔者所在的公司,在上周末大促之后,系统暴露了这样一个问题:分布式锁使用的zk锁,由于大促那天用户量比较大,系统疯狂加锁和释放锁。***zk承受不了这么大的压力,崩溃了。由于618即将到来,为了避免类似的事情再次发生,公司决定将所有系统的zk锁换成高性能的Redis锁。这里简单提一下zk锁性能低于redis的原因:zk中的角色分为leader和flower,每次写请求只能请求leader,leader会把写请求广播给所有花朵。它将提交给领导。其实这相当于一个2PC流程。加锁时,是写请求。当写请求很多的时候,zk会有很大的压力,会导致服务器响应很慢。主题:什么时候需要加锁?当多个线程和用户同时竞争同一个资源时,就需要锁。比如下单减库存、抢票、选课、抢红包等,这里如果没有锁控,就会出现严重的问题。下单时如果不加锁减少库存,会造成产品超卖;如果抢票时不锁,两个人抢同一个位置;选课时没有锁控,导致选课成功人数大于教室座位数;抢红包时无锁控,抢红包数量大于实际红包数量。什么是分布式锁?研究过JAVA多线程的朋友都知道,为了防止多个线程同时执行同一段代码,可以通过synchronized关键字或者JAVAAPI中的ReentrantLock类来控制。但目前几乎任何系统都经常部署多台机器。单机部署的应用很少。当Thread1线程获取到锁并执行锁中的代码时,其他线程或其他机器再次请求锁,发现锁被Thread1占用,加锁失败。Thread1释放锁后,其他线程就可以获取锁并执行相应的操作。我们可以使用Jedis中的setnx命令来构建这把锁。首先列举一些错误的加锁方式:错误示例1Longlock=jedis.setnx(key,value);if(lock>0){//executebusinessLogic}通过setnx命令创建key和value。如果key不存在,则加锁成功。这有什么问题?如果加锁操作成功,在释放锁时系统会崩溃,导致key永远无法删除。也就是说,其他线程始终无法获取到锁,从而导致死锁。为了避免这种情况,请看下面的代码错误示例2Longlock=jedis.setnx(key,value);if(lock>0){jedis.expire(key,expireTime);}和上面的例子类似,唯一不同的是这里多了一个设置密钥过期时间的步骤。如果在del过程中系统崩溃,Redis会在过期时间到时删除key。其他线程可以重新获取锁。这样安全吗?这里有一个问题。如果第一次setnx成功后突然断网,expire命令执行失败,同样存在死锁风险。这两个步骤不是原子的,不能保证全部成功或全部失败。正确的构建方式returnfalse;}参数说明:key:keyvalue:valuenx:如果当前key存在,则设置失败,否则成功ex:设置key的过期时间expireTime:key的过期时间,时间到了,Redis会自动删除key和value。此命令将上述错误示例2中的两个操作合并为一个原子操作,同时保证成功或失败。解锁方法:错误示例1:jedis.del(key);执行这个操作的线程在不判断锁的所有者的情况下删除了锁。还记得set命令可以设置值吗?获取锁的时候主要是判断key是否存在,那么value有什么用呢?如果在删除锁的时候不判断当前的锁拥有者,任何线程都可以释放锁。这个时候,value值就发挥作用了。错误示例2:if(value==jedis.get(key)){jedis.del(key);}在加锁的时候,我们可以将value设置为唯一标识当前线程的值。这个值可以是一个UUID,在释放锁的时候,判断这个值和设置的时候的值是否相同,如果相同,说明加锁和释放锁是同一个线程,并且释放是允许的。否则,释放锁失败。这能绝对安全吗??答案当然是否定的。这一步也不是原子的。如果ThreadA执行value==jedis.get(key)后返回true,del命令还没来执行,key过期,此时ThreadB获取锁,然后ThreadA执行del命令锁上ThreadB的锁发布。所以为了保证这两个操作的原子性,我们不得不使用一个简单的Lua脚本。正确解锁姿势:publicstaticbooleanreleaseDistributedLock(Jedisjedis,StringlockKey,StringrequestId){Stringscript="ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end";Objectresult=jedis.eval(script,Collections.singletonList(lockKey),Collections.singletonList(requestId));if(RELEASE_SUCCESS.equals(result)){returntrue;}returnfalse;}Redis在2.6Interpreter之后嵌入Lua脚本,所以我们通过一个简单的Lua脚本就可以保证以上操作的原子性。代码中Lua脚本的意思是:我们将LockKey赋给KEYS[1],将RequestId赋给ARGV[1]。如果键中的值等于RequestId,则返回true,否则返回false。这样就保证了锁释放操作是原子的,当前客户端只会释放当前客户端的锁。