4. 可靠的请求-回复模式

第四章 - 可靠的请求-回复模式 #

第三章 - 高级请求-回复模式 涵盖了 ZeroMQ 请求-回复模式的高级用法,并提供了可运行的示例。本章着眼于可靠性这个普遍问题,并在 ZeroMQ 核心请求-回复模式的基础上构建了一系列可靠的消息模式。

在本章中,我们重点关注用户空间的请求-回复 模式,这些是可重用的模型,有助于你设计自己的 ZeroMQ 架构

  • Lazy Pirate 模式:客户端的可靠请求-回复
  • Simple Pirate 模式:使用负载均衡的可靠请求-回复
  • Paranoid Pirate 模式:带心跳的可靠请求-回复
  • Majordomo 模式:面向服务的可靠排队
  • Titanic 模式:基于磁盘/断开连接的可靠排队
  • Binary Star 模式:主备服务器故障转移
  • Freelance 模式:无代理的可靠请求-回复

什么是“可靠性”? #

大多数谈论“可靠性”的人并不真正知道他们在说什么。我们只能根据失败来定义可靠性。也就是说,如果我们能够处理一组定义明确且易于理解的失败,那么相对于这些失败,我们就是可靠的。不多不少。所以让我们来看看分布式 ZeroMQ 应用中可能导致失败的原因,按概率大致降序排列:

  • 应用程序代码是罪魁祸首。它可能崩溃退出,冻结并停止响应输入,运行太慢而无法处理输入,耗尽所有内存等等。

  • 系统代码——例如我们使用 ZeroMQ 编写的代理——可能因与应用程序代码相同的原因而死亡。系统代码应该比应用程序代码更可靠,但它仍然可能崩溃和失败,特别是如果它试图为慢客户端排队消息时,可能会耗尽内存。

  • 消息队列可能会溢出,这通常发生在学会残酷对待慢客户端的系统代码中。当队列溢出时,它会开始丢弃消息。因此,我们得到的是“丢失”的消息。

  • 网络可能会发生故障(例如,WiFi 关闭或超出范围)。ZeroMQ 在这种情况下会自动重连,但在重连期间,消息可能会丢失。

  • 硬件可能会发生故障,并导致该机器上运行的所有进程随之终止。

  • 网络可能以奇特的方式发生故障,例如,交换机上的某些端口可能损坏,导致部分网络无法访问。

  • 整个数据中心可能遭受雷击、地震、火灾,或更常见的电源或冷却故障。

要使一个软件系统能够完全可靠地应对所有这些可能的失败,这是一项极其困难和昂贵的工作,超出了本书的范围。

由于上述列表中的前五种情况涵盖了大型公司以外现实世界需求的 99.9%(根据我刚刚进行的一项高度科学的研究,该研究还告诉我 78% 的统计数据都是凭空捏造的,而且我们永远不应该相信未经我们自己证伪的统计数据),所以这就是我们将要探讨的内容。如果你是一家大型公司,有钱用于处理最后两种情况,请立即联系我的公司!我家海滨别墅后面有一个大洞,正等着改造成行政游泳池呢。

设计可靠性 #

因此,简单粗暴地说,可靠性就是“在代码冻结或崩溃时保持事物正常运行”,这种情况我们简称“死亡”。然而,我们希望保持正常运行的事物比简单的消息更复杂。我们需要研究每种核心 ZeroMQ 消息模式,看看即使代码“死亡”时,我们如何(如果可能的话)使其继续工作。

让我们逐一 살펴보

  • 请求-回复:如果服务器“死亡”(正在处理请求时),客户端可以发现这一点,因为它收不到回复。然后它可以恼火地放弃,或者稍后等待并重试,或者寻找另一台服务器,等等。至于客户端“死亡”,我们现在可以将其视为“别人的问题”。

  • 发布-订阅:如果客户端“死亡”(已经收到一些数据),服务器并不知道。发布-订阅不会从客户端向服务器发送任何信息。但客户端可以通过带外方式(例如,通过请求-回复)联系服务器,并询问“请重新发送我遗漏的所有内容”。至于服务器“死亡”,这超出了本文的范围。订阅者也可以自我验证它们是否运行得太慢,并在确实太慢时采取行动(例如,警告操作员并“死亡”)。

  • 管道:如果一个 worker “死亡”(正在工作时),ventilator(鼓风机)并不知道。管道就像时间的齿轮一样,只在一个方向工作。但是下游的 collector(收集器)可以检测到某个任务没有完成,并向 ventilator 发送一条消息说:“嘿,重发任务 324!”如果 ventilator 或 collector 死亡,最初发送工作批次的任何上游客户端都可能因为等待太久而感到厌烦,然后重发全部任务。这并不优雅,但系统代码应该不会经常死亡到成为问题。

在本章中,我们将重点讨论请求-回复,这是可靠消息传递中容易实现的部分。

基本的请求-回复模式(一个 REQ 客户端套接字对 REP 服务器套接字进行阻塞发送/接收)在处理最常见的故障类型方面得分较低。如果服务器在处理请求时崩溃,客户端只会永远挂起。如果网络丢失了请求或回复,客户端也会永远挂起。

由于 ZeroMQ 能够静默重连对端、负载均衡消息等等,请求-回复仍然比 TCP 好得多。但它仍然不足以应对实际工作。唯一可以真正信任基本请求-回复模式的情况是在同一进程中的两个线程之间,因为没有网络或独立的服务器进程会“死亡”。

然而,只需做一些额外的工作,这种基础模式就能成为分布式网络中实际工作的良好基础,并且我们得到了一组可靠请求-回复 (RRR) 模式,我喜欢称之为 Pirate 模式(我希望你最终能明白这个笑话)。

根据我的经验,客户端连接服务器的方式大致有三种。每种都需要特定的可靠性处理方法

  • 多个客户端直接与单个服务器通信。用例:客户端需要与之通信的单个知名服务器。我们旨在处理的故障类型:服务器崩溃和重启,以及网络断开连接。

  • 多个客户端与一个代理通信,该代理将工作分发给多个 worker。用例:面向服务的事务处理。我们旨在处理的故障类型:worker 崩溃和重启,worker 忙循环,worker 过载,队列崩溃和重启,以及网络断开连接。

  • 多个客户端与多个服务器通信,中间没有代理。用例:分布式服务,如名称解析。我们旨在处理的故障类型:服务崩溃和重启,服务忙循环,服务过载,以及网络断开连接。

这些方法各有优缺点,而且你经常会混合使用它们。我们将详细 بررسی 这三种方法。

客户端可靠性 (Lazy Pirate 模式) #

通过对客户端进行一些更改,我们可以实现非常简单的可靠请求-回复。我们称之为 Lazy Pirate 模式。我们不是进行阻塞接收,而是:

  • 轮询 REQ 套接字,并仅在确定收到回复时才进行接收。
  • 如果在超时时间内没有收到回复,则重新发送请求。
  • 如果几次请求后仍未收到回复,则放弃该事务。

如果你尝试以非严格的发送/接收方式使用 REQ 套接字,你会收到错误(技术上,REQ 套接字实现了一个小型有限状态机来强制执行发送/接收的乒乓式交互,因此错误代码称为“EFSM”)。这在我们想要在 pirate 模式中使用 REQ 时稍微有点烦人,因为我们在收到回复之前可能会发送多次请求。

一个相当不错的暴力解决方案是在出错后关闭并重新打开 REQ 套接字

C | C++ | Delphi | Go | Haskell | Haxe | Java | Lua | Perl | PHP | Python | Ruby | Rust | Tcl | Ada | Basic | C# | CL | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Q | Racket | Scala | OCaml

将其与配套的服务器一起运行

C | C++ | Delphi | Go | Haskell | Haxe | Java | Lua | Perl | PHP | Python | Ruby | Rust | Scala | Tcl | Ada | Basic | C# | CL | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Q | Racket | OCaml
图 47 - Lazy Pirate 模式

要运行此测试用例,请在两个控制台窗口中分别启动客户端和服务器。服务器将在发送几条消息后随机出现异常行为。你可以检查客户端的响应。以下是服务器的典型输出:

I: normal request (1)
I: normal request (2)
I: normal request (3)
I: simulating CPU overload
I: normal request (4)
I: simulating a crash

以下是客户端的响应:

I: connecting to server...
I: server replied OK (1)
I: server replied OK (2)
I: server replied OK (3)
W: no response from server, retrying...
I: connecting to server...
W: no response from server, retrying...
I: connecting to server...
E: server seems to be offline, abandoning

客户端对每条消息进行编号,并检查回复是否完全按顺序返回:确保没有请求或回复丢失,也没有回复返回多次或乱序。多运行几次测试,直到你确信这个机制确实有效。在生产应用中不需要序号;它们只是帮助我们验证我们的设计。

客户端使用 REQ 套接字,并执行暴力关闭/重新打开操作,因为 REQ 套接字强制执行严格的发送/接收循环。你可能会想使用 DEALER 代替,但这并不是一个好主意。首先,这意味着你需要模拟 REQ 在 envelope(信封)方面做的特殊处理(如果你忘了那是什么,这说明你不想自己去实现它)。其次,这意味着你可能会收到意料之外的回复。

