RabbitMq进阶

目录

消息何去何从

mandatory参数

immediate参数

备份交换器

过期时间(TTL)

设置消息的TTL

设置队列的TTL

死信队列

延迟队列

优先级队列

RPC实现

持久化

生产者确认

事务机制

发送方确认机制

消费端要点介绍

消息分发

消息顺序性

弃用Queueing Consumer

消息传输保障


消息何去何从

mandatory和immediate是channel.basicPublish方法中的两个参数,它们都有 当消息传递过程中不可达目的地时将消息返回给生产者的功能。RabbitMQ提供的备份交换器 (Alternate Exchange)可以将未能被交换器路由的消息(没有绑定队列或者没有匹配的绑定)存 储起来,而不用返回给客户端。

对于初学者来说,特别容易将mandatory和immediate这两个参数混淆,而对于备份 交换器更是一筹莫展,

mandatory参数

当mandatory参数设为true时,交换器无法根据自身的类型和路由键找到一个符合条件 的队列,那么RabbitMQ会调用Basic.Return命令将消息返回给生产者。当mandatory参 数设置为false时,出现上述情形,则消息直接被丢弃。

那么生产者如何获取到没有被正确路由到合适队列的消息呢?这时候可以通过调用 channel.addReturnListener来添加ReturnListener监听器实现。

使用mandatory参数的关键代码如代码清单所示。

上面代码中生产者没有成功地将消息路由到队列,此时RabbitMQ会通过Basic.Return 返回"mandatory test"这条消息,之后生产者客户端通过:ReturnListener监听到了这个事 件,上面代码的最后输出应该是"Basic.Return返回的结果是: mandatory test"。

从AMQP协议层面来说,其对应的流转过程如图所示。

immediate参数

当immediate参数设为true时,如果交换器在将消息路由到队列时发现队列上并不存在 任何消费者,那么这条消息将不会存入队列中。当与路由键匹配的所有队列都没有消费者时, 该消息会通过Basic.Return返回至生产者。

概括来说,mandatory参数告诉服务器至少将该消息路由到一个队列中,否则将消息返 回给生产者。

immediate参数告诉服务器,如果该消息关联的队列上有消费者,则立刻投递; 如果所有匹配的队列上都没有消费者,则直接将消息返还给生产者,不用将消息存入队列而等 待消费者了。

RabbitMQ 3.0版本开始去掉了对immediate参数的支待,对此RabbitMQ官方解释是:

immediate参数会影响镜像队列的性能,增加了代码复杂性,建议采用TTL和DLX的方法替代。

备份交换器

备份交换器,英文名称为Alternate Exchange, 简称AE, 或者更直白地称之为"备胎交换器”。

生产者在发送消息的时候如果不设置mandatory参数,那么消息在未被路由的情况下将会丢失; 如果设置了mandatory参数,那么需要添加ReturnListener的编程逻辑,生产者的代码将 变得复杂。如果既不想复杂化生产者的编程逻辑,又不想消息丢失,那么可以使用备份交换器, 这样可以将未被路由的消息存储在RabbitMQ中,再在需要的时候去处理这些消息。

可以通过在声明交换器(调用channel.exchangeDeclare方法)的时候添加 alternate-exchange参数来实现,也可以通过策略的方式实现。 如果两者同时使用,则前者的优先级更高,会覆盖掉Policy的设置。

使用参数设置的关键代码如代码清单所示。

上面的代码中声明了两个交换器normalExchange和myAe, 分别绑定了normal Queue和 unroutedQueue这两个队列,同时将myAe设置为normalE:xchange的备份交换器。注意myAe 的交换器类型为fanout。

参考图, 如果此时发送一条消息到normalExchange上,当路由键等于"normal.Key"的 时候,消息能正确路由到normal Queue这个队列中。如果路由键设为其他值,比如"error Key", 即消息不能被正确地路由到与normalExchange绑定的任何队列上,此时就会发送给myAe, 进 而发送到unroutedQueue这个队列。

备份交换器其实和普通的交换器没有太大的区别,为了方便使用,建议设置为fanout类型, 如若想设置为direct或者topic的类型也没有什么不妥。需要注意的是,消息被重新发送到 备份交换器时的路由键和从生产者发出的路由键是一样的。

考虑这样一种情况,如果备份交换器的类型是direct, 并且有一个与其绑定的队列,假设绑 定的路由键是key1, 当某条携带路由键为key2的消息被转发到这个备份交换器的时候,备份交换器没有匹配到合适的队列,则消息丢失。如果消息携带的路由键为key1, 则可以存储到队 列中。

对于备份交换器,总结了以下几种特殊情况:

如果设置的备份交换器不存在,客户端和RabbitMQ服务端都不会有异常出现,此时消 息会丢失。

如果备份交换器没有绑定任何队列,客户端和RabbitMQ服务端都不会有异常出现,此 时消息会丢失。

如果备份交换器没有任何匹配的队列,客户端和RabbitMQ服务端都不会有异常出现, 此时消息会丢失。

如果备份交换器和mandatory参数一起使用,那么mandatory参数无效。

过期时间(TTL)

TTL, Time to Live的简称,即过期时间。RabbitMQ可以对消息和队列设置TTL。

设置消息的TTL

目前有两种方法可以设置消息的TTL。第一种方法是通过队列属性设置,队列中所有消息 都有相同的过期时间。

第二种方法是对消息本身进行单独设置,每条消息的TTL可以不同。如 果两种方法一起使用,则消息的TTL以两者之间较小的那个数值为准。

