基於rocketMq實現分佈式事務解決方案

前言

在處理分佈式事務的問題上,除了前幾篇談到的可以使用seata,Hmily保證事務的最終一致性之外,使用消息隊列也可以達到同樣的效果

使用消息中間件解決分佈式事務的問題,是在分佈式事務框架還沒有真正流行起來的時候比較常用的解決此類問題的手段。使用消息中間件解決分佈式事務問題,也有2種主要的思考方向,一種是通過消息表,另一種就是藉助消息中間件自身的事務特性,比如rocketMq在4.X版本之後提供了事務消息,可以藉助事務消息的特性來解決分佈式事務問題

業務場景

我們仍以用戶A向用戶B轉賬爲例,如果使用rocketMq來做,將會出現下面的情形

在這裏插入圖片描述

交互流程如下:

  1. Bank1向MQ Server發送轉賬消息
  2. Bank1執行本地事務,扣減金額
  3. Bank2接收消息,執行本地事務,添加金額

rocketMq事務消息機制

我們以下面的這張圖爲例進行上述轉賬過程的分析

在這裏插入圖片描述

  1. 用戶A【消息生產者】首先發送一條事務消息到rocketMq【這裏發送到MQ的broker】
  2. 發送成功後,broker會返回給本地事務一個通知,如果成功發送到broker,本地事務收到通知執行DB操作,這裏即爲扣減賬戶餘額
  3. 本地事務執行成功後,通知broker,此時broker將原來的消息變爲可消費狀態,即對consumer可見,從圖示上面理解爲,此時的消息爲可消費消息
  4. 消費者消費消息,並執行本地事務

比較難理解的就是半消息,因爲rocketMq自身的事務消息機制,需要將本地事務和broker上面的消息狀態進行綁定,因此需要一種可靠性的機制,確保兩方的執行狀態可靠,即可以簡單理解爲,初次發送到broker上面的消息是一個臨時消息,有一個狀態位控制消息可見性,等本地事務執行成功後,broker收到了本地事務的執行成功狀態後再去更新這條消息的狀態設置爲可見即可

對於生產者來說,只需要提供一個類,實現RocketMQLocalTransactionListener這個接口即可

環境準備

  • 數據庫:MySQL-5.7.25,包括bank1和bank2兩個數據庫。
  • rocketmq 服務端:RocketMQ-4.5.0 rocketmq
  • 客戶端:RocketMQ-Spring-Boot-starter.2.0.2-RELEASE
  • 微服務框架:spring-boot-2.1.3、spring-cloud-Greenwich.RELEASE

數據庫腳本以及要用到的表,我們在前一篇的hmily的分佈式事務講解中已經提供了,這裏不再貼出

工程搭建

工程結構如下:
在這裏插入圖片描述

  • bank-provider消息生產者,模擬用戶A轉賬給用戶B
  • bank-consumer消息生產者,模擬用戶A轉賬給用戶B

bank-provider工程搭建

1、添加pom依賴

<dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>


        <dependency>
            <groupId>javax.interceptor</groupId>
            <artifactId>javax.interceptor-api</artifactId>
        </dependency>


        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>2.0.2</version>
        </dependency>

        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-client</artifactId>
            <version>4.4.0</version>
        </dependency>

    </dependencies>

2、配置文件application.properties

spring.application.name = provider-bank1
server.port=7004
swagger.enable = true

spring.datasource.driver-class-name = com.mysql.jdbc.Driver
spring.datasource.url = jdbc:mysql://106.15.37.145:3306/bank1?useUnicode=true
spring.datasource.username = root
spring.datasource.password = root

rocketmq.producer.group = producer_bank1
rocketmq.name-server = 106.15.37.145:9876

logging.level.root = info
logging.level.org.springframework.web = info

server.servlet.context-path = /bank1
spring.http.encoding.enabled = true
spring.http.encoding.charset = UTF-8
spring.http.encoding.force = true
server.tomcat.remote_ip_header = x-forwarded-for
server.tomcat.protocol_header = x-forwarded-proto
server.use-forward-headers = true

spring.mvc.throw-exception-if-no-handler-found = true
spring.resources.add-mappings = true
spring.freemarker.enabled = true
spring.freemarker.suffix = .html
spring.freemarker.request-context-attribute = rc
spring.freemarker.content-type = text/html
spring.freemarker.charset = UTF-8

3、接口層

@Mapper
@Component
public interface AccountInfoDao {

    /**
     * 增加金額
     * @param accountNo
     * @param amount
     * @return
     */
    @Update("update account_info set account_balance=account_balance+#{amount} where account_no=#{accountNo}")
    int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);

    @Select("select * from account_info where where account_no=#{accountNo}")
    AccountInfo findByIdAccountNo(@Param("accountNo") String accountNo);

    /**
     * 查詢之前是否已經添加過
     * @param txNo
     * @return
     */
    @Select("select count(1) from de_duplication where tx_no = #{txNo}")
    int isExistTx(String txNo);

    @Insert("insert into de_duplication values(#{txNo},now());")
    int addTx(String txNo);

}

