关于分布式锁:redis分布式锁以及会出现的问题

52次阅读

共计 3887 个字符,预计需要花费 10 分钟才能阅读完成。

一、redis 实现分布式锁的次要原理:

1. 加锁
最简略的办法是应用 setnx 命令。key 是锁的惟一标识,按业务来决定命名。比方想要给一种商品的秒杀流动加锁,能够给 key 命名为“lock_sale_商品 ID”。而 value 设置成什么呢?咱们能够权且设置成 1。加锁的伪代码如下:
setnx(key,1)
当一个线程执行 setnx 返回 1,阐明 key 本来不存在,该线程胜利失去了锁;当一个线程执行 setnx 返回 0,阐明 key 曾经存在,该线程抢锁失败。
2. 解锁

有加锁就得有解锁。当失去锁的线程执行完工作,须要开释锁,以便其余线程能够进入。开释锁的最简略形式是执行 del 指令,伪代码如下:
del(key)
开释锁之后,其余线程就能够继续执行 setnx 命令来取得锁。
3. 锁超时
锁超时是什么意思呢?如果一个失去锁的线程在执行工作的过程中挂掉,来不及显式地开释锁,这块资源将会永远被锁住,别的线程再也别想进来。
所以,setnx 的 key 必须设置一个超时工夫,以保障即便没有被显式开释,这把锁也要在肯定工夫后主动开释。setnx 不反对超时参数,所以须要额定的指令,伪代码如下:
expire(key,30)

二、加锁的代码

/**
 * 尝试获取分布式锁
 * @param jedis Redis 客户端
 * @param lockKey 锁
 * @param requestId 申请标识
 * @param expireTime 超期工夫
 * @return 是否获取胜利
 */
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在这里程序忽然解体,则无奈设置过期工夫,将产生死锁
        jedis.expire(lockKey, expireTime);
    }

}

下面的代码有一个致命的问题,就是加锁和设置过期工夫不是原子操作。
那么会有两种极其状况:
一种是在并发状况下,两个线程同时执行 setnx,那么失去的后果都是 1,这样两个线程同时拿到了锁。
别一种是如代码正文所示,即执行完 setnx,程序解体没有执行过期工夫,那这把锁就永远不会被开释,造成了死锁。
之所以有人这样实现,是因为低版本的 jedis 并不反对多参数的 set()办法。正确的代码如下:

/**
 * 尝试获取分布式锁
 * @param jedis Redis 客户端
 * @param lockKey 锁
 * @param requestId 申请标识
 * @param expireTime 超期工夫
 * @return 是否获取胜利
 */
public static boolean tryGetDistributedLock(Jedis jedis,String lockKey, String requestId, int expireTime) {String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
        if ("OK".equals(result)) {return true;}
        return false;

}

这个 set()办法一共有五个形参:

第一个为 key,咱们应用 key 来当锁,因为 key 是惟一的。

第二个为 value,咱们传的是 requestId,很多童鞋可能不明确,有 key 作为锁不就够了吗,为什么还要用到 value?起因就是,通过给 value 赋值为 requestId,咱们就晓得这把锁是哪个申请加的了,在解锁的时候就能够有根据。requestId 能够应用 UUID.randomUUID().toString() 办法生成。

第三个为 nxxx,这个参数咱们填的是 NX,意思是 SET IF NOT EXIST,即当 key 不存在时,咱们进行 set 操作;若 key 曾经存在,则不做任何操作;

第四个为 expx,这个参数咱们传的是 PX,意思是咱们要给这个 key 加一个过期的设置,具体工夫由第五个参数决定。

第五个为 time,与第四个参数相响应,代表 key 的过期工夫。

总的来说,执行下面的 set()办法就只会导致两种后果:1. 以后没有锁(key 不存在),那么就进行加锁操作,并对锁设置个有效期,同时 value 示意加锁的客户端。2. 已有锁存在,不做任何操作。

二、解锁的代码

public static void wrongReleaseLock1(Jedis jedis, String lockKey) {jedis.del(lockKey);
}

