Redis 分布式锁

服务器

浏览数:112

2020-6-21


前言

在 Java 中当多个线程同时竞争操作同一个资源的时候,如 i++ 等,为了保证结果正确性,往往需要对资源进行加锁;而在分布式环境中的,如果多个客户端同时竞争操作同一个资源,也需要进行加锁,只有获取锁的客户端才能进行操作;而在 Redis 中也提供了分布式的实现,使用的是 setnx 命令来实现,即 set key if not exist,key 不存在的时候才进行set操作,否则不会进行操作。

当获取到锁,执行完业务逻辑的时候,需要释放锁,以至于其他客户端才能有机会获取锁进而执行自己的业务逻辑,而释放锁使用的是 del(key) 命令来实现。

单Redis实例实现分布式锁

加锁

在只有单个Redis实例的环境下,使用如下命令来实现分布式锁:

 SET key_name random_value NX PX 30000

其中 key_name 表示锁的名称,所有的客户端竞争都竞争统一把锁,

random_value 是键对应的值,它应该是一个随机的值,在不同客户端的值都不同,它的作用主要是用来区分客户端的,当一个客户端在释放锁的时候,需要判断该锁是不是它加的,如果不是它加的,就不应该释放锁。

NX表示如果 key 不存在,才进行 set 操作,相当于 setnx 命令,使用了该参数,才能起到加锁的作用

PX表示 key 的过期时间,如果不设置该参数,表示该 Key永不过期,试想一下,如果不设置过期时间,一个客户端获取到锁之后,执行业务逻辑,但是在执行业务逻辑的过程中挂掉了,那这个锁是不是就不会被释放了?其他客户端相当于被一直阻塞了,所以,设置锁的过期时间是为了以防万一,锁最终一定会被释放的。

/**
 * @ Author:tsmyk0715
 * @ Date:Created in 下午 5:17 2018/8/18 0018
 */
public class RedisDistributeLock {
    /**
     * 获取锁
     * @param aJedis Redis 客户端
     * @param aKey 锁
     * @param aRandomValue 随机值,标识客户端
     * @param expireTime 过期时间
     * @return true:获取到锁, false:获取不到锁
     */
    public boolean tryLock(Jedis aJedis, String aKey, String aRandomValue, int aExpireTime)
    {
        String result = aJedis.set(aKey, aRandomValue, "NX", "PX", aExpireTime);
        if("OK".equals(result))
        {
            return true;
        }
        return false;
    }
}

在 Redis 的老版本中,由于 set 命令没有 nx 参数,所以可能会使用 setnx 和 expire 命令来获取分布式锁,由于两个命令并不是原子操作,所有会出现并发的问题.

解锁

当获取锁的客户端执行完业务逻辑的时候,就需要释放锁,在释放锁的时候,需要判断是否是自己加的锁,如果不是自己加的锁,就不能释放掉其他客户端加的锁,判断是不是自己加的锁,是通过加锁时设置的随机数 value 来判断的,释放锁的时候,需要使用 LUA 脚本来实现,如下所示:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

Java 代码如下:

    /**
     * 释放锁
     * @param aJedis Redis 客户端
     * @param aKey 锁
     * @param aClientId 客户端ID,即在获取锁时设置的随机数value
     * @return
     */
    public boolean unlock(Jedis aJedis, String aKey, String aClientId)
    {
        String luaScript = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then " +
                "return redis.call(\"del\",KEYS[1]) " +
                "else return 0 " +
                "end";
        Object result = aJedis.eval(luaScript, Collections.singletonList(aKey), Collections.singletonList(aClientId));
        if(result.equals(1L))
        {
            return true;
        }
        return false;
    }

使用这种方式释放锁可以避免删除别的客户端获取成功的锁。举个例子:客户端A取得资源锁,但是紧接着被一个其他操作阻塞了,当客户端A运行完毕其他操作后要释放锁时,原来的锁早已超时并且被Redis自动释放,并且在这期间资源锁又被客户端B再次获取到。如果仅使用DEL命令将key删除,那么这种情况就会把客户端B的锁给删除掉。使用Lua脚本就不会存在这种情况,因为脚本仅会删除value等于客户端A的value的key(value相当于客户端的一个签名)

多Redis实例实现分布式锁

以上只是在只有单个 Redis 实例的情况下锁的获取,而如果在具有多个 Redis 实例或者节点的情况,是如何获取到锁呢?在 Redis 的分布式环境中,如果存在多个 Redis 节点,而且这些节点不存在主从复制机制,在这种环境下,是通过 RedLock 算法来获取 Redis  锁,。

RedLock 算法的原理就是能从这些 Redis 节点中的大部分节点(N/2 + 1)成功获取到锁,就代表客户端获取到锁;比如有 5 个 Redis 节点,客户端能中 3 个 Redis 节点中成功获取到锁,就表示该客户端成功获取到锁。

为了取到锁,客户端应该执行以下操作:

  1. 获取当前Unix时间,以毫秒为单位。
  2. 依次尝试从N个实例,使用相同的key和随机值获取锁。为了避免服务器端 Redis 挂掉的时候,客户端还在等待的情况, 客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
  3. 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2 + 1)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。

当获取到锁的客户端执行完业务逻辑或获取锁失败的时候,就需要在每个节点上释放锁

当客户端获取锁失败的时候,应该在随机时间后在进行重新获取锁,随机时间可以避免多个客户端同时竞争锁的情况

关于如何在多个 Redis 节点中获取锁,可以参考 redisson 和 Redisson 分布式锁浅析

 

 

 

 

作者:TSMYK