<返回更多

浅析如何基于ZooKeeper实现高可用架构

2022-08-03    再多一点呢
加入收藏

1. 背景

对于zookeeper,大家都比较熟悉,在Kafka、HBase、Dubbo中都有看到过他的身影,那zookeeper到底是什么,都能做些什么呢? 今天我们一起来了解下。

2. ZooKeeper简介

2.1 概述

简单理解,ZooKeeper 是分布式应用程序的分布式开源协调服务,封装了分布式架构中核心和主流的需求和功能,并提供一系列简单易用的接口提供给用户使用。 分布式应用程序可以根据zookeeper实现分布式锁、分布式集群的集中式元数据存储、Master选举、分布式协调和通知等。下图是官网上zookeeper的架构简图:

 

从zk的架构图中也可以了解到,zk本身就是一个分布式的、高可用的系统。有多个server节点,其中有一个是leader节点,其他的是follower节点,他们会从leader节点中复制数据。客户端节点连接单个zk服务器,并维护一个TCP连接,通过该连接发送请求、获取响应以及监听事件并发送心跳,如果与服务器的连接中断,就会连接到另外的服务器。

2.2 技术特点

zookeeper有以下几个特点:

  • 集群部署:一般是3~5台机器组成一个集群,每台机器都在内存保存了zk的全部数据,机器之间互相通信同步数据,客户端连接任何一台机器都可以。
  • 顺序一致性:所有的写请求都是有序的;集群中只有leader机器可以写,所有机器都可以读,所有写请求都会分配一个zk集群全局的唯一递增编号:zxid,用来保证各种客户端发起的写请求都是有顺序的。
  • 原子性:要么全部机器成功,要么全部机器都不成功。
  • 数据一致性:无论客户端连接到哪台节点,读取到的数据都是一致的;leader收到了写请求之后都会同步给其他机器,保证数据的强一致,你连接到任何一台zk机器看到的数据都是一致的。
  • 高可用:如果某台机器宕机,会保证数据不丢失。集群中挂掉不超过一半的机器,都能保证集群可用。比如3台机器可以挂1台,5台机器可以挂2台。
  • 实时性:一旦数据发生变更,其他节点会实时感知到。
  • 高性能:每台zk机器都在内存维护数据,所以zk集群绝对是高并发高性能的,如果将zk部署在高配置物理机上,一个3台机器的zk集群抗下每秒几万请求是没有问题的。
  • 高并发:高性能决定的,主要是基于纯内存数据结构来处理,并发能力是很高的,只有一台机器进行写,但是高配置的物理机,比如16核32G,可以支撑几万的写入QPS。所有机器都可以读,选用3台高配机器的话,可以支撑十万+的QPS。可参考官网「基于3.2版本做的性能压测【1】」

2.3 技术本质

 

zookeeper的技术本质决定了,一些开源项目为什么要使用zk来实现自己系统的高可用。zk是基于zab协议来实现的分布式一致性的,他的核心就是图中的Atomic Broadcast,通过原子广播的能力,保持所有服务器的同步,来实现了分布式一致性。

zookeeper算法即zab算法或者叫zab协议,需注意zab并不是paxos,zab的核心是为了实现primary-back systems这种架构模式,而paxos实际上是叫 state machine replication,就是状态复制机。这里简单对比下。

  • primary-back systems:leader节点接受写请求,先写到自己的本地机器,然后通过原子协议或其他的方式,将结果复制到其他的系统。
  • state machine replication:没有一个明显的leader节点。写的时候,把接收到的命令记录下来,然后把命令复制给各个节点,然后各个节点再去执行命令,应用到自己的状态机里面。

关于一致性算法这里不做具体对比,后续会详细说下常见的分布式集群算法。

2.3.1 ZAB工作流程

ZAB协议,即ZooKeeper Atomic Broadcas。 集群启动自动选举一个Leader出来,只有Leader是可以写的,Follower是只能同步数据和提供数据的读取,Leader挂了,Follower可以继续选举出来Leader。这里可以看下zab的工作流程:

 