4、業務實現

@Service
public class AccountInfoServiceImpl implements AccountInfoService {

    @Autowired
    private AccountInfoDao accountInfoDao;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    private static Logger logger = LoggerFactory.getLogger(AccountInfoServiceImpl.class);

    /**
     * 發送MQ轉賬消息
     *
     * @param accountChangeEvent
     */
    public void sendUpdateAccountBalance(AccountChangeEvent accountChangeEvent) {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("accountChange", accountChangeEvent);
        String jsonString = jsonObject.toJSONString();
        //生成message類型
        Message<String> message = MessageBuilder.withPayload(jsonString).build();
        //發送一條事務消息
        String producerGroup = "producer_group_txmsg_bank1";
        String topicName = "topic_txmsg";
        rocketMQTemplate.sendMessageInTransaction(producerGroup, topicName, message, null);
    }

    /**
     * 更新賬戶,扣減金額
     *
     * @param accountChangeEvent
     */
    @Transactional
    public void doUpdateAccountBalance(AccountChangeEvent accountChangeEvent) {
        //冪等判斷
        Integer txNo = accountInfoDao.isExistTx(accountChangeEvent.getTxNo());
        if (txNo > 0) {
            logger.info("已經扣減過,無需重複扣減");
            return;
        }
        //扣減金額
        accountInfoDao.updateAccountBalance(accountChangeEvent.getAccountNo(), accountChangeEvent.getAmount() * -1);
        //添加事務日誌
        accountInfoDao.addTx(accountChangeEvent.getTxNo());
        if (accountChangeEvent.getAmount() == 3) {
            throw new RuntimeException("人爲製造異常");
        }
    }

}

其中,這個服務實現類主要完成的工作是,發送MQ轉賬消息以及更新賬戶,扣減金額,要注意的是冪等性處理,因此我們在“更新賬戶,扣減金額”方法中,使用了下面的這行代碼進行判斷

Integer txNo = accountInfoDao.isExistTx(accountChangeEvent.getTxNo());

5、消息監聽器

在本類中,我們需要實現RocketMQLocalTransactionListener這個接口,其作用就是,當我們要執行本地事務的時候,怎麼知道消息發送到broker是否成功了呢?就可以在其待實現的方法中可以獲取到,即executeLocalTransaction這個方法裏面進行執行

@Component
@RocketMQTransactionListener(txProducerGroup = "producer_group_txmsg_bank1")
public class ProducerTxmsgListener implements RocketMQLocalTransactionListener {

    private static Logger logger = LoggerFactory.getLogger(ProducerTxmsgListener.class);

    @Autowired
    AccountInfoService accountInfoService;

    @Autowired
    AccountInfoDao accountInfoDao;

    /**
     * 事務消息發送後的回調方法,當消息發送給mq成功,此方法被回調
     * @param message
     * @param o
     * @return
     */
    @Transactional
    public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
        try {
            //解析message,轉成AccountChangeEvent
            String messageString = new String((byte[]) message.getPayload());
            JSONObject jsonObject = JSONObject.parseObject(messageString);
            String accountChangeString = jsonObject.getString("accountChange");
            AccountChangeEvent accountChangeEvent = JSONObject.parseObject(accountChangeString, AccountChangeEvent.class);
            //執行本地事務,扣減金額
            accountInfoService.doUpdateAccountBalance(accountChangeEvent);
            //當返回RocketMQLocalTransactionState.COMMIT,自動向mq發送commit消息,mq將消息的狀態改爲可消費
            return RocketMQLocalTransactionState.COMMIT;
        } catch (Exception e) {
            e.printStackTrace();
            logger.error("執行本地事務失敗");
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }

    /**
     * 事務狀態回查,查詢是否扣減金額
     * @param message
     * @return
     */
    public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
        String messageString = new String((byte[]) message.getPayload());
        JSONObject jsonObject = JSONObject.parseObject(messageString);
        String accountChangeString = jsonObject.getString("accountChange");
        AccountChangeEvent accountChangeEvent = JSONObject.parseObject(accountChangeString, AccountChangeEvent.class);
        //事務id
        String txNo = accountChangeEvent.getTxNo();
        logger.info("事務id:{}",txNo);
        int existTx = accountInfoDao.isExistTx(txNo);
        if (existTx > 0) {
            logger.info("扣減金額成功");
            return RocketMQLocalTransactionState.COMMIT;
        } else {
            return RocketMQLocalTransactionState.UNKNOWN;
        }
    }

}

上述業務邏輯中用到的一個消息體封裝類AccountChangeEvent

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AccountChangeEvent implements Serializable {
    /**
     * 賬號
     */
    private String accountNo;
    /**
     * 變動金額
     */
    private double amount;
    /**
     * 事務號
     */
    private String txNo;

}

最後提供一個對外的接口,方便後面測試

