RocketMQ進擊(三)順序消息與高速公路收費站


楔子:在信息高速公路上,我們開着大大小小的車輛,我們或快或慢高速飛馳,東南西北,日月星辰,我們要經過收費站服務區,我們要選擇繳費窗口並減速排隊繳費才能順利通過。MQ 的順序消息也是這樣。

 

1. 日常排隊經驗

也許我們經常有這樣的生活經驗:

  1. 在大型超市購物結算時,你最終只能在一個結算口進行排隊結算,即先進先出(這裏排除插隊搞事情現象)
  2. 在高速上過收費站時,在同一窗口,先進隊的車一定是先繳完費出去,即先進先出(這裏排除插隊搞事情現象)
  3. 在機場出關時,你只能在一個隊列,你也會比在你後面的人先過安檢,即先進先出(這裏排除插隊搞事情現象)

順序消息(First Input First Output,FIFO 消息)是消息隊列 MQ 提供的一種嚴格按照順序來發布和消費的消息順序發佈和順序消費是指對於指定的一個 Topic,生產者按照一定的先後順序發佈消息;消費者按照既定的先後順序訂閱消息,即先發布的消息一定會先被客戶端接收到。

 

2. 淺探順序消息

順序消息分爲全局順序消息分區順序消息

在默認的情況下消息發送會採取 Round Robin 輪詢方式把消息發送到不同的 queue(分區隊列);而消費消息的時候從多個 queue 上拉取消息,這種情況發送和消費是不能保證順序。但是如果控制發送的順序消息只依次發送到同一個 queue 中,消費的時候只從這個 queue 上依次拉取,則就保證了順序。

當發送和消費參與的 queue 只有一個,則是全局有序;如果多個 queue 參與,則爲分區有序,即相對每個 queue,消息都是有序的。

 

2.1. 全局順序消息

對於指定的一個 Topic,所有消息按照嚴格的先入先出(FIFO)的順序來發布和消費。

適用場景:適用於性能要求不高,所有的消息嚴格按照 FIFO 原則來發布和消費的場景。

示例:在證券處理中,以人民幣兌換美元爲 Topic,在價格相同的情況下,先出價者優先處理,則可以按照 FIFO 的方式發佈和消費全局順序消息。

 

2.2. 分區順序消息

對於指定的一個 Topic,所有消息根據 Sharding Key 進行區塊分區。同一個分區內的消息按照嚴格的 FIFO 順序進行發佈和消費。Sharding Key 是順序消息中用來區分不同分區的關鍵字段,和普通消息的 Key 是完全不同的概念。

適用場景:適用於性能要求高,以 Sharding Key 作爲分區字段,在同一個區塊中嚴格地按照 FIFO 原則進行消息發佈和消費的場景。

示例:電商的訂單創建,以訂單 ID 作爲 Sharding Key,那麼同一個訂單相關的創建訂單消息、訂單支付消息、訂單退款消息、訂單物流消息都會按照發布的先後順序來消費。

 

2.3. 源碼與案例

下面用訂單進行分區有序的示例。一個訂單的順序流程是:創建、付款、推送、完成。訂單號相同的消息會被先後發送到同一個隊列中,消費時,同一個 OrderId 獲取到的肯定是同一個隊列。

分區順序消息生產者(Producer )

package com.meiwei.service.mq.tcp.producer;

import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.MessageQueueSelector;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.remoting.common.RemotingHelper;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

/**
 * 順序消息 - 生產者
 *
 * 消息有序指的是可以按照消息的發送順序來消費(FIFO)。RocketMQ可以嚴格的保證消息有序,可以分爲分區有序或者全局有序。
 */
public class OrderMqProducer {

    // Topic 爲 Message 所屬的一級分類,就像學校裏面的初中、高中
    // Topic 名稱長度不得超過 64 字符長度限制,否則會導致無法發送或者訂閱
    private static final String MQ_CONFIG_TOPIC = "TOPIC_MEIWEI_SMS_NOTICE_TEST";

    // Tag 爲 Message 所屬的二級分類,比如初中可分爲初一、初二、初三;高中可分爲高一、高二、高三
    private static final String MQ_CONFIG_TAG_A = "PID_MEIWEI_SMS_ORDER_A";
    private static final String MQ_CONFIG_TAG_B = "PID_MEIWEI_SMS_ORDER_B";
    private static final String MQ_CONFIG_TAG_C = "PID_MEIWEI_SMS_ORDER_C";

