RabbitMQ釋出訂閱實戰-實現延時重試佇列

管宜堯發表於2018-05-14

RabbitMQ是一款使用Erlang開發的開源訊息佇列。本文假設讀者對RabbitMQ是什麼已經有了基本的瞭解,如果你還不知道它是什麼以及可以用來做什麼,建議先從官網的 RabbitMQ Tutorials 入門教程開始學習。

本文將會講解如何使用RabbitMQ實現延時重試和失敗訊息佇列,實現可靠的訊息消費,消費失敗後,自動延時將訊息重新投遞,當達到一定的重試次數後,將訊息投遞到失敗訊息佇列,等待人工介入處理。在這裡我會帶領大家一步一步的實現一個帶有失敗重試功能的釋出訂閱元件,使用該元件後可以非常簡單的實現訊息的釋出訂閱,在進行業務開發的時候,業務開發人員可以將主要精力放在業務邏輯實現上,而不需要花費時間去理解RabbitMQ的一些複雜概念。

本文將會持續修正和更新,最新內容請參考我的 GITHUB 上的 程式猿成長計劃 專案,歡迎 Star,更多精彩內容請 follow me

概要

我們將會實現如下功能

  • 結合RabbitMQ的Topic模式和Work Queue模式實現生產方產生訊息,消費方按需訂閱,訊息投遞到消費方的佇列之後,多個worker同時對訊息進行消費
  • 結合RabbitMQ的 Message TTLDead Letter Exchange 實現訊息的延時重試功能
  • 訊息達到最大重試次數之後,將其投遞到失敗佇列,等待人工介入處理bug後,重新將其加入佇列消費

具體流程見下圖

xxx

  1. 生產者釋出訊息到主Exchange
  2. 主Exchange根據Routing Key將訊息分發到對應的訊息佇列
  3. 多個消費者的worker程式同時對佇列中的訊息進行消費,因此它們之間採用“競爭”的方式來爭取訊息的消費
  4. 訊息消費後,不管成功失敗,都要返回ACK消費確認訊息給佇列,避免訊息消費確認機制導致重複投遞,同時,如果訊息處理成功,則結束流程,否則進入重試階段
  5. 如果重試次數小於設定的最大重試次數(3次),則將訊息重新投遞到Retry Exchange的重試佇列
  6. 重試佇列不需要消費者直接訂閱,它會等待訊息的有效時間過期之後,重新將訊息投遞給Dead Letter Exchange,我們在這裡將其設定為主Exchange,實現延時後重新投遞訊息,這樣消費者就可以重新消費訊息
  7. 如果三次以上都是消費失敗,則認為訊息無法被處理,直接將訊息投遞給Failed Exchange的Failed Queue,這時候應用可以觸發報警機制,以通知相關責任人處理
  8. 等待人工介入處理(解決bug)之後,重新將訊息投遞到主Exchange,這樣就可以重新消費了

技術實現

Linus Torvalds 曾經說過

Talk is cheap. Show me the code

我分別用Java和PHP實現了本文所講述的方案,讀者可以通過參考程式碼以及本文中的基本步驟來更好的理解

建立Exchange

為了實現訊息的延時重試和失敗儲存,我們需要建立三個Exchange來處理訊息。

  • master 主Exchange,釋出訊息時釋出到該Exchange
  • master.retry 重試Exchange,訊息處理失敗時(3次以內),將訊息重新投遞給該Exchange
  • master.failed 失敗Exchange,超過三次重試失敗後,訊息投遞到該Exchange

所有的Exchange宣告(declare)必須使用以下引數

引數 說明
exchange - Exchange名稱
type topic Exchange 型別
passive false 如果Exchange已經存在,則返回成功,不存在則建立
durable true 持久化儲存Exchange,這裡僅僅是Exchange本身持久化,訊息和佇列需要單獨指定其持久化
no-wait false 該方法需要應答確認

Java程式碼