@RestController
public class AccountInfoController {

    @Autowired
    private AccountInfoService accountInfoService;

    //http://localhost:7004/bank1/transfer?accountNo=1&amount=2
    @GetMapping(value = "/transfer")
    public String transfer(@RequestParam("accountNo") String accountNo, @RequestParam("amount") Double amount) {
        //創建一個事務id,作爲消息內容發到mq,後面將會在查詢事務狀態被用到
        String tx_no = UUID.randomUUID().toString();
        AccountChangeEvent accountChangeEvent = new AccountChangeEvent(accountNo, amount, tx_no);
        //發送消息
        accountInfoService.sendUpdateAccountBalance(accountChangeEvent);
        return "轉賬成功";
    }
}

bank-consumer工程搭建

consumer端主要用戶接收MQ的消息,然後執行本地增加金額的操作

1、pom依賴【同上】

2、配置文件【同上,修改數據庫連接信息以及context-path】

3、接口層

@Mapper
@Component
public interface AccountInfoDao {

    @Update("update account_info set account_balance=account_balance+#{amount} where account_no=#{accountNo}")
    int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);

    @Select("select count(1) from de_duplication where tx_no = #{txNo}")
    int isExistTx(String txNo);

    @Insert("insert into de_duplication values(#{txNo},now());")
    int addTx(String txNo);

}

4、服務實現層

@Service
public class AccountInfoServiceImpl implements AccountInfoService {

    private static Logger log = LoggerFactory.getLogger(AccountInfoServiceImpl.class);

    @Autowired
    AccountInfoDao accountInfoDao;

    /**
     * 更新賬戶,增加金額
     *
     * @param accountChangeEvent
     */
    @Transactional
    public void addAccountInfoBalance(AccountChangeEvent accountChangeEvent) {
        log.info("bank2更新本地賬號,賬號:{},金額:{}", accountChangeEvent.getAccountNo(), accountChangeEvent.getAmount());
        Integer doSuccess = accountInfoDao.isExistTx(accountChangeEvent.getTxNo());
        if (doSuccess > 0) {
            return;
        }
        //增加金額
        accountInfoDao.updateAccountBalance(accountChangeEvent.getAccountNo(), accountChangeEvent.getAmount());
        //添加事務記錄,用於冪等
        accountInfoDao.addTx(accountChangeEvent.getTxNo());
        if (accountChangeEvent.getAmount() == 4) {
            throw new RuntimeException("人爲製造異常");
        }
    }

}

5、消息監聽器

對於消費者來說,需要一個MsgConsumer類,監聽指定的topic,收到消息後執行本地事務

@Component
@RocketMQMessageListener(consumerGroup = "consumer_group_txmsg_bank2",topic = "topic_txmsg")
public class MsgConsumer implements RocketMQListener<String> {

    private static Logger log = LoggerFactory.getLogger(MsgConsumer.class);

    @Autowired
    AccountInfoService accountInfoService;

    /**
     * 接收消息,執行本地事務
     * @param message
     */
    public void onMessage(String message) {
        log.info("開始消費消息:{}",message);
        JSONObject jsonObject = JSONObject.parseObject(message);
        String accountChangeString = jsonObject.getString("accountChange");
        //轉成AccountChangeEvent
        AccountChangeEvent accountChangeEvent = JSONObject.parseObject(accountChangeString, AccountChangeEvent.class);
        //設置賬號爲李四的
        accountChangeEvent.setAccountNo("2");
        //更新本地賬戶,增加金額
        accountInfoService.addAccountInfoBalance(accountChangeEvent);
    }

}

bank2服務不需要對外提供接口,其他的類和bank1中可保持一致,基本上到這裏我們就完成了工程的搭建與準備工作,主啓動類如下:

@SpringBootApplication
public class AppConsumer {

    public static void main(String[] args) {
        SpringApplication.run(AppConsumer.class,args);
    }

}

6、測試

測試場景

  • bank1本地事務失敗,則bank1不發送轉賬消息
  • bank2接收轉賬消息失敗,會進行重試發送消息
  • bank2多次消費同一個消息,實現冪等

啓動2個工程,首先需要確保rocketMq已經啓動,rocketMq啓動命令如下:

啓動 nameserv
nohup sh mqnamesrv & 

啓動 broker
nohup sh mqbroker -n 外網IP:9876 autoCreateTopicEnable=true &

在這裏插入圖片描述

瀏覽器輸入:http://localhost:7004/bank1/transfer?accountNo=1&amount=2 進行正常測試,觀察數據庫中賬戶表的數據變化
在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述

瀏覽器輸入:http://localhost:7004/bank1/transfer?accountNo=1&amount=3 進行異常測試,觀察數據庫中賬戶表的數據變化
在這裏插入圖片描述

在這裏插入圖片描述

本篇的內容比較簡單,重點是要搞明白rocketMq實現事務消息的原理即可,本篇到此結束,最後感謝觀看!

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