    public static void main(String[] args) throws Exception {
        // 聲明並實例化一個 producer 生產者來產生消息
        // 需要一個 producer group 名字作爲構造方法的參數
        DefaultMQProducer producer = new DefaultMQProducer("meiwei-producer-orderdmq");

        // 指定 NameServer 地址列表,多個nameServer地址用半角分號隔開。此處應改爲實際 NameServer 地址
        // NameServer 的地址必須有,但也可以通過啓動參數指定、環境變量指定的方式設置,不一定要寫死在代碼裏
        producer.setNamesrvAddr("127.0.0.1:9876");

        // 在發送消息前,必須調用 start 方法來啓動 Producer,只需調用一次即可
        producer.start();

        // 二級分類標籤
        String[] tags = new String[] {MQ_CONFIG_TAG_A, MQ_CONFIG_TAG_B, MQ_CONFIG_TAG_C};

        List<OrderStep> orderList = new OrderMqProducer().buildOrders();

        Date date = new Date();
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String dateStr = sdf.format(date);

        for (int i = 0; i < orderList.size(); i++) {
            // 加個時間綴
            String content = "【MQ測試消息】順序消息, 時間 " + dateStr + " " + orderList.get(i);

            // 新建一條消息,指定topic,tag、key和body
            // KEY 就好比具體某個班級,唯一
            Message message = new Message(MQ_CONFIG_TOPIC, tags[i % tags.length], "KEY" + i, content.getBytes(RemotingHelper.DEFAULT_CHARSET));

            // 提交消息,制定 queue 選擇器和排序參數
            // 做了一個取模運算再丟到 selector 中,selector 保證同一個模的都會投遞到同一條 queue
            // 即:相同訂單號的 有相同的模 有相同的 queue
            SendResult sendResult = producer.send(message, new MessageQueueSelector() {
                @Override
                public MessageQueue select(List<MessageQueue> list, Message message, Object o) {
                    // 根據訂單id選擇發送queue
                    Long id = (Long) o;
                    long index = id % list.size();
                    return list.get((int) index);
                }
            }, orderList.get(i).getOrderId()); // 訂單id

            // 日誌打印
            System.out.printf("Send MQ message success! Topic: %s,Tag: %s, Message: %s %n",
                    message.getTopic(), message.getTags(), new String(message.getBody()));
        }

        // 在發送完消息之後,銷燬 Producer 對象。如果不銷燬也沒有問題
        producer.shutdown();
    }

    /**
     * 訂單的步驟
     */
    private static class OrderStep {
        private long orderId;
        private String desc;

        public long getOrderId() {
            return orderId;
        }

        public void setOrderId(long orderId) {
            this.orderId = orderId;
        }

        public String getDesc() {
            return desc;
        }

        public void setDesc(String desc) {
            this.desc = desc;
        }

        @Override
        public String toString() {
            return "OrderStep{" +
                    "orderId=" + orderId +
                    ", desc='" + desc + '\'' +
                    '}';
        }
    }

    /**
     * 生成模擬訂單數據
     */
    private List<OrderStep> buildOrders() {
        List<OrderStep> orderList = new ArrayList<OrderStep>();

        OrderStep orderDemo = new OrderStep();
        orderDemo.setOrderId(15103111039L);
        orderDemo.setDesc("創建");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103111065L);
        orderDemo.setDesc("創建");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103111039L);
        orderDemo.setDesc("付款");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103117235L);
        orderDemo.setDesc("創建");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103111065L);
        orderDemo.setDesc("付款");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103117235L);
        orderDemo.setDesc("付款");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103111065L);
        orderDemo.setDesc("完成");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103111039L);
        orderDemo.setDesc("推送");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103117235L);
        orderDemo.setDesc("完成");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103111039L);
        orderDemo.setDesc("完成");
        orderList.add(orderDemo);

        return orderList;
    }
}

Producer 生產端確保消息順序,唯一要做的事情就是將消息路由到特定的分區。在 RocketMQ 中,通過 MessageQueueSelector 來實現分區的選擇。

  • List<MessageQueue> list:消息要發送的 Topic 下所有的分區
  • Message message:消息對象
  • 額外參數:用戶可以傳遞自己的參數

 

分區順序消息消費者(Consumer )【Push模式】

package com.meiwei.service.mq.tcp.consumer;

import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerOrderly;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.MessageExt;

import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;

/**
 * 順序消息 - 消費者
 *
 * 消息有序指的是可以按照消息的發送順序來消費(FIFO)。RocketMQ可以嚴格的保證消息有序,可以分爲分區有序或者全局有序。
 */
public class OrderMqConsumer {

    // Topic 爲 Message 所屬的一級分類,就像學校裏面的初中、高中
    // Topic 名稱長度不得超過 64 字符長度限制,否則會導致無法發送或者訂閱
    private static final String MQ_CONFIG_TOPIC = "TOPIC_MEIWEI_SMS_NOTICE_TEST";

