分布式锁原理初探

Posted 58无线技术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了分布式锁原理初探相关的知识,希望对你有一定的参考价值。

1 概念 
我们在开发时最常用的一个是java给我们提供的基于jvm的锁,锁的获取和释放由jvm来管理,我们只需要标注synchronized就可以。
另外还有Lock,需要显式的调用锁定和解锁。这两种锁的作用范围是一个jvm进程,也就是我们的一个系统中;
在分布式系统中,一个集群内的不同主机或者不同集群同时访问共享的资源,会出现竞态条件(两个或多个线程竞争同一资源时,如果对资源的访问顺序敏感就称为竞态条件),使用传统的锁就没有办法处理,此时就需要使用分布式锁来解决。
分布式锁主要就是解决分布式系统中共享资源的竞态条件问题。
 2 使用场景
多个应用有操作共享资源的情景
 3 需要具备的功能
先看下我们所熟悉的锁都具备什么功能
1.作为锁所要具备的最基本的功能其一是获取锁,其二是释放锁;
synchronized关键字在jvm中使用了字节码指令monitorenter和monitorexitlock来获取和释放对象锁,这两个字节码指令隐式的调用了lock和unlock操作。锁的获取和释放无需我们关心,jvm进程挂掉资源回收;
java提供的Lock需要我们显示的调用锁定和解锁。底层使用cas算法,控制原子变量的状态,来标记锁的获取与释放,
同样jvm进程挂掉资源进行回收;
分布式锁同样也需要提供获取和释放锁的功能。
2.处理死锁
3.重入性
4.锁的性能等
 4 实现方式 
方式:incr、decr 原子操作
    加锁:在需要使用的地方执行该key的incr操作,如果返回值是1,则获取锁
    解锁:在finally块中将key做decr操作
    设置过期时间:如果进程挂掉,导致锁没有释放,自动过期删除
优点:操作简单易行
缺点:
    1.设置过期时间正确
       获取锁的客户端进程在执行过程中挂掉,没有走finally块减一,那其他进程只能等redis的ttl自动删除;
       该过期时间设置的长短难以把控,如果我们的请求因为其他原因阻塞了没有处理完,但已经到了redis的过期时间,其他进程可以获得锁进行处理,结果。。。
    2.设置过期时间不正确
       设置key的自增和设置过期时间不是原子操作,假如前者设置成功了,而过期时间因为各种原因没有设置成功,一旦该锁的计数出现错误,那么所有进程都无法获取到锁,结果。

总结上面方式所存在的问题:
1.操作非原子性
2.网络中断、命令发送失败
3.死锁
4.互斥
5 实现分布式锁需要考虑的几点
1.线程和锁关系
   拿最简单的互斥锁来说
   synchronized内置锁是作用于对象,java中每个对象是唯一存在的,
   每个对象的对象头中包含获取该对象锁的线程ID,那就保证了线程对该对象锁的唯一性。     
   ReentrantLock内含Sync对象,其继承自同步器对象,同步器对象继承自     AbstractOwnableSynchronizer,该对象可以设置获取该锁的独占线程。
    从上面两种锁可以看出,锁的标识要与线程保持唯一性的关系
    不例外的分布式锁也该如此,需要明确当前锁被哪个线程占有,也就是要维护锁与线程的关系。

2.如何防止死锁的产生
    对于synchronized产生的死锁,似乎我们无能为力,即死锁状态无法解除;
    一个线程已经获得了对象锁,其他线程访问共享对象的时就必须无限期等待,不能中断那些获取锁的线程。
    因此我们编码时让线程按照相同的顺序获得一组锁进行预防。
    而Lock提供了更加灵活的方法,如果当前锁可用则返回true,否则返回false,并且可以设置获取锁的超时     时间,超时退出,防止死锁。
    对于分布式的锁第一要设置锁的超时时间,让锁能及时释放掉。
    其次还要设置客户端请求锁的超时时间,以防止通信过程出现问题,客户端线程一直等待锁响应。

