rocketmq-事務消息

更多請移步我的博客

前言

之前有轉載過一篇關於分佈式事務最終一致的MQ實現的文章,當時也是碰到了分佈式事務的情形,最後按照文章的思路利用rmq實現了數據的最終一致。不太清楚分佈式事務的,可以先看下這邊文章瞭解下。

PS:本篇默認你已經瞭解rmq的一些基礎並看過部分源代碼,建議在看該篇時,先看下官方的文檔RocketMQ事務消息

牽涉到的分佈式的話題一般都會提到CAP,從知乎上打撈來一份比較好解釋。

P 意指分區容忍性。 所謂分區指的是網絡分區的意思。詳細一點解釋,比如你有A B兩臺服務器,它們之間是有通信的,突然,不知道爲什麼,它們之間的網絡鏈接斷掉了。
好了,那麼現在本來AB在同一個網絡現在發生了網絡分區,變成了A所在的A網絡和B所在的B網絡。所謂的分區容忍性,就是說一個數據服務的多臺服務器在發生了上述情況的時候,
依然能繼續提供服務。所以顯而易見的,P是大前提,如果P發生了,咱們的數據服務直接不服務了,還談個毛的可用性和一致性呢。因此CAP要解釋成,當P發生的時候,A和C只能而選一。
舉個簡單的例子,A服務器B服務器同步數據,現在A B之間網絡斷掉了,那麼現在發來A一個寫入請求,但是B卻沒有相關的請求,顯然,如果A不寫,保持一致性,那麼我們就失去了A的服務,
但是如果A寫了,跟B的數據就不一致了,我們自然就喪失了一致性。這裏設計就涉及到架構師的選擇了。注意這裏的一致性是強一致性,意思是AB的數據時刻都是同步的,
如果我們放棄了強一致性,不代表我們的數據就是一定是不一致的了,我們可以讓A先寫入本地,等到通信恢復了再同步給B,這就是所謂的最終一致性,長遠的看我們的數據還是一致的,
我們只是在某一個時間窗口裏數據不一致罷了。如果這個時間窗口小過了用戶邏輯處理的時間。那麼其實對於用戶來說根本毛都感覺不到。
最終一致性有個很有意思的協議叫gossip就跟傳八卦一個意思,我就把我收到裏信息裏我本地沒有的部分加到我本地,再把這個信息發出去,那麼長遠的看,網絡時好時壞,
但是最終所有人都會有所有的信息。因此我們還是能夠保證數據的最終一致性的。綜上,CAP應該描述成,當發生網絡分區的時候,如果我們要繼續服務,那麼強一致性和可用性只能2選1。

事務消息之前的實現

rmq不提供事務消息之前,通過“本地事務存根+rmq消息語義”來實現事務的最終一致性。

本地最終一致

如上圖,其實就是每個涉及到分佈式事務的應用自己要有一張表來存儲需要發送的消息,保證消息一定可以在本地事務完成後可以被髮送到broker端。然後依賴“至少消費一次”的rmq語義來確保消息的投遞,當然,消費端需要做冪等設計。

這麼做除了各個業務段冗餘一張表和一個兜底任務外,也無不妥。

rmq實現

事務消息的主要目標:確保本地事務執行完成後,一定會有通知到broker端。要達成這個目標,broker就需要知道每個在執行的事務,並且能在事務超時未發送結束消息時主動去問詢事務執行的情況,覈對完事務執行情況後再決定是否將事務完成消息推送給consumer。那rmq怎麼收集執行的本地事務呢?這個當然需要事務本身在開啓前進行上報。簡單來講事務消息就是要實現預約-履約-回查的場景。

rmq事務消息模型

rmq事務消息圍繞着兩個topic展開RMQ_SYS_TRANS_HALF_TOPIC(half_topic)和RMQ_SYS_TRANS_OP_HALF_TOPIC(op_half_topic)。

`RMQ_SYS_TRANS_HALF_TOPIC`用來記錄一個事務;
`RMQ_SYS_TRANS_OP_HALF_TOPIC`用來記錄事務的執行狀態。
這兩個topic均爲rmq的系統topic。

結合上圖,描述下事務消息的一般流程:

  1. 發送開啓事務消息(half消息)。

  2. 服務端響應消息響應寫入結果。該步驟中會默認將目標topic和queueId進行替換,源topic和queueId當作屬性值記到消息體中,half消息對生產和消費方均不可見。

  3. 根據發送結果執行本地事務,併發送(異步發送)本地事務執行的狀態。

  4. 根據本地事務狀態執行Commit或者Rollback。如果是Commit,就取出half消息,新建消息,並拷貝half消息的內容,同時把topic和queueId設置爲half消息屬性值中的topic和queueId,即:還原源消息,然後將新建的消息進行存儲並在op_half_topic創建操作記錄,此時,消費方可以看到並消費事務消息。

PS:改變消息topic是rmq的常用方法,延時消息也是靠這種方式實現。

Producer

