楔子:在信息高速公路上,我們開着大大小小的車輛,我們或快或慢高速飛馳,東南西北,日月星辰,我們要經過收費站服務區,我們要選擇繳費窗口並減速排隊繳費才能順利通過。MQ 的順序消息也是這樣。
1. 日常排隊經驗
也許我們經常有這樣的生活經驗:
- 在大型超市購物結算時,你最終只能在一個結算口進行排隊結算,即先進先出(這裏排除插隊搞事情現象)
- 在高速上過收費站時,在同一窗口,先進隊的車一定是先繳完費出去,即先進先出(這裏排除插隊搞事情現象)
- 在機場出關時,你只能在一個隊列,你也會比在你後面的人先過安檢,即先進先出(這裏排除插隊搞事情現象)
順序消息(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'