基于Redis的分布式锁实现方案

传统方式

利用setnx+expire命令(错误做法)

Redis的setnx命令

setnx key value

将key设置为value,当键不存在时,才能成功,若键存在,什么也不做,成功返回1,失败返回0 。 setnx实际上就是SET IF NOT Exists的缩写。

expire命令

expire key timeout

将key的超时时间设置为timeout。成功返回1,失败返回0。

代码如下。

public boolean tryLock(String key,String requset,int timeout) {
    Long result = jedis.setnx(key, requset);
    // 当result=1时,设置成功,否则设置失败
    if (result == 1L) {
        return jedis.expire(key, timeout) == 1L;
    } else {
        return false;
    }
}

但是这种方案是由问题的,因为setnx和expire命令是两个操作,不具有原子性。当上锁之后设置超时之前服务器宕机,那么锁将无法过期。

利用lua脚本

既然setnx和expire命令不是原子性的,那么久利用lua脚本来保证其原子性。代码如下

public boolean tryLock_with_lua(String key, String UniqueId, int seconds) {
    String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
            "redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
    List<String> keys = new ArrayList<>();
    List<String> values = new ArrayList<>();
    keys.add(key);
    values.add(UniqueId);
    values.add(String.valueOf(seconds));
    Object result = jedis.eval(lua_scripts, keys, values);
    //判断是否成功
    return result.equals(1L);
}

利用set key value px milliseconds nx

set key value [EX seconds][PX milliseconds][NX|XX]

EX seconds:过期时间,单位为秒

PX milliseconds:过期时间,单位为毫秒

NX:仅当key不存在时设置值

XX:仅当key存在时设置值

加锁代码如下

public boolean tryLock_with_set(String key, String UniqueId, int seconds) {
    return "OK".equals(jedis.set(key, UniqueId, "NX", "EX", seconds));
}

释放锁代码如下,使用lua脚本的方式,尽量保证原子性。

public boolean releaseLock_with_lua(String key,String value) {
    String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
            "return redis.call('del',KEYS[1]) else return 0 end";
    return jedis.eval(luaScript, Collections.singletonList(key), \
                      Collections.singletonList(value)).equals(1L);
}

需要注意的地方是,1)value必须要具有唯一性,2)释放锁时要验证value值,避免误解锁

下面解释下为什么。

如果返回值是一个固定值,那么

  1. 客户端A获取锁成功
  2. A在某个操作上阻塞太长时间
  3. A设置的key过期了,锁被自动释放
  4. 客户端B获取到了同一个资源的锁
  5. A从阻塞中恢复,因为value一样,所以释放锁时就会把B的锁释放掉,造成问题。

所以释放锁的时候需要对vlaue进行验证,判断是不是自己的锁。value可以使用uuid来做。

但是这种方式也会存在问题。它最大的缺点是加锁时只作用在一个Redis节点上,即使Redis使用Sentinel保证高可用,如果master节点由于某些原因宕机,导致主从切换,就会发生锁丢失的情况。具体如下

  1. 客户端A在master上拿到了锁
  2. master还没来的及同步到slave节点
  3. master宕机,主从转移,slave节点升级为master节点
  4. 锁丢失

Redlock

针对上面所说的这种情况,Redis作者antirez提出了一种丰高级的分布式锁实现方式:Redlock。

加锁算法大致如下

在Redis分布式环境中,假设有N个Redis master,这些master完全互相独立,不存在主从复制或其他集群协调机制。(可以是N个Redis单master实例,也可以是N个Cluster集群,但不能是一个Cluster有5个master节点。)

  1. 获取当前系统时间(毫秒)。
  2. 按顺序依次向N个Redis节点发送获取锁的操作。这个操作和前面说的基于单机的获取锁的方式相同(set key value px milliseconds nx),其中,key相同,value唯一但不相同。同时也要设置一个超时时间,这个时间要远小于锁的有效时间,一般为几十毫秒。客户端在超时时间内等不到Redis的回复则说明获取锁失败(可能Redis宕机),应立即尝试向下一个Redis节点获取锁。
  3. 计算整个获取锁的过程消耗的多少时间。计算方法为当前时间减去第一步获取到的时间。如果从大多数Redis节点(>=N/2+1)获取到了锁且获取锁的总时间没有超过锁的有效时间,那么客户端才认为最终获取锁成功,否则认为获取锁失败。
  4. 如果锁获取成功,那么这个锁的有效时间应该重新计算,算法为最初设置的锁的有效时间减去第三部计算出来的获取锁的消耗的时间。
  5. 如果最终获取锁失败(可能由于获取到的锁的Redis节点个数少于N/2+1,或者整个获取锁的消耗时间超过了锁的有效时间),那么客户端应该立即向所有Redis几点发起释放锁的操作(即使没有从对应的Redis节点获取到锁也要发送释放锁的操作。操作内容和单机版的一样)。

解锁算法很简单,客户端向所有Redis节点发起释放锁操作,不论这些节点在获取锁时是否成功(和获取锁失败的处理方式一致)。

这么做确实提高了可用性,但是也会存在一些问题。

假设一个5个Redis节点,A,B,C,D,E。

  1. 客户端1从A,B,C上获取锁成功
  2. 节点C崩溃,但是崩溃前还没来的及持久化,丢失了
  3. 节点C重启后,客户端2从C,D,E上获取锁,获取成功

在默认情况下,Redis的AOF持久化方式是每秒写一次磁盘(即执行fsync),因此最坏情况下可能丢失1秒的数据。为了尽可能不丢数据,Redis允许设置成每次修改数据都进行fsync,但这会降低性能。当然,即使执行了fsync也仍然有可能丢失数据(这取决于系统而不是Redis的实现)。所以,上面分析的由于节点重启引发的锁失效问题,总是有可能出现的。为了应对这一问题,antirez又提出了延迟重启(delayed restarts)的概念。也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。

 

最后一个细节,为什么解锁的时候需要向所有Redis发送解锁请求?

因为可能出现这么一种情况,客户端请求的锁在Redis上成功执行,但是返回的响应包缺丢失了。导致客户端认为Redis没加锁,但是Redis加了锁的情况。

展开阅读全文
©️2020 CSDN 皮肤主题: 大白 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值