Leader收到写请求,就把请求广播给所有的follower,每一个消息广播的时候,都是按照2PC思想,先是发起事务Proposal的广播,就是事务提议,在发起一个事务proposal之前,leader会分配一个全局唯一递增的事务id,zxid,通过这个可以严格保证顺序。每个follower收到一个事务proposal之后,就立即写入本地磁盘日志中,写入成功之后返回一个ack给leader,然后过半的follower都返回了ack之后,leader会推送commit消息给全部follower,Leader自己也会进行commit操作,commit之后,数据才会写入znode,然后这个数据可以被读取到了。

如果突然leader宕机了,会进入恢复模式,重新选举一个leader,只要过半的机器都承认你是leader,就可以选举出来一个新的leader,所以zookeeper很重要的一点是宕机的机器数量小于一半,他就可以正常工作。这里不具体展开详细的选举和消息丢弃的逻辑。

从上述的工作过程,可以看出zookeeper并不是强制性的,leader并不是保证一条数据被全部follower都commit了才会让客户端读到,过程中可能会在不同的follower上读取到不一致的数据,但是最终所有节点都commit完成后会一致性的。zk官方给自己的定义是:顺序一致性。

从上述的这些技术特点上,我们也可以看出,为什么zookeeper只能是小集群部署,而且是适合读多写少的场景。想象一下,如果集群中有几十个节点,其中1台是leader,每次写请求,都要跟所有节点同步,并等过半的机器ack之后才能提交成功,这个性能肯定是很差的。举个例子,如果使用zookeeper做注册中心,大量的服务上线、注册、心跳的压力,如果达到了每秒几万甚至上十万的场景,zookeeper的单节点写入是扛不住那么大压力的。如果这是kafka或者其他中间件共用了一个zookeeper集群,那基本就都瘫痪了。

2.3.2 ZooKeeper角色

上述 的场景中已经提到了leader和follower,既然提到了性能问题,就额外说下,除了leader和follower,zookeeper还有一个角色是:observer。

observer节点是不参与leader选举的;他也不参与zab协议同步时,过半follower ack的环节。他只是单存的接收数据,同步数据,提供读服务。这样zookeeper集群,可以通过扩展observer节点,线性提升高并发查询的能力 。

2.4 Znode数据模型

如 果想使用好zk的话,必须要了解下他的数据模型。虽然zookeeper的实现比较复杂,但是数据结构还是比较简单的。如下图所示,zookeeper的数据结构是一个类型文件系统的树形目录分层结构,它是纯内存保存的。基于这个目录结构,可以根据自己的需要,设计的具体的概念含义。ZooKeeper 的层次模型称作 Data Tree,Data Tree 的每个节点叫作 znode。如下图所示:

 

ZNode在我们应用中是主要访问的实体,有些特点这里提出来,说一下:

  • 【Watches】 监听

zookeeper支持 watch 的概念。客户端可以在 znode 上设置监听,当 znode 发生变化时,watch 将被触发并移除。当 watch 被触发时,客户端会收到一个数据包,说明 znode 已更改,这个在分布式系统的协调中是很有用的一个功能。

  • 【Data Access】 数据访问

存储在命名空间中每个 znode 的数据的读取和写入都是原子的。读取操作获取与 znode 关联的所有数据,写入操作会替换所有数据。每个节点都有一个访问控制列表 (ACL),用于限制谁可以做什么。

  • 【Ephemeral Nodes】临时节点

ZooKeeper 也有临时节点的概念。只要创建 znode 的会话处于活动状态,这些 znode 就存在。当会话结束时,znode 被删除。临时 znode 不允许有子节点。

  • 持久节点

zooKeeper除了临时节点还有持久节点,客户端断开连接,或者集群宕机,节点也会一直存在。

  • 【Sequence Nodes】 顺序节点

创建 znode 时,可以 在path路径末尾附加一个单调递增的计数器。这个计数器对于父 znode 是唯一的,是全局递增的序号。

3. ZooKeeper的应用

对zookeeper有了一定的了解之后,我们看下他有哪些应用场景。前面的背景中也提到过,在一些常见的开源项目中,都看到过zk的身影,那zk在这些开源项目中是如何使用的呢?

