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

Redis分布式锁的前世今生

时间:2023-03-12 03:01:05 科技观察

背景当今的服务大多是集群部署的,这使得synchronized、ReentrantLock等传统的本地锁无用武之地。因此需要引入第三方服务来实现这些并发进程的调度,从而控制对共享资源的访问,如redis、zookeeper、mysql等,其中以redis应用最为广泛。分布式锁的两个最重要的元素是独占性和容错性。排他性是指在分布式应用集群中,同一个方法在同一时间只能由一台机器上的一个线程执行。容错是指无论是正常的业务执行完成,还是突然的程序崩溃、网络中断,都要保证分布式锁最终会被释放,不会出现死锁现象。redis分布式锁的基本命令1、锁定SETNX键值setnx,意思是SETifNotExists。有两个参数setnx(key,value)。这个方法是一个原子操作。如果key不存在,则当前key设置成功,返回1;如果当前键已经存在,则当前键设置失败并返回0。2.Unlockdel(key)获得锁的线程完成任务,需要释放锁,以便其他Thread进入。3.配置锁超时时间expire(key,30s)。如果客户端崩溃或网络中断,资源将永远被锁定,即死锁。所以需要为key配置过期时间,保证即使没有显式释放锁也不会被释放。也会在一定时间后自动释放。OK,有了以上的理论基础,我们就可以逐步揭开redis分布式锁的神秘面纱了。我们以常见的库存抵扣场景为例。当有线程执行扣库存的方法时,一般的逻辑是先判断当前库存。如果还有库存,则库存减1,然后生成明细记录。问题多多的一把锁先来看一段伪代码。methodA(){//ID为666的商品库存扣除keyStringkey="stock:deduct:666"if(setnx(key,1)==1){expire(key,10,TimeUnit.SECONDS)try{//查询是否有库存//扣除库存//生成详细记录}finally{del(key)}}else{//获取锁失败,休眠100毫秒,然后自旋调用该方法methodA()}}这段代码的主要逻辑是先锁定ID为666的商品库存,然后设置key的过期时间为10秒,然后执行扣除库存的逻辑。业务逻辑执行完成后,删除key释放锁。在此期间,如果有其他线程去获取锁,就会导致锁失败。失败后会再次调用methodA方法继续尝试加锁,然后循环往复,直到加锁成功。看来大功告成了,所谓的分布式锁也不过如此。然而,正如我们在标题中所写的那样,这是一把有很多问题的锁。有什么问题?首先,最大的问题是多条命令不是原子操作。setnx和expire之间有两个步骤要执行。如果setnx成功了,但是expire执行失败,或者执行前突然崩溃,就会导致这个资源死锁,这就违反了我们上面说的。容错原则。另一个存在的问题是线程A可能会删除线程B的锁。假设有两个线程A和B,A先加锁成功开始执行业务逻辑,但是由于某些原因,A的执行速度很慢,需要15秒才执行完毕,但是锁A的有效时间仅为10秒。A的锁到期后,B成功加锁,但是B还没有执行完业务逻辑。线程A完成业务逻辑的执行,执行锁删除操作。这时候删除的其实是B的锁,B的锁删除后,就不能阻止其他线程了。线程要加锁,违反了上面提到的排他性原则。如何解决这两个问题?优化锁的第一个问题是,由于多条命令不是原子操作,我们可以使用一条命令,redis也提供了这样一条命令setex,就是在赋值的时候设置过期时间,这是一个原子操作命令。对应java,也有这样一个API供我们使用:redisTemplate.opsForValue().setIfAbsent("key","success",10,TimeUnit.SECONDS)第二个问题是在删除之前进行判断和验证lock当前要删除的锁是否是自己的锁,实现方法也很简单,可以将值设置为当前线程ID或者任意UUID。优化后的伪代码应该是这样的:methodA(){//Id666产品库存扣除键Stringkey="stock:deduct:666";字符串值=Thread.currentThread().getId();if(setex(key,10,value)==1){try{//查询是否有库存//扣除库存//生成明细记录}finally{if(get(key).equals(value)){del(key)}}}else{//获取锁失败,sleep100毫秒,然后自旋调用这个方法methodA()}}这个锁一直没问题吗?不过仔细一看,还是会发现不对劲。虽然我们在删除锁的时候做了判断,但是还是有可能删错锁的。根本原因是判断锁和删除锁也不是原子操作。那么如何保证绝对原子性呢?lua脚本的诞生这里先不深究什么是lua脚本。我们只需要知道lua是一种脚本语言即可。redis在执行lua脚本的时候,会将其中的命令整体执行,或者全部执行成功。要么出现异常,结果没有更新到redis。因此,对于上面的锁删除操作,我们可以将判断命令和删除命令都放在lua脚本中,然后代码执行lua脚本,最终实现我们想要的原子操作。其实这正是redis官方推荐的。详情请参考官方文档:set命令——Redis中国用户组(CRUG)。下面是一段在java中调用lua脚本的代码。看完之后可以加深理解:Stringscript="ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end";Integerresult=redisTemplate.execute(newDefaultRedisScript<>(script,Integer.class),Arrays.asList(lockKey),uuid);其中脚本代码是官方文档中提供的,可以直接复制使用。基于上面提到的原生分布式锁,一个完整的原生分布式锁应该是这样的:methodA(){//ProductinventorydeductionkeywithID666Stringkey="stock:deduct:666";字符串值=Thread.currentThread().getId();Stringscript="ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1]])elsereturn0end";if(setex(key,10,value)==1){try{//查询是否有库存//扣除库存//生成明细记录}finally{//unlockLongresult=redisTemplate.execute(newDefaultRedisScript<>(script,Long.class),Arrays.asList(lockKey),uuid);}}else{//获取锁失败,休眠100毫秒,然后自旋调用这个方法methodA()}}这是一个比较完整的分布式锁,既满足了共享资源的并发控制,又保证了加锁和解锁的原子操作,防止突发事件引起的死锁问题。这里我们再思考一个问题,如何避免业务执行时间过长导致锁过期的问题?为了保证独占性,必须保证锁在业务执行时间内一定不能过期。在原来的分布式锁中,没有什么好的办法,只能增加锁的过期时间来保证业务可以执行。那么,有更好的解决方案吗?嗨,我叫雷迪森。Redisson是redis官方推荐的分布式锁框架。它可以帮助我们解决上述所有问题。底层也是由lua脚本实现的,同时也提供看门狗(watchdog)机制。当锁即将到期时,会自动检测业务执行是否完成。如果没有,锁到期时间会自动延长,直到业务执行完成。而且最重要的一点是使用起来非常简单,几行代码就可以搞定。不像原生锁那么麻烦,是我们分布式锁开发的最佳选择。这里就不详细描述了,有兴趣的可以上网搜索一下。好了,这就是关于redis分布式锁的全部内容。