Redis分布式锁

分布式锁


命令:

SET lock_name my_random_value NX PX 30000
这个指令的含义是在键“lock_name”不存在时,设置键的值,到期时间为30秒。我们通过该命令就能实现加锁功能。
SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]
	EX seconds − 设置到期时间(秒为单位)。
	PX milliseconds - 设置到期时间(毫秒为单位)。
	NX - 仅在键不存在时设置键。
	XX - 只有在键已存在时才设置。

解锁通过del命令即可触发,完整指令如下:

del lock_name

对该指令做一个解释:
•在加锁时为锁设置过期时间,当过期时间到达,Redis 会自动删除对应的 Key-Value,从而避免死锁。
注意,这个过期时间需要结合具体业务综合评估设置,以保证锁的持有者能够在过期时间之内执行完相关操作并释放锁。
•正常执行完毕,未到达锁过期时间,通过del lock_name主动释放锁。

解锁:
•通过Lua脚本执行解锁
•通过使用Redis的事务功能,通过 Redis 事务功能,利用 Watch 命令监控锁对应的 Key实现可靠解锁

Jedis jedis = null;     
try {
    jedis = jedisPool.getResource();
    // 监控锁对应的Key,如果其它的客户端对这个Key进行了更改,那么本次事务会被取消。
    jedis.watch(lockName);
    // 成功获取锁,则操作公共资源执行自定义流程
    // ...自定义流程代码省略...

    // 校验是否持有锁  检查到事务持有锁后  再执行 multi.del(lockName);
    if (lockValue.equals(jedis.get(lockName))) {
        // 开启事务功能,
        Transaction multi = jedis.multi();
        // 释放锁
        multi.del(lockName);
        // 执行事务(如果其它的客户端对这个Key进行了更改,那么本次事务会被取消,不会执行)
        // 如果正常执行,由于只有一个删除操作,返回的list将只有一个对象。
        List<Object> result = multi.exec();
        if (RELEASE_SUCCESS.equals(result.size())) {
            return true;
        }
    }
}
catch (Exception e) {
    throw e;
}
finally {
    if (null != jedis) {
        jedis.unwatch();
        jedis.close();
    }
}

根据代码实现,我们总结下通过Redis的事务功能监控并释放锁的步骤:
1.首先通过 Watch 命令监控锁对应的 key(lockName)。当事务开启后,如果其它的客户端对这个 Key 进行了更改,那么本次事务会被取消而不会执行 jedis.watch(lockName)。
2.开启事务功能,代码:jedis.multi()
3.执行释放锁操作。当事务开启后,释放锁的操作便是事务中的一个元素且隶属于该事务,代码:multi.del(lockName);
4.执行事务,代码: multi.exec();
5.最后对资源进行释放,代码 jedis.unwatch();jedis.close();

Redission分布式锁

        基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的口。
一种常见的使用方式如下: RLock lock = redisson.getLock(“anyLock”); lock.lock();
        当储存这个分布式锁的Redisson节点宕机以后,且这个锁刚好是锁住的状态时,会出现锁死的情况。为了避免这种死锁情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,提供锁续约能力,不断的延长锁的有效期。
        默认情况下,看门狗的检查锁的超时时间是30秒钟,这个具体的值可以通过修改Config.lockWatchdogTimeout来另行指定。Redisson还提供了显式进行锁过期时间制定的接口,超过该时间便会对锁进行自动解锁,代码如下:

// 显式制定解锁时间,无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
    ...
} finally {
    lock.unlock();
}

        Redisson还提供了异步方式的分布式锁执行方法,由于用的不多,此处不再赘述,感兴趣的同学可以自行查看官方文档。
        这里还要补充一下,Redisson的分布式锁实现的优点之一,在于它的RLock对象完全符合Java的Lock规范,RLock实现了JUC的Lock接口,之所以称之为可重入锁在于只有拥有锁的进程才能解锁,当其他进程解锁则会抛出IllegalMonitorStateException错误。

Redisson分布式锁–公平锁(Fair Lock)

        基于Redis的Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock接口的一种RLock对象。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
常见的Redisson公平锁使用方式如下:

RLock fairLock = redisson.getFairLock("anyLock");
// 最常见的使用方法
fairLock.lock();

        公平锁实现同样具有自动续约的能力,该能力也是通过看门狗实现,与上文提到的重入锁RLock原理完全相同。下文中提到的锁类型也具有该能力,因此不再赘述,读者只要记住,这些类型的锁都能通过看门狗实现锁自动续约,且看门狗检查锁超时时间默认为30s,该参数可以通过修改Config.lockWatchdogTimeout自行配置。

