用于rails的AMQP gem正在重新排列数百个成功处理的消息

时间:2016-02-18 00:05:18

标签: ruby-on-rails ruby rabbitmq amqp consumer

为什么,当我在队列(1200)上有很多消息时,即使我的代码成功处理了这些消息,我的消息也会被重新排队,并且#34; acks"他们?

我该如何解决这个问题?

...

我有一个使用rails amqp gem的应用程序来使用RabbitMQ。我们将消息放在队列中,其中包含有关需要发送的电子邮件的信息,订阅者将其关闭并发送。

有时会有数百条消息快速连续放入队列。

我们使用确认来确保邮件不会丢失。

直到最近,当我发现队列中有1200条消息并且它们没有被消耗时,它工作得非常好。

那么为什么我的消费者没有消费它们?

查看日志我发现是的,已经消耗了它们并且发送了电子邮件。我重新启动了消费者,它重新构建了它们,这意味着我们向用户发送了多个相同的电子邮件。哎呀!但是我通过观察RabbitMQ UI注意到的是,当我重新启动消费者时,它立即将所有1200条消息从队列中删除。然后几分钟后,这些消息被重新排队,即使我的消费者仍在浏览它们并发送电子邮件。在我们的代码中,消费者会在发送每封电子邮件(处理消息)后确认消息。

所以我最好的猜测是,当队列中有很多消息时,消费者将它们全部关闭,但不会单独响应每个消息,而是等待所有消息都被处理完之后再进行质量ack。由于需要很长时间,10分钟,RabbitMQ方面正在发生一些事情,他们说,嘿,这花了太长时间,让我们重新排列所有这些消息,即使我的消费者仍在成功处理它们。

我已经四处寻找并发现了一种叫做心跳的东西,但如果我需要使用它,我找不到任何关于这是什么以及如何使用它的明确解释。但听起来它可能与队列和消费者之间的通信有关,并且可能是在处理它们时不将所有这些消息重新排队的关键。

我尝试的另一件事是使用预取:1。描述here。虽然这似乎不合适,因为我只有一个消费者。但它听起来很有希望,因为它看起来好像会逐一确认消息。

我应该考虑多个消费者,因为我们可以快速连续地将数百条消息放在队列中吗?

这是我的rake任务,订阅队列

task :subscribe_basic => :environment do |task_name|
  begin # make sure any exception is logged
    log = Rails.logger
    routing_key = "send_letter"
    tcp_connection_settings =
        {:host=>"localhost",
         :port=>5672,
         :vhost=>"dev_vhost",
         :user=>"dev_user",
         :pass=>"abc123",
         :timeout=>0.3,
         :ssl=>false,
         :on_tcp_connection_loss=>
             handle_conn_loss,
         :logging=>true}

    begin
      ::AMQP.start(tcp_connection_settings) do |connection|
        channel = ::AMQP::Channel.new(connection, :prefetch => 1)
        binding.pry
        channel.auto_recovery = true
        cons = SendLetterConsumer.new channel, log

        queue = channel.queue(routing_key, exclusive: false, durable: true)

        consumer1 = AMQP::Consumer.new(channel, queue, nil, exclusive = false, no_ack = false)
        consumer1.consume.on_delivery(&cons.method(:handle_message))
        log.info "subscribed to queue #{routing_key}, config_key #{config_key} (#{Process.pid})"

        Signal.trap 'INT' do # kill -s INT <pid> , kill -2 <pid>,  Ctrl+C
          log.info "#{task_name} stopping(#{Process.pid})..."
          channel.close { EventMachine.stop } # otherwise segfault
        end
      end
    rescue StandardError => ex
      # 2015-03-20 02:52:49 UTC MQ raised EventMachine::ConnectionError: unable to resolve server address
      log.error "MQ raised #{ex.class.name}: #{ex.message} Backtrace: #{ex.backtrace}"
    end
  rescue Exception => ex
    log.error "#{ex.class.name}: #{ex.message} -- #{ex.backtrace.inspect}"
    raise ex
  end

end

以下是我们用于处理消息的消费者代码(在上面的代码中调用:consumer1.consume.on_delivery(&cons.method(:handle_message))):

def handle_message(metadata, payload)
  logger.info "*** SendLetterConsumer#handle_message start #{Time.now}"
  logger.info payload
  begin
    # {course_app: aCourseApplication, errors:[]}
    # {course_app: aFaultyCourseApplication, errors: ['error1', 'error2']}
    msg = JSON.parse(payload)
    ca = CourseApplication.find(msg['course_application_id'])
    am = AutomatedMessage.find(msg['automated_message_id'])
    user_name = msg['user_name']
    if am.present?
      raise "Cannot send a letter for Automated message with id #{am.id} because it does not have an associated message template" if am.message_template.nil?
      logger.info "attempt to send letter for Automated Message with id #{am.id}"
      result = LetterSender::send_letter a_course_application: ca, a_message_template: am.message_template, user_name: user_name
    elsif msg.message_template_id
      mt = MessageTemplate.find(msg.message_template_id)
      result = LetterSender::send_letter a_course_application: ca, a_message_template: mt, user_name: user_name
    end
    if result
      metadata.ack #'ack'-ing will remove the message from the queue - do this even if we created a faultyCourseApp
    else
      logger.error "Could not ack for #{msg}"
    end
  rescue StandardError => e
    logger.error "#{e.message} #{e.backtrace}"
    # do not 'ack' - must be a programming mistake so leave message on queue - keep connection open to cont processing other messages
    # fix bug and restart the rake task to redeliver the unacknowledged messages
  end
  logger.info "*** SendLetterConsumer#handle_message   end #{Time.now}"
end    

2 个答案:

答案 0 :(得分:0)

预取确实是答案,但是doc I linked to above regarding this说要通过使用:

进行配置

channel = AMQP::Channel.new(connection, :prefetch => 1)

但这根本不起作用。

我必须这样做

channel    = AMQP::Channel.new(connection)
channel.prefetch(1)

现在它可以工作,只调度一条消息并等待,直到调度下一条消息为止。

这个解决方案在rabbitmq教程中描述here,而不是amqp gem。

如果我只有一个带有预取的消费者,并且它无法收到消息,那么会发生什么?消息会开始堆积吗?

YES

因此,拥有2名消费者可能会有所帮助,但这两位消费者都可能会失败。

为了解决这个问题,我正在尝试拒绝并重新排队。所以在我的Consumer中,如果我没有点击我收到消息的代码部分,我使用metadata.reject(:requeue=>true),这会将消息放回队列的前面。是的,这是正确的,排队的“前线” - 无赖。这意味着消息仍会堆积,因为同一个失败的消息会不断地分发给一个消费者。

如上面的前一个链接所说:“当队列中只有一个消费者时,请确保不要通过一遍又一遍地拒绝和重新排队来自同一消费者的消息来创建无限的消息传递循环。”

为什么不重新排队将它放在队列的末尾?那不是更好吗?你仍然会得到循环消息,但至少新消息会被处理而不是堆积。

所以我尝试将预取设置为多个...两个。但同样的问题。一旦2条消息被拒绝并被重新排队,我的可怜的老消费者就会不断地将这些消息传递给它,而不是让那些没有被拒绝的消息让它有机会处理积压的消息。

多个消费者怎么样?同样的问题。如果出现问题,我有2个预取x消息的消费者和metadata.reject(requeue:true)个消息。现在,如果前面的2x消息在我的消费者中导致错误,那么我会遇到与备份消息的无限循环消息相同的问题。如果队列前面的消息总是少于2个,那么消费者会逐渐完成消息的积压。

似乎没有令人满意的解决方案。

理想情况下,我希望我的预取消费者(由于初始问题而需要预取)能够不收到他们无法正常使用的消息,而且还要移动到队列中的下一条消息。换句话说,将坏的保留在未确认的消息集合中,而不是将它们放回队列中。问题是,通过预取,我必须拒绝它们,否则一切都会停止,我必须重新排队,否则我会失去它们。

一种方法可能是:在我的消费者中,当重新传递的消息无法在代码中正确使用时,我会拒绝它,但不会使用metadata.reject()对其进行重新排队,并以某种方式将此消息报告给开发人员,或者将它保存在db中的失败消息表中,以便我们可以处理它。 (重新传送标志metadata.redelivered在“消费者”部分中查看here

如果rabbitmq提供了重新计算的数量,那将是非常好的 - 所以我可以通过更高的重新排列来切断,但它似乎没有这样做,它只提供了一个重新传送的标志。

答案 1 :(得分:0)

我的另一个答案说prefetch可以解决问题,但是引入了一个新问题,即使用prefetch必须拒绝并重新排队失败的消息,这会导致循环,因为reject(requeue:true)置放它只在队列的前面再次被消耗掉。多个消费者有所帮助,但你仍然可以进入循环。

因此,为了使用预取但在队列的后面放置失败的消息,我发现使用死信交换设置有效。请参阅this article,但它适用于C#,但您可以看到一般的想法。另请参阅有关Dead Letter Exchanges的RabbitMQ文档。

我一开始并没有理解它,所以这是我在这种情况下使用死信交换的简短说明:

RabbitMq不会执行延迟消息,因此我们的想法是使用重试队列并将使用中失败的消息发布到此重试队列中。反过来,这个重试队列会在一段时间后杀死它们,导致它们被放在主队列的末尾。

  1. 消费者试图消费消息。

  2. 出现问题,或者您发现错误,因此您不会确认消息(metadata.ack),而是metadata.reject(requeue:false)并发布到重试队列。

  3. 使用此重试队列的死信交换配置会发生以下情况:

    1. 消息位于重试队列中的时间段x(在参数“x-message-ttl”中创建重试队列时设置,见下文)然后RabbitMq将其杀死。

    2. 由于使用参数“x-dead-letter-exchange”和“x-dead-letter-routing-key”(见下文)在重试队列上配置了死信交换设置,此消息会自动返回到主队列的后面。

    3. 关于这一点的一个好处是重试队列甚至不需要任何消费者。

      以下是我在消费者中发布到重试队列的一些代码

      def publish_to_retry_queue(msg:, metadata:)
        @channel.queue("send_letter.retry", exclusive: false, persistent: true, durable: true,
                       arguments:{"x-dead-letter-exchange" => "dead_letter_exchange",
                                  "x-dead-letter-routing-key" => "send_letter",
                                  "x-message-ttl" => 25000})
        metadata.reject(requeue: false)
        res = @channel.default_exchange.publish(msg, routing_key: "send_letter.retry", headers: metadata.headers)
        @logger.info "result from publishing to retry queue is"
        @logger.info  res
        res
      end
      

      其中@channel是主队列中的使用者正在使用的通道。 注意这要求您已经在rabbitmq上设置了名为dead_letter_exchange的交换,并在其中添加了一个绑定到主队列,在这种情况下,它是send_letter队列。