开源应用架构(第二卷)
ZeroMQ

Martin Süstrik

ØMQ 是一种消息系统,或者说是“面向消息的中间件”。它被用于金融服务、游戏开发、嵌入式系统、学术研究和航空航天等各种环境。

消息系统基本上是应用程序之间的即时消息。一个应用程序决定将事件传达给另一个应用程序(或多个应用程序),它会组装要发送的数据,点击“发送”按钮,然后消息系统就会负责剩下的工作。

然而,与即时消息不同,消息系统没有 GUI 并且假设没有人在端点可以进行智能干预来处理错误。因此,消息系统必须既容错又比常见的即时消息更快。

ØMQ 最初被设想为一种用于股票交易的超高速消息系统,因此重点是极度优化。该项目的第一年花在了设计基准测试方法和尝试定义尽可能高效的架构上。

后来,大约在开发的第二年,重点转移到了为构建分布式应用程序提供通用系统,并支持任意消息模式、各种传输机制、任意语言绑定等。

在第三年,重点主要放在提高可用性和降低学习曲线。我们采用了 BSD 套接字 API,尝试清理单个消息模式的语义等等。

希望本章能深入了解上述三个目标如何转化为 ØMQ 的内部架构,并为那些在解决相同问题方面遇到困难的人提供一些建议。

从第三年开始,ØMQ 的代码库已经超出了其规模;有一个倡议是将其使用的线协议标准化,以及在 Linux 内核中实验性地实现类似 ØMQ 的消息系统等等。这些主题不包括在本手册中。但是,您可以查看在线资源以获取更多详细信息:http://www.250bpm.com/conceptshttp://groups.google.com/group/sp-discuss-group,以及 http://www.250bpm.com/hits.

24.1. 应用程序与库

ØMQ 是一个库,而不是一个消息服务器。我们花了数年时间研究 AMQP 协议(金融行业试图标准化业务消息的线协议的尝试)——为其编写参考实现并参与了几个严重依赖消息技术的 крупномасштабные项目——才意识到经典的智能消息服务器(代理)和哑消息客户端的客户端/服务器模型存在问题。

我们当时最关心的问题是性能:如果中间有一个服务器,每条消息都必须通过网络两次(从发送者到代理,再从代理到接收者),从而在延迟和吞吐量方面造成损失。此外,如果所有消息都通过代理传递,那么在某个时刻它将不可避免地成为瓶颈。

第二个问题与大规模部署有关:当部署跨越组织边界时,管理整个消息流的中央权威的概念不再适用。没有公司愿意将控制权让渡给其他公司的服务器;存在商业秘密,也存在法律责任。实际上,结果是每家公司都有一个消息服务器,并通过手工编写的桥接器将其连接到其他公司的消息系统。因此,整个生态系统严重分散,维护大量连接到每个参与公司的桥接器并不能改善这种情况。为了解决这个问题,我们需要一个完全分布式的架构,一个每个组件都可能由不同的业务实体管理的架构。鉴于基于服务器的架构中的管理单元是服务器,我们可以通过为每个组件安装单独的服务器来解决这个问题。在这种情况下,我们可以通过使服务器和组件共享相同的进程来进一步优化设计。最终得到的是一个消息库。

当我们想到如何在没有中央服务器的情况下使消息传递工作时,ØMQ 就开始了。这需要颠覆消息传递的整个概念,用基于端到端原则的“智能端点、哑网络”架构取代网络中心的消息集中存储的模型。该决定的技术结果是,ØMQ 从一开始就是一个库,而不是一个应用程序。

在此期间,我们已经证明这种架构既更高效(延迟更低,吞吐量更高),也更灵活(它可以轻松构建任意复杂的拓扑结构,而不是局限于经典的中心辐射模型)。

然而,一个意外的结果是,选择库模型提高了产品的可用性。用户一次又一次地表达了他们对不需要安装和管理独立消息服务器的欣慰之情。事实证明,没有服务器是一个更好的选择,因为它可以降低运营成本(不需要消息服务器管理员)并缩短上市时间(不需要与客户端、管理层或运营团队协商运行服务器的必要性)。

从中学到的教训是,在启动新项目时,如果可能,应该选择库设计。从库创建应用程序非常容易,只需从一个微不足道的程序中调用它即可;然而,从现有可执行文件创建库几乎不可能。库为用户提供了更大的灵活性,同时为他们节省了非凡的管理工作。

