RocketMQ順序消費
如果要保證順序消費,那麼他的核心點就是:生產者有序儲存
、消費者有序消費
。
一、概念
1、什麼是無序訊息
無序訊息
無序訊息也指普通的訊息,Producer 只管傳送訊息,Consumer 只管接收訊息,至於訊息和訊息之間的順序並沒有保證。
舉例
Producer 依次傳送 orderId 為 1、2、3 的訊息,Consumer 接到的訊息順序有可能是 1、2、3,也有可能是 2、1、3 等情況,這就是普通訊息。
2、什麼是全域性順序
對於指定的一個 Topic,所有訊息按照嚴格的先入先出(FIFO)的順序進行釋出和消費。
舉例
比如 Producer 傳送orderId 1,3,2 的訊息, 那麼 Consumer 也必須要按照 1,3,2 的順序進行消費。
3、區域性順序
在實際開發有些場景中,我並不需要訊息完全按照完全按的先進先出,而是某些訊息保證先進先出就可以了。
就好比一個訂單涉及 訂單生成
,訂單支付
、訂單完成
。我不用管其它的訂單,只保證同樣訂單ID能保證這個順序
就可以了。
二、實現原理
我們知道 生產的message最終會存放在Queue中,如果一個Topic關聯了16個Queue,如果我們不指定訊息往哪個佇列裡放,那麼預設是平均分配訊息到16個queue,
好比有100條訊息,那麼這100條訊息會平均分配在這16個Queue上,那麼每個Queue大概放5~6個左右。這裡有一點很重的是:
同一個queue,儲存在裡面的message 是按照先進先出的原則
這個時候思路就來了,好比有orderId=1的3條訊息,分別是 訂單生產、訂單付款、訂單完成。只要保證它們放到同一個Queue那就保證消費者先進先出了。
這就保證區域性順序了,即同一訂單按照先後順序放到同一Queue,那麼取訊息的時候就可以保證先進先取出。
那麼全域性訊息呢?
這個就簡單啦,你把所有訊息都放在一個Queue裡,這樣不就保證全域性訊息了。
就這麼簡單
當然不是,這裡還有很關鍵的一點,好比在一個消費者叢集的情況下,消費者1先去Queue拿訊息,它拿到了 訂單生成,它拿完後,消費者2去queue拿到的是 訂單支付。
拿的順序是沒毛病了,但關鍵是先拿到不代表先消費完它。會存在雖然你消費者1先拿到訂單生成,但由於網路等原因,消費者2比你真正的先消費訊息。這是不是很尷尬了。
訂單付款還是可能會比訂單生成更早消費的情況。那怎麼辦。
分散式鎖來了
Rocker採用的是分段鎖,它不是鎖整個Broker而是鎖裡面的單個Queue,因為只要鎖單個Queue就可以保證區域性順序消費了。
所以最終的消費者這邊的邏輯就是
消費者1去Queue拿 訂單生成,它就鎖住了整個Queue,只有它消費完成並返回成功後,這個鎖才會釋放。
然後下一個消費者去拿到 訂單支付 同樣鎖住當前Queue,這樣的一個過程來真正保證對同一個Queue能夠真正意義上的順序消費,而不僅僅是順序取出。
全域性順序與分割槽順序對比
訊息型別對比
傳送方式對比
其它的注意事項
1、順序訊息暫不支援廣播模式。
2、順序訊息不支援非同步傳送方式,否則將無法嚴格保證順序。
3、建議同一個 Group ID 只對應一種型別的 Topic,即不同時用於順序訊息和無序訊息的收發。
4、對於全域性順序訊息,建議建立例項個數 >=2。
三、程式碼示例
這裡保證兩點
1、生產端 同一orderID的訂單放到同一個queue。
2、消費端 同一個queue取出訊息的時候鎖住整個queue,直到消費後再解鎖。
1、ProductOrder實體
@AllArgsConstructor
@Data
@ToString
public class ProductOrder {
/**
* 訂單編號
*/
private String orderId;
/**
* 訂單型別(訂單建立、訂單付款、訂單完成)
*/
private String type;
}
2、Product(生產者)
生產者和之前傳送普通訊息最大的區別,就是針對每一個message都手動通過MessageQueueSelector
選擇好queue。
@RestController
public class Product {
private static List<ProductOrder> orderList = null;
private static String producerGroup = "test_producer";
/**
* 模擬資料
*/
static {
orderList = new ArrayList<>();
orderList.add(new ProductOrder("XXX001", "訂單建立"));
orderList.add(new ProductOrder("XXX001", "訂單付款"));
orderList.add(new ProductOrder("XXX001", "訂單完成"));
orderList.add(new ProductOrder("XXX002", "訂單建立"));
orderList.add(new ProductOrder("XXX002", "訂單付款"));
orderList.add(new ProductOrder("XXX002", "訂單完成"));
orderList.add(new ProductOrder("XXX003", "訂單建立"));
orderList.add(new ProductOrder("XXX003", "訂單付款"));
orderList.add(new ProductOrder("XXX003", "訂單完成"));
}
@GetMapping("message")
public void sendMessage() throws Exception {
//示例生產者
DefaultMQProducer producer = new DefaultMQProducer(producerGroup);
//不開啟vip通道 開通口埠會減2
producer.setVipChannelEnabled(false);
//繫結name server
producer.setNamesrvAddr("IP:9876");
producer.start();
for (ProductOrder order : orderList) {
//1、生成訊息
Message message = new Message(JmsConfig.TOPIC, "", order.getOrderId(), order.toString().getBytes());
//2、傳送訊息是 針對每條訊息選擇對應的佇列
SendResult sendResult = producer.send(message, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
//3、arg的值其實就是下面傳入 orderId
String orderid = (String) arg;
//4、因為訂單是String型別,所以通過hashCode轉成int型別
int hashCode = orderid.hashCode();
//5、因為hashCode可能為負數 所以取絕對值
hashCode = Math.abs(hashCode);
//6、保證同一個訂單號 一定分配在同一個queue上
long index = hashCode % mqs.size();
return mqs.get((int) index);
}
}, order.getOrderId(),50000);
System.out.printf("Product:傳送狀態=%s, 儲存queue=%s ,orderid=%s, type=%s\n", sendResult.getSendStatus(),
sendResult.getMessageQueue().getQueueId(), order.getOrderId(), order.getType());
}
producer.shutdown();
}
}
看看生產者有沒有把相同訂單指定到同一個queue
通過測試結果可以看出:相同訂單已經存到同一queue中了
。
3、Consumer(生產者)
上面說過,消費者真正要達到消費順序,需要分散式鎖,所以這裡需要將MessageListenerOrderly
替換之前的MessageListenerConcurrently,因為它裡面實現了分散式鎖。
@Slf4j
@Component
public class Consumer {
/**
* 消費者實體物件
*/
private DefaultMQPushConsumer consumer;
/**
* 消費者組
*/
public static final String CONSUMER_GROUP = "consumer_group";
/**
* 通過建構函式 例項化物件
*/
public Consumer() throws MQClientException {
consumer = new DefaultMQPushConsumer(CONSUMER_GROUP);
consumer.setNamesrvAddr("IP:9876");
//TODO 這裡真的是個坑,我product設定VipChannelEnabled(false),但消費者並沒有設定這個引數,之前傳送普通訊息的時候也沒有問題。能正常消費。
//TODO 但在順序訊息時,consumer一直不消費訊息了,找了好久都沒有找到原因,直到我這裡也設定為VipChannelEnabled(false),竟然才可以消費訊息。
consumer.setVipChannelEnabled(false);
//訂閱主題和 標籤( * 代表所有標籤)下資訊
consumer.subscribe(JmsConfig.TOPIC, "*");
//註冊消費的監聽 這裡注意順序消費為MessageListenerOrderly 之前併發為ConsumeConcurrentlyContext
consumer.registerMessageListener((MessageListenerOrderly) (msgs, context) -> {
//獲取訊息
MessageExt msg = msgs.get(0);
//消費者獲取訊息 這裡只輸出 不做後面邏輯處理
log.info("Consumer-執行緒名稱={},訊息={}", Thread.currentThread().getName(), new String(msg.getBody()));
return ConsumeOrderlyStatus.SUCCESS;
});
consumer.start();
}
}
看看消費結果是不是我們需要的結果
通過測試結果我們看出
1、消費訊息的順序並沒有完全按照之前的先進先出,即沒有滿足全域性順序。
2、同一訂單來講,訂單的 訂單生成、訂單支付、訂單完成 消費順序是保證的。
這是區域性保證順序消費就已經滿足我們當前實際開發中的需求了。
有關消費端選擇MessageListenerOrderly
後,consumer.start()啟動相關的原始碼可以參考部落格:RocketMQ順序訊息消費端原始碼
只要自己變優秀了,其他的事情才會跟著好起來(上將4)