消息在队列中的生存时 间一旦超过设置的TTL值时,就会变成“死信" (Dead Message), 消费者将无法再收到该消息 (这点不是绝对的)。

通过队列属性设置消息TTL的方法是在channel.queueDeclare方法中加入 x-message-ttl参数实现的,这个参数的单位是毫秒。

示例代码如代码清单所示。

如果不设置TTL, 则表示此消息不会过期;

如果将TTL设置为0, 则表示除非此时可以直 接将消息投递到消费者,否则该消息会被立即丢弃,这个特性可以部分替代RabbitMQ 3.0版本 之前的immediate参数,之所以部分代替,是因为immediate参数在投递失败时会用 Basic.Return将消息返回(这个功能可以用死信队列来实现)。

针对每条消息设置TTL的方法是在channel.basicPublish方法中加入expiration 的属性参数,单位为毫秒。

关键代码如代码清单所示。

或者

还可以通过HTTP API接口设置:

对于第一种设置队列TTL属性的方法,一旦消息过期,就会从队列中抹去,而在第二种方 法中,即使消息过期,也不会马上从队列中抹去,因为每条消息是否过期是在即将投递到消费 者之前判定的。

为什么这两种方法处理的方式不一样?因为第一种方法里,队列中已过期的消息肯定在队 列头部,RabbitMQ只要定期从队头开始扫描是否有过期的消息即可。

而第二种方法里,每条消 息的过期时间不同,如果要删除所有过期消息势必要扫描整个队列,所以不如等到此消息即将 被消费时再判定是否过期,如果过期再进行删除即可。

设置队列的TTL

通过channel.queueDeclare方法中的x-expires参数可以控制队列被自动删除前,处 于未使用状态的时间。

未使用的意思是队列上没有任何的消费者,队列也没有被重新声明,并 且在过期时间段内也未调用过Basic.Get命令。

设置队列里的TTL可以应用于类似RPC方式的回复队列,在RPC中,许多队列会被创建 出来,但是却是未被使用的。

RabbitMQ会确保在过期时间到达后将队列删除,但是不保障删除的动作有多及时。在 RabbitMQ重启后,持久化的队列的过期时间会被重新计算。

用于表示过期时间的x-expires参数以亳秒为单位,并且服从和x-message-ttl一样 的约束条件,不过不能设置为0。比如该参数设置为1000, 则表示该队列如果在1秒钟之内未 使用则会被删除。

代码清单演示了创建一个过期时间为30分钟的队列:

死信队列

DLX, 全称为Dead-Letter-Exchange, 可以称之为死信交换器,也有人称之为死信邮箱。当 消息在一个队列中变成死信(dead message)之后,它能被重新被发送到另一个交换器中,这个 交换器就是DLX, 绑定DLX的队列就称之为死信队列。

消息变成死信一般是由于以下几种情况:

消息被拒绝(Basic.Reject/Basic. Nack), 并且设置requeue参数为false; 令消息过期;

队列达到最大长度。

DLX也是一个正常的交换器,和一般的交换器没有区别,它能在任何的队列上被指定,实 际上就是设置某个队列的属性。当这个队列中存在死信时,RabbitMQ就会自动地将这个消息重 新发布到设置的DLX上去,进而被路由到另一个队列,即死信队列。

可以监听这个队列中的消 息以进行相应的处理,这个特性与将消息的TTL设置为0配合使用可以弥补immediate参数 的功能。

通过在channel.queueDeclare方法中设置x-dead-letter-exchange参数来为这 个队列添加DLX (代码清单中的dlx _exchange):

下面创建一个队列,为其设置TTL和DLX等,如代码清单

这里创建了两个交换器exchange.normal和exchange.dlx, 分别绑定两个队列queue.normal 和queue.dlx。

由Web管理页面可以看出,两个队列都被标记了"D", 这个是durable的缩写, 即设置了队列持久化。queue.normal这个队列还配置了TTL、DLX和DLK, 其中DLX指的是 x-dead-letter-routing-key这个属性。

参考图, 生产者首先发送一条携带路由键为"rk"的消息,然后经过交换器 exchange.normal顺利地存储到队列queue.normal中。由于队列queue.normal设置了过期时间为 10s, 在这10s内没有消费者消费这条消息,那么判定这条消息为过期。由于设置了DLX, 过期 之时,消息被丢给交换器exchange.dlx中,这时找到与exchange.dlx匹配的队列queue.dlx, 最 后消息被存储在queue.dlx这个死信队列中。

对于RabbitMQ来说,DLX是一个非常有用的特性。它可以处理异常情况下,消息不能够 被消费者正确消费(消费者调用了Basic.Nack或者Basic.Reject)而被置入死信队列中 的情况,后续分析程序可以通过消费这个死信队列中的内容来分析当时所遇到的异常情况,进 而可以改善和优化系统。DLX配合TTL使用还可以实现延迟队列的功能

延迟队列

延迟队列存储的对象是对应的延迟消息,所谓“延迟消息”是指当消息被发送以后,并不 想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。

延迟队列的使用场景有很多,比如:

在订单系统中,一个用户下单之后通常有30分钟的时间进行支付,如果30分钟之内 没有支付成功,那么这个订单将进行异常处理,这时就可以使用延迟队列来处理这些 订单了。

用户希望通过手机远程遥控家里的智能设备在指定的时间进行工作。这时候就可以将 用户指令发送到延迟队列,当指令设定的时间到了再将指令推送到智能设备。