24.2. 全局状态

全局变量与库不兼容。一个库可能会在进程中加载多次,但即使那样,也只有一组全局变量。图 24.1 显示了从两个不同且独立的库中使用 ØMQ 库。然后,应用程序使用这两个库。

图 24.1:不同库使用 ØMQ

当发生这种情况时,ØMQ 的两个实例都会访问相同的变量,从而导致竞争条件、奇怪的故障和未定义的行为。

为了防止这个问题,ØMQ 库没有全局变量。相反,库的使用者负责显式地创建全局状态。包含全局状态的对象称为上下文。虽然从用户的角度来看,上下文看起来或多或少像一组工作线程,但从 ØMQ 的角度来看,它只是一个对象,用于存储我们碰巧需要的任何全局状态。在上图中,libA 将有自己的上下文,libB 也会有自己的上下文。他们之间不可能相互破坏或破坏对方。

这里的教训很明显:不要在库中使用全局状态。如果您这样做,当库碰巧在同一个进程中实例化两次时,它很可能会崩溃。

24.3. 性能

ØMQ 启动时,其主要目标是优化性能。消息系统的性能用两个指标来表示:吞吐量——在给定时间内可以传递多少条消息;以及延迟——一条消息从一个端点到另一个端点需要多长时间。

我们应该关注哪项指标?两者之间的关系是什么?这不是很明显吗?运行测试,将测试的总时间除以传递的消息数,得到的就是延迟。将消息数除以时间,得到的就是吞吐量。换句话说,延迟是吞吐量的倒数。很简单,对吧?

我们没有直接开始编码,而是花了几周时间详细研究了性能指标,发现吞吐量和延迟之间的关系比这要微妙得多,而且指标往往非常反直觉。

想象一下 A 向 B 发送消息。(参见 图 24.2。)测试的总时间是 6 秒。传递了 5 条消息。因此,吞吐量为 0.83 msgs/sec (5/6),延迟为 1.2 秒 (6/5),对吧?

图 24.2:从 A 发送消息到 B

再次查看图表。每条消息从 A 到 B 所花费的时间都不一样:2 秒、2.5 秒、3 秒、3.5 秒、4 秒。平均值为 3 秒,与我们最初计算的 1.2 秒相差甚远。这个例子表明,人们在直觉上倾向于对性能指标产生误解。

现在看看吞吐量。测试的总时间是 6 秒。但是,在 A 处,发送所有消息只需要 2 秒。从 A 的角度来看,吞吐量为 2.5 msgs/sec (5/2)。在 B 处,接收所有消息需要 4 秒。所以从 B 的角度来看,吞吐量为 1.25 msgs/sec (5/4)。这两个数字都不匹配我们最初计算的 1.2 msgs/sec。

长话短说,延迟和吞吐量是两个不同的指标;这很明显。重要的是要理解两者之间的区别及其相互关系。延迟只能在系统的两个不同点之间进行测量;不存在点 A 的延迟。每条消息都有自己的延迟。您可以对多条消息的延迟进行平均;但是,不存在消息流的延迟。

另一方面,吞吐量只能在系统的单个点进行测量。发送方有吞吐量,接收方有吞吐量,两者之间任何中间点都有吞吐量,但整个系统的总吞吐量并不存在。并且吞吐量只有对一组消息才有意义;单个消息的吞吐量并不存在。

至于吞吐量和延迟之间的关系,事实证明确实存在关系;但是,公式涉及积分,我们在这里不讨论。有关更多信息,请阅读关于排队理论的文献。

我们在基准测试消息系统方面还有许多其他陷阱,我们在这里不再赘述。重点应该放在从中学到的教训上:确保您理解要解决的问题。即使是“让它变得更快”这样简单的问题,也要花很多工作才能真正理解。更重要的是,如果您不理解问题,您很可能会将隐含的假设和流行的神话融入代码中,使解决方案要么存在缺陷,要么至少比可能实现的解决方案复杂得多或有用得多。

24.4. 关键路径

在优化过程中,我们发现以下三个因素对性能有重大影响

但是,并非每次内存分配或每次系统调用对性能的影响都相同。我们对消息系统感兴趣的性能是在给定时间内可以传递在两个端点之间的消息数。或者,我们可能想知道一条消息从一个端点传送到另一个端点需要多长时间。

