RabbitMQ使用教程(三)如何保證訊息99.99%被髮送成功?

申城異鄉人發表於2019-05-29

1. 前情回顧

RabbitMQ使用教程(一)RabbitMQ環境安裝配置及Hello World示例

RabbitMQ使用教程(二)RabbitMQ使用者管理,角色管理及許可權設定

在以上兩篇部落格釋出後不久,有細心的網友就評論,建立的佇列和傳送的訊息,如果在沒有啟動消費者程式的時候,重啟了RabbitMQ服務,佇列和訊息都丟失了。

這就引出了一個非常重要的問題,也是面試中經常會問的:在使用RabbitMQ時,如何保證訊息最大程度的不丟失並且被正確消費?

2. 本篇概要

RabbitMQ針對這個問題,提供了以下幾個機制來解決:

  1. 生產者確認
  2. 持久化
  3. 手動Ack

本篇部落格我們先講解下生產者確認機制,剩餘的機制後續單獨寫部落格進行講解。

3. 生產者確認

要想保證訊息不丟失,首先我們得保證生產者能成功的將訊息傳送到RabbitMQ伺服器。

但在之前的示例中,當生產者將訊息傳送出去之後,訊息到底有沒有正確地到達伺服器呢?如果不進行特殊配置,預設情況下傳送訊息的操作是不會返回任何訊息給生產者的,也就是預設情況下生產者是不知道訊息有沒有正確的到達伺服器。

從basicPublish方法的返回型別我們也能知曉:

public void basicPublish(String exchange, String routingKey, BasicProperties props, byte[] body) throws IOException {
    this.basicPublish(exchange, routingKey, false, props, body);
}

為了更好理解,我們將之前的生產者Producer類中的channel.queueDeclare(QUEUE_NAME, false, false, false, null);註釋:

package com.zwwhnly.springbootaction.rabbitmq.helloworld;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

import java.io.IOException;
import java.util.concurrent.TimeoutException;

public class Producer {
    private final static String QUEUE_NAME = "hello";

    public static void main(String[] args) throws IOException, TimeoutException {
        // 建立連線
        ConnectionFactory factory = new ConnectionFactory();
        // 設定 RabbitMQ 的主機名
        factory.setHost("localhost");
        // 建立一個連線
        Connection connection = factory.newConnection();
        // 建立一個通道
        Channel channel = connection.createChannel();
        // 指定一個佇列,不存在的話自動建立
        //channel.queueDeclare(QUEUE_NAME, false, false, false, null);
        // 傳送訊息
        String message = "Hello World!";
        channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
        System.out.println(" [x] Sent '" + message + "'");
        // 關閉頻道和連線
        channel.close();
        connection.close();
    }
}

此時執行程式碼,因為佇列不存在,訊息肯定沒地方儲存,但是程式卻並未出錯,也就是訊息丟失了但是我們卻並不知曉。

RabblitMQ針對這個問題,提供了兩種解決方案:

  1. 通過事務機制實現
  2. 通過傳送方確認(publisher confirm)機制實現

4. 事務機制

RabblitMQ客戶端中與事務機制相關的方法有以下3個:

  1. channel.txSelect:用於將當前的通道設定成事務模式
  2. channel.txCommit:用於提交事務
  3. channel.txRollback:用於回滾事務

新建事務生產者類TransactionProducer,程式碼如下:

package com.zwwhnly.springbootaction.rabbitmq.producerconfirm;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

import java.io.IOException;
import java.util.concurrent.TimeoutException;

public class TransactionProducer {
    private final static String QUEUE_NAME = "hello";

    public static void main(String[] args) throws IOException, TimeoutException {
        // 建立連線
        ConnectionFactory factory = new ConnectionFactory();
        // 設定 RabbitMQ 的主機名
        factory.setHost("localhost");
        // 建立一個連線
        Connection connection = factory.newConnection();
        // 建立一個通道
        Channel channel = connection.createChannel();
        // 指定一個佇列,不存在的話自動建立
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        channel.txSelect();

        // 傳送訊息
        String message = "Hello World!";
        channel.basicPublish("", QUEUE_NAME, null, message.getBytes());

        channel.txCommit();
        System.out.println(" [x] Sent '" + message + "'");

        // 關閉頻道和連線
        channel.close();
        connection.close();
    }
}

