本文题目为《为什么要应用zookeeper》,然而本文并不是专门介绍zookeeper原理及其应用办法的文章。如果你在网上搜寻为什么要应用zookeeper,肯定能能到从zookeeper原理、实用场景到Zab算法原理等各种各样的介绍,然而看过之后是不是还是懵懵懂懂,只是学会了一些全面的、具体的知识点,还是不能文章题目的问题。zookeeper应用一种名为Zab的共识算法实现,除了Zab算法之外还有Paxos、Multi-Paxos、Raft等共识算法,实现上也有cubby、etcd、consul等独立的中间件和像Redis哨兵模式一样的嵌入式实现,这些实现都是基于相似的底层逻辑为了实用于不同场景下的工程学落地,本文的重点内容是共性的底层原理而不是具体的软件应用领导。

多线程与锁

我以如何实现分布式锁为切入点,将多线程编程、锁、分布式系统、分布式系统一致性模型(线性一致性、最终一致性)、CAP定理、复制冗余、容错容灾、共识算法等一众概念有机联合起来。采纳层层递进的形式对相干概念及其互相分割开展阐述,不仅让你能将零散的知识点串连成线,而且还能站在理论利用的角度对相干概念从新思考。

之所以用分布式锁来举例,是因为在编程畛域,锁这个概念太广泛了,在多线程编程场景有同步锁、共享锁、排他所、自旋锁和锁降级等与锁无关的概念,在数据库畛域也有行级锁、表级锁、读锁、写锁、谓词锁和间隙锁等各种名词概念。实质上锁就是一种有肯定排他性的资源占有机制,一旦一方持有某个对象的锁,另一方就不能持有同一对象雷同的锁。 那么什么是分布式锁呢?要答复这个问题咱们须要先理解单机状况下锁的原理。在单机多线程编程中,咱们须要同步机制来保障共享变量的可见性和原子性。如何了解可见性和原子性呢?我用一个经典的计数器代码举例。

class Counter{    private int sum=0;    public int count(int increment){        return sum += increment    }}

代码很简略,有过多线程编程教训的人都应该晓得count()办法在单线程下工作失常,然而在多线程场景下就会生效。原则上一个线程循环执行一百遍count(1)和一百个线程每个线程执行一遍count(1)后果应该都是100,然而理论执行的后果大概率是不雷同,这种单线程下执行正确然而多线程下执行逻辑不正确的状况咱们称之为线程不平安。

为什么在多线程下执行后果不正确呢?
首先当两个线程同时执行sum=sum+1这条语句的时候,语句并不是原子性的,而是一个读操作和一个写操作。有可能两个线程都同时读取到了sum的值为0,加1操作后sum的值被两次赋值为1,这就像第一个线程的操作被第二个线程笼罩了一下,咱们称之为笼罩更新(表1)。

工夫/线程T1T2
t1读取到sum值为0
t2读取到sum值为0
t3执行sum=0+1操作
t4执行sum=0+1操作

(表1)

接下来咱们再说可见性,即便两个线程不是同时读取sum的值,也有可能当一个线程批改了sum值之后,另一个线程不能及时看到最新的批改后的值。这是因为当初的CPU为了执行效率,为每个线程调配了一个寄存器,线程对内存的赋值不是间接更新,而是先更新本人的寄存器,而后CPU异步的将寄存器的值刷新到内存。因为寄存器的读写性能远远大于内存,所以这种异步的读写形式能够大幅度晋升CPU执行效率,让CPU时钟不会因为期待IO操作而暂停。

工夫/线程T1T2
t1读取到sum值为0
t2执行sum=0+1操作
t3读取到sum值为0
t4执行sum=0+1操作

(表2)

咱们须要同步机制保障count()的原子性和可见性

class Counter{    private int sum=0;    public synchronized int count(int increment){        return sum += increment    }}

如果替换为锁的语义,这段代码就相当于

class Counter{    private int sum=0;    public int count(int increment){        lock();        sum += increment        unlock();        return sum;    }}

lock()和unlock()办法都是伪代码,相当于加锁和解锁操作。一个线程调用了lock()办法获取到锁之后才能够执行前面的语句,执行结束后调用unlock()办法开释锁。此时如果另一个线程也调用lock()办法就会因无奈获取到锁而期待,直到第一个线程执行结束后开释锁。锁岂但能保障代码执行的原子性,还能保障变量的可见性,获取到锁之后的线程读取的任何共享变量肯定是它最新的值,不会获取到其余线程批改后的过期值。

