【RocketMQ】高階使用:四個問題詳解事務訊息

A minor發表於2020-11-19

RocketMQ和其他訊息中介軟體最大的一個區別是支援了事務訊息,這也是分散式事務裡面的基於訊息的最終一致性方案。

1.事務訊息是什麼?

事務訊息:具有事務特性的訊息,即Producer傳送到broker後,該訊息可以回滾或者提交(提交後Consumer才可見)。

2.事務訊息有什麼用?

RocketMQ官方示例:使用者A發起訂單,支付100塊錢操作完成後,能得到100積分,賬戶服務和會員服務是兩個獨立的微服務模組,有各自的資料庫,按照上文提及的問題可能性,將會出現這些情況:

  • 如果先扣款,再發訊息,可能錢剛扣完,當機了,訊息沒發出去,結果積分沒增加。

  • 如果先發訊息,再扣款,可能積分增加了,但錢沒扣掉,白送了100積分給人家。

// 先扣款,再加積分虛擬碼:
@Transational
pay() {
    mysql.payMoney() // 資料庫中新增使用者資訊
    reduceRepo() // 資料庫中減少庫存  
}
-------------------------------------// 若先發訊息再加積分,那在這行當機怎麼辦?
producer.send(msg) // 傳送100積分

==> 所以上述方式不可行,我們可以先傳送訊息(加積分)到Broker,但將訊息置為Consumer不可見狀態

  • 若本地事務(扣款)處理成功了再讓Consumer可見
  • 若本地事務(扣款)失敗了就回滾當前訊息

這裡可能會存在一個問題,生產者本地事務成功後,傳送事務確認訊息到broker上失敗了怎麼辦?
這個時候意味著消費者無法正常消費到這個訊息。所以RocketMQ提供了訊息回查機制,如果事務訊息一直處於中間狀態,broker會發起重試去查詢broker上這個事務的處理狀態。一旦發現事務處理成功,則把當前這條訊息設定為可見

整體的模型圖如下:

在這裡插入圖片描述

從上例我們看見,事務訊息一般先於本地事務使用。這裡也可以理解成巢狀事務,發訊息是外層事務,本地事務是記憶體事務。

3.java使用事務訊息?

針對上面的示例,我們來看看如何通過具體的程式碼實現。

TransactionProducer

public class TransactionProducer {
    public static void main(String[] args) throws Exception {  
        // 這裡用的是事務Producer(TransactionMQProducer)
        TransactionMQProducer transactionProducer=new TransactionMQProducer("tx_producer_group");
        transactionProducer.setNamesrvAddr("43.105.136.120:9876");    
        // 自定義執行緒池,用於非同步執行事務操作     
        transactionProducer.setExecutorService(Executors.newFixedThreadPool(10); );  
        // 核心!!新增事務訊息監聽     
        transactionProducer.setTransactionListener(new TransactionListenerLocal());
        transactionProducer.start();   
        
        for(int i=0;i<20;i++) {       
            String orderId= UUID.randomUUID().toString();    
            String body="{'operation':'doOrder','orderId':'"+orderId+"'}";     
            // 構建訊息
            Message message = new Message("pay_tx_topic", "TagA",orderId, body.getBytes(RemotingHelper.DEFAULT_CHARSET));  
            // 傳送訊息, 注:是傳送事務訊息
            transactionProducer.sendMessageInTransaction(message, orderId+"&"+i);   
            Thread.sleep(1000); // 1秒一次
        }
    } 
}

TransactionListenerLocal(事務訊息核心)

// 本地事務監聽,實現TransactionListener介面
public class TransactionListenerLocal implements TransactionListener {
    private static final Map<String,Boolean> results=new ConcurrentHashMap<>();
    
    // 執行本地事務   
    @Override   
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { 
        System.out.println(":執行本地事務:"+arg.toString());    
        String orderId=arg.toString();  
        // 模擬資料入庫操作(成功/失敗)
        boolean rs=saveOrder(orderId);
        return rs? LocalTransactionState.COMMIT_MESSAGE:LocalTransactionState.UNKNOW;  
        // 這個返回狀態表示告訴broker這個事務訊息是否被確認,允許給到consumer進行消費    
        // LocalTransactionState.ROLLBACK_MESSAGE 回滾    
        // LocalTransactionState.UNKNOW  未知   
    }  
    
    // 提供事務執行狀態的回查方法,提供給broker回撥   
    @Override   
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {  
        String orderId=msg.getKeys();     
        System.out.println("執行事務執行狀態的回查,orderId:"+orderId);  
        boolean rs=Boolean.TRUE.equals(results.get(orderId));   
        System.out.println("回撥:"+rs);     
        return rs?LocalTransactionState.COMMIT_MESSAGE:     
      							  LocalTransactionState.ROLLBACK_MESSAGE;  
    }   
    
    private boolean saveOrder(String orderId){    
        //如果訂單取模等於0,表示成功,否則表示失敗  
        boolean success=Math.abs(Objects.hash(orderId))%2==0;   
        results.put(orderId,success);    
        return success;  
    } 
}

TransactionConsumer

public class TransactionConsumer {
    public static void main(String[] args) throws MQClientException, IOException {   
        DefaultMQPushConsumer defaultMQPushConsumer=new      
            DefaultMQPushConsumer("tx_consumer_group");   
        defaultMQPushConsumer.setNamesrvAddr("43.105.136.120:9876");       
        defaultMQPushConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_O FFSET);   
        defaultMQPushConsumer.subscribe("pay_tx_topic","*");    
         
        defaultMQPushConsumer.registerMessageListener((MessageListenerConcurrently)
                  (msgs, context) -> {           
                      msgs.stream().forEach(messageExt -> {    
                          try {                 
                              String orderId=messageExt.getKeys();
                              // 拿到訊息
                              String body=new String(messageExt.getBody(), 
                                                     RemotingHelper.DEFAULT_CHARSET); 
                              // 扣減庫存
                              System.out.println("收到訊息:"+body+",開始扣減庫存:"+orderId);  
                          } catch (UnsupportedEncodingException e) {    
                              e.printStackTrace();                
                          }             
                      });   
                      return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;    
                  });     
        defaultMQPushConsumer.start();    
        System.in.read();  
    }
}

4.事務訊息的三種狀態?

在上面的 TransactionListenerLocal 類中,我們看見重寫的兩個方法都需要返回 LocalTransactionState,表示告訴broker對於這條已經存在了的事務訊息如何處理:

  1. ROLLBACK_MESSAGE:回滾事務

    當executeLocalTransaction方法返回ROLLBACK_MESSAGE時,表示直接回滾事務

  2. COMMIT_MESSAGE: 提交事務

  3. UNKNOW: broker會定時的回查Producer訊息狀態,直到徹底成功或失敗。

    當返回UNKNOW時,Broker會在一段時間之後回查checkLocalTransaction,根據 checkLocalTransaction返回狀態執行事務的操作(回滾或提交)

如示例中,當返回 ROLLBACK_MESSAGE 時消費者不會收到訊息,且不會呼叫回查函式,當返回 COMMIT_MESSAGE 時事務提交,消費者收到訊息,當返回UNKNOW時,在一段時間之後呼叫回查函式,並根據status判斷返回提交或回滾狀態,返回提交狀態的訊息將會被消費者消費,所以此時消費者可以消費部分訊息。

相關文章