rmq在生產Client中使用模版消息封裝了事務開啓消息及實務完結消息的發送,開發者只需要實現自己本地事務和定義事務消息數據結構即可(DefaultMQProducerImpl#sendMessageInTransaction)。

事務消息存在三種狀態:

public enum LocalTransactionState {
	/**
     * 本地事務執行成功
     */
    COMMIT_MESSAGE,

    /**
     * 本地事務回滾
     */
    ROLLBACK_MESSAGE,

    /**
     * 本地事務狀態未知
     * 該種狀態下,broker會按照配置來檢查事務的執行狀態
     */
    UNKNOW,
}

至於狀態對應的場景,自己擼下代碼吧,比較簡單。

Broker

Broker端對Producer事務響應的處理代碼在EndTransactionProcessor中,邏輯也比較簡單。

OperationResult result = new OperationResult();
if (MessageSysFlag.TRANSACTION_COMMIT_TYPE == requestHeader.getCommitOrRollback()) {
    // 查詢half消息
    result = this.brokerController.getTransactionalMessageService().commitMessage(requestHeader);
    if (result.getResponseCode() == ResponseCode.SUCCESS) {
        // 檢查消息與生產方提交的消息信息是否一致
        RemotingCommand res = checkPrepareMessage(result.getPrepareMessage(), requestHeader);
        if (res.getCode() == ResponseCode.SUCCESS) {
            // 製作新的事務消息,將half消息拷貝到新建的消息(topic等信息寫成源信息)
            MessageExtBrokerInner msgInner = endMessageTransaction(result.getPrepareMessage());
            // 消息投遞到源目標topic
            RemotingCommand sendResult = sendFinalMessage(msgInner);
            if (sendResult.getCode() == ResponseCode.SUCCESS) {
                // 創建刪除half消息的操作消息
                this.brokerController.getTransactionalMessageService().deletePrepareMessage(result.getPrepareMessage());
            }
            return sendResult;
        }
        return res;
    }
} else if (MessageSysFlag.TRANSACTION_ROLLBACK_TYPE == requestHeader.getCommitOrRollback()) {
    // 查詢half消息
    result = this.brokerController.getTransactionalMessageService().rollbackMessage(requestHeader);
    if (result.getResponseCode() == ResponseCode.SUCCESS) {
        RemotingCommand res = checkPrepareMessage(result.getPrepareMessage(), requestHeader);
        if (res.getCode() == ResponseCode.SUCCESS) {
            // 創建刪除half消息的操作消息
            this.brokerController.getTransactionalMessageService().deletePrepareMessage(result.getPrepareMessage());
        }
        return res;
    }
}

不管事務結束消息成功與否,總會記錄一條操作消息(RMQ_SYS_TRANS_OP_HALF_TOPIC(op_half_topic)),這個topic究竟有什麼用呢?
我們知道rmq對文件是連續寫隨機讀的,這樣就意味着我們不可能想操作數據庫那樣可以update/delete一條記錄,所以rmq在處理事務消息的消息補償-回查邏輯時就利用RMQ_SYS_TRANS_HALF_TOPIC(half_topic)和RMQ_SYS_TRANS_OP_HALF_TOPIC(op_half_topic)這兩個topic來判斷哪些消息處理完成了,哪些消息需要發起回查。

在broker啓動時,會創建一個任務(消費者)來定時消費這兩個topic,具體消費代碼在TransactionalMessageServiceImpl#check()中。

String topic = MixAll.RMQ_SYS_TRANS_HALF_TOPIC;
// 獲取半消息隊列,只有1個
Set<MessageQueue> msgQueues = transactionalMessageBridge.fetchMessageQueues(topic);
for (MessageQueue messageQueue : msgQueues) {
    long startTime = System.currentTimeMillis();
    // 獲取半消息隊列對應的操作記錄隊列
    MessageQueue opQueue = getOpQueue(messageQueue);
    // 獲取兩個隊列的消費進度
    long halfOffset = transactionalMessageBridge.fetchConsumeOffset(messageQueue);
    long opOffset = transactionalMessageBridge.fetchConsumeOffset(opQueue);
    
    List<Long> doneOpOffset = new ArrayList<>();
    HashMap<Long, Long> removeMap = new HashMap<>();
    // 從操作隊列中取出32個
    // 如果操作操作記錄中的halfoffset比消費進度還小,表示該消息已經處理(removeMap),否則表示該消息需要在本次處理完成(doneOpOffset)
    PullResult pullResult = fillOpRemoveMap(removeMap, opQueue, opOffset, halfOffset, doneOpOffset);

    // single thread
    int getMessageNullCount = 1;
    long newOffset = halfOffset;
    long i = halfOffset;
    while (true) {
        // 超過一次檢查的耗時,結束本次檢查,等待下次調度
        if (System.currentTimeMillis() - startTime > MAX_PROCESS_TIME_LIMIT) {
            break;
        }
        if (removeMap.containsKey(i)) {
            // 如果消息已經被處理過,跳過進行下一個
            removeMap.remove(i);
        } else {
            // 獲取半消息
            GetResult getResult = getHalfMsg(messageQueue, i);
            MessageExt msgExt = getResult.getMsg();
            if (msgExt == null) {
                // 如果半消息獲取爲null次數超過 MAX_RETRY_COUNT_WHEN_HALF_NULL=1,終止本次檢查
                if (getMessageNullCount++ > MAX_RETRY_COUNT_WHEN_HALF_NULL) {
                    break;
                }
                // 如果沒有拉到新消息,終止本次檢查
                if (getResult.getPullResult().getPullStatus() == PullStatus.NO_NEW_MSG) {
                    break;
                } else {
                    // 沒有明確的失敗原因,重試該偏移量的消息
                    i = getResult.getPullResult().getNextBeginOffset();
                    newOffset = i;
                    continue;
                }
            }
            // 消息是否已經超過最大檢查次數 || 消息所在的文件已經超過系統配置的保留時間(默認72小時)
            if (needDiscard(msgExt, transactionCheckMax) || needSkip(msgExt)) {
                listener.resolveDiscardMsg(msgExt);
                newOffset = i + 1;
                i++;
                continue;
            }
            // 如果檢查任務開始的時間小於消息存儲的時間,不必再繼續本次檢查任務
            // 可能事務尚未完成,不必多餘檢查
            if (msgExt.getStoreTimestamp() >= startTime) {
                log.debug("Fresh stored. the miss offset={}, check it later, store={}", i,
                    new Date(msgExt.getStoreTimestamp()));
                break;
            }

            // 從時間差來看事務是否需要發起檢查
            long valueOfCurrentMinusBorn = System.currentTimeMillis() - msgExt.getBornTimestamp();
            long checkImmunityTime = transactionTimeout;
            // 消息本身是否設置了事務超時回查時間
            String checkImmunityTimeStr = msgExt.getUserProperty(MessageConst.PROPERTY_CHECK_IMMUNITY_TIME_IN_SECONDS);
            if (null != checkImmunityTimeStr) {
                // 如果消息自己設置有固定的檢查時間
                checkImmunityTime = getImmunityTime(checkImmunityTimeStr, transactionTimeout);
                if (valueOfCurrentMinusBorn < checkImmunityTime) {
                    // 如果事務消息尚未到檢查時間,先檢查該條消息的源消息是否被刪除(removeMap)
                    // 如果沒有被刪除,就將消息重新投遞到半隊列,等待下次檢查,並將消費進度向前推進
                    if (checkPrepareQueueOffset(removeMap, doneOpOffset, msgExt)) {
                        newOffset = i + 1;
                        i++;
                        continue;
                    }
                }
            } else {
                // 說明消息爲新消息,不必再繼續本次檢查任務
                if ((0 <= valueOfCurrentMinusBorn) && (valueOfCurrentMinusBorn < checkImmunityTime)) {
                    break;
                }
            }
            List<MessageExt> opMsg = pullResult.getMsgFoundList();
            // valueOfCurrentMinusBorn <= -1 是什麼場景時出現???
            boolean isNeedCheck = (opMsg == null && valueOfCurrentMinusBorn > checkImmunityTime)
                || (opMsg != null && (opMsg.get(opMsg.size() - 1).getBornTimestamp() - startTime > transactionTimeout))
                || (valueOfCurrentMinusBorn <= -1);

            if (isNeedCheck) {
                // 將消息重新投遞到half隊列
                if (!putBackHalfMsgQueue(msgExt, i)) {
                    continue;
                }
                // 異步向客戶端發起檢查命令
                listener.resolveHalfMsg(msgExt);
            } else {
                // 取出操作隊列數據,繼續檢查
                pullResult = fillOpRemoveMap(removeMap, opQueue, pullResult.getNextBeginOffset(), halfOffset, doneOpOffset);
                continue;
            }
        }
        newOffset = i + 1;
        // 將消費進度向前推進
        i++;
    }
    // 計算並更新隊列的消費進度
    if (newOffset != halfOffset) {
        transactionalMessageBridge.updateConsumeOffset(messageQueue, newOffset);
    }
    long newOpOffset = calculateOpOffset(doneOpOffset, opOffset);
    if (newOpOffset != opOffset) {
        transactionalMessageBridge.updateConsumeOffset(opQueue, newOpOffset);
    }
}

上面這段代碼便是補償邏輯的核心了,看懂這塊代碼,整個事務消息的處理也就基本明白的差不多了。

總結

在看這段代碼時我有一個疑問:

  1. 爲什麼消息回查時,要重新提交一條消息到half隊列呢?

自己思考的答案:

  1. 因爲rmq順序寫,同時消費進度需要向前推進,不能夠因爲某個消息有問題影響消息的處理(單線程處理),基於rmq的底層實現和特性,提交一條新的消息除了佔用一些存儲空間外,處理問題的複雜度和時間消耗均能得到保證。

小生不才,以上如有描述有誤的地方還望各位不吝賜教 !_

源代碼版本:release-4.5.0 貼出的源代碼會有所刪減

參考

CAP
seata
消息事務樣例

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