在AMQP协议中,或者RabbitMQ本身没有直接支待延迟队列的功能,但是可以通过前面 所介绍的DLX和TTL模拟出延迟队列的功能。

在图中,不仅展示的是死信队列的用法,也是延迟队列的用法,对于queue.dlx这个死 信队列来说,同样可以看作延迟队列。假设一个应用中需要将每条消息都设置为10秒的延迟, 生产者通过exchange.normal这个交换器将发送的消息存储在queue.normal这个队列中。消费者 订阅的并非是queue.normal这个队列,而是queue.dlx这个队列。当消息从queue.normal这个队 列中过期之后被存入queue.dlx这个队列中,消费者就恰巧消费到了延迟10秒的这条消息。

在真实应用中,对于延迟队列可以根据延迟时间的长短分为多个等级,一般分为5秒、10 秒、30秒、1分钟、5分钟、10分钟、30分钟、1小时这几个维度,当然也可以再细化一下。

参考图, 为了简化说明,这里只设置了5秒、10秒、30秒、1分钟这四个等级。根据 应用需求的不同,生产者在发送消息的时候通过设置不同的路由键,以此将消息发送到与交换 器绑定的不同的队列中。这里队列分别设置了过期时间为5秒、10秒、30秒、1分钟,同时也 分别配置了DLX和相应的死信队列。当相应的消息过期时,就会转存到相应的死信队列(即延 迟队列)中,这样消费者根据业务自身的情况,分别选择不同延迟等级的延迟队列进行消费。

优先级队列

优先级队列,顾名思义,具有高优先级的队列具有高的优先权,优先级高的消息具备优先 被消费的特权。

可以通过设置队列的x-max-priority参数来实现。示例代码如代码清单所示。

上面的代码演示的是如何配置一个队列的最大优先级。在此之后,需要在发送时在消息中 设置消息当前的优先级。示例代码如代码清单所示。

上面的代码中设置消息的优先级为5。默认最低为0, 最高为队列设置的最大优先级。

优先 级高的消息可以被优先消费,这个也是有前提的:如果在消费者的消费速度大于生产者的速度 且Broker中没有消息堆积的情况下,对发送的消息设置优先级也就没有什么实际意义。因为生 产者刚发送完一条消息就被消费者消费了,那么就相当于Broker中至多只有一条消息,对于单 条消息来说优先级是没有什么意义的。

RPC实现

RPC, 是Remote Procedure Call的简称,即远程过程调用。它是一种通过网络从远程计算 机上请求服务,而不需要了解底层网络的技术。RPC的主要功用是让构建分布式计算更容易, 在提供强大的远程调用能力时不损失本地调用的语义简洁性。

通俗点来说,假设有两台服务器A和B, 一个应用部署在A服务器上,想要调用B服务器 上应用提供的函数或者方法,由于不在同一个内存空间,不能直接调用,需要通过网络来表达 调用的语义和传达调用的数据。

RPC的协议有很多,比如最早的COREA、Java RMI、WebService的RPC风格、Hessian、 Thrift甚至还有Restful API。

一般在RabbitMQ中进行RPC是很简单。客户端发送请求消息,服务端回复响应的消息。

为了接收响应的消息,我们需要在请求消息中发送一个回调队列(参考下面代码中的replyTo)。 可以使用默认的队列,具体示例代码如代码清单所示。

对于代码中涉及的BasicProperties这个类,其包含14个属性,这里就用到两个属性。

replyTo: 通常用来设置一个回调队列。

correlationid: 用来关联请求(request)和其调用RPC之后的回复(response)。 如果像上面的代码中一样,为每个RPC请求创建一个回调队列,则是非常低效的。但是幸 运的是这里有一个通用的解决方案一—可以为每个客户端创建一个单一的回调队列。

这样就产生了一个新的问题,对于回调队列而言,在其接收到一条回复的消息之后,它并不知道这条消息应该和哪一个请求匹配。这里就用到correlationId这个属性了,我们应该 为每一个请求设置一个唯一的correlationid。之后在回调队列接收到回复的消息时,可以 根据这个属性匹配到相应的请求。如果回调队列接收到一条未知correlationId的回复消息, 可以简单地将其丢弃。

你有可能会问,为什么要将回调队列中的位置消息丢弃而不是仅仅将其看作失败?这样可 以针对这个失败做一些弥补措施。参考图, 考虑这样一种情况,RPC服务器可能在发送给 回调队列(amq .gen-LhQz 1 gv3GhDOv8PIDabOXA)并且在确认接收到请求的消息(rpc_queue 中的消息)之后挂掉了,那么只需重启下RPC服务器即可,RPC服务会重新消费rpc_queue队 列中的请求,这样就不会出现RPC服务端未处理请求的情况。

这里的回调队列可能会收到重复 消息的情况,这需要客户端能够优雅地处理这种情况,并且RPC请求也需要保证其本身是幕等 的(补充:消费者消费消息一般是先处理业务逻辑,再使用Basic.Ack 确认已接收到消息以防止消息不必要地丢失)。

据图所示,RPC的处理流程如下:

(1)当客户端启动时,创建一个匿名的回调队列(名称由RabbitMQ自动创建,图中 的回调队列为amq.gen-LhQz l gv3GhDOv8PIDabOXA)。

(2)客户端为RPC请求设置2个属性: replyTo用来告知RPC服务端回复请求时的目的 队列,即回调队列; correlation Id用来标记一个请求。

(3)请求被发送到rpc_queue队列中。

