<audio id="audio" title="加餐 | ZAB协议（三）：如何处理读写请求？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/77/f0e15a20227950d5d9881f18552f5b77.mp3"></audio>

你好，我是韩健！

你应该有这样的体会，如果你想了解一个网络服务，执行的第一个功能肯定是写操作，然后才执行读操作。比如，你要了解ZooKeeper，那么肯定会在zkCli.sh命令行中执行写操作（比如“create /geekbang 123”）写入数据，然后再是读操作（比如“get /geekbang”）查询数据。这样一来，你才会直观地理解ZooKeeper是如何使用的了。

在我看来，任何网络服务最重要的功能就是处理读写请求，因为我们访问网络服务本质上都是在执行读写操作，ZooKeeper也不例外。**而且对ZooKeeper而言，这些功能更为重要，因为在ZooKeeper中，如何处理写请求，关乎着操作的顺序性，而操作的顺序性会影响节点的创建；如何处理读请求，关乎着一致性，它们又影响着客户端是否会读到旧数据。**

接下来，我会从ZooKeeper系统的角度，全面地分析整个读写请求的流程，帮助你更加全面、透彻地理解读写请求背后的原理。

你肯定知道，在ZooKeeper中，写请求是必须在领导者上处理，如果跟随者接收到了写请求，它需要将写请求转发给领导者，当写请求对应的提案被复制到大多数节点上时，领导者会提交提案，并通知跟随者提交提案。而读请求可以在任何节点上处理，也就是说，ZooKeeper实现的是最终一致性。

对你来说，理解了如何处理读写请求，不仅能理解读写这个最重要功能的核心原理，还能更好地理解ZooKeeper的性能和一致性。这样一来，当你在实际场景中安装部署ZooKeeper的时候，就能游刃有余地做资源规划了。比如，如果读请求比较多，你可以增加节点，配置5节点集群，而不是常见的3节点集群。

话不多说，我们进入今天的内容，一起探究ZooKeeper处理读写请求的背后原理和代码实现。

## ZooKeeper处理读写请求的原理

