RocketMQ(7)---RocketMQ順序消費

雨點的名字發表於2019-07-05

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 的順序進行消費。

RocketMQ(7)---RocketMQ順序消費

3、區域性順序

在實際開發有些場景中,我並不需要訊息完全按照完全按的先進先出,而是某些訊息保證先進先出就可以了。

就好比一個訂單涉及 訂單生成訂單支付訂單完成。我不用管其它的訂單,只保證同樣訂單ID能保證這個順序就可以了。

RocketMQ(7)---RocketMQ順序消費


二、實現原理

我們知道 生產的message最終會存放在Queue中,如果一個Topic關聯了16個Queue,如果我們不指定訊息往哪個佇列裡放,那麼預設是平均分配訊息到16個queue,

好比有100條訊息,那麼這100條訊息會平均分配在這16個Queue上,那麼每個Queue大概放5~6個左右。這裡有一點很重的是:

同一個queue,儲存在裡面的message 是按照先進先出的原則

這個時候思路就來了,好比有orderId=1的3條訊息,分別是 訂單生產訂單付款訂單完成。只要保證它們放到同一個Queue那就保證消費者先進先出了。

RocketMQ(7)---RocketMQ順序消費

這就保證區域性順序了,即同一訂單按照先後順序放到同一Queue,那麼取訊息的時候就可以保證先進先取出。

那麼全域性訊息呢?

這個就簡單啦,你把所有訊息都放在一個Queue裡,這樣不就保證全域性訊息了。

就這麼簡單

當然不是,這裡還有很關鍵的一點,好比在一個消費者叢集的情況下,消費者1先去Queue拿訊息,它拿到了 訂單生成,它拿完後,消費者2去queue拿到的是 訂單支付

拿的順序是沒毛病了,但關鍵是先拿到不代表先消費完它。會存在雖然你消費者1先拿到訂單生成,但由於網路等原因,消費者2比你真正的先消費訊息。這是不是很尷尬了。

訂單付款還是可能會比訂單生成更早消費的情況。那怎麼辦。

分散式鎖來了

Rocker採用的是分段鎖,它不是鎖整個Broker而是鎖裡面的單個Queue,因為只要鎖單個Queue就可以保證區域性順序消費了。

所以最終的消費者這邊的邏輯就是

消費者1去Queue拿 訂單生成,它就鎖住了整個Queue,只有它消費完成並返回成功後,這個鎖才會釋放。

然後下一個消費者去拿到 訂單支付 同樣鎖住當前Queue,這樣的一個過程來真正保證對同一個Queue能夠真正意義上的順序消費,而不僅僅是順序取出。

全域性順序與分割槽順序對比

訊息型別對比
RocketMQ(7)---RocketMQ順序消費

傳送方式對比

RocketMQ(7)---RocketMQ順序消費

其它的注意事項

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

RocketMQ(7)---RocketMQ順序消費

通過測試結果可以看出:相同訂單已經存到同一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();
    }
}

看看消費結果是不是我們需要的結果

RocketMQ(7)---RocketMQ順序消費

通過測試結果我們看出

1、消費訊息的順序並沒有完全按照之前的先進先出,即沒有滿足全域性順序。
2、同一訂單來講,訂單的 訂單生成、訂單支付、訂單完成 消費順序是保證的。

這是區域性保證順序消費就已經滿足我們當前實際開發中的需求了。

有關消費端選擇MessageListenerOrderly後,consumer.start()啟動相關的原始碼可以參考部落格:RocketMQ順序訊息消費端原始碼




只要自己變優秀了,其他的事情才會跟著好起來(上將4)

相關文章