这段代码的问题是容易导致误删,如果某线程胜利失去了锁,并且设置的超时工夫是 30 秒。如果某些起因导致线程 A 执行的很慢很慢,过了 30 秒都没执行完,这时候锁过期主动开释,线程 B 失去了锁。
随后,线程 A 执行完了工作,线程 A 接着执行 del 指令来开释锁。但这时候线程 B 还没执行完,线程 A 实际上删除的是线程 B 加的锁
怎么防止这种状况呢?能够在 del 开释锁之前做一个判断,验证以后的锁是不是本人加的锁。
至于具体的实现,能够在加锁的时候把以后的线程 ID 当做 value,并在删除之前验证 key 对应的 value 是不是本人线程的 ID。

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
        
    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此时,这把锁忽然不是这个客户端的,则会误会锁
        jedis.del(lockKey);
    }

}

然而,这样做又隐含了一个新的问题,判断和开释锁是两个独立操作,不是原子性
解决方案就是应用 lua 脚本,把它变成原子操作,代码如下:

public class RedisTool {

    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 开释分布式锁
     * @param jedis Redis 客户端
     * @param lockKey 锁
     * @param requestId 申请标识
     * @return 是否开释胜利
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {return true;}
        return false;

    }

}

三、续约问题

下面加锁最初的代码就完满了吗?假想这样一个场景,如果过期工夫为 30S,A 线程超过 30S 还没执行完,然而主动过期了。这时候 B 线程就会再拿到锁,造成了同时有两个线程持有锁。这个问题能够归结为”续约“问题,即 A 没执行完时应该过期工夫续约,执行实现能力开释锁。怎么办呢?咱们能够让取得锁的线程开启一个 守护线程 ,用来给快要过期的锁“续约”。
其实,前面解锁呈现的删除非本人锁,也属于“续约”问题。

四、集群同步提早问题

用于 redis 的服务必定不能是单机,因为单机就不是高可用了,一量挂掉整个分布式锁就没用了。
在集群场景下,如果 A 在 master 拿到了锁, 在没有把数据同步到 slave 时,master 挂掉了。B 再拿锁就会从 slave 拿锁,而且会拿到。又呈现了两个线程同时拿到锁。
基于以上的思考,Redis 的作者也思考到这个问题,他提出了一个 RedLock 的算法。
这个算法的意思大略是这样的:假如 Redis 的部署模式是 Redis Cluster,总共有 5 个 Master 节点。
通过以下步骤获取一把锁:

  • 获取以后工夫戳,单位是毫秒。
  • 轮流尝试在每个 Master 节点上创立锁,过期工夫设置较短,个别就几十毫秒。
  • 尝试在大多数节点上建设一个锁,比方 5 个节点就要求是 3 个节点(n / 2 +1)。
  • 客户端计算建设好锁的工夫,如果建设锁的工夫小于超时工夫,就算建设胜利了。
  • 要是锁建设失败了,那么就顺次删除这个锁。
  • 只有他人建设了一把分布式锁,你就得一直轮询去尝试获取锁。

然而这样的这种算法还是颇具争议的,可能还会存在不少的问题,无奈保障加锁的过程肯定正确。
这个问题的根本原因就是 redis 的集群属于 AP,分布式锁属于 CP,用 AP 去实现 CP 是不可能的。

五、Redisson

Redisson 是架设在 Redis 根底上的一个 Java 驻内存数据网格(In-Memory Data Grid)。充沛的利用了 Redis 键值数据库提供的一系列劣势,基于 Java 实用工具包中罕用接口,为使用者提供了一系列具备分布式个性的常用工具类。
Redisson 通过 lua 脚本解决了下面的原子性问题,通过“看门狗”解决了续约问题,然而它应该解决不了集群中的同步提早问题。

总结

redis 分布式锁的计划,无论用何种形式实现都会有续约问题与集群同步提早问题。总的来说,是一个不太靠谱的计划。如果谋求高正确率,不能采纳这种计划。
然而它也有长处,就是比较简单,在某些非严格要求的场景是能够应用的,比方社交零碎一类,交易系统一类不能呈现反复交易则不倡议用。

正文完
 0