3.1 典型的应用场景

(1)元数据管理:Kafka、Canal,本身都是分布式架构,分布式集群在运行,本身他需要一个地方集中式的存储和管理分布式集群的核心元数据,所以他们都选择把核心元数据放在zookeeper中。

Dubbo:使用zookeeper作为注册中心、分布式集群的集中式元数据存储

HBase:使用zookeeper做分布式集群的集中式元数据存储

(2)分布式协调:如果有节点对zookeeper中的数据做了变更,然后zookeeper会反过来去通知其他监听这个数据的节点,告诉它这个数据变更了。

kafka: 通过zookeeper解决controller的竞争问题。kafka有多个broker,多个broker会竞争成为一个controller的角色。如果作为controller的broker挂掉了,此时他在zk里注册的一个节点会消失,其他broker瞬间会被zookeeper反向通知这个事情,继续竞争成为新的controller,这个就是非常经典的一个分布式协调的场景。

(3)Master选举 -> HA架构

Canal:通过zookeeper解决Master选举问题,来实现HA架构

HDFS:Master选举实现HA架构,NameNode HA架构,部署主备两个NameNode,只有一个人可以通过zookeeper选举成为Master,另外一个作为backup。

(4)分布式锁

一般用于分布式的业务系统中,控制并发写操作。不过实际使用中,使用zookeeper做分布式锁的案例比较少,大部分都是使用的redis.

3.2 开源产品使用zk的情况

下图是有关Paxos Systems Demystified的论文中,常见的开源产品使用的一致性算法系统,可以看到除了google系的产品用paxos算法或他们自己的chubby服务外,开源的系统大部分都使用zookeeper来实现高可用的。

 

4. 实践-通过ZooKeeper来实现高可用

了解了zookeeper的一些技术特性及常见的使用场景后,考虑一个问题:我们平时在工作中,大部分是使用一些开源的成熟的产品,如果出现部分产品是不能满足我们的业务需求时,我们是需要根据自己的业务场景去改造或重新设计的,但一般不会从头开始,也就是我们说的不会重复造轮子。

己实现一个产品时,一般都是分布式集群部署的,那就要考虑,是否需要一个地方集中式存储分布式集群的元数据?

是否需要进行Master选举实现HA架构?是否需要进行分布式协调通知?如果有这些需求,zookeeper作为一个久经考验的产品,是可以考虑直接拿来使用的,这大大提高我们的效率以及提升产品的稳定性。

基于以上对zookeeper技术特点的了解,如果使用zookeeper来实现系统的高可用时,一般需要考虑4个问题,或者理解为4个步骤:

  • 如何设计znode的path
  • znode的类型如何选择?比如是临时节点,还是顺序节点?
  • znode中存储什么数据?如何表达自己的业务含义
  • 如何设计watch,客户端需要关注什么事件,事件发生后需要如何处理?

下面我们通过两个案例,看下zookeeper是如何实现主备切换、集群选举的。

4.1 主备切换

主备切换在我们日常用到的分布式系统很常见,那我们自己如何通过zookeeper的接口来实现呢?

简单回顾下主备切换架构:

 

主备架构的工作流程:

正常阶段的话,业务数据读写在主机,数据会复制到备机。当主机故障了,数据没办法复制到备机,原来的备机自动升级为主机,业务请求到新的主机。原来的主机恢复后,成为新的备机,将数据从新的主机同步到备机上

4.1.1 实现方式

 

(1)设计 Path

由于只有2个角色,因此直接设置两个 znode 即可,master和slave,样例:

    • /com/dewu/order/operate/master
    • /com/dewu/order/operate/slave。

(2)选择节点类型

当 master 节点挂掉的时候,原来的 slave 升级为 master 节点,因此用 ephemeral 类型的 znode。

因为当一个节点成为master时,需要zk创建master节点,一旦这台主机挂掉了,它到zk的连接就断掉了,这个master节点会在超时之后,被zk自动删除,这样的话,就知道原来的主机宕机了,所以选择使用ephemeral类型的节点。

