架构师进阶之四redis实现分布式锁redission

1. setNX实现方式

一般的setNX实现方式,在设置key之后,需要设置超时时间,防止死锁。另外设置key和设置超时必须是一个原子操作。

这种方式缺点:

1.1 Fail over 

因为是把key写到master 节点,无论是主从结构还是cluster集群模式,存在failover的时候,key丢失的问题

1.2 不可重入

如果业务多个地方需要用一个key,可能死锁,尤其是一些递归的场景,习惯用jdk的sychronize和lock锁,容易忽略这一点,因为这两者都是可重入的

1.3. 不具备阻塞性

在通常情况下这些锁都具备两个共性:一是互斥性,二是阻塞性。互斥性是指在任何时刻最多只能有一个线程获得通行的资格。阻塞性是指的在有竞争的情况下,未获取到资源的线程会停止继续操作,直到成功获取到资源或取消操作。很显然setnx命令只提供了互斥的特性,却没有提供阻塞的能力。虽然在业务代码里可以引入自旋机制来进行再次获取,但这仅仅是把原本应该在锁里实现的功能搬到了业务代码里,通过增加业务代码的复杂程度来简化锁的实现似乎显得有点南辕北辙。

1.4.不支持续约

在分布式环境中,为了保证锁的活性和避免程序宕机造成的死锁现象,分布式锁往往会引入一个失效时间,超过这个时间则认为自动解锁。这样的设计前提是开发人员对这个自动解锁时间的粒度有一个很好的把握,太短了可能会出现任务没做完锁就失效了,而太长了在出现程序宕机或业务节点挂掉时,其它节点需要等很长时间才能恢复,而难以保证业务的SLA。setnx的设计缺乏一个延续有效期的续约机制,无法保证业务能够先工作做完再解锁,也不能确保在某个程序宕机或业务节点挂掉的时候,其它节点能够很快的恢复业务处理能力

Redisson的分布式锁在满足以上三个基本要求的同时还增加了线程安全的特点。利用Redis的Hash结构作为储存单元,将业务指定的名称作为key,将随机UUID和线程ID作为field,最后将加锁的次数作为value来储存。同时UUID作为锁的实例变量保存在客户端。将UUID和线程ID作为标签在运行多个线程同时使用同一个锁的实例时,仍然保证了操作的独立性,满足了线程安全的要求。

hash结构:

redis_key------uuid:threadId  1

业务key---------随机uuid:当前线程id   加锁次数(可重入)

2. Redssion实现方式

redission 框架支持多种redis 部署方法,包括单节点,主从模式,集群模式,哨兵模式等等。

2.1 RedLock算法实现思路

一个客户端需要做如下操作来获取锁:

1.获取当前时间(单位是毫秒)

2.轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点

3.客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了

4.如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间

5.如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。

2.2 代码分析

加锁时通过Lua脚本先检查锁是否存在,如不存在则创建hash相关字段并设定过期时间后返回,这表示加锁成功。如果该hash字段已经存在,再检查随机字段和线程id是否一致。如果一致则递增value的值并重新更新过期时间后返回,此时表示同一节点同一线程再次成功加锁,从而保证了可重入性。如果hash存在且字段不一致,说明其他节点或线程已经拥有了这个锁。因此Lua脚本返回这个hash的当前有效期。当结果返回到在客户端后,如果加锁成功,则通过线程池依照设定好的参数定时执行续约,最后通知请求线程继续后续操作。如果加锁没有成功,则监听一个以这个key为后缀的pubsub频道,直到收到解锁消息后再次重试

image

解锁时通过Lua脚本先检查锁是否存在,如果已经不存在则直接发布解锁消息并返回。如果任然存在则检查标签是否存在,如果不存在则表示这个锁并不为本线程所拥有,这种情况请求线程将收到报错。如果存在则表示该锁正是被该线程所拥有。在这种情况下,递减标签字段后判断,如果返回的加锁数量仍然大于0,说明当前的锁仍然有效,仅仅只是重入次数减少了。相反这表示锁已经完全解开,则立即删除该锁并发布解锁信息

image

3 RedLock算法问题

虽然Redlock的算法提供了高可用的特性,但建立在大多数可见原则的前提下,这样的算法适用性仍然有一定局限。Redisson为此提供了基于增强型的算法的高可用分布式联锁RedissonMultiLock。这种算法要求客户端必须成功获取全部节点的锁才被视为加锁成功,从而更进一步提高了算法的可靠性。

 public void testRedLock(RedissonClient redisson1,
                              RedissonClient redisson2, RedissonClient redisson3){

        RLock lock1 = redisson1.getLock("lock1");
        RLock lock2 = redisson2.getLock("lock2");
        RLock lock3 = redisson3.getLock("lock3");

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

            // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
            boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);

        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

    }
public void testMultiLock(RedissonClient redisson1,
                              RedissonClient redisson2, RedissonClient redisson3){

        RLock lock1 = redisson1.getLock("lock1");
        RLock lock2 = redisson2.getLock("lock2");
        RLock lock3 = redisson3.getLock("lock3");

        RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);

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

            // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
            boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);

        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

    }

4 Redisson相比Jedis的优势

工控和某些IoT场景对实时处理能力要求很高,所有的信号都必须实现毫秒级响应。这类场景还具有并发量巨大的特点。与社交电商等场景不同的是这类应用场景基本没有峰谷流量,时时刻刻都是峰值。因此其它场景里常见的削峰填谷措施在这里只能加重负担。在这样的场景下如果使用像Jedis这样采用同步编程模型的客户端时,就需要随时确保并发线程数与连接数一对一,否则获取不到可用连接会直接报错。相比之下Redisson利用了Netty异步编程框架,使用了与Redis服务端结构类似的事件循环(EventLoop)式的线程池,并结合连接池的方式弹性管理连接。最终做到了使用少量的连接既可以满足对大量线程的要求,从根本上缓解线程之间的竞争关系。同时异步操作的模式还能够避免数据请求造成业务线程的阻塞