仅在客户端处理失败的情况适用于一组客户端与单个服务器通信的场景。它可以处理服务器崩溃,但仅限于恢复意味着重新启动同一台服务器的情况。如果存在永久性错误,例如服务器硬件电源故障,这种方法将不起作用。由于服务器中的应用程序代码通常是任何架构中最大的故障源,因此依赖于单个服务器并非明智之举。

所以,优缺点:

  • 优点:易于理解和实现。
  • 优点:易于与现有客户端和服务器应用程序代码一起工作。
  • 优点:ZeroMQ 会自动重试实际的重连直到成功。
  • 缺点:不会故障转移到备用或备选服务器。

基本可靠排队 (Simple Pirate 模式) #

我们的第二种方法是在 Lazy Pirate 模式的基础上扩展一个队列代理,该代理允许我们透明地与多个服务器通信,我们可以更准确地称这些服务器为“worker”。我们将分阶段开发这个模式,从一个最简工作模型 Simple Pirate 模式开始。

在所有这些 Pirate 模式中,worker 都是无状态的。如果应用程序需要一些共享状态,例如共享数据库,我们在设计消息框架时并不知道这些。拥有一个队列代理意味着 worker 可以随时加入和离开,而客户端对此一无所知。如果一个 worker 死了,另一个会接管。这是一个不错、简单的拓扑结构,只有一个真正的弱点,那就是中心队列本身,它可能变得难以管理,并成为单点故障。

图 48 - Simple Pirate 模式

队列代理的基础是 第三章 - 高级请求-回复模式 中的负载均衡代理。为了处理死亡或阻塞的 worker,我们需要做最少的事情是什么?结果是,出乎意料地少。客户端已经有了重试机制。所以使用负载均衡模式会工作得很好。这符合 ZeroMQ 的哲学,即我们可以通过在中间插入简单的代理来扩展请求-回复这样的点对点模式。

我们不需要特殊的客户端;我们仍然使用 Lazy Pirate 客户端。以下是队列,它与负载均衡代理的主要任务完全相同:

C | C++ | Delphi | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | C# | CL | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

以下是 worker,它使用了 Lazy Pirate 服务器并将其适配到负载均衡模式(使用 REQ 的“ready”信号):

C | C++ | Delphi | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | C# | CL | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

为了测试这个模式,启动几个 worker、一个 Lazy Pirate 客户端和一个队列,顺序不限。你会看到 worker 最终都会崩溃,然后客户端会重试并最终放弃。队列永远不会停止,你可以反复重启 worker 和客户端。这个模型适用于任意数量的客户端和 worker。

健壮可靠排队 (Paranoid Pirate 模式) #

图 49 - Paranoid Pirate 模式

Simple Pirate 队列模式运行得相当不错,尤其因为它只是两个现有模式的组合。尽管如此,它确实有一些弱点:

  • 它在队列崩溃并重启时不够健壮。客户端会恢复,但 worker 不会。虽然 ZeroMQ 会自动重连 worker 的套接字,但对于新启动的队列来说,worker 没有发送准备信号,所以它们似乎不存在。要解决这个问题,我们必须从队列向 worker 发送心跳,以便 worker 能检测到队列何时消失。

  • 队列无法检测到工作器故障,因此如果工作器在空闲时死亡,队列无法将其从工作器队列中移除,直到队列向其发送请求。客户端白白等待和重试。这虽然不是一个关键问题,但也不好。为了使其正常工作,我们让工作器向队列发送心跳,以便队列可以在任何阶段检测到丢失的工作器。

我们将在一个恰当而严谨的偏执海盗模式(Paranoid Pirate Pattern)中修复这些问题。

之前我们为工作器使用了 REQ 套接字。对于偏执海盗工作器,我们将切换到 DEALER 套接字。这样做的好处是允许我们随时发送和接收消息,而不是像 REQ 那样强制同步的发送/接收。DEALER 的缺点是我们必须自己进行信封管理(请重新阅读 第 3 章 - 高级请求-回复模式 以了解此概念的背景)。

我们仍然使用懒惰海盗客户端。这是偏执海盗队列代理:

C | C++ | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

队列通过工作器的心跳机制扩展了负载均衡模式。心跳机制是那些看似“简单”但很难做对的事情之一。稍后我会对此进行更多解释。

这是偏执海盗工作器

C | C++ | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

关于此示例的一些注释

  • 代码中包含故障模拟,如之前一样。这使得它 (a) 非常难以调试,以及 (b) 重用时很危险。当你想要调试它时,请禁用故障模拟。

  • 工作器使用了一种与我们为懒惰海盗客户端设计的相似的重连策略,但有两个主要区别:(a) 它采用指数退避,以及 (b) 它无限重试(而客户端在报告失败前只重试几次)。

尝试运行客户端、队列和工作器,例如使用这样的脚本:

ppqueue &
for i in 1 2 3 4; do
    ppworker &
    sleep 1
done
lpclient &

你应该会看到工作器在模拟崩溃时一个接一个地死亡,客户端最终放弃。你可以停止并重新启动队列,客户端和工作器都会重新连接并继续运行。而且无论你对队列和工作器做什么,客户端永远不会收到乱序的回复:整个链要么正常工作,要么客户端放弃。

心跳 #

心跳解决了判断对端是活着还是死亡的问题。这并不是 ZeroMQ 特有的问题。TCP 有一个很长的超时时间(30 分钟左右),这意味着你可能无法知道对端是死了、断开连接了,还是带着一箱伏特加、一个红发女郎和一笔巨额报销款去布拉格度周末了。

做对心跳机制并不容易。在编写偏执海盗示例时,花了我大约五个小时才让心跳机制正常工作。其余的请求-回复链可能只花了十分钟。尤其容易产生“误报故障”,即对端因为心跳发送不正常而判定对方已断开连接。

我们将看看人们在使用 ZeroMQ 时处理心跳机制的三种主要方法。

不予理会 #

最常见的方法是完全不做心跳,寄希望于一切顺利。很多(如果不是大多数)ZeroMQ 应用程序都这样做。在许多情况下,ZeroMQ 通过隐藏对端来鼓励这种做法。这种方法会导致什么问题呢?

  • 当我们在一个跟踪对端的应用程序中使用 ROUTER 套接字时,随着对端断开和重新连接,应用程序会泄漏内存(应用程序为每个对端持有的资源),并且变得越来越慢。

  • 当我们使用基于 SUB 或 DEALER 的数据接收方时,我们无法区分“好的沉默”(没有数据)和“坏的沉默”(另一端已死亡)。当接收方知道对方死亡时,例如,它可以切换到备用路由。

  • 如果我们使用长时间保持沉默的 TCP 连接,在某些网络中它会直接断开。发送一些东西(技术上讲,这更像是一种“保活”而不是心跳)会保持网络连接活跃。

单向心跳 #

第二个选项是每个节点大约每秒向其对端发送一个心跳消息。当一个节点在一定超时时间内(通常是几秒)没有收到另一端的消息时,它就会将该对端视为已死亡。听起来不错,对吧?遗憾的是,并非如此。这在某些情况下可行,但在其他情况下存在棘手的边缘情况。

对于 pub-sub 模式,这是可行的,而且是你能使用的唯一模式。SUB 套接字无法回复 PUB 套接字,但 PUB 套接字可以愉快地向其订阅者发送“我还活着”的消息。

作为一种优化,你只在没有实际数据要发送时才发送心跳。此外,如果网络活动是问题(例如,在活动会耗尽电池的移动网络上),你可以逐渐降低发送心跳的频率。只要接收方能够检测到故障(活动突然停止),那就可以了。

这是这种设计的典型问题

  • 当我们发送大量数据时,它可能不准确,因为心跳会延迟在数据后面。如果心跳延迟,你可能会因为网络拥堵而遇到误报超时和断开连接。因此,无论发送方是否省略了心跳,始终将任何接收到的数据视为心跳。

  • 尽管 pub-sub 模式会丢弃发往已消失接收方的消息,但 PUSH 和 DEALER 套接字会将其排队。因此,如果你向一个已死亡的对端发送心跳,并且它重新上线,它会收到你发送的所有心跳,数量可能高达数千个。天哪,天哪!

  • 这种设计假定整个网络中的心跳超时时间是相同的。但这并不准确。一些对端会想要非常积极的心跳(以便快速检测故障),而另一些则会想要非常宽松的心跳(以便让休眠的网络保持休眠状态并节省电量)。

Ping-Pong 心跳 #

第三种选择是使用 ping-pong 对话。一个对端向另一个对端发送 ping 命令,后者回复 pong 命令。这两个命令都没有负载。Ping 和 pong 不相关。由于在某些网络中,“客户端”和“服务器”的角色是任意的,我们通常规定任何一个对端都可以发送 ping 并期望收到 pong 回复。然而,由于超时时间取决于动态客户端最了解的网络拓扑,通常是客户端 ping 服务器。