(3)设计 节点数据

由于 slave 成为 master 后,会成为新的复制源,可能出现数据冲突,因此 slave 成为 master 后,节点需要写入成为 master 的时间,这样方便修复冲突数据。还可以写入slave上最新的事务id,这里可以根据自己的业务灵活设计,znode节点中应该写入什么数据。

(4)设计 Watch

节点启动的时候,尝试创建 master znode,创建成功则切换为master,否则创建 slave znode,成为 slave,并监听master节点; 如果 slave 节点收到 master znode 删除的事件,就自己去尝试创建 master znode,创建成功,则自己成为 master,删除自己创建的slave znode。

4.1.2 示意代码

public class Node {

    

    public static final Node INSTANCE = new Node();

    private volatile String role = "slave";

    

    private CuratorFramework curatorFramework;

    

    private Node(){

        

    }

    

    public void start(String connectString) throws Exception{

        if(connectString == null || connectString.isEmpty()){

            throw new Exception("connectString is null or empty");

        }

        

        CuratorFramework client = CuratorFrameworkFactory.builder()

            .connectString(connectString).sessionTimeoutMs(5000).connectionTimeoutMs(3000)

            .retryPolicy(new ExponentialBackoffRetry(1000, 3))

            .build();

        

        String groupNodePath = "/com/dewu/order/operate";

        String masterNodePath = groupNodePath + "/master";

        String slaveNodePath = groupNodePath + "/slave";

        

        //watch master node

        PathChildrenCache pathChildrenCache = new PathChildrenCache(client, groupNodePath, true);

        pathChildrenCache.getListenable().addListener(new PathChildrenCacheListener() {

            @Override

            public void childEvent(CuratorFramework client, PathChildrenCacheEvent event) throws Exception {

                if(event.getType().equals(PathChildrenCacheEvent.Type.CHILD_REMOVED)){

                    String childPath = event.getData().getPath();

                    System.out.println("child removed: " + childPath);

                    //如果是master节点删除了,则自己尝试变成master

                    if(masterNodePath.equals(childPath)){

                        switchMaster(client, masterNodePath, slaveNodePath);

                    }

                } else if(event.getType().equals(PathChildrenCacheEvent.Type.CONNECTION_LOST)) {

                    System.out.println("connection lost, become slave");

                    role = "slave";

                } else if(event.getType().equals(PathChildrenCacheEvent.Type.CONNECTION_RECONNECTED)) {

                    System.out.println("connection connected……");

                    if(!becomeMaster(client, masterNodePath)){

                        becomeSlave(client, slaveNodePath);

                    }

                }

                else{

                    System.out.println("path changed: " + event.getData().getPath());

                }

                

            }

        });

        

        client.start();

        pathChildrenCache.start();

    }

    

    public String getRole(){

        return role;

    }

    

    private boolean becomeMaster(CuratorFramework client, String masterNodePath){

        //try to become master

        try {

            client.create().creatingParentContainersIfNeeded().withMode(CreateMode.EPHEMERAL)

                .forPath(masterNodePath, Longs.toByteArray(System.currentTimeMillis()));

            

            System.out.println("succeeded to become master");

            role = "master";

            

            return true;

        } catch (Exception e) {

            System.out.println("failed to become master: " + e.getMessage());

            return false;

        }

    }

    

    private boolean becomeSlave(CuratorFramework client, String slaveNodePath) throws Exception {

        //try to become slave

        try {

            client.create().creatingParentContainersIfNeeded().withMode(CreateMode.EPHEMERAL)

                .forPath(slaveNodePath, Longs.toByteArray(System.currentTimeMillis()));

            

            System.out.println("succeeded to become slave");

            role = "slave";

            

            return true;

        } catch (Exception e) {

            System.out.println("failed to become slave: " + e.getMessage());

            throw e;

        }

    }

    

    private void switchMaster(CuratorFramework client, String masterNodePath, String slaveNodePath){

        if(becomeMaster(client, masterNodePath)){

            try {

                client.delete().forPath(slaveNodePath);

            } catch (Exception e) {

                System.out.println("failed to delete slave node when switch master: " + slaveNodePath);

            }

        }

    }

}

