概述
第三章中我们使用实例介绍了高级请求-应答模式,本章我们会讲述请求-应答模式的可靠性问题,并使用ZMQ提供的套接字类型组建起可靠的请求-应答消息系统。
本章将介绍的内容有:
-
客户端请求-应答
-
最近最少使用队列
-
心跳机制
-
面向服务的队列
-
基于磁盘(脱机)队列
-
主从备份服务
-
无中间件的请求-应答
什么是可靠性?
要给可靠性下定义,我们可以先界定它的相反面——故障。如果我们可以处理某些类型的故障,那么我们的模型对于这些故障就是可靠的。下面我们就来列举分布式ZMQ应用程序中可能发生的问题,从可能性高的故障开始:
-
应用程序代码是最大的故障来源。程序会崩溃或中止,停止对数据来源的响应,或是响应得太慢,耗尽内存等。
-
系统代码,如使用ZMQ编写的中间件,也会意外中止。系统代码应该要比应用程序代码更为可靠,但毕竟也有可能崩溃。特别是当系统代码与速度过慢的客户端交互时,很容易耗尽内存。
-
消息队列溢出,典型的情况是系统代码中没有对蛮客户端做积极的处理,任由消息队列溢出。
-
网络临时中断,造成消息丢失。这类错误ZMQ应用程序是无法及时发现的,因为ZMQ会自动进行重连。
-
硬件系统崩溃,导致所有进程中止。
-
网络会出现特殊情形的中断,如交换机的某个端口发生故障,导致部分网络无法访问。
-
数据中心可能遭受雷击、地震、火灾、电压过载、冷却系统失效等。
想要让软件系统规避上述所有的风险,需要大量的人力物力,故不在本指南的讨论范围之内。
由于前五个故障类型涵盖了99.9%的情形(这一数据源自我近期进行的一项研究),所以我们会深入探讨。如果你的公司大到足以考虑最后两种情形,那请及时联系我,因为我正愁没钱将我家后院的大坑建成游泳池。
可靠性设计
简单地来说,可靠性就是当程序发生故障时也能顺利地运行下去,这要比搭建一个消息系统来得困难得多。我们会根据ZMQ提供的每一种核心消息模式,来看看如何保障代码的持续运行。
-
请求-应答模式:当服务端在处理请求是中断,客户端能够得知这一信息,并停止接收消息,转而选择等待重试、请求另一服务端等操作。这里我们暂不讨论客户端发生问题的情形。
-
发布-订阅模式:如果客户端收到一些消息后意外中止,服务端是不知道这一情况的。发布-订阅模式中的订阅者不会返回任何消息给发布者。但是,订阅者可以通过其他方式联系服务端,如请求-应答模式,要求服务端重发消息。这里我们暂不讨论服务端发生问题的情形。此外,订阅者可以通过某些方式检查自身是否运行得过慢,并采取相应措施(向操作者发出警告、中止等)。
-
管道模式:如果worker意外终止,任务分发器将无从得知。管道模式和发布-订阅模式类似,只朝一个方向发送消息。但是,下游的结果收集器可以检测哪项任务没有完成,并告诉任务分发器重新分配该任务。如果任务分发器或结果收集器意外中止了,那客户端发出的请求只能另作处理。所以说,系统代码真的要减少出错的几率,因为这很难处理。
本章主要讲解请求-应答模式中的可靠性设计,其他模式将在后续章节中讲解。
最基本的请求应答模式是REQ客户端发送一个同步的请求至REP服务端,这种模式的可靠性很低。如果服务端在处理请求时中止,那客户端会永远处于等待状态。
相比TCP协议,ZMQ提供了自动重连机制、消息分发的负载均衡等。但是,在真实环境中这也是不够的。唯一可以完全信任基本请求-应答模式的应用场景是同一进程的两个线程之间进行通信,没有网络问题或服务器失效的情况。
但是,只要稍加修饰,这种基本的请求-应答模式就能很好地在现实环境中工作了。我喜欢将其称为“海盗”模式。
粗略地讲,客户端连接服务端有三种方式,每种方式都需要不同的可靠性设计:
-
多个客户端直接和单个服务端进行通信。使用场景:只有一个单点服务器,所有客户端都需要和它通信。需处理的故障:服务器崩溃和重启;网络连接中断。
-
多个客户端和单个队列装置通信,该装置将请求分发给多个服务端。使用场景:任务分发。需处理的故障:worker崩溃和重启,死循环,过载;队列装置崩溃和重启;网络中断。
-
多个客户端直接和多个服务端通信,无中间件。使用场景:类似域名解析的分布式服务。需处理的故障:服务端崩溃和重启,死循环,过载;网络连接中断。
以上每种设计都必须有所取舍,很多时候会混合使用。下面我们详细说明。
客户端的可靠性设计(懒惰海盗模式)
我们可以通过在客户端进行简单的设置,来实现可靠的请求-应答模式。我暂且称之为“懒惰的海盗”(Lazy Pirate)模式。
在接收应答时,我们不进行同步等待,而是做以下操作:
-
对REQ套接字进行轮询,当消息抵达时才进行接收;
-
请求超时后重发消息,循环多次;
-
若仍无消息,则结束当前事务。
使用REQ套接字时必须严格遵守发送-接收过程,因为它内部采用了一个有限状态机来限定状态,这一特性会让我们应用“海盗”模式时遇上一些麻烦。最简单的做法是将REQ套接字关闭重启,从而打破这一限定。
lpclient: Lazy Pirate client in C
C++ | C# | Clojure | Delphi | Go | Haskell | Haxe | Java | Lua | Perl | PHP | Python | Ruby | Tcl | Ada | Basic | CL | Erlang | F# | Felix | Node.js | Objective-C | ooc | Q | Racket | Scala
Run this together with the matching server:
lpserver: Lazy Pirate server in C
C++ | C# | Clojure | Delphi | Go | Haskell | Haxe | Java | Lua | Perl | PHP | Python | Ruby | Scala | Tcl | Ada | Basic | CL | Erlang | F# | Felix | Node.js | Objective-C | ooc | Q | Racket
运行这个测试用例时,可以打开两个控制台,服务端会随机发生故障,你可以看看客户端的反应。服务端的典型输出如下:
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套接字,但这并不是一个好主意。首先,DEALER并不会像REQ那样处理信封(如果你不知道信封是什么,那更不能用DEALER了)。其次,你可能会获得你并不想得到的结果。
客户端使用REQ套接字进行请求,并在发生问题时打开一个新的套接字来,绕过REQ强制的发送/接收过程。可能你会想用DEALER套接字,但这并不是一个好主意。首先,DEALER并不会像REQ那样处理信封(如果你不知道信封是什么,那更不能用DEALER了)。其次,你可能会获得你并不想得到的结果。
这一方案的优劣是:
-
优点:简单明了,容易实施;
-
优点:可以方便地应用到现有的客户端和服务端程序中;
-
优点:ZMQ有自动重连机制;
-
缺点:单点服务发生故障时不能定位到新的可用服务。
基本的可靠队列(简单海盗模式)
在第二种模式中,我们使用一个队列装置来扩展上述的“懒惰的海盗”模式,使客户端能够透明地和多个服务端通信。这里的服务端可以定义为worker。我们可以从最基础的模型开始,分阶段实施这个方案。
在所有的海盗模式中,worker是无状态的,或者说存在着一个我们所不知道的公共状态,如共享数据库。队列装置的存在意味着worker可以在client毫不知情的情况下随意进出。一个worker死亡后,会有另一个worker接替它的工作。这种拓扑结果非常简洁,但唯一的缺点是队列装置本身会难以维护,可能造成单点故障。
在第三章中,队列装置的基本算法是最近最少使用算法。那么,如果worker死亡或阻塞,我们需要做些什么?答案是很少很少。我们已经在client中加入了重试的机制,所以,使用基本的LRU队列就可以运作得很好了。这种做法也符合ZMQ的逻辑,所以我们可以通过在点对点交互中插入一个简单的队列装置来扩展它:
我们可以直接使用“懒惰的海盗”模式中的client,以下是队列装置的代码:
C++ | C# | Clojure | Delphi | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | CL | Erlang | F# | Felix | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
以下是worker的代码,用到了“懒惰的海盗”服务,并将其调整为LRU模式(使用REQ套接字传递“已就绪”信号):
spworker: Simple Pirate worker in C
C++ | C# | Clojure | Delphi | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | CL | Erlang | F# | Felix | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
运行上述事例,启动多个worker,一个client,以及一个队列装置,顺序随意。你可以看到worker最终都会崩溃或死亡,client则多次重试并最终放弃。装置从来不会停止,你可以任意重启worker和client,这个模型可以和任意个worker、client交互。
健壮的可靠队列(偏执海盗模式)
“简单海盗队列”模式工作得非常好,主要是因为它只是两个现有模式的结合体。不过,它也有一些缺点:
-
模式“简单海盗队列”无法处理队列的崩溃或重启。client会进行重试,但worker不会重启。虽然ZMQ会自动重连worker的套接字,但对于新启动的队列装置来说,由于worker并没有发送“已就绪”的消息,所以它相当于是不存在的。为了解决这一问题,我们需要从队列发送心跳给worker,这样worker就能知道队列是否已经死亡。
-
队列没有检测worker是否已经死亡,所以当worker在处于空闲状态时死亡,队列装置只有在发送了某个请求之后才会将该worker从队列中移除。这时,client什么都不能做,只能等待。这不是一个致命的问题,但是依然是不够好的。所以,我们需要从worker发送心跳给队列装置,从而让队列得知worker什么时候消亡。
我们使用一个名为“偏执的海盗模式”来解决上述两个问题。
之前我们使用REQ套接字作为worker的套接字类型,但在偏执海盗模式中我们会改用DEALER套接字,从而使我们能够任意地发送和接受消息,而不是像REQ套接字那样必须完成发送-接受循环。而DEALER的缺点是我们必须自己管理消息信封。如果你不知道信封是什么,那请阅读第三章。
我们仍会使用懒惰海盗模式的client,以下是偏执海盗的队列装置代码:
ppqueue: Paranoid Pirate queue in C
C++ | C# | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | Clojure | CL | Delphi | Erlang | F# | Felix | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
该队列装置使用心跳机制扩展了LRU模式,看起来很简单,但要想出这个主意还挺难的。下文会更多地介绍心跳机制。
以下是偏执海盗的worker代码:
ppworker: Paranoid Pirate worker in C
C++ | C# | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | Clojure | CL | Delphi | Erlang | F# | Felix | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
几点说明:
-
代码中包含了几处失败模拟,和先前一样。这会让代码极难维护,所以当投入使用时,应当移除这些模拟代码。
-
偏执海盗模式中队列的心跳有时会不正常,下文会讲述这一点。
-
worker使用了一种类似于懒惰海盗client的重试机制,但有两点不同:1、回退算法设置;2、永不言弃。
尝试运行以下代码,跑通流程:
ppqueue & | for i in 1 2 3 4; do | ppworker & | sleep 1 | done |
你会看到worker逐个崩溃,client在多次尝试后放弃。你可以停止并重启队列装置,client和worker会相继重连,并正确地发送、处理和接收请求,顺序不会混乱。所以说,整个通信过程只有两种情形:交互成功,或client最终放弃。
心跳
心跳解决了知道同伴是生是死的问题。这不是ZeroMQ特有的问题。TCP有一个很长的超时(大约30分钟),这意味着不可能知道一个同伴是死了、断开连接,还是周末带着一箱伏特加、一个红头发的人和一个大的消费账户去布拉格。
让人心跳是不容易的。在写偏执海盗的例子时,让心跳正常工作大约需要5个小时。请求-回复链的其余部分可能需要10分钟。特别容易造成“错误的失败”,即当同龄人因为心跳发送不正确而决定断开连接时。
我们来看看人们使用ZeroMQ进行心跳的三个主要答案。
耸耸肩(不足之处)
最常见的方法是不做任何心跳和希望最好。大多数ZeroMQ应用程序都会这样做。ZeroMQ通过在许多情况下隐藏对等点来鼓励这一点。这种方法会导致什么问题?
-
当我们在跟踪对等端的应用程序中使用路由器套接字时,当对等端断开连接并重新连接时,应用程序将泄漏内存(应用程序为每个对等端保留的资源),并变得越来越慢。
-
当我们使用子或基于经销商的数据接收者时,我们无法区分好的沉默(没有数据)和坏的沉默(另一端死了)之间的区别。当一个接收者知道另一方死亡时,它可以切换到备份路由。
-
如果我们使用一个长时间保持沉默的TCP连接,那么在某些网络中,它就会死掉。发送一些东西(从技术上讲,是“保持活动”而不是心跳),将使网络保持活动状态。
单向心跳
-
第二种选择是每隔一秒左右从每个节点向其对等节点发送一条心跳消息。当一个节点在某个超时(通常几秒钟)内没有从另一个节点听到任何消息时,它会将该对等端视为死节点。听起来不错,对吧?可悲的是,没有。这在某些情况下是可行的,但在其他情况下却有令人讨厌的边缘情况。
-
对于Pub Sub,这确实有效,而且它是您唯一可以使用的模型。子套接字不能与pub套接字通信,但是pub套接字可以愉快地向其订户发送“我还活着”消息。
-
作为一种优化,您只能在没有实际数据发送时发送心跳。此外,如果网络活动是一个问题(例如,在活动耗尽电池的移动网络上),您可以以越来越慢的速度发送心跳。只要接受者能够检测到故障(活动突然停止),就可以了。
以下是这种设计的典型问题:
-
当我们发送大量数据时,这可能是不准确的,因为心跳会延迟到该数据之后。如果心跳延迟,您可能会因网络拥塞而出现错误的超时和断开连接。因此,无论发送方是否优化心跳,始终将任何传入的数据视为心跳。
-
虽然pub子模式将为消失的收件人删除消息,但push和dealer套接字将对其进行排队。所以,如果你把心跳发送给一个死去的同伴,而它又回来了,它将得到你发送的所有心跳,可以是几千次。哇,哇!
-
这种设计假定整个网络中的心跳超时是相同的。但那不太准确。一些同龄人会想要非常积极的心跳来快速检测故障。还有一些人会想要非常放松的心跳,以便让沉睡的网络说谎,节省电力。
乒乓球心跳
第三个选项是使用乒乓球对话框。一个对等机向另一个对等机发送ping命令,后者用pong命令进行响应。两个命令都没有任何有效负载。Ping和Pong不相关。因为在某些网络中,“客户机”和“服务器”的角色是任意的,所以我们通常指定任何一个对等机实际上都可以发送一个ping并期望响应一个pong。但是,由于超时取决于动态客户机最熟悉的网络拓扑,因此通常是客户机ping服务器。
这适用于所有基于路由器的代理。我们在第二个模型中使用的相同优化使这一点更加有效:将任何传入的数据视为pong,并且仅在不以其他方式发送数据时发送ping。
偏执海盗的心跳
在工作人员中,这就是我们如何处理队列中的心跳:对于偏执的海盗,我们选择了第二种方法。这可能不是最简单的选择:如果今天设计这个,我可能会尝试使用乒乓球的方法。但是原理是相似的。心跳消息在两个方向上异步流动,任何一个对等方都可以决定另一个是“死的”,并停止与它说话。
-
我们计算一个活跃度,这是多少心跳,我们仍然可以错过,然后决定队列是死的。它从三点开始,每次我们错过一个心跳,它就会递减。
-
我们在zmq_轮询循环中,每次等待一秒钟,这就是我们的心跳间隔。
-
如果在这段时间内有来自队列的消息,我们会将活动性重置为3。
-
如果在这段时间里没有消息,我们就把我们的活力倒计时。
-
如果活跃度达到零,我们认为队列已经死了。
-
如果队列死了,我们将销毁套接字,创建一个新的套接字,然后重新连接。
-
为了避免打开和关闭过多的套接字,我们在重新连接之前等待一定的时间间隔,并且每次将时间间隔加倍,直到达到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或reactor作为应用程序主任务的核心。
-
从构建对等端之间的心跳开始,通过模拟失败对其进行测试,然后构建其余的消息流。事后再加上心跳要复杂得多。
-
使用简单的跟踪,即打印到控制台,以使其正常工作。为了帮助您跟踪对等端之间的消息流,请使用ZMSG提供的转储方法,并对消息进行递增编号,以便查看是否存在间隙。
-
在实际的应用程序中,心跳必须是可配置的,并且通常与对等方协商。一些同龄人会想要剧烈的心跳,低至10毫秒。其他的同龄人则会在很远的地方,并希望心跳高达30秒。
-
如果不同的对等端有不同的心跳间隔,那么轮询超时应该是其中最短的时间。不要使用无限超时。
-
在消息使用的同一个套接字上进行心跳,这样心跳也可以充当“保持活动”来阻止网络连接过时(某些防火墙可能对静默连接不友好)。
约定和协议
也许你已经注意到,由于心跳机制,偏执海盗模式和简单海盗模式是不兼容的。
其实,这里我们需要写一个协议。也许在试验阶段是不需要协议的,但这在真实的应用程序中是有必要。如果我们想用其他语言来写worker怎么办?我们是否需要通过源代码来查看通信过程?如果我们想改变协议怎么办?规范可能很简单,但并不显然。越是成功的协议,就会越为复杂。
一个缺乏约定的应用程序一定是不可复用的,所以让我们来为这个协议写一个规范,怎么做呢?
-
位于[rfc.zeromq.org](http://rfc.zeromq.org/)的wiki页上,我们特地设置了一个用于存放ZMQ协议的页面。
-
要创建一个新的协议,你需要注册并按照指导进行。过程很直接,但并不一定所有人都能撰写技术性文档。
我大约花了15分钟的时间草拟[海盗模式规范(PPP)](http://rfc.zeromq.org/spec:6),麻雀虽小,但五脏俱全。
要用PPP协议进行真实环境下的编程,你还需要:
-
在READY命令中加入版本号,这样就能再日后安全地新增PPP版本号。
-
目前,READY和HEARTBEAT信号并没有指定其来源于请求还是应答。要区分他们,需要新建一个消息结构,其中包含“消息类型”这一信息。
面向服务的可靠队列(管家模式)
世上的事物往往瞬息万变,正当我们期待有更好的协议来解决上一节的问题时,已经有人制定好了:
http://rfc.zeromq.org/spec:7
这份协议只有一页,它将PPP协议变得更为坚固。我们在设计复杂架构时应该这样做:首先写下约定,再用软件去实现它。
管家模式协议(MDP)在扩展PPP协议时引入了一个有趣的特性:client发送的每一个请求都有一个“服务名称”,而worker在像队列装置注册时需要告知自己的服务类型。MDP的优势在于它来源于现实编程,协议简单,且容易提升。
引入“服务名称”的机制,是对偏执海盗队列的一个简单补充,而结果是让其成为一个面向服务的代理。
所以,我们第一个协议(即管家模式协议)定义了分布式架构中节点是如何互相交互的,第二个协议则要定义应用程序应该如何通过框架来使用这一协议。
管家模式有两个端点,客户端和服务端。因为我们要为client和worker都撰写框架,所以就需要提供两套API。以下是用简单的面向对象方法设计的client端API雏形,使用的是C语言的[ZFL library](http://zfl.zeromq.org/page:read-the-manual )。
// Majordomo Protocol client example // Uses the mdcli API to hide all MDP aspects // Lets us build this source without creating a library #include "mdcliapi.c" int main (int argc, char *argv []) { int verbose = (argc > 1 && streq (argv [1], "-v")); mdcli_t *session = mdcli_new ("tcp://localhost:5555", verbose); int count; for (count = 0; count < 100000; count++) { zmsg_t *request = zmsg_new (); zmsg_pushstr (request, "Hello world"); zmsg_t *reply = mdcli_send (session, "echo", &request); if (reply) zmsg_destroy (&reply); else break; // Interrupt or failure } printf ("%d requests/replies processedn", count); mdcli_destroy (&session); return 0; } |
就这么简单。我们创建了一个会话来和代理通信,发送并接收一个请求,最后关闭连接。以下是worker端API的雏形。
// Majordomo Protocol worker example // Uses the mdwrk API to hide all MDP aspects // Lets us build this source without creating a library #include "mdwrkapi.c" int main (int argc, char *argv []) { int verbose = (argc > 1 && streq (argv [1], "-v")); mdwrk_t *session = mdwrk_new ( "tcp://localhost:5555", "echo", verbose); zmsg_t *reply = NULL; while (true) { zmsg_t *request = mdwrk_recv (session, &reply); if (request == NULL) break; // Worker was interrupted reply = request; // Echo is complex… :-) } mdwrk_destroy (&session); return 0; } |
上面两段代码看起来差不多,但是worker端API略有不同。worker第一次执行recv()后会传递一个空的应答,之后才传递当前的应答,并获得新的请求。
两段的API都很容易开发,只需在偏执海盗模式代码的基础上修改即可。以下是client API:
mdcliapi: Majordomo client API in C
C# | Go | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Haskell | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
Let's see how the client API looks in action, with an example test program that does 100K request-reply cycles:
mdclient: Majordomo client application in C
C++ | C# | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | Clojure | CL | Delphi | Erlang | F# | Felix | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
And here is the worker API:
mdwrkapi: Majordomo worker API in C
C# | Go | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Haskell | Node.js | Objective-C | ooc | Perl | Q | Racket | Scala
Let's see how the worker API looks in action, with an example test program that implements an echo service:
mdworker: Majordomo worker application in C
C++ | C# | Go | Haskell | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | Clojure | CL | Delphi | Erlang | F# | Felix | Node.js | Objective-C | ooc | Perl | Q | Racket | Scala
几点说明:
-
API是单线程的,所以说worker不会再后台发送心跳,而这也是我们所期望的:如果worker应用程序停止了,心跳就会跟着中止,代理便会停止向该worker发送新的请求。
-
wroker API没有做回退算法的设置,因为这里不值得使用这一复杂的机制。
-
API没有提供任何报错机制,如果出现问题,它会直接报断言(或异常,依语言而定)。这一做法对实验性的编程是有用的,这样可以立刻看到执行结果。但在真实编程环境中,API应该足够健壮,合适地处理非法消息。
也许你会问,worker API为什么要关闭它的套接字并新开一个呢?特别是ZMQ是有重连机制的,能够在节点归来后进行重连。我们可以回顾一下简单海盗模式中的worker,以及偏执海盗模式中的worker来加以理解。ZMQ确实会进行自动重连,但如果代理死亡并重连,worker并不会重新进行注册。这个问题有两种解决方案:一是我们这里用到的较为简便的方案,即当worker判断代理已经死亡时,关闭它的套接字并重头来过;另一个方案是当代理收到未知worker的心跳时要求该worker对其提供的服务类型进行注册,这样一来就需要在协议中说明这一规则。
下面让我们设计管家模式的代理,它的核心代码是一组队列,每种服务对应一个队列。我们会在worker出现时创建相应的队列(worker消失时应该销毁对应的队列,不过我们这里暂时不考虑)。额外的,我们会为每种服务维护一个worker的队列。
为了让C语言代码更为易读易写,我使用了[ZFL项目](http://zfl.zeromq.org)提供的哈希和链表容器,并命名为[zhash](https://github.com/imatix/zguide/blob/master/examples/C/zhash.h zhash)和[zlist](https://github.com/imatix/zguide/blob/master/examples/C/zlist.h)。如果使用现代语言编写,那自然可以使用其内置的容器。
mdbroker: Majordomo broker in C
C++ | C# | Go | Haskell | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | Clojure | CL | Delphi | Erlang | F# | Felix | Node.js | Objective-C | ooc | Perl | Q | Racket | Scala
这个例子应该是我们见过最复杂的一个示例了,大约有500行代码。编写这段代码并让其变的健壮,大约花费了两天的时间。但是,这也仅仅是一个完整的面向服务代理的一部分。
几点说明:
-
管家模式协议要求我们在一个套接字中同时处理client和worker,这一点对部署和管理代理很有益处:它只会在一个ZMQ端点上收发请求,而不是两个
-
代理很好地实现了MDP/0.1协议中规范的内容,包括当代理发送非法命令和心跳时断开的机制。
-
可以将这段代码扩充为多线程,每个线程管理一个套接字、一组client和worker。这种做法在大型架构的拆分中显得很有趣。C语言代码已经是这样的格式了,因此很容易实现。
-
还可以将这段代码扩充为主备模式、双在线模式,进一步提高可靠性。因为从本质上来说,代理是无状态的,只是保存了服务的存在与否,因此client和worker可以自行选择除此之外的代理来进行通信。
-
示例代码中心跳的间隔为5秒,主要是为了减少调试时的输出。现实中的值应该设得低一些,但是,重试的过程应该设置得稍长一些,让服务有足够的时间启动,如10秒钟。
后来我们改进并扩展了协议和Majordomo实现,现在它位于自己的Github项目中。如果您想要一个适当可用的majordomo堆栈,请使用github项目。
异步管家模式
上文那种实现管家模式的方法比较简单,client还是简单海盗模式中的,仅仅是用API重写了一下。我在测试机上运行了程序,处理10万条请求大约需要14秒的时间,这和代码也有一些关系,因为复制消息帧的时间浪费了CPU处理时间。但真正的问题在于,我们总是逐个循环进行处理(round-trip),即发送-接收-发送-接收……ZMQ内部禁用了TCP发包优化算法([Nagle's algorithm](http://en.wikipedia.org/wiki/Nagles_algorithm)),但逐个处理循环还是比较浪费。
理论归理论,还是需要由实践来检验。我们用一个简单的测试程序来看看逐个处理循环是否真的耗时。这个测试程序会发送一组消息,第一次它发一条收一条,第二次则一起发送再一起接收。两次结果应该是一样的,但速度截然不同。
tripping: Round-trip demonstrator in C
C++ | C# | Go | Haskell | Haxe | Java | Lua | PHP | Python | Tcl | Ada | Basic | Clojure | CL | Delphi | Erlang | F# | Felix | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
在我的开发环境中运行结果如下:
Setting up test... | Synchronous round-trip test... | 9057 calls/second | Asynchronous round-trip test... |
需要注意的是client在运行开始会暂停一段时间,这是因为在向ROUTER套接字发送消息时,若指定标识的套接字没有连接,那么ROUTER会直接丢弃该消息。这个示例中我们没有使用LRU算法,所以当worker连接速度稍慢时就有可能丢失数据,影响测试结果。
我们可以看到,逐个处理循环比异步处理要慢将近20倍,让我们把它应用到管家模式中去。
首先,让我们修改client的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); |
然后花很短的时间就能将同步的client API改造成异步的API:
mdcliapi2: Majordomo asynchronous client API in C
C# | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Node.js | Objective-C | ooc | Perl | Q | Racket | Scala
区别在于:
-
我们使用一个代理套接字而不是req,因此我们在每个请求和响应之前用一个空的分隔符帧来模拟req。
-
我们不重试请求;如果应用程序需要重试,它可以自己重试。
-
我们将同步发送方法分为单独的发送和接收方法。
-
send方法是异步的,发送后立即返回。因此,调用者可以在得到响应之前发送许多消息。
-
recv方法等待(超时)一个响应并将其返回给调用方。
下面是对应的测试代码:
mdclient2: Majordomo client application in C
C++ | C# | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Node.js | Objective-C | ooc | Perl | Q | Racket | Scala
代理和worker的代码没有变,因为我们并没有改变MDP协议。经过对client的改造,我们可以明显看到速度的提升。如以下是同步状况下处理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个worker,看看效果如何:
$ time mdclient2 100000 replies received
real 0m3.863s user 0m0.730s sys 0m0.470s |
由于worker获得消息需要通过LRU队列机制,所以并不能做到完全的异步。但是,worker越多其效果也会越好。在我的测试机上,当worker的数量达到8个时,速度就不再提升了——四核处理器只能做这么多。但是,我们仍然获得了近四倍的速度提升,而改造过程只有几分钟而已。此外,代理其实还没有进行优化,它仍会复制消息,而没有实现零拷贝。不过,我们已经做到每秒处理2.5万次请求-应答,已经很不错了。
当然,异步的管家模式也并不完美,有一个显著的缺点:它无法从代理的崩溃中恢复。可以看到mdcliapi2的代码中并没有恢复连接的代码,重新连接需要有以下几点作为前提:
-
每个请求都做了编号,每次应答也含有相应的编号,这就需要修改协议,明确定义;
-
client的API需要保留并跟踪所有已发送、但仍未收到应答的请求;
-
如果代理发生崩溃,client会重发所有消息。
可以看到,高可靠性往往和复杂度成正比,值得在管家模式中应用这一机制吗?这就要看应用场景了。如果是一个名称查询服务,每次会话会调用一次,那不需要应用这一机制;如果是一个位于前端的网页服务,有数千个客户端相连,那可能就需要了。
服务查询
现在,我们已经有了一个面向服务的代理了,但是我们无法得知代理是否提供了某项特定服务。如果请求失败,那当然就表示该项服务目前不可用,但具体原因是什么呢?所以,如果能够询问代理“echo服务正在运行吗?”,那将会很有用处。最明显的方法是在MDP/Client协议中添加一种命令,客户端可以询问代理某项服务是否可用。但是,MDP/Client最大的优点在于简单,如果添加了服务查询的功能就太过复杂了。
另一种方案是学电子邮件的处理方式,将失败的请求重新返回。但是这同样会增加复杂度,因为我们需要鉴别收到的消息是一个应答还是被退回的请求。
让我们用之前的方式,在MDP的基础上建立新的机制,而不是改变它。服务定位本身也是一项服务,我们还可以提供类似于“禁用某服务”、“提供服务数据”等其他服务。我们需要的是一个能够扩展协议但又不会影响协议本身的机制。
这样就诞生了一个小巧的RFC - MMI(管家接口)的应用层,建立在MDP协议之上:http://rfc.zeromq.org/spec:8 。我们在代理中其实已经加以实现了,不知你是否已经注意到。下面的代码演示了如何使用这项服务查询功能:
-
当一个客户机请求一个以mmi开始的服务时,我们在内部处理它,而不是将它路由给一个工作人员。
-
我们在这个代理中只处理一个服务,即mmi.service,即服务发现服务。
-
请求的有效负载是外部服务的名称(一个真正的服务,由工作人员提供)。
-
代理返回“200”(确定)或“404”(未找到),这取决于是否有为该服务注册的工作人员。
mmiecho: Service discovery over Majordomo in C
C# | Go | Haxe | Java | Lua | PHP | Python | Ruby | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Haskell | Node.js | Objective-C | ooc | Perl | Q | Racket | Scala
代理在运行时会检查请求的服务名称,自行处理那些mmi.开头的服务,而不转发给worker。你可以在不开启worker的情况下运行以上代码,可以看到程序是报告200还是404。MMI在示例程序代理中的实现是很简单的,比如,当某个worker消亡时,该服务仍然标记为可用。实践中,代理应该在一定间隔后清除那些没有worker的服务。
幂等服务
幂等是指能够安全地重复执行某项操作。如,看钟是幂等的,但借钱给别人老婆就不是了。有些客户端至服务端的通信是幂等的,但有些则不是。幂等的通信示例有:
-
无状态的任务分配,即管道模式中服务端是无状态的worker,它的处理结果是根据客户端的请求状态生成的,因此可以重复处理相同的请求;
-
命名服务中将逻辑地址转化成实际绑定或连接的端点,可以重复查询多次,因此也是幂等的。
非幂等的通信示例有:
-
日志服务,我们不会希望相同的日志内容被记录多次;
-
任何会对下游节点有影响的服务,如该服务会向下游节点发送信息,若收到相同的请求,那下游节点收到的信息就是重复的;
-
当服务修改了某些共享的数据,且没有进行幂等方面的设置。如某项服务对银行账户进行了借操作(debit),这一定是非幂等的。
如果应用程序提供的服务是非幂等的,那就需要考虑它究竟是在哪个阶段崩溃的。如果程序在空闲或处理请求的过程中崩溃,那不会有什么问题。我们可以使用数据库中的事务机制来保证借贷操作是同时发生的。如果应用程序在发送请求的时候崩溃了,那就会有问题,因为对于该程序来说,它已经完成了工作。
如果在返回应答的过程中网络阻塞了,客户端会认为请求发送失败,并进行重发,这样服务端会再一次执行相同的请求。这不是我们想要的结果。
常用的解决方法是在服务端检测并拒绝重复的请求,这就需要:
-
客户端为每个请求加注唯一的标识,包括客户端标识和消息标识;
-
服务端在发送应答时使用客户端标识和消息标识作为键,保存应答内容;
-
当服务端发现收到的请求已在应答哈希表中存在,它会跳过该次请求,直接返回应答内容。
脱机可靠性(巨人模式)
当你意识到管家模式是一种非常可靠的消息代理时,你可能会想要使用磁盘做一下消息中转,从而进一步提升可靠性。这种方式虽然在很多企业级消息系统中应用,但我还是有些反对的,原因有:
-
我们可以看到,懒惰海盗模式的client可以工作得非常好,能够在多种架构中运行。唯一的问题是它会假设worker是无状态的,且提供的服务是幂等的。但这个问题我们可以通过其他方式解决,而不是添加磁盘。
-
添加磁盘会带来新的问题,需要额外的管理和维护费用。海盗模式的最大优点就是简单明了,不会崩溃。如果你还是担心硬件会出问题,可以改用点对点的通信模式,这会在本章最后一节讲到。
虽然有以上原因,但还是有一个合理的场景可以用到磁盘中转的——异步脱机网络。海盗模式有一个问题,那就是client发送请求后会一直等待应答。如果client和worker并不是长连接(可以拿电子邮箱做个类比),我们就无法在client和worker之间建立一个无状态的网络,因此需要将这种状态保存起来。
于是我们就有了巨人模式,该模式下会将消息写到磁盘中,确保不会丢失。当我们进行服务查询时,会转向巨人这一层进行。巨人是建立在管家之上的,而不是改写了MDP协议。这样做的好处是我们可以在一个特定的worker中实现这种可靠性,而不用去增加代理的逻辑。
-
实现更为简单;
-
代理用一种语言编写,worker使用另一种语言编写;
-
可以自由升级这种模式。
唯一的缺点是,代理和磁盘之间会有一层额外的联系,不过这也是值得的。
我们有很多方法来实现一种持久化的请求-应答架构,而目标当然是越简单越好。我能想到的最简单的方式是提供一种成为“巨人”的代理服务,它不会影响现有worker的工作,若client想要立即得到应答,它可以和代理进行通信;如果它不是那么着急,那就可以和巨人通信:“嗨,巨人,麻烦帮我处理下这个请求,我去买些菜。”
这样一来,居然就既是worker又是client。client和巨人之间的对话一般是:
Client: 请帮我处理这个请求。巨人:好的。 | Client: 有要给我的应答吗?巨人:有的。(或者没有) |
巨人和代理之间的对话一般是:
巨人:嗨,代理程序,你这里有个叫echo的服务吗?代理:恩,好像有。 | 巨人:嗨,echo服务,请帮我处理一下这个请求。Echo: 好了,这是应答。 |
你可以想象一些发生故障的情形,看看上述模式是否能解决?worker在处理请求的时候崩溃,巨人会不断地重新发送请求;应答在传输过程中丢失了,巨人也会重试;如果请求已经处理,但client没有得到应答,那它会再次询问巨人;如果巨人在处理请求或进行应答的时候崩溃了,客户端会进行重试;只要请求是被保存在磁盘上的,那它就不会丢失。
这个机制中,握手的过程是比较漫长的,但client可以使用异步的管家模式,一次发送多个请求,并一起等待应答。
我们需要一种方法,让client会去请求应答内容。不同的client会访问到相同的服务,且client是来去自由的,有着不同的标识。一个简单、合理、安全的解决方案是:
-
当巨人收到请求时,它会为每个请求生成唯一的编号(UUID),并将这个编号返回给client;
-
client在请求应答内容时需要提供这个编号。
这样一来client就需要负责将UUID安全地保存起来,不过这就省去了验证的过程。有其他方案吗?我们可以使用持久化的套接字,即显式声明客户端的套接字标识。然而,这会造成管理上的麻烦,而且万一两个client的套接字标识相同,那会引来无穷的麻烦。
在我们开始制定一个新的协议之前,我们先思考一下client如何和巨人通信。一种方案是提供一种服务,配合三个不同的命令;另一种方案则更为简单,提供三种独立的服务:
-
titanic.request - 保存一个请求,并返回UUID
-
titanic.reply - 根据UUID获取应答内容
-
titanic.close - 确认某个请求已被正确地处理
我们需要创建一个多线程的worker,正如我们之前用ZMQ进行多线程编程一样,很简单。但是,在我们开始编写代码之前,先讲巨人模式的一些定义写下来:http://rfc.zeromq.org/spec:9 。我们称之为“巨人服务协议”,或TSP。
使用TSP协议自然会让client多出额外的工作,下面是一个简单但足够健壮的client:
ticlient: Titanic client example in C
C# | Haxe | Java | PHP | Python | Ruby | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Lua | Node.js | Objective-C | ooc | Perl | Q | Racket | Scala
当然,上面的代码可以整合到一个框架中,程序员不需要了解其中的细节。如果我有时间的话,我会尝试写一个这样的API的,让应用程序又变回短短的几行。这种理念和MDP中的一致:不要做重复的事。
下面是巨人的实现。这个服务端会使用三个线程来处理三种服务。它使用最原始的持久化方法来保存请求:为每个请求创建一个磁盘文件。虽然简单,但也挺恐怖的。比较复杂的部分是,巨人会维护一个队列来保存这些请求,从而避免重复地扫描目录。
titanic: Titanic broker example in C
C# | Haxe | Java | PHP | Python | Ruby | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Lua | Node.js | Objective-C | ooc | Perl | Q | Racket | Scala
测试时,打开mdbroker和titanic,再运行ticlient,然后开启任意个mdworker,就可以看到client获得了应答。
几点说明:
-
我们使用MMI协议去向代理询问某项服务是否可用,这一点和MDP中的逻辑一致;
-
我们使用inproc(进程内)协议建立主循环和titanic.request服务间的联系,保存新的请求信息。这样可以避免主循环不断扫描磁盘目录,读取所有请求文件,并按照时间日期排序。
这个示例程序不应关注它的性能(一定会非常糟糕,虽然我没有测试过),而是应该看到它是如何提供一种可靠的通信模式的。你可以测试一下,打开代理、巨人、worker和client,使用-v参数显示跟踪信息,然后随意地开关代理、巨人、或worker(client不能关闭),可以看到所有的请求都能获得应答。
如果你想在真实环境中使用巨人模式,你肯定会问怎样才能让速度快起来。以下是我的做法:
-
使用一个磁盘文件保存所有数据。操作系统处理大文件的效率要比处理许多小文件来的高。
-
使用一种循环的机制来组织该磁盘文件的结构,这样新的请求可以被连续地写入这个文件。单个线程在全速写入磁盘时的效率是比较高的。
-
将索引保存在内存中,可以在启动程序时重建这个索引。这样做可以节省磁盘缓存,让索引安全地保存在磁盘上。你需要用到fsync的机制来保存每一条数据;或者可以等待几毫秒,如果不怕丢失上千条数据的话。
-
如果条件循序,应选择使用固态硬盘;
-
提前分配该磁盘文件的空间,或者将每次分配的空间调大一些,这样可以避免磁盘碎片的产生,并保证读写是连续的。
另外,我不建议将消息保存在数据库中,甚至不建议交给那些所谓的高速键值缓存,它们比起一个磁盘文件要来得昂贵。
如果你想让巨人模式变得更为可靠,你可以将请求复制到另一台服务器上,这样就不需要担心主程序遭到核武器袭击了。
如果你想让巨人模式变得更为快速,但可以牺牲一些可靠性,那你可以将请求和应答都保存在内存中。这样做可以让该服务作为脱机网络运行,不过若巨人服务本身崩溃了,我也无能为力。
高可靠对称节点(双子星模式)
双子星模式是一对具有主从机制的高可靠节点。任一时间,某个节点会充当主机,接收所有客户端的请求;另一个则作为一种备机存在。两个节点会互相监控对方,当主机从网络中消失时,备机会替代主机的位置。
双子星模式由Pieter Hintjens和Martin Sustrik设计,应用在iMatix的[OpenAMQ服务器](http://www.openamq.org/)中。它的设计理念是:
-
提供一种简明的高可靠性解决方案;
-
易于理解和使用;
-
能够进行可靠的故障切换。
假设我们有一组双子星模式的服务器,以下是可能发生的故障:
-
主机发生硬件故障(断电、失火等),应用程序发送后立刻使用备机进行连接;
-
主机的网络环境发生故障,可能某个路由器被雷击了,立刻使用备机;
-
主机上的服务被维护人员误杀,无法自动恢复。
恢复步骤如下:
-
维护人员排查主机故障;
-
将备机关闭,造成短时间的服务不可用;
-
待应用程序都连接到主机后,维护人员重启备机。
恢复过程是人工进行的,惨痛的经验告诉我们自动恢复是很可怕的:
-
故障的发生会造成10-30秒之间的服务暂停,如果这是一个真正的突发状况,那最好还是让主机暂停服务的好,因为立刻重启服务可能造成另一个10-30秒的暂停,不如让用户停止使用
-
当有紧急状况发生时,可以在修复的过程中记录故障发生原因,而不是让系统自动恢复,管理员因此无法用其经验抵御下一次突发状况。
-
最后,如果自动恢复确实成功了,管理员将无从得知故障的发生原因,因而无法进行分析。
双子星模式的故障恢复过程是:在修复了主机的问题后,将备机做关闭处理,稍后再重新开启:
双子星模式的关闭过程有两种:
1. 先关闭备机,等待一段时间后再关闭主机;
1. 同时关闭主机和备机,间隔时间不超过几秒。
关闭时,间隔时间要比故障切换时间短,否则会导致应用程序失去连接、重新连接、并再次失去连接,导致用户投诉。
详细要求
双子星模式可以非常简单,但能工作得很出色。事实上,这里的实现方法已经历经三个版本了,之前的版本都过于复杂,想要做太多的事情,因而被我们抛弃。我们需要的只是最基本的功能,能够提供易理解、易开发、高可靠的解决方法就可以了。
以下是该架构的详细需求:
-
需要用到双子星模式的故障是:系统遭受灾难性的打击,如硬件崩溃、火灾、意外等。对于其他常规的服务器故障,可以用更简单的方法。
-
故障恢复时间应该在60秒以内,理想情况下应该在10秒以内;
-
故障恢复(failover)应该是自动完成的,而系统还原(recover)则是由人工完成的。我们希望应用程序能够在发生故障时自动从主机切换到备机,但不希望在问题解决之前自动切换回主机,因为这很有可能让主机再次崩溃。
-
程序的逻辑应该尽量简单,易于使用,最好能封装在API中;
-
需要提供一个明确的指示,哪台主机正在提供服务,以避免“精神分裂”的症状,即两台服务器都认为自己是主机;
-
两台服务器的启动顺序不应该有限制;
-
启动或关闭主从机时不需要更改客户端的配置,但有可能会中断连接;
-
管理员需要能够同时监控两台机器;
-
两台机器之间必须有专用的高速网络连接,必须能使用特定IP进行路由。
我们做如下架假设:
-
单台备机能够提供足够的保障,不需要再进行其他备份机制;
-
主从机应该都能够提供完整的服务,承载相同的压力,不需要进行负载均衡;
-
预算中允许有这样一台长时间闲置的备机。
双子星模式不会用到:
-
多台备机,或在主从机之间进行负载均衡。该模式中的备机将一直处于空闲状态,只有主机发生问题时才会工作;
-
处理持久化的消息或事务。我们假设所连接的网络是不可靠的(或不可信的)。
-
自动搜索网络。双子星模式是手工配置的,他们知道对方的存在,应用程序则知道双子星的存在。
-
主从机之间状态的同步。所有服务端的状态必须能由应用程序进行重建。
以下是双子星模式中的几个术语:
-
主机 - 通常情况下作为master的机器;
-
备机 - 通常情况下作为slave的机器,只有当主机从网络中消失时,备机才会切换成master状态,接收所有的应用程序请求;
-
master- 双子星模式中接收应用程序请求的机器;同一时刻只有一台master;
-
slave - 当master消失时用以顶替的机器。
配置双子星模式的步骤:
-
让主机知道备机的位置;
-
让备机知道主机的位置;
-
调整故障恢复时间,两台机器的配置必须相同。
比较重要的配置是应让两台机器间隔多久检查一次对方的状态,以及多长时间后采取行动。在我们的示例中,故障恢复时间设置为2000毫秒,超过这个时间备机就会代替主机的位置。但若你将主机的服务包裹在一个shell脚本中进行重启,就需要延长这个时间,否则备机可能在主机恢复连接的过程中转换成master。
要让客户端应用程序和双子星模式配合,你需要做的是:
-
知道两台服务器的地址;
-
尝试连接主机,若失败则连接备机;
-
检测失效的连接,一般使用心跳机制;
-
尝试重连主机,然后再连接备机,其间的间隔应比服务器故障恢复时间长;
-
重建服务器端需要的所有状态数据;
-
如果要保证可靠性,应重发故障期间的消息。
这不是件容易的事,所以我们一般会将其封装成一个API,供程序员使用。
双子星模式的主要限制有:
-
服务端进程不能涉及到一个以上的双子星对称节点;
-
主机只能有一个备机;
-
当备机处于slave状态时,它不会处理任何请求;
-
备机必须能够承受所有的应用程序请求;
-
故障恢复时间不能在运行时调整;
-
客户端应用程序需要做一些重连的工作。
防止精神分裂
“精神分裂”症状指的是一个集群中的不同部分同时认为自己是master,从而停止对对方的检测。双子星模式中的算法会降低这种症状的发生几率:主备机在决定自己是否为master时会检测自身是否收到了应用程序的请求,以及对方是否已经从网络中消失。
但在某些情况下,双子星模式也会发生精神分裂。比如说,主备机被配置在两幢大楼里,每幢大楼的局域网中又分布了一些应用程序。这样,当两幢大楼的网络通信被阻断,双子星模式的主备机就会分别在两幢大楼里接受和处理请求。
为了防止精神分裂,我们必须让主备机使用专用的网络进行连接,最简单的方法当然是用一根双绞线将他们相连。
我们不能将双子星部署在两个不同的岛屿上,为各自岛屿的应用程序服务。这种情况下,我们会使用诸如联邦模式的机制进行可靠性设计。
最好但最夸张的做法是,将两台机器之间的连接和应用程序的连接完全隔离开来,甚至是使用不同的网卡,而不仅仅是不同的端口。这样做也是为了日后排查错误时更为明确。
实现双子星模式
闲话少说,下面是双子星模式的服务端代码:
bstarsrv: Binary Star server in C
Haxe | Java | Python | Ruby | Tcl | Ada | Basic | C++ | C# | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Scala
And here is the client:
bstarcli: Binary Star client in C
Haxe | Java | Python | Ruby | Tcl | Ada | Basic | C++ | C# | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Scala
运行以下命令进行测试,顺序随意:
bstarsrv -p # Start primary | bstarsrv -b # Start backup |
可以将主机进程杀掉,测试故障恢复机制;再开启主机,杀掉备机,查看还原机制。要注意是由客户端触发这两个事件的。
下图展现了服务进程的状态图。绿色状态下会接收客户端请求,粉色状态会拒绝请求。事件指的是同伴的状态,所以“同伴激活态”指的是同伴机器告知我们它处于激活态。“客户请求”表示我们从客户端获得了请求,“客户投票”则指我们从客户端获得了请求并且同伴已经超时死亡。
需要注意的是,服务进程使用PUB-SUB套接字进行状态交换,其它类型的套接字在这里不适用。比如,PUSH和DEALER套接字在没有节点相连的时候会发生阻塞;PAIR套接字不会在节点断开后进行重连;ROUTER套接字需要地址才能发送消息。
这些是Binary Star模式的主要限制:
-
服务器进程不能是多个二进制星对的一部分。
-
主服务器可以只有一个备份服务器。
-
在从属模式下,备份服务器无法执行有用的工作。
-
备份服务器必须能够处理完整的应用程序负载。
-
无法在运行时修改故障转移配置。
-
客户端应用程序必须做一些工作才能从故障转移中受益
我们可以将双子星模式打包成一个类似反应堆的类,供以后复用。在C语言中,我们使用czmq的zloop类,其他语言应该会有相应的实现。以下是C语言版的bstar接口:
双子星反应堆
以下是类的实现:
// bstar class - Binary Star reactor #include "bstar.h" // States we can be in at any point in time typedef enum { STATE_PRIMARY = 1, // Primary, waiting for peer to connect STATE_BACKUP = 2, // Backup, waiting for peer to connect STATE_ACTIVE = 3, // Active - accepting connections STATE_PASSIVE = 4 // Passive - not accepting connections } state_t; // Events, which start with the states our peer can be in typedef enum { PEER_PRIMARY = 1, // HA peer is pending primary PEER_BACKUP = 2, // HA peer is pending backup PEER_ACTIVE = 3, // HA peer is active PEER_PASSIVE = 4, // HA peer is passive CLIENT_REQUEST = 5 // Client makes request } event_t; // Structure of our class struct _bstar_t { zctx_t *ctx; // Our private context zloop_t *loop; // Reactor loop void *statepub; // State publisher void *statesub; // State subscriber state_t state; // Current state event_t event; // Current event int64_t peer_expiry; // When peer is considered 'dead' zloop_fn *voter_fn; // Voting socket handler void *voter_arg; // Arguments for voting handler zloop_fn *active_fn; // Call when become active void *active_arg; // Arguments for handler zloop_fn *passive_fn; // Call when become passive void *passive_arg; // Arguments for handler };
// The finite-state machine is the same as in the proof-of-concept server. // To understand this reactor in detail, first read the CZMQ zloop class. // We send state information every this often // If peer doesn't respond in two heartbeats, it is 'dead' #define BSTAR_HEARTBEAT 1000 // In msecs // Binary Star finite state machine (applies event to state) // Returns -1 if there was an exception, 0 if event was valid. static int s_execute_fsm (bstar_t *self) { int rc = 0; // Primary server is waiting for peer to connect // Accepts CLIENT_REQUEST events in this state if (self->state == STATE_PRIMARY) { if (self->event == PEER_BACKUP) { zclock_log ("I: connected to backup (passive), ready as active"); self->state = STATE_ACTIVE; if (self->active_fn) (self->active_fn) (self->loop, NULL, self->active_arg); } else if (self->event == PEER_ACTIVE) { zclock_log ("I: connected to backup (active), ready as passive"); self->state = STATE_PASSIVE; if (self->passive_fn) (self->passive_fn) (self->loop, NULL, self->passive_arg); } else if (self->event == CLIENT_REQUEST) { // Allow client requests to turn us into the active if we've // waited sufficiently long to believe the backup is not // currently acting as active (i.e., after a failover) assert (self->peer_expiry > 0); if (zclock_time () >= self->peer_expiry) { zclock_log ("I: request from client, ready as active"); self->state = STATE_ACTIVE; if (self->active_fn) (self->active_fn) (self->loop, NULL, self->active_arg); } else // Don't respond to clients yet - it's possible we're // performing a failback and the backup is currently active rc = -1; } } else // Backup server is waiting for peer to connect // Rejects CLIENT_REQUEST events in this state if (self->state == STATE_BACKUP) { if (self->event == PEER_ACTIVE) { zclock_log ("I: connected to primary (active), ready as passive"); self->state = STATE_PASSIVE; if (self->passive_fn) (self->passive_fn) (self->loop, NULL, self->passive_arg); } else if (self->event == CLIENT_REQUEST) rc = -1; } else // Server is active // Accepts CLIENT_REQUEST events in this state // The only way out of ACTIVE is death if (self->state == STATE_ACTIVE) { if (self->event == PEER_ACTIVE) { // Two actives would mean split-brain zclock_log ("E: fatal error - dual actives, aborting"); rc = -1; } } else // Server is passive // CLIENT_REQUEST events can trigger failover if peer looks dead if (self->state == STATE_PASSIVE) { if (self->event == PEER_PRIMARY) { // Peer is restarting - become active, peer will go passive zclock_log ("I: primary (passive) is restarting, ready as active"); self->state = STATE_ACTIVE; } else if (self->event == PEER_BACKUP) { // Peer is restarting - become active, peer will go passive zclock_log ("I: backup (passive) is restarting, ready as active"); self->state = STATE_ACTIVE; } else if (self->event == PEER_PASSIVE) { // Two passives would mean cluster would be non-responsive zclock_log ("E: fatal error - dual passives, aborting"); rc = -1; } else if (self->event == CLIENT_REQUEST) { // Peer becomes active if timeout has passed // It's the client request that triggers the failover assert (self->peer_expiry > 0); if (zclock_time () >= self->peer_expiry) { // If peer is dead, switch to the active state zclock_log ("I: failover successful, ready as active"); self->state = STATE_ACTIVE; } else // If peer is alive, reject connections rc = -1; } // Call state change handler if necessary if (self->state == STATE_ACTIVE && self->active_fn) (self->active_fn) (self->loop, NULL, self->active_arg); } return rc; } static void s_update_peer_expiry (bstar_t *self) { self->peer_expiry = zclock_time () + 2 * BSTAR_HEARTBEAT; } // Reactor event handlers… // Publish our state to peer int s_send_state (zloop_t *loop, int timer_id, void *arg) { bstar_t *self = (bstar_t *) arg; zstr_sendf (self->statepub, "%d", self->state); return 0; } // Receive state from peer, execute finite state machine int s_recv_state (zloop_t *loop, zmq_pollitem_t *poller, void *arg) { bstar_t *self = (bstar_t *) arg; char *state = zstr_recv (poller->socket); if (state) { self->event = atoi (state); s_update_peer_expiry (self); free (state); } return s_execute_fsm (self); } // Application wants to speak to us, see if it's possible int s_voter_ready (zloop_t *loop, zmq_pollitem_t *poller, void *arg) { bstar_t *self = (bstar_t *) arg; // If server can accept input now, call appl handler self->event = CLIENT_REQUEST; if (s_execute_fsm (self) == 0) (self->voter_fn) (self->loop, poller, self->voter_arg); else { // Destroy waiting message, no-one to read it zmsg_t *msg = zmsg_recv (poller->socket); zmsg_destroy (&msg); } return 0; } // This is the constructor for our bstar class. We have to tell it // whether we're primary or backup server, as well as our local and // remote endpoints to bind and connect to: bstar_t * bstar_new (int primary, char *local, char *remote) { bstar_t *self; self = (bstar_t *) zmalloc (sizeof (bstar_t)); // Initialize the Binary Star self->ctx = zctx_new (); self->loop = zloop_new (); self->state = primary? STATE_PRIMARY: STATE_BACKUP; // Create publisher for state going to peer self->statepub = zsocket_new (self->ctx, ZMQ_PUB); zsocket_bind (self->statepub, local); // Create subscriber for state coming from peer self->statesub = zsocket_new (self->ctx, ZMQ_SUB); zsocket_set_subscribe (self->statesub, ""); zsocket_connect (self->statesub, remote); // Set-up basic reactor events zloop_timer (self->loop, BSTAR_HEARTBEAT, 0, s_send_state, self); zmq_pollitem_t poller = { self->statesub, 0, ZMQ_POLLIN }; zloop_poller (self->loop, &poller, s_recv_state, self); return self; } // The destructor shuts down the bstar reactor: void bstar_destroy (bstar_t **self_p) { assert (self_p); if (*self_p) { bstar_t *self = *self_p; zloop_destroy (&self->loop); zctx_destroy (&self->ctx); free (self); *self_p = NULL; } } // This method returns the underlying zloop reactor, so we can add // additional timers and readers: zloop_t * bstar_zloop (bstar_t *self) { return self->loop; } // This method registers a client voter socket. Messages received // on this socket provide the CLIENT_REQUEST events for the Binary Star // FSM and are passed to the provided application handler. We require // exactly one voter per bstar instance: int bstar_voter (bstar_t *self, char *endpoint, int type, zloop_fn handler, void *arg) { // Hold actual handler+arg so we can call this later void *socket = zsocket_new (self->ctx, type); zsocket_bind (socket, endpoint); assert (!self->voter_fn); self->voter_fn = handler; self->voter_arg = arg; zmq_pollitem_t poller = { socket, 0, ZMQ_POLLIN }; return zloop_poller (self->loop, &poller, s_voter_ready, self); } // Register handlers to be called each time there's a state change: void bstar_new_active (bstar_t *self, zloop_fn handler, void *arg) { assert (!self->active_fn); self->active_fn = handler; self->active_arg = arg; } void bstar_new_passive (bstar_t *self, zloop_fn handler, void *arg) { assert (!self->passive_fn); self->passive_fn = handler; self->passive_arg = arg; } // Enable/disable verbose tracing, for debugging: void bstar_set_verbose (bstar_t *self, bool verbose) { zloop_set_verbose (self->loop, verbose); } // Finally, start the configured reactor. It will end if any handler // returns -1 to the reactor, or if the process receives SIGINT or SIGTERM: int bstar_start (bstar_t *self) { assert (self->voter_fn); s_update_peer_expiry (self); return zloop_start (self->loop); } |
这样一来,我们的服务端代码会变得非常简短:
bstar: Binary Star core class in C
Haxe | Java | Python | Tcl | Ada | Basic | C++ | C# | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Scala
This gives us the following short main program for the server:
bstarsrv2: Binary Star server, using core class in C
Haxe | Java | Python | Tcl | Ada | Basic | C++ | C# | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Scala
无中间件的可靠性(自由者模式)
我们讲了那么多关于中间件的示例,好像有些违背“ZMQ是无中间件”的说法。但要知道在现实生活中,中间件一直是让人又爱又恨的东西。实践中的很多消息架构能都在使用中间件进行分布式架构的搭建,所以说最终的决定还是需要你自己去权衡的。这也是为什么虽然我能驾车10分钟到一个大型商场里购买五箱音量,但我还是会选择走10分钟到楼下的便利店里去买。这种出于经济方面的考虑(时间、精力、成本等)不仅在日常生活中很常见,在软件架构中也很重要。
这就是为什么ZMQ不会强制使用带有中间件的架构,但仍提供了像内置装置这样的中间件供编程人员自由选用。
这一节我们会打破以往使用中间件进行可靠性设计的架构,转而使用点对点架构,即自由者模式,来进行可靠的消息传输。我们的示例程序会是一个名称解析服务。ZMQ中的一个常见问题是:我们如何得知需要连接的端点?在代码中直接写入TCP/IP地址肯定是不合适的;使用配置文件会造成管理上的不便。试想一下,你要在上百台计算机中进行配置,只是为了让它们知道google.com的IP地址是74.125.230.82。
一个ZMQ的名称解析服务需要实现的功能有:
-
将逻辑名称解析为一个或多个端点地址,包括绑定端和连接端。实际使用时,名称服务会提供一组端点。
-
允许我们在不同的环境下,即开发环境和生产环境,进行解析;
-
该服务必须是可靠的,否则应用程序将无法连接到网络。
为管家模式提供名称解析服务会很有用,虽然将代理程序的端点对外暴露也很简单,但是如果用好名称解析服务,那它将成为唯一一个对外暴露的接口,将更便于管理。
我们需要处理的故障类型有:服务崩溃或重启、服务过载、网络因素等。为获取可靠性,我们必须建立一个服务群,当某个服务端崩溃后,客户端可以连接其他的服务端。实践中,两个服务端就已经足够了,但事实上服务端的数量可以是任意个。
在这个架构中,大量客户端和少量服务端进行通信,服务端将套接字绑定至单独的端口,这和管家模式中的代理有很大不同。对于客户端来说,它有这样几种选择:
-
客户端可以使用REQ套接字和懒惰海盗模式,但需要有一个机制防止客户端不断地请求已停止的服务端。
-
客户端可以使用DEALER套接字,向所有的服务端发送请求。很简单,但并不太妙;
-
客户端使用ROUTER套接字,连接特定的服务端。但客户端如何得知服务端的套接字标识呢?一种方式是让服务端主动连接客户端(很复杂),或者将服务端标识写入代码进行固化(很混乱)。
模型一:简单重试
让我们先尝试简单的方案,重写懒惰海盗模式,让其能够和多个服务端进行通信。启动服务端时用命令行参数指定端口。然后启动多个服务端。
启动客户端,指定一个或多个端点:
flserver1: Freelance server, Model One in C
C# | Java | Lua | PHP | Python | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Haxe | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
Then start the client, specifying one or more connect endpoints as arguments:
flclient1: Freelance client, Model One in C
C# | Java | PHP | Python | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Haxe | Lua | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
可用如下命令运行:
flserver1 tcp://*:5555 & flserver1 tcp://*:5556 & flclient1 tcp://localhost:5555 tcp://localhost:5556 |
客户端的核心机制是懒惰海盗模式,即获得一次成功的应答后就结束。会有两种情况:
-
如果只有一个服务端,客户端会再尝试N次后停止,这和懒惰海盗模式的逻辑一致;
-
如果有多个服务端,客户端会每个尝试一次,收到应答后停止。
这种机制补充了海盗模式,使其能够克服只有一个服务端的情况。
但是,这种设计无法在现实程序中使用:当有很多客户端连接了服务端,而主服务端崩溃了,那所有客户端都需要在超时后才能继续执行。
模型二:批量发送
下面让我们使用DEALER套接字。我们的目标是能再最短的时间里收到一个应答,不能受主服务端崩溃的影响。可以采取以下措施:
-
连接所有的服务端;
-
当有请求时,一次性发送给所有的服务端;
-
等待第一个应答;
-
忽略其他应答。
这样设计客户端时,当发送请求后,所有的服务端都会收到这个请求,并返回应答。如果某个服务端断开连接了,ZMQ可能会将请求发给其他服务端,导致某些服务端会收到两次请求。
更麻烦的是客户端无法得知应答的数量,容易发生混乱。
我们可以为请求进行编号,忽略不匹配的应答。我们要对服务端进行改造,返回的消息中需要包含请求编号:flserver2: Freelance server, Model Two in C
flserver2: Freelance server, Model Two in C
C# | Java | Lua | PHP | Python | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Haxe | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
Then start the client, specifying the connect endpoints as arguments:
flclient2: Freelance client, Model Two in C
C# | Java | PHP | Python | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Haxe | Lua | Node.js | Objective-C | ooc | Perl | Q | Racket | Ruby | Scala
几点说明:
-
客户端被封装成了一个API类,将复杂的代码都包装了起来。
-
客户端会在几秒之后放弃寻找可用的服务端;
-
客户端需要创建一个合法的REP信封,所以需要添加一个空帧。
程序中,客户端发出了1万次名称解析请求(虽然是假的),并计算平均耗费时间。在我的测试机上,有一个服务端时,耗时60微妙;三个时80微妙。
该模型的优缺点是:
-
优点:简单,容易理解和编写;
-
优点:它工作迅速,有重试机制;
-
缺点:占用了额外的网络带宽;
-
缺点:我们不能为服务端设置优先级,如主服务、次服务等;
-
缺点:服务端不能同时处理多个请求。
模式三 - 复杂和讨厌模型
批量发送模型看起来不太真实,那就让我们来探索最后这个极度复杂的模型。很有可能在编写完之后我们又会转而使用批量发送,哈哈,这就是我的作风。
我们可以将客户端使用的套接字更换为ROUTER,让我们能够向特定的服务端发送请求,停止向已死亡的服务端发送请求,从而做得尽可能地智能。我们还可以将服务端的套接字更换为ROUTER,从而突破单线程的瓶颈。
但是,使用ROUTER-ROUTER套接字连接两个瞬时套接字是不可行的,节点只有在收到第一条消息时才会为对方生成套接字标识。唯一的方法是让其中一个节点使用持久化的套接字,比较好的方式是让客户端知道服务端的标识,即服务端作为持久化的套接字。
为了避免产生新的配置项,我们直接使用服务端的端点作为套接字标识。
回想一下ZMQ套接字标识是如何工作的。服务端的ROUTER套接字为自己设置一个标识(在绑定之前),当客户端连接时,通过一个握手的过程来交换双方的标识。客户端的ROUTER套接字会先发送一条空消息,服务端为客户端生成一个随机的UUID。然后,服务端会向客户端发送自己的标识。
这样一来,客户端就可以将消息发送给特定的服务端了。不过还有一个问题:我们不知道服务端会在什么时候完成这个握手的过程。如果服务端是在线的,那可能几毫秒就能完成。如果不在线,那可能需要很久很久。
这里有一个矛盾:我们需要知道服务端何时连接成功且能够开始工作。自由者模式不像中间件模式,它的服务端必须要先发送请求后才能的应答。所以在服务端发送消息给客户端之前,客户端必须要先请求服务端,这看似是不可能的。
我有一个解决方法,那就是批量发送。这里发送的不是真正的请求http://rfc.zeromq.org/spec:10
下面让我们制定一个协议,来定义自由者模式是如何传递这种心跳的: http://rfc.zeromq.org/spec:10
实现这个协议的服务端很方便,下面就是经过改造的echo服务:flserver3: Freelance server, Model Three in C
flserver3: Freelance server, Model Three in C
C# | Java | Lua | Python | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Haxe | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Scala
The Freelance client, however, has gotten large. For clarity, it's split into an example application and a class that does the hard work. Here's the top-level application:
flclient3: Freelance client, Model Three in C
C# | Java | Python | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Haxe | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Scala
And here, almost as complex and large as the Majordomo broker, is the client API class:
flcliapi: Freelance client API in C
C# | Java | Python | Tcl | Ada | Basic | C++ | Clojure | CL | Delphi | Erlang | F# | Felix | Go | Haskell | Haxe | Lua | Node.js | Objective-C | ooc | Perl | PHP | Q | Racket | Ruby | Scala
但是,自由者模式的客户端会变得大一写。为了清晰期间,我们将其拆分为两个类来实现。首先是在上层使用的程序:
下面是该模式复杂的实现过程:
这组API使用了较为复杂的机制,我们之前也有用到过:
这个API实现相当复杂,使用了一些我们以前没有见过的技术。
-
多线程API:客户端API由两部分组成:在应用程序线程中运行的同步flcliapi类和作为后台线程运行的异步代理类。记住zeromq如何使创建多线程应用程序变得容易。flcliapi和agent类通过inproc套接字与消息进行通信。所有zeromq方面(如创建和销毁上下文)都隐藏在API中。实际上,代理就像一个小型代理,在后台与服务器对话,这样当我们发出请求时,它可以尽最大努力到达它认为可用的服务器。
-
轮询计时器:在以前的轮询循环中,我们总是使用固定的滴答间隔,例如1秒,这是足够简单的,但在电源敏感的客户机(如笔记本或手机)上不太好,在这些客户机上唤醒CPU需要消耗电源。为了好玩,为了帮助拯救地球,代理使用了一个无滴答的计时器,它根据我们期望的下一个超时计算投票延迟。适当的实现将保持一个有序的超时列表。我们只检查所有超时并计算投票延迟到下一个。
总结
这一章中我们看到了很多可靠的请求-应答机制,每种机制都有其优劣性。大部分示例代码是可以直接用于生产环境的,不过还可以进一步优化。有两个模式会比较典型:使用了中间件的管家模式,以及未使用中间件的自由者模式。
最后
以上就是玩命百合为你收集整理的ZEROMQ 第 4 章 - 可靠地请求响应模型的全部内容,希望文章能够帮你解决ZEROMQ 第 4 章 - 可靠地请求响应模型所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复