这适用于所有基于 ROUTER 的代理。我们在第二种模型中使用的相同优化使这种方法效果更好:将任何接收到的数据视为 pong,并且只在不发送其他数据时发送 ping。

偏执海盗模式的心跳 #

对于偏执海盗模式,我们选择了第二种方法。这可能不是最简单的方法:如果今天来设计,我可能会尝试 ping-pong 方法。然而,其原理是相似的。心跳消息在两个方向上异步流动,任何一端都可以判定另一端“已死亡”并停止与之通信。

在工作器中,我们是这样处理来自队列的心跳的:

  • 我们计算一个活跃度(liveness),它是我们在判定队列死亡之前还可以错过的心跳次数。它开始时为三,每次错过一个心跳就递减一次。
  • 我们在zmq_poll循环中,每次等待一秒,这是我们的心跳间隔。
  • 如果在这段时间内收到来自队列的任何消息,我们就将活跃度重置为三。
  • 如果在这段时间内没有消息,我们就递减活跃度。
  • 如果活跃度达到零,我们就认为队列已死亡。
  • 如果队列已死亡,我们就销毁套接字,创建一个新的并重新连接。
  • 为了避免打开和关闭过多的套接字,我们在重新连接之前会等待一定的时间间隔,并且每次都将间隔加倍,直到达到 32 秒。

我们是这样处理发送队列的心跳的:

  • 我们计算何时发送下一个心跳;这是一个单独的变量,因为我们只与一个对端(队列)通信。
  • zmq_poll循环中,每当超过这个时间,我们就向队列发送一个心跳。

这是工作器的核心心跳代码:


#define HEARTBEAT_LIVENESS  3       //  3-5 is reasonable
#define HEARTBEAT_INTERVAL  1000    //  msecs
#define INTERVAL_INIT       1000    //  Initial reconnect
#define INTERVAL_MAX       32000    //  After exponential backoff

...
//  If liveness hits zero, queue is considered disconnected
size_t liveness = HEARTBEAT_LIVENESS;
size_t interval = INTERVAL_INIT;

//  Send out heartbeats at regular intervals
uint64_t heartbeat_at = zclock_time () + HEARTBEAT_INTERVAL;

while (true) {
    zmq_pollitem_t items [] = { { worker,  0, ZMQ_POLLIN, 0 } };
    int rc = zmq_poll (items, 1, HEARTBEAT_INTERVAL * ZMQ_POLL_MSEC);

    if (items [0].revents & ZMQ_POLLIN) {
        //  Receive any message from queue
        liveness = HEARTBEAT_LIVENESS;
        interval = INTERVAL_INIT;
    }
    else
    if (--liveness == 0) {
        zclock_sleep (interval);
        if (interval < INTERVAL_MAX)
            interval *= 2;
        zsocket_destroy (ctx, worker);
        ...
        liveness = HEARTBEAT_LIVENESS;
    }
    //  Send heartbeat to queue if it's time
    if (zclock_time () > heartbeat_at) {
        heartbeat_at = zclock_time () + HEARTBEAT_INTERVAL;
        //  Send heartbeat message to queue
    }
}

队列也做同样的事情,但为每个工作器管理一个过期时间。

以下是关于你自己实现心跳的一些技巧:

  • 使用zmq_poll或反应器作为应用程序主任务的核心。

  • 首先构建对端之间的心跳机制,通过模拟故障进行测试,然后再构建其余的消息流。之后再添加心跳机制要棘手得多。

  • 使用简单的跟踪(即打印到控制台)来使其工作。为了帮助你跟踪对端之间的消息流,使用 zmsg 提供的转储(dump)方法,并按顺序给你的消息编号,这样你就可以看到是否有遗漏。

  • 在实际应用程序中,心跳必须是可配置的,并且通常需要与对端协商。一些对端可能需要非常积极的心跳,低至 10 毫秒。其他对端可能距离很远,希望心跳间隔高达 30 秒。

  • 如果你对不同的对端有不同的心跳间隔,你的 poll 超时时间应该是这些间隔中最低的(最短的)。不要使用无限超时。

  • 在你用于发送消息的同一套接字上进行心跳,这样你的心跳也充当了保活(keep-alive)机制,阻止网络连接变得陈旧(有些防火墙对静默连接不太友好)。

契约和协议 #

如果你仔细留意,你会发现偏执海盗模式与简单海盗模式不互通,原因在于心跳。但是,我们如何定义“互通性”呢?为了保证互通性,我们需要一种契约,一种约定,它允许不同时间、不同地点的不同团队编写能够保证协同工作的代码。我们称之为“协议”。

在没有规范的情况下进行实验很有趣,但这并不是实际应用程序的合理基础。如果我们想用另一种语言编写工作器怎么办?我们是否必须阅读代码才能了解其工作原理?如果出于某种原因我们想更改协议怎么办?即使是一个简单的协议,如果成功,也会演变并变得更复杂。

缺乏契约是“一次性应用程序”的明显标志。所以,让我们为这个协议编写一份契约。我们该怎么做呢?

rfc.zeromq.org 有一个 Wiki,我们专门将其作为公共 ZeroMQ 契约的存放地。要创建新的规范,如果需要,请在 Wiki 上注册并遵循说明。这相当直接,尽管编写技术文本并非人人所爱。

我花了大约十五分钟起草了新的 海盗模式协议(Pirate Pattern Protocol)。它不是一个庞大的规范,但足以作为论证的基础(“你的队列与 PPP 不兼容;请修复它!”)。

将 PPP 变成一个真正的协议需要更多工作

  • READY 命令中应该包含协议版本号,以便区分不同版本的 PPP。

  • 目前,READY 和 HEARTBEAT 与请求和回复并不完全区分。为了区分它们,我们需要一种包含“消息类型”部分的报文结构。

面向服务的可靠队列 (Majordomo 模式) #

图 50 - Majordomo 模式

进步的美妙之处在于,当律师和委员会不参与时,进展是多么迅速。一页纸的 MDP 规范将 PPP 变成了一些更可靠的东西。这就是我们应该设计复杂架构的方式:先写下契约,然后再编写软件来实现它们。

Majordomo 协议(MDP)以一种有趣的方式扩展和改进了 PPP:它在客户端发送的请求中添加了“服务名称”,并要求工作器注册特定服务。添加服务名称将我们的偏执海盗队列变成了面向服务的代理。MDP 的好处在于它源自可工作的代码、一个更简单的先祖协议(PPP),以及一套精确的改进措施,每个改进都解决了一个明确的问题。这使得起草工作变得容易。

要实现 Majordomo,我们需要为客户端和工作器编写一个框架。要求每个应用程序开发人员阅读规范并使其工作,而他们本可以使用更简单的 API 来为他们完成工作,这真的不明智。

因此,我们的第一个契约(MDP 本身)定义了我们分布式架构的各个部分如何相互通信,而我们的第二个契约则定义了用户应用程序如何与我们将要设计的技术框架进行通信。

Majordomo 有两个部分:客户端和服务端。由于我们将编写客户端和工作器应用程序,因此需要两个 API。这是使用简单的面向对象方法构建的客户端 API 草图:


mdcli_t *mdcli_new     (char *broker);
void     mdcli_destroy (mdcli_t **self_p);
zmsg_t  *mdcli_send    (mdcli_t *self, char *service, zmsg_t **request_p);

就这样。我们向代理打开一个会话,发送一个请求消息,收到一个回复消息,最后关闭连接。这是工作器 API 的草图:


mdwrk_t *mdwrk_new     (char *broker,char *service);
void     mdwrk_destroy (mdwrk_t **self_p);
zmsg_t  *mdwrk_recv    (mdwrk_t *self, zmsg_t *reply);

它或多或少是对称的,但工作器对话稍微有点不同。工作器第一次执行 recv() 时,会传递一个空回复。之后,它会传递当前的回复,并获取一个新的请求。

客户端和工作器 API 构造起来相当简单,因为它们很大程度上基于我们已经开发的偏执海盗代码。这是客户端 API:

C | C++ | Go | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Haskell | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

让我们通过一个执行 10 万次请求-回复循环的示例测试程序来看看客户端 API 的实际应用:

C | C++ | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

这是工作器 API:

C | C++ | Go | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Haskell | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Rust | Scala | OCaml

让我们看看 worker API 实际运行起来是什么样子,这里有一个实现回显服务的示例测试程序

C | C++ | Go | Haskell | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Rust | Scala | OCaml

关于 worker API 代码的一些注意事项

  • 这些 API 是单线程的。这意味着,例如,worker 不会在后台发送心跳。令人高兴的是,这正是我们想要的:如果 worker 应用卡住了,心跳就会停止,broker 也将停止向该 worker 发送请求。

  • worker API 不会进行指数退避;这样做不值得增加额外的复杂性。

  • 这些 API 不会进行任何错误报告。如果出现意外情况,它们会引发断言(或异常,取决于语言)。这对于参考实现来说是理想的,这样任何协议错误都会立即显示出来。对于实际应用,API 应该能够健壮地处理无效消息。

