本文标题为《为什么要使用zookeeper》,但是本文并不是专门介绍zookeeper原理及其使用方法的文章。如果你在网上搜索为什么要使用zookeeper,一定能能到从zookeeper原理、适用场景到Zab算法原理等各种各样的介绍,但是看过之后是不是还是懵懵懂懂,只是学会了一些片面的、具体的知识点,还是不能文章标题的问题。zookeeper使用一种名为Zab的共识算法实现,除了Zab算法之外还有Paxos、Multi-Paxos、Raft等共识算法,实现上也有chubby、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()方法变成一个不能被打破的原子操作,按照一定的顺序依次执行,并且每个操作的执行结果都可以被后续操作立即可见。注意这里面的顺序,后面还会再讲。

多进程与分布式锁

上文中介绍了单机多线程场景使用锁来保证代码线程安全的场景,分布式锁顾名思义就是在分布式场景下多台机器(多个进程)间使用的锁。这么说还是有点抽象,我们依然用计数器举例。假设我们的计数器并发访问压力非常大,单机已经不能满足我们的性能要求了,我们需要将单机扩展为多机运行,这样就形成了一个计数器服务集群。(这里只是为了举例,我相信没有人会为了性能而搭建这样的集群,其实也没有任何理由搭建这样的集群。)

image.png

我们还需要对单机版计数器代码改造为计数器服务,以适应分布式多机场景。

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集群优先保证可用性,集群具备一定的容错能力,出现故障后集群依然可以对外提供服务,但是不保证获取到最新的数据。

image.png
(图1)
image.png

(图2)

同步复制

让我们假设Redis支持同步复制再分析以上读写的场景。同步复制就是主节点接收到写数据请求后,除了完成自身的写入操作外必须要等待所有从节点完成复制操作后才算操作完成并返回客户端(异步复制则不需要等待)(图3)。此时主节点数据和从节点数据没有复制延迟问题,无论从主节点或者从节点读取数据都可以获取到最新的值(主节点写入操作和所有从节点写入操作不是发生在同一时间点,而如何让主节点和从节点新写入数据在同一时间点对外可见还是有很多需要考虑的地方)。而且主从切换后也不会丢失数据。但是同步复制模式也会带来新的问题,首先因为写操作要等待所有从节点完成,对于系统性能有比较大的影响。其次,一旦某个从节点故障或者网络故障,系统就无法写入数据了。显然在同步复制模式下,系统用降低可用性和性能为代价,换取数据一致性。这不仅符合CAP定理两者选其一的要求,也再一次体现了线性一致性对于性能的影不容小觑。所以对于Redis来说,选择性能和可用性更加符合它的使用场景和自身定位。
image.png
(图3)

脑裂

对于一个分布式系统由网络分区的等原因造成系统分割成不同的部分且都对外提供服务就称之为脑裂。对应到Redis集群场景就是一旦发生脑裂,会有两个Redis主节点同时接受客户端的写请求(图4),这会导致并发写入冲突而造成数据不一致现象。可以引起脑裂的场景很多,例如主从间网络延时、主节点故障后恢复、错误的自动/人工主从切换行为等。显然对于分布式集群脑裂是一个我们不得不解决的问题。
image.png
(图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:紫光云云平台开发部架构组架构师,拥有十余年分布式系统设计及开发经验,现从事云计算、企业上云及企业数字化转型相关架构设计工作。


JerryTse
768 声望126 粉丝