redis分布式锁的实现(1)- 分布式锁的设计理论

1,703 阅读6分钟

分布式锁是什么

分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现 如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往通过互斥来防止彼此干扰。

redis分布式锁实现的三要素

加锁

使用setnx命令加锁,key是锁的唯一标识,可以根据业务来命名,value为当前线程的ID或者UUID(后面介绍原因) 比如扣减商品库存,key可是 lock_stock_upc ,value可以为当前线程ID。

  • setnx(key,value):
    • 当且仅当 key 不存在时,set 一个 key 为 val 的字符串,返回 1,此时说明加锁成功。
    • 若 key 存在,则什么都不做,返回 【0】加锁,此时说明加锁失败。

锁超时

如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程再也别想进来。那么这个锁就永远被无法获取到 所以,我们需要给setnx的key必须设置一个超时时间,以保证在异常情况下即使锁没有被显式释放,这把锁也要在一定时间后自动释放。

释放锁

当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行del指令, del(key)释放锁之后,其他线程就可以继续执行setnx命令来获得锁。

实现分布式锁的问题

死锁

由于set和expire的非原子性,会导致一个异常情况

  • 服务器宕机
    • 程序刚获取到锁,还没有设置超时时间,这个时候服务器宕机啦,那么锁没来得及释放,其他服务端永远获取不到锁。
  • redis 宕机
    • redis获取到锁之后,还没设置过期时间,redis服务挂了,那这个时候也会导致锁无法被释放,其他服务无法获取到锁

所以要保证SETNX和SETEX(设置过期时间)这2个命令一起执行,要么都成功,要么都失败,保证其原子性。

误删其他线程的锁

  • 假如某线程成功得到了锁,并且设置的超时时间是30秒。如果某些原因导致线程B执行的很慢很慢,过了30秒都没执行完,这时候锁过期自动释放,线程B得到了锁。

  • 线程A执行完了任务,线程A接着执行del指令来释放锁。但这时候线程B还没执行完,线程A实际上删除的是线程B加的锁。

怎么避免这种情况呢?可以在del释放锁之前做一个判断,验证当前的锁是不是自己加的锁。

至于具体的实现,可以在加锁的时候把当前的线程ID当做value,并在删除之前验证key对应的value是不是自己线程的ID。 这就是前面说的设置value的时候要设置成uuid或者线程ID的原因。

if(threadId .equals(redisClient.get(key))){
    del(key)
}

然而由于,if判断和释放锁是两个独立操作,不是原子性,所以采用Lua脚本释放锁。后面介绍实现。

分布式锁设计方案

通过以上可知,要想实现一个可靠的分布式锁,设计锁的时候需要考虑一下要素:

  1. 获取锁的时候,使用 setnx(SETNX key val:当且仅当 key 不存在时,set 一个 key 为 val 的字符串,返回 1;
  2. 若 key 存在,则什么都不做,返回 【0】加锁,锁的 value 值为当前占有锁服务器内网IP编号拼接任务标识
  3. 在释放锁的时候进行判断。并使用 expire 命令为锁添 加一个超时时间,超过该时间则自动释放锁。 4 .返回1则成功获取锁。还设置一个获取的超时时间, 若超过这个时间则放弃获取锁。setex(key,value,expire)过期以秒为单位 5 .释放锁的时候,判断是不是该锁(即Value为当前服务器内网IP编号拼接任务标识),若是该锁,则执行 delete 进行锁释放
  4. 设置锁的时候要保证set 和expire(key, 30)这2个命令的原子性,
  5. 释放锁的时候要保证删除的当前线程ID的锁,要保证if(threadId .equals(redisClient.get(key))) 和 del(key)的原子性。
  6. setnx指令本身是不支持传入超时时间的,Redis2.6.12以上版本为set指令增加了可选参数,伪代码如下:set(key,1,30,NX)

加锁

SET key value NX PX 30000
value是由客户端生成的一个随机字符串,相当于是客户端持有锁的标志
NX表示只有key值不存在的时候才能SET成功,相当于只有第一个请求的客户端才能获得锁
PX 30000表示这个锁有一个30秒的自动过期时间。

解锁

为了防止客户端1获得的锁,被客户端2给释放,采用下面的Lua脚本来释放锁
if redis.call("get",KEYS[1]) == ARGV[1] then
   return redis.call("del",KEYS[1])
else
   return 0
end

redis分布式锁的不靠谱

假如在redis sentinel集群中,我们具有多台redis,他们之间有着主从的关系,例如一主二从。我们的set命令对应的数据写到主库,然后同步到从库。当我们申请一个锁的时候,对应就是一条命令 setnx mykey myvalue ,在redis sentinel集群中,这条命令先是落到了主库。假设这时主库down了,而这条数据还没来得及同步到从库,sentinel将从库中的一台选举为主库了。这时,我们的新主库中并没有mykey这条数据,若此时另外一个client执行 setnx mykey hisvalue , 也会成功,即也能得到锁。这就意味着,此时有两个client获得了锁。这不是我们希望看到的,虽然这个情况发生的记录很小,只会在主从failover的时候才会发生,大多数情况下、大多数系统都可以容忍,但是不是所有的系统都能容忍这种瑕疵。

redlock

为了解决故障转移情况下的缺陷,Antirez 发明了 Redlock 算法,使用redlock算法,需要多个redis实例,加锁的时候,它会想多半节点发送 setex mykey myvalue 命令,只要过半节点成功了,那么就算加锁成功了。释放锁的时候需要想所有节点发送del命令。这是一种基于【大多数都同意】的一种机制。我们可以选择已有的开源实现,python有redlock-py,java 中有Redisson redlock。

redlock确实解决了上面所说的“不靠谱的情况”。但是,它解决问题的同时,也带来了代价。你需要多个redis实例,你需要引入新的库 代码也得调整,性能上也会有损。所以,果然是不存在“完美的解决方案”,我们更需要的是能够根据实际的情况和条件把问题解决了就好。