但是,鉴于 ØMQ 是为长期连接场景而设计的,建立连接所花费的时间或处理连接错误所需的时间基本上无关紧要。这些事件很少发生,因此它们对整体性能的影响微不足道。

代码库中经常使用的那一部分代码称为关键路径;优化应该集中在关键路径上。

让我们看一个例子:ØMQ 在内存分配方面并没有经过高度优化。例如,在操作字符串时,它通常会为每个转换的中间阶段分配一个新的字符串。但是,如果我们严格地看关键路径(实际的消息传递),我们会发现它几乎不使用内存分配。如果消息很小,它每 256 条消息只分配一次内存(这些消息保存在一个单一大分配的内存块中)。此外,如果消息流稳定,没有巨大的流量峰值,关键路径上的内存分配次数就会降至零(分配的内存块不会返回到系统,而是被反复重用)。

经验教训:在有意义的地方进行优化。优化不在关键路径上的代码部分是浪费精力。

24.5. 内存分配

假设所有基础设施都已初始化,并且两个端点之间已建立连接,在发送消息时,只需要分配一个东西:消息本身。因此,为了优化关键路径,我们必须研究消息是如何分配以及如何在堆栈中上传和下载的。

在高性能网络领域,众所周知,通过仔细平衡消息分配成本和消息复制成本可以实现最佳性能(例如,http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf:查看“小”、“中”和“大”消息的不同处理方式)。对于小消息,复制比分配内存便宜得多。在需要时,将消息复制到预先分配的内存中,而不分配任何新的内存块是有意义的。另一方面,对于大消息,复制比内存分配昂贵得多。分配消息一次并将指向分配块的指针传递给它,而不是复制数据是有意义的。这种方法称为“零拷贝”。

ØMQ 以透明的方式处理这两种情况。ØMQ 消息由一个不透明的句柄表示。非常小的消息的内容直接编码在句柄中。因此,复制句柄实际上复制了消息数据。当消息较大时,它将在一个单独的缓冲区中分配,并且句柄仅包含指向缓冲区的指针。复制句柄不会导致复制消息数据,这在消息长度为兆字节时很有意义(图 24.3)。应该注意的是,在后一种情况下,缓冲区是引用计数的,因此多个句柄可以引用它,而无需复制数据。

图 24.3:消息复制(或不复制)

经验教训:在考虑性能时,不要假设只有一个最佳解决方案。可能存在问题的几个子类(例如,小消息与大消息),每个子类都有自己的最佳算法。

24.6. 批量处理

前面已经提到,消息系统中的系统调用数量过多会导致性能瓶颈。实际上,这个问题比这更普遍。与遍历调用堆栈相关联的性能损失不容小觑,因此,在创建高性能应用程序时,最好避免尽可能多的堆栈遍历。

考虑图 24.4。要发送四条消息,必须遍历整个网络堆栈四次(即,ØMQ、glibc、用户/内核空间边界、TCP 实现、IP 实现、以太网层、网卡本身,然后返回堆栈)。

图 24.4:发送四条消息

但是,如果您决定将这些消息加入一个批次,则只需要遍历堆栈一次(图 24.5)。对消息吞吐量的影响可能是巨大的:最多可以提高两个数量级,尤其是在消息很小,并且数百条消息可以打包到一个批次中的情况下。

图 24.5:批量处理消息

另一方面,批量处理会对延迟产生负面影响。例如,让我们以 TCP 中实现的众所周知的 Nagle 算法为例。它会延迟出站消息一段时间,并将所有累积的数据合并到一个数据包中。显然,数据包中第一条消息的端到端延迟远远大于最后一条消息的延迟。因此,对于需要始终保持低延迟的应用程序来说,通常会关闭 Nagle 算法。甚至还会关闭堆栈所有级别的批量处理(例如,网卡的中断合并功能)。

但同样,没有批量处理意味着广泛遍历堆栈,并导致消息吞吐量低。我们似乎陷入了吞吐量与延迟的困境。

ØMQ 试图通过以下策略来提供始终保持低延迟和高吞吐量:当消息流稀疏,并且不超过网络堆栈的带宽时,ØMQ 会关闭所有批量处理以提高延迟。这里的权衡是 CPU 使用率略高——我们仍然需要频繁遍历堆栈。但是,在大多数情况下,这不是问题。