关键点:同步编程模型要求线程数和连接数一一对应。而异步编程模型不同。

参考文章:

https://yq.aliyun.com/articles/603575

5 异步编程框架

异步编程框架是并发编程的一种实现。什么情况下适合用异步编程框架呢?我们先来看看传统的编程框架。

1. 单线程串行模型

下图是最简单的单线程串行编程模型,多个任务的情况下,依次执行,如果前一个block,后面的任务需要等待。在任务数不多的情况下,不会有问题。实现也比较简单,就是依次串行处理。

2 多线程编程模型

如果请求数量比较多,依赖第一种传统的单线程串行模型,显然无法处理大量的请求。而且如果在多核的CPU情况下,无法利用多线程的优势。所以我们经常会利用多线程去并行处理多个任务。虽然有时候所谓的并行执行在单cpu的情况下其实是cpu的调度,但是从编程角度,我们仍然把并行编程理解为下面的结构,因为如果在多核cpu的情况下,的确是并行执行的。并行编程可以同时执行多个任务,必须要必须等待某个任务执行。但是需要对共享资源进行并发控制,加锁机制,线程阻塞需要进行context switch,而且线程的创建和销毁都是需要消耗资源的。

多线程编程模型图如下:

什么情况下,多线程模型是最有优势的呢?比如3个线程都执行一项操作,三个任务耗时都很长,这种情况下多线程的优势就没有了。因为大部分时间都是block,而且因为多线程,需要额外付出线程开销,context switch等资源操作。所以我理解多线程的编程模型适合在每个线程的任务耗时较短,不会轻易block的操作。比如一些计算操作,这些操作一般不是IO密集型操作,大多数为CPU密集型操作。

所以,多线程编程模型更加适合于CPU密集型操作。

3 异步编程

3.1 异步模型

下面到了今天的主角,异步编程。异步编程貌似和多线程差不多,但是两者是有区别的。下面是异步编程的模型图:

异步编程模型只有一个线程,任务交叉执行。一个任务放弃执行,只有两种情况,一种是当前任务执行完毕,另一个情况是当前任务阻塞。貌似异步编程模型和单线程串行模型没有什么太大区别,但是如果每个任务经常被强制等待,block,异步编程的性能优势就大大优于单线程串行编程。

在异步编程模型中,线程阻塞只会在没有任何任务执行的情况下 ,如果有任务执行就会执行可以运行的任务。这也就是为什么异步编程模型也被成为非阻塞编程。NIO也是类似的原理。从一个任务切换到另一个任务,要么是任务执行完毕,要么是任务被阻塞。对于大量可能被阻塞的任务的情况下,异步编程性能优越,很容易想到大量可能被阻塞的情况最常见的就是IO操作。

总结一下,相比于单线程串行模型,异步编程模型更适合下面的场景:

1 存在大量任务,大部分情况下至少有一个执行

2 任务执行大量IO操作,被阻塞但是其他任务仍旧能执行

3 任务执行不需要相互依赖,不需要通讯交互

为什么启用很多线程去做呢?因为如果涉及IO密集型任务,启用大量线程耗费资源,总体性能远不如异步编程模式。

3.2 事件驱动

我们了解了异步编程模型,再来回顾一下,下面这个图:

任务之间两个点:

1 没有依赖

2  前一个任务执行完成或者block才会执行其他任务。期望至少有一个任务在执行,除非所有任务都不具备执行条件

3 单一线程处理

类似下面的图,只有一个线程,在不同任务之间切换。

如果任务阻塞了,如何唤醒继续执行呢?这里就需要利用事件驱动机制。Redis服务端也是利用类似都架构,redis 服务端对libevent进行了一些封装开发。无论怎样,底层都是利用系统函数select,poll, epoll完成。

  • select。 调用select监听socket。循环调用select函数获取当前可用的socket,根据可用的事件进行具体的操作
{

select(socket);

while(1) {

  sockets = select();

  for(socket in sockets) {

   if(can_read(socket)) {

         read(socket, buffer);

         process(buffer);

}

}

}

}

利用select,我们在一个线程内处理了大量的socket请求,但是问题是还是阻塞的。因为select返回可用的socket才能继续。如何才能不阻塞呢。利用回调函数就可以。类似于我们在swing编程中,按钮点击的适合,addActionListener(new ActionListener),注册完后我们可以继续当前用户线程的工作,而不需要等待按钮被点击。这就是回调的异步作用。

用户线程使用IO多路复用模型的伪代码描述为:

void UserEventHandler::handle_event() {

if(can_read(socket)) {

read(socket, buffer);

process(buffer);

}

}

{

Reactor.register(new UserEventHandler(socket));

}

下面的代码是个事件循环模块,通过select调用判断当前可用的socket,然后回调socke上注册的hander进行逻辑处理,从而实现非阻塞操作。

Reactor::handle_events() {

while(1) {

sockets = select();

for(socket in sockets) {

get_event_handler(socket).handle_event();

}

}

}

大致流程图如下:

参考文章:

https://blog.csdn.net/happy_wu/article/details/80052617

http://cs.brown.edu/courses/cs196-5/f12/handouts/async.pdf

猜你喜欢

转载自blog.csdn.net/hanruikai/article/details/87917251