Welcome to the StudyZeroMQ wiki!

原文链接 http://www.aosabook.org/en/zeromq.html

第24章 ZeroMQ

ZeroMQ是一个消息通信系统,如果你愿意的话也可以称其为“面向消息的中间件”。它能被应用在多种多样的环境中,例如金融服务、游戏开发、嵌入式系统、学术研究和航空航天领域。

消息传递系统大体上来说是应用的即时通信,一个应用程序决定发送一个事件给另一个(或多个)应用程序,它将需要发送的数据组合起来,点击“发送”按钮就行了——消息通信系统会负责剩下的工作。

不同于即时通信,消息传递系统没有图形用户界面且假设出现错误时终端没有人为干预。因此,消息传递系统必须既要有容错性,也要比一般的即时通信更快速。

ZeroMQ最初的设想是作为股票交易中的一个极快速的消息通信系统,因此重点放在了高度优化上。项目开始的头一年都花在制定性能基准测试的方法和尝试设计出一个尽可能高效的架构上了。

之后,大约是在项目进行的第二年里,开发的重点转变成为构建分布式应用程序提供一个通用系统,支持任意模式的消息通信、多种传输机制、对多种编程语言的绑定等等。

开发的第三年里重点主要是提高系统的可用性,将学习曲线平坦化。我们已经采用了BSD套接字API,尝试整理单个消息通信模式的语义等等。
希望本章能向读者介绍上述三个目标是如何转化为ZeroMQ的内部架构的,也希望给同样面对这些问题的人提供一些启示。

启动ZeroMQ项目的第三年里,其代码库已经膨胀的过于庞大。有一项提议要标准化ZeroMQ中所使用的协议,以及实验性地实现一个类?MQ的消息通信系统以加入到Linux内核中等等。不过,本书并未涵盖这些主题,更多细节可以参考:http://www.250bpm.com/concepts, http://groups.google.com/group/sp-discuss-grop, 和http://www.250bpm.com/hits.

24.1 应用vs库

ZeroMQ是一个库,不是消息通信服务器。我们花了好几年时间在AMQP协议上,这是一种在金融行业中尝试标准化商业消息通信的协议。我们为其编写了一个参考性的实现并部署到几个主要基于消息通信技术的大型项目中使用——然后我们意识到,智能消息服务器(代理/broker)和哑客户端之间的这种客户机/服务器经典模型是有问题的。

当时我们的首要关注点是性能,如果中间有个服务器的话,每条消息都不得不穿越网络两次(从发送者到服务器,再从服务器到接收者),产生延迟并降低吞吐量。此外,如果所有的消息都要通过服务器传递的话,某一时刻它就必然会成为瓶颈。

一个次要关注点与大规模部署有关:当通信需要跨越组织的界限时,中央集权式管理所有消息流的设想就不再有效了。没有一家公司愿意把对服务器的控制权放在别的公司里,这里有商业机密和法律责任的问题。实际结果就是每家公司都有一个消息通信服务器,可通过手动桥接连接到其他公司的消息通信系统中,整个通信系统四分五裂,为每个公司维护大量的桥接并没有使情况变得更好。要解决这个问题,我们需要一个分布式的架构,每部分都可以由一个不同的商业实体来管辖。鉴于基于服务器架构的管理单元就是服务器,我们可以通过为每部分单独设置一个服务器,这样我们就可以让服务器和该部分共享同一个进程,进一步优化设计,最终得到了一个消息通信库。

当我们开始设想一种不需要中间服务器的消息通信机制时,ZeroMQ项目就开始了。这需要彻底颠覆消息通信的概念并将位于网络中央的集中信息存储模型替换为基于端到端机制的“智能终端哑网络”架构。正是因为这样的技术决策,ZeroMQ从一开始就是一个库,而非应用程序。同时,我们也证明了这种架构更加高效(低延迟,高吞吐量)也更加灵活(很容易在此之上构建任意复杂的拓扑结构,而不必拘泥于经典的中心辐射模型)。