Redisson分布式锁–联锁(MultiLock)

        基于Redis的Redisson分布式联锁RedissonMultiLock对象可以将多个RLock对象关联为一个联锁,每个RLock对象实例可以来自于不同的Redisson实例。
这种锁类型挺有意思的,它为我们提供了多重锁机制,当所有的锁均加锁成功,才认为成功,调用的代码如下,(个人认为使用场景并不算多,因此作为了解即可)

RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 所有的锁都上锁成功才算成功。
lock.lock();
...
lock.unlock();

Redisson分布式锁–红锁(RedLock)

        红锁是Redisson实现的一种高可用的分布式锁实现,因此此处对红锁做一个较为详细的展开。基于Redis的Redisson红锁RedissonRedLock对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个RLock对象关联为一个红锁,每个RLock对象实例可以来自于不同的Redisson实例。
        基于上文对红锁的概述,我们可以得知,红锁是一个复合锁,且每一个锁的实例是位于不同的Redisson实例上的。

Config config1 = new Config();
config1.useSingleServer().setAddress("redis://172.0.0.1:5378").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);

Config config2 = new Config();
config2.useSingleServer().setAddress("redis://172.0.0.1:5379").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);

Config config3 = new Config();
config3.useSingleServer().setAddress("redis://172.0.0.1:5380").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);

/**
 * 获取多个 RLock 对象
 */
RLock lock1 = redissonClient1.getLock(lockKey);
RLock lock2 = redissonClient2.getLock(lockKey);
RLock lock3 = redissonClient3.getLock(lockKey);

/**
 * 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
 */
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);


RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
if(redissonLock.isLocked() &&redissonLock.isHeldByCurrentThread()){
    redissonLock.unlock();
}

                在分布式版本的算法里我们假设我们有N个Redis master节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。我们已经描述了如何在单节点环境下安全地获取和释放锁。因此我们理所当然地应当用这个方法在每个单节点里来获取和释放锁。在我们的例子里面我们把N设成5,这个数字是一个相对比较合理的数值,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:
1、获取当前时间(单位是毫秒)。
2、轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
3、客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
4、如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
5、如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。
我们可以从中提取出红锁实现的关键点:半数以上节点获取锁成功,才认为加锁成功,某个节点超时就去下一个继续获取。这里体现出分布式领域解决一致性的一种常用思路:多数派思想。这种思想在Raft算法、Zab算法、Paxos算法中都有所体现。

Redisson分布式锁–读写锁(ReadWriteLock)

        Redisson同样实现了java.util.concurrent.locks.ReadWriteLock接口,使得其具有了读写锁能力。其中,读锁和写锁都继承了RLock接口。同上述的锁一样,读写锁同样是分布式的。
分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。

RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();

        Redisson同时还实现了分布式AQS同步器组件,如:分布式信号量(RSemaphore)、可过期行分布式信号量(RPermitExpirableSemaphore)、分布式闭锁(RCountDownLatch)等,由于本文主要讲解锁相关的内容,因此不再进行展开介绍,感兴趣的同学可以自行查看官方文档及源码。


     在分布式版本的算法里我们假设我们有N个Redis master节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。我们已经描述了如何在单节点环境下安全地获取和释放锁。因此我们理所当然地应当用这个方法在每个单节点里来获取和释放锁。在我们的例子里面我们把N设成5,这个数字是一个相对比较合理的数值,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:
1、获取当前时间(单位是毫秒)。
2、轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
3、客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
4、如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
5、如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。

Redis分布式锁的缺点

        其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。
但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。时系统在业务语义上一定会出现问题,导致各种脏数据的产生。所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁

几种不安全的加锁方式

非原子性操作
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhuge"); //jedis.setnx(k,v)
stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);

当服务宕机  部署了微服务的代码层面 根本没有走到finally这块,没办法保证解锁  
这个key还没有被删除 需要有lockkey的过期时间,设置过期时间的  也要给时间续期
当一个业务处理时间比较长,锁的时间已经过期,就会存在这一个锁会在执行finally时将下一个线程的锁释放,导致释放的不是自己的锁
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
    stringRedisTemplate.delete(lockKey);
}

释放锁

