RabbitMQ非官方教程(三)工作隊列

上個教程的Demo中,我們編寫了程序來發送和接收來自命名隊列的消息。在這一部分中,我們將創建一個工作隊列,該隊列將用於在多個工作人員之間分配耗時的任務。

工作隊列(又稱任務隊列)的主要思想是避免立即執行資源密集型任務,而不得不等待它完成。相反,我們安排任務在以後完成。我們將任務封裝 爲消息並將其發送到隊列。在後臺運行的工作進程將彈出任務並最終執行作業。當您運行許多工作人員時,任務將在他們之間共享。

這是本節代碼地址:https://gitee.com/mjTree/javaDevelop/tree/master/testDemo

製備過程

之前發送了一條包含“ Hello World!”的消息。現在我們將發送代表複雜任務的字符串,假裝處理過程時間較長(使用Sleep函數來僞造它)。新建一個Secod包,裏面包含NewTask.java和Work.java。

我們將稍微修改上一個示例中的Send.java代碼,以允許從命令行發送任意消息。該程序會將任務調度到我們的工作隊列中,因此將其命名爲NewTask.java。註釋的是因爲官方想在命令行編譯並運行文件,這裏就不用那種方式了。

package com.mytest.rabbitMQ.Second;

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

public class NewTask {
    private final static String TASK_QUEUE_NAME = "task_queue";

    public static void main(String[] argv) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");

        try(Connection connection = factory.newConnection();
            Channel channel = connection.createChannel()) {
            channel.queueDeclare(TASK_QUEUE_NAME, false, false, false, null);
            //String message = String.join(" ", argv);  //命令行編譯執行時添加參數的方式
            String message = "No.1 message";
            channel.basicPublish("", TASK_QUEUE_NAME,
                    null,
                    message.getBytes("UTF-8"));
            System.out.println(" [x] Sent '" + message + "'");
        }
    }
}

上面代碼執行完之後在把message中的"No.1"改成"No.2"和"No.3"各運行一次,這樣我們隊列中存放了3條消息。

package com.mytest.rabbitMQ.Second;

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

public class Work {
    private final static String TASK_QUEUE_NAME = "task_queue";

    public static void main(String[] argv) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        channel.queueDeclare(TASK_QUEUE_NAME, false, false, false, null);
        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");

        DeliverCallback deliverCallback = (consumerTag, delivery) -> {
            String message = new String(delivery.getBody(), "UTF-8");

            System.out.println(" [x] Received '" + message + "'");
            try {
                doWork(message);
            } finally {
                System.out.println(" [x] Done");
            }
        };
        boolean autoAck = true;     // 確認內容如下
        channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, consumerTag -> { });
    }

    private static void doWork(String task) {
        for (char ch: task.toCharArray()) {
            if (ch == '.') {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException _ignored) {
                    Thread.currentThread().interrupt();
                }
            }
        }
    }
}

然後我們編寫Work代碼,然後執行。每次讀取一條消息會模擬消費者在很忙的處理消息並花費1s的時間。

循環調度

使用任務隊列的優點之一是能夠輕鬆並行化工作。如果我們正在積壓工作,我們可以增加更多的工人,這樣就可以輕鬆擴展。首先,讓我們嘗試同時運行兩個消費者程序。他們倆都將從隊列中獲取消息,但是究竟如何呢?

由於IDEA不能像命令行那樣打開多個窗口去執行Work類模擬該過程,這裏暫時不演示了。使用官網的演示結果說明過程。

# 首先執行5次NewTask使得隊列存放5條消息

java -cp $CP NewTask First message.
# => [x] Sent 'First message.'
java -cp $CP NewTask Second message..
# => [x] Sent 'Second message..'
java -cp $CP NewTask Third message...
# => [x] Sent 'Third message...'
java -cp $CP NewTask Fourth message....
# => [x] Sent 'Fourth message....'
java -cp $CP NewTask Fifth message.....
# => [x] Sent 'Fifth message.....'
# 下面是打開的第一個Work消費者執行的結果
java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C
# => [x] Received 'First message.'
# => [x] Received 'Third message...'
# => [x] Received 'Fifth message.....'


# 下面是打開的第二個Work消費者執行的結果
java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C
# => [x] Received 'Second message..'
# => [x] Received 'Fourth message....'

默認情況下,RabbitMQ將每個消息依次發送給下一個使用者。換句話說每個消費者都應該會收到相同數量的消息,這種分發消息的方式稱爲循環

消息確認