参考 【代码样例】 【2】

4.2 实现集群选举


集群选举的方式比较多,主要是根据自己的业务场景,考虑选举时的一些具体算法。

4.2.1 最小节点获胜

就是在共同的父节点下创建znode,谁的编号最小,谁是leader。

 

(1)设计 Path

集群共用父节点 parent znode,也就是上图中的operate,集群中的每个节点在 parent 目录下创建自己的 znode。如上图中,假如有5个节点,编号是从
node0000000001~node0000000005。

(2)选择节点类型

当 Leader 节点挂掉的时候,持有最小编号 znode 的集群节点成为新的 Leader,因此用ephemeral_sequential 类型 znode。使用ephemeral类型的目的是,leader挂掉的时候,节点能自动删除,使用sequential类型的目的是,让这些节点都是有序的,我们选择最小节点的时候就比较简单。

(3)设计 节点数据

可以根据业务需要灵活写入各种数据。

(4)设计 Watch

    • 节点启动或者重连后,在 parent 目录下创建自己的 ephemeral_sequntial znode;
    • 创建成功后扫描 parent 目录下所有 znode,如果自己的 znode 编号是最小的,则成为 Leader,否则 监听 parent整个目录;
    • 当 parent 目录有节点删除的时候,首先判断其是否是 Leader 节点,然后再看其编号是否正好比自己小1,如果是则自己成为 Leader,如果不是继续 watch。

Curator 的 LeaderLatch、LeaderSelector 采用这种策略,可以参考【Curator】【3】看下对应的代码 。

4.2.2 抢建唯一节点


集群共用父节点 parent znode,也就是operate,集群中的每个节点在 parent 目录下创建自己的 znode。也就是集群中只有一个节点,谁创建成功谁就是leader。

 

(1)设计 Path

集群所有节点只有一个 leader znode,其实本质上就是一个分布式锁。

(2)选择 znode 类型

当 Leader 节点挂掉的时候,剩余节点都来创建 leader znode,看谁能最终抢到 leader znode,因此用ephemeral 类型。

(3)设计 节点数据

可以根据业务需要灵活写入各种数据。

(4)设计 Watch

    • 节点启动或者重连后,尝试创建 leader znode,尝试失败则监听 leader znode;
    • 当收到 leader znode 被删除的事件通知后,再次尝试创建leader znode,尝试成功则成为leader ,失败则继续监听leader znode。

4.2.3 法官判决

整体实现比较复杂的一个方案,通过创建节点,判断谁是法官节点。 法官节点可以根据一定的逻辑算法来判断谁是leader,比如看谁的数据最新等等。

 

(1) 设计 Path

集群共用父节点 parent znode,集群中的每个节点在 parent 目录下创建自己的 znode。

    • parent znode:图中的 operate,代表一个集群,选举结果写入到operate节点,比如写入的内容可以是:leader=server6。
    • 法官 znode:图中的橙色 znode,最小的 znode,持有这个 znode 的节点负责选举算法/规则。
        • 例如:实现Redis 存储集群的选举,各个 slave 节点可以将自己存储的数据最新的 trxId写入到 znode,然后法官节点将 trxID 最大的节点选为新的 Leader。
    • 成员 znode:图中的绿色 znode,每个集群节点对应一个,在选举期间将选举需要的信息写入到自己的 znode。
    • Leader znode:图中的红色 znode,集群里面只有一个,由法官选出来。

(2)选择节点类型

当 Leader 节点挂掉的时候,持有最小编号 znode 的集群节点成为“法官”,因此用 ephemeral_sequential 类型 znode。

(3)设计 节点数据

可以根据业务需要灵活写入各种数据,比如写入当前存储的最新的数据对应的事务 ID。

(4)设计 Watch

    • 节点启动或者重连后,在 parent 目录下创建自己的 ephemeral_sequntial znode,并 监听 parent 目录;
    • 当 parent 目录有节点删除的时候,所有节点更新自己的 znode 里面和选举相关的数据;
    • “法官”节点读取所有 znode的数据,根据规则或者算法选举新的 Leader,将选举结果写入parent znode;
    • 所有节点监听 parent znode,收到变更通知的时候读取 parent znode 的数据,判断自己是否成为 Leader。