使用luna脚本
Jedis jedis = RedisUtil.getJedis();
String script =
        "if redis.call('EXISTS',KEYS[1]) ==1 and redis.call('GET',KEYS[1])==ARGV[1]  "+
         "then"+
         	 "  return redis.call('del',KEYS[1]) "+
         "else"+
              " return 0 "+
         "end";
Object result2 = jedis.eval(script, Collections.singletonList("name"),Collections.singletonList("111"));
使用redis自身事务
while (true){
   if(stringRedisTemplate.opsForValue().get("lockKey".equalsIgnoreCase("")){
        stringRedisTemplate.setEnableTransactionSupport(true);
        stringRedisTemplate.multi();
        stringRedisTemplate.delete("lockKey");
        List<Object>list = stringRedisTemplate.exec();
        if(list==null){
            continue;
        }
        stringRedisTemplate.unwatch();
        break;
   }
}

Luna脚本

        Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。使用脚本的好处如下:
1、减少网络开销:本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。这点跟管道类似
2、原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的,不过redis的批量操作命令(类似mset)是原子的。
3、替代redis的事务功能:redis自带的事务功能很鸡肋,报错不支持回滚,而redis的lua脚本几乎实现了常规的事务功能,支持报错回滚操作,官方推荐如果要使用redis的事务功能可以用redis lua替代。
官网文档上有这样一段话:A Redis script is transactional by definition, so everything you can do with a Redis transaction, you can also do with a script, and usually the script will be both simpler and faster.
luna脚本执行命令:

EVAL script numkeys key [key ...] arg [arg ...] 

script参数是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一个Lua函数。
numkeys参数用于指定键名参数的个数。
键名参数 key [key ...] 从EVAL的第三个参数开始算起,表示在脚本中所用到的那些Redis键(key),这些键名参数可以在 Lua中通过全局变量KEYS数组,用1为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。
在命令的最后,那些不是键名参数的附加参数 arg [arg ...] ,可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)

在 Lua 脚本中,可以使用**redis.call()**函数来执行Redis命令

 String script = " local count = redis.call('get', KEYS[1]) " +
                    " local a = tonumber(count) " +
                    " local b = tonumber(ARGV[1]) " +
                    " if a >= b then " +
                    "   redis.call('set', KEYS[1], a-b) " +
                    "   return 1 " +
                    " end " +
                    " return 0 ";
Object obj = jedis.eval(script, Arrays.asList("product_stock_10016"), Arrays.asList("10"));

管道(Pipeline)

        客户端可以一次性发送多个请求而不用等待服务器的响应,待所有命令都发送完后再一次性读取服务的响应,这样可以极大的降低多条命令执行的网络传输开销,管道执行多条命令的网络开销实际上只相当于一次命令执行的网络开销。需要注意到是用pipeline方式打包命令发送,redis必须在处理完所有命令前先缓存起所有命令的处理结果
        打包的命令越多,缓存消耗内存也越多。所以并不是打包的命令越多越好。pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在此后的响应中得到信息;也就是pipeline并不是表达“所有command都一起成功”的语义,管道中前面命令失败,后面命令不会有影响,继续执行。

Redis管道处理 设置管道模式

src/redis‐cli ‐h 127.0.0.1 ‐a password ‐ p 6379 ‐‐pipe
 //管道的命令执行方式:cat redis.txt | redis-cli -h 127.0.0.1 -a password - p 6379 --pipe
Pipeline pl = jedis.pipelined();
for (int i = 0; i < 10; i++) {
     pl.incr("pipelineKey");
     pl.set("zhuge" + i, "zhuge");
     //模拟管道报错
     pl.setbit("zhuge", -1, true);
 }
List<Object> results = pl.syncAndReturnAll();

注意:不要在Lua脚本中出现死循环和耗时的运算,否则redis会阻塞,将不接受其他的命令, 所以使用时要注意不能出现死循环、耗时的运算。redis是单进程、单线程执行脚本。管道不会阻塞redis。

参考资料:

redis分布式锁场景分析:Redis分布式锁的使用与实现原理 - 林本托 - 博客园
每秒上千订单场景下的分布式锁:每秒上千订单场景下的分布式锁高并发优化实践!【石杉的架构笔记】
redis分布式锁底层原理:拜托,面试请不要再问我Redis分布式锁的实现原理【石杉的架构笔记】
redis红锁:红锁的实现 - IT路上的小白 - 博客园

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员路同学

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值