3.保证互斥性和重入性
   哪一个线程可以获得锁(独占锁)?哪些线程可以获取锁(共享锁)?线程之间互斥锁与共享锁是如何保证的?
   我们先来看下传统锁是如何来处理的
    synchronized是互斥锁,只需维护互斥关系,对象唯一,锁唯一;
   重入性也是维护计数器,累加;
   Lock既有互斥锁也有共享锁
   其互斥特性是利用int变量state来判断,当然state是用volatile修饰。
   判断state是否为0,如果为0,设置当前线程为独占锁拥有者,并将state加1;
   重入特性实现也比较简单,state一直累加即可。
   redis如何保证互斥性
   使用过期时间,如果当前应用的线程获取锁的过期时间是null,设置锁的过期时间,并返回null,当然该操作是原子操作(如何保证原子性,请看下文);
    如果在该锁过期时间内,其他线程获取的过期时间不为空,也就没有获得锁。
    重入性:使用hset,设定当前锁的value值,如果重复获取锁,则累加;释放锁递减。

4.如何保证原子性
   上面的实现方式中,存在的原子性的问题。那如何来保证操作的原子性?
   貌似通过我们的java程序保证是无济于事的;
   redis在2.6版本以后,可以使用lua语言编写脚本传到redis服务端执行,
   将我们之前多次与服务端交互才能完成的功能放到一次脚本中处理,那我们所担心的原子性问题迎刃而解,还可以减少网络传输。

5.未获取锁的线程如何处理
    得到锁的线程如愿以偿的去执行临界区内代码了,那未得到锁的线程去哪里了?
    有两种方式:
    非阻塞:未获取到锁的线程一直循环看锁的持有者是否释放锁。
    优点:处理相对简单   缺点:占用cpu资源,容易产生死锁
    阻塞:未获得锁,将线程本身进行阻塞。
    优点:对线程统一管理调度   缺点:逻辑相对复杂
    Lock把未得到锁的线程封装成Node节点,放入其构造的虚拟双向队列中,该队列是FIFO队列,
    并进行阻塞操作,等待东山再起,将队列符合条件的线程调用park()方法挂起阻塞。
    当获取锁的线程释放锁时,会调用unpark()唤醒队列中第一个阻塞节点,使程序在阻塞处继续执行,

    让队列head节点的下一个节点持有的线程获得锁,并且将该节点设为head节点。过程见下图:

 

Reentrantlock 锁处理过程
Powered by pb.ishangtong.com
synchronized也是封装Node节点,构造虚拟队列,与Lock不同的是,该队列是LIFO队列,所有请求锁的线程都被放入队列中,将符合条件的线程移到另外的队列,具体过程不再赘述;
    也是采用阻塞的方式处理线程。
    那分布式redis锁该如何处理未获得到锁的线程呢?
    采用非阻塞方式,没得到锁的线程,不断的轮询来获取锁,很明显这样的方式会增加额外的无用功,
    会增加redis服务端节点的压力。
    如何进行优化?
    一个应用里面可能会有多个线程竞争该锁,可以控制应用里线程对锁的申请频率
    1.让线程sleep一段时间再请求锁,那sleep多长时间呢,时间不好把控。
    2.单个应用进程里可以使用信号量来限制,那就需要对信号量进行增减操作,来控制一定数量的线程。没有信号量的线程阻塞,直到某个线程释放锁后信号量加1,线程获得信号量后来竞争锁。那就需要监听线程释放锁的操作,如何监听呢?可以使用redis的pub/sub方式,异步处理,增加吞吐量;
     首先线程订阅某个锁的topic,如果获取不到锁,就发起sub操作,并阻塞当前线程,一旦有线程释放锁,pub消息给订阅的客户端,客户端进行信号量的处理,使阻塞的线程获取许可来竞争锁。对于某个锁有哪些线程需要,也就转移到redis服务端记录。

    具体过程如下图:

分布式锁原理初探
redis分布式锁处理过程

Powered by pb.ishangtong.com
除此之外还需要考虑网络因素,命令发送失败重试(使用netty通讯),单点因素等。

以上是关于分布式锁原理初探的主要内容,如果未能解决你的问题,请参考以下文章

Redis分布式锁Redisson原理

分布式锁02-使用Redisson实现公平锁原理

分布式锁01-使用Redisson实现可重入分布式锁原理

分布式锁原理——redis分布式锁,zookeeper分布式锁

分布式锁原理与实现(数据库rediszookeeper)

分布式锁原理探究