Loading

官方文档 消费确认和发布确认

Consumer Acknowledgements and Publisher Confirms

基础

使用如RabbitMQ这样的消息代理的系统在定义上是分布式的。因此,发送一个协议方法(消息)时无法保证它到达对端或者被它成功的处理。无论是发布者还是消费者都需要一个机制来传送以及处理确认。RabbitMQ所支持的多种消息协议都提供了这种特性,这篇文章介绍了AMQP 0-9-1中的特性,但是这些概念在其它被支持的协议中也大致相同。

在消息协议(AMQP)中,消费者传输处理确认到RabbitMQ的过程被称作acknowledgements;而broker到发布者的确认则是一个被称作publisher confirms的协议扩展。这些特性的理念是相同的,并且都受到了TCP的启发。

无论是对于发布者到RabbitMQ节点还是从RabbitMQ节点到消费者,这些特性都是提供可靠传输的关键,换句话说,它们是数据安全的关键。(使用RabbitMQ的)应用对于数据安全的责任就像RabbitMQ节点一样。

消费者传送确认

当RabbitMQ传送一条消息到消费者,它需要知道,何时才应该认为消息已经被成功的发送了。而什么样的逻辑才是最佳的取决于你的系统,因此,这主要是由应用来做的决定。在AMQP 0-9-1中,当一个消费者使用basic.consume方法进行注册或者一个消息被使用basic.get方法来即时地获取时,这个(采取什么样的确认逻辑的)决定才被做下。

传送标识符:delivery-tags

在我们开始讨论其它话题之前,解释清楚交付的数据是如何被确认的(以及ack如何表明它们所对应的交付数据)是非常重要的。当一个消费者(订阅者)被注册,消息将通过basic.deliver方法被RabbitMQ传送(推送)。这个方法携带一个delivery-tag,它是在一个通道中的交付数据的唯一标识符。因此,delivery-tag的范围是每个通道。

delivery-tag是单调递增的正整数,并且它们在客户端中也以这样的形式表示。确认交互数据的客户端库方法携带一个delivery-tag作为一个参数。

因为delvery-tag的作用域是通道,所以交付数据必须在接收到它们的那个通道被确认。在一个不同的通道上确认将得到一个unknown delivery tag协议异常,并关闭通道。

客户端确认模式以及数据安全的考量

当一个节点传送一条消息到一个消费者,它必须决定是否考虑消费者有没有处理完成这个消息(或至少是接收了)。因为这其中的许多东西都可能失败(比如客户端连接、消费者程序等),这个决定是一种数据安全问题。消息协议通常提供一个确认机制来允许消费者向其连接的节点确认数据已经被交付。而这些机制是否被使用,则是消费者订阅时被决定的。

根据使用的确认模式(手动确认和自动确认),RabbitMQ可以在一个消息被发送出去后(被写入一个TCP套接字)立即认为消息被成功传送,或是是当一个明确的手动客户端确认被接受时才认为成功。使用如下的协议方法,可以手动发送肯定确认或者是否定确认:

  1. basic.ack用于发送肯定确认
  2. basic.nack用于发送否定确认(注意:这是一个RabbitMQ对AMQP0-9-1的扩展)
  3. basic.reject用于发送否定确认,但是与basic.nack相比有一个局限性

这些方法被暴露在我们接下来要讨论的客户端库API中。

肯定确认引导RabbitMQ去记录一个消息已经被正确传送,它已经可以被MQ丢弃了。使用basic.reject的否定确认也有相同的效果。它们的差异主要体现在语义上:肯定确认假设一个消息已经被正确的处理了,而相反,否定确认则代表一个消息并没有被处理,但仍然应该被删除。

在自动确认模式下,一个消息在它被发送后立即被认为是成功的。这个模式通过降低交付数据和消费者处理的安全性来换取更高的吞吐量(只要消费者能跟上消息发送的速度)。这种模式经常被称为"fire-and-forget"(即发即忘)。不同于手动确认模式,如果消费者的TCP连接或通道在(消息)成功传送前被关闭,那么服务器发送的消息就会丢失。因此,自动消息确认应该被认为是不安全的,并且并不适用于所有工作负载。

另一个在使用自动确认时需要被考虑的是消费者过载。手动确认模式通常与一个有限的通道预取数一起使用,这可以限制一个通道中尚未归还(正在处理中)的交付数据数量。而在定义中,自动确认模式中没有这种限制。消费者可能因此被传送的速率所压垮,积压的工作在内存中累积,然后堆内存用量超限或进程被系统终结。某些客户端库会应用TCP背压(TCP back pressure),即不再从socket读取直到未处理的交付数据的积压数量超过某个限制。因此,自动确认模式仅在客户端可以高效并以一个稳定的速率处理交付数据时被推荐使用。