选择以库的形式发布还带来了一个意想不到的结果,就是提高了产品的可用性,用户反复地表示他们很高兴不再需要安装和管理一个独立的消息通信服务器了。事实证明,去掉中间服务器是更优秀的方案,降低了运营的成本(不需要为消息通信服务器安排管理员)也加快了市场响应的时间(没有必要对客户、管理层或运营团队谈判沟通是否要运行服务器)。

我们从中学到的是,开始新项目时应尽可能的选择库的形式。我们可以很容易的调用库创建一个应用,却几乎不可能从已有的可执行程序中创建一个库。对用户来说,库可以提供更高的灵活性,也无需花费很多精力管理。

24.2 全局状态

全局变量不适合在库中使用。一个进程可能会多次加载同一个库,而它们会共用一组全局变量。在图24.1中,ZeroMQ库被两个不同的、彼此独立的库所调用,而应用本身调用了这两个库。

图24.1 不同的库在使用ZeroMQ

当这种情况出现时,两个ZeroMQ的实例会访问到相同的变量,导致竞争条件,奇怪的错误和未定义的行为。

为了防止该问题,ZeroMQ中没有使用任何全局变量,而是由库的使用者来显式地创建全局状态。包含全局状态的对象称为context。从用户的角度来看,context或多或少类似一个工人线程(worker thread)池,而从?MQ的角度来看,它仅仅是一个存储我们需要的全局状态的对象。在上图中,A库和B库都有自己的context。它们之间无法互相干扰。

看到这里应该已经非常明显了:绝不要在库中使用全局状态。如果你这么做了,当库恰好需要在同一个进程中实例化两次时,它很可能会崩溃。

24.3 性能

ZeroMQ早期的主要目标是优化性能。消息通信系统的性能可以用两个指标来界定:吞吐量——在一段给定的时间内可以传递多少条消息;时延——一条消息从一端传到另一端需要花费多长时间。

我们应该重点关注哪个指标?这两者之间的关系是什么?这还不明摆着吗?跑测试,用测试的总时间除以消息的数量,你得到的就是时延。用消息的数量除以总时间,你得到的就是吞吐量。换句话说,时延是吞吐量的倒数。很简单,不是吗?

