曾奇:谈谈我所认识的分布式锁

出品 | 滴滴技术
做者 | 曾奇php

图片描述

前言:随着计算机技术和工程架构的发展,微服务变得愈来愈热。现在,绝大多数服务都处于分布式环境中,其中,数据一致性是咱们一直关注的重点。分布式锁究竟是什么?通过了哪些发展演进?工程上有哪些实现方案?各类方案的利弊权衡又有哪些?但愿这篇文章可以对你有一些帮助。html

▍阅读索引node

0.名词定义
1.问题引入
2.分布式环境的特色
3.锁
4.分布式锁
5.分布式锁实现方案mysql

  • 5.1朴素Redis实现方案、朴素Redis方案小结
  • 5.2 ZooKeeper实现方案、ZooKeeper方案小结
  • 5.3 Redisson实现方案、Redission方案小结

6.总结
7.结束语
8.Referencegit

▍0. 名词定义github

分布式锁:顾名思义,是指在分布式环境下的锁,重点在锁。因此咱们先从锁开始讲起。redis

▍1. 问题引入算法

举个例子:sql

某服务记录数据X,当前值为100。A请求须要将X增长200;同时,B请求须要将X减100。 在理想的状况下,A先读取到X=100,而后X增长200,最后写入X=300。B请求接着读取到X=300,减小100,最后写入X=200。 然而在真实状况下,若是不作任何处理,则可能会出现:A和B同时读取到X=100;A写入以前B读取到X;B比A先写入等等状况。数据库

上面这个例子相信你们都很是熟悉。出现不符合预期的结果本质上是对临界资源没有作好互斥操做。互斥性问题通俗来说,就是对共享资源的抢占问题。对于共享资源争抢的正确性,锁是最经常使用的方式,其余的如CAS(compare and swap)等,这里不展开。

▍2. 分布式环境的特色

咱们的绝大部分服务都处于分布式环境中。那么,分布式系统有哪些特色呢?大体以下:

  • 可扩展性:可经过横向水平扩展提升系统的性能和吞吐量。
  • 高可靠性:高容错,即便系统中一台或几台故障,系统仍可提供服务。
  • 高并发性:各机器并行独立处理和计算。
  • 廉价高效:多台小型机而非单台高性能机。

▍3.锁

咱们先来看下非分布式状况下的锁方案(多线程和多进程的状况),而后再演进到分布式锁。

▍多线程下的锁机制:

各类语言有不一样的实现方式,比较成熟。好比,go语言中的sync.RWMutex(读写锁)、sync.Mutex(互斥锁);JAVA中的ReentrantLock、synchronized;在php中没有找到原生的支持锁的方式,只能经过外部来间接实现,好比文件锁,借助外部存储的锁等。

▍多进程下的锁机制:

对于临界资源的访问已经超出了单个进程的控制范围。在多进程的状况下,主要是利用操做系统层面的进程间通讯原理来解决临界资源的抢占问题。比较常见的一种方法即是使用信号量(Semaphores)。

▍对信号量的操做,主要是P操做(wait)和V操做(signal):

  • P操做 ( wait ) :

先检查信号量的大小,若值大于零,则将信号量减1,同时进程得到共享资源的访问权限,继续执行;若小于或者等于零,则该进程被阻塞后,进入等待队列。

  • V操做 ( signal ) :

该操做将信号量的值加1,若是有进程阻塞着等待该信号量,那么其中一个进程将被唤醒。

可看出,多进程锁方案跟多线程的锁方案实现思路大同小异。

咱们将互斥的级别拉高,分布式环境下不一样节点不一样进程或线程之间的互斥,就是分布式锁的挑战之一。后面再细讲。

另外,在传统的基于数据库的架构中,对于数据的抢占问题也能够经过数据库事务(ACID)来保证。在分布式环境中,出于对性能以及一致性敏感度的要求,使得分布式锁成为了一种比较常见而高效的解决方案。

▍从上面对于多线程和多进程锁的归纳,能够总结出锁的抽象条件:

1)“须要有存储锁的空间,而且锁的空间是能够访问到的”:

对于多线程就是内存(进程中不一样的线程均可以读写),多进程中经过共享内存的方式,也是提供一块地方,供不一样进程读写。主要目的是保证不一样的进线程改动对于其余进线程可见,进而知足互斥性需求。

