SpringCloud+RocketMQ實現分散式事務

黃青石發表於2021-10-27

  隨著網際網路公司的微服務越來越多,分散式事務已經成為了我們的經常使用的。所以我們來一步一步的實現基於RocketMQ的分散式事務。接下來,我們將要做的主題寫出來。

  • RocketMQ的分散式事務結構和說明
  • 搭建RocketMQ步驟
  • 事務場景,然後準備工程,執行程式碼

  一、RocketMQ的分散式事務結構和說明

  我們通過下圖來了解一下RocketMQ實現分散式事務的結構。採用半訊息機制實現分散式事務,半訊息顧名思義,就是傳送方將訊息傳送到MQ中的Broker端,這個訊息被標記為“暫不投遞”狀態,這個時間訂閱方是不能收到這個訊息的,當傳送方將提交了Commit後,這個訊息才可以被訂閱方收到。如果傳送方提交了Rollback,那麼訂閱方就不會收到以該訊息。

  RocketMQ採用的是最終一致性事務,因為它使用了半訊息機制,訂閱方可能收不到訊息,但是最終狀態是能保持一致的。

  我們在講這個流程前,我們需要先看下圖的紅色部分,當傳送方發給MQ的Commit/Rollback沒有收到的時候,這個時候需要啟用一個執行緒,從本地事務庫裡邊查詢到當前的狀態,根據當前的狀態來決定是否重新傳送Commit/Rollback,相當於重試。 

 

 

 

   接下來,我們說一下整個執行的過程。

  1. 傳送方將半訊息傳送到MQ中,成功後MQ將訊息保持好後將結果同步給傳送方,即半訊息傳送成功。
  2. 當MQ告訴傳送方成功後,我們需要記錄下傳送到MQ的相關的事情,比如傳送的訊息內容,傳送時間,傳送的相關ID,比如事務ID,都要做好相應記錄。
  3. 當本地事務庫,也可以理解為日誌庫,我們可以針對發生的問題進行追溯。
  4. 本地庫記錄好事務後,傳送方就可以將Commit/Rollback提交到MQ了。
  5. 當MQ收到了Commit命令後,這個時候就會將該條訊息傳送給訂閱方。收到了Rollback後,那該條訊息不會投遞給訂閱方,訊息儲存3天后刪除。

   整個過程就是這些,大家如果覺得有遺漏的,可以告訴我。

  二、搭建RocketMQ

  我使用的是windos10, 所以需要下載windows版本。

    進入到RocketMQ的官方地址: http://rocketmq.apache.org/release_notes/release-notes-4.3.0/

    下載二進位制檔案壓縮包。如下圖:

 

 

   先解壓,然後進行環境變數配置,開啟選單,直接輸入環境變數。

    變數名:ROCKETMQ_HOME

    值: 解壓的release的路徑,如 D:\rocketmq-all-4.3.0-bin-release

  然後開啟CMD命令,進入到解壓路徑中的bin目標,進行nameserver啟動。

start mqnameserv.cmd

  啟動後的效果為:

 

 

   再進行broker的啟動,啟動需要連到的nameserver為127.0.0.1:9876,開啟自動建立Topic,這個時候當用到某個Topic沒有的情況下會自動建立一個。

start mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true

  

 

   這個時候Windows的rocketmq就下載好並且啟動成功了。

   三、事務場景,然後準備工程,執行程式碼

  因為我們使用的RocketMq是使用的分散式事務是最終一致性的,柔性的,所以我們使用的場景也要考慮到。我們用的場景就是下訂單充話費,使用者支付了訂單,那麼直接給使用者的進行充值。購買的訂單不會立馬充值到手機的,需要等一會才到賬,這就是最終一致性。

  我們使用的如下程式碼版本號,SpringCloud的版本號要和SpringBoot保持一致,否則會出現類找不到的情況。