当消息速率超过网络堆栈的带宽时,消息必须排队——存储在内存中,直到堆栈准备好接受它们。排队意味着延迟会增加。如果消息在队列中停留一秒钟,端到端延迟至少为一秒钟。更糟糕的是,随着队列大小的增加,延迟会逐渐增加。如果队列的大小不受限制,延迟可能会超过任何限制。

人们已经观察到,即使网络堆栈针对最低延迟进行了调整(Nagle 算法已关闭,网卡中断合并已关闭等),由于上述队列效应,延迟仍然可能很糟糕。

在这种情况下,有意义的是积极地开始批量处理。因为延迟已经很高了,所以没有什么损失。另一方面,积极的批量处理提高了吞吐量,可以清空待处理消息的队列——这反过来意味着随着队列延迟的降低,延迟也会逐渐降低。一旦队列中没有未处理的消息,就可以关闭批量处理以进一步提高延迟。

另一个观察结果是,批量处理应该只在最顶层进行。如果消息在那里被批量处理,那么底层就没有什么需要批量处理的了,因此所有底层的批量处理算法都不会做任何事情,只会增加额外的延迟。

经验教训:要在异步系统中获得最佳吞吐量和最佳响应时间,请关闭堆栈低层的批量处理算法,并在最顶层进行批量处理。只有在新数据到达速度快于处理速度时才进行批量处理。

24.7. 架构概述

到目前为止,我们已经关注了使 ØMQ 速度更快的通用原则。从现在开始,我们将看一下系统的实际架构(图 24.6)。

图 24.6:ØMQ 架构

用户使用所谓的“套接字”与 ØMQ 进行交互。它们与 TCP 套接字非常相似,主要区别在于每个套接字都可以处理与多个对等点的通信,有点像无绑定的 UDP 套接字。

套接字对象位于用户的线程中(有关线程模型的讨论,请参阅下一节)。除此之外,ØMQ 运行多个工作线程来处理通信的异步部分:从网络读取数据,将消息排队,接受传入连接等。

工作线程中存在各种对象。每个这些对象都由一个父对象完全拥有(所有权由图中的简单实线表示)。父对象可以位于与子对象不同的线程中。大多数对象由套接字直接拥有;但是,在一些情况下,一个对象由另一个由套接字拥有的对象拥有。我们得到一个对象树,每个套接字对应一个这样的树。该树在关闭时使用;在关闭所有子对象之前,任何对象都不能关闭自身。这样,我们可以确保关闭过程按预期执行;例如,在终止发送进程之前,会将待处理的出站消息推送到网络。

粗略地说,异步对象有两种:不参与消息传递的对象和参与消息传递的对象。前者主要与连接管理有关。例如,TCP 侦听器对象侦听传入的 TCP 连接,并为每个新连接创建一个引擎/会话对象。类似地,TCP 连接器对象尝试连接到 TCP 对等点,并在成功后创建引擎/会话对象来管理连接。当此类连接失败时,连接器对象会尝试重新建立它。

后者是处理数据传输本身的对象。这些对象由两部分组成:会话对象负责与 ØMQ 套接字交互,而引擎对象负责与网络通信。会话对象只有一种,但每种底层协议都有一个不同的引擎类型,ØMQ 支持。因此,我们有 TCP 引擎、IPC(进程间通信)引擎、PGM 引擎(一种可靠的多播协议,请参阅 RFC 3208)等等。引擎集是可扩展的——将来,我们可能会选择实现 WebSocket 引擎或 SCTP 引擎。

会话与套接字交换消息。有两个方向可以传递消息,每个方向都由一个管道对象处理。每个管道基本上是一个无锁队列,针对线程之间快速传递消息进行了优化。

最后,还有一个上下文对象(在前面的部分中讨论过,但未在图中显示),它保存全局状态,并且可以被所有套接字和所有异步对象访问。

24.8. 并发模型

ØMQ 的一项要求是利用多核计算机;换句话说,使吞吐量随着可用 CPU 内核数量的增加而线性扩展。

我们以前使用消息系统的经验表明,以经典方式使用多个线程(临界区、信号量等)并不能带来太多性能提升。事实上,消息系统的多线程版本可能比单线程版本慢,即使是在多核计算机上进行测量也是如此。单个线程只是花费了太多时间等待彼此,同时又引入了大量的上下文切换,从而减慢了系统速度。

鉴于这些问题,我们决定采用不同的模型。目标是完全避免锁定,让每个线程以全速运行。线程之间的通信将通过在线程之间传递的异步消息(事件)来提供。正如内部人士所知,这是经典的actor 模型