2)“锁须要被惟一标识”:

不一样的共享资源,必然须要用不一样的锁进行保护,所以相应的锁必须有惟一的标识。在多线程环境中,锁能够是一个对象,那么对这个对象的引用即是这个惟一标识。多进程下,好比有名信号量,即是用硬盘中的文件名做为惟一标识。

3)“锁要有至少两种状态”:

有锁,没锁。存在,不存在等等。很好理解。

知足上述三个条件就能够实现基础的分布式锁了。可是随着技术的演进,

▍相应地,对锁也提出了更高级的条件:

1)可重入:

外层函数得到锁以后,内层函数还能够得到锁。缘由是随着软件复杂性增长,方法嵌套获取锁已经很难避免。可是从代码层面很难分析出这个问题,所以咱们要使用可重入锁。致使锁须要支持可重入的场景。对于可重入的思考,每种语言有本身的哲学和取舍,如go就舍弃了支持重入:Recursive locking in Go [ https://stackoverflow.com/que... ]之后go又会不会认为“可重入真香”呢?哈哈,咱们拭目以待。

2)避免产生惊群效应(Herd Effect):

惊群效应指,在有多个请求等待获取锁的时候,一旦占有锁的线程释放以后,全部等待方都同时被唤醒,尝试抢占锁。可是绝大多数的抢占都是没必要要的。这种状况在多线程和多进程中开销一样很大。要尽可能避免这种状况出现。

3)公平锁和非公平锁:

公平锁:优先把锁给等待时间最长的一方;非公平锁:不保证等待线程拿锁的顺序。公平锁的实现成本较高。

4)阻塞锁和自旋锁:

主要是效率的考虑。自旋锁适用于临界区操做耗时短的场景;阻塞锁适用于临界区操做耗时长的场景。

5)锁超时:

防止释放锁失败,出现死锁的状况。

6)高效,高可用:

加锁和解锁须要高效,同时也须要保证高可用防止分布式锁失效,能够增长降级。

还有不少其余更高要求的条件,不一一列举了。有兴趣的小伙伴能够看看编程史上锁的演进过程。

▍4. 分布式锁

▍使用分布式锁的必要性:

1)服务要求:部署的服务自己就处于分布式环境中

2)效率:使用分布式锁能够避免不一样节点重复相同的工做,这些工做会浪费资源。好比用户付了钱以后有可能不一样节点会发出多封短信

3)正确性:跟2)相似。若是两个节点在同一条数据上面操做,好比多个节点机器对同一个订单操做不一样的流程有可能会致使该笔订单最后状态出现错误,形成损失

包括但不限于这些必要性,在强烈地呼唤咱们今天的主角---“分布式锁”闪亮登场。

▍5. 分布式锁实现方案

有了非分布式锁的实现思路,和分布式环境的挑战,咱们来看看分布式锁的实现策略。
分布式锁本质上仍是要实现一个简单的目标---占一个“坑”,当别的节点机器也要来占时,发现已经有人占了,就只好放弃或者稍后再试。

▍大致分为4种

1)使用数据库实现
2)使用朴素Redis等缓存系统实现
3)使用ZooKeeper等分布式协调系统实现
4)使用Redisson来实现(本质上基于Redis)

由于利用mysql实现分布式锁的性能低以及改造大,咱们这里重点讲一下下面3种实现分布式锁的方案。

▍5.1 朴素Redis实现方案

咱们按部就班,对比几种实现方式,找出优雅的方式:

方案1:setnx+delete

1 setnx lock_key lock_value
2 // do sth
3 delete lock_key

缺点:一旦服务挂掉,锁没法被删除释放,会致使死锁。硬伤,pass!2

方案2:setnx + setex

1 setnx lock_key lock_value
2 setex lock_key N lock_value  // N s超时
3 // do sth
4 delete lock_key

在方案1的基础上设置了超时时间。可是仍是会出现跟1同样的问题。若是setnx以后、setex以前服务挂掉,同样会陷入死锁。本质缘由是,setnx/setex分为了两个步骤,非原子操做。硬伤,pass!

方案3:set ex nx

1 SET lock_key lock_value EX N NX //N s超时
2 // do sth
3 delete lock_key