SpringCloud(Finchley.RELEASE) + SpringBoot2.0.4.RELEASE +  RocketMQ4.3 +MySQL + lombok(外掛)

   我們使用SpringCloud的幾個元件:

  Euerka Server : 用於提供服務註冊的能力和發現

  Euerka Client :  用於進行服務註冊

  Feign: 用於服務間的呼叫

  Config : 用於進行配置

  接下來,我們建立需要進行配置的表。

DROP TABLE IF EXISTS `spring_cloud_config`;

CREATE TABLE `spring_cloud_config` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `akey` varchar(30) DEFAULT NULL,
  `avalue` varchar(128) DEFAULT NULL,
  `application` varchar(30) DEFAULT NULL,
  `aprofile` varchar(30) DEFAULT NULL,
  `label` varchar(30) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;



INSERT INTO `spring_cloud_config` (`id`, `akey`, `avalue`, `application`, `aprofile`, `label`)
VALUES
    (2,'name_server','127.0.0.1:9876','product-service','dev','dev'),
    (3,'name_server','127.0.0.1:9876','order-service','dev','dev'),
    (4,'order_topic','order_topic','order-service','dev','dev'),
    (5,'order_topic','order_topic','product-service','dev','dev');

  我們構建了4個模組,eureka模組用於服務註冊和發現,springcloud-config將資料庫建立的配置載入供其他工程使用。charge-order-service 用於下充值訂單,phone-charge-service 用於給手機進行充值業務。

           

 

   主要的流程為,通過charge-order-service產生了訂單,然後將訂單和數量到phone-charge-service進行二次確認看是否還有足夠的數量可以使用,如果數量不夠的話直接將事務進行rollback,這樣訊息就不會到消費端。如果下過去的訂單號已經充值過了,那麼該訊息將會被直接丟掉,這也是訊息端的冪等設計。

  接下來,我們看一下生產者以及事務的核心程式碼。主要用於連線RocketMQ,  執行本地事務,在本地事務中進行二次確認,根據結果進行Commit、Rollback。 當連線RocketMQ出現問題的時候可能會收到UNKNOWN, 這個時候會呼叫 checkLocalTransaction() 方法,用於檢查是否將訊息傳送給RocketMQ了。

package com.hqs.chargeorderservice.mqservice;

import com.alibaba.fastjson.JSONObject;
import com.hqs.chargeorderservice.config.Jms;
import com.hqs.chargeorderservice.service.ProduceOrderService;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.LocalTransactionState;
import org.apache.rocketmq.client.producer.TransactionListener;
import org.apache.rocketmq.client.producer.TransactionMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.concurrent.*;


/**
 *
 * @Description: 分散式事務RocketMQ 生產者
 */
@Slf4j
@Component
public class TransactionProducer {

    /**
     * 需要自定義事務監聽器 用於 事務的二次確認 和 事務回查
     */
    private TransactionListener transactionListener ;

    /**
     * 這裡的生產者和之前的不一樣
     */
    private TransactionMQProducer producer = null;

    /**
     * 官方建議自定義執行緒 給執行緒取自定義名稱 發現問題更好排查
     */
    private ExecutorService executorService = new ThreadPoolExecutor(2, 5, 100, TimeUnit.SECONDS,
            new ArrayBlockingQueue<Runnable>(2000), new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            Thread thread = new Thread(r);
            thread.setName("client-transaction-msg-check-thread");
            return thread;
        }

    });

    public TransactionProducer(@Autowired Jms jms, @Autowired ProduceOrderService produceOrderService) {
        transactionListener = new TransactionListenerImpl(produceOrderService);
        // 初始化 事務生產者
        producer = new TransactionMQProducer(jms.getOrderTopic());
        // 新增伺服器地址
        producer.setNamesrvAddr(jms.getNameServer());
        // 新增事務監聽器
        producer.setTransactionListener(transactionListener);
        // 新增自定義執行緒池
        producer.setExecutorService(executorService);

        start();
    }

    public TransactionMQProducer getProducer() {
        return this.producer;
    }

    /**
     * 物件在使用之前必須要呼叫一次,只能初始化一次
     */
    public void start() {
        try {
            this.producer.start();
        } catch (MQClientException e) {
            e.printStackTrace();
        }
    }

    /**
     * 一般在應用上下文,使用上下文監聽器,進行關閉
     */
    public void shutdown() {
        this.producer.shutdown();
    }
}