    // Tag 爲 Message 所屬的二級分類,比如初中可分爲初一、初二、初三;高中可分爲高一、高二、高三
    private static final String MQ_CONFIG_TAG_A = "PID_MEIWEI_SMS_ORDER_A";
    private static final String MQ_CONFIG_TAG_B = "PID_MEIWEI_SMS_ORDER_B";
    private static final String MQ_CONFIG_TAG_C = "PID_MEIWEI_SMS_ORDER_C";

    public static void main(String[] args) throws Exception {
        // 聲明並初始化一個 consumer
        // 需要一個 consumer group 名字作爲構造方法的參數
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("meiwei-consumer-ordermq");

        // 同樣也要設置 NameServer 地址,須要與提供者的地址列表保持一致
        consumer.setNamesrvAddr("127.0.0.1:9876");

        // 這裏設置的是一個consumer的消費策略
        // CONSUME_FROM_LAST_OFFSET:默認策略,從該隊列最尾開始消費,即跳過歷史消息
        // CONSUME_FROM_FIRST_OFFSET:從隊列最開始開始消費,即歷史消息(還儲存在broker的)全部消費一遍
        // CONSUME_FROM_TIMESTAMP:從某個時間點開始消費,和setConsumeTimestamp() 配合使用,默認是半個小時以前
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

        // 設置 consumer 所訂閱的 Topic 和 Tag,*代表全部的 Tag
        consumer.subscribe(MQ_CONFIG_TOPIC, MQ_CONFIG_TAG_A + " || " + MQ_CONFIG_TAG_B + " || " + MQ_CONFIG_TAG_C);

        // 設置一個Listener,主要進行消息的邏輯處理
        // 注意這裏使用的是 MessageListenerOrderly 這個接口來實現順序消費
        consumer.registerMessageListener(new MessageListenerOrderly() {

            Random random = new Random();

            @Override
            public ConsumeOrderlyStatus consumeMessage(List<MessageExt> list, ConsumeOrderlyContext consumeOrderlyContext) {
                // 設置自動提交
                consumeOrderlyContext.setAutoCommit(true);

                list.forEach(mq->{
                    System.out.printf("Thread: %s, Topic: %s, Tags: %s, Message: %s",
                            Thread.currentThread().getName(),
                            mq.getTopic(),
                            mq.getTags(),
                            new String(mq.getBody()));
                    System.out.println();
                });

                try {
                    //模擬業務邏輯處理中...
                    TimeUnit.SECONDS.sleep(random.nextInt(10));
                } catch (Exception e) {
                    e.printStackTrace();
                }

                // 返回消費狀態
                // SUCCESS 消費成功
                // SUSPEND_CURRENT_QUEUE_A_MOMENT 消費失敗,暫停當前隊列的消費
                return ConsumeOrderlyStatus.SUCCESS;
            }
        });

        // 調用 start() 方法啓動 consumer
        consumer.start();
        System.out.println("Consumer Started.");
    }
}

 

2.4. 測試及結果

分區順序消息生產者(Producer)發送結果:

分區順序消息消費者(Consumer)消費結果:

 

順序消息缺陷

  • 發送順序消息無法利用集羣 FailOver 特性
  • 消費順序消息的並行度依賴於隊列數量
  • 隊列熱點問題,個別隊列由於哈希不均導致消息過多,消費速度跟不上,產生消息堆積問題
  • 遇到消息失敗的消息,無法跳過,當前隊列消費暫停

 

2.5. 全局與分區對比

消息類型對比

Topic 的消息類型 是否支持事務消息 是否支持定時/延時消息 性能
無序消息(普通、事務、定時/延時消息) 最高
分區順序消息
全局順序消息 一般

發送方式對比

消息類型 是否支持可靠同步發送 是否支持可靠異步發送 是否支持 Oneway 發送
無序消息(普通、事務、定時/延時消息)
分區順序消息
全局順序消息


參考資料
阿里雲:https://help.aliyun.com/document_detail/49319.html?spm=a2c4g.11186623.6.553.5dd918fdfmtTSh


RocketMQ 進擊物語:
RocketMQ進擊(零)RocketMQ這個大水池子
RocketMQ進擊(一)Windows環境下安裝部署Apache RocketMQ
RocketMQ進擊(二)一個默認生產者,兩種消費方式,三類普通消息詳解分析
RocketMQ進擊(三)順序消息與高速公路收費站
RocketMQ進擊(四)定時消息(延時隊列)
RocketMQ進擊(五)集羣消費模式與廣播消費模式
RocketMQ進擊(六)磕一磕RocketMQ的事務消息
RocketMQ進擊(七)盤一盤RocketMQ的重試機制
RocketMQ進擊(八)RocketMQ的日誌收集Logappender
RocketMQ異常:RocketMQ順序消息收不到或者只能收到一部分消息
RocketMQ異常:Unrecognized VM option 'MetaspaceSize=128m'

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