您可能想知道为什么 worker API 会手动关闭并重新打开其套接字,而 ZeroMQ 在对端消失并重新出现时会自动重新连接套接字。回顾 Simple Pirate 和 Paranoid Pirate worker 即可理解。尽管 ZeroMQ 会在 broker 死亡并重新启动时自动重新连接 worker,但这不足以让 worker 向 broker 重新注册。我知道至少有两种解决方案。这里使用的最简单方法是 worker 使用心跳来监控连接,如果确定 broker 已死,就关闭其套接字并使用新套接字重新开始。另一种方法是当 broker 收到来自 worker 的心跳时,对其进行质询并要求它们重新注册。这需要协议支持。

现在让我们设计 Majordomo broker。其核心结构是一组队列,每个服务一个。我们会在 worker 出现时创建这些队列(我们也可以在 worker 消失时删除它们,但现在先忽略这一点,因为它会变得复杂)。此外,我们为每个服务维护一个 worker 队列。

以下是 broker

C | C++ | Go | Haskell | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Rust | Scala | OCaml

这是目前为止我们见过最复杂的示例。它将近 500 行代码。编写并使其具有一定健壮性花费了两天时间。然而,对于一个完整的面向服务的 broker 来说,这仍然是一小段代码。

关于 broker 代码的一些注意事项

  • Majordomo 协议允许我们在单个套接字上处理客户端和 worker。这对于部署和管理 broker 的人来说更友好:它只需要一个 ZeroMQ 端点,而不是大多数代理所需的两个。

  • 这个 broker 正确地实现了 MDP/0.1 的所有功能(据我所知),包括在 broker 发送无效命令时的断开连接、心跳机制等等。

  • 它可以扩展为运行多个线程,每个线程管理一个套接字和一组客户端及 worker。这对于对大型架构进行分段可能很有用。C 代码已经围绕一个 broker 类组织,使得这一点变得简单。

  • 实现主/备或活/活的 broker 可靠性模型很容易,因为 broker 除了服务存在状态外,基本上没有其他状态。客户端和 worker 需要在首选 broker 不在线时选择另一个。

  • 示例使用五秒一次的心跳,主要是为了减少启用跟踪时的输出量。对于大多数局域网(LAN)应用,实际值会更低。然而,任何重试都必须足够慢,以便服务能够重新启动,例如至少 10 秒。

我们后来改进和扩展了协议以及 Majordomo 实现,该实现现在有自己的 Github 项目。如果您想要一个真正可用的 Majordomo 栈,请使用 GitHub 项目。

异步 Majordomo 模式 #

前面章节中的 Majordomo 实现是简单而笨拙的。客户端就是原始的 Simple Pirate,被包装在一个漂亮的 API 中。当我在一台测试机上启动一个客户端、broker 和 worker 时,它可以在大约 14 秒内处理 100,000 个请求。这部分归因于代码,它高兴地复制消息帧,好像 CPU 周期是免费的一样。但真正的问题在于我们正在进行网络往返。ZeroMQ 会禁用 Nagle 算法,但往返仍然很慢。

理论上,理论是很棒的,但在实践中,实践更重要。让我们用一个简单的测试程序来衡量往返的实际成本。这个程序发送一批消息,首先等待每条消息的回复,然后批量发送,并批量读取所有回复。这两种方法做的工作相同,但结果却大相径庭。我们模拟一个客户端、broker 和 worker

C | C++ | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

在我的开发机上,这个程序输出:

Setting up test...
Synchronous round-trip test...
 9057 calls/second
Asynchronous round-trip test...
 173010 calls/second

注意,客户端线程在开始前会有一个小的暂停。这是为了绕过 router 套接字的一个“特性”:如果您向一个尚未连接的对端地址发送消息,该消息会被丢弃。在这个示例中,我们没有使用负载均衡机制,所以如果没有睡眠,如果 worker 线程连接太慢,它会丢失消息,导致我们的测试出现问题。

正如我们所见,在最简单的情况下,往返比异步的“尽可能快地推入管道”方法慢 20 倍。让我们看看是否可以将此应用于 Majordomo 以使其更快。

首先,我们将客户端 API 修改为使用两个独立的方法进行发送和接收


mdcli_t *mdcli_new     (char *broker);
void     mdcli_destroy (mdcli_t **self_p);
int      mdcli_send    (mdcli_t *self, char *service, zmsg_t **request_p);
zmsg_t  *mdcli_recv    (mdcli_t *self);

将同步客户端 API 重构为异步版本,字面上只需要几分钟的工作量

C | C++ | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Rust | Scala | OCaml

区别在于

  • 我们使用 DEALER 套接字而不是 REQ,因此我们在每个请求和每个响应之前使用一个空分隔符帧来模拟 REQ。
  • 我们不重试请求;如果应用程序需要重试,它可以自行处理。
  • 我们将同步的send方法分解为独立的sendrecv方法。
  • 其中,sendsend 方法是异步的,发送后立即返回。调用者因此可以在收到回复之前发送多个消息。
  • 其中,recvrecv 方法等待一个回复(带超时)并将其返回给调用者。

以下是相应的客户端测试程序,它发送 100,000 条消息,然后接收 100,000 条回复

C | C++ | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Rust | Scala | OCaml

代理和工作节点没有改变,因为我们完全没有修改协议。我们立即看到了性能提升。这是同步客户端处理 10 万次请求-应答循环的情况

$ time mdclient
100000 requests/replies processed

real    0m14.088s
user    0m1.310s
sys     0m2.670s

这是异步客户端,使用单个工作节点的情况

$ time mdclient2
100000 replies received

real    0m8.730s
user    0m0.920s
sys     0m1.550s

快了一倍。不错,但让我们启动 10 个工作节点,看看它如何处理流量

$ time mdclient2
100000 replies received

real    0m3.863s
user    0m0.730s
sys     0m0.470s

它不是完全异步的,因为工作节点严格按照上次使用的时间获取消息。但使用更多工作节点时,它的伸缩性会更好。在我的 PC 上,达到八个左右的工作节点后,速度就不会再提升了。四个核心的性能也就到此为止了。但只需几分钟的工作,我们就获得了 4 倍的吞吐量提升。代理仍然没有优化。它大部分时间都花在复制消息帧上,而不是使用零拷贝,尽管它可以做到。不过,我们以相当低的代价实现了每秒 2.5 万次可靠的请求/应答调用。

然而,异步 Majordomo 模式并非完美无缺。它有一个根本性弱点,即如果代理崩溃,它无法在不进行额外工作的情况下恢复。如果你看一下mdcliapi2代码,你会发现它在失败后没有尝试重新连接。一个适当的重新连接将需要以下几点

  • 每个请求上有一个编号,每个应答上有一个匹配的编号,这最好通过修改协议来实现强制执行。
  • 在客户端 API 中跟踪并保存所有未完成的请求,即那些尚未收到应答的请求。
  • 在故障转移的情况下,客户端 API 需要将所有未完成的请求 重新发送 给代理。

这并非决定性问题,但这确实表明性能往往意味着复杂性。这对 Majordomo 来说值得做吗?这取决于你的使用场景。对于每个会话只调用一次的名称查找服务,不值得。对于服务数千个客户端的 Web 前端,可能值得。

服务发现 #

好的,我们有了一个很好的面向服务的代理,但我们无法知道某个特定的服务是否可用。我们知道请求是否失败了,但不知道原因。能够询问代理“echo 服务正在运行吗?”是很有用的。最明显的方法是修改我们的 MDP/客户端协议,添加命令来询问这一点。但 MDP/客户端最大的魅力在于其简单性。向其中添加服务发现会使其像 MDP/工作节点协议一样复杂。

另一个选项是像电子邮件那样,要求返回无法投递的请求。这在异步世界中可能运作良好,但它也增加了复杂性。我们需要区分被返回的请求和正常的应答,并正确处理它们的方法。

让我们尝试利用我们已有的构建,在 MDP 之上构建,而不是修改它。服务发现本身就是一个服务。它实际上可能是多个管理服务之一,例如“禁用服务 X”、“提供统计数据”等等。我们想要的是一个通用、可扩展的解决方案,它不影响协议或现有应用程序。

于是,这里有一个小型 RFC,它将此功能叠加在 MDP 之上:Majordomo 管理接口 (MMI)。我们已经在代理中实现了它,但除非你通读全文,否则你可能错过了这一点。我将解释它在代理中是如何工作的

  • 当客户端请求一个以mmi.开头的服务时,我们会在内部处理它,而不是将其路由到工作节点。

  • 在这个代理中,我们只处理一个服务,那就是mmi.service,即服务发现服务。

  • 请求的载荷是一个外部服务(由工作节点提供的实际服务)的名称。

  • 代理根据是否有为该服务注册的工作节点,返回“200”(OK)或“404”(未找到)。

以下是如何在应用程序中使用服务发现

C | C++ | Go | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Haskell | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Rust | Scala | OCaml

在工作节点运行和不运行的情况下尝试这个程序,你应该会看到这个小程序相应地报告“200”或“404”。我们示例代理中 MMI 的实现很简陋。例如,如果一个工作节点消失了,服务仍然显示为“存在”。实际上,代理应该在某个可配置的超时后移除没有工作节点的服务。