執行任務可能需要幾秒鐘。您可能想知道,如果其中一個使用者開始一項漫長的任務而僅部分完成而死掉,會發生什麼情況。使用我們當前的代碼,RabbitMQ一旦向消費者發送了一條消息,便立即將其標記爲刪除。在這種情況下,如果您殺死一個工人,我們將丟失正在處理的消息。我們還將丟失所有發送給該特定工作人員但尚未處理的消息。

但是我們不想丟失任何任務。如果一個消費者進程死亡,我們希望將任務交付給另一個消費者。爲了確保消息永不丟失,RabbitMQ支持 消息確認。消費者發送回一個確認(告知),告知RabbitMQ特定的消息已被接收和處理,之後RabbitMQ便可以自由刪除該消息。如果消費者進程死了(其通道已關閉,連接已關閉或TCP連接丟失)而沒有發送確認,RabbitMQ將瞭解消息未完全處理,並將重新排隊。如果同時有其他消費者在線,它將很快將其重新分發給另一個消費者。這樣,您可以確保即使消費者進程偶爾死亡也不會丟失任何消息。沒有任何消息超的時候,消費者進程死亡時,RabbitMQ將重新傳遞消息。即使處理一條消息花費非常非常長的時間也沒關係。

首先我們先執行NewTask四次得到4條消息,之後修改Work的代碼。

package com.mytest.rabbitMQ.Second;

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

public class Work {
    private final static String TASK_QUEUE_NAME = "task_queue";

    public static void main(String[] argv) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        channel.queueDeclare(TASK_QUEUE_NAME, false, false, false, null);
        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");

        channel.basicQos(1);    // 一次僅接受一條未經確認的消息
        DeliverCallback deliverCallback = (consumerTag, delivery) -> {
            String message = new String(delivery.getBody(), "UTF-8");

            System.out.println(" [x] Received '" + message + "'");
            try {
                doWork(message);
            } finally {
                System.out.println(" [x] Done");
                channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
            }
        };
        boolean autoAck = false;     // 確認內容如下
        channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, consumerTag -> { });
    }

    private static void doWork(String task) {
        for (char ch: task.toCharArray()) {
            if (ch == '.') {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException _ignored) {
                    Thread.currentThread().interrupt();
                }
            }
        }
    }
}

默認情況下,手動確認消息處於打開狀態。在前面的示例中,我們通過autoAck=true標誌顯式關閉了它們。現在,是時候將該標誌設置爲false,在完成任務後從消費者那裏發送適當的確認。

然後在執行Work過程中對其中斷(點擊紅色按鈕強制結束進程),此時消費者接受了第三條消息但還是還在處理突然中斷。

以前代碼,消息隊列是會刪除第三條消息,但加上消息確認則不會出現這種消息丟失現象。去網頁查看隊列中的消息數還有2條,說明我們的消息確認起到效果了。

使用此代碼,我們可以確保,即使您在處理消息時終止消費者進程,也不會丟失任何信息。消費者進程死亡後不久,所有未確認的消息將重新發送。

確認必須在收到交貨的同一通道上發送。嘗試使用其他通道進行確認將導致通道級協議異常。請參閱有關確認文檔指南 以瞭解更多信息。

 

訊息持久性

我們已經學會了如何確保即使消費者進程死亡,消息也不會丟失。但如果RabbitMQ服務器停止,我們的消息仍然會丟失。RabbitMQ退出或崩潰時,它將忘記隊列和消息,除非您告知不要這樣做。要確保消息不會丟失需要做兩件事:我們需要將隊列和消息都標記爲持久。

boolean durable = true;
channel.queueDeclare("hello", durable, false, false, null);

上面代碼本身是正確的,但當前的設置中將無法使用。因爲我們在上一節的demo中已經定義了一個名爲hello的隊列 ,並且默認它不持久,而RabbitMQ不允許您使用不同的參數重新定義現有隊列,也就是不能去修改已經定義過的隊列屬性。但是有一個快速的解決方法-讓我們聲明一個名稱不同的隊列去進行操作。

如果還是使用原來的隊列task_queue,我們需要去網頁把它刪除,在網頁上,直接點擊隊列表格中某個隊列名稱,然後跳轉到新頁面會有一個"delete queue"按鈕,點擊就直接刪除並自動跳回原來頁面。

// 需要對NewTask.java和Work.java中隊列聲明註釋並修改
/*註釋前*/
channel.queueDeclare(TASK_QUEUE_NAME, false, false, false, null);



/*註釋後*/
 // 聲明持久化
boolean durable = true;
//channel.queueDeclare(TASK_QUEUE_NAME, false, false, false, null);
channel.queueDeclare(TASK_QUEUE_NAME, durable, false, false, null);

