Distributed transaction solution 2: message queue to achieve final consistency

Posted by elflacodepr on Sat, 26 Feb 2022 14:09:51 +0100

1. Reliable messaging for ultimate consistency

We have studied the CAP theory before and know that we generally guarantee P and A, abandon C and ensure the final consistency.
Distributed transaction
The core of using message queue to achieve final consistency is to split distributed transactions into multiple local transactions, and then coordinate all transactions through the network by message queue to achieve final consistency.

This scheme is easy to understand, but it will also face many problems:
1. The atomicity between the message sender executing the local transaction and sending the message, that is, how to ensure the successful execution of the local transaction, the message must be sent successfully

begin transaction
	1.Database operation
	2.send message
commit transation

In this case, there seems to be no problem. If the message fails to be sent, an exception will be thrown, resulting in the rollback of database transactions. However, if it is a timeout exception, the database will roll back, but the message has been sent normally at this time, which will also lead to inconsistency.

2. The atomicity between the message received by the message receiver and the local transaction, that is, how to ensure that the local transaction will be executed successfully after the message is received successfully

3. Because the message may be sent repeatedly, the message receiver must realize idempotency

In the production environment, the consumer is likely to be a cluster. If a consumer node times out but the consumption is successful, it will lead to repeated consumption of the message by other nodes in the same group. In addition, if the consumption progress is not written to the disk in time, the consumption progress will be partially lost, resulting in repeated consumption of messages.

2,RocketMQ

RocketMQ is a distributed messaging middleware from Alibaba. It was open source in 2012 and officially became the top project of Apache in 2017. Apache RocketMQ version after 4.3 officially supports transaction messages, which provides convenient support for the implementation of distributed transactions. Therefore, we can solve the previous problem through RocketMQ.

1. The atomicity between the message sender executing the local transaction and sending the message, that is, how to ensure the successful execution of the local transaction, the message must be sent successfully

The broker in RocketMQ has the ability of two-way communication with the sender, so that the broker can naturally exist as a transaction coordinator; Moreover, RocketMQ itself provides a storage mechanism so that transaction messages can be persisted; These excellent designs can ensure that RocketMQ can still ensure the final consistency of transactions even if exceptions occur.

  1. When the sender sends a transaction message to the Broker, RocketMQ will mark the message status as "Prepared". At this time, this message cannot be consumed by the receiver for the time being. Such a message is called Half Message, that is, Half Message.
  2. The Broker returns the successful sending to the sender
  3. The sender performs local transactions, such as manipulating the database
  4. If the local transaction is executed successfully, send a commit message to the Broker, and RocketMQ will mark the message status as "consumable". At this time, the message can be consumed by the receiver; If the local transaction fails to execute, send a rollback message to the Broker, and RocketMQ will delete the message.
  5. If the service hangs, the network flashes or times out during the local transaction of the sender, the Broker will not receive the confirmation result
  6. At this time, RocketMQ will keep asking the sender to obtain the execution status of the local transaction (i.e. transaction backcheck)
  7. The Commit or Rollback is determined according to the result of the transaction check back, which ensures that the message sending and the local transaction succeed or fail at the same time.

The above backbone processes have been implemented by RocketMQ. For us, we only need to implement the local transaction execution method and the local transaction backcheck method respectively. Specifically, we need to implement the following interface:

public interface TransactionListener {
    /**
    - Call back after sending the prepare message successfully. This method is used to execute local transactions
    - @param msg The unique Id of the returned message can be obtained by using transactionId
    - @param arg Parameters passed when calling the send method. If additional parameters can be passed to the send method when sending, you can get them here
    - @return Return transaction status, COMMIT: COMMIT ROLLBACK: ROLLBACK unknown: unknown, need to check back
     */
    LocalTransactionState executeLocalTransaction(final Message msg, final Object arg);
    
    /**
    - @param msg Determine the local transaction execution status of this message by obtaining the transactionId
    - @return Return transaction status, COMMIT: COMMIT ROLLBACK: ROLLBACK unknown: unknown, need to check back
     */
    LocalTransactionState checkLocalTransaction(final MessageExt msg);
}

2. The atomicity between the message received by the message receiver and the local transaction, that is, how to ensure that the local transaction will be executed successfully after the message is received successfully

If an exception occurs, RocketMQ will consume the message at regular intervals through the retry mechanism, and then execute the local transaction; If it is timeout, RocketMQ will consume messages without limit and continue to execute local transactions until it succeeds.

3. A small test