幂等服务 #

幂等性不是吃药就能有的东西。它意味着重复操作是安全的。检查时钟是幂等的。把信用卡借给孩子则不是。虽然许多客户端到服务器的使用场景是幂等的,但有些不是。幂等使用场景的例子包括

  • 无状态任务分发,即服务器是无状态工作节点的流水线,它们完全基于请求提供的状态计算应答。在这种情况下,多次执行同一请求是安全的(尽管效率不高)。

  • 将逻辑地址转换为绑定或连接端点的名称服务。在这种情况下,多次进行相同的查找请求是安全的。

以下是非幂等使用场景的例子

  • 日志服务。人们不希望同一条日志信息被记录多次。

  • 任何对下游节点有影响的服务,例如,向其他节点发送信息。如果该服务多次收到同一请求,下游节点将收到重复的信息。

  • 以某种非幂等方式修改共享数据的任何服务;例如,一个扣除银行账户金额的服务,如果没有额外处理,就不是幂等的。

当我们的服务器应用程序不是幂等时,我们必须更仔细地考虑它们究竟会在何时崩溃。如果应用程序在空闲时或处理请求时崩溃,通常没问题。我们可以使用数据库事务来确保借记和贷记(如果发生的话)总是同时完成。如果服务器在发送应答时崩溃,那是个问题,因为就它而言,它已经完成了工作。

如果网络在应答返回客户端的途中中断,同样的问题也会发生。客户端会认为服务器崩溃了并重新发送请求,服务器会重复同样的工作,这不是我们想要的。

为了处理非幂等操作,可以使用相当标准的解决方案:检测并拒绝重复请求。这意味着

  • 客户端必须为每个请求加上一个唯一的客户端标识符和一个唯一的消息编号。

  • 服务器在发送应答之前,使用客户端 ID 和消息编号的组合作为键来存储应答。

  • 服务器在收到来自某个客户端的请求时,首先检查是否有针对该客户端 ID 和消息编号的应答。如果有,它不会处理该请求,而只是重新发送应答。

断开连接可靠性 (Titanic 模式) #

一旦你意识到 Majordomo 是一个“可靠的”消息代理,你可能会想添加一些旋转的铁锈(即基于铁的硬盘盘片)。毕竟,这对所有企业消息系统都有效。这个想法如此诱人,以至于不得不对其持否定态度有点令人伤心。但冷酷的犬儒主义是我的专长之一。所以,你不希望基于“铁锈”的代理位于你架构中心的一些原因有

  • 正如你所见,Lazy Pirate 客户端表现得非常出色。它适用于各种架构,从直接客户端到服务器到分布式队列代理。它确实倾向于假设工作节点是无状态和幂等的。但我们可以在不依赖“铁锈”的情况下绕过这个限制。

  • “铁锈”带来了一系列问题,从性能低下到需要管理、修复和处理清晨 6 点的崩溃,因为它们不可避免地会在日常运营开始时出现问题。Pirate 模式的总体美妙之处在于它们的简单性。它们不会崩溃。如果你仍然担心硬件问题,你可以转向完全没有代理的对等模式。我将在本章后面解释。

然而,话虽如此,基于“铁锈”的可靠性有一个合理的用例,那就是异步断开连接的网络。它解决了 Pirate 的一个主要问题,即客户端必须实时等待应答。如果客户端和工作节点只是间歇性地连接(可以类比电子邮件),我们就不能在客户端和工作节点之间使用无状态网络。我们必须在中间层维护状态。

于是,这里是 Titanic 模式,在这种模式下,我们将消息写入磁盘以确保它们永不丢失,无论客户端和工作节点的连接多么间歇性。正如我们在服务发现中所做的那样,我们将 Titanic 叠加在 MDP 之上,而不是对其进行扩展。这样做非常省力,因为这意味着我们可以在一个专门的工作节点中实现我们的即发即忘可靠性,而不是在代理中。这对于以下几个原因来说非常出色

  • 容易得多,因为我们分而治之:代理处理消息路由,工作节点处理可靠性。
  • 它允许我们将用一种语言编写的代理与用另一种语言编写的工作节点混合使用。
  • 它允许我们独立地发展即发即忘技术。

唯一的缺点是代理和硬盘之间多了一次网络跳跃。但这些好处绝对值得。

有许多方法可以构建持久化的请求-应答架构。我们将尝试一种简单且无痛的方法。在研究了几个小时后,我能想到的最简单的设计是“代理服务”。也就是说,Titanic 完全不影响工作节点。如果客户端希望立即获得应答,它就直接与服务对话,并希望服务可用。如果客户端乐意等待一段时间,它就会转而与 Titanic 对话,并问:“嘿,伙计,在我去买菜的时候,你能帮我处理这件事吗?”

图 51 - Titanic 模式

因此,Titanic 既是工作节点又是客户端。客户端和 Titanic 之间的对话大致如下

  • 客户端:请帮我接受这个请求。Titanic:好的,完成了。
  • 客户端:你有我的应答吗?Titanic:是的,在这里。或者,不,还没有。
  • 客户端:好的,你现在可以清除那个请求了,我很满意。Titanic:好的,完成了。

而 Titanic 与代理和工作节点之间的对话是这样的

  • Titanic:嘿,代理,有 coffee 服务吗?代理:嗯,好像有。
  • Titanic:嘿,coffee 服务,请帮我处理这件事。
  • Coffee:当然,给你。
  • Titanic:太好了!

你可以仔细研究这种情况以及可能的失败场景。如果工作节点在处理请求时崩溃,Titanic 会无限次重试。如果应答在某个地方丢失,Titanic 会重试。如果请求被处理了但客户端没有收到应答,它会再次请求。如果 Titanic 在处理请求或应答时崩溃,客户端会再次尝试。只要请求被完全提交到安全存储中,工作就不会丢失。

握手过程有些冗长,但可以进行管道化,即客户端可以使用异步 Majordomo 模式处理大量工作,然后在稍后获取应答。

我们需要某种方法让客户端请求 它的 应答。我们将有很多客户端请求相同的服务,并且客户端会以不同的身份消失和重新出现。这里有一个简单、合理安全的解决方案

  • 每个请求生成一个通用唯一标识符 (UUID),Titanic 在将请求排队后将其返回给客户端。
  • 当客户端请求应答时,它必须指定原始请求的 UUID。

在实际情况中,客户端可能希望安全地存储其请求 UUID,例如,在本地数据库中。

在我们急着去写另一个正式规范(真有趣!)之前,让我们考虑一下客户端如何与 Titanic 对话。一种方法是使用单个服务并向其发送三种不同的请求类型。另一种看起来更简单的方法是使用三种服务

  • titanic.request:存储请求消息,并返回该请求的 UUID。
  • titanic.reply:根据给定的请求 UUID 获取应答(如果可用)。
  • titanic.close:确认应答已存储并处理。

我们只需创建一个多线程工作节点,正如我们从 ZeroMQ 的多线程经验中看到的那样,这是非常简单的。然而,让我们首先勾勒出 Titanic 在 ZeroMQ 消息和帧方面的样子。这为我们提供了 Titanic 服务协议 (TSP)

使用 TSP 对客户端应用程序来说显然比直接通过 MDP 访问服务工作量更大。以下是最短的健壮“echo”客户端示例

C | C++ | Haxe | Java | PHP | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Julia | Lua | Node.js | Objective-C | ooc | Perl | Q | Racket | Rust | Scala | OCaml

当然,这可以也应该被封装在某种框架或 API 中。让普通应用程序开发者学习消息传递的全部细节是不健康的:这会让他们伤脑筋,耗费时间,并且提供了太多制造复杂 bug 的方式。此外,这也使得添加智能变得困难。

例如,这个客户端在每个请求上都会阻塞,而在实际应用程序中,我们希望在任务执行期间能够做其他有用的工作。这需要一些不简单的管道来构建后台线程并与之干净地通信。这是那种你想封装在一个简单易用的 API 中的东西,这样普通开发者就不会误用。这与我们用于 Majordomo 的方法相同。

以下是 Titanic 的实现。这个服务器按照提议,使用三个线程处理这三个服务。它使用最简单粗暴的方法实现了完全的磁盘持久化:每个消息一个文件。它简单得令人害怕。唯一复杂的部分是它维护了一个单独的所有请求队列,以避免反复读取目录。

C | C++ | Haxe | Java | PHP | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Julia | Lua | Node.js | Objective-C | ooc | Perl | Q | Racket | Rust | Scala | OCaml

要测试这一点,启动mdbrokertitanic,然后运行ticlient。现在启动mdworker任意次数,你应该会看到客户端收到应答并愉快地退出。

关于这段代码的一些注意事项

  • 注意,有些循环以发送消息开始,有些则以接收消息开始。这是因为 Titanic 在不同的角色中既充当客户端又充当工作节点。
  • Titanic 代理使用 MMI 服务发现协议,仅向看起来正在运行的服务发送请求。由于我们的小型 Majordomo 代理中的 MMI 实现相当简陋,这并非总是有效。
  • 我们使用进程内连接将新的请求数据从titanic.request服务端发送到主分发器。这避免了分发器必须扫描磁盘目录、加载所有请求文件并按日期/时间排序的麻烦。