咱们再来放大一下lock()外部细节。显然为了保障获取锁的排他性,咱们须要先去判断线程是否曾经取得了锁,如果还没有线程取得锁就给以后线程加锁,如果曾经有其余线程曾经获取了锁就期待。显然获取锁自身也须要保障原子性和可见性,所以lock()办法必须是一个同步(synchronized)办法,unlock()也是一样的情理。 在强调一下,加解锁办法自身都要具备原子性和可见性是一个重要的概念,前面咱们会用到。

public synchronized void lock(){    if(!hasLocked()){        locked();        return;    }else{        awaited();    }}

注:以上所有代码均为伪代码,只为阐明锁的作用及原理,无需深究

应用锁(同步办法)之后的count()就能够在多线程下并发执行了(表1),并且是线程平安的。

工夫/线程T1T2T3
t1读取到sum值为0
t2执行sum=0+1操作
t3读取到sum值为1
t4执行sum=1+1操作
t5读取到sum值为2
t6执行sum=2+1操作

(表3)
通过加锁操作之后,count()办法变成一个不能被突破的原子操作,依照肯定的程序顺次执行,并且每个操作的执行后果都能够被后续操作立刻可见。留神这外面的程序,前面还会再讲。

多过程与分布式锁

上文中介绍了单机多线程场景应用锁来保障代码线程平安的场景,分布式锁顾名思义就是在分布式场景下多台机器(多个过程)间应用的锁。这么说还是有点形象,咱们仍然用计数器举例。假如咱们的计数器并发拜访压力十分大,单机曾经不能满足咱们的性能要求了,咱们须要将单机扩大为多机运行,这样就造成了一个计数器服务集群。(这里只是为了举例,我置信没有人会为了性能而搭建这样的集群,其实也没有任何理由搭建这样的集群。)

咱们还须要对单机版计数器代码革新为计数器服务,以适应分布式多机场景。

class Counter{    public int count(int increment){        lock();        int sum=getSumFromDB();        sum += increment        setSumToDB(sum);        unlock();        return sum;    }}
  • 因为咱们要在多台机器间共享并操作总数数据,所以不能应用只有单机可见的变量存储,能够将这个值存储在一个多台机器能拜访和操作的数据存储层,代码中应用数据库(getSumFromDB)作为存储目的地。无论采纳何种形式实现,数据存储中间件都必须保证数据的可见性,即数据变更后能够读取到最新的值。
  • count()办法还是读取-写回模式,所以仍然要应用锁模式来阻止多台机器多台机器(多个过程)间并发操作,保障计数操作在分布式场景下的正确性。这里的锁就是分布式锁,lock()和unlcok()就是分布式锁的加锁和解锁办法。
  • 分布式锁的加解锁操作须要在多台机器(或多个过程)间被调用。所以编程语言中没有原生的办法供咱们应用,通常须要咱们基于各种中间件本人实现。

注:以上分布式计数器代码仅为示例,只为阐明相干概念,无需深究。

分布式锁实现原理

锁的实现原理并不简单(留神我说额是基本原理,理论还是比较复杂的),锁自身能够了解为一个标识,加锁解锁就是扭转这个标识的状态,当然因为要满足排他性要求,加锁前要判断锁是否曾经存在。判断标识和扭转状态操作必须是一个原子单元(原子性),并且锁的状态一旦扭转就立即可见(可见性),这样能力保障在多方(多线程或多过程)同时获取锁的时候,只有惟一一方能够失去。

synchronized{    if(flag==null){        flag=locked;    }else{        print("Flag is locked");    }}

