原文链接:http://www.aosabook.org/en/zeromq.html
ØMQ是一个消息通信系统,如果你愿意的话也可以称其为“面向消息的中间件”。ØMQ的应用环境很广泛,包括金融服务、游戏开发、嵌入式系统、学术研究以及航空航天等领域。
消息通信系统完成的工作基本上可看作为负责应用程序之间的即时消息通信。一个应用程序决定发送一个事件给另一个应用程序(或者多个应用程序),它将需要发送的数据组合起来,点击“发送”按钮就行了——消息通信系统会搞定剩下的工作。
不同于即时消息通信的是,消息通信系统没有图形用户界面,并假设当出现错误时,对端并不会有人为干预的智能化处理。因此,消息通信系统必须既要有高度的容错性,也要比一般的即时消息通信更快速。
ØMQ最初的设想是作为股票交易中的一个极快速的消息通信系统,因此重点放在了高度优化上。项目开始的头一年都花在制定性能基准测试的方法上了,并尝试设计出一个尽可能高效的架构。
之后,大约是在项目进行的第二年里,开发的重点转变成为构建分布式应用程序而提供的一个通用系统,支持任意模式的消息通信、多种传输机制、对多种编程语言的绑定等等。
在开发的第三年里,重点主要集中于提高系统的可用性,将学习曲线平坦化。我们已经采用了BSD套接字API,尝试整理单个消息通信模式的语义等等。
本章试图向读者介绍,ØMQ为达到上述三个目标是如何设计其内部架构的,也希望给同样面对这些问题的人提供一些启示。
启动ØMQ项目的第三年里,其代码库已经膨胀的过于庞大。有一项提议要标准化ØMQ中所使用的协议,以及实验性地实现一个类ØMQ的消息通信系统以加入到Linux内核中等等。不过,本书并未涵盖这些主题,更多细节可以参考:http://www.250bpm.com/concepts,http://groups.google.com/group/sp-discuss-group,和http://www.250bpm.com/hits。
ØMQ是一个程序库,不是消息通信服务器。我们花了好几年时间在AMQP上,这是一种在金融行业中尝试标准化用于商业消息通信的协议。我们为其编写了一个参考性的实现,然后部署到几个主要基于消息通信技术的大型项目中使用——由此我们意识到,智能消息服务器(代理/broker)和哑客户端之间的这种经典的客户机/服务器模型是有问题的。
当时我们主要关心的是性能:如果中间有个服务器的话,每条消息都不得不穿越网络两次(从发送者到服务器,然后从服务器再到接收者),还附带有延迟和吞吐量方面的损耗。此外,如果所有的消息都要通过服务器传递的话,某一时刻它就必然会成为性能的瓶颈。
第二点需要关心的是关于大规模部署的问题:当消息通信需要跨越公司的界限时,这种中央集权式管理所有消息流的概念就不再有效了。没有一家公司愿意把对服务器的控制权放在别的公司里,这包含有商业机密以及法律责任相关的问题。实际结果就是每家公司都有一个消息通信服务器,可通过手动桥接的方式连接到其他公司的消息通信系统中。因此整个经济系统被极大的划分开来,但是为每个公司维护这样大量的桥接并没有使情况变得更好。要解决这个问题,我们需要一个分布式的架构。在这种架构中每一个组件都可以由一个不同的商业实体来管辖。鉴于基于服务器架构的管理单元就是服务器,我们可以通过为每个组件设置一个单独的服务器来解决这个问题。在这种情况下,我们可以通过使服务器和组件共享同一个进程来进一步地优化设计。我们最终得到的就是一个消息通信的程序库。
当我们开始设想一种不需要中间服务器的消息通信机制时,也就是ØMQ项目开始之时。这需要自下而上的将整个消息通信的概念颠倒过来,将位于网络中央的集中信息存储模型替换为基于端到端机制的“智能型终端,沉默化网络”的架构。正是由于这样的技术决策,ØMQ从一开始就作为一个库而存在,它不是应用程序。 同时,我们也已经证明了这种架构更加高效(低延迟,高吞吐量)也更加灵活(很容易在此之上构建任意复杂的拓扑结构,而不必拘泥于经典的中心辐射模型)。
然而选择以库的形式发布,这其中还有一个意想不到的结果,那就是这么做提高了产品的可用性。用户反复地表示由于他们不再需要安装和管理一个独立的消息通信服务器了,为此他们感到很庆幸。事实证明,去掉中间服务器是首选方案,因为这么做降低了运营的成本(不需要为消息通信服务器安排管理员),也加快了市场响应的时间(没有必要对客户、管理层或运营团队谈判沟通是否要运行服务器)。
我们从中学到的是,当开始一个新项目时,你应该尽可能的选择以库的形式来设计。我们可以很容易的通过从小型程序中调用库的实现而创建出一个应用,但是却几乎不可能从已有的可执行程序中创建一个库。库对用户来说可以提供更高的灵活性,同时也不需要花费他们很多精力来管理。
全局变量不适于在库中使用。因为一个进程可能会加载同一个库几次,而它们会共用一组全局变量。在图24.1中,ØMQ库被两个不同的、彼此独立的库所调用,而应用本身调用了这两个库。
图24.1 不同的库在使用ØMQ
当出现这种情况时,两个ØMQ的实例会访问到相同的变量,这会产生竞争条件,出现奇怪的错误和未定义的行为。
要防止出现这种问题,ØMQ中没有使用任何全局变量。相反地,是由库的使用者来负责显式地创建全局状态。包含全局状态的对象称为context。从用户的角度来看,context或多或少类似一个工作者线程池,而从ØMQ的角度来看,它仅仅是一个存储我们所需要的任意全局状态的对象。在上图中,libA会有它自己的context,而libB也会有它自己的context。它们之间无法互相干扰。
看到这里应该已经非常明显了:绝不要在库中使用全局状态。如果你这么做了,当库恰好需要在同一个进程中实例化两次时,它很可能会崩溃。
当ØMQ项目开始之后,主要的目标是优化性能。消息通信系统的性能可以用两个指标来界定:吞吐量——在一段给定的时间内可以传递多少条消息;以及时延——一条消息从一端传到另一端需要花费多长时间。
我们应该重点关注哪个指标?这两者之间的关系是什么?这还不明摆着吗?跑测试,用测试的总时间除以消息的数量,你得到的就是时延。用消息的数量除以总时间,你得到的就是吞吐量。换句话说,时延是吞吐量的倒数。很简单,不是吗?
我们并没有直接开始编码,而是花了几周的时间详细调查性能指标,我们发现吞吐量和时延之间的关系绝非如此简单,通常这个指标数是相当违反直觉的。
假设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点本身并没有什么时延。每条消息都有它们自己的时延,你可以通过多条消息来计算平均时延,但是,对于一个消息流来说并没有什么时延。
换句话说,吞吐量只能在系统的某个端点处才能测量。发送端有吞吐量,接收端有吞吐量,这两者之间的任意中间结点也有吞吐量,但对整个系统来说就没有什么总吞吐量的概念了。另外,吞吐量只对一组消息有意义,单条消息是没有什么吞吐量可言的。
至于吞吐量和时延之间的关系,我们已经证明了原来它们之间确实有关系。但是,公式表达中涉及到积分,我们就不在这里讨论了。要得到更多的信息,可以去读一读有关队列的论文。
关于对消息通信系统进行的基准测试还有许多缺陷存在,但我们不会进一步探讨了。这里应该再次强调我们为此得到的教训:确保理解你正在解决的问题。即使是一个“让它更快”这样简单的问题也会耗费你大量的工作才能正确理解之。更何况如果你不理解问题,你很可能会隐式地将假设和某种流行的观点置入代码中,这使得解决方案要么是有缺陷的或者至少会变得非常复杂,又或者会使得该方案没有达到它应有的适用范围。
我们在性能优化的过程中发现有3个因素会对性能产生严重的影响:
但是,并不是每个内存分配或者每个系统调用都会对性能产生同样的影响。对于消息通信系统的性能,我们所感兴趣的是在给定的时间内能在两点间传送的消息数量。另外,我们可能会感兴趣的是消息从一点传送到另一点需要多久。
考虑到ØMQ被设计为针对长期连接的场景,因此建立一个连接或者处理一个连接错误所花费的时间基本上可忽略。这些事件极少发生,因此它们对总体性能的影响可以忽略不计。
代码库中某个一遍又一遍被频繁使用的部分,我们称之为关键路径。优化应该集中到这些关键路径上来。 让我们看一个例子:ØMQ在内存分配方面并没有做高度优化。比如,当操作字符串时,常常是在每个转化的中间阶段分配一个新的字符串。但是,如果我们严格审查关键路径——实际完成消息通信的部分——我们会发现这部分几乎没有使用任何内存分配。如果是短消息,那么每256个消息才会有一次内存分配(这些消息都被保存到一个单独的大内存块中)。此外,如果消息流是稳定的,在不出现流峰值的情况下,关键路径部分的内存分配次数会降为零(已分配的内存块不会返回给系统,而是不断的进行重用)。
我们从中学到的是:只在对结果能产生影响的地方做优化。优化非关键路径上的代码只是在做无用功。
假设所有的基础组件都已经初始化完成,两点之间的一条连接也已经建立完成,此时要发送一条消息时只有一样东西需要分配内存:消息体本身。因此,要优化关键路径,我们就必须考虑消息体是如何分配的以及是如何在栈上来回传递的。
在高性能网络编程领域中,最佳性能是通过仔细地平衡消息的分配以及消息拷贝所带来的开销而实现的,这是常识(比如,http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf 参见针对“小型”、“中型”、“大型”消息的不同处理)。对于小型的消息,拷贝操作比内存分配要经济的多。只要有需要,完全不分配新的内存块而直接把消息拷贝到预分配好的内存块上,这么做是有道理的。另一方面,对于大型的消息,拷贝操作比内存分配的开销又要昂贵的多。为消息体分配一次内存,然后传递指向分配块的指针,而不是拷贝整个数据。这种方式被称为“零拷贝”。
ØMQ以透明的方式同时处理这两种情况。一条ØMQ消息由一个不透明的句柄来表示。对于非常短小的消息,其内容被直接编码到句柄中。因此,对句柄的拷贝实际上就是对消息数据的拷贝。当遇到较大的消息时,它被分配到一个单独的缓冲区内,而句柄只包含一个指向缓冲区的指针。对句柄的拷贝并不会造成对消息数据的拷贝,当消息有数兆字节长时,这么处理是很有道理的(图24.3)。需要提醒的是,后一种情况里缓冲区是按引用计数的,因此可以做到被多个句柄引用而不必拷贝数据。
图24.3 消息拷贝(或者不拷贝)
我们从中学到的是:当考虑性能问题时,不要假设存在有一个最佳解决方案。很可能这个问题有多个子问题(例如,小型消息和大型消息),而每一个子问题都有各自的优化算法。
前面已经提到过,在消息通信系统中,系统调用的数量太多的话会导致出现性能瓶颈。实际上,这个问题绝非一般。当需要遍历调用栈时会有不小的性能损失,因此,明智的做法是,当创建高性能的应用时应该尽可能多的去避免遍历调用栈。
参见图24.4,为了发送4条消息,你不得不遍历整个网络协议栈4次(也就是,ØMQ、glibc、用户/内核空间边界、TCP实现、IP实现、以太网链路层、网卡本身,然后反过来再来一次)。
图24.4 发送4条消息
但是,如果你决定将这些消息集合到一起成为一个单独的批次,那么就只需要遍历一次调用栈了(图24.5)。这种处理方式对消息吞吐量的影响是巨大的:可大至2个数量级,尤其是如果消息都比较短小,数百个这样的短消息才能包装成一个批次。
图24.5 批量处理消息
另一方面,批量处理会对时延带来负面影响。我们来分析一下,比如,TCP实现中著名的Nagle算法。它为待发出的消息延迟一定的时间,然后将所有的数据合并成一个单独的数据包。显然,数据包中的第一条消息,其端到端的时延要比最后一条消息严重的多。因此,如果应用程序需要持续的低时延的话,常见做法是将Nagle算法关闭。更常见的是取消整个调用栈层次上的批量处理(比如,网卡的中断汇聚功能)。
但同样,不做批量处理就意味着需要大量穿越整个调用栈,这会导致消息吞吐量降低。似乎我们被困在吞吐量和时延的两难境地中了。
ØMQ尝试采用以下策略来提供一致性的低时延和高吞吐量。当消息流比较稀疏,不超过网络协议栈的带宽时,ØMQ关闭所有的批量处理以改善时延。这里的权衡是CPU的使用率会变得略高——我们仍然需要经常穿越整个调用栈。但是在大多数情况下,这并不是个问题。
当消息的速率超过网络协议栈的带宽时,消息就必须进行排队处理了——保存在内存中直到协议栈准备好接收它们。排队处理就意味着时延的上升。如果消息在队列中要花费1秒时间,端到端的时延就至少会达到1秒。更糟糕的是,随着队列长度的增长,时延会显著提升。如果队列的长度没有限制的话,时延就会超过任何限定值。
据观察,即使调整网络协议栈以追求最低的时延(关闭Nagle算法,关闭网卡中断汇聚功能,等等),由于受前文所述的队列的影响,时延仍然会比较高。
在这种情况下,积极的采取批量化处理是有意义的。反正时延已经比较高了,也没什么好顾虑的了。另一方面,积极的采用批量处理能够提高吞吐量,而且可以清空队列中等待的消息——这反过来又意味着时延将逐步降低,因为正是排队才造成了时延的上升。一旦队列中没有未发送的消息了,就可以关闭批量处理,进一步的改善时延。
我们观察到批量处理只应该在最高层进行,这是需要额外注意的一点。如果消息在最高层汇聚为批次,在低层次上就没什么可做批量处理的了,而且所有低层次的批量处理算法除了会增加总体时延外什么都没做。 我们从中学到了:在一个异步系统中,要获得最佳的吞吐量和响应时间,需要在调用栈的底层关闭批量处理算法,而在高层开启。仅在新数据到达的速率快于它们被处理的速率时才做批量处理。
到目前为止,我们都专注于那些使ØMQ变得快速的通用性原则。从现在起,我们可以看一看实际的系统架构了(图24.6)。
图24.6 ØMQ的架构框图
用户使用被称为“套接字”的对象同ØMQ进行交互。它们同TCP套接字很相似,主要的区别在于这里的套接字能够处理同多个对端的通信,有点像非绑定的UDP套接字。
套接字对象存在于用户线程中(见下一节的线程模型讨论)。除此之外,ØMQ运行多个工作者线程用以处理通信中的异步环节:从网络中读取数据、将消息排队、接受新的连接等等。
工作者线程中存在着多个对象。每一个对象只能由唯一的父对象所持有(所有权由图中一个简单的实线来标记)。与子对象相比,父对象可以存在于其他线程中。大多数对象直接由套接字sockets所持有。但是,这里有几种情况下会出现一个对象由另一个对象所持有,而这个对象又由socket所持有。我们得到的是一个对象树,每个socket都有一个这样的对象树。我们在关闭连接时会用到对象树,在一个对象关闭它所有的子对象前,任何对象都不能自行关闭。这样我们可以确保关闭操作可以按预期的行为那样正常工作。比如,在队列中等待发送的消息要先发送到网络中,之后才能终止发送过程。
大致来说,这里有两种类型的异步对象。有的对象不会涉及到消息传递,而有些需要。前者主要负责连接管理。比如,一个TCP监听对象在监听接入的TCP连接,并为每一个新的连接创建一个engine/session对象。类似的,一个TCP连接对象尝试连接到TCP对端,如果成功,它就创建一个engine/session对象来管理这个连接。如果失败了,连接对象会尝试重新建立连接。
而后者用来负责数据的传输。这些对象由两部分组成:session对象负责同ØMQ的socket交互,而engine对象负责同网络进行通信。session对象只有一种类型,而对于每一种ØMQ所支持的协议都会有不同类型的engine对象与之对应。因此,我们有TCP engine,IPC(进程间通信)engine,PGM engine(一种可靠的多播协议,参见RFC 3208),等等。engine的集合非常广泛——未来我们可能会选择实现比如WebSocket engine或者SCTP engine。
session对象同socket之间交换消息。可以由两个方向来传递消息,在每个方向上由一个pipe对象来处理。基本上来说,pipe就是一个优化过的用来在线程之间快速传递消息的无锁队列。
最后我们来看看context对象(在前一节中提到过,但没有在图中表示出来),该对象保存全局状态,所有的socket和异步对象都可以访问它。
ØMQ需要充分利用多核的优势,换句话说就是随着CPU核心数的增长能够线性的扩展吞吐量。
以我们之前对消息通信系统的经验表明,采用经典的多线程方式(临界区、信号量等等)并不会使性能得到较大提升。事实上,就算是在多核环境下,一个多线程版的消息通信系统可能会比一个单线程的版本还要慢。有太多时间都花在等待其他线程上了,同时,引入了大量的上下文切换拖慢了整个系统。
针对这些问题,我们决定采用一种不同的模型。目标是完全避免锁机制,并让每个线程能够全速运行。线程间的通信是通过在线程间传递异步消息(事件)来实现的。内行人都应该知道,这就是经典的actor模式。
我们的想法是在每一个CPU核心上运行一个工作者线程——让两个线程共享同一个核心只会意味着大量的上下文切换而没有得到任何别的优势。每一个ØMQ的内部对象,比如说TCP engine,将会紧密地关联到一个特定的工作者线程上。反过来,这意味着我们不再需要临界区、互斥锁、信号量等等这些东西了。此外,这些ØMQ对象不会在CPU核之间迁移,从而可以避免由于缓存被污染而引起性能上的下降(图24.7)。
图24.7 多个工作者线程
这个设计让很多传统多线程编程中出现的顽疾都消失了。然而,我们还需要在许多对象间共享工作者线程,这反过来又意味着必须要有某种多任务间的合作机制。这表示我们需要一个调度器,对象必须是事件驱动的,而不是在整个事件循环中来控制。我们必须考虑任意序列的事件,甚至非常罕见的情况也要考虑到。我们必须确保不会有哪个对象持有CPU的时间过长等等。
简单来说,整个系统必须是全异步的。任何对象都无法承受阻塞式的操作,因为这不仅会阻塞其自身,而且所有共享同一个工作者线程的其他对象也都会被阻塞。所有的对象都必须或显式或隐式的成为一种状态机。随着有数百或数千的状态机在并行运转着,你必须处理这些状态机之间的所有可能发生的交互,而其中最重要的就是——关闭过程。
事实证明,要以一种清晰的方式关闭一个全异步的系统是一个相当复杂的任务。试图关闭一个有着上千个运转着的部分的系统,其中有的正在工作中,有的处于空闲状态,有的正在初始化过程中,有的已经自行关闭了,此时极易出现各种竞态条件、资源泄露等诸如此类的情况。ØMQ中最为复杂的部分肯定就是这个关闭子系统了。快速检查一下bug跟踪系统的记录显示,约30%到50%的bug都同关闭有某种联系。
我们从中学到的是:当要追求极端的性能和可扩展性时,考虑采用actor模型。在这种情况下这几乎是你唯一的选择。不过,如果不使用像Erlang或者ØMQ这种专门的系统,你将不得不手工编写并调试大量的基础组件。此外,从一开始就要好好思考关于系统关闭的步骤。这将是代码中最为复杂的部分,而如果你没有清晰的思路该如何实现它,你可能应该重新考虑在一开始就使用actor模型。
最近比较流行使用无锁算法。它们是用于线程间通信的一种简单机制,同时并不会依赖于操作系统内核提供的同步原语,如互斥锁和信号量。相反,它们通过使用CPU原子操作来实现同步,比如原子化的CAS指令(比较并交换)。我们应该理解清楚的是它们并不是字面意义上的无锁——相反,锁机制是在硬件层面实现的。
ØMQ在pipe对象中采用无锁队列来在用户线程和ØMQ的工作者线程之间传递消息。关于ØMQ是如何使用无锁队列的,这里有两个有趣的地方。
首先,每个队列只有一个写线程,也只有一个读线程。如果有1对多的通信需求,那么就创建多个队列(图24.8)。鉴于采用这种方式时队列不需要考虑对写线程和读线程的同步(只有一个写线程,也只有一个读线程),因此可以以非常高效的方式来实现。
图24.8 队列
其次,尽管我们意识到无锁算法要比传统的基于互斥锁的算法更加高效,CPU的原子操作开销仍然非常高昂(尤其是当CPU核心之间有竞争时),对每条消息的读或者写都采用原子操作的话,效率将低于我们所能接受的水平。
提高速度的方法——再次采用批量处理。假设你有10条消息要写入到队列。比如,可能会出现当你收到一个网络数据包时里面包含有10条小型的消息的情况。由于接收数据包是一个原子事件,你不能只接收一半,因此这个原子事件导致需要写10条消息到无锁队列中。那么对每条消息都采用一次原子操作就显得没什么道理了。相反,你可以让写线程拥有一块自己独占的“预写”区域,让它先把消息都写到这里,然后再用一次单独的原子操作,整体刷入队列。
同样的方法也适用于从队列中读取消息。假设上面提到的10条消息已经刷新到队列中了。读线程可以对每条消息采用一个原子操作来读取,但是,这种做法过于重量级了。相反,读线程可以将所有待读取的消息用一个单独的原子操作移动到队列的“预读取”部分。之后就可以从“预读”缓存中一条一条的读取消息了。“预读取”部分只能由读线程单独访问,因此这里没有什么所谓的同步需求。
图24.9中左边的箭头展示了如何通过简单地修改一个指针来将预写入缓存刷新到队列中的。右边的箭头展示了队列的整个内容是如何通过修改另一个指针来移动到预读缓存中的。
图24.9 无锁队列
我们从中学到的是:发明新的无锁算法是很困难的,而且实现起来很麻烦,几乎不可能对其调试。如果可能的话,可以使用现有的成熟算法而不是自己来发明轮子。当需要追求极度的性能时,不要只依靠无锁算法。虽然它们的速度很快,但可以在其之上通过智能化的批量处理来显著提高性能。
用户接口是任何软件产品中最为重要的部分。这是你的程序唯一暴露给外部世界的部分,如果搞砸了全世界都会恨你的。对于面向最终用户的产品来说,用户接口就是图形用户界面或者命令行界面,而对于库来说,那就是API了。
在ØMQ的早期版本中,其API是基于AMQP的交易和队列模型的(参见AMQP规范)。从历史的角度来看,2007年的白皮书尝试要将AMQP同一个代理模式的消息通信系统相整合,这很有趣。我于2009年底重新使用BSD套接字API从零开始重写了整个项目。那就是转折点,从那一刻起ØMQ的用户数量开始猛增。之前的ØMQ是由消息通信领域的专家们所使用的产品,而现在成为任何人都能方便使用的普通工具。在1年左右的时间里,ØMQ的用户社群扩大了10倍之多,我们还实现了对20多种不同编程语言的绑定等等。
用户接口定义了人们对产品的感观。基本没有改变功能——仅仅通过修改了API——ØMQ就从一个“企业级消息通信”产品转变为一个“网络化”的产品。换句话说,人们对ØMQ的感观从一个“大金融机构所使用的复杂基础组件”转变为“嘿,这工具可以帮助我从程序A发送10字节长的消息到程序B”。
我们从中学到的是:正确理解你的项目,根据你对项目的愿景来合理地设计用户接口。用户接口同项目的愿景不相符合的话,可以100%保证该项目注定会失败。
将ØMQ的用户接口替换为BSD套接字API,这其中有个很重要的因素,那就是BSD套接字API并不是一个新的发明,而是早就为人们所熟悉了。事实上,BSD套接字API是当今仍在使用中的最为古老的API之一了。那得回溯到1983年以及4.2版BSD Unix的时代。它已经被广泛且稳定的使用了几十年了。
上面的事实带来了很多优势。首先,人人都知道BSD套接字API,因此学习的难度曲线非常平坦。就算你从未听说过ØMQ,你也可以在几分钟内创建出一个应用程序,这都得感谢你可以重用过去在BSD套接字上积累的经验。
其次,使用这样一种被广泛支持的API使得ØMQ可以同已有的技术进行融合。比如,将ØMQ对象暴露为“套接字”或者“文件描述符”,这可以让我们在同样的事件循环中处理TCP、UDP、管道、文件以及ØMQ事件。另一个例子是:要将类似ØMQ的功能加入到Linux内核中,这个实验性的项目就变得非常容易实现了。通过共享相同的概念框架,ØMQ可以复用很多已有的基础组件。
第三,也许也是最重要的一点,那就是BSD套接字API已经存活了将近30年的时间了,尽管中间人们曾多次尝试替换它。这意味着设计中有某种固有的正确性。BSD套接字API的设计者——无论是故意的还是偶然的——都做出了正确的设计决策。通过借用这套API,我们可以自动分享到这些设计决策,而不必知道这些决策究竟是什么,或者它们到底解决了什么问题。
我们从中学到的是:虽然代码复用的思想从远古时代就有了,随后模式复用的概念也加入了进来,重要的是要以一种更一般化的方式来思考复用。当做产品设计时,参考一下其他相似的产品。调查一下哪些方面是失败的,哪些方面是成功的,从成功的项目中学习。不要觉得没有创新就接受不了。复用好的点子、API、概念框架,任何你觉得合适的东西都可以复用。这么做的好处是你可以让用户重用他们之前的知识,同时你也可以避免当前你并不了解的技术方面的陷阱。
在任何消息通信系统中,所面临的最重要的设计问题是如何提供一种方式可以让用户指定哪条消息可以路由到哪个目的地。这里主要有两种方法,而且我相信这两种方法是相当通用的,基本可适用于软件领域中遇到的任何问题。
第一种方式是吸收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的请求/回复模式对上面的应用进行重构,注意ØMQ将繁杂的选择缩减为一个单一的步骤,这只要通过选择正确的消息模式“REQ”就可以了。
s = socket (REQ) s.connect ("tcp://192.168.0.111:5555") s.send ("Hello World!") reply = s.recv ()
到这里为止,我们已经可以认为具体化的解决方案比通用型解决方案要更好。我们希望自己的解决方案能尽可能的具体化。但是,同时我们又希望提供给用户的功能面尽可能的广。我们该如何解决这个明显的矛盾?
答案分两步:
让我们看看网络协议栈中有关传输层的例子。传输层意味着需要在网络层(IP)之上提供例如数据流传输、流控、可靠性等服务。它是通过定义多种互不干扰的解决方案来实现的:TCP作为面向连接的可靠数据流传输机制、UDP作为面向非连接的非可靠式数据包传输机制、SCTP作为多个流的传输、DCCP作为非可靠性连接等等。
注意,这里每种实现都是完全正交的:UDP端不能同TCP端通信,SCTP端也不能同DCCP端通信。这意味着新的实现可以在任意时刻加到这个栈上,而不会对栈中已有的部分产生影响。相反如果实现是失败的,则可以被完全丢弃而不会影响传输层的整体能力。
同样的道理也适用于ØMQ中定义的消息模式。消息模式在传输层(TCP及其它成员)之上组成了新的一层(所谓的“可扩展性层”)。每个消息模式都是这一层的具体实现。它们都是严格正交的——“发布者/订阅者”端无法同“请求/回复”端通信,等等之类。消息模式之间的严格分离反过来又意味着新的模式可以按照需求增加进来,开发新模式的实验如果失败了,也不会对已有的模式产生影响。
我们从中学到的是:当解决一个复杂且多面化的问题时,单个通用型的解决方案可能并不是最好的方式。相反,我们可以把问题的领域想象成一个抽象层,并基于这个层次提供多个实现,每种实现只致力于解决一种定义良好的情况。当我们这么做时,要仔细划定用例情况。要确认什么在范围内,什么不在范围内。如果对使用范围限制的太过于严格,软件的应用性就会受到限制。如果对问题定义的太广,那么产品就会变得非常复杂,给用户带来模糊和混乱的感觉。
由于我们的世界里已经充斥着大量通过互联网相连的小型计算机——移动电话、RFID阅读器、平板电脑以及便携式计算机、GPS设备等等。分布式计算已经不再局限于学术领域了,成为了每位开发者需要去解决的日常问题。不幸的是,对此的解决方案大多数都是领域相关的独门秘技。本文以系统化的方式总结了我们在构建大规模分布式系统中的经验。本文主要侧重于从软件架构的观点来阐明我们需要面对的挑战,希望开源社区中的架构师和程序员会觉得本文很有帮助。