这个示例的重要之处不在于性能(尽管我没有测试,但肯定很糟糕),而在于它如何很好地实现了可靠性契约。要尝试它,启动 mdbroker 和 titanic 程序。然后启动 ticlient,再启动 mdworker echo 服务。你可以使用-v选项来执行详细活动跟踪。你可以停止和重新启动任何部分,除了客户端,并且不会丢失任何东西。

如果你想在实际案例中使用 Titanic,你会很快问:“如何让它更快?”

以下是我会做的事情,从示例实现开始

  • 对所有数据使用单个磁盘文件,而不是多个文件。操作系统通常更擅长处理几个大文件,而不是许多小文件。
  • 将该磁盘文件组织成一个循环缓冲区,以便新的请求可以连续写入(只有极少量的回绕)。一个线程全速写入磁盘文件,可以快速工作。
  • 将索引保存在内存中,并在启动时从磁盘缓冲区重建索引。这避免了为了将索引完全安全地保存在磁盘上所需的额外磁盘头抖动。你可能希望在每条消息后执行一次 fsync,或者每隔 N 毫秒执行一次,如果你愿意在系统故障时丢失最后的 M 条消息的话。
  • 使用固态硬盘,而不是旋转的氧化铁盘片(机械硬盘)。
  • 预分配整个文件,或大块分配,这使得循环缓冲区可以根据需要增长和缩小。这避免了碎片化,并确保大多数读写是连续的。

等等。我不建议将消息存储在数据库中,即使是“快速”的键/值存储也不建议,除非你真的喜欢某个特定的数据库并且不担心性能问题。你将为这种抽象付出高昂的代价,比直接操作原始磁盘文件要慢十到一千倍。

如果你想让 Titanic 更加可靠,可以将请求复制到第二个服务器上,这个服务器应放置在第二个位置,距离你的主要位置足够远以抵御核攻击,但又不能太远以免引入过高的延迟。

如果你想让 Titanic 快得多且可靠性降低,则完全将请求和应答存储在内存中。这将为你提供断开连接网络的功能,但请求不会在 Titanic 服务器自身崩溃时幸存。

高可用对 (Binary Star 模式) #

图 52 - 高可用对,正常运行

Binary Star 模式将两台服务器配置成一个主备高可用对。在任何给定时间,其中一台(活动的)接受客户端应用程序的连接。另一台(被动的)不执行任何操作,但这两台服务器相互监控。如果活动的服务器从网络中消失,被动的服务器会在一定时间后接管成为活动的服务器。

我们在 iMatix 为我们的 OpenAMQ 服务器 开发了 Binary Star 模式。我们设计它的目的是

  • 提供一个简单易懂的高可用解决方案。
  • 足够简单以便实际理解和使用。
  • 在需要时可靠地进行故障转移,且仅在需要时进行。

假设我们有一个 Binary Star 对正在运行,以下是导致故障转移的不同场景

  • 运行主服务器的硬件出现致命问题(电源爆炸、机器着火,或者有人不小心拔掉了电源),并从网络中消失。应用程序发现这一点后,会重新连接到备用服务器。
  • 主服务器所在的网络段崩溃——也许是路由器受到电涌冲击——应用程序开始重新连接到备用服务器。
  • 主服务器崩溃或被操作员终止,且未自动重启。
图 53 - 故障转移期间的高可用对

从故障转移中恢复的工作流程如下

  • 操作员重启主服务器,并修复导致其从网络中消失的任何问题。
  • 操作员在对应用程序造成最小中断的时刻停止备用服务器。
  • 当应用程序重新连接到主服务器后,操作员重启备用服务器。

恢复(切换回使用主服务器作为活动的)是一个手动操作。痛苦的经验告诉我们,自动恢复是不可取的。原因有几个

  • 故障转移会导致应用程序的服务中断,可能持续 10-30 秒。如果发生真正的紧急情况,这比完全中断要好得多。但如果恢复过程又导致额外的 10-30 秒中断,最好在非高峰时段进行,也就是用户不在网络上的时间。

  • 当出现紧急情况时,首要任务是让修复人员拥有确定性。自动恢复给系统管理员带来了不确定性,他们无法再不经二次检查就确定哪台服务器是主导的。

  • 自动恢复可能导致网络先故障转移然后又恢复的情况,这使得操作员难以分析发生了什么。服务中断了,但原因却不清楚。

话虽如此,如果主服务器(再次)运行且备用服务器发生故障,Binary Star 模式将切换回主服务器。事实上,这就是我们引发恢复的方式。

Binary Star 对的关闭过程可以按以下方式进行:

  1. 先停止被动服务器,然后在稍后的任何时间停止活动服务器;或者
  2. 以任何顺序停止两台服务器,但要在彼此关闭的几秒钟之内完成。

如果先停止活动服务器,然后停止被动服务器,并且两者之间的延迟超过了故障转移超时时间,则会导致应用程序先断开连接,然后重新连接,然后再次断开连接,这可能会干扰用户。

详细要求 #

Binary Star 模式尽可能简单,同时仍能准确工作。事实上,当前设计是第三次彻底重新设计。我们发现之前的每个设计都过于复杂,试图做太多事情,于是我们不断删减功能,直到得到一个易于理解、易于使用且足够可靠值得使用的设计。

以下是我们对高可用性架构的要求:

  • 故障转移旨在为硬件故障、火灾、事故等灾难性系统故障提供保障。对于普通服务器崩溃,有更简单的恢复方法,我们已经介绍过了。

  • 故障转移时间应在 60 秒以内,最好在 10 秒以内。

  • 故障转移必须自动发生,而恢复必须手动进行。我们希望应用程序能够自动切换到备用服务器,但不希望它们切换回主服务器,除非操作员已经修复了问题,并决定此时适合再次中断应用程序。

  • 客户端应用程序的语义应该简单易懂,方便开发者理解。理想情况下,它们应该隐藏在客户端 API 中。

  • 应该有清晰的网络架构师指导,说明如何避免可能导致出现 split brain syndrome(脑裂综合症)的设计,在这种情况下,Binary Star 对中的两台服务器都认为自己是活动服务器。

  • 不应对两台服务器的启动顺序存在任何依赖关系。

  • 必须能够在不停止客户端应用程序的情况下对任一服务器进行计划性停止和重启(尽管它们可能被迫重新连接)。

  • 操作员必须能够随时监控两台服务器。

  • 必须能够使用高速专用网络连接来连接两台服务器。也就是说,故障转移同步必须能够使用特定的 IP 路由。

我们做出以下假设:

  • 单个备用服务器提供了足够的保障;我们不需要多层备份。

  • 主服务器和备用服务器都能够承受相同的应用程序负载。我们不尝试在服务器之间平衡负载。

  • 有足够的预算来覆盖一个完全冗余的备用服务器,它几乎一直在空闲。

我们不尝试涵盖以下内容:

  • 使用活动备用服务器或负载均衡。在 Binary Star 对中,备用服务器处于非活动状态,直到主服务器离线之前,它不做任何有用的工作。

  • 以任何方式处理持久消息或事务。我们假设存在一个由不可靠(且可能不受信任)的服务器或 Binary Star 对组成的网络。

  • 任何自动的网络探索。Binary Star 对是在网络中手动明确定义的,并且应用程序知道它(至少在其配置数据中)。

  • 服务器之间的状态或消息复制。所有服务器端状态必须在应用程序发生故障转移时由应用程序重新创建。

以下是我们在 Binary Star 中使用的关键术语:

  • 主服务器 (Primary):通常或最初是活动状态的服务器。

  • 备用服务器 (Backup):通常处于被动状态的服务器。如果主服务器从网络中消失,并且当客户端应用程序请求连接备用服务器时,它将变为活动状态。

  • 活动状态 (Active):接受客户端连接的服务器。最多只有一台活动服务器。

  • 被动状态 (Passive):如果活动服务器消失,则接管的服务器。请注意,当 Binary Star 对正常运行时,主服务器处于活动状态,备用服务器处于被动状态。当发生故障转移时,角色会互换。

要配置 Binary Star 对,你需要:

  1. 告诉主服务器备用服务器的位置。
  2. 告诉备用服务器主服务器的位置。
  3. 可选地,调整故障转移响应时间,这两台服务器必须相同。

主要的调优考虑是你希望服务器检查其对等状态的频率,以及你希望多快激活故障转移。在我们的示例中,故障转移超时值默认为 2000 毫秒。如果你减少这个值,备用服务器将更快地接管成为活动状态,但可能会在主服务器可以恢复的情况下接管。例如,你可能使用一个 shell 脚本包装了主服务器,如果它崩溃就重启它。在这种情况下,超时时间应该高于重启主服务器所需的时间。

