Zookeeper原理和代码实现

  Zookeeper是一个分布式一致性协调框架,它使用Zab一致性协议实现分布式一致性,提供树状节点的数据存储,该一致性特性可用于支持集群Leader选举;在一致性的基础上,Zookeeper的分布式一致性数据存储功能可用于名字服务(Naming Server)、动态DNS(DDNS)、分布式锁、分布式数据结构(常见的是队列)、发布订阅、配置中心、注册中心、分布式协调等功能。本文采取《从Paxos到Zookeeper——分布式一致性原理与实践》第7章“Zookeeper技术内幕”的分节方式进行叙述,并试图把Zookeeper的主要特性和核心特性对应的代码实现讲清楚。

1 上层数据模型

1.1 特性

  本节对应"7.1节 系统模型"。
  1.树/节点Zookeeper的数据模型是一颗树,树的节点称为ZNode,在表示节点路径时使用斜杠隔开的Unix风格,例如“/.root/node1/node2”。ZNode有持久节点、临时节点、持久顺序节点、临时顺序节点,共4类。其中临时节点生命周期从会话创建开始到会话结束终止,持久节点生命周期从创建开始到手动删除终止,可以跨多个会话。顺序节点是自动在子节点名称后跟一串数字来维护创建顺序。

  2.事务ID通常把“客户端会话创建+对数据模型进行的一组操作+客户端会话失效”定义成事务,分配一个全局唯一的64位事务ID(ZXID)。

  3.节点数据节点数据由数据内容+状态信息组成,数据内容由客户端控制,状态信息由Zookeeper自动维护,下表展示的是ZNode状态信息含义。
在这里插入图片描述
  4.并发控制Zookeeper用乐观并发控制(OCC)实现事务并发操作同一个数据的一致性,它在并发竞争不大、事务冲突少的情况下拥有比悲观并发控制(PCC)更高的性能。实际使用时和CAS类似,每次更新需要携带最新的版本号(version),如果在本次更新期间有其他事务成功更新,本次携带的版本号就过期了,本次更新会失败,需要重新重试。
  watcher机制

1.2 代码实现

2 序列化与协议

2.1 特性

  1.序列化方式Zookeeper底层使用Jute序列化框架,要求被序列化对象实现Record接口的serialize(OutputArchive archive, String tag)和deSerialize(InputArchive archive, String tag)方法。其中最重要的是OutputArchive 和InputArchive,序列化工具OutputArchive 其实只是简单的把对象的属性(要求属性是基本数据类型或者包装数据类型)转换成byt数组,拼接到FileOutputStream中。InputArchive则是相反的过程,要反序列化的数据没有类型信息,因此要求我们反序列化之前必须知道反序列化的数据是什么类型,而且要按照属性被序列化的顺序来反序列化。
  2.协议Zookeeper使用自己实现的基于TCP/IP的协议。TCP/IP这么受欢迎的原因之一大概就是它能保证消息的完整性,像Zookeeper这种基于TCP/IP的协议就不需要自己去保证完整性,唯一要做的就是制定请求和响应的协议定义,然后实现编解码,这没什么好说的。

请求协议定义

图片描述

响应协议定义

在这里插入图片描述

2.2 代码实现

  1. 序列化和反序列化方式序列化和反序列化代码如下。

/**
 * 准备一个待序列化对象
 */
public class SerializeDemo implements Record {

    private String name;
    private int age;
    private String address;
    
    public SerializeDemo(){}
    public SerializeDemo(String name, int age, String address){
        this.name = name;
        this.age = age;
        this.address = address;
    }
   
    @Override
    public void serialize(OutputArchive archive, String tag) throws IOException {
        archive.startRecord(this, tag);
        archive.writeString(name, "name");
        archive.writeInt(age, "age");
        archive.writeString(address, "address");
        archive.endRecord(this, tag);
    }

    @Override
    public void deserialize(InputArchive archive, String tag) throws IOException {
        archive.startRecord(tag);
        name = archive.readString("name");
        age = archive.readInt("age");
        address = archive.readString("address");
    }
    //省略Getter/Setter
}
public class JuteDemo {