4.2.4 集群选举模式对比

实现方式

实现复杂度

选举灵活性

应用场景

最小节点获胜

计算集群

抢建唯一节点

计算集群

法官判决

高,可以设计满足业务需求的复杂选举算法和规则

存储集群

这里简单说下计算集群和存储集群的应用场景:

计算集群

无状态,不存在存储集群里所有的数据覆盖问题,计算集群只要选出一个leader或主节点就行,对业务没什么影响。

存储集群

需要考虑比较复杂的选举逻辑,考虑数据一致性,考虑数据尽量不要丢失不要冲突等等,所以需要一个复杂的选举逻辑。

这里也可以看到,并不是功能越强大越好,实际上需要考虑不同的应用场景,特性,基于不同的业务要求选择合适的方案。一切脱离业务场景的方案都是耍流氓。

5. Etcd


这里再跟大家简答提一下etcd,主要是zookeeper与etcd的应用场景类似,在实际的落地选型时也会拿来对比,如何选择。

5.1 etcd概述

除了zookeeper,etcd也是最近几年比较火的一个分布式协调框架

etcd是一种强一致性的分布式键值存储,它提供了一种可靠的方式来存储需要由分布式系统或机器集群访问的数据。 它优雅地处理网络分区期间的leader选举,并且可以容忍机器故障。 etcd是go写的一个分布式协调组件,尤其是在云原生的技术领域里,目前已经成为了云原生和分布式系统的存储基石。 下图是etcd的请求示意图:

 


通常,一个用户的请求发送过来,会经由 HTTP Server 转发给 逻辑层进行具体的事务处理,如果涉及到节点的修改,则交给 Raft 模块进行状态的变更、日志的记录,然后再同步给别的 etcd 节点以确认数据提交,最后进行数据的提交,再次同步。

5.2 应用场景

与zookeeper一样,有类似的应用场景,包括:

  • 服务发现
  • 配置管理
  • 分布式协调
  • Master选举
  • 分布式锁
  • 负载均衡

比如openstack 使用etcd做配置管理和分布式锁,ROOK使用etcd研发编排引擎。

5.3 简单对比

 

zookeeper

etcd

语言

JAVA

go

协议

TCP

grpc

接口调用

必须要使用自己的client进行调用

可通过http传输,即可通过curl等命令实现调用

一致性算法

Zab; Zab 协议则由 Leader 选举、发现、同步、广播组成

Raft ;Raft 算法由 Leader 选举、日志同步、安全性组成

watch功能

较局限,一次性触发器

一次watch可以监听所有的事件

数据模型

基于目录的层次模式

参考了zk的数据模型,是个扁平的kv模型

存储

kv存储,使用的是 Concurrent HashMap,内存存储,一般不建议存储较多数据

kv存储,使用bbolt存储引擎,可以处理几个GB的数据。

支持mvcc

不支持

etcd支持mvcc,通过两个b+tree进行版本控制

权限校验

实现的 ACL

etcd 实现了 RBAC 的权限检验

事务能力

提供了简易的事务能力

只提供了版本号的检查能力


在实际的业务场景中具体选择哪个产品,要考虑自己的业务场景,考虑具体的特性,开发语言等等。目前zookeeper 是用 java 语言的,被 Apache 很多项目采用,etcd 是用 go 开发的,在云原生的领域使用比较多。不过从实现上看,etcd提供了更稳定的高负载稳定读写能力。

结尾


综上所述,zookeeper是一个比较成熟的,经过市场验证的分布式协调框架,可以协助我们快速地解决分布式系统中遇到的一些难题。另从上面的介绍中发现,zookeeper的核心是zab,etcd的核心是raft,那可以思考下,还有哪些一致性算法?在分布式存储的架构里中又有哪些关联呢呢?这篇文章不做详细介绍了,后面会针对这块做个详细讲解。

声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>