将加锁、设置超时两个步骤合并为一个原子操做,从而解决方案一、2的问题。(Redis原生命令支持,Redis version须要>=2.6.12,滴滴生产环境Redis version通常为3.2,因此平常可以使用)。

优势:此方案目前大多数sdk、Redis部署方案都支持,实现简单
缺点:会存在锁被错误的释放,被错误的抢占的状况。以下图:

图片描述

这块有2个问题:

1)GC期间,client1超时时间已到,致使将client2错误地放进来

2)client1执行完逻辑后会显式调用del,将全部的锁都释放了(正确的状况应该只释放本身的锁,错误地释放了client2的锁)

方案4:

在3的基础上,对于问题1,将client的超时时间设置长一些,保证只能经过显式del来释放锁,而超时时间只是做为一种最终兜底的方案。针对问题2,增长对 value 的检查,只解除本身加的锁,为保证原子性,只能须要经过lua脚本实现。

lua脚本:https://redis.io/commands/eval

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

若是超时时间设置长,只能经过显式的del来释放锁,就不会出现问题2(错误释放掉其余client的锁)。跟滴滴KV store的王斌同窗讨论过,目前没有找到方案4优于方案3(只要超时时间设置的长一些)的场景。因此,在个人认知中,方案4跟方案3的优点同样,可是方案3的实现成本明显要低不少。

朴素Redis方案小结

方案3用的最多,实现成本小,对于大部分场景,将超时时间设置的长一些,极少出现问题。同时本方案对不一样语言的友好度极高。

▍5.2 ZooKeeper实现方案

咱们先简要介绍一些ZooKeeper(如下简称ZK):

ZooKeeper是一种“分布式协调服务”。所谓分布式协调服务,能够在分布式系统中共享配置,协调锁资源,提供命名服务等。为读多写少的场景所设计,ZK中的节点(如下简称ZNode)很是适合用于存储少许的状态和配置信息。

对ZK常见的操做:

create:建立节点
delete:删除节点
exists:判断一个节点的数据
setdata:设置一个节点的数据
getchildren:获取节点下的全部子节点

这其中,exists,getData,getChildren属于读操做。Zookeeper客户端在请求读操做的时候,能够选择是否设置Watch(监听机制)。

什么是Watch?

Watch机制是zk中很是有用的功能。咱们能够理解成是注册在特定Znode上的触发器。当这个Znode发生改变,也就是调用了create,delete,setData方法的时候,将会触发Znode上注册的对应事件,请求Watch的客户端会接收到异步通知。
咱们在实现分布式锁的时候,正是经过Watch机制,来通知正在等待的session相关锁释放的信息。

什么是ZNode?

ZNode就是ZK中的节点。ZooKeeper节点是有生命周期的,这取决于节点的类型。在 ZooKeeper 中,节点类型能够分为临时节点(EPHEMERAL),时序节点(SEQUENTIAL ),持久节点(PERSISTENT )。

临时节点(EPHEMERAL):

节点的生命周期跟session绑定,session建立的节点,一旦该session失效,该节点就会被删除。

临时顺序节点(EPHEMERAL_SEQUENTIAL):

在临时节点的基础上增长了顺序。每一个父结点会为本身的第一级子节点维护一份时序。在建立子节点的时候,会自动加上数字后缀,越后建立的节点,顺序越大,后缀越大。

持久节点(PERSISTENT ):

节点建立以后就一直存在,不会由于session失效而消失。

持久顺序节点(PERSISTENT_SEQUENTIAL):

与临时顺序节点同理。

ZNode中的数据结构:

data(znode存储的数据信息),acl(记录znode的访问权限,即哪些人或哪些ip能够访问本节点),stat(包含znode的各类元数据,好比事务id,版本号,时间戳,大小等等),child(当前节点的子节点引用)。

利用ZK实现分布式锁,主要得益于ZK保证了数据的强一致性。

下面说说经过zk简单实现一个保持独占的锁(利用临时节点的特性):

咱们能够将ZK上的ZNode当作一把锁(相似于Redis方案中的key)。多个session都去建立同一个distribute_lock节点,只会有一个建立成功的session。至关于只有该session获取到锁,其余session没有获取到锁。在该成功获锁的session失效前,锁将会一直阻塞住。session失效时,节点会自动被删除,锁被解除。(相似于Redis方案中的expire)。

上述实现方案跟Redis方案3的实现效果同样。