(4) RPC服务端监听rpc_queue队列中的请求,当请求到来时,服务端会处理并且把带有 结果的消息发送给客户端。接收的队列就是replyTo设定的回调队列。

(5)客户端监听回调队列,当有消息时,检查corelationid属性,如果与请求匹配, 那就是结果了。

下面沿用RabbitMQ官方网站的一个例子来做说明,RPC客户端通过RPC来调用服务端的 方法以便得到相应的斐波那契值。

首先是服务端的关键代码,代码清单所示。

客户端的关键代码如代码清单

持久化

“持久化”这个词汇在前面的篇幅中有多次提及,持久化可以提高RabbitMQ的可靠性,以 防在异常情况(重启、关闭、宅机等)下的数据丢失。RabbitMQ 的待久化分为三个部分:交换器的待久化、队列的持久化和消息的持久化。

交换器的持久化是通过在声明交换器是将durable参数置为true实现的。如果交换器不设置持久化,那么在RabbitMQ服务重启之后,相关的交换器元数据会丢失, 不过消息不会丢失,只是不能将消息发送到这个交换器中了。对一个长期使用的交换器来说, 建议将其置为待久化的。

队列的持久化是通过在声明队列时将durable参数置为true实现的。如果队列不设置持久化,那么在RabbitMQ服务重启之后,相关队列的元数据会丢失, 此时数据也会丢失。正所谓“皮之不存,毛将焉附",队列都没有了,消息又能存在哪里呢?

队列的持久化能保证其本身的元数据不会因异常情况而丢失,但是并不能保证内部所存储的 消息不会丢失。要确保消息不会丢失,需要将其设置为待久化。通过将消息的投递模式 (BasicProperties中的deliveryMode属性)设置为2即可实现消息的持久化。前面示例 中多次提及的Message Properties.PERSISTENT_TEXT_PLAIN实际上是封装了这个属性:

设置了队列和消息的持久化,当RabbitMQ服务重启之后,消息依旧存在。单单只设置队 列待久化,重启之后消息会丢失;单单只设置消息的持久化,重启之后队列消失,继而消息也 丢失。单单设置消息待久化而不设置队列的待久化显得毫无意义。

注意要点:

可以将所有的消息都设置为持久化,但是这样会严重影响RabbitMQ的性能(随机)。写入 磁盘的速度比写入内存的速度慢得不只一点点。对于可靠性不是那么高的消息可以不采用持久 化处理以提高整体的吞吐量。在选择是否要将消息持久化时,需要在可靠性和吐吞量之间做一 个权衡。

将交换器、队列、消息都设置了待久化之后就能百分之百保证数据不丢失了吗?答案是否 定的。

首先从消费者来说,如果在订阅消费队列时将autoAck参数设置为true, 那么当消费者接 收到相关消息之后,还没来得及处理就宅机了,这样也算数据丢失。这种情况很好解决,将 autoAck参数设置为false, 并进行手动确认。

其次,在持久化的消息正确存入RabbitMQ之后,还需要有一段时间(虽然很短,但是不 可忽视)才能存入磁盘之中。RabbitMQ并不会为每条消息都进行同步存盘(调用内核的fsync1 方法)的处理,可能仅仅保存到操作系统缓存之中而不是物理磁盘之中。如果在这段时间内 RabbitMQ服务节点发生了宅机、重启等异常情况,消息保存还没来得及落盘,那么这些消息将会丢失。

这个问题怎么解决呢?这里可以引入RabbitMQ的镜像队列机制,相当 于配置了副本,如果主节点(master)在此特殊时间内挂掉,可以自动切换到从节点(slave), 这样有效地保证了高可用性,除非整个集群都挂掉。虽然这样也不能完全保证RabbitMQ消息 不丢失,但是配置了镜像队列要比没有配置镜像队列的可靠性要高很多,在实际生产环境中的 关键业务队列一般都会设置镜像队列。

还可以在发送端引入事务机制或者发送方确认机制来保证消息已经正确地发送并存储至 RabbitMQ中,前提还要保证在调用channel . basicPublish方法的时候交换器能够将消息 正确路由到相应的队列之中。

生产者确认

在使用RabbitMQ的时候,可以通过消息持久化操作来解决因为服务器的异常崩溃而导致 的消息丢失,除此之外,我们还会遇到一个问题,当消息的生产者将消息发送出去之后,消息 到底有没有正确地到达服务器呢?如果不进行特殊配置,默认情况下发送消息的操作是不会返 回任何信息给生产者的,也就是默认情况下生产者是不知道消息有没有正确地到达服务器。如 果在消息到达服务器之前已经丢失,待久化操作也解决不了这个问题,因为消息根本没有到达 服务器,何谈待久化?

RabbitMQ针对这个问题,提供了两种解决方式:

通过事务机制实现;

通过发送方确认(publisher confirm)机制实现。

事务机制

RabbitMQ客户端中与事务机制相关的方法有三个: channel.txSelect、 channel. txCommit和channel.txRollback。channel.txSelect用于将当前的信道设置成事务模式,channel.txCommit用于提交事务,channel.txRollback用于事务回 滚。在通过channel. txSelect方法开启事务之后,我们便可以发布消息给RabbitMQ了, 如果事务提交成功,则消息一定到达了RabbitMQ中,如果在事务提交执行之前由于RabbitMQ 异常崩溃或者其他原因抛出异常,这个时候我们便可以将其捕获,进而通过执行 channel.txRollback方法来实现事务回滚。注意这里的RabbitMQ中的事务机制与大多数 数据库中的事务概念并不相同,需要注意区分。