为了让客户端应用程序与 Binary Star 对正常工作,它们必须:

  1. 知道两台服务器的地址。
  2. 尝试连接主服务器,如果失败,则连接备用服务器。
  3. 检测连接失败,通常使用心跳机制。
  4. 尝试按顺序重新连接主服务器,然后是备用服务器,重试之间的延迟至少应等于服务器故障转移超时时间。
  5. 在服务器上重新创建它们所需的所有状态。
  6. 如果消息需要可靠,则重新发送在故障转移期间丢失的消息。

这不是一项简单的工作,我们通常会将其封装在一个 API 中,以将其从实际的最终用户应用程序中隐藏起来。

以下是 Binary Star 模式的主要限制:

  • 一个服务器进程不能属于多个 Binary Star 对。
  • 一个主服务器只能有一个备用服务器,不能更多。
  • 被动服务器不做任何有用的工作,因此是被浪费的资源。
  • 备用服务器必须能够处理完整的应用程序负载。
  • 故障转移配置不能在运行时修改。
  • 客户端应用程序必须做一些工作才能从故障转移中受益。

防止脑裂综合症 #

脑裂综合症 (Split-brain syndrome) 发生时,集群的不同部分同时认为自己处于活动状态。这会导致应用程序停止相互可见。Binary Star 有一种检测和消除脑裂的算法,它基于一种三方决策机制(一个服务器在收到应用程序连接请求且无法看到其对等服务器之前,不会决定成为活动状态)。

然而,仍然可能(错误地)设计网络来欺骗这种算法。一个典型的场景是,Binary Star 对分布在两栋建筑之间,每栋建筑都有一组应用程序,并且两栋建筑之间只有一条网络链接。断开此链接将创建两组客户端应用程序,每组都连接到 Binary Star 对中的一半,并且每个故障转移服务器都会变为活动状态。

为了防止脑裂情况,我们必须使用专用网络链接连接 Binary Star 对,这可以像将它们都插入同一个交换机一样简单,或者,更好的是,直接使用交叉线连接两台机器。

我们不能将 Binary Star 架构分成两个孤岛,每个孤岛都有一组应用程序。虽然这可能是一种常见的网络架构类型,但在这种情况下,你应该使用联邦(federation)模式,而不是高可用性故障转移。

一个足够偏执的网络配置会使用两条私有集群互联线路,而不是一条。此外,用于集群的网络卡将与用于消息流量的网络卡不同,甚至可能位于服务器硬件上不同的路径。目标是将网络中可能的故障与集群中可能的故障分离开来。网络端口可能具有相对较高的故障率。

Binary Star 实现 #

事不迟疑,以下是 Binary Star 服务器的概念验证实现。主服务器和备用服务器运行相同的代码,你在运行代码时选择它们的角色:

C | C++ | Haxe | Java | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Julia | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Rust | Scala | OCaml

以下是客户端实现:

C | C++ | Haxe | Java | Python | Ruby | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Julia | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Rust | Scala | OCaml

要测试 Binary Star,请以任何顺序启动服务器和客户端:

bstarsrv -p     # Start primary
bstarsrv -b     # Start backup
bstarcli

然后你可以通过杀死主服务器来引发故障转移,以及通过重启主服务器并杀死备用服务器来引发恢复。注意,是客户端投票触发了故障转移和恢复。

Binary Star 由一个有限状态机驱动。事件是节点的对等状态,因此“Peer Active”(对等活动)意味着另一台服务器告诉我们它是活动状态。“Client Request”(客户端请求)意味着我们收到了一个客户端请求。“Client Vote”(客户端投票)意味着我们收到了一个客户端请求,并且我们的对等节点在两个心跳周期内处于非活动状态。

请注意,服务器使用 PUB-SUB 套接字进行状态交换。没有其他套接字组合在这里能工作。如果没有准备好接收消息的对等节点,PUSH 和 DEALER 会阻塞。如果对等节点消失后又回来,PAIR 不会重新连接。ROUTER 在发送消息之前需要对等节点的地址。

图 54 - Binary Star 有限状态机

Binary Star Reactor #

Binary Star 足够有用和通用,可以打包成一个可重用的 reactor 类。然后 reactor 运行,每当有需要处理的消息时就调用我们的代码。这比将 Binary Star 代码复制/粘贴到每个需要此功能的服务器中要好得多。

在 C 语言中,我们包装了 CZMQ 的zloop类,我们在前面看到过。zloop它让你注册处理程序,以响应套接字和定时器事件。在 Binary Star reactor 中,我们提供了投票者处理程序以及状态变化(从活动到被动,反之亦然)的处理程序。以下是bstarAPI。


//  Create a new Binary Star instance, using local (bind) and
//  remote (connect) endpoints to set up the server peering.
bstar_t *bstar_new (int primary, char *local, char *remote);

//  Destroy a Binary Star instance
void bstar_destroy (bstar_t **self_p);

//  Return underlying zloop reactor, for timer and reader
//  registration and cancelation.
zloop_t *bstar_zloop (bstar_t *self);

//  Register voting reader
int bstar_voter (bstar_t *self, char *endpoint, int type,
                 zloop_fn handler, void *arg);

//  Register main state change handlers
void bstar_new_active (bstar_t *self, zloop_fn handler, void *arg);
void bstar_new_passive (bstar_t *self, zloop_fn handler, void *arg);

//  Start the reactor, which ends if a callback function returns -1,
//  or the process received SIGINT or SIGTERM.
int bstar_start (bstar_t *self);

以下是类实现:

C | C++ | Haxe | Java | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Julia | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Rust | Scala | OCaml

这为我们提供了以下使用核心类实现的简短服务器主程序:

C | C++ | Haxe | Java | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Julia | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Rust | Scala | OCaml

无 Broker 可靠性 (Freelance 模式) #

当我们经常将 ZeroMQ 解释为“无 broker 消息传递”时,花如此多精力关注基于 broker 的可靠性可能看起来有点讽刺。然而,在消息传递中,就像在现实生活中一样,中间人既是负担也是益处。实践中,大多数消息传递架构都受益于分布式和基于 broker 的消息传递的混合。当你能够自由决定想要做出哪些权衡时,你会得到最好的结果。这就是为什么我可以开车二十分钟去批发商那里为聚会买五箱葡萄酒,但我也可以步行十分钟到街角商店为晚餐买一瓶。我们高度依赖情境的时间、精力、成本的相对价值评估对现实世界的经济至关重要,它们对于最优的消息传递架构也至关重要。

这就是为什么 ZeroMQ 不强制要求基于 broker 的架构,尽管它确实提供了构建 broker 的工具,也称为代理 (proxies),到目前为止,我们已经构建了十几种不同的(broker),只是为了练习。

因此,我们将通过解构我们目前构建的基于 broker 的可靠性来结束本章,并将其转换回我称之为 Freelance 模式的分布式点对点架构。我们的用例将是一个名称解析服务。这是 ZeroMQ 架构中一个常见的问题:我们如何知道要连接的端点?在代码中硬编码 TCP/IP 地址非常脆弱。使用配置文件会造成管理噩梦。想象一下,你必须在你使用的每台 PC 或手机上手动配置你的网络浏览器,才能知道“google.com”是“74.125.230.82”。

一个 ZeroMQ 名称服务(我们将实现一个简单的版本)必须做到以下几点:

  • 将逻辑名称解析为至少一个绑定端点和一个连接端点。一个实际的名称服务会提供多个绑定端点,也可能提供多个连接端点。

  • 允许我们管理多个并行环境,例如“测试”与“生产”,而无需修改代码。

  • 必须可靠,因为如果它不可用,应用程序将无法连接到网络。

将名称服务置于面向服务的 Majordomo broker 之后从某些角度看是聪明的。然而,更简单、也更少意外的是,只需将名称服务暴露为一个客户端可以直接连接的服务器。如果我们这样做得当,名称服务就成为我们在代码或配置文件中需要硬编码的唯一全局网络端点。

图 55 - Freelance 模式

我们旨在处理的故障类型包括服务器崩溃和重启、服务器忙循环、服务器过载和网络问题。为了获得可靠性,我们将创建一个名称服务器池,这样如果一个崩溃或消失,客户端可以连接到另一个,依此类推。实践中,两台就足够了。但为了示例,我们假设服务器池可以是任何大小。

在这种架构中,大量客户端直接连接到少量服务器。服务器绑定到各自的地址。这与像 Majordomo 这样的基于 broker 的方法根本不同,在 Majordomo 中,工作者连接到 broker。客户端有几种选择:

  • 使用 REQ 套接字和 Lazy Pirate 模式。这种方法简单,但需要一些额外的智能,以免客户端反复愚蠢地尝试重新连接到已死的服务器。

  • 使用 DEALER 套接字并广播请求(这些请求会被负载均衡到所有连接的服务器),直到收到回复。这种方法有效,但不够优雅。

  • 使用 ROUTER 套接字,以便客户端可以寻址特定的服务器。但客户端如何知道服务器套接字的身份?要么服务器必须先 ping 客户端(复杂),要么服务器必须使用客户端已知的一个硬编码的固定身份(糟糕)。

我们将在下面的小节中分别介绍这些方法。

模型一:简单重试和故障转移 #