    public static void main(String[] args) throws IOException {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        BinaryOutputArchive outputArchive = BinaryOutputArchive.getArchive(outputStream);
        // 序列化得到二进制数据
        new SerializeDemo("张三", 12, "天涯").serialize(outputArchive, "nameOfThisObj");
        byte[] binary = outputStream.toByteArray();
        // 反序列化得到对象
        ByteArrayInputStream inputStream = new ByteArrayInputStream(binary);
        BinaryInputArchive inputArchive = BinaryInputArchive.getArchive(inputStream);
        SerializeDemo result = new SerializeDemo();
        result.deserialize(inputArchive, "nameOfThisObj");
        System.out.println(result.getAddress());
    }
}

  2.Jute底层实现我们以BinaryOutputArchive#writeString()和BinaryInputArchive#readString()为例,看看如何实现序列化和反序列化的,从代码得到的信息:Archive (Input和Output)有二进制、CSV、XML三种类型,其中二进制没有用到属性名称,是顺序读写,因此序列化和反序列化属性时的顺序要完全一致。

    // 序列化,注意根本没有用tag,所以反序列化的顺序要和序列化顺序一致
    public void writeString(String s, String tag) throws IOException {
        if (s == null) {
            writeInt(-1, "len");
            return;
        }
        ByteBuffer bb = stringToByteBuffer(s);
        writeInt(bb.remaining(), "len");
        //out是java.io.DataOutput,把字符串的字节数组写进去。
        out.write(bb.array(), bb.position(), bb.limit());
    }
    // DataOutput(实际展示的是DataOutputStream)的Write
    public synchronized void write(byte b[], int off, int len)
        throws IOException {
        // out是OutPutStream
        out.write(b, off, len);
        // 统计整个对象的写入长度
        incCount(len);
    }
    // 反序列化,没有用到tag,说明是顺序读取,因此反序列化顺序要和序列化顺序一致
    public String readString(String tag) throws IOException {
    	int len = in.readInt();
    	if (len == -1) return null;
        checkLength(len);
    	byte b[] = new byte[len];
    	in.readFully(b);
    	return new String(b, "UTF8");
    }

3 客户端

3.1 特性

  由于Zookeeper客户端和服务端采用长连接,所以Zookeeper原生客户端只需要提供简单的功能:一个Zookeeper集群信息维护器HostProvider,一个网络连接器ClientCnxn,一个发送请求的线程sendThread,一个处理“来自sendThread收到的服务器事件”的线程,一个Watcher。Watcher接口有process和processResult方法,用于处理那些事件。

3.2 代码实现

  略

4 会话

4.1 特性

  会话创建客户端连接到服务端时,Zookeeper服务端创建会话,会话用Session表示。Session有四个属性:sessionID,TImeout(超时时间段),TickTime(超时时间点),isClosing。Session管理工作由SessionTracer负责,每当新创建一个连接,Zookeeper会解析请求并创建一个Session。
  会话超时Session管理的工作之一是会话过期检测,方式:创建一个会话时根据超时时间段计算超时时间点,对超时时间点按进一法近似处理,所有会话之间间隔2s的倍数,在同一个时间点过期的会话放一起,称为分桶策略。Zookeeper只需要每2s处理一次超时的桶里的会话即可。因此Zookeeper的超时策略是通过轮询实现,而且并不精确,误差在2s以内。
在这里插入图片描述
  会话保活会话活性由客户端发送心跳进行保持,Zookeeper每收到一次心跳或读写请求就对Session的过期时间点进行更新,并把它迁移到正确的桶里。
  会话清理一旦会话过期,SessionTracer(会话管理工具)会修改Session状态为isClosing,然后向Leader发起关闭会话请求,通过Zab协议的一致性来保证整个集群都知道该会话已关闭。接下来会删除会话的临时节点,最后删除Session,关闭连接。
  重连重连是指链接中的TCP长连接被断开后再次连上,协议中会携带ZXID。重连后有三种结果:超时时间内重连则一切正常;超时时间外重连则会话已过期,需要客户端重新发起新的会话创建请求;如果断开前后连接上的是不同服务端节点,且会话没有过期,则称会话转移,此时断开前的服务器若在重连后收到断开前的请求就会抛出SessionMovedException(不可思议,断开前的请求过了很久才被断开前的服务端收到)。