其实，我在[15讲](https://time.geekbang.org/column/article/229975)演示“如何实现操作顺序性”时，就已经介绍了ZooKeeper是如何处理读写请求的了。所以在这里我就不啰嗦了，只是在此基础上，再补充几点。

首先，在ZooKeeper中，与领导者“失联”的节点，是不能处理读写请求的。比如，如果一个跟随者与领导者的连接发生了读超时，设置了自己的状态为LOOKING，那么此时它既不能转发写请求给领导者处理，也不能处理读请求，只有当它“找到”领导者后，才能处理读写请求。

举个例子：当发生分区故障了，C与A（领导者）、B网络不通了，那么C将设置自己的状态为LOOKING，此时在C节点上既不能执行读操作，也不能执行写操作。<br>
<img src="https://static001.geekbang.org/resource/image/22/ad/22dfaa624590885c4b8406deb445afad.jpg" alt="">

其次，当大多数节点进入到广播阶段的时候，领导者才能提交提案，因为提案提交，需要来自大多数节点的确认。

最后，写请求只能在领导者节点上处理，所以ZooKeeper集群写性能约等于单机。而读请求是可以在所有的节点上处理的，所以，读性能是能水平扩展的。也就是说，你可以通过分集群的方式来突破写性能的限制，并通过增加更多节点，来扩展集群的读性能。

熟悉了ZooKeeper处理读写请求的过程和原理后，相信你应该好奇这些功能在ZooKeeper代码中是如何实现的呢？

## ZooKeeper代码是如何实现读写操作的呢？

### 如何实现写操作？

我先来说一说写操作，在ZooKeeper代码中，处理写请求的核心流程就像下图一样（为了帮你更好的理解这部分内容，我来演示一下复杂的情况，也就是跟随者接收到写请求的情况）。

<img src="https://static001.geekbang.org/resource/image/c7/8a/c77c241713b154673e15083fd063428a.jpg" alt="">

接下来，咱们一起走一遍核心代码的流程，加深一下印象。

1.跟随者在FollowerRequestProcessor.processRequest()中接收到写请求。具体来说，写请求是系统在ZooKeeperServer.submitRequestNow()中发给跟随者的。

```
firstProcessor.processRequest(si);

```

而firstProcessor，是在FollowerZooKeeperServer.setupRequestProcessors()中创建的。

```
protected void setupRequestProcessors() {
  // 创建finalProcessor，提交提案或响应查询     
  RequestProcessor finalProcessor = new FinalRequestProcessor(this);
  // 创建commitProcessor，处理提案提交或读请求
  commitProcessor = new CommitProcessor(finalProcessor,   Long.toString(getServerId()), true, getZooKeeperServerListener());
  commitProcessor.start();
  // 创建firstProcessor，接收发给跟随者的请求
  firstProcessor = new FollowerRequestProcessor(this, commitProcessor);
  ((FollowerRequestProcessor) firstProcessor).start();
  // 创建syncProcessor，将提案持久化存储，并返回确认响应给领导者
  syncProcessor = new SyncRequestProcessor(this, new SendAckRequestProcessor(getFollower()));
  syncProcessor.start();
}

```

需要你注意的是，跟随者节点和领导者节点的firstProcessor是不同的，这样当firstProcessor在ZooKeeperServer.submitRequestNow()中被调用时，就分别进入了跟随者和领导者的代码流程。另外，setupRequestProcessors()创建了2条处理链，就像下图的样子。

<img src="https://static001.geekbang.org/resource/image/d5/e7/d5dfec8e64a2d3cb6f905421d5e918e7.jpg" alt="">

其中，处理链1是核心处理链，最终实现了提案提交和读请求对应的数据响应。处理链2实现了提案持久化存储，并返回确认响应给领导者。

2.跟随者在FollowerRequestProcessor.run()中将写请求转发给领导者。

```
// 调用learner.request()将请求发送给领导者
zks.getFollower().request(request);

```

3.领导者在LeaderRequestProcessor.processRequest()中接收写请求，并最终调用pRequest()创建事务（也就是提案），并持久化存储。

```
// 创建事务
pRequest2Txn(request.type, zks.getNextZxid(), request, create2Request, true);
......
// 分配事务标识符
request.zxid = zks.getZxid();
// 调用ProposalRequestProcessor.processRequest()处理写请求，并将事务持久化存储
nextProcessor.processRequest(request);

```

在这里，需要你注意的是，写请求也是在ZooKeeperServer.submitRequestNow()中发给领导者的。

```
firstProcessor.processRequest(si);

```

而firstProcessor，是在LeaderZooKeeperServer.setupRequestProcessors()中创建的。

```
protected void setupRequestProcessors() {
  // 创建finalProcessor，最终提交提案和响应查询
  RequestProcessor finalProcessor = new FinalRequestProcessor(this);
  // 创建toBeAppliedProcessor，存储可提交的提案，并在提交提案后，从toBeApplied队列移除已提交的
  RequestProcessor toBeAppliedProcessor = new Leader.ToBeAppliedRequestProcessor(finalProcessor, getLeader());
  // 创建commitProcessor，处理提案提交或读请求      
  commitProcessor = new CommitProcessor(toBeAppliedProcessor, Long.toString(getServerId()), false, getZooKeeperServerListener());
  commitProcessor.start();
  // 创建proposalProcessor，按照顺序广播提案给跟随者
  ProposalRequestProcessor proposalProcessor = new ProposalRequestProcessor(this, commitProcessor);
        proposalProcessor.initialize();
  // 创建prepRequestProcessor，根据请求创建提案      
  prepRequestProcessor = new PrepRequestProcessor(this, proposalProcessor);
  prepRequestProcessor.start();
  // 创建firstProcessor，接收发给领导者的请求
  firstProcessor = new LeaderRequestProcessor(this, prepRequestProcessor);
  ......
}

```

需要你注意的是，与跟随者类似，setupRequestProcessors()给领导者也创建了2条处理链（其中处理链2是在创建proposalRequestProcessor时创建的）。

<img src="https://static001.geekbang.org/resource/image/f7/83/f780e2d141a766579ba20c3b3d0a2283.jpg" alt="">

其中，处理链1是核心处理链，最终实现了写请求处理（创建提案、广播提案、提交提案）和读请求对应的数据响应。处理链2实现了提案持久化存储，并返回确认响应给领导者自己。

4.领导者在ProposalRequestProcessor.processRequest()中，调用propose()将提案广播给集群所有节点。

```
zks.getLeader().propose(request);

```

5.跟随者在Follower.processPacket()中接收到提案，持久化存储，并返回确认响应给领导者。

```
// 接收、持久化、返回确认响应给领导者
fzk.logRequest(hdr, txn, digest);

```

6.当领导者接收到大多数节点的确认响应（Leader.processAck()）后，最终在CommitProcessor.tryToCommit()提交提案，并广播COMMIT消息给跟随者。

```
// 通知跟随者提交
commit(zxid);
// 自己提交
zk.commitProcessor.commit(p.request);

```

7.当跟随者接收到COMMIT消息后，在FollowerZooKeeperServer.commit()中提交提案，如果最初的写请求是自己接收到的，返回成功响应给客户端。

```
// 必须顺序提交
long firstElementZxid = pendingTxns.element().zxid;
if (firstElementZxid != zxid) {
  LOG.error(&quot;Committing zxid 0x&quot; + Long.toHexString(zxid)
            + &quot; but next pending txn 0x&quot; + 
            Long.toHexString(firstElementZxid));
  ServiceUtils.requestSystemExit(ExitCode.UNMATCHED_TXN_COMMIT.getValue());
}
// 将准备提交的提案从pendingTxns队列移除
Request request = pendingTxns.remove();
request.logLatency(ServerMetrics.getMetrics().COMMIT_PROPAGATION_LATENCY);
// 最终调用FinalRequestProcessor.processRequest()提交提案，并如果最初的写请求是自己接收到的，返回成功响应给客户端
commitProcessor.commit(request);

```

这样，ZooKeeper就完成了写请求的处理。你要特别注意一下，在分布式系统中，消息或者核心信息的持久化存储很关键，也很重要，因为这是保证集群能稳定运行的关键。

当然了，写入数据，最终还是为了后续的数据读取，那么在ZooKeeper中，是如何实现读操作的呢？

### 如何实现读操作？

相比写操作，读操作的处理要简单很多，因为接收到读请求的节点，只需要查询本地数据，然后响应数据给客户端就可以了。读操作的核心代码流程，如图所示。

<img src="https://static001.geekbang.org/resource/image/f4/6d/f405d2a81f374e6e63b49c469506f26d.jpg" alt="">

咱们一起走一遍核心代码的流程，加深一下印象。

1.跟随者在FollowerRequestProcessor.processRequest()中接收到读请求。

2.跟随者在FinalRequestProcessor.processRequest()中查询本地数据，也就是dataTree中的数据。

```
// 处理读请求
case OpCode.getData: {
  ......
  // 查询本地dataTree中的数据
  rsp = handleGetDataRequest(getDataRequest, cnxn, request.authInfo);
  ......
  break;
}

```

3.然后跟随者响应查询到数据给客户端。

```
case OpCode.getData : {
  ......
  // 响应查询到的数据给客户端
  cnxn.sendResponse(hdr, rsp, &quot;response&quot;, path, stat, opCode);
  break;
}

```

你看，这样ZooKeeper就完成读操作的处理。在这里，我想补充一点，你可以dataTree理解为Raft的状态机，提交的数据，最终存放在dataTree中。

## 内容小结

本节课我主要带你了解了ZooKeeper处理读写请求的过程，以及ZooKeeper的代码实现和核心流程。我希望你明确这样几个重点。

1.与领导者“失联”的跟随者（比如发生分区故障时），是既不能处理写请求，也不能处理读请求的。

2.在ZooKeeper中，写请求只能在领导者节点上处理，读请求可以在所有节点上处理，实现的是最终一致性。

因为本讲是ZAB协议的最后一讲，为了帮你后续学习本课程没有提到的内容，我想补充几点。

首先，ZAB的术语众多，而且有些术语表达的是同一个含义，这些术语有些在文档中出现，有些在代码中出现。而你只有准确理解术语，才能更好地理解ZAB协议的原理，所以，我补充一些内容。

- 提案（Proposal）：进行共识协商的基本单元，你可以理解为操作（Operation）或指令（Command），常出现在文档中。
- 事务（Transaction）：也是指提案，常出现在代码中。比如，pRequest2Txn()将接收到的请求转换为事务；再比如，未提交提案会持久化存储在事务日志中。在这里需要你注意的是，这个术语很容易引起误解，因为它不是指更广泛被接受的含义，具有ACID特性的操作序列。

其次，在我看来，Raft算法和ZAB协议很类似，比如主备模式（也就是领导者、跟随者模型）、日志必须是连续的、以领导者的日志为准来实现日志一致等等。那为什么它们会比较类似呢？

**我的看法是，“英雄所见略同”。**比如ZAB协议要实现操作的顺序性，而Raft的设计目标，不仅仅是操作的顺序性，而是线性一致性，这两个目标，都决定了它们不能允许日志不连续，要按照顺序提交日志，那么，它们就要通过上面的方法实现日志的顺序性，并保证达成共识（也就是提交）后的日志不会再改变。

最后，我想就ZAB和Raft做个对比，来具体说说ZAB和Raft的异同。既然我们要做对比，那么首先要定义对比标准，我是这么考虑的：你应该有这样的体会，同一个功能，不同的同学实现的代码都会不一样（比如数据结构、代码逻辑），所以过于细节的比较，尤其是偏系统实现方面的，意义不大（比如跟随者是否转发写请求到领导者，不仅意义不大，而且这是ZAB和Raft都没有约定的，是集群系统需要考虑的），我们可以从核心原理上做对比。

<li>
领导者选举：ZAB采用的“见贤思齐、相互推荐”的快速领导者选举（Fast Leader Election），Raft采用的是“一张选票、先到先得”的自定义算法。在我看来，Raft的领导者选举，需要通讯的消息数更少，选举也更快。
</li>
<li>
日志复制：Raft和ZAB相同，都是以领导者的日志为准来实现日志一致，而且日志必须是连续的，也必须按照顺序提交。
</li>
<li>
读操作和一致性：ZAB的设计目标是操作的顺序性，在ZooKeeper中默认实现的是最终一致性，读操作可以在任何节点上执行；而Raft的设计目标是强一致性（也就是线性一致性），所以Raft更灵活，Raft系统既可以提供强一致性，也可以提供最终一致性。
</li>
<li>
写操作：Raft和ZAB相同，写操作都必须在领导者节点上处理。
</li>
<li>
成员变更：Raft和ZAB都支持成员变更，其中ZAB以动态配置（dynamic configuration）的方式实现的。那么当你在节点变更时，不需要重启机器，集群是一直运行的，服务也不会中断。
</li>
<li>
其他：相比ZAB，Raft的设计更为简洁，比如Raft没有引入类似ZAB的成员发现和数据同步阶段，而是当节点发起选举时，递增任期编号，在选举结束后，广播心跳，直接建立领导者关系，然后向各节点同步日志，来实现数据副本的一致性。**在我看来，ZAB的成员发现，可以和领导者选举合到一起，类似Raft，在领导者选举结束后，直接建立领导者关系，而不是再引入一个新的阶段；数据同步阶段，是一个冗余的设计，可以去除的，因为ZAB不是必须要先实现数据副本的一致性，才可以处理写请求，而且这个设计是没有额外的意义和价值的。**
</li>

另外，ZAB和ZooKeeper强耦合，你无法在实际系统中独立使用；而Raft的实现（比如Hashicorp Raft）是可以独立使用的，编程友好。

## 课堂思考

我提到ZooKeeper提供的是最终一致性，读操作可以在任何节点上执行。那么如果读操作访问的是备份节点，为什么无法保证每次都能读到最新的数据呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这节课让你有所收获，也欢迎你将它分享给更多的朋友。
