从底部开始构造:队列
首先你需要理解什么是队列。从概念上来讲,AMQP消息路由必须有三部分:交换器、队列和绑定。生产者把消息发布到交换器上;消息最终达到队列,并被消费者接收;绑定决定了消息如何从路由器由到特定的队列。在你研究交换器和绑定之前,需要先理解队列的概念和工作原理,看下图。
就像我们之前在讨论生产者和消费者时说的那样,队列就如同具名有项。消息最终达到队列中并等待消费。消费者通过以下两种方式从特定的队列中接收消息:
- 通过AMQP的basic.consume命令订阅。这样做会将信道置为接收模式,直到取消对队列的订阅为止。订阅了消息后,消费者在消费(或者拒绝)最近接收的那条消息后,就能从队列中(可用的)自动接收下一条消息。如果消费者处理队列消息,并且/或者需要在消息一到达队列就自动接收的话,你应该使用basic.consume。
- 某些时候,你只想从队列获得单条消息而不是持续订阅。向队列请求单条消息是通过AMQP的basic.get命令实现的。这样做可以让消费者接收队列中的下一条消息。如果要获得等多消息的话,需要再次发送basic.get命令。你不应该将basic.get放在一个循环里来代替basic.consume。因为这样做会影响Rabbit的性能。大致上讲,basic.get命令会订阅消息,获得单条消息,然后取消订阅。消费者理应始终使用basic.consume来实现高吞吐量。
如果至少有一个消费者订阅了队列的话,消息会立即发送给这些订阅的消费者。但是如果消息到达了无人订阅的队列呢?在这种情况下,消息会在队列中等待。一旦有消费者订阅到该队列,那么队列的消息就会发送给消费者。更有趣的问题是,当有多个消费者订阅到同一队列上时,消息是如何分发的。
当RabbitMQ队列拥有多个消费者时,队列收到的消息将以循环(round-robin)的方式发送给消费者。每条消息只会发送给一个订阅的消费者。假设有seed_bin队列,消费者Farmer Bob和消费者Farmer Esmeralda订阅到seed_bin队列。当消息到达seed_bin队列时,消息投递方式如下:
- 消息Message_A到达seed_bin队列。
- RabbitMQ把消息Message_A发送到Farmer Bob。
- Farmer Bob确认接收到了消息Message_A。
- RabbitMQ把消息Message_A从seed_bin中删除。
- 消息Message_B到达seed_bin队列。
- RabbitMQ把消息Message_B发送给Farmer Esmeralda。
- Farmer Esmeralda确认接收到了消息Message_B。
- RabbitMQ把消息Message_B从seed_bin中删除。
你可能注意到了Farmers Bob和Esmeralda做了一些我们还未讨论过的事情:他们对消息进行了确认。消费者接收到的每一条消息都必须进行确认。消费者必须通过AMQP的basic.ack命令显式的向RabbitMQ发送一个确认,或者在订阅到队列的时候就将auto_ack参数设置为true。当设置auto_ack时,一旦消费者接收消息,RabbitMQ会自动视其确认了消息。需要记住的是,消费者对消息的确认和告诉生产者消息已经被接收了这两件事毫不相关。因此,消费者通过确认命令告诉RabbitMQ他已经正确的接收了消息,同时RabbitMQ才能安全的把消息从队列中删除。
如果消费者收到一条消息,然后确认之前从Rabbit断开连接(或者从队列上取消订阅),RabbitMQ会认为这条消息没有分发,然后重新分发给下一个订阅的消费者。如果你的应用程序崩溃了,这样做可以确保消息会被发送给另一个消费者进行处理。另一方面,如果应用程序有bug而忘记确认消息的话,Rabbit将不会给该消费者发送更多消息了。这是因为在上一条消息被确认之前,Rabbit会认为这个消费者并没有准备好接收下一条消息。你可以好好利用这一点。如果处理消息内容非常耗时,则你的应用程序可以延迟确认该消息,直到消息处理完成。这样可以防止Rabbit持续不断的消息涌向你的应用而导致过载。
在收到消息后,如果你想要明确拒绝而不是确认收到该消息的话,该如何呢?举例来说,假设在处理消息的时候你遇到了不可恢复的错误,但是由于硬件问题,只影响到当前消费者(这就是一个很好的示例,直到消息处理完成之前,你绝不能进行确认)。只要消息尚未确认,则你有以下两个选择:
- 把消费者从RabbitMQ服务器断开连接。这会导致RabbitMQ自动重新把消息入队并发送给另一个消费者。这样做的好处是所有的RabbitMQ版本都支持。缺点是,这样连接/断开连接的方式会额外增加RabbitMQ的负担(如果消费者在处理每条消息时都遇到错误的话,会导致潜在的重大负荷)。
- 如果你正使用RabbitMQ 2.0.0或者更新的版本,那就使用AMQP的basic.reject命令。顾名思义:basic.reject允许消费者拒绝RabbitMQ发送的消息。如果把reject命令的requeue参数设置成true的话,RabbitMQ立即会把消息重新发送给下一个订阅的消费者。如果设置成false的话,RabbitMQ立即会把消息从队列中移除,而不会把他发送给新的消费者。你也可以通过对消息确认的方式来简单地忽略该消息(这种忽略消息的方式的优势在于所有版本的RabbitMQ都支持)。如果你检测到一条格式错误的消息而任何一个消费者都无法处理的时候,这样做就十分有用。
注意:当丢弃一条消息时,为什么要使用jasic.reject命令,并将requeue参数设置成false来替代确认消息呢?在将来的RabbitMQ版本中会支持一个特殊的“死信”(dead letter)队列,用来存放那些被拒绝而不重入队列的消息。死信队列让你通过检测拒绝/未送达的消息来发现问题。如果应用程序想自动从死信队列功能中获益的话,需要使用reject命令并将requeue参数设置成false。
还有一件更重要的事情:如何创建队列。消费者和生产者都能使用AMQP的queue.declare命令来创建队列。但是如果消费者在同一条信道上订阅了另一个队列的话,就无法再声明队列。必须首先取消订阅,将信道置为“传输”模式。当创建队列时,你常常想要指定队列名称。消费者订阅队列时需要队列名称,并在创建绑定时也需要指定队列名称。如果不指定队列名称的话,Rabbit会分配一个随机名称并在queue.declare命令的响应中返回(对于构建在AMQP上的RPC应用来说,使用临时“匿名”队列很有用)。以下是队列设置中另一些有用的参数:
- exclusive——如果设置为true的话,队列将变成私有的,此时只有你的应用程序才能够消费队列消息。当你想要限制一个队列只有一个消费者的时候很有帮助。
- auto-delete——当最后一个消费者取消订阅的时候,队列就会自动移除。如果你需要临时队列只为一个消费者服务的话,请结合使用auto-delete和exclusive。当消费者断开连接时,队列就被移除了。
如果尝试声明一个已经存在的队列会发生什么呢?只要声明参数完全匹配现存的队列的话,Rabbit就什么都不做,并成功返回,就好像这个队列已经创建成功一样(如果参数不匹配的话,队列声明尝试会失败)。如果你只是想检测队列是否存在,则可以设置queue.declare的passive选项为true。在该设置下,如果队列存在,那么queue.declare命令会成功返回;如果队列不存在的话,queue.declare命令不会创建队列而会返回一个错误。
当设计应用程序时,你最有可能会问自己,是该由生产者还是消费者来创建所需的队列呢?看起来最自然的答案是由消费者来创建队列。毕竟,消费者才需要订阅队列,而且总不能订阅一个不存在的队列,是吧?先别这么快下结论。你首先需要想清楚消息的生产者能否承担得起丢失消息。发送出去的消息如果路由到了不存在的队列的话,Rabbit会忽略他们。因此,如果你不能承担得起消息进入“黑洞”而丢失的话,你的生产者和消费者就都应该尝试去创建队列。另一方面,如果你承担得起丢失消息,或者你实现一种方法来重新发布未处理的消息的话,你可以只让自己的消费者来声明队列。
队列是AMQP消息通信的基础模块:
- 为消息提供了处所,消息在此等待消费。
- 对负载均衡来说,队列是绝佳方案。只需附加一堆消费者,并让RabbitMQ以循环的方式均匀的分配发来的消息。
- 队列是Rabbit中消息的最后的终点(除非消息进入了“黑洞”)。
在掌握了队列之后,你已经准备好进入下一个Rabbit构造块:交换器和绑定!
联合起来:交换器和绑定
消息是如何到达队列的呢?让我们来认识一下AMQP的交换器和绑定。当你想要将消息投递到队列时,你通过把消息发送给交换器来完成。然后,根据确定的规则,RabbitMQ竟会决定消息该投递到哪个队列。这些规则被称作路由键(routing key)。队列通过路由键绑定到交换器。当你把消息发送到代理服务器时,消息将拥有一个路由键——即便是空的——RabbitMQ也会将其和绑定使用的路由键进行匹配。如果相匹配的话,那么消息将会投递到该队列。如果路由的消息不匹配任何绑定模式的话,消息将进入“黑洞”。
为何要如此大费周章?你也许会这么说:“我只想让消息到达队列而已”。让我们看一个例子,以便理解上述概念的优势。
你可以将这个场景和邮件进行比较:如果你想把一条消息发送给任何一个联系人,则需要把消息发送到对方的邮件地址,SMTP服务器会检查消息是发送给谁的并会负责投递到用户的收件箱。但是,如果你的联系人想要把来自你的每条消息都归档到商务文件夹下的话会如何呢?为了能达成这个目标,他们需要根据消息内容设置明确的规则。举例来说,他们可能也想通过设置基于主机名的规则,将某些商业供应商归类到同一个文件夹中。通过交换器、绑定和队列的概念,AMQP实现上述以及更多的使用场景,因此你能将队列绑定到交换器上,而不使用路由键,然后你发送给交换器的每一条没有路由键的消息会投递到上述的队列中去。这一点和邮件系统非常类似。如果你需要复杂的使用案例,例如发布/订阅或者多播,则也能够轻易完成。一会儿你就能看到。
除了可以用交换器和绑定来完成不同的使用场景之外,还有另一个好处是:对于发送消息给服务器的发布者来说,他不需要关心服务器的另一端(整个消息处理环节中的队列和消费者)的逻辑。就如你将看到的那样,这可以导致有趣的消息通信场景。如果服务器只允许你直接发布消息到队列的话,是无法实现这些场景的——或者说非常难以实现。
就像你之前看到的那样,服务器会根据路由键将消息从交换器路由到队列,但他是如何处理投递到多个队列的情况的呢?协议中定义的不同类型交换器发挥了作用。一共有四种类型:direct、fanout、topic和headers。每一种类型实现了不同的路由算法。我们会讲解除了headers交换器外的其他三种。headers交换器允许你匹配AMQP消息的header而非路由键。除此之外,headers交换器和direct交换器完全一致,但性能会差很多。因此他并不太实用,而且几乎再也用不到了。让我们仔细看看其他几种类型的交换器吧。
direct交换器
如果路由键匹配的话,消息就被投递到对应的队列。请参考下图。
服务器必须实现direct类型交换器,包含一个空白字符串名称的默认交换器。当声明一个队列时,他会自动绑定到默认交换器,并以队列名称作为路由键。这意味你可以使用如下代码发送消息到之前声明的队列中。前提是你已经获得了信道实例:
$channel->basic_publish($msg,'','queue-name');
第一个参数是你想要发送的消息内容;第二个参数是一个空的字符串,制定了默认交换器;而第三个参数是路由键了。这个路由键就是之前用来声明队列的名称。之后,你会看到如何使用默认交换器和临时队列来实现RPC消息通信模式。
当默认的direct交换器无法满足应用程序的需求时,你可以声明你自己的交换器。只需发送exchange.declare命令并设置合适的参数就行了。
fanout交换器
就如上图所示那样,这种类型交换器会将收到的消息广播到绑定的队列上。消息通信模式很简单:当你发送一条消息到fanout交换器时,他会把消息投递给所有附加在此交换器上的队列。这允许你对单条消息做不同方式的反应。举例来说,一个Web应用程序可能需要在用户上传新的图片时,用户相册必须清除缓存,同时用户应当得到些积分奖励。你可以将两个队列绑定到图片上传交换器上。一个用于清除缓存,另一个用户增加用户积分。从这个场景中你可以了解到,使用交换器、绑定和队列比直接向指定的队列发送消息要有优势。假设应用程序的第一个需求是在图片上传到网站上后,需要清除用户相册缓存。你可以通过只使用一个队列就能轻易完成。但是当产品负责人让你实现一个新功能,即在上传完成后给用户一点奖励,你该怎么办呢?如果你是直接将消息发送给队列的话,就不得不修改发送方的代码,以将消息发送给新的用户积分(points)队列。如果你使用的是fanout交换器的话,你唯一需要做的就是为新的消费者写一段代码,然后声明新的队列并将其绑定到fanout交换器上。就如同我们之前讲的那样,发送方的代码和消费者的代码两者之间完全解耦了,这允许你轻而易举的添加应用程序的功能。
topic交换器
这类交换器允许你实现有趣的消息通信场景,他使得来自不同源头的消息能够到达同一个队列。让我们用Web应用程序日志系统作为示例。你拥有多个不同的日志级别,例如error、info和warming。与此同时,你的应用程序分为以下几个模块:user-profile、image-gallery、msg-inbox等。参见上图,如果在发送消息的动作失败时,你想要报告一个error的话,则可以编写以下代码:
$channel->basic_publish($msg,'logs-exchange','error.msg-inbox');
然后,假设你声明了一个msg-inbox-errors队列,你可以将其绑定到交换器上来接收消息,如下所示:
$channel->queue_bind('msg-inbox-errors','logs-exchange','error.msg-inbox');
到目前为止,这看起来和使用direct交换器很想。你为队列绑定操作和消息发布路由键制定了相同的error.msg-inbox字符串作为绑定规则。那样就能确保你的消息会路由到msg-inbox-errors队列,这没什么特别的。但是如果你想要一个队列监听msg-inbox模块的所有error级别的话,你该怎么做呢?你可以通过将新的队列绑定到已有的同一个交换器来实现,就像下面这样:
$channel->queue_bind('msg-inbox-logs','logs-exchange','*.msg-inbox');
msg-inbox-logs队列将会接收从msg-inbox模块发来的所有error、warning和info日志信息。那么如何接收所有的日志呢?这实现起来也很简单。你可以在队列绑定到交换器上的时候使用通配符。从之前的例子可以看到,单个“.”把路由键分为了几部分,“*”匹配特定位置的任意文本。为了实现匹配所有规则,你可以使用“#”字符:
$channel->queue_bind('all-logs','logs-exchange','#');
通过这样的绑定方式,all-logs队列将会接收所有从Web应用程序发布的日志。当然,要使得之前的示例能够运行,你必须在绑定之前对队列进行声明。“*”操作符将“.”视为分隔符;与之不同的是,“#”操作符没有分块的概念,他将任意“.”字符均视为关键字的匹配部分。
到目前为止,你应该已经理解了这三种交换器类型,并能体会AMQP的强大之处了。你可以对服务器的行为编程以满足自己的需求。他既能够以发布/订阅的设置方式作为队列服务器使用,也可以作为RPC服务器。这取决于你如何组织这些功能。