想法是为每个 CPU 内核启动一个工作线程——让两个线程共享同一个内核只会意味着大量的上下文切换,而不会带来任何特殊优势。每个 ØMQ 内部对象,例如 TCP 引擎,将与特定的工作线程紧密绑定。这反过来意味着不需要临界区、互斥锁、信号量等等。此外,这些 ØMQ 对象不会在 CPU 内核之间迁移,因此会避免缓存污染的负面性能影响(图 24.7)。

图 24.7:多个工作线程

这种设计消除了许多传统的线程问题。但是,需要在许多对象之间共享工作线程,这意味着必须进行某种形式的协作式多任务处理。这意味着我们需要一个调度器;对象需要是事件驱动的,而不是控制整个事件循环;我们必须处理任意顺序的事件,即使是非常罕见的事件;我们必须确保没有对象长时间占用 CPU 等等。

简而言之,整个系统必须变成完全异步的。没有对象能够进行阻塞操作,因为它不仅会阻塞自身,还会阻塞共享同一工作线程的所有其他对象。所有对象都必须,无论显式还是隐式,变成状态机。当数百或数千个状态机并行运行时,您必须处理它们之间所有可能的交互,最重要的是处理关闭过程。

事实证明,以干净的方式关闭完全异步的系统是一项令人生畏的复杂任务。尝试关闭一千个活动部件,其中一些正在工作,一些处于空闲状态,一些正在启动过程中,一些正在自行关闭,很容易出现各种竞争条件、资源泄漏等问题。关闭子系统无疑是 ØMQ 最复杂的组成部分。快速查看错误跟踪器表明,约 30% 到 50% 的报告的错误与关闭有关。

经验教训:当追求极致的性能和可扩展性时,考虑使用 Actor 模型;在这些情况下,它几乎是唯一的选择。但是,如果您没有使用 Erlang 或 ØMQ 本身这样的专门系统,您将不得不手动编写和调试大量的基础设施。此外,从一开始就考虑关闭系统的过程。这将是代码库中最复杂的组成部分,如果您对如何实现它没有明确的想法,您可能应该重新考虑是否使用 Actor 模型。

24.9. 无锁算法

无锁算法近来十分流行。它们是线程间通信的简单机制,不依赖于内核提供的同步原语,例如互斥体或信号量;相反,它们使用原子 CPU 操作(例如原子比较并交换 (CAS))进行同步。应该理解的是,它们并非严格意义上的无锁——相反,锁定是在硬件级别后台完成的。

ØMQ 在管道对象中使用无锁队列,用于在用户的线程和 ØMQ 的工作线程之间传递消息。ØMQ 使用无锁队列的方式有两个有趣的方面。

首先,每个队列只有一个写入线程和一个读取线程。如果需要进行一对多通信,则会创建多个队列(图 24.8)。鉴于此方式下,队列无需处理写入器同步(只有一个写入器)或读取器同步(只有一个读取器),因此可以以极高的效率进行实现。

图 24.8:队列

其次,我们意识到,尽管无锁算法比传统的基于互斥体的算法更有效,但原子 CPU 操作仍然相当昂贵(尤其是在 CPU 核心之间存在竞争时),并且为写入和/或读取的每条消息执行一次原子操作的速度比我们愿意接受的要慢。

加快速度的方法是——再次——批处理。想象一下,您需要将 10 条消息写入队列。例如,当您收到包含 10 条小消息的网络数据包时,就会发生这种情况。接收数据包是一个原子事件;您无法获得它的一半。此原子事件导致需要将 10 条消息写入无锁队列。为每条消息执行原子操作没有太大意义。相反,您可以将这些消息累积在队列的“预写入”部分,该部分仅由写入线程访问,然后使用单个原子操作将其刷新。

同样适用于从队列读取。假设上面的 10 条消息已经刷新到队列中。读取线程可以使用原子操作从队列中提取每条消息。但是,这有点过头了;相反,它可以使用单个原子操作将所有挂起的消息移动到队列的“预读取”部分。之后,它可以从“预读取”缓冲区中一条一条地检索消息。“预读取”由读取线程拥有并访问,因此在此阶段不需要任何同步。

图 24.9 左侧的箭头显示了如何通过修改单个指针将预写入缓冲区刷新到队列中。右侧的箭头显示了如何通过修改另一个指针将队列的全部内容移到预读取中。