肯定确认交付

用于传送确认的API方法在客户端库中通常被暴露为Channel上的一个操作。Java客户端用户可以使用Channel#basicAck以及Channel#basicNack来执行一个相应的basic.ackbasic.nack。这里是一个展示肯定确认的Java客户端示例:

// this example assumes an existing channel instance

boolean autoAck = false;
channel.basicConsume(queueName, autoAck, "a-consumer-tag",
     new DefaultConsumer(channel) {
         @Override
         public void handleDelivery(String consumerTag,
                                    Envelope envelope,
                                    AMQP.BasicProperties properties,
                                    byte[] body)
             throws IOException
         {
             long deliveryTag = envelope.getDeliveryTag();
             // positively acknowledge a single delivery, the message will
             // be discarded
             channel.basicAck(deliveryTag, false);
         }
     });

一次性确认多个交付

手动确认可以是批量的,这有助于降低网络流量。可以通过设置确认方法的multiple属性为true。注意,basic.reject由于历史原因并没有这个属性,所以这也是为何basic.nack被RabbitMQ作为一个协议扩展来引入。

否定确认以及重排队交付

有些时候,某个消费者可能不能立即处理一个交付,但是其它的实例也许可以。在这种情况下,我们希望让这个交付重新排队,并且让另一个消费者接收到并处理它。basic.rejectbasic.nack是两个用于实现这个目的的协议方法。

这些方法通常被用于否定确认一个交付。这样的交付应该被丢弃或死信化(dead-lettered)或者重新返回broker中排队。这个行为通过requeue属性控制,当这个属性设为true,broker将用指定的delivery-tag来重排队这个交付(或者多个交付,我们稍后会解释)。相反地,如果这个属性设置成false,消息将被路由到死信交换机,如果它被配置了的话,否则它就将被丢弃。

这两个方法通常在客户端库中作为Channel的操作被暴露出来,Java客户端用户将使用Channel#basicRejectChannel#basicNack来执行一个相应的basic.rejectbasic.nack

boolean autoAck = false;
channel.basicConsume("test-message", autoAck, "a-consumer-tag", new DefaultConsumer(channel) {
    @Override
    public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
        long deliveryTag = envelope.getDeliveryTag();
        
        boolean multiple = false, requeue = true;
        channel.basicNack(deliveryTag, multiple, requeue);
        // channel.basicReject(deliveryTag, requeue);
    }
});

——以下是翻译者的私货——

我们开启一个刚刚的肯定确认的测试代码,再开启一个否定确认的测试代码,来模拟上面说的某个消费者无法成功处理交付的情况。然后在队列中,我们发布一条消息

img

上面是返回肯定确认的消费者,第一条消息发给了它,delivery-tag为1,而第二条消息实际上发给了返回否定确认的消费者,但由于该消息被requeue了,所以它又被发送给肯定的消费者来处理,此时对于它的通道来说,delivery-tag为2。而我们打开否定消费者的控制台输出,可以看到这个消息对于它所在的通道的delivery-tag为1,这也证明了delivery-tag是和通道相关的。

img

考虑当肯定确认的消费者关闭后再发布消息会发生什么情况。消息不断地被否定确认的消费者否定并requeue,之后再发布给它(因为当前没有其它的消费者了),这样,该消息在MQ和它之间不断无谓的传输,白白耗费网络资源。

——以下是原文——

当一个消息重新入队(requeue)了,它将尽可能的被放置到它的队列中它原始的位置。如果没有(这是由于多个消费者共享一个队列时的并发交付及其它消费者的确认导致的),消息就被放置到最接近队头的位置。

重新入队的消息将会立即准备被重传(redelivery),这依赖于它们在队列中的位置以及当前活动消费者的通道的预取值。这意味着如果所有的消费者都由于一个瞬间的情况而无法处理一个交付时,会出现一个重新入队/重新交付的循环。就网络带宽和CPU资源来说,这种循环是非常昂贵的。消费者实现可以很好的跟踪重新交付次数并且reject消息(丢弃它们)或在一个延时后执行重新入队。

使用basic.nack在一次reject或requeue多条消息是可能的。这是它与basic.reject的不同。它接收一个额外的参数,multiple,下面是一个Java客户端的示例:

// this example assumes an existing channel instance