要实现分布式锁,咱们能够将锁的状态存储在数据库中,每个过程通过读取写数据库中锁的状态值来实现加解锁操作。这样就相当于把加锁操作原子性和锁状态数据可见性的要求转移给了数据库。这种原子性和可见性的要求在数据库畛域是由数据一致性模型来定义并保障的。针对分布式锁这个场景,咱们须要数据库提供线性一致性(linearizability)保障。线性一致性也称强一致性或原子一致性,它的实践定义比较复杂,这里就不开展了,咱们只须要晓得线性一致性提供额一下三点保障:

  • 就近性:一旦一个新值被写入或者读取,所有后续的对该值读取看到的都是最新的值,直到它被再次批改。
  • 原子性:所有操作都是原子操作,没有并发行为。
  • 程序性:所有操作都能够依照全局工夫程序排序,并且所有客户端看到的程序统一。这也就保障了所有客户端看到的数据状态变动是一致性的。

显然线性一致性的确能够满足咱们对于实现分布式锁的全副要求。那么接下来的问题就是哪些数据库能够提供线性一致性保障?
就近性看似是一个公理,仿佛数据库都应该反对(其实不然,可见最新值数据对于分布式系统其实是一个很严格的要求。即便是单机场景,受限于性能及应用场景也需有不同实现,前面咱们会介绍。)。提到原子性你应该可能想到咱们很相熟的一款缓存数据库Redis,因为Redis自身是以单线程形式执行而闻名,所以所有针对Redis的操作都是原子性的并且依照达到Redis的服务端的程序顺次程序执行。那么接下来咱们就尝试用Redis实现上文代码中加锁逻辑。

应用Redis实现分布式锁

Redis中有一个原子命令SETNX KEY VALUE,命令的意思是当指定的key不存在时,为key设置指定的值。咱们能够通过SETNX flag locked实现加锁操作,通过判断命令的返回值(胜利为1,失败为0)确定本人是否失去了锁。
这么简略吗?咱们后面做了这么多铺垫,从线程平安开始始终讲到数据库一致性模型,最初就用一条Redis命令实现了。然而这仅仅是开始,作为开发人员你肯定晓得很多时候失常的业务逻辑实现起来很简略,然而如何解决异样才是难点所在。下面这段代码正确的前提是咱们的Redis部署为繁多节点。而单点就意味着一旦呈现故障,咱们分布式锁服务就不可用,单点故障就是咱们要解决的异样场景。为了晋升零碎整体的可用性,就必须防止单点部署,一旦咱们的Redis就从单机降级为集群,问题就会趋于简单。在分布式场景下如何既能提供一致性保障又能在异样时保证系统可用性,将是咱们接下来的重点。

CAP定理

一说到一致性和可用性关系,你应该能想到一个广为人知的分布式实践——CAP定理。CAP定理说的是在一个布式零碎中分区容错性、可用性和一致性最多只能实现两个,因为分布式系统网络故障肯定会产生,网络分区场景不可避免,所以分区容错性咱们必须保障,只能在一致性和可用性中二选一,最终零碎要么抉择分区容错性和一致性(CP),要么抉择分区容错性和可用性(AP)。而这里所说的一致性就是线性一致性。CP零碎要求要么不返回,返回肯定是最新的值。AP零碎要求每个申请必须有响应,然而能够返回过期值。

CAP定理自身限度条件比拟多。首先分布式系统除了网络分区之外还有很多故障场景,如网络延时、机器故障等、过程解体等。其次定理自身并没有将零碎性能思考在内,一致性不仅须要和可用性做衡量,也须要在性能上做取舍,上文中提到的每个线程都先更新本人的寄存器后异步更新内存显然就是为了性能考量而不是为了容错。尽管CAP定理在工程落地中指导意义略显有余,然而作为一个简化模型,为咱们了解分布式系统在产生网络分区故障时如何在一致性和可用性间均衡取舍提供了参考。

集群下窘境

补充完分布式理论知识,让咱们回到分布式锁场景。为了躲避单点故障,咱们应用两台机服务器搭建了一个Redis集群,集群有两个节点,一个主节点,一个从节点。只有主节点能够承接客户端写操作,并且将负责将数据异步复制到从节点中(Redis只反对异步复制),从节点只能承受读申请,这样咱们就搭建了一个一主一从的Redis集群。那么这个Redis集群以整体的形式对外提供服务是否能够提供线性一致性呢?

异步复制