图 24.9:无锁队列

经验教训:无锁算法很难发明,难以实现,几乎不可能调试。如果可能的话,使用现有的经过验证的算法,而不是发明自己的算法。当需要极端性能时,不要仅仅依赖无锁算法。虽然它们很快,但通过在它们之上进行智能批处理,性能可以显著提高。

24.10. API

用户界面是任何产品最重要的组成部分。它是您程序中唯一对外部世界可见的部分,如果做错了,全世界都会讨厌您。在最终用户产品中,它是 GUI 或命令行界面。在库中,它是 API。

在 ØMQ 的早期版本中,API 基于 AMQP 的交换和队列模型。(请参阅 AMQP 规范。)从历史的角度来看,看看 2007 年的白皮书很有意思,该白皮书试图将 AMQP 与无代理的消息传递模型相协调。我在 2009 年底几乎从头开始重写它,以使用 BSD 套接字 API。那是转折点;从那时起,ØMQ 的采用率飞速增长。之前,它是一款小众产品,由一些消息传递专家使用,之后,它成为了任何人都可以方便使用的常用工具。大约一年后,社区规模增长了十倍,实现了大约 20 个不同语言的绑定等等。

用户界面定义了对产品的感知。实际上,没有对功能进行任何更改——只是更改了 API——ØMQ 从“企业消息传递”产品转变为“网络”产品。换句话说,感知从“大银行的复杂基础设施”转变为“嘿,这有助于我将 10 字节的消息从应用程序 A 发送到应用程序 B”。

经验教训:了解您希望您的项目是什么,并相应地设计用户界面。拥有与项目愿景不一致的用户界面是失败的 100% 保证。

迁移到 BSD 套接字 API 的一个重要方面是,它不是一个革命性的全新 API,而是一个现有的、众所周知的 API。实际上,BSD 套接字 API 是目前仍在积极使用的最古老的 API 之一;它可以追溯到 1983 年和 4.2BSD Unix。它已经被广泛使用并且稳定了几十年。

以上事实带来了很多优势。首先,它是一个每个人都知道的 API,因此学习曲线非常平坦。即使您从未听说过 ØMQ,您也可以在几分钟内构建第一个应用程序,这要归功于您可以重用您的 BSD 套接字知识。

其次,使用广泛实现的 API 可以使 ØMQ 与现有技术集成。例如,将 ØMQ 对象暴露为“套接字”或“文件描述符”允许在同一个事件循环中处理 TCP、UDP、管道、文件和 ØMQ 事件。另一个例子是,将 ØMQ 类功能引入 Linux 内核的 实验性项目 证明了实现起来相当简单。通过共享相同的概念框架,它可以重用已有的基础设施。

第三,也是最重要的,BSD 套接字 API 尽管多次尝试替换,但仍然存在了近三十年,这一事实意味着该设计本身存在某种正确性。BSD 套接字 API 设计师——无论是刻意还是偶然——做出了正确的设计决策。通过采用该 API,我们可以自动共享这些设计决策,而无需了解它们是什么以及它们解决的问题。

经验教训:虽然代码重用从古至今一直得到推崇,模式重用后来也加入进来,但重要的是要以更通用的方式考虑重用。在设计产品时,看看类似的产品。检查哪些失败了,哪些成功了;从成功的项目中吸取教训。不要屈服于“自己造轮子”综合征。重用您认为合适的思路、API、概念框架等等。这样做可以让用户重用他们现有的知识。同时,您还可以避免您目前甚至没有意识到的技术陷阱。

24.11. 消息传递模式

在任何消息传递系统中,最重要的设计问题是如何为用户提供一种方法来指定哪些消息被路由到哪些目的地。主要有两种方法,我认为这种二分法非常通用,适用于软件领域中遇到的几乎任何问题。

一种方法是采用 Unix 的“做一件事并做好”的理念。这意味着问题域应该被人工限制在一个小而易于理解的领域。然后,程序应该以正确且完整的方式解决这个受限的问题。这种方法在消息传递领域的一个例子是 MQTT。它是一种用于将消息分发到一组消费者的协议。它不能用于其他任何事情(例如 RPC),但它易于使用,并且可以很好地完成消息分发。

另一种方法是专注于通用性,并提供一个强大且高度可配置的系统。AMQP 就是这种系统的例子。它的队列和交换模型为用户提供了编程定义几乎所有他们能想到的路由算法的方法。当然,权衡是需要处理大量选项。