執行程式碼,發現佇列新增成功,訊息傳送成功:

RabbitMQ使用教程(三)如何保證訊息99.99%被髮送成功?

稍微修改下程式碼,看下異常機制的事務回滾:

try {
    channel.txSelect();

    // 傳送訊息
    String message = "Hello World!";
    channel.basicPublish("", QUEUE_NAME, null, message.getBytes());

    int result = 1 / 0;

    channel.txCommit();
    System.out.println(" [x] Sent '" + message + "'");
} catch (IOException e) {
    e.printStackTrace();
    channel.txRollback();
}

因為int result = 1 / 0;肯定會觸發java.lang.ArithmeticException異常,所以事務會回滾,訊息傳送失敗:

RabbitMQ使用教程(三)如何保證訊息99.99%被髮送成功?

如果要傳送多條訊息,可以將channel.basicPublish,channel.txCommit等方法放在迴圈體內,如下所示:

channel.txSelect();
int loopTimes = 10;

for (int i = 0; i < loopTimes; i++) {
    try {
        // 傳送訊息
        String message = "Hello World!" + i;
        channel.basicPublish("", QUEUE_NAME, null, message.getBytes());

        channel.txCommit();
        System.out.println(" [x] Sent '" + message + "'");
    } catch (IOException e) {
        e.printStackTrace();
        channel.txRollback();
    }
}

雖然事務能夠解決訊息傳送方和RabbitMQ之間訊息確認的問題,只有訊息成功被RabbitMQ接收,事務才能提交成功,否則便可在捕獲異常之後進行事務回滾。但是使用事務機制會“吸乾”RabbitMQ的效能,因此建議使用下面講到的傳送方確認機制。

5. 傳送方確認機制

傳送方確認機制是指生產者將通道設定成confirm(確認)模式,一旦通道進入confirm模式,所有在該通道上面釋出的訊息都會被指派一個唯一的ID(從1開始),一旦訊息被投遞到RabbitMQ伺服器之後,RabbitMQ就會傳送一個確認(Basic.Ack)給生產者(包含訊息的唯一ID),這就使得生產者知曉訊息已經正確到達了目的地了。

如果RabbitMQ因為自身內部錯誤導致訊息丟失,就會傳送一條nack(Basic.Nack)命令,生產者應用程式同樣可以在回撥方法中處理該nack指令。

如果訊息和佇列是可持久化的,那麼確認訊息會在訊息寫入磁碟之後發出。

事務機制在一條訊息傳送之後會使傳送端阻塞,以等待RabbitMQ的回應,之後才能繼續傳送下一條訊息。

相比之下,傳送方確認機制最大的好處在於它是非同步的,一旦釋出一條訊息。生產者應用程式就可以在等通道返回確認的同時繼續傳送下一條訊息,當訊息最終得到確認後,生產者應用程式便可以通過回撥方法來處理該確認訊息。

5.1 普通confirm

新建確認生產類NormalConfirmProducer,程式碼如下:

package com.zwwhnly.springbootaction.rabbitmq.producerconfirm;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

import java.io.IOException;
import java.util.concurrent.TimeoutException;

public class NormalConfirmProducer {
    private final static String EXCHANGE_NAME = "normal-confirm-exchange";