4.2 代码实现

  略

5 服务端启动

5.1 特性

  Zookeeper服务端可以认为是一个服务器程序,这部分我们重点关注启动了哪些部件,有助于我们自己设计服务器。
  配置从conf/zoo.cfg读取配置信息,封装成QuorumPeerConfig,然后依次配置:FileTxnSnapLog的路径,ZooKeeperServer的过期时间、ServerCnxnFactory的端口信息。
  启动启动过程分为单机版和集群版,流程见下图,不多说:
在这里插入图片描述
在这里插入图片描述

5.2 代码实现

  这里只看集群模式的启动代码,注意ServerCnxnFactory才是处理外部Socket连接的类。

// org.apache.zookeeper.server.quorum.QuorumPeerMain中的main()方法启动本方法。
protected void initializeAndRun(String[] args) throws ConfigException, IOException {
        QuorumPeerConfig config = new QuorumPeerConfig();
        if (args.length == 1) {
            config.parse(args[0]);
        }
        // 创建并启动周期清理任务,专门清理快照和事务日志
        DatadirCleanupManager purgeMgr = new DatadirCleanupManager(config
                .getDataDir(), config.getDataLogDir(), config
                .getSnapRetainCount(), config.getPurgeInterval());
        purgeMgr.start();

        if (args.length == 1 && config.servers.size() > 0) {
            // 集群模式启动
            runFromConfig(config);
        } else {
            // 单机模式启动
            ZooKeeperServerMain.main(args);
        }
    //org.apache.zookeeper.server.quorum.QuorumPeerMain,使用配置启动服务
    public void runFromConfig(QuorumPeerConfig config) throws IOException {
      try {
          ServerCnxnFactory cnxnFactory = ServerCnxnFactory.createFactory();
          cnxnFactory.configure(config.getClientPortAddress(),
                                config.getMaxClientCnxns());
          // 配置集群信息管理器,存储本节点信息和其他节点信息
          quorumPeer = getQuorumPeer();
          ... ... 省略更多quorumPeer配置
          quorumPeer.setElectionType(config.getElectionAlg());
          quorumPeer.setMyid(config.getServerId());
          quorumPeer.setTickTime(config.getTickTime());
          // 配置权限管理服务
          quorumPeer.initialize();
          // 启动Zookeeper服务
          quorumPeer.start();
          // 主线程等待后台线程退出
          quorumPeer.join();
      } catch (InterruptedException e) { }
    }
    // org.apache.zookeeper.server.quorum.QuorumPeer,启动Zookeeper服务
    public synchronized void start() {
        // 加载Zookeeper数据库
        loadDataBase();
        // 监听端口,启动主循环,处理外部请求
        cnxnFactory.start(); 
        // 启动选举流程       
        startLeaderElection();
        // 注册JMX
        super.start();
    }

  ServerCnxnFactory有两个实现类,分别是NIOServerCnxnFactory和NettyServerCnxnFactory,后者采用Netty框架实现,请求处理使用自定义的netty Handler。这里我们只看前者NIOServerCnxnFactory实现:

public void run() {
        while (!ss.socket().isClosed()) {
            try { // 一次取一大波可以IO操作的事件
                selector.select(1000);
                Set<SelectionKey> selected;
                synchronized (this) {
                    selected = selector.selectedKeys();
                }
                ArrayList<SelectionKey> selectedList = new ArrayList<SelectionKey>(
                        selected);
                Collections.shuffle(selectedList);
                for (SelectionKey k : selectedList) {
                    if ((k.readyOps() & SelectionKey.OP_ACCEPT) != 0) {
                        SocketChannel sc = ((ServerSocketChannel) k.channel()).accept();
                        InetAddress ia = sc.socket().getInetAddress();
                        int cnxncount = getClientCnxnCount(ia);
                        if (maxClientCnxns > 0 && cnxncount >= maxClientCnxns){
                            // 连接数过多,直接关闭
                            sc.close();
                        } else {
                            // 非阻塞模式
                            sc.configureBlocking(false);
                            SelectionKey sk = sc.register(selector, SelectionKey.OP_READ);
                            // 创建一个新连接
                            NIOServerCnxn cnxn = createConnection(sc, sk);
                            // 统计请求信息
                            sk.attach(cnxn);
                            addCnxn(cnxn);
                        }
                    } else if ((k.readyOps() & (SelectionKey.OP_READ | SelectionKey.OP_WRITE)) != 0) {
                        // 可读/可写事件类型
                        NIOServerCnxn c = (NIOServerCnxn) k.attachment();
                        // 处理读写事件
                        c.doIO(k);
                    } else { }
                }
                selected.clear();
            } catch (RuntimeException e) { } catch (Exception e) { }
        }
        closeAll();
    }

6 Leader选举

6.1 特性

  启动集群启动集群时由于大家的ZXID相同,因此实际ServerID最大的会当选Leader。
  故障恢复故障恢复会触发选举,首先准备投票计数器(统计所有ServerID的票数)、其他节点信息列表,准备自己的选票<ServerID, ZXID,Round>,然后投自己一票并不断轮询节点列表(可能有多轮),如果超时,则不断放松超时时间,直至上限。节点在每次收到其它节点的选票后先PK轮次(Round),如果比自己的轮次小则忽略,比自己的大则清空投票计数器,把自己的选票轮次更新然后重新发给每个节点。如果轮次相同则PK事务ID(ZXID),ZXID大的获胜,ZXID相同则PK ServerID,ServerID大的获胜。如果比自己的轮次小则忽略,比自己的大则把收到的选票发给其他所有节点。每次发出选票前统计票数,统计出某个节点的选票超过半数时该节点当选为领导,自己从Looking状态转换为Follower;如果当选领导的是自己,则状态转换成Leader,开始连接Follower,选举阶段结束。
  整个过程相当于一群君子在选举,有比自己优秀的(其他节点的选票比自己的大)则帮他宣传(把他的选票发给其他节点),最终选出最优秀的那个人(大部分节点都服它)。

选举流程图 (LogicClock是选举轮次Round)

在这里插入图片描述

6.2 代码实现

  选举算法选举算法所在的位置。逻辑位置:选举算法发送选票->sendqueue->workerSender->queueSendMap->网线;网线->recvQueue->WorkerReceiver->recvqueue->选举算法接收选票。物理位置:org.apache.zookeeper.server.quorm.AuthFastLeaderElection

// org.apache.zookeeper.server.quorm.AuthFastLeaderElection#lookForLeader(),该方法实现接口Election。
public Vote lookForLeader() throws InterruptedException {
        try {
            // 投票计数器
            HashMap<InetSocketAddress, Vote> recvset = new HashMap<InetSocketAddress, Vote>();
            // 投票收件箱
            HashMap<InetSocketAddress, Vote> outofelection =  new HashMap<InetSocketAddress, Vote>();
            // 逻辑时钟,就是按轮(Round)投票里的“轮”
            logicalclock++;
            // 心目中最适合当Leader的节点信息(第一轮设置成自己,相当于给自己投一票)
            proposedLeader = self.getId();
            proposedZxid = self.getLastLoggedZxid();
            // 选票投递到其他所有节点的收件箱
            sendNotifications();
            // 当前节点状态如果是Looking就一直循环
            while (self.getPeerState() == ServerState.LOOKING) {
                // 在2*finalizeWait超时时间内从收件箱读取其他节点发来的选票
                Notification n = recvqueue.poll(2 * finalizeWait,
                        TimeUnit.MILLISECONDS);
                // 如果没有收到其它节点来信,再发一次自己的选票
                if (n == null) {
                    if (((!outofelection.isEmpty()) || (recvset.size() > 1)))
                        sendNotifications();
                } else
                // 收到其他节点的信息,读取这个节点的状态
                    switch (n.state) {
                    case LOOKING:
                        // 对比轮次
                        if (n.epoch > logicalclock) {
                        //自己的轮次比较小,重设自己的逻辑时钟(轮次),清空投票计数器
                            logicalclock = n.epoch;
                            recvset.clear();
                            if (totalOrderPredicate(n.leader, n.zxid)) {
                            // 如果该节点看起来比自己更适合当Leader(zxid比自己大或者zxid相同但ServerID比自己大)
                                proposedLeader = n.leader;
                                proposedZxid = n.zxid;
                            }
                            // 散发这个未来可能是Leader的节点(不管是不是自己)的选票给其他所有节点。
                            sendNotifications();
                        } else if (n.epoch < logicalclock) {
                        // 忽略比自己轮次小的
                            break;
                        } else if (totalOrderPredicate(n.leader, n.zxid)) {
                        // 该节点比自己适合当Leader,帮他散发选票
                            proposedLeader = n.leader;
                            proposedZxid = n.zxid;
                            sendNotifications();
                        }
                        // 把选票放入投票计数器
                        recvset.put(n.addr, new Vote(n.leader, n.zxid));
    
                        // 投票计数器的节点数量等于节点列表的数量
                        if (self.getVotingView().size() == recvset.size()) {
                            // 最可能是Leader的节点ID是自己的Id,自己就真的是Leader,否则其他节点是
                            self.setPeerState((proposedLeader == self.getId()) ? 
                                    ServerState.LEADING: ServerState.FOLLOWING);
                            // 逻辑时钟(纪元或称轮次)++
                            leaveInstance();
                            // 返回Leader信息
                            return new Vote(proposedLeader, proposedZxid);
                        } else if (termPredicate(recvset, proposedLeader,
                                proposedZxid)) {
                            Thread.sleep(finalizeWait);
                            // 不断的从收件箱取出选票,如果发现更适合做Leader的,暂时退出循环
                            while ((!recvqueue.isEmpty())
                                    && !totalOrderPredicate(
                                            recvqueue.peek().leader, recvqueue
                                                    .peek().zxid)) {
                                recvqueue.poll();
                            }
                            if (recvqueue.isEmpty()) {
                            // 收件箱已经空了(投票已经结束),如果最适合最的是自己,改变自己的状态为LEADING ,否则FOLLOWING
                                self.setPeerState(
                                        (proposedLeader == self.getId()) ? 
                                         ServerState.LEADING :
                                         ServerState.FOLLOWING);
                                // 纪元++
                                leaveInstance();
                                return new Vote(proposedLeader, proposedZxid);
                            }
                        }
                        break;
                    case LEADING:
                    // 这张选票的节点是Leader,改变自己状态,通知其他节点并返回
                        outofelection.put(n.addr, new Vote(n.leader, n.zxid));
                        if (termPredicate(outofelection, n.leader, n.zxid)) {
                            self.setPeerState((n.leader == self.getId()) ? 
                                    ServerState.LEADING: ServerState.FOLLOWING);
    
                            leaveInstance();
                            return new Vote(n.leader, n.zxid);
                        }
                        break;
                    case FOLLOWING:
                    // 这张选票的节点是Follower(说明Leader已经选出来了),改变自己状态,通知其他节点并返回
                        outofelection.put(n.addr, new Vote(n.leader, n.zxid));
                        if (termPredicate(outofelection, n.leader, n.zxid)) {
                            self.setPeerState((n.leader == self.getId()) ? 
                                    ServerState.LEADING: ServerState.FOLLOWING);
                            leaveInstance();
                            return new Vote(n.leader, n.zxid);
                        }
                        break;
                    default:
                        break;
                    }
            }
            return null;

7 各服务器角色

7.1 特性

  Leader:集群数据同步的协调者,一个集群中最多只有一个,只有它会处理来自外部的事务请求。
  Follower:转发事务请求,处理事务请求和非事务请求,会参与Leader选举。
  Observer:数据备份,还可以处理非事务请求,不处理事务请求,不参与Leader选举。

7.2 代码实现

  略。

8 请求处理

8.1 特性

(待完善)

8.2 代码实现

(待完善)

9 底层数据模型

  本节对应“7.9节 数据与存储”。

9.1特性

  Zookeeper数据分为内存数据和持久化数据两部分,内存数据是一棵树,持久化数据又包括事务日志和快照两部分。
  内存数据:Zookeeper内存数据是一棵目录树DataTee,这棵树整体存放所有非临时节点,临时节点,Leader节点信息,候选Leader节点信息,路径字典树权限控制列表(ACL)。每级目录DataNode包含了父节点指针,二进制data,当前节点在ACL中的ID,子节点ID列表,持久化状态。
  事务日志:事务日志和快照可以分别指定保存路径。事务日志文件以最新事务ID命名,大小固定,内容是二进制,文件内容的格式:事务操作时间,客户端会话ID,CXID(客户端操作序列号)、ZXID、操作类型、节点路径、节点数据内容。

  快照:快照保存了内存中的全量数据,默认不删除之前的快照文件但可以配置。

  数据恢复与数据同步:快照数据是周期生成的,所以一定不是全部数据,恢复过程中还要结合事务日志一起恢复到最新的事务ID。数据恢复是单机数据从磁盘加载到内存,数据同步是对集群的数据进行同步,主要方法有:直接差异化同步,先回滚再差异化同步,回滚同步,全量同步。
  同步方法的适用场景:设当前Zookeeper的最大事务ID为peerLastZxid,集群当前最大事务ID为①maxCommited,集群当前最小事务ID为minCommited,集群中的事务介于minCommited、maxCommited之间。
peerLastZxid < minCommited,全量同步,因为当前节点落后太多。
②minCommited < peerLastZxid < maxCommited,且peerLastZxid存在于minCommited——maxCommited,差异化同步,因为当前节点只是少了部分数据。
③minCommited < peerLastZxid < maxCommited,且peerLastZxid不存在于minCommited——maxCommited,先回滚再差异化同步,因为当前节点不只是少了部分数据,还出现与集群有差异的数据。
④ peerLastZxid > maxCommited,回滚同步,因为当前节点比集群多出了部分数据。

9.2 代码实现

ZKDatabase

public class ZKDatabase {
    // 树
    protected DataTree dataTree;
    // Session过期时间
    protected ConcurrentHashMap<Long, Integer> sessionsWithTimeouts;
    // 事务日志和快照工具类
    protected FileTxnSnapLog snapLog;
    // 集群最大最小事务ID
    protected long minCommittedLog, maxCommittedLog;
    public static final int commitLogCount = 500;
    protected static int commitLogBuffer = 700;
    // 提案
    protected LinkedList<Proposal> committedLog = new LinkedList<Proposal>();
    // 读写锁
    protected ReentrantReadWriteLock logLock = new ReentrantReadWriteLock();
    

DataTree代码实现:

public class DataTree {
    private static final Logger LOG = LoggerFactory.getLogger(DataTree.class);
    // 节点Hash表
    private final ConcurrentHashMap<String, DataNode> nodes =
        new ConcurrentHashMap<String, DataNode>();
    // 数据监听器管理器
    private final WatchManager dataWatches = new WatchManager();
    // 子节点监听器管理器
    private final WatchManager childWatches = new WatchManager();
    // 根节点路径
    private static final String rootZookeeper = "/";
    // 状态管理信息所在目录,="/zookeeper"
    private static final String procZookeeper = Quotas.procZookeeper;
    // 备选节点信息所在目录,="/zookeeper/quota
    private static final String quotaZookeeper = Quotas.quotaZookeeper;
    // 路径字典树
    private final PathTrie pTrie = new PathTrie();
    // 临时节点
    private final Map<Long, HashSet<String>> ephemerals =
        new ConcurrentHashMap<Long, HashSet<String>>();
    // 权限控制列表(ACL)
    private final ReferenceCountedACLCache aclCache = new ReferenceCountedACLCache();

DataNode代码实现:

public class DataNode implements Record {
    // 父节点
    DataNode parent;
    // 当前节点的二进制数据
    byte data[];
    // 在ACL列表中的键
    Long acl;
    // 持久化状态
    public StatPersisted stat;
    // 子节点
    private Set<String> children = null;

  3.协议Zookeeper使用自己实现的基于TCP/IP的协议。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值