上面代碼保證了rabbitmq重啓也不會丟失隊列,接下來接着修改代碼保證消息不會丟失。我們需要將消息標記爲持久性-通過將MessageProperties(實現BasicProperties)設置爲PERSISTENT_TEXT_PLAIN的值即可。詳細代碼在最下面提供

// 把NewTask中消息的某個屬性值修改科技

/*修改前*/
channel.basicPublish("", TASK_QUEUE_NAME,
                    null,
                    message.getBytes("UTF-8"));

/*修改後*/
channel.basicPublish("", TASK_QUEUE_NAME,
                    MessageProperties.PERSISTENT_TEXT_PLAIN,
                    message.getBytes("UTF-8"));

有關消息持久性的說明

將消息標記爲持久性並不能完全保證不會丟失消息。儘管它告訴RabbitMQ將消息保存到磁盤,但是RabbitMQ接受消息並且尚未保存消息時,還有很短的時間。而且,RabbitMQ不會對每條消息都執行 fsync(2)-它可能只是保存到緩存中,而沒有真正寫入磁盤。持久性保證並不強,但是對於我們的簡單任務隊列而言,這已經綽綽有餘了。如果您需要更強有力的保證,則可以使用 發佈者確認

 

公平派遣

您可能已經注意到,調度仍然無法完全按照我們的要求進行。例如,在有兩個消費者進程工作的情況下,所有操作繁瑣的任務交個A消費者,所有簡單的任務交給B消費者,則就使得A消費者將一直忙碌而B消費者幾乎沒什麼任何工作。RabbitMQ對此一無所知,並且仍將平均分配消息。發生這種情況是因爲RabbitMQ在消息進入隊列時才調度消息。它不會查看使用者的未確認消息數,它只是盲目地將每第n條消息發送給第n個使用者。

爲了克服這一點,我們可以使用basicQos方法。這告訴RabbitMQ一次不要給消費者特定數目以上的消息。如果我們設置爲1,在處理並確認上一條消息之前,不要將新消息發送給該消費者,而是將其分派給不忙的下一個消費者。這個設置方式在上面代碼中測試消費者進程中斷時用到了。

關於隊列大小的注意事項

如果所有消費者都忙,您的隊列就滿了。您將需要留意這一點,也許需要增加更多的消費者,或用其他一些策略。

整合持久化和公平派發功能代碼

package com.mytest.rabbitMQ.Second;

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


public class NewTask {
    private final static String TASK_QUEUE_NAME = "task_queue";

    public static void main(String[] argv) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("127.0.0.1");

        try(Connection connection = factory.newConnection();
            Channel channel = connection.createChannel()) {
            // 聲明持久化
            boolean durable = true;
            //channel.queueDeclare(TASK_QUEUE_NAME, false, false, false, null);
            channel.queueDeclare(TASK_QUEUE_NAME, durable, false, false, null);

            //String message = String.join(" ", argv);  //命令行編譯執行時添加參數的方式
            String message = "No.1 message";

            channel.basicPublish("", TASK_QUEUE_NAME,
                    MessageProperties.PERSISTENT_TEXT_PLAIN,
                    message.getBytes("UTF-8"));
            System.out.println(" [x] Sent '" + message + "'");
        }
    }
}
package com.mytest.rabbitMQ.Second;

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

public class Work {
    private final static String TASK_QUEUE_NAME = "task_queue";

    public static void main(String[] argv) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        boolean durable = true;
        //channel.queueDeclare(TASK_QUEUE_NAME, false, false, false, null);
        channel.queueDeclare(TASK_QUEUE_NAME, durable, false, false, null);
        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");

        int prefetchCount = 1;
        channel.basicQos(prefetchCount);    // 一次僅接受一條未經確認的消息

        DeliverCallback deliverCallback = (consumerTag, delivery) -> {
            String message = new String(delivery.getBody(), "UTF-8");
            System.out.println(" [x] Received '" + message + "'");
            try {
                doWork(message);
            } finally {
                System.out.println(" [x] Done");
                channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
            }
        };
        boolean autoAck = false;     // 確認內容如下
        channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, consumerTag -> { });
    }

    private static void doWork(String task) {
        for (char ch: task.toCharArray()) {
            if (ch == '.') {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException _ignored) {
                    Thread.currentThread().interrupt();
                }
            }
        }
    }
}

 

使用消息確認basicQos可以設置工作隊列。持久化可以使重新啓動RabbitMQ也可以使任務繼續存在。

有關Channel方法和MessageProperty的更多信息,您可以在線瀏覽 JavaDocs

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章