因为主从间为异步复制,所以会呈现复制提早状况。也就是采纳读写拆散形式(图2),客户端在主节点写入数据后,在从节点不肯定读取到最新的数据(此时满足最终一致性,即当没有数据写入操作后,通过一段时间后主从节点数据最终将达成统一)。如果所有读写操作均在主节点进行(图1),此时仿佛和单节点一样能够满足线性一致性的,然而一旦产生故障导致主节点不能拜访,为保证系统可用性集群会进行主从切换将从节点晋升为主节点,而此时未复制实现的数据就会失落,客户端也有可能读取到旧数据。所以无论采取什么样的读数据模式,在Redis主从异步复制的架构下,均不满足线性一致性要求,不能用于分布式锁场景。从CAP定理角度看,Redis集群优先保障可用性,集群具备肯定的容错能力,呈现故障后集群仍然能够对外提供服务,然而不保障获取到最新的数据。


(图1)

(图2)

同步复制

让咱们假如Redis反对同步复制再剖析以上读写的场景。同步复制就是主节点接管到写数据申请后,除了实现本身的写入操作外必须要期待所有从节点实现复制操作后才算操作实现并返回客户端(异步复制则不须要期待)(图3)。此时主节点数据和从节点数据没有复制提早问题,无论从主节点或者从节点读取数据都能够获取到最新的值(主节点写入操作和所有从节点写入操作不是产生在同一时间点,而如何让主节点和从节点新写入数据在同一时间点对外可见还是有很多须要思考的中央)。而且主从切换后也不会失落数据。然而同步复制模式也会带来新的问题,首先因为写操作要期待所有从节点实现,对于零碎性能有比拟大的影响。其次,一旦某个从节点故障或者网络故障,零碎就无奈写入数据了。显然在同步复制模式下,零碎用升高可用性和性能为代价,换取数据一致性。这不仅合乎CAP定理两者选其一的要求,也再一次体现了线性一致性对于性能的影不容小觑。所以对于Redis来说,抉择性能和可用性更加合乎它的应用场景和本身定位。

(图3)

脑裂

对于一个分布式系统由网络分区的等起因造成零碎宰割成不同的局部且都对外提供服务就称之为脑裂。对应到Redis集群场景就是一旦产生脑裂,会有两个Redis主节点同时承受客户端的写申请(图4),这会导致并发写入抵触而造成数据不统一景象。能够引起脑裂的场景很多,例如主从间网络延时、主节点故障后复原、谬误的主动/人工主从切换行为等。显然对于分布式集群脑裂是一个咱们不得不解决的问题。

(图4)

本节小结

咱们做个小结,单机版的Redis满足线性一致性要求,能够用来实现分布式锁,然而有单点故障问题。为了进步可用性,咱们构建了一个Redis集群,然而集群并不能满足线性一致性要求,所以也无奈来实现分布式锁。仿佛咱们又陷入了一个CAP定理二选一的难题中,那么有没有一个分布式存储系统,即能够实现一致性,又能够保障可用性呢。

应用zookeeper实现分布式锁

我想你曾经猜到答案了,接下来咱们正式介绍zookeeper。zookeeper被定义为一个高牢靠的分布式协调服务。这个定义不是很直观,其实我更违心将zookeeper了解为数据库,只不多zookeeper的读写形式更像是对文件系统操作,而不是传统关系数据库中SQL语句模式或者key-value数据库的get/set形式。协调服务也不难理解,分布式锁不就是对各个争抢锁的过程由谁取得锁这个行为进行协调,还有主从切换也是对各个候选节点谁能够降职为主节点这个行为进行协调。只不过这些协调操作以操作zookeeper中ZNode(相似于文件系统里的目录)的形式实现。

zookeeper有一个原子级命令create能够用创立一个节点(ZNode)。ZooKeeper中的节点的门路必须是惟一的,这意味着在同一级目录下,你不能创立同名的节点。所以客户端能够通这条命令过创立同一级目录下的同名节点并依据返回的后果来确定是否加锁胜利,如果创立胜利阐明加锁胜利,否者加锁失败。和Redis的实现一样简略。
(注:此处的实现形式只是示例阐明,并非罕用的实现办法)

全序关系播送

zookeeper是以名为Zab的分布式共识算法为根底实现的。“共识”的意思就是在所有分布式节点中达成统一。和Redis主从复制相似,zookeeper也由一个主节点(leader节点)用来接管客户端写操作,并且将数据复制个所有的从节点(follower节点)。这种由一个主节点承受数据写入申请,再将数据有序复制到从节点的形式咱们称之为全序关系播送。对全序关系播送是一种节点之间的数据交换协定,它要求满足上面两个根本属性:

  • 数据可靠性:复制数据的音讯必须被发送到所有节点
  • 数据有序性:音讯发送到各个节点的程序与主节点操作程序完全相同