我们并没有直接开始编码,而是花了几周的时间详细调查性能指标,然后,我们发现吞吐量和时延之间的关系绝非如此简单,通常是相当违反直觉的。假设A发送消息给B(见图24.2),测试的总时间是6秒,总共有5条消息传递,因此吞吐量是0.83条消息/每秒(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条消息/秒(5/2)。在B点共花费了4秒才将所有的消息都接收完毕。因此,从B的角度来看,吞吐量是1.25条消息/秒(5/4)。这两个数据都同之前计算得出的1.2条消息/秒不吻合。

长话短说吧,时延和吞吐量显然是两个不同的指标,重要的是理解这两者之间的区别以及它们的相互关系。时延只能在系统的两个不同端点之间才能测量,A点本身并没有什么时延。每条消息都有它们自己的时延,你可以通过多条消息来计算平均时延,但对于一个消息流来说并没有什么时延。另一方面,吞吐量只能在系统的某个端点处才能测量。发送端有吞吐量,接收端有吞吐量,这两者之间的任意中间结点也有吞吐量,但整个系统没有什么总吞吐量的概念。另外,吞吐量只对一组消息有意义,单条消息是没有什么吞吐量可言的。至于吞吐量和时延的关系,我们已经证明了它们之间确实有联系。但公式中涉及到积分,我们就不在这里讨论了,想了解更多可以去读关于队列理论的著作。

对消息通信系统进行的基准测试中还有许多缺陷,但我们不会进一步探讨了。这里应该再次强调我们学到的东西:确保理解你正在解决的问题,即使是理解一个“让它更快”这样简单的问题也需要耗费大量工作。更何况如果你不理解问题,你很可能会隐式的将假设和流行的错误观点置入代码中,让解决方案要么有缺陷或至少非常复杂,要么没有达到它应达到的实用程度。

24.4 关键路径

在性能优化的过程中,我们发现有三个因素会对性能产生重要影响:

  • 内存分配的次数
  • 系统调用的次数
  • 并发模型

然而,并非每次内存分配或系统调用都对性能产生同样的影响。对于消息通信系统的性能,我们关注在给定的时间内能在两点间传送的消息数量,同时可能关注消息从一点传送到另一点需要多久。考虑到ZeroMQ被设计为针对长期连接的场景,建立一个连接或处理一个连接错误花费的时间基本上可以忽略,这些事件极少发生,因此它们对总体性能的影响可以忽略不计。

代码库中某个一遍又一遍被频繁使用的部分被称为关键路径,优化应该集中到这些关键路径上来。

让我们看一个例子:ZeroMQ在内存分配方面并没有优化太多,比如操作字符串时通常在每个变换的中间阶段都分配一个新字符串。然而,如果我们严格审查关键路径——实际完成消息通信的部分——我们会发现这部分几乎没有使用任何内存分配。如果是短消息,那么每256个消息才会有一次内存分配(这些消息都被保存到一个单独的大内存块中)。此外,如果消息流是稳定的,在不出现流峰值的情况下,关键路径部分的内存分配次数会降为零(已分配的内存块不会返回给系统,而是不断的进行重用)。

我们从中学到的是:只优化能对结果能产生影响的部分,优化非关键路径上的代码只是在做无用功。

24.5 内存分配

当所有基础组件都已经初始化完成,两点之间的一条连接也已经建立完成时,要发送一条消息只有一样东西需要分配内存:消息本身。因此,要优化关键路径,我们就必须考虑消息是如何分配和在栈上来回传递的。

在高性能网络编程领域的常识中,最佳性能是通过仔细的平衡消息分配和消息拷贝的开销实现的(比如,http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf 参见针对“小型”、“中型”、“大型”消息的不同处理)。对小型的消息,拷贝操作比内存分配要经济的多。只要有需要,完全不分配新的内存块而直接把消息拷贝到预分配好的内存块上是有道理的。对于大型的消息,拷贝操作又比内存分配的开销要昂贵的多。为消息体分配一次内存然后传递指向分配块的指针,而非拷贝整个数据。这种方式被称为“零拷贝”。

ZeroMQ以透明的方式处理这两种情况,一条ZeroMQ消息由一个不透明的句柄来表示。对非常短小的消息,其内容被直接编码到句柄中。因此,对句柄的拷贝实际上就是对消息数据的拷贝。当遇到较大的消息时,它被分配到一个单独的缓冲区内,而句柄只包含一个指向缓冲区的指针。对句柄的拷贝并不会造成对消息数据的拷贝,当消息有数兆字节长时,这么处理是很有道理的(图24.3)。需要提醒的是,后一种情况里缓冲区是按引用计数的,因此可以做到被多个句柄引用而不必拷贝数据。

图24.3 消息拷贝(或者不拷贝)

我们从中学到的是:当考虑性能问题时,不要假设存在一个单一的最佳解决方案。很可能这个问题有多个子问题(例如,小型消息和大型消息),而每一个子问题都有各自的最佳算法。

24.6 批量处理

前面已经提到过,消息通信系统中过多的系统调用会导致性能瓶颈。实际上,这个问题要更普遍化的多,有无法忽视的性能损失与遍历栈有关。因此,明智的做法是,当创建高性能应用时应该尽可能的去避免遍历栈。

参考图24.4,为了发送4条消息,你不得不遍历整个网络协议栈4次(也就ZeroMQ、glibc、用户/内核空间边界、TCP实现、IP实现、以太网链路层、网卡本身,然后反过来再来一次)。

图24.4 发送4条消息

然而,如果你决定将这些消息合为一个单独的批次,就只需要遍历一次栈了(见图24.5)。这种处理方式对消息吞吐量的影响是巨大的,可达两个数量级,尤其是如果消息都比较短小,数百个这样的短消息才能包装成一个批次。

图24.5 批量处理消息

另一方面,批量处理会对时延带来负面影响。比如,我们来分析一下TCP实现中著名的Nagle算法。它为待发出的消息延迟一定的时间,然后将所有的数据合并成一个单独的数据包。显然,数据包中第一条消息的端到端时延要比最后一条消息严重的多。因此,如果应用程序需要持续的低时延,常见做法是将Nagle算法关闭,更常见的是取消整个栈层次上的批量处理(比如,网卡的中断汇聚功能)。

但同样,没有批量处理就意味着需要大量穿越整个调用栈,这会导致消息吞吐量降低。似乎我们被困在吞吐量和时延的两难境地中了。

ZeroMQ尝试采用以下策略来提供持续的低时延和高吞吐量:当消息流比较稀疏,不超过网络协议栈的带宽时,ZeroMQ关闭所有批量处理以改善时延。代价是CPU的使用率会变得略高——我们仍然需要经常穿越整个调用栈,好在大多数情况下这并不是问题。

当消息速率超过网络协议栈的带宽时,消息就必须排队处理了——保存在内存中直到栈准备好接收它们。排队处理就意味着时延上升,如果消息在队列中要花费1秒时间,端到端的时延就至少会达到1秒。更糟糕的是,随着队列长度的增长,时延会显著提升。如果队列的长度没有限制的话,时延就会超过任何限定值。据观察,即使调整网络协议栈以追求最低的时延(关闭Nagle算法,关闭网卡中断汇聚功能等等),时延仍会因队列的影响而较高。

在这种情况下积极采取批量化处理是合理的,反正时延已经比较高了,也没什么好顾虑的了。另一方面,积极的采用批量处理能够提高吞吐量,而且可以清空队列中等待的消息——这反过来又意味着排队时间逐渐变短时延逐步降低。一旦队列中没有未发送的消息了,就可以关闭批量处理,进一步改善时延。

需要额外注意的是,批量处理只应该在最高层进行。如果消息在最高层汇聚为批次,在低层次上就没什么可做批量处理的了,所有低层次的批量处理算法除了会增加总体时延外什么都没做。

我们从中学到的是,在一个异步系统中,要获得最佳的吞吐量和响应时间,需要在调用栈的底层关闭批量处理算法,而在高层开启。仅在新数据到达的速率快于它们被处理的速率时才做批量处理。

24.7 架构概览

到目前为止,我们都专注于那些使ZeroMQ变得快速的通用原则。从现在起,我们可以看一看实际的系统架构了(图24.6)。

图24.6: ZeroMQ的架构

用户使用所谓的“套接字”与ZeroMQ交互,它们同TCP套接字很相似,主要的区别是这里的套接字能处理同多个对端的通信,有点像非绑定的UDP套接字。

套接字对象存在于用户线程中(见下一节的线程模型讨论)。除此之外,ZeroMQ运行多个工人线程以处理通信中的异步环节:从网络中读取数据、将消息排队、接受新连接等等。

工人线程中有多个对象,每个对象只能由唯一的父母对象拥有(所有权由图中一个简单的实线标记),父母对象的线程可以与子女对象不同。大多数对象直接由套接字拥有,但在几种情况下对象会被一个由套接字拥有的对象拥有,对每个套接字我们都有一个对应的对象树。我们在关闭连接时会用到对象树,在一个对象关闭它所有的子对象前其不能被关闭。这样我们可以确保关闭操作可以按预期的行为那样正常工作。比如,在队列中等待发送的消息要先发送到网络中,之后才能终止发送过程。

大致来说,异步对象有两种类型,有的对象不会涉及消息传递,而有些需要。前者主要负责管理连接,比如,一个TCP监听对象在监听接入的TCP连接,并为每个新连接创建一个引擎/会话对象。类似的,一个TCP连接对象试图连接到TCP对端,成功则创建一个引擎/会话对象来管理这个连接,失败则连接对象会尝试重新建立连接。

后者自己负责数据传输,这些对象由两部分组成:会话对象负责与ZeroMQ的套接字交互,而引擎对象负责同网络进行通信。会话对象只有一种类型,而每种ZeroMQ所支持的协议都有对应类型的引擎对象。因此,我们有TCP引擎,IPC(进程间通信)引擎,PGM引擎(一种可靠的多播协议,参见RFC 3208),等等。引擎的集合非常广泛——未来我们可能会选择实现比如WebSocket引擎或者SCTP引擎。

会话对象与套接字对象交换消息,允许双向传递消息,在每个方向上由一个管对象来处理。基本上来说,管对象就是一个优化过的用来在线程之间快速传递消息的无锁队列。最后我们来看看上下文对象(在前一节中提到过,但没有在图中表示出来),该对象保存全局状态,所有的套接字和异步对象都可以访问它。

24.8 并发模型

ZeroMQ需要充分利用多核的优势,换句话说,就是增加CPU核心数能够线性的提升吞吐量。

我们之前关于消息通信系统的经验表明,采用经典的多线程方式(临界区、信号量等等)并不能较大的提升性能。事实上,即使在多核环境下,一个多线程版的消息通信系统可能会比一个单线程的版本还要慢。太多时间都花在等待其他线程上了,同时,引入的大量上下文切换拖慢了整个系统。

针对这些问题,我们决定采用一种不同的模型,希望能完全避免锁机制并让每个线程都能全速运行。线程间通信通过在线程间传递异步消息(事件)实现。内行人都应该知道,这就是经典的actor模式。

我们的想法是在每一个CPU核心上运行一个工人线程——让两个线程共享一个核心只会导致大量的上下文切换而没有特别的优势。每一个ZeroMQ的内部对象,比如TCP引擎,将会紧密地关联到一个特定的工人线程上。反过来,这意味着我们不再需要临界区、互斥锁、信号量这些东西了。此外,这些ZeroMQ对象不会在CPU核之间迁移,从而能避免由于缓存被污染导致的性能下降(图24.7)。

图24.7 多个工人线程

这个设计让很多传统多线程的问题都消失了。然而我们还需要在许多对象间共享工人线程,这又意味着必须有某种多任务间的合作机制,即我们需要一个调度器,对象必须是事件驱动的而非在整个事件循环中来控制。我们必须考虑任意序列的事件,甚至非常罕见的情况,必须确保不会有哪个对象持有CPU的时间过长等等。

简单来说,整个系统必须是全异步的。任何对象都无法承受阻塞式的操作,因为这不仅会阻塞其自身,而且会阻塞所有共享同一个工人线程的其他对象。所有对象都必须显式或隐式的成为一种状态机。随着成百上千的状态机并行运转着,你必须处理这些状态机之间的所有可能发生的交互,而其中最重要的就是关闭进程。

事实证明,要以一种清晰的方式关闭全异步的系统是一个相当复杂的任务。试图关闭上千个有的正在工作中、有的处于空闲状态、有的正在初始化中、有的已经自行关闭了的运转着的部分,极易出现各种竞态条件、资源泄露之类的情况。ZeroMQ中最复杂的部分就是这个关闭子系统了,快速检查一下bug跟踪系统的记录,就能发现30-50%的bug都同关闭有某种联系。

我们从中学到的是:当要追求极端的性能和可扩展性时,考虑采用actor模型,在这种情况下这几乎是你唯一的选择。不过,如果不使用像Erlang或者ZeroMQ这种专门的系统,你将不得不手工编写并调试大量的基础组件。此外,从一开始就要好好思考关于系统关闭的步骤。这将是代码中最为复杂的部分,而如果你没有清晰的思路该如何实现它,你可能应该重新考虑在一开始就使用actor模型。

24.9 无锁算法

最近比较流行使用无锁算法。它们是用于线程间通信的一种简单机制,不依赖于内核提供的互斥锁和信号量等同步原语。相反,它们用CPU原子操作来实现同步,比如原子化的比较并交换指令(CAS)。应该理解它们并不是字面意义上的无锁,锁机制是在硬件层面实现的。

ZeroMQ在管对象中采用无锁队列在用户线程和ZeroMQ的工人线程之间传递消息。关于ZeroMQ是如何使用无锁队列的,这里有两个有趣的地方。

首先,每个队列只有一个写线程和一个读线程。如果有一对多的通信需求,那么就创建多个队列(图24.8)。采用这种方式时队列不需要考虑对写线程和读线程的同步(只有一个写线程,也只有一个读线程),能以非常高效的方式来实现。

图24.8队列

其次,我们意识到尽管无锁算法比经典的基于互斥锁的算法高效,CPU的原子操作开销仍然非常高昂(尤其是当CPU核心之间有竞争时),对每条消息的读写都采用原子操作的效率将低于我们所能接受的水平。

提高速度的方法仍是批量处理。假设你有10条消息要写入到队列。比如,你可能会收到一个包含10条短消息的网络数据包。接收数据包是一个原子事件,你不能只接收一半,这个原子事件导致需要将10条消息写到无锁队列中,因此对每条消息都采用一次原子操作就没什么道理了。取而代之的是,你可以让写线程拥有一块自己独占的“预写”区域,让它先把消息都写到这里,然后再用一次单独的原子操作,整体刷入队列。

同样的方法也适用于从队列中读取消息。假设上面提到的10条消息已经刷新到队列中了。读线程可以用一个原子操作读取每条消息,但这种做法过于笨重了。相反,读线程可以将所有待读取的消息用一次原子操作移动到队列的“预读取”部分,之后就可以从预读缓存中一条一条的读取消息了。预读缓存只能由读线程访问,因此这里没有同步之类的问题。

图24.9中左边的箭头展示了如何通过简单地修改一个指针来将预写缓存刷新到队列中的,右边的箭头展示了队列的整个内容是如何通过修改另一个指针来移动到预读缓存中的。

图24.9 无锁队列

Figure 24.9: Lock-free queue

我们从中学到的是:发明无锁算法非常困难,实现起来很麻烦,几乎不可能对其调试。如果可能的话,使用现有的成熟算法而非自己重新发明轮子。当需要极高的性能时,不要只依赖无锁算法。虽然它们的速度很快,在其之上进行智能化的批量处理仍可以显著提高性能。

24.10 API

用户接口是任何软件产品中最重要的部分。这是你的程序唯一暴露给外部世界的部分,如果搞砸了全世界都会恨你的。对于面向最终用户的产品来说,用户接口就是图形用户界面或者命令行界面,对于库来说就是API了。

在ZeroMQ的早期版本中,其API基于AMQP模型的交换和队列(参见AMQP规范)。事后看来,2007年的白皮书尝试要将AMQP同一个代理模式的消息通信系统相整合相当有趣。我在2009年底几乎从零开始重写了整个项目以使用BSD套接字API。那就是转折点,之后ZeroMQ的用户数量开始猛增。之前的ZeroMQ是消息通信领域的专家们使用的产品,而现在成为任何人都能方便使用的普通工具。在1年左右的时间里,ZeroMQ的用户社群扩大了10倍之多,我们还实现了对20多种不同编程语言的绑定等等。

用户接口定义了人们对产品的感观,ZeroMQ基本没有改变功能——仅仅通过修改了API——就从一个“企业级消息通信”产品转变为一个“网络化”的产品。换句话说,人们对ZeroMQ的感观从一个“大金融机构所使用的复杂基础组件”转变为“嘿,它可以帮助我从程序A发送10字节长的消息到程序B”。

我们从中学到的是:正确理解你的项目,根据你对项目的愿景来合理地设计用户接口。用户接口同项目的愿景不相符将100%的保证该项目失败。

将ZeroMQ的用户接口改为BSD套接字API最重要的因素之一就是它并非一个新的API,而是早就被人们所熟悉了。事实上,BSD套接字API是当今仍在使用中的最为古老的API之一了,其历史可以回溯到1983年和4.2版BSD Unix的时代,它已经被广泛且稳定的使用了几十年了。

上面的事实带来了许多优势。首先,人人都知道BSD套接字API,其学习曲线非常平坦。就算你从未听说过ZeroMQ,你也可依靠过去你在BSD套接字上的经验在几分钟内创建一个应用程序。

其次,使用一种被广泛支持的API让ZeroMQ可能兼容已有的技术。比如,将ZeroMQ对象暴露为“套接字”或者“文件描述符”,可以让我们在同样的事件循环中处理TCP、UDP、管道、文件以及ZeroMQ事件。另一个例子是:要将类似ZeroMQ的功能加入到Linux内核中就变得非常容易实现了。通过共享相同的概念框架,ZeroMQ可以复用很多已有的基础组件。

第三,也许也是最重要的一点,尽管人们曾多次尝试替换它,BSD套接字API已经存活了近30年了,这意味着设计中有某种内在的正确性。BSD套接字API的设计者——无论是有意的还是偶然的——做出了正确的设计决策。采用这套API,我们可以自动分享到这些设计决策,而不必知道这些决策究竟是什么,或者它们到底解决了什么问题。

我们从中学到的是:虽然代码复用的思想和稍晚的模式复用的概念从很久很久以前就有了,以一种更一般化的方式来思考复用很重要。当设计产品设计,参考一下相似的产品,调查一下哪些方面是失败的,哪些方面是成功的。从成功的项目中学习。不要觉得没有创新就接受不了。复用好的点子、API、概念框架或任何你觉得合适的东西。这么做的好处是你可以让用户重用他们之前的知识,同时也可以避开当前你并不了解的技术陷阱。

24.11 消息模式

在任何消息通信系统中,最重要的设计问题是如何向用户提供一种指定哪条消息可以路由到哪个目的地。这里主要有两种方法,我相信这两种方法相当通用,适用于软件领域中遇到的几乎所有问题。

第一种方式是采用Unix哲学中的“只做一件事,并把它做好”原则。这意味着问题域应该人为地限制在一个较小且易理解的范围内,程序应以正确和详尽的方式来解决这个受限制的问题。消息通信领域中一个采用这种方式的例子是MQTT。这是一种将消息分发给一组用户的协议,它不能用于任何其他用途(比如RPC),但它很容易使用而且在消息分发方面做得很出色。

另一种方式是致力于一般性,提供一种功能强大且高度可配置的系统。AMQP就是这样一个例子。它的队列和互换的模型提供给用户编程能力,几乎可以定义出任一种路由算法。当然了,有得必有失,取舍的结果就是增加了许多选项需要我们去处理。

ZeroMQ选择了前一种方式,允许几乎所有的人使用该产品,而通用方式下的产品需要消息通信方面的专家才能使用。为了阐明这个观点,让我们看看模型是如何影响API复杂度的,如下代码是在通用系统(AMQP)之上的RPC客户端实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 ()

另一方面,ZeroMQ将消息划分为所谓的“消息模式”。几个模式的例子是“发布者/订阅者”,“请求/回复”和“并行管线”。每种消息通信的模式之间都是完全正交的,可视作一个独立的工具。接下来采用ZeroMQ的请求/回复模式对上面的应用进行重构,注意ZeroMQ将繁杂的选择通过正确的消息模式(“REQ”)缩减为一个单一的步骤。

1
2
3
4
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端通信。这意味着新的实现可以在任意时刻加到这个栈上,而不会对栈中已有的部分产生影响。相反,如果实现是失败的,则可以被完全放弃而不影响传输层的整体能力。

同样的道理也适用于ZeroMQ中定义的消息模式,消息模式在传输层(TCP及其它成员)之上组成了新的一层(所谓的“可扩展性层”)。每个消息模式都是这层的具体实现。它们都是严格正交的——“发布者/订阅者”端无法同“请求/回复”端通信。消息模式之间的严格分离反过来又意味着新的模式可以按照需求增加进来,开发新模式的实验如果失败了,也不会伤害现有的模式。

我们从中学到的是:当解决一个复杂且多面的问题时,单个通用型的解决方案可能并不是最好的方式。取而代之的是,我们可以把问题的领域想象成一个抽象层,并基于此提供多个实现,每种实现只致力于解决一种定义良好的情况。当我们这么做时,要仔细划定用例情况。要确认什么在范围内,什么不在范围内。如果范围限制的过于严格,软件的应用就会受到限制。如果问题定义的太广,那么产品就会变得过于复杂,给用户带来模糊和混乱的感觉。

24.12 结论

由于我们的世界变的充斥着大量通过互联网相连的小型计算机——移动电话、RFID阅读器、平板电脑以及便携式计算机、GPS设备等等——分布式计算已经不再局限于学术领域而是成为了每位开发者需要去解决的日常问题了。不幸的是,大多数解决方案都是领域相关的独门秘技。本文以系统化的方式总结了我们在构建大规模分布式系统中的经验,主要侧重于从软件架构的观点来阐明我们需要面对的挑战,希望开源社区中的架构师和程序员会发现本文很有帮助。