Environmental requirements
  • Database: MySQL-5.7+
  • JDK: 64 bit jdk1 8+
  • Microservices: spring-boot-2.1.3, spring cloud Greenwich RELEASE
  • RocketMQ server: RocketMQ-4.5.0
  • RocketMQ client: RocketMQ spring boot starter 2.0.2-RELEASE
Create database

This case requires two databases, one is bank1 and the other is bank2. There is no need to create and use them directly Hmily Just the database in the quick start case. In addition, in order to achieve idempotency, we need to add de in bank1 and bank2 databases respectively_ Duplication table, i.e. transaction record table (de duplication table).

DROP TABLE IF EXISTS `de_duplication`;
CREATE TABLE `de_duplication`  (
  `tx_no` bigint(20) NOT NULL,
  `create_time` datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (`tx_no`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;
Start RocketMQ

Start nameserver:

set ROCKETMQ_HOME=[RocketMQ Server decompression path]
start [RocketMQ Server decompression path]/bin/mqnamesrv.cmd

Start broker:

set ROCKETMQ_HOME=[RocketMQ Server decompression path]
start [RocketMQ Server decompression path]/bin/mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true
Maven project

Create two maven projects to connect different databases

Function realization

Message sender bank1
  1. Define a class to encapsulate the transfer message:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AccountChangeEvent implements Serializable {
    /**
     * account number
     */
    private String accountNo;
    /**
     * Change amount
     */
    private double amount;
    /**
     * Transaction number, timestamp
     */
    private long txNo;
}
  1. Realize the data access layer, a total of four functions
@Mapper
@Component
public interface AccountInfoDao {

    /**
     * Modify the balance of an account
     * @param accountNo account number
     * @param amount Change 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);

    /**
     * Query account information
     * @param accountNo account number
     * @return
     */
    @Select("select * from account_info where where account_no=#{accountNo}")
    AccountInfo findByIdAccountNo(@Param("accountNo") String accountNo);

    /**
     * Query whether a transaction record has been executed
     * @param txNo Transaction number
     * @return
     */
    @Select("select count(1) from de_duplication where tx_no = #{txNo}")
    int isExistTx(long txNo);

    /**
     * Save a transaction execution record
     * @param txNo Transaction number
     * @return
     */
    @Insert("insert into de_duplication values(#{txNo},now());")
    int addTx(long txNo);

}
  1. Send transfer message
@Component
@Slf4j
public class BankMessageProducer {
   @Resource
   private RocketMQTemplate rocketMQTemplate;

   public void sendAccountChangeEvent(AccountChangeEvent accountChangeEvent) {
      // 1. Construct message
      JSONObject object = new JSONObject();
      object.put("accountChange", accountChangeEvent);
      Message<String> msg = MessageBuilder.withPayload(object.toJSONString()).build();
      // 2. Send message
      rocketMQTemplate.sendMessageInTransaction("producer_ensure_transfer",
            "topic_ensure_transfer",
            msg, null);
   }
}
  1. The business layer code is implemented to send transaction messages and deduct the amount of local transactions respectively. Note that if the local transaction of doUpdateAccountBalance is executed successfully, the data will be saved in the de_duplication table of the transaction record.
public interface AccountInfoService {
	/**
	 * Update account balance - send message
	 * @param accountChange
	 */
	void updateAccountBalance(AccountChangeEvent accountChange);

	/**
	 * Update account balance - local transaction
	 * @param accountChange
	 */
	void doUpdateAccountBalance(AccountChangeEvent accountChange);
}

@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {

   @Autowired
   private BankMessageProducer bankMessageProducer;

   @Autowired
   private AccountInfoDao accountInfoDao;
    
	/**
	 * Update account balance - send notification
	 * @param accountChange
	 */
   @Override
   public void updateAccountBalance(AccountChangeEvent accountChange) {
      bankMessageProducer.sendAccountChangeEvent(accountChange);
   }
    
    /**
    * Update account balance - local transaction
    * @param accountChange
    */
   @Override
   @Transactional(isolation = Isolation.SERIALIZABLE)
   public void doUpdateAccountBalance(AccountChangeEvent accountChange) {
      accountInfoDao.updateAccountBalance(accountChange.getAccountNo(),accountChange.getAmount() * -1);
      accountInfoDao.addTx(accountChange.getTxNo());
   }
}
  1. Implement RocketMQ transaction message listener, which has two functions:

(1) executelocal transaction. This method executes local transactions and will be automatically called by RocketMQ

(2)checkLocalTransaction. This method realizes transaction backcheck and uses the de_duplication of transaction records, which will be automatically called by RocketMQ

@Component
@Slf4j
@RocketMQTransactionListener(txProducerGroup = "producer_ensure_transfer")
public class TransferTransactionListenerImpl implements RocketMQLocalTransactionListener {

    @Autowired
    private AccountInfoService accountInfoService;

    @Autowired
    private AccountInfoDao accountInfoDao;

    /**
     * Execute local transactions
     * @param msg
     * @param arg
     * @return
     */
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
 		//1. Receive and parse messages
        final JSONObject jsonObject = JSON.parseObject(new String((byte[])
                msg.getPayload()));
        AccountChangeEvent accountChangeEvent =
                JSONObject.parseObject(jsonObject.getString("accountChange"),AccountChangeEvent.
                        class);

        //2. Execute local affairs
        Boolean isCommit = true;
        try {
            accountInfoService.doUpdateAccountBalance(accountChangeEvent);
        }catch (Exception e){
            isCommit = false;
        }

        //3. Return the execution result
        if(isCommit){
            return RocketMQLocalTransactionState.COMMIT;
        }else {
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }

    /**
     * Transaction backcheck
     * @param msg
     * @return
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        //1. Receive and parse messages
        final JSONObject jsonObject = JSON.parseObject(new String((byte[])
                msg.getPayload()));
        AccountChangeEvent accountChangeEvent =
                JSONObject.parseObject(jsonObject.getString("accountChange"),AccountChangeEvent.
                        class);

        //2. Query de_duplication table
        int isExistTx = accountInfoDao.isExistTx(accountChangeEvent.getTxNo());

        //3. Return value according to query result
        if(isExistTx>0){
            return RocketMQLocalTransactionState.COMMIT;
        }else {
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }
}
  1. Improve Controller code
@RestController
@Slf4j
public class AccountInfoController {
    @Autowired
    private AccountInfoService accountInfoService;

    @GetMapping(value = "/transfer")
    public String transfer(){
        accountInfoService.updateAccountBalance(new AccountChangeEvent("1",100,System.currentTimeMillis()));
        return "Transfer succeeded";
    }
}
Message receiver bank2
  1. The implementation of the data access layer, like bank1, can be used directly
  2. Realize the business layer function and increase the account balance. Note that the transaction de_duplication table is used to realize idempotency control
public interface AccountInfoService {
    /**
     * Update account balance
     * @param accountChange
     */
    void updateAccountBalance(AccountChangeEvent accountChange);
} 

@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {

   @Autowired
   private AccountInfoDao accountInfoDao;

   @Override
   @Transactional(isolation = Isolation.SERIALIZABLE)
   public void updateAccountBalance(AccountChangeEvent accountChange) {
      int isExistTx = accountInfoDao.isExistTx(accountChange.getTxNo());
      if(isExistTx == 0){
         accountInfoDao.updateAccountBalance(accountChange.getAccountNo(),accountChange.getAmount());
         accountInfoDao.addTx(accountChange.getTxNo());
      }
   }
}
  1. Implement the RocketMQ transaction message listener. After receiving the message, parse the message and call the business layer for processing
@Component
@RocketMQMessageListener(topic = "topic_ensure_transfer", consumerGroup = "consumer_ensure_transfer")
@Slf4j
public class EnsureMessageConsumer implements RocketMQListener<String>{

   @Autowired
   private AccountInfoService accountInfoService;

   @Override
   public void onMessage(String  projectStr) {
      System.out.println("Start consumption message:" + projectStr);
      final JSONObject jsonObject = JSON.parseObject(projectStr);
      AccountChangeEvent accountChangeEvent = JSONObject.parseObject(jsonObject.getString("accountChange"),AccountChangeEvent.class);
      accountChangeEvent.setAccountNo("2");
      accountInfoService.updateAccountBalance(accountChangeEvent);
   }
}

functional testing

  • Both bank1 and bank2 succeeded
  • If bank1 fails to execute the local transaction, bank2 cannot receive the transfer message.
  • After bank1 executes the local transaction and does not return any information, the Broker will perform a transaction backcheck.
  • bank2 failed to execute the local transaction and will retry the consumption.

Reliable message final consistency transaction is suitable for scenarios with long execution cycle and low real-time requirements. After introducing this mechanism, the synchronous transaction operation becomes asynchronous operation based on message execution, which avoids the influence of synchronous blocking operation in distributed transactions, and realizes the decoupling of the two services.

--------------------------The content of the article comes from the dark horse course. Learn to use it--------------------------

Topics: Java Database Distribution