boolean autoAck = false;
channel.basicConsume(queueName, autoAck, "a-consumer-tag",
     new DefaultConsumer(channel) {
         @Override
         public void handleDelivery(String consumerTag,
                                    Envelope envelope,
                                    AMQP.BasicProperties properties,
                                    byte[] body)
             throws IOException
         {
             long deliveryTag = envelope.getDeliveryTag();
             // requeue all unacknowledged deliveries up to
             // this delivery tag
             channel.basicNack(deliveryTag, true, true);
         }
     });

通道预取设置(QoS)

由于消息是异步被发送(push)到客户端的,所以在任意给定时间,一个通道中通常由多于一个正在传送(in flight)的消息。此外,来自客户端的手动确认也天生的具有异步性。所以,我们有一个未确认消息的滑动窗口。开发者通常希望限制这个窗口的大小以避免客户端的无界缓冲问题。可以通过basic.qos方法来设置一个预取数量值来达到这个目的。这个值定义了一个通道允许的最大的未确认交付数。一旦到达了所配置的数量,RabbitMQ将停止在通道上传送更多的消息除非至少一个尚未确认的消息被确认。(值0被认为是无穷,即允许任意数量的未确认消息。)

举例来说,现在有这样几个delivery tag,分别是5, 6, 7和8,它们在通道Ch上处于未确认状态,而且Ch的预取值是4。RabbitMQ将不再推送任何更多的交付到Ch,除非至少有一个未完成的交付被确认。当一个确认帧在这个通道上到达,并且它的delivery_tag被设置成5(或678),RabbitMQ将注意到并且传送一个或更多个消息。一次性确认多个消息将让多于一个的消息可以被用于传送。

值得重申的一点是,交付的流程以及客户端的手动确认是完全异步的,因此,如果预取值在它们正在传送过程中时被更改,一个自然的竞态条件就产生了,可能会出现临时的,多余预取值数量的未确认消息在通道中的情况。

Per-channel, Per-consumer以及全局预取

QoS设置可以被配置在一个特定的通道或者一个特定的消费者上。消费者预取指南解释了这个作用域的效果。

预取和主动拉取的消费者(Polling Consumer)

使用basic.get(Pull API)进行消息拉取时,QoS预取设置没有效果,即使在手动确认模式中。

消费者确认模式、预取以及吞吐量

确认模式以及QoS预取值对于客户端吞吐量有重大影响。通常来说,增加预取值有助于提升消息传送到客户端的速率,自动确认模式有着最好的交付速度。不过,在这两种情况下(增加预取值和自动确认模式),已递交但尚未处理完成的消息的数量都会增加,这也导致了消费者的内存消耗。

你应该小心的使用自动确认模式或无预取数量限制的手动确认模式。消费了大量消息但没有确认的消费者也将会导致它们连接到的节点的内存消耗上升。找到一个合适的预取值是一个反复试验的过程,并且根据不同的工作负载有所变化。100到300之间的值通常有助于优化吞吐量而且通常没有显著的会压倒客户端的风险,更高的值往往会陷入收益递减的规律中。

预取值设置成1时是最保守的,这将会在客户端连接延迟较高的环境中显著的降低吞吐量。对于大多数应用来说,更高的值是合适且更优的。

当消费者失败或丢失连接:自动回队

当发生交付的通道(或连接)被关闭时,如果此时使用的是手动确认,任何未确认的交付(消息)都将自动的回队(requeue)。这其中包含了TCP连接被客户端丢失,消费者应用崩溃以及通道级别的协议异常(下面会介绍)

注意,检测到一个不可用的客户端需要一段时间。

由于这个行为,客户端必须准备好处理重新递交的消息,要不就使用幂等的实现。重新递交的消息将有一个特殊的布尔属性,redeliver,它被RabbitMQ设置为true,而第一次被递交的消息则会被设为false。再次注意,一个消费者可以接收到一个先前被递送到另一个消费者的消息。

客户端错误:两次Ack以及未知Tag

当一个客户端多次确认一个相同的delivery-tag时,RabbitMQ将返回一个通道错误,如PRECONDITION_FILED - unknown delivery tag 100。如果一个位置的delivery-tag被使用,同样的通道异常将会被抛出。

broker将报告"unknown delivery tag"的另一种情况是,一个确认——不管是肯定或否定——被尝试发给一个与接收到它的通道不同的通道。交付必须被在相同的通道中被确认。

发布者确认

网络可能以一种并不明显的方式出错,并且检测这种错误需要一些时间。因此一个向它的socket写入一个或一系列协议帧(比如一个被发布的消息)的客户端不能假设消息已经到达了服务器并且被成功的处理。它可能在路上丢失了或者它的交付可能有很大的延迟。

使用标准的AMQP0-9-1协议,唯一能够保证消息不丢失的办法就是使用事务 —— 让通道事务化,然后对于每一条或多条消息的发布进行提交。在这个场景中,使用事务显得没有必要,且太过笨重,并且会降低吞吐量250倍(不会翻译,这里写的是decrease throughput by a factory of 250)。为了补救,一种确认机制被引入了。它模仿协议里已经存在的消费者确认机制。

想要启用确认,客户端必须发送confirm.select方法。根据no-wait参数的设定与否,broker将返回一个confirm.select-ok。一旦confirm.select方法在一个通道上被使用,它就进入了确认模式。一个事务通道不可以开启确认模式,一旦通道处于确认模式,它就不能被事务化。

一旦一个通道处于确认模式,broker和客户端就都开始对消息进行计数(在首个confirm.select时从1开始)。然后,broker一处理消息就确认它们,它通过在同样的通道上发送basic.ack来确认,delivery-tag属性包含了被确认消息的序号。同样,这个broker也可能设置basic.ackmultiple属性去指定之前的所有消息,包括具有这个序号的一个已经被处理了。

发布者的否定确认

在broker不能成功处理消息的例外情况下,broker将使用basic.nack来代替basic.ack。在这个上下文中,basic.nackbasic.ack中对应的属性具有相同的含义,并且requeue属性应该被忽略。通过对一个或多个消息发送nack,broker说明了它无法处理这些消息并且拒绝承担责任。这时,客户端可以选择重新发布这些消息。

在通道处于confirm模式时,所有后续被发布的消息都会被确认(basic.ack)或nack一次,不保证一个消息被确认的速度,不会有消息被确认并且被nack。

basic.nack只会在负责一个队列的Erlang进程发生一个内部错误时被传送。

已发布消息何时被broker确认

对于无法路由的消息,一旦路由器发现一个消息无法路由到任何队列(返回一个空的队列列表)broker将提交一次确认。如果消息设置了mandatorybasic.return会在basic.ack前发送到客户端。(这其中的顺序)对于否定确认(basic.nack)也是一样的。

对于可路由的消息,basic.ack将在一个消息已经被所有队列接收的情况下被发送到客户端。对于路由到持久队列的persistent消息,意味着要将它持久化到硬盘(basic.ack才会被发送);对于quorum队列,这意味着一个quorum副本已经接收并且对当前leader确认了这条消息(basic.ack才会被发送)。

持久化消息的Ack延迟

一个被路由到durable队列的持久化消息的basic.ack将在它被持久化到磁盘之后被发送。RabbitMQ在一个间隔后或在队列处于空闲(idle)状态时批量的将消息持久到磁盘(几百毫秒),这样做的目的是最小化fsync(2)调用的次数。

这意味着在恒定的负载下,basic.ack的延迟会在几百毫秒后到达。为了改进吞吐量,应用程序被强烈建议异步的(as a stream)处理确认或者批量发布消息并且等待未完成的确认。实际API在客户端库间有所不同。

发布者确认的顺序考量

在大部分情况下,RabbitMQ将以与发布顺序相同的顺序来确认消息(对于在单个通道上发布的消息)。然而,发布者确认是被异步提交的并且可以确认一个单独的消息或一组消息。确认被发送的确切时刻依赖于消息的传送模式(persistent vs. transient)以及消息被路由到的队列的属性(见上文)。这表明不同的消息需要被认为会在不同的事件被确认,也就意味着消息的确认可能会以与确认的消息不同的顺序到达(我猜是序号为2的消息比序号为1的先到)。应用应该尽可能的不依赖ack到达的顺序。

发布者确认以及交付保证

如果一个RabbitMQ节点在持久化消息已经被保存到磁盘之前出错,消息可能丢失。举例来说,考虑下面的场景:

  1. 一个客户端发布一个持久化消息到一个durable队列
  2. 一个客户端从队列中消费这个消息(注意消息和队列都是持久的),但是(消费者)确认没有打开
  3. broker节点出错并重启
  4. 客户端重连并开始消费消息

此时,客户端将合理的假设消息将会被再次传递。可事实是:重启已经导致了broker丢失了消息。为了保证持久性,一个客户端应该使用confirms。如果发布者通道已经处于确认模式,发布者将不会收到丢失消息的ack(因为消息尚未写入到磁盘中)

没看懂...

限制

最大delivery tag

Delivery tag是一个64位的长整型值,因此,它的最大值是9223372036854775807。因此delivery tag被限定在通道作用域内,在实践中,发布者和消费者一般不会运行到超过这个值。

posted @ 2022-09-05 14:44  yudoge  阅读(87)  评论(0编辑  收藏  举报