故障及容错

单从这两个属性上看,主从复制的Redis也实现了全序关系播送。然而就像前文所属,如何解决零碎运行过程中产生的异样逻辑才是要害。

  • 首先咱们要保证系统在任何期间都只能有一个主节点(这点很重要,否则会呈现“脑裂”,毁坏数据一致性)。
  • 其次当主节点故障的状况下零碎零碎能够本人选举一个新的主节点持续提供服务。选举过程也要保障主节点唯一性,并且新的主节点不能失落数据(参见Redis异步复制场景)。显然让让新的主节点信息在所有节点间达成统一也是一个共识问题。
  • 最初咱们还要能解决从节点产生产生故障的状况,不能呈现从节点故障造成零碎不可用的状况(参见Redis同步复制假如场景)。

和Redis主从间异步复制数据不同,zookeeper采纳相似半同步复制的形式。zookeeper写入操作须要期待大多数从节点实现复制后才算实现,这里的大多数为集群的所有节点数N除以2在加1(N/2+1)。假如集群中有三个节点,zookeeper的写入操作就须要同步期待两个节点实现复制操作。这样为集群提供了肯定的容错性,最多容许1-(N/2+1)个节点故障,零碎仍然能够对外提供服务。
zookeeper主、节点间通过网络心跳的形式监测并确定主节点失常的状态,心跳中断一段时候后从节点认为主节点故障,就会发动新的主节点选举过程,从节点向集群中的其余节点发送一个投票的提案,申明本人心愿成为主节点。其余节点依据状况批准或拥护。这里有三个关键点:

  • 只有收到大多数节点批准选主投票的状况下,选举的过程才算实现。也就是说选取的主节点的行为在集群中达成了共识。
  • 每一个主节点的任期内都有一个全局惟一、枯燥递增任期编号,从节点发动选主提案的时候会带着本人的任期编号递增后的新编号,其余节点只对大于本人已知最大的任期编号的选主提案投赞成票。任期编号不仅在选主过程中应用,主节点向从节点的复制数据的音讯中也携带这个编号,只有音讯的任期编号不小于从节点已知的任期编号,也就是从节点上次参加投票达成共识的主节点位置没有变动,音讯才可被承受。通过任期编号,咱们就保证系统在同一期间内只能只有惟一一个主节点。
  • zookeeper每一次写操作主节点都会生成一个全局惟一的递增的zxid,并将zxid通过复制音讯流传给所有的从节点。选主的提案也会蕴含zxid,从节点不会给一个zxid小于本人zxid的选主提案投票。这样就能保障不会呈现有不实现数据的从节点被选取为主节点,防止主从切换后数据失落。

本节小结

咱们再小结一下,像Zab这类分布式识算法通常有如下特点:

  • 只有一个主节点承当所有的写操作并采纳全序关系播送向从节点复制数据。以此保障复制音讯的可靠性和有序性,并且能够进一步保障操作的原子性。
  • 写操作须要期待大多数从节点(N/2+1)实现复制,能够容忍节点小局部节点(1-(N/2+1))故障。保障肯定水平上的可用性
  • 能够监测到主节点故障并以投票的形式选取新的主节点。选取主节点的提案须要取得集群中大多数节点的批准,并且算法通过主节点任期编码和全局操作程序编码(zxid)躲避了脑裂和主从切换后数据失落问题。