看来我们的选择有:简单、粗暴、复杂或糟糕。让我们从简单开始,然后解决其中的问题。我们借鉴 Lazy Pirate 模式,并将其重写以支持多个服务器端点。

首先启动一个或多个服务器,将绑定端点指定为参数

C | C++ | Java | Lua | PHP | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Haxe | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

然后启动客户端,将一个或多个连接端点指定为参数

C | C++ | Java | PHP | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Haxe | Julia | Lua | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

示例如下:

flserver1 tcp://*:5555 &
flserver1 tcp://*:5556 &
flclient1 tcp://localhost:5555 tcp://localhost:5556

尽管基本方法是 Lazy Pirate,但客户端旨在只获得一个成功的回复。它有两种技术,取决于你运行的是单个服务器还是多个服务器

  • 使用单个服务器时,客户端将重试多次,与 Lazy Pirate 完全相同。
  • 使用多个服务器时,客户端将尝试每个服务器最多一次,直到收到回复或已尝试所有服务器。

这解决了 Lazy Pirate 的主要弱点,即它无法故障转移到备份或备用服务器。

然而,这个设计在实际应用中效果不佳。如果我们连接许多套接字,并且我们的主要名称服务器宕机,每次都会经历这种痛苦的超时。

模型二:粗暴散弹枪扫射 #

让我们将客户端切换到使用 DEALER 套接字。我们的目标是确保在最短的时间内获得回复,无论特定服务器是运行还是宕机。我们的客户端采用这种方法:

  • 我们进行设置,连接到所有服务器。
  • 当我们有请求时,会向所有服务器发送请求。
  • 我们等待第一个回复,并采用它。
  • 我们忽略任何其他回复。

实际情况是,当所有服务器都在运行时,ZeroMQ 会分配请求,使每个服务器收到一个请求并发送一个回复。当有服务器离线或断开连接时,ZeroMQ 会将请求分发给剩余的服务器。因此,在某些情况下,服务器可能会多次收到同一个请求。

更让客户端烦恼的是,我们会收到多个回复,但不能保证收到准确数量的回复。请求和回复可能会丢失(例如,如果服务器在处理请求时崩溃)。

因此,我们必须为请求编号,并忽略任何与请求编号不匹配的回复。我们的模型一服务器之所以能工作,是因为它是一个回显服务器,但巧合并不是理解事物的良好基础。所以我们将构建一个模型二服务器,它会处理消息并返回带有正确编号且内容为“OK”的回复。我们将使用由两部分组成的消息:一个序列号和一个消息体。

启动一个或多个服务器,每次指定一个绑定端点

C | C++ | Java | Lua | PHP | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Haxe | Julia | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

然后启动客户端,将连接端点指定为参数

C | C++ | Java | PHP | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Haxe | Julia | Lua | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Rust | Scala | OCaml

以下是关于客户端实现的一些注意事项:

  • 客户端被构建成一个精致的基于类的 API,它隐藏了创建 ZeroMQ 上下文和套接字以及与服务器通信的脏活累活。也就是说,如果对腹部进行散弹枪扫射可以被称为“通信”的话。

  • 如果在几秒钟内找不到任何响应的服务器,客户端将放弃尝试。

  • 客户端必须创建一个有效的 REP 包络,即在消息前面添加一个空消息帧。

客户端执行 10,000 个名称解析请求(假的,因为我们的服务器基本上什么都不做)并测量平均开销。在我的测试机上,与一个服务器通信需要大约 60 微秒。与三个服务器通信需要大约 80 微秒。

我们的散弹枪方法的优缺点如下:

  • 优点:简单,易于构建和理解。
  • 优点:实现故障转移,并且工作迅速,只要至少有一个服务器正在运行。
  • 缺点:产生冗余的网络流量。
  • 缺点:无法优先处理服务器,例如:主服务器,然后是备用服务器。
  • 缺点:服务器一次最多只能处理一个请求,仅此而已。

模型三:复杂且糟糕 #

散弹枪方法似乎好得令人难以置信。让我们科学地分析所有备选方案。我们将探索复杂/糟糕的选项,即使最终只是为了意识到我们更喜欢粗暴的方法。啊,这就是我的人生故事。

我们可以通过切换到 ROUTER 套接字来解决客户端的主要问题。这使我们可以向特定服务器发送请求,避免已知宕机的服务器,并且总的来说可以随心所欲地智能。我们也可以通过切换到 ROUTER 套接字来解决服务器的主要问题(单线程)。

但在两个匿名套接字(未设置身份)之间进行 ROUTER 到 ROUTER 的通信是不可能的。双方只有在收到第一条消息时才会为对方生成一个身份,因此任何一方都无法与对方通信,直到先收到消息。解决这个难题的唯一方法是“作弊”,即在一个方向上使用硬编码的身份。在客户端/服务器场景中,正确的“作弊”方式是让客户端“知道”服务器的身份。反过来做会是疯狂的,而且是复杂和糟糕的,因为任意数量的客户端都应该能够独立启动。疯狂、复杂和糟糕是种族灭绝独裁者的绝佳属性,但对于软件来说却是糟糕透顶的。

我们不发明另一个需要管理的概念,而是将连接端点用作身份。这是一个独特的字符串,双方无需比散弹枪模型已有的先验知识更多就能达成一致。这是连接两个 ROUTER 套接字的一种巧妙且有效的方式。

回顾一下 ZeroMQ 身份的工作原理。服务器 ROUTER 套接字在绑定其套接字之前设置一个身份。当客户端连接时,双方会进行一个小的握手来交换身份,然后任何一方才发送真正的消息。客户端 ROUTER 套接字由于未设置身份,会向服务器发送一个空身份。服务器生成一个随机 UUID 来标识客户端供自己使用。服务器将其身份(我们约定它将是一个端点字符串)发送给客户端。

这意味着我们的客户端一旦连接建立,就可以将消息路由到服务器(即在其 ROUTER 套接字上发送,将服务器端点指定为身份)。但这并非是执行立即之后 zmq_connect(),而是在某个随机时间之后。这里存在一个问题:我们不知道服务器何时真正可用并完成连接握手。如果服务器在线,可能在几毫秒之后。如果服务器宕机并且系统管理员外出吃午饭,那可能要等一个小时。

这里有一个小小的悖论。我们需要知道服务器何时连接并准备好工作。在自由职业者模式中,不像本章前面看到的基于代理的模式,服务器在被告知之前是沉默的。因此,我们无法与服务器通信,直到它告诉我们它在线,而它只有在我们问过它之后才能这样做。

我的解决方案是混合使用模型 2 的散弹枪方法,这意味着我们会向任何能射击到的东西发射(无害的)“子弹”,如果有什么东西动了,我们就知道它还活着。我们不会发送真正的请求,而是一种乒乓心跳信号。

这再次将我们带到了协议领域,这里是一个 定义自由职业者客户端和服务器如何交换乒乓命令和请求-回复命令的简短规范

作为服务器实现,它简洁又易懂。这是我们的回显服务器,模型三,现在使用 FLP 协议:

C | C++ | Java | Lua | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Haxe | Julia | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Rust | Scala | OCaml

然而,自由职业客户端已经变得很大。为了清晰起见,它被拆分成一个示例应用程序和一个负责核心工作的类。以下是顶层应用程序:

C | C++ | Java | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Haxe | Julia | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Rust | Scala | OCaml

同样,这里是客户端 API 类,其复杂度和规模几乎与 Majordomo 代理(broker)相当

C | C++ | Java | Python | Tcl | Ada | Basic | C# | CL | Delphi | Erlang | Elixir | F# | Felix | Go | Haskell | Haxe | Julia | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Rust | Scala | OCaml

这个 API 实现相当复杂,并使用了我们之前没有见过的一些技术。

  • 多线程 API:客户端 API 包含两部分,一个同步的flcliapi类在应用程序线程中运行,以及一个作为后台线程运行的异步代理类。记住 ZeroMQ 如何轻松创建多线程应用程序。flcliapi 和代理类通过一个inproc套接字使用消息相互通信。所有 ZeroMQ 相关操作(例如创建和销毁上下文)都隐藏在 API 中。代理实际上就像一个迷你代理,在后台与服务器通信,这样当我们发出请求时,它就可以尽力连接到它认为可用的服务器。

  • 无时钟滴答的轮询计时器:在之前的轮询循环中,我们总是使用固定的时钟滴答间隔,例如 1 秒,这足够简单,但在对功耗敏感的客户端(例如笔记本电脑或手机)上效果不佳,因为唤醒 CPU 会消耗电力。为了有趣并帮助节约能源,代理使用了无时钟滴答计时器,它根据我们预期的下一个超时时间来计算轮询延迟。一个合适的实现会维护一个有序的超时列表。我们只是检查所有超时,并计算直到下一个超时为止的轮询延迟。

总结 #

在本章中,我们看到了各种可靠的请求-应答机制,每种机制都有特定的成本和收益。示例代码大体上已可用于实际应用,尽管尚未进行优化。在所有不同的模式中,用于生产环境的两种突出模式是:用于基于代理可靠性的 Majordomo 模式,以及用于无代理可靠性的 Freelance 模式。