可是,这样的锁有没有改进的地方?固然!

1)咱们可能会有可重入的需求,所以但愿能有可重入的锁机制。

2)有些场景下,在争抢锁的时候,咱们既不想一次争抢不到就pass,也不想一直阻塞住直到获取到锁。一个朴素的需求是,咱们但愿有超时时间来控制是否去上锁。更进一步,咱们不想主动的去查究竟是否可以加锁,咱们但愿可以有事件机制来通知是否可以上锁。(这里,你是否是想到了ZK的Watch机制呢?)

要知足这样的需求就须要控制时序。利用顺序临时节点和Watch机制的特性,来实现:

咱们事先建立/distribute_lock节点,多个session在它下面建立临时有序节点。因为zk的特性,/distribute_lock该节点会维护一份sequence,来保证子节点建立的时序性。

具体实现以下:

1)客户端调用create()方法在/distribute_lock节点下建立EPHEMERAL_SEQUENTIAL节点。

2)客户端调用getChildren(“/distribute_lock”)方法来获取全部已经建立的子节点。

3)客户端获取到全部子节点path以后,若是发现本身在步骤1中建立的节点序号最小,那么就认为这个客户端得到了锁。

4)若是在步骤3中发现本身并不是全部子节点中最小的,说明本身尚未获取到锁。此时客户端须要找到比本身小的那个节点,而后对其调用exist()方法,同时注册事件监听。须要注意是,只在比本身小一号的节点上注册Watch事件。若是在比本身都小的节点上注册Watch事件,将会出现惊群效应,要避免。

5)以后当这个被关注的节点被移除了,客户端会收到相应的通知。这个时候客户端须要再次调用getChildren(“/distribute_lock”)方法来获取全部已经建立的子节点,确保本身确实是最小的节点了,而后进入步骤3)。

Curator框架封装了对ZK的api操做。以Java为例来进行演示:
引入依赖:

1 <dependency>
2   <groupId>org.apache.curator</groupId>
3   <artifactId>curator-recipes</artifactId>
4   <version>2.11.1</version>
5 </dependency>

使用的时候须要注意Curator框架和ZK的版本兼容问题。
以排他锁为例,看看怎么使用:

1 public class TestLock {
 2
 3    public static void main(String[] args) throws Exception {
 4        //建立zookeeper的客户端
 5        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
 6        CuratorFramework client = CuratorFrameworkFactory.newClient(“ip:port", retryPolicy);
 7        client.start();
 8
 9        //建立分布式锁, 锁空间的根节点路径为/sunnyzengqi/curator/lock
10        InterProcessMutex mutex = new InterProcessMutex(client, "/sunnyzengqi/curator/lock");
11        mutex.acquire();
12        //得到了锁, 进行业务流程
13        System.out.println("Enter mutex");
14        Thread.sleep(10000);
15        //完成业务流程, 释放锁
16        mutex.release();
17        //关闭客户端
18        client.close();
19    }
20 }
21

△左滑浏览全貌

上面代码在业务执行的过程当中,在ZK的/sunnyzengqi/curator/lock路径下,会建立一个临时节点来占位。相同的代码,在两个机器节点上运行,能够看到该路径下建立了两个临时节点:

图片描述

图片描述

图片描述

运行命令echo wchc | nc localhost 2181查看watch信息:

图片描述

能够看到lock1节点的session在监听节点lock0的变更。此时是lock0获取到锁。等到lock0执行完,session会失效,触发Watch机制,通知lock1的session说锁已经被释放了。这时,lock1能够来抢占锁,进而执行本身的操做。

除了简单的排它锁的实现,还能够利用ZK的特性来实现更高级的锁(好比信号量,读写锁,联锁)等,这里面有不少的玩法。

ZooKeeper方案小结

可以实现不少具备更高条件的锁机制,而且因为ZK优越的session和watch机制,适用于复杂的场景。由于有久经检验的Curator框架,集成了不少基于ZK的分布式锁的api,对于Java语言很是友好。对于其余语言,虽然也有一些开源项目封装了它的api,可是稳定性和效率须要本身去实际检验。

▍5.3 Redisson实现方案

咱们先简要介绍一下Redisson:

Redisson是Java语言编写的基于Redis的client端。功能也很是强大,功能包括:分布式对象,分布式集合,分布式锁和同步器,分布式服务等。被你们熟知的场景仍是在分布式锁的场景。

为了解决加锁线程在没有解锁以前崩溃进而出现死锁的问题,不一样于朴素Redis中经过设置超时时间来处理。Redisson采用了新的处理方式:Redisson内部提供了一个监控锁的看门狗,它的做用是在Redisson实例被关闭前,不断的延长锁的有效期。
跟Zookeeper相似,Redisson也提供了这几种分布式锁:可重入锁,公平锁,联锁,红锁,读写锁等。具体怎么用这里不展开,感兴趣的朋友能够本身去实验。

Redisson方案小结

跟ZK同样,都可以实现不少具备更高条件的锁机制,适用于复杂的场景。但对语言很是挑剔,目前只能支持Java语言。

▍6. 总结

上一节,咱们讨论了三种实现的方案:朴素Redis实现方案,ZooKeeper实现方案,Redisson实现方案。因为第1种与第3种都是基于Redis,因此主要是ZK和基于Redis两种。咱们不由想问,在实现分布式锁上,基于ZK与基于Redis的方案,有什么不一样呢?

1)锁的时长设置上:

得益于ZK的session机制,客户端能够持有锁任意长的时间,这能够确保它作完全部须要的资源访问操做以后再释放锁。避免了基于Redis的锁对于有效时间到底设置多长的两难问题。实际上,基于ZooKeeper的锁是依靠Session(心跳)来维持锁的持有状态的,而Redis不支持Sesion。

优点:ZK>Redisson>朴素Redis。

2)监听机制上:

得益于ZK的watch机制,在获取锁失败以后能够等待锁从新释放的事件。这让客户端对锁的使用更加灵活。避免了Redis方案主要去轮询的方式。

优点:ZK>Redisson=朴素Redis。

3)使用便利性上:

因为生产环境都有稳定的Redis和ZooKeeper集群,有专业的同窗维护,这二者差异不大。在语言局限性上,朴素Redis从不挑食。ZK和Redisson都偏向于Java语言。在开发难度上,Redis最简单,几乎不用写什么代码;ZK和Redisson次之,依赖于使用的语言是否有集成的api以及集成稳定性等。

优点:朴素Redis>ZK>Redisson。

4)支持锁形式的多样性上:

上面有说起,ZK和Redisson都支持了各类花样的分布锁。朴素Redis就比较捉急了,在实现更高要求的锁方面,若是本身造轮子,每每费时费力,力不从心。

优点:ZK=Redisson>Redis。

▍7. 结束语

分布式锁在平常Coding中已经很经常使用。可是分布式锁这方面的知识依然很是深奥。2016年,Martin Kleppmann与Antirez两位分布式领域很是有造诣的前辈还针对“Redlock算法”在分布式锁上面的应用炒得沸沸扬扬。

最后借助这场历史闹剧中Martin的话来结束咱们今天的分享。与诸君共勉!将学习当成一辈子的主题!

对我来讲最重要的一点在于:我并不在意在这场辩论中谁对谁错 —— 我只关心从其余人的工做中学到的东西,以便咱们可以避免重蹈覆辙,并让将来更加美好。前人已经为咱们创造出了许多伟大的成果:站在巨人的肩膀上,咱们得以构建更棒的软件。
……
对于任何想法,务必要详加检验,经过论证以及检查它们是否经得住别人的详细审查。那是学习过程的一部分。但目标应该是为了得到知识,而不该该是为了说服别人相信你本身是对的。有时候,那只不过意味着停下来,好好地想想。

因为时间仓促,本身水平有限,文中一定存在诸多疏漏与理解不当的地方。很是但愿获得各位指正,畅谈技术。

▍Reference

0.Apache ZooKeeper
1.Redisson
2.Redis
3.Redis分布式锁进化史
4.分布式系统互斥性与幂等性问题的分析与解决
5.浅谈可重入性及其余
6.Distributed locks with Redis
7.How to do distributed locking
8.Is Redlock safe?
9.Note on fencing and distributed locks

▍END
转载请至 / 转载合做入口

图片描述

图片描述

北京科技大学本硕,2018年应届入职滴滴。热爱技术,更热爱用技术去解决实际问题。对分布式系统,大型网站架构有必定的了解。

图片描述

相关文章
相关标签/搜索