可能你曾经留神到了,下面提到共识算法的特点中提到了原子性、程序性和肯定水平的可用性,而线性一致性准则中就近性准则没有波及。不同的共识算法对写入数据的一致性要求比拟对立,然而对读取数据一致性要求各不相同,具体落地实现的时候会依据应用场景进行取舍(zookeeper提供最终一致性读,etcd提供串行一致性和线性一致性读),所以应用前肯定要浏览阐明文档,明确他们所提供的一致性保障,否则很可能谬误应用。zookeeper文档中明确阐明了本人不满足读数据线性一致性要求,只保障写数据的线性一致性。因为zookeeper为了性能将读操作交由从节点实现,所以有可能读取到旧的数据。那么上文提到的应用create()创立分布式锁的办法还能失效吗。答案是必定的,因为create()办法作为一个写办法只能在Redis主节点执行,主节点数据为最新能够保障就近性准则。这里要啰嗦一句,理论应用场景中不会应用create()办法创立分布式锁(存在锁开释后告诉、锁无奈开释等问题),而是采纳创立并监听长期程序节点的形式实现,在不满足读数据的线性一致性场景下,zookeeper仍然能够实现一个分布式锁,这就是zookeeper的精妙之处,如果有同学对这方面感兴趣,有机会我将撰文介绍。

总结

最初让咱们来做个总结吧。本文从多线程下锁的原理开始,一步一步介绍到共识算法。 对于锁的实现来说,无论是单机线程锁还是多机分布式说,都必须要求锁操作具备原子性和可见性——即线性一致性一致性,单机状况下编程语言级就能够反对,然而分布式场下必须通过能满足线性一致性的中间件反对。在单节点场景下,很多数据库都能够保障某种意义上的线性一致性,显然在一个节点上更好确定操作的程序和保障操作的原子性,也更好实现数据就近性。然而单节点无奈防止单点故障,如果减少节点数组成分布式洁群,咱们又会受制于CAP定理的限度,只能在一致性和可用性中两者选其一。

共识算法实质上是为了在多个节点间达成统一,这就能够成为实现线性一致性的根底,所以实践上共识算法是能够实现线性一致性的。并且共识算法通过在复制操作和选主行为上应用“大多数”准则,保障了肯定水平上的可用性。然而这里要留神,共识算法也不能齐全违反CAP定理,应用共识算法的分布式系统实践上还是CP零碎,即便通过主从切换和半同步复制提供肯定水平的零碎容错能力,然而这些容错都有限度条件(小于半数节点故障),一旦超过容错极限,零碎还是不可用的。

共识算法也并非“银弹”,应用共识算法构建的零碎也会有诸多影响及限度。首先零碎写入和读取的性能影响咱们就不能小觑。写入数据通常只能在单节点进行,所以单节点的吞吐能力会成为整个集群的瓶颈,而且期待大多数节点实现复制操作也远比异步复制来的慢些,这些都会影响写入性能。不同的共识算法实现零碎有不同的抉择抉择策略,然而要保障线性一致性读取数据(常见的形式就是只从主节点读取数据),对系统的性能和吞吐量影响咱们也是不能疏忽的。其次共识算法对节点数量有要求,因为“大多数”准则,所以集群起始节点数起码为3台,而且为了防止网络分区为同数量集群集群造成选主效率降落问题,集群节点数量通常倡议为奇数,这就为集群部署提出了要求。还有共识算法通常依赖超时来判断主节点故障状况,在网络延时较高的场景下可能呈现无意义的主从切换,所以共识算法对网络性能和稳定性更加敏感。最初共识算法自身比设想的要简单许多,须要精美的设计和工程化落地,所以咱们常见的共识算法和实用组件并不多。也不要去挑战设计并实现一个全新的共识算法,而是从已有的、经验过大规模场景应用验证过的成熟中间件中抉择,这样就导致共识算法和已有工程的集成性和革新性欠佳(参见kafka依赖zookeeper和Redis的哨兵模式)。

鉴于以上对共识算法了解,像zookeeper这类实现了共识算法的中间件更实用于没有大量的数据写入或者变更,并且对数据一致性有较高要求(读取最新数据和故障复原后不失落数据),对性能没有太高要求,然而心愿零碎有肯定的容错能力的场景。所以zookeeper这类服务尽管也能提供数据存储服务然而通常不作为业务数据库应用(业务数据库通常有较高的读写吞吐量和性能要求),而是作为服务组件或中间件的根底组件,用于选主、加锁、服务注册发现等这类对数据一致性有较强要求且心愿零碎能提供肯定的可用性的场景。心愿通过我下面的介绍,你能有所播种。如有你有问题也能够在文章后留言评论,咱们一起探讨。

作者简介

Jerry Tse:紫光云云平台开发部架构组架构师,领有十余年分布式系统设计及开发教训,现从事云计算、企业上云及企业数字化转型相干架构设计工作。