    public static void main(String[] args) throws IOException, TimeoutException {
        // 建立連線
        ConnectionFactory factory = new ConnectionFactory();
        // 設定 RabbitMQ 的主機名
        factory.setHost("localhost");
        // 建立一個連線
        Connection connection = factory.newConnection();
        // 建立一個通道
        Channel channel = connection.createChannel();
        // 建立一個Exchange
        channel.exchangeDeclare(EXCHANGE_NAME, "direct");


        try {
            channel.confirmSelect();
            // 傳送訊息
            String message = "normal confirm test";
            channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes());
            if (channel.waitForConfirms()) {
                System.out.println("send message success");
            } else {
                System.out.println("send message failed");
                // do something else...
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 關閉頻道和連線
        channel.close();
        connection.close();
    }
}

channel.confirmSelect();將通道設定成confirm模式。

channel.waitForConfirms();等待傳送訊息的確認訊息,如果傳送成功,則返回ture,如果傳送失敗,則返回false。

如果要傳送多條訊息,可以將channel.basicPublish,channel.waitForConfirms等方法放在迴圈體內,如下所示:

channel.confirmSelect();
int loopTimes = 10;

for (int i = 0; i < loopTimes; i++) {
    try {
        // 傳送訊息
        String message = "normal confirm test" + i;
        channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes());
        if (channel.waitForConfirms()) {
            System.out.println("send message success");
        } else {
            System.out.println("send message failed");
            // do something else...
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

執行結果:

send message success

send message success

send message success

send message success

send message success

send message success

send message success

send message success

send message success

send message success

如果不開啟通道的confirm模式,呼叫channel.waitForConfirms()會報錯:

RabbitMQ使用教程(三)如何保證訊息99.99%被髮送成功?

注意事項:

1)事務機制和publisher confirm機制是互斥的,不能共存。

如果企圖將已開啟事務模式的通道再設定為publisher confirm模式,RabbitMQ會報錯:

channel.txSelect();
channel.confirmSelect();

RabbitMQ使用教程(三)如何保證訊息99.99%被髮送成功?

如果企圖將已開啟publisher confirm模式的通道再設定為事務模式,RabbitMQ也會報錯:

channel.confirmSelect();
channel.txSelect();

RabbitMQ使用教程(三)如何保證訊息99.99%被髮送成功?

2)事務機制和publisher confirm機制確保的是訊息能夠正確地傳送至RabbitMQ,這裡的“傳送至RabbitMQ”的含義是指訊息被正確地發往至RabbitMQ的交換器,如果此交換器沒有匹配的佇列,那麼訊息也會丟失。所以在使用這兩種機制的時候要確保所涉及的交換器能夠有匹配的佇列。

比如上面的NormalConfirmProducer類傳送的訊息,傳送到了交換器normal-confirm-exchange,但是該交換器並沒有繫結任何佇列,從業務角度來講,訊息仍然是丟失了。

普通confirm模式是每傳送一條訊息後就呼叫channel.waitForConfirms()方法,之後等待服務端的確認,這實際上是一種序列同步等待的方式。因此相比於事務機制,效能提升的並不多。

5.2 批量confirm

批量confirm模式是每傳送一批訊息後,呼叫channel.waitForConfirms()方法,等待伺服器的確認返回,因此相比於5.1中的普通confirm模式,效能更好。

但是不好的地方在於,如果出現返回Basic.Nack或者超時情況,生產者客戶端需要將這一批次的訊息全部重發,這樣會帶來明顯的重複訊息數量,如果訊息經常丟失,批量confirm模式的效能應該是不升反降的。

程式碼示例:

package com.zwwhnly.springbootaction.rabbitmq.producerconfirm;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeoutException;

public class BatchConfirmProducer {
    private final static String EXCHANGE_NAME = "batch-confirm-exchange";