ØMQ 选择了前一种模型,因为它允许基本所有人都使用最终产品,而通用模型需要消息传递专家使用它。为了说明这一点,让我们看看该模型如何影响 API 的复杂性。以下是基于通用系统(AMQP)的 RPC 客户端的实现

connect ("192.168.0.111")
exchange.declare (exchange="requests", type="direct", passive=false,
    durable=true, no-wait=true, arguments={})
exchange.declare (exchange="replies", type="direct", passive=false,
    durable=true, no-wait=true, arguments={})
reply-queue = queue.declare (queue="", passive=false, durable=false,
    exclusive=true, auto-delete=true, no-wait=false, arguments={})
queue.bind (queue=reply-queue, exchange="replies",
    routing-key=reply-queue)
queue.consume (queue=reply-queue, consumer-tag="", no-local=false,
    no-ack=false, exclusive=true, no-wait=true, arguments={})
request = new-message ("Hello World!")
request.reply-to = reply-queue
request.correlation-id = generate-unique-id ()
basic.publish (exchange="requests", routing-key="my-service",
    mandatory=true, immediate=false)
reply = get-message ()

另一方面,ØMQ 将消息传递领域划分为所谓的“消息传递模式”。模式的例子包括“发布/订阅”、“请求/回复”或“并行管道”。每种消息传递模式都与其他模式完全正交,可以被认为是一个独立的工具。

以下是使用 ØMQ 的请求/回复模式重新实现上述应用程序。请注意,所有选项调整都简化为选择正确消息传递模式(“REQ”)的单个步骤

s = socket (REQ)
s.connect ("tcp://192.168.0.111:5555")
s.send ("Hello World!")
reply = s.recv ()

到目前为止,我们已经论证了特定解决方案优于通用解决方案。我们希望我们的解决方案尽可能地具体。但是,同时我们希望为客户提供尽可能广泛的功能范围。我们如何解决这种明显的矛盾?

答案包括两个步骤

  1. 定义一个堆栈层来处理特定的问题领域(例如传输、路由、表示等等)。
  2. 提供该层的多种实现。每个用例都应该有独立且不重叠的实现。

让我们看一下互联网堆栈中传输层的例子。它旨在提供诸如数据流传输、流量控制、提供可靠性等服务,这些服务建立在网络层(IP)之上。它是通过定义多个不重叠的解决方案来实现的:TCP 用于面向连接的可靠流传输,UDP 用于无连接的不可靠数据包传输,SCTP 用于多流传输,DCCP 用于不可靠连接等等。

请注意,每个实现都是完全正交的:UDP 端点无法与 TCP 端点通信。SCTP 端点也无法与 DCCP 端点通信。这意味着可以随时将新的实现添加到堆栈中,而不会影响堆栈的现有部分。反之,失败的实现可以被遗忘和丢弃,而不会影响传输层整体的可用性。

同样的原则也适用于 ØMQ 定义的消息传递模式。消息传递模式在传输层(TCP 及其相关协议)之上形成一个层(即所谓的“可扩展性层”)。各个消息传递模式是该层的实现。它们是严格正交的 - 发布/订阅端点不能与请求/回复端点通信,等等。模式之间的严格分离反过来意味着可以根据需要添加新模式,并且对新模式的失败实验不会损害现有模式。

经验教训:当解决一个复杂且多方面的难题时,单一的通用解决方案可能不是最好的方法。相反,我们可以将问题领域视为一个抽象层,并提供该层的多种实现,每种实现都专注于一个特定的、明确定义的用例。在这样做时,要仔细界定用例。确定范围内的内容和范围外的内容。如果用例限制过于严格,软件的应用可能会受到限制。然而,如果定义的问题过于宽泛,产品可能会变得过于复杂、模糊和混乱,使用户难以理解。

24.12. 结论

随着我们的世界充斥着通过互联网连接的大量小型计算机 - 手机、RFID 阅读器、平板电脑和笔记本电脑、GPS 设备等等 - 分布式计算的问题不再是学术科学的领域,而是每个开发人员在日常工作中都要解决的常见问题。不幸的是,解决方案大多是特定于领域的技巧。本文总结了我们以系统的方式构建大型分布式系统的经验。它专注于从软件架构的角度来看很有趣的问题,我们希望开源社区的设计人员和程序员能够发现它有用。