关键示例代码如代码清单所示。

上面代码对应的AMQP协议流转过程如图所示。

可以发现开启事务机制与不开启相比多了四个步骤:

客户端发送Tx.Select, 将信道置为事务模式;

Broker回复Tx. Select-Ok, 确认已将信道置为事务模式;

在发送完消息之后,客户端发送Tx.Commit提交事务;

Broker回复Tx.Commit-Ok, 确认事务提交。

上面所陈述的是正常的情况下的事务机制运转过程,而事务回滚是什么样子呢?我们先来 参考下面一段示例代码, 来看看怎么使用事务回滚。

上面代码中很明显有一个java.lang.ArithmeticException, 在事务提交之前捕获 到异常,之后显式地提交事务回滚,其AMQP协议流转过程如图所示。

如果要发送多条消息,则将channel.basicPublish和channel.txComrnit等方法包 裹进循环内即可,可以参考如下示例代码。

事务确实能够解决消息发送方和RabbitMQ之间消息确认的问题,只有消息成功被 RabbitMQ接收,事务才能提交成功,否则便可在捕获异常之后进行事务回滚,与此同时可以进 行消息重发。但是使用事务机制会“吸干"RabbitMQ的性能,那么有没有更好的方法既能保证 消息发送方确认消息已经正确送达,又能基本上不带来性能上的损失呢?从AMQP协议层面来 看并没有更好的办法,但是RabbitMQ提供了一个改进方案,即发送方确认机制。

发送方确认机制

前面介绍了RabbitMQ可能会遇到的一个问题,即消息发送方(生产者)并不知道消息是 否真正地到达了RabbitMQ。随后了解到在AMQP协议层面提供了事务机制来解决这个问题, 但是采用事务机制实现会严重降低RabbitMQ的消息吞吐量,这里就引入了一种轻量级的方式 一一发送方确认(publisher confirm)机制。

生产者将信道设置成confirm (确认)模式,一旦信道进入confirm模式,所有在该信道上 面发布的消息都会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后, RabbitMQ就会发送一个确认(Basic.Ack)给生产者(包含消息的唯一 ID), 这就使得生产 者知晓消息已经正确到达了目的地了。

如果消息和队列是可待久化的,那么确认消息会在消息 写入磁盘之后发出。RabbitMQ回传给生产者的确认消息中的detiveryTag包含了确认消息的序号,此外RabbitMQ也可以设置channel.basicAck方法中的multiple参数,表示到 这个序号之前的所有消息都已经得到了处理,可以参考图。注意辨别这里的确认和消费时 候的确认之间的异同。

事务机制在一条消息发送之后会使发送端阻塞,以等待RabbitMQ的回应,之后才能继续 发送下一条消息相比之下,发送方确认机制最大的好处在于它是异步的,一旦发布一条消息, 生产者应用程序就可以在等信道返回确认的同时继续发送下一条消息,当消息最终得到确认之 后,生产者应用程序便可以通过回调方法来处理该确认消息,如果RabbitMQ因为自身内部错 误导致消息丢失,就会发送一条nack (Basic. Nack)命令,生产者应用程序同样可以在回调 方法中处理该nack命令。

生产者通过调用channel.confirmSelect方法(即Confirm.Select命令)将信道 设置为confirm模式,之后RabbitMQ会返回Confirm.Select-Ok命令表示同意生产者将当 前信道设置为confirm模式。

所有被发送的后续消息都被ack或者nack一次,不会出现一条消 息既被ack又被nack的情况,并且RabbitMQ也并没有对消息被confirm的快慢做任何保证。

下面看一下publisher confirm机制怎么运作,简要代码如代码清单所示。

如果发送多条消息,只需要将channel.basicPublish和channel.waitFor Confirms方法包裹在循环里面即可,可以参考事务机制,不过不需要把 channel.confirmSelect方法包裹在循环内部。

在publisher confirm模式下发送多条消息的AMQP协议流转过程可以参考图。

对于channel.waitForConfirms而言,在RabbitMQ客户端中它有4个同类的方法:

如果信道没有开启publisher confirm模式,则调用任何waitForConfirrns方法都会报出 java.lang.IllegalStateException。

对于没有参数的waitForConfirms方法来说, 其返回的条件是客户端收到了相应的Basic.Ack/.Nack或者被中断。

参数timeout表示超 时时间,一旦等待RabbitMQ回应超时就会抛出java.util.concurrent. TirneoutException的异常。

两个waitForConfirrnsOrDie方法在接收到RabbitMQ返回 的Basic.Nack之后会抛出java.io.IOException。

业务代码可以根据自身的特性灵活地 运用这四种方法来保障消息的可靠发送。

前面提到过RabbitMQ引入了publisher confirm机制来弥补事务机制的缺陷,提高了整体的 吞吐量,那么我们来对比下两者之间的QPS, 测试代码可以参考上面的示例代码。

测试环境:客户端和Broker机器配置-CPU为24核、主频为2600Hz、内存为64GB、 硬盘为1TB。客户端发送的消息体大小为lOB, 单线程发送,并且消息都进行持久化处理。

图中的横坐标表示测试的次数,纵坐标表示QPS。可以发现publisher confirm与事务 机制相比,QPS并没有提高多少

我们再来回顾下前面的示例代码,可以发现publisher confirm模式是每发送一条消息后就 调用channel.waitForConfirm方法,之后等待服务端的确认,这实际上是一种串行同步 等待的方式。事务机制和它一样,发送消息之后等待服务端确认,之后再发送消息。两者的存 储确认原理相同,尤其对于待久化的消息来说,两者都需要等待消息确认落盘之后才会返回(调 用Linux内核的fsync方法)。