    public static void main(String[] args) throws IOException, TimeoutException {
        // 建立連線
        ConnectionFactory factory = new ConnectionFactory();
        // 設定 RabbitMQ 的主機名
        factory.setHost("localhost");
        // 建立一個連線
        Connection connection = factory.newConnection();
        // 建立一個通道
        Channel channel = connection.createChannel();
        // 建立一個Exchange
        channel.exchangeDeclare(EXCHANGE_NAME, "direct");

        int batchCount = 100;
        int msgCount = 0;
        BlockingQueue blockingQueue = new ArrayBlockingQueue(100);
        try {
            channel.confirmSelect();
            while (msgCount <= batchCount) {
                String message = "batch confirm test";
                channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes());
                // 將傳送出去的訊息存入快取中,快取可以是一個ArrayList或者BlockingQueue之類的
                blockingQueue.add(message);
                if (++msgCount >= batchCount) {
                    try {
                        if (channel.waitForConfirms()) {
                            // 將快取中的訊息清空
                            blockingQueue.clear();
                        } else {
                            // 將快取中的訊息重新傳送
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        // 將快取中的訊息重新傳送
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 關閉頻道和連線
        channel.close();
        connection.close();
    }
}

5.3 非同步confirm

非同步confirm模式是在生產者客戶端新增ConfirmListener回撥介面,重寫介面的handAck()和handNack()方法,分別用來處理RabblitMQ回傳的Basic.Ack和Basic.Nack。

這兩個方法都有兩個引數,第1個引數deliveryTag用來標記訊息的唯一序列號,第2個引數multiple表示的是是否為多條確認,值為true代表是多個確認,值為false代表是單個確認。

示例程式碼:

package com.zwwhnly.springbootaction.rabbitmq.producerconfirm;

import com.rabbitmq.client.*;

import java.io.IOException;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.TimeoutException;

public class AsyncConfirmProducer {

    private final static String EXCHANGE_NAME = "async-confirm-exchange";

    public static void main(String[] args) throws IOException, TimeoutException {
        // 建立連線
        ConnectionFactory factory = new ConnectionFactory();
        // 設定 RabbitMQ 的主機名
        factory.setHost("localhost");
        // 建立一個連線
        Connection connection = factory.newConnection();
        // 建立一個通道
        Channel channel = connection.createChannel();
        // 建立一個Exchange
        channel.exchangeDeclare(EXCHANGE_NAME, "direct");

        int batchCount = 100;
        long msgCount = 1;
        SortedSet<Long> confirmSet = new TreeSet<Long>();
        channel.confirmSelect();
        channel.addConfirmListener(new ConfirmListener() {
            @Override
            public void handleAck(long deliveryTag, boolean multiple) throws IOException {
                System.out.println("Ack,SeqNo:" + deliveryTag + ",multiple:" + multiple);
                if (multiple) {
                    confirmSet.headSet(deliveryTag - 1).clear();
                } else {
                    confirmSet.remove(deliveryTag);
                }
            }

            @Override
            public void handleNack(long deliveryTag, boolean multiple) throws IOException {
                System.out.println("Nack,SeqNo:" + deliveryTag + ",multiple:" + multiple);
                if (multiple) {
                    confirmSet.headSet(deliveryTag - 1).clear();
                } else {
                    confirmSet.remove(deliveryTag);
                }
                // 注意這裡需要新增處理訊息重發的場景
            }
        });
        // 演示傳送100個訊息
        while (msgCount <= batchCount) {
            long nextSeqNo = channel.getNextPublishSeqNo();
            channel.basicPublish(EXCHANGE_NAME, "", null, "async confirm test".getBytes());
            confirmSet.add(nextSeqNo);
            msgCount = nextSeqNo;
        }

        // 關閉頻道和連線
        channel.close();
        connection.close();
    }
}

執行結果:

Ack,SeqNo:1,multiple:false

Ack,SeqNo:2,multiple:false

Ack,SeqNo:3,multiple:false

Ack,SeqNo:4,multiple:false

Ack,SeqNo:5,multiple:false

Ack,SeqNo:6,multiple:false

Ack,SeqNo:7,multiple:false

Ack,SeqNo:8,multiple:false

Ack,SeqNo:9,multiple:false

Ack,SeqNo:10,multiple:false

注意:多次執行,發現每次執行的輸出結果是不一樣的,說明RabbitMQ端回傳給生產者的ack訊息並不是以固定的批量大小回傳的。

6. 效能比較

到目前為止,我們瞭解到4種模式(事務機制,普通confirm,批量confirm,非同步confirm)可以實現生產者確認,讓我們來對比下它們的效能,簡單修改下以上示例程式碼中傳送訊息的數量,比如10000條,以下為4種模式的耗時:

傳送10000條訊息,事務機制耗時:2103

傳送10000條訊息,普通confirm機制耗時:1483

傳送10000條訊息,批量confirm機制耗時:281

傳送10000條訊息,非同步confirm機制耗時:214

可以看出,事務機制最慢,普通confirm機制雖有提升但是不多,批量confirm和非同步confirm效能最好,大家可以根據自己喜好自行選擇使用哪種機制,個人建議使用非同步confirm機制。

7. 原始碼

原始碼地址:https://github.com/zwwhnly/springboot-action.git,歡迎下載。

8. 參考

《RabbitMQ實戰指南》

相關文章