/**
 * @Description: 自定義事務監聽器
 */
@Slf4j
class TransactionListenerImpl implements TransactionListener {

    @Autowired
    private ProduceOrderService produceOrderService ;

    public TransactionListenerImpl( ProduceOrderService produceOrderService) {
        this.produceOrderService = produceOrderService;
    }

    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        log.info("=========本地事務開始執行=============");
        String message = new String(msg.getBody());
        JSONObject jsonObject = JSONObject.parseObject(message);
        Integer productId = jsonObject.getInteger("productId");
        Integer total = jsonObject.getInteger("total");
        Integer tradeNo = jsonObject.getInteger("tradeNo");
        int userId = Integer.parseInt(arg.toString());
        //模擬執行本地事務begin=======
        /**
         * 本地事務執行會有三種可能
         * 1、commit 成功
         * 2、Rollback 失敗
         * 3、網路等原因服務當機收不到返回結果
         */
        log.info("本地事務執行引數,使用者id={},商品ID={},銷售庫存={}",userId,productId,total);
        int result = produceOrderService.save(userId, productId, total, tradeNo);
        //模擬執行本地事務end========
        //TODO 實際開發下面不需要我們手動返回,而是根據本地事務執行結果自動返回
        //1、二次確認訊息,然後消費者可以消費
        if (result == 0) {
            return LocalTransactionState.COMMIT_MESSAGE;
        }
        //2、回滾訊息,Broker端會刪除半訊息
        if (result == 1) {
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
        //3、Broker端會進行回查訊息
        if (result == 2) {
            return LocalTransactionState.UNKNOW;
        }
        return LocalTransactionState.COMMIT_MESSAGE;
    }

    /**
     * 只有上面介面返回 LocalTransactionState.UNKNOW 才會呼叫查介面被呼叫
     *
     * @param msg 訊息
     * @return
     */
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        log.info("==========回查介面=========");
        String key = msg.getKeys();
        //TODO 1、必須根據key先去檢查本地事務訊息是否完成。
        /**
         * 因為有種情況就是:上面本地事務執行成功了,但是return LocalTransactionState.COMMIT_MESSAG的時候
         * 服務掛了,那麼最終 Brock還未收到訊息的二次確定,還是個半訊息 ,所以當重新啟動的時候還是回撥這個回撥介面。
         * 如果不先查詢上面本地事務的執行情況 直接在執行本地事務,那麼就相當於成功執行了兩次本地事務了。
         */
        // TODO 2、這裡返回要麼commit 要麼rollback。沒有必要在返回 UNKNOW
        return LocalTransactionState.COMMIT_MESSAGE;
    }
}

  我們啟動一下程式。我們登入localhost:7001註冊中心,看到3個服務都成功註冊了。

 

   然後我們進行下訂單呼叫介面, userId 使用者編碼, productId 產品號,total 購買量, tradeNo 交易訂單號

http://localhost:9001/api/v1/order/chargeOrder?userId=1&productId=1&total=1&tradeNo=4

  charge-order-service顯示出來本地執行事務的引數。

 

   phone-charge-service消費事務執行的日誌。

 

   當我們在建立charge-order-service和phone-charge-service的時候一定要注意,將工程裡邊的application.properties變為bootstrap.yml,要不然程式啟動的時候會從8888找config的配置內容。因為SpringCloud裡面有個“啟動上下文”,主要是用於載入遠端的配置,也就是載入ConfigServer裡面的配置,預設載入順序為:載入bootstrap.*裡面的配置 --> 連結configserver,載入遠端配置 --> 載入application.*裡面的配置。

  好了,程式碼也放到git地址了,https://github.com/stonehqs/springcloud-rocketmq-transaction, 有問題歡迎留言。

  

  

 

 

  

 

相關文章