在同步等待的方式下,publisher confrrm机制发送一条消息需要通 信交互的命令是2条: Basic.Publish和Basic .Ack;

事务机制是3条: Basic.Publish、 Tx:Commit/.Commit-Ok (或者Tx: Rollback/. Rollback-Ok), 事务机制多了一个命 令帧报文的交互,所以QPS会略微下降。

注意要点:

(1)事务机制和publisher confirm机制两者是互斥的,不能共存。如果企图将已开启事务模式 的信道再设置为publisher confirm模式,RabbitMQ会报错: {arnqp_error, precondition_ failed, "cannot switch from tx: to confirm mode",'confirm. select'}; 或 者如果企图将已开启publisher confrrm模式的信道再设置为事务模式,RabbitMQ也会报错:

{amqp—error, precondition_failed, "cannot switch from confirm to tx mode",'tx. select')。

(2)事务机制和publisher confirm机制确保的是消息能够正确地发送至RabbitMQ, 这里的 “发送至RabbitMQ"的含义是指消息被正确地发往至RabbitMQ的交换器,如果此交换器没有 匹配的队列,那么消息也会丢失。所以在使用这两种机制的时候要确保所涉及的交换器能够有 匹配的队列。更进一步地讲,发送方要配合mandatory参数或者备份交换器一起使用来提高 消息传输的可靠性。

publisher confirm的优势在于并不一定需要同步确认。这里我们改进了一下使用方式,总结 有如下两种:

同步confirm方法:每发送一批消息后,调用channel.waitForConfirms方法,等 待服务器的确认返回。

异步confirm方法:提供一个回调方法,服务端确认了一条或者多条消息后客户端会回调这个方法进行处理。

在批量confirm方法中,客户端程序需要定期或者定量(达到多少条),亦或者两者结合起 来调用channel.waitForConfirms来等待RabbitMQ的确认返回。相比于前面示例中的普 通confirm方法,批量极大地提升了confirm的效率,但是问题在于出现返回Basic.Nack或 者超时情况时,客户端需要将这一批次的消息全部重发,这会带来明显的重复消息数量,并且 当消息经常丢失时,批量confirm的性能应该是不升反降的。

批量confirm方法的示例代码如代码清单所示。

异步confirm方法的编程实现最为复杂。在客户端Channel接口中提供的 addConfirmListener方法可以添加ConfirmListener这个回调接口,这个 ConfirmListener接口包含两个方法: handleAck和handleNack, 分别用来处理 RabbitMQ回传的Basic.Ack和Basic.Nack。

在这两个方法中都包含有一个参数 detiveryTag (在publisher confmn模式下用来标记消息的唯一有序序号)。我们需要为每一 个信道维护一个"unconfirm"的消息序号集合,每发送一条消息,集合中的元素加1。每当调 用ConfirmListener中的handleAck方法时," unconfirm "集合中删掉相应的一条 (multiple设置为false)或者多条(multiple设置为true)记录。

从程序运行效率上来看, 这个"unconfirm"集合最好采用有序集合SortedSet的存储结构。事实上,Java客户端SDK 中的waitForConfirms方法也是通过SortedSet维护消息序号的。代码清单为我们 演示了异步confirm的编码实现,其中的confirmSet就是一个SortedSet类型的集合。

最后我们将事务、普通confirm、批量confirm和异步confirm这4种方式放到一起来比较 一下彼此的QPS。测试环境和数据和图中的测试相同,具体测试对比如图所示。

可以看到批量confirm和异步confirm这两种方式所呈现的性能要比其余两种好得多。事务 机制和普通confirm的方式吐吞量很低,但是编程方式简单,不需要在客户端维护状态(这里 指的是维护detiveryTag及缓存未确认的消息)。

批量confirm方式的问题在于遇到RabbitMQ 服务端返回Basic.Nack需要重发批量消息而导致的性能降低。异步confirm方式编程模型最 为复杂,而且和批量confirm方式一样需要在客户端维护状态。在实际生产环境中采用何种方 式,这里就仁者见仁智者见智了,不过强烈建议读者使用异步confirm的方式。

消费端要点介绍

消费者客户端可以通过推模式或者拉模式的方 式来获取并消费消息,当消费者处理完业务逻辑需要手动确认消息已被接收,这样RabbitMQ 才能把当前消息从队列中标记清除。当然如果消费者由于某些原因无法处理当前接收到的消息, 可以通过channel.basicNack或者channel.basicReject来拒绝掉。

这里对于RabbitMQ消费端来说,还有几点需要注意:

消息分发;

令消息顺序性;

弃用QueueingConsumer。

消息分发

当RabbitMQ队列拥有多个消费者时,队列收到的消息将以轮询(round-robin)的分发方式 发送给消费者。每条消息只会发送给订阅列表里的一个消费者。这种方式非常适合扩展,而且 它是专门为并发程序设计的。如果现在负载加重,那么只需要创建更多的消费者来消费处理消 息即可。

很多时候轮询的分发机制也不是那么优雅。默认情况下,如果有n个消费者,那么RabbitMQ 会将第m条消息分发给第m%n (取余的方式)个消费者,RabbitMQ不管消费者是否消费并已 经确认(Basic.Ack)了消息。试想一下,如果某些消费者任务繁重,来不及消费那么多的消 息,而某些其他消费者由于某些原因(比如业务逻辑简单、机器性能卓越等)很快地处理完了 所分配到的消息,进而进程空闲,这样就会造成整体应用吞吐量的下降。

