Redis 分布式锁

Redis 分布式锁特性:

  1. 互斥。在任何给定时刻,只有一个客户端可以持有锁。
  2. 无死锁。最终,即使锁定资源的客户端崩溃或分区,也始终可以获得锁定。
  3. 容错能力。只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁。

单例 Redis 分布式锁

获取锁

使用以下方法:

SET resource_name my_random_value NX PX 30000

该命令仅在密钥不存在时才设置密钥(NX选项),并且到期时间为30000毫秒(PX选项)。密钥设置为随机值。该值在所有客户端和所有锁定请求中必须是唯一的。

释放锁

基本上,使用随机值是为了以安全的方式释放锁,并且脚本会告诉Redis:仅当密钥存在且存储在密钥上的值恰好是我期望的值时,才删除该密钥。这是通过以下Lua脚本完成的:

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

为了避免删除另一个客户端创建的锁,这一点很重要。

简单实现代码:


import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.params.SetParams;

import java.util.Collections;

public class RedisLock {

    private JedisPool jedisPool;
    public static final String LOCK_KEY = "lock";
    public static final int LOCK_EXPIRES = 5000;
    public static final long MAX_WAIT = 20000;
    public static final SetParams LOCK_PARAMS = SetParams.setParams().nx().px(LOCK_EXPIRES);
    public static final String RELEASE_LOCK_SCRIPT =
            "if redis.call('get',KEYS[1]) == ARGV[1] then" +
            "   return redis.call('del',KEYS[1]) " +
            "else" +
            "   return 0 " +
            "end";

    public RedisLock() {
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(1024);
        jedisPoolConfig.setMaxIdle(100);
        jedisPoolConfig.setMaxWaitMillis(100);
        jedisPoolConfig.setTestOnBorrow(false);
        jedisPoolConfig.setTestOnReturn(true);
        jedisPool = new JedisPool(jedisPoolConfig, "127.0.0.1", 6379, 10000);
    }

    public boolean getLock(String lockId) {
        long start = System.currentTimeMillis();
        try (Jedis jedis = jedisPool.getResource()) {
            for (;;) {
                String result = jedis.set(LOCK_KEY, lockId, LOCK_PARAMS);
                if ("OK".equals(result)) {
                    return true;
                }
                if (System.currentTimeMillis() - start > MAX_WAIT) {
                    return false;
                }
                Thread.sleep(10);
            }
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    public boolean releaseLock(String lockId) {
        try (Jedis jedis = jedisPool.getResource()) {
            Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(LOCK_KEY), Collections.singletonList(lockId));
            return "1".equals(result.toString());
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
}

多实例 Redis 分布式锁

单实例的分布式锁,如果遇到特殊情况,redis 实例不能提供服务,则客户端就不能继续服务。多实例的分布式锁可以用来解决这个问题。

假设存在N个 redis master 实例。

操作:

  1. 它以毫秒为单位获取当前时间。
  2. 它尝试在所有N个实例中依次使用所有实例中相同的键名和随机值来获取锁定。在第2步中,在每个实例中设置锁时,客户端使用的超时时间小于总锁自动释放时间,以便获取该超时时间。例如,如果自动释放时间为10秒,则超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点通信时保持阻塞:如果一个实例不可用,我们应该尝试与下一个实例尽快通信。
  3. 客户端通过从当前时间中减去在步骤1中获得的时间戳,来计算获取锁所花费的时间。 仅当从超过一半的实例中成功获取锁,并且获取锁所花费的总时间小于锁有效时间,则认为已获取锁。
  4. 如果获取了锁,则将其有效时间视为初始有效时间减去经过的时间,如步骤3中所计算。
  5. 如果客户端由于某种原因(无法锁定N / 2 + 1实例或有效时间为负数)而未能获得该锁,它将尝试解锁所有实例(即使它认为不是该实例)能够锁定)。

使用于Java 的分布式锁客户端的实现 Redisson

存在的问题

1. 有效时间设置多长,假如我的业务操作比有效时间长,我的业务代码还没执行完就自动给我解锁了,不就完蛋了吗?

  • 第一种解决方法就是靠程序员自己去把握,预估一下业务代码需要执行的时间,然后设置有效期时间比执行时间长一些,保证不会因为自动解锁影响到客户端业务代码的执行。但是可能会影响执行效率,而且也不是万全之策,比如网络抖动这种情况是无法预测的,也有可能导致业务代码执行的时间变长,所以并不安全。
  • 第二种方法比较靠谱一点,就是给锁续期。在Redisson框架实现分布式锁的思路,就使用watchDog机制实现锁的续期。当加锁成功后,同时开启守护线程,默认有效期是30秒,每隔10秒就会给锁续期到30秒,只要持有锁的客户端没有宕机,就能保证一直持有锁,直到业务代码执行完毕由客户端自己解锁,如果宕机了自然就在有效期失效后自动解锁。

2. 这个锁只能加一次,不可重入?

在Redisson实现可重入锁的思路,使用Redis的哈希表存储可重入次数,当加锁成功后,使用hset命令,value(重入次数)则是1。

"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; "

如果同一个客户端再次加锁成功,则使用hincrby自增加一。

"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"

解锁时,先判断可重复次数是否大于0,大于0则减一,否则删除键值,释放锁资源。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}

3. 上面的加锁方法是加锁后立即返回加锁结果,如果加锁失败的情况下,总不可能一直轮询尝试加锁,直到加锁成功为止,这样太过耗费性能。所以需要利用发布订阅的机制进行优化。

步骤如下:

当加锁失败后,订阅锁释放的消息,自身进入阻塞状态。

当持有锁的客户端释放锁的时候,发布锁释放的消息。

当进入阻塞等待的其他客户端收到锁释放的消息后,解除阻塞等待状态,再次尝试加锁。

4. 如下图,如果Client1 在获取到锁之后,遇到了长时间的GC,所有线程被挂起,时间超过了锁的过期时间,就有可能导致两个客户端都获取到锁并操作资源。

详细看撕比现场 :https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html  http://antirez.com/news/101

所谓的Stop the World机制,简称STW,即在执行垃圾收集算法时,Java应用程序的其他所有除了垃圾收集收集器线程之外的线程都被挂起

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值