// 宣告Exchange:主體,失敗,重試
channel.exchangeDeclare("master", "topic", true);
channel.exchangeDeclare("master.retry", "topic", true);
channel.exchangeDeclare("master.failed", "topic", true);
複製程式碼

PHP程式碼

// 普通交換機
$this->channel->exchange_declare('master', 'topic', false, true, false);
// 重試交換機
$this->channel->exchange_declare('master.retry', 'topic', false, true, false);
// 失敗交換機
$this->channel->exchange_declare('master.failed', 'topic', false, true, false);
複製程式碼

在RabbitMQ的管理介面中,我們可以看到建立的三個Exchange

-w539

訊息釋出

訊息釋出時,使用basic_publish方法,引數如下

引數 說明
message - 釋出的訊息物件
exchange master 訊息釋出到的Exchange
routing-key - 路由KEY,用於標識訊息型別
mandatory false 是否強制路由,指定了該選項後,如果沒有訂閱該訊息,則會返回路由不可達錯誤
immediate false 指定了當訊息無法直接路由給消費者時如何處理

釋出訊息時,對於message物件,其內容建議使用json編碼後的字串,同時訊息需要標識以下屬性

'delivery_mode'=> 2 // 1為非持久化,2為持久化
複製程式碼

Java程式碼

channel.basicPublish(
    "master", 
    routingKey, 
    MessageProperties.PERSISTENT_BASIC, // delivery_mode
    message.getBytes()
);
複製程式碼

PHP程式碼

$msg = new AMQPMessage($message->serialize(), [
    'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
]);

$this->channel->basic_publish($msg, 'master', $routingKey);
複製程式碼

訊息訂閱

訊息訂閱的實現相對複雜一些,需要完成佇列的宣告以及佇列和Exchange的繫結。

Declare Queue

對於每一個訂閱訊息的服務,都必須建立一個該服務對應的佇列,將該佇列繫結到關注的路由規則,這樣之後,訊息生產者將訊息投遞給Exchange之後,就會按照路由規則將訊息分發到對應的佇列供消費者消費了。

消費服務需要declare三個佇列

  • [queue_name] 佇列名稱,格式符合 [服務名稱]@訂閱服務標識
  • [queue_name]@retry 重試佇列
  • [queue_name]@failed 失敗佇列

訂閱服務標識是客戶端自己對訂閱的分類識別符號,比如使用者中心服務(服務名稱ucenter),包含兩個訂閱:user和enterprise,這裡兩個訂閱的佇列名稱就為 ucenter@userucenter@enterprise,其對應的重試佇列為 ucenter@user@retryucenter@enterprise@retry

Declare佇列時,引數規定規則如下

引數 說明
queue - 佇列名稱
passive false 佇列不存在則建立,存在則直接成功
durable true 佇列持久化
exclusive false 排他,指定該選項為true則佇列只對當前連線有效,連線斷開後自動刪除
no-wait false 該方法需要應答確認
auto-delete false 當不再使用時,是否自動刪除

對於@retry重試佇列,需要指定額外引數

'x-dead-letter-exchange' => 'master'
'x-message-ttl'          => 30 * 1000 // 重試時間設定為30s
複製程式碼

這裡的兩個header欄位的含義是,在佇列中延遲30s後,將該訊息重新投遞到x-dead-letter-exchange對應的Exchange中

Java程式碼

// 宣告監聽佇列
channel.queueDeclare(
    queueName, // 佇列名稱
    true,      // durable
    false,     // exclusive
    false,     // autoDelete
    null       // arguments
);
channel.queueDeclare(queueName + "@failed", true, false, false, null);

Map<String, Object> arguments = new HashMap<String, Object>();
arguments.put("x-dead-letter-exchange", exchangeName());
arguments.put("x-message-ttl", 30 * 1000);
channel.queueDeclare(queueName + "@retry", true, false, false, arguments);
複製程式碼

PHP程式碼

$this->channel->queue_declare($queueName, false, true, false, false, false);
$this->channel->queue_declare($failedQueueName, false, true, false, false, false);
$this->channel->queue_declare(
    $retryQueueName, // 佇列名稱
    false,           // passive
    true,            // durable
    false,           // exclusive
    false,           // auto_delete
    false,           // nowait
    new AMQPTable([
        'x-dead-letter-exchange' => 'master',
        'x-message-ttl'          => 30 * 1000,
    ])
);
複製程式碼

在RabbitMQ的管理介面中,Queues部分可以看到我們建立的三個佇列

RabbitMQ釋出訂閱實戰-實現延時重試佇列

檢視佇列的詳細資訊,我們可以看到 queueName@retry 佇列與其它兩個佇列的不同

-w486

Bind Exchange & Queue

建立完佇列之後,需要將佇列與Exchange繫結(bind),不同佇列需要繫結到之前建立的對應的Exchange上面

Queue Exchange
[queue_name] master
[queue_name]@retry master.retry
[queue_name]@failed master.failed

繫結時,需要提供訂閱的路由KEY,該路由KEY與訊息釋出時的路由KEY對應,區別是這裡可以使用萬用字元同時訂閱多種型別的訊息。

引數 說明
queue - 繫結的佇列
exchange - 繫結的Exchange
routing-key - 訂閱的訊息路由規則
no-wait false 該方法需要應答確認

Java程式碼

// 繫結監聽佇列到Exchange
channel.queueBind(queueName, "master", routingKey);
channel.queueBind(queueName + "@failed", "master.failed", routingKey);
channel.queueBind(queueName + "@retry", "master.retry", routingKey);
複製程式碼

PHP程式碼

$this->channel->queue_bind($queueName, 'master', $routingKey);
$this->channel->queue_bind($retryQueueName, 'master.retry', $routingKey);
$this->channel->queue_bind($failedQueueName, 'master.failed', $routingKey);
複製程式碼

在RabbitMQ的管理介面中,我們可以看到該佇列與Exchange和routing-key的繫結關係

-w361

-w405

-w399

訊息消費實現

使用 basic_consume 對訊息進行消費的時候,需要注意下面引數

引數 說明
queue - 消費的佇列名稱
consumer-tag - 消費者標識,留空即可
no_local false 如果設定了該欄位,伺服器將不會發布訊息到 釋出它的客戶端
no_ack false 需要消費確認應答
exclusive false 排他訪問,設定後只允許當前消費者訪問該佇列
nowait false 該方法需要應答確認

消費端在消費訊息時,需要從訊息中獲取訊息被消費的次數,以此判斷該訊息處理失敗時重試還是傳送到失敗佇列。

Java程式碼

protected Long getRetryCount(AMQP.BasicProperties properties) {
	Long retryCount = 0L;
	try {
		Map<String, Object> headers = properties.getHeaders();
		if (headers != null) {
			if (headers.containsKey("x-death")) {
				List<Map<String, Object>> deaths = (List<Map<String, Object>>) headers.get("x-death");
				if (deaths.size() > 0) {
					Map<String, Object> death = deaths.get(0);
					retryCount = (Long) death.get("count");
				}
			}
		}
	} catch (Exception e) {}

	return retryCount;
}
複製程式碼

PHP程式碼

protected function getRetryCount(AMQPMessage $msg): int
{
	$retry = 0;
	if ($msg->has('application_headers')) {
		$headers = $msg->get('application_headers')->getNativeData();
		if (isset($headers['x-death'][0]['count'])) {
			$retry = $headers['x-death'][0]['count'];
		}
	}

	return (int)$retry;
}
複製程式碼

訊息消費完成後,需要傳送消費確認訊息給服務端,使用basic_ack方法

ack(delivery-tag=訊息的delivery-tag標識)
複製程式碼

Java程式碼

// 訊息消費處理
Consumer consumer = new DefaultConsumer(channel) {
    @Override
    public void handleDelivery(String consumerTag, Envelope envelope,
                               AMQP.BasicProperties properties, byte[] body) throws IOException {
        ...
        // 注意,由於使用了basicConsume的autoAck特性,因此這裡就不需要手動執行
        // channel.basicAck(envelope.getDeliveryTag(), false);
    }
};
// 執行訊息消費處理
channel.basicConsume(
    queueName, 
    true, // autoAck
    consumer
);
複製程式碼

PHP程式碼

$this->channel->basic_consume(
    $queueName,
    '',    // customer_tag
    false, // no_local
    false, // no_ack
    false, // exclusive
    false, // nowait
    function (AMQPMessage $msg) use ($queueName, $routingKey, $callback) {
        ...
        $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']);
    }
);
複製程式碼

如果訊息處理中出現異常,應該將該訊息重新投遞到重試Exchange,等待下次重試

basic_publish(msg, 'master.retry', routing-key)
ack(delivery-tag) // 不要忘記了應答消費成功訊息
複製程式碼

如果判斷重試次數大於3次,仍然處理失敗,則應該講訊息投遞到失敗Exchange,等待人工處理

basic_publish(msg, 'master.failed', routing-key)
ack(delivery-tag) // 不要忘記了應答消費成功訊息
複製程式碼

一定不要忘記ack訊息,因為重試、失敗都是通過將訊息重新投遞到重試、失敗Exchange來實現的,如果忘記ack,則該訊息在超時或者連線斷開後,會重新被重新投遞給消費者,如果消費者依舊無法處理,則會造成死迴圈。

Java程式碼

try {
    String message = new String(body, "UTF-8");
    // 訊息處理函式
    handler.handle(message, envelope.getRoutingKey());

} catch (Exception e) {
    long retryCount = getRetryCount(properties);
    if (retryCount > 3) {
        // 重試次數大於3次,則自動加入到失敗佇列
        channel.basicPublish("master.failed", envelope.getRoutingKey(), MessageProperties.PERSISTENT_BASIC, body);
    } else {
        // 重試次數小於3,則加入到重試佇列,30s後再重試
        channel.basicPublish("master.retry", envelope.getRoutingKey(), properties, body);
    }
}
複製程式碼

失敗任務重試

如果任務重試三次仍未成功,則會被投遞到失敗佇列,這時候需要人工處理程式異常,處理完畢後,需要將訊息重新投遞到佇列進行處理,這裡唯一需要做的就是從失敗佇列訂閱訊息,然後獲取到訊息後,清空其application_headers頭資訊,然後重新投遞到master這個Exchange即可。

Java程式碼

channel.basicPublish(
    'master', 
    envelope.getRoutingKey(),
    MessageProperties.PERSISTENT_BASIC,
    body
);
複製程式碼

PHP程式碼

$msg->set('application_headers', new AMQPTable([]));
$this->channel->basic_publish(
    $msg,
    'master',
    $msg->get('routing_key')
);
複製程式碼

怎麼使用

佇列和Exchange以及釋出訂閱的關係我們就說完了,那麼使用起來是什麼效果呢?這裡我們以Java程式碼為例

// 釋出訊息
Publisher publisher = new Publisher(factory.newConnection(), 'master');
publisher.publish("{\"id\":121, \"name\":\"guanyiyao\"}", "user.create");

// 訂閱訊息
new Subscriber(factory.newConnection(), Main.EXCHANGE_NAME)
    .init("user-monitor", "user.*")
    .subscribe((message, routingKey) -> {
        // TODO 業務邏輯
        System.out.printf("    <%s> message consumed: %s\n", routingKey, message);
    }
);
複製程式碼

總結

使用RabbitMQ時,實現延時重試和失敗佇列的方式並不僅僅侷限於本文中描述的方法,如果讀者有更好的實現方案,歡迎拍磚,在這裡我也只是拋磚引玉了。本文中講述的方法還有很多優化空間,讀者也可以試著去改進其實現方案,比如本文中使用了三個Exchagne,是否只使用一個Exchange也能實現本文中所講述的功能。

本文將會持續修正和更新,最新內容請參考我的 GITHUB 上的 程式猿成長計劃 專案,歡迎 Star,更多精彩內容請 follow me

相關文章