那么该如何处理这种情况呢?这里就要用到channel.basicQos(int prefetchCount) 这个方法,如前面章节所述,channel.basicQos方法允许限制信道上的消费者所能保持的最大 未确认消息的数量

举例说明,在订阅消费队列之前,消费端程序调用了channel.basicQos(5), 之后订 阅了某个队列进行消费。RabbitMQ会保存一个消费者的列表,每发送一条消息都会为对应的消 费者计数,如果达到了所设定的上限,那么RabbitMQ就不会向这个消费者再发送任何消息。 直到消费者确认了某条消息之后,RabbitMQ将相应的计数减1, 之后消费者可以继续接收消息, 直到再次到达计数上限。这种机制可以类比于TCP/IP中的“滑动窗口”。

注意要点:

Basic.Qos的使用对于拉模式的消费方式无效。 channel.basicQos有三种类型的重载方法:

(1) void basicQos(int prefetchCount) throws IOException;

(2) void basicQos(int prefetchCount, boolean global) throws IOException;

(3) void basicQos(intprefetchSize, int prefetchCount, boolean global) throws IOException;

前面介绍的都只用到了prefetchCount这个参数,当prefetchCount设置为0则表示 没有上限。还有prefetchSIze这个参数表示消费者所能接收未确认消息的总体大小的上限, 单位为B, 设置为0则表示没有上限。

对于一个信道来说,它可以同时消费多个队列,当设置了prefetchCount大于0时,这个 信道需要和各个队列协调以确保发送的消息都没有超过所限定的prefetchCount的值,这样会 使RabbitMQ的性能降低尤其是这些队列分散在集群中的多个Broker节点之中。RabbitMQ为了 提升相关的性能,在AMQP0-9-1协议之上重新定义了global这个参数,对比如表所示。

前面章节中的channel.basicQos方法的示例都是针对单个消费者的,而对于同一个 信道上的多个消费者而言,如果设置了prefetchCount的值,那么都会生效。代码清单示例中有两个消费者,各自的能接收到的未确认消息的上限都为10。

如果在订阅消息之前,既设置了global为true的限制,又设置了global为false的限 制,那么哪个会生效呢? RabbitMQ会确保两者都会生效。举例说明,当前有两个队列queue1 和queue2: queue1有10条消息,分别为1到10; queue2也有10条消息,分别为11到20。有 两个消费者分别消费这两个队列,如代码清单所示。

那么这里每个消费者最多只能收到3个未确认的消息,两个消费者能收到的未确认的消息 个数之和的上限为5。在未确认消息的情况下,如果consumer 1接收到了消息l、2和3, 那么 consumer2至多只能收到11和12。如果像这样同时使用两种global的模式,则会增加RahhitMQ 的负载,因为RahhitMQ需要更多的资源来协调完成这些限制。如无特殊需要,最好只使用 global为false的设置,这也是默认的设置。

 

消息顺序性

消息的顺序性是指消费者消费到的消息和发送者发布的消息的顺序是一致的。举个例子, 不考虑消息重复的情况,如果生产者发布的消息分别为msg1、msg2、msg3, 那么消费者必然 也是按照msg1、msg2、msg3的顺序进行消费的。

目前很多资料显示RabbitMQ的消息能够保障顺序性,这是不正确的,或者说这个观点有 很大的局限性。在不使用任何RabbitMQ的高级特性,也没有消息丢失、网络故障之类异常的 情况发生,并且只有一个消费者的情况下,最好也只有一个生产者的情况下可以保证消息的顺 序性。如果有多个生产者同时发送消息,无法确定消息到达Broker的前后顺序,也就无法验证 消息的顺序性。

那么哪些情况下RabbitMQ的消息顺序性会被打破呢?下面介绍几种常见的清形。

如果生产者使用了事务机制,在发送消息之后遇到异常进行了事务回滚,那么需要重新补 偿发送这条消息,如果补偿发送是在另一个线程实现的,那么消息在生产者这个源头就出现了 错序。

同样,如果启用publisher confirm时,在发生超时、中断,又或者是收到RabbitMQ的 Basic.Nack命令时,那么同样需要补偿发送,结果与事务机制一样会错序。或者这种说法有 些牵强,我们可以固执地认为消息的顺序性保障是从存入队列之后开始的,而不是在发送的时候开始的。

考虑另一种情形,如果生产者发送的消息设置了不同的超时时间,并且也设置了死信队列, 整体上来说相当于一个延迟队列,那么消费者在消费这个延迟队列的时候,消息的顺序必然不 会和生产者发送消息的顺序一致。

再考虑一种情形,如果消息设置了优先级,那么消费者消费到的消息也必然不是顺序性的。 如果一个队列按照前后顺序分有msg1、msg2、msg3、msg4这4个消息,同时有Consumer A 和ConsumerB这两个消费者同时订阅了这个队列。队列中的消息轮询分发到各个消费者之中, ConsumerA中的消息为msgl和msg3, ConsumerB中的消息为msg2、msg4。ConsumerA收到 消息msgl之后并不想处理而调用了Basic.Nack/.Reject将消息拒绝,与此同时将 requeue设置为true, 这样这条消息就可以重新存入队列中。消息msgl之后被发送到了 ConsumerB中,此时ConsumerB已经消费了msg2、msg4, 之后再消费msgl, 这样消息顺序性 也就错乱了。或者消息msgl又重新发往Consumer A中,此时ConsumerA已经消费了msg3, 那么再消费msgl, 消息顺序性也无法得到保障。同样可以用在Basic.Recover这个AMQP 命令中。

包括但不仅限于以上几种情形会使RabbitMQ消息错序。如果要保证消息的顺序性,需要 业务方使用RabbitMQ之后做进一步的处理,比如在消息体内添加全局有序标识(类似Sequence ID)来实现。

弃用Queueing Consumer

在前面的章节中所介绍的订阅消费的方式都是通过继承DefaultConsumer类来实现的。

提及了QueueingConsumer这个类,并且建议不要使用这个类来实现订阅消费。 QueueingConsumer在RabbitMQ客户端3.x版本中用得如火如荼,但是在4.x版本开始就被 标记为@Deprecated, 想必这个类中有些无法弥补的缺陷。

不妨先看一下QueueingConsumer的用法,示例代码如代码清单所示。

乍一看也没什么问题,而且实际生产环境中如果不是太“傲娇“地使用也不会造成什么大 问题。QueueingConsumer本身有几个大缺陷,需要读者在使用时特别注意。首当其冲的就 是内存溢出的问题,如果由于某些原因,队列之中堆积了比较多的消息,就可能导致消费者客 户端内存溢出假死,于是发生恶性循环,队列消息不断堆积而得不到消化。

采用代码清单中的代码进行演示,首先向一个队列发送200多MB的消息,然后进行 消费。在客户端调用channel.basicConsume方法订阅队列的时候,RabbitMQ会待续地将 消息发往QueueingConsumer中,QueueingConsumer内部使用LinkedBlockingQueue 来缓存这些消息。通过Nisua!VM可以看到堆内存的变化,如图所示。

由图可以看到堆内存一直在增加,这里只测试了发送200MB左右的消息,如果发送更 多的消息,那么这个堆内存会变得更大,直到出现java.lang.OutOfMernoryError的报错。

这个内存溢出的问题可以使用Basic.Qos来得到有效的解决,Basic.Qos可以限制某个 消费者所保待未确认消息的数量,也就是间接地限制了QueueingConsurner中的LinkedBlockingQueue的大小。注意一定要在调用Basic.Consume之前调用Basic.Qos 才能生效。

QueueingConsumer还包含(但不仅限于)以下一些缺陷:

QueueingConsumer会拖累同一个Connection下的所有信道,使其性能降低;

同步递归调用QueueingConsumer会产生死锁;

RabbitMQ的自动连接恢复机制(automatic connection recovery)不支持Queueing Consumer的这种形式;

QueueingConsumer不是事件驱动的。

为了避免不必要的麻烦,建议在消费的时候尽量使用继承DefaultConsumer的方式。

消息传输保障

消息可靠传输一般是业务系统接入消息中间件时首要考虑的问题,一般消息中间件的消息

传输保障分为三个层级。

At most once: 最多一次。消息可能会丢失,但绝不会重复传输。

At least once: 最少一次。消息绝不会丢失,但可能会重复传输。

Exactly once: 恰好一次。每条消息肯定会被传输一次且仅传输一次。

RabbitMQ支持其中的“最多一次“和“最少一次“。

其中“最少一次“投递实现需要考虑 以下这个几个方面的内容:

(1)消息生产者需要开HY启事务机制或者publisher confirm机制,以确保消息可以可靠地传 输到RabbitMQ中。

(2)消息生产者需要配合使用mandatory参数或者备份交换器来确保消息能够从交换器 路由到队列中,进而能够保存下来而不会被丢弃。

(3)消息和队列都需要进行持久化处理,以确保RabbitMQ服务器在遇到异常情况时不会成消息丢失。

(4)消费者在消费消息的同时需要将autoAck设置为false, 然后通过手动确认的方式去 确认已经正确消费的消息,以避免在消费端引起不必要的消息丢失。

“最多一次"的方式就无须考虑以上那些方面,生产者随意发送,消费者随意消费,不过这 样很难确保消息不会丢失。

“恰好一次“是RabbitMQ目前无法保障的。

考虑这样一种情况,消费者在消费完一条消息 之后向RabbitMQ发送确认Basic.Ack命令,此时由于网络断开或者其他原因造成RabbitMQ 并没有收到这个确认命令,那么RabbitMQ不会将此条消息标记删除。在重新建立连接之后, 消费者还是会消费到这一条消息,这就造成了重复消费。

再考虑一种情况,生产者在使用 publisher confirm机制的时候,发送完一条消息等待RabbitMQ返回确认通知,此时网络断开, 生产者捕获到异常情况,为了确保消息可靠性选择重新发送,这样RabbitMQ中就有两条同样 的消息,在消费的时候,消费者就会重复消费。

那么RabbitMQ有没有去重的机制来保证“恰好一次”呢?答案是并没有,不仅是RabbitMQ, 目前大多数主流的消息中间件都没有消息去重机制,也不保障"恰好一次“。去重处理一般是在 业务客户端实现,比如引入GUID (Globally Unique Identifier)的概念。针对GUID, 如果从客 户端的角度去重,那么需要引入集中式缓存,必然会增加依赖复杂度,另外缓存的大小也难以 界定。建议在实际生产环境中,业务方根据自身的业务特性进行去重,比如业务消息本身具备 幕等性,或者借助Redis等其他产品进行去重处理。

发布了524 篇原创文章 · 获赞 80 · 访问量 15万+

猜你喜欢

转载自blog.csdn.net/xushiyu1996818/article/details/104405880