分布式事务(四)本地消息表和消息事务(RocketMQ详细实现)

3,119 阅读7分钟

「这是我参与11月更文挑战的第14天,活动详情查看:2021最后一次更文挑战

本地消息表

本地消息表的方案最初由ebay的工程师提出,核心思想是将分布式事务拆分成本地事务进行处理。本地消息表实现最终一致性。

image.png

具体案例

image.png

  • 步骤1和2,系统收到用户下单请求,将订单业务数据写入订单表中,同时把该订单对应的消息数据写入本地消息表中,订单表与本地消息表为同一个数据库,更新订单和存储消息为同一个本地事务,数据库事务处理,要么都成功,要么都失败。
  • 步骤3、4、5,订单服务发送消息到消息队列,库存服务收到消息,进行库存业务操作,更新库存数据
  • 步骤6和7,返回业务处理结果,订单服务收到结果后,将本地消息表中的数据设置完成状态或者删除数据。
  • 步骤8,另起定时任务,定时扫描本地消息表,看是否有未完成的任务,有则重试。

本地消息表优缺点

本地消息表实现了分布式事务的最终一致性,优缺点比较明显。
优点
1.实现逻辑简单,开发成本比较低
缺点
1.与业务场景绑定,高耦合,不可公用
2.本地消息表与业务数据表在同一个库,占用业务系统资源,量大可能会影响数据库性能

消息事务

消息事务的原理是将两个事务「通过消息中间件进行异步解耦」,和上述的本地消息表有点类似,但是是通过消息中间件的机制去做的,其本质就是'将本地消息表封装到了消息中间件中'。

执行流程

  • 发送prepare消息到消息中间件
  • 发送成功后,执行本地事务
    • 如果事务执行成功,则commit,消息中间件将消息下发至消费端
    • 如果事务执行失败,则回滚,消息中间件将这条prepare消息删除
  • 消费端接收到消息进行消费,如果消费失败,则不断重试

消息事务优缺点

优点
1.可用性高
2.吞吐高
3.实现简单
缺点
1.下游服务失败,缺少事务回滚能力

RocketMQ消息事务案例

交互流程

image.png

事务消息发送步骤如下:

  1. 发送方将半事务消息发送至消息队列RocketMQ版服务端。
  2. 消息队列RocketMQ版服务端将消息持久化成功之后,向发送方返回Ack确认消息已经发送成功,此时消息为半事务消息。
  3. 发送方开始执行本地事务逻辑。
  4. 发送方根据本地事务执行结果向服务端提交二次确认(Commit或是Rollback),服务端收到Commit状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到Rollback状态则删除半事务消息,订阅方将不会接受该消息。

代码示例:

package com.alibaba.webx.TryHsf.app1;

import com.aliyun.openservices.ons.api.Message;
import com.aliyun.openservices.ons.api.PropertyKeyConst;
import com.aliyun.openservices.ons.api.SendResult;
import com.aliyun.openservices.ons.api.transaction.LocalTransactionExecuter;
import com.aliyun.openservices.ons.api.transaction.TransactionProducer;
import com.aliyun.openservices.ons.api.transaction.TransactionStatus;
import java.util.Properties;
import java.util.concurrent.TimeUnit;

public class TransactionProducerClient {
 private final static Logger log = ClientLogger.getLog(); // 您需要设置自己的日志,便于排查问题。

 public static void main(String[] args) throws InterruptedException {
     final BusinessService businessService = new BusinessService(); // 本地业务。
     Properties properties = new Properties();
        // 您在消息队列RocketMQ版控制台创建的Group ID。注意:事务消息的Group ID不能与其他类型消息的Group ID共用。
     properties.put(PropertyKeyConst.GROUP_ID,"XXX");
        // AccessKey ID阿里云身份验证,在阿里云RAM控制台创建。
     properties.put(PropertyKeyConst.AccessKey,"XXX");
        // AccessKey Secret阿里云身份验证,在阿里云RAM控制台创建。
     properties.put(PropertyKeyConst.SecretKey,"XXX");
        // 设置TCP接入域名,进入消息队列RocketMQ版控制台实例详情页面的接入点区域查看。
     properties.put(PropertyKeyConst.NAMESRV_ADDR,"XXX");

     TransactionProducer producer = ONSFactory.createTransactionProducer(properties,
             new LocalTransactionCheckerImpl());
     producer.start();
     Message msg = new Message("Topic","TagA","Hello MQ transaction===".getBytes());
     try {
             SendResult sendResult = producer.send(msg, new LocalTransactionExecuter() {
                 @Override
                 public TransactionStatus execute(Message msg, Object arg) {
                     // 消息ID(有可能消息体一样,但消息ID不一样,当前消息属于半事务消息,所以消息ID在消息队列RocketMQ版控制台无法查询)。
                     String msgId = msg.getMsgID();
                     // 消息体内容进行crc32,也可以使用其它的如MD5。
                     long crc32Id = HashUtil.crc32Code(msg.getBody());
                     // 消息ID和crc32id主要是用来防止消息重复。
                     // 如果业务本身是幂等的,可以忽略,否则需要利用msgId或crc32Id来做幂等。
                     // 如果要求消息绝对不重复,推荐做法是对消息体使用crc32或MD5来防止重复消息。
                     Object businessServiceArgs = new Object();
                     TransactionStatus transactionStatus = TransactionStatus.Unknow;
                     try {
                         boolean isCommit =
                             businessService.execbusinessService(businessServiceArgs);
                         if (isCommit) {
                             // 本地事务已成功则提交消息。
                             transactionStatus = TransactionStatus.CommitTransaction;
                         } else {
                             // 本地事务已失败则回滚消息。
                             transactionStatus = TransactionStatus.RollbackTransaction;
                         }
                     } catch (Exception e) {
                         log.error("Message Id:{}", msgId, e);
                     }
                     System.out.println(msg.getMsgID());
                     log.warn("Message Id:{}transactionStatus:{}", msgId, transactionStatus.name());
                     return transactionStatus;
                 }
             }, null);
         }
         catch (Exception e) {
                // 消息发送失败,需要进行重试处理,可重新发送这条消息或持久化这条数据进行补偿处理。
             System.out.println(new Date() + " Send mq message failed. Topic is:" + msg.getTopic());
             e.printStackTrace();
         }
     // demo example防止进程退出(实际使用不需要这样)。
     TimeUnit.MILLISECONDS.sleep(Integer.MAX_VALUE);
 }
}                        

事务消息回查步骤如下:

  1. 在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达服务端,经过固定时间后服务端将对消息发送方即生产者集群中任意一生产者实例发起消息回查。
  2. 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
  3. 发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行操作。

代码示例:

public class LocalTransactionCheckerImpl implements LocalTransactionChecker {
   private final static Logger log = ClientLogger.getLog();
   final  BusinessService businessService = new BusinessService();

   @Override
   public TransactionStatus check(Message msg) {
       //消息ID(有可能消息体一样,但消息ID不一样,当前消息属于半事务消息,所以消息ID在消息队列RocketMQ版控制台无法查询)。
       String msgId = msg.getMsgID();
       //消息体内容进行crc32,也可以使用其它的方法如MD5。
       long crc32Id = HashUtil.crc32Code(msg.getBody());
       //消息ID和crc32Id主要是用来防止消息重复。
       //如果业务本身是幂等的,可以忽略,否则需要利用msgId或crc32Id来做幂等。
       //如果要求消息绝对不重复,推荐做法是对消息体使用crc32或MD5来防止重复消息。
       //业务自己的参数对象,这里只是一个示例,需要您根据实际情况来处理。
       Object businessServiceArgs = new Object();
       TransactionStatus transactionStatus = TransactionStatus.Unknow;
       try {
           boolean isCommit = businessService.checkbusinessService(businessServiceArgs);
           if (isCommit) {
               //本地事务已成功则提交消息。
               transactionStatus = TransactionStatus.CommitTransaction;
           } else {
               //本地事务已失败则回滚消息。
               transactionStatus = TransactionStatus.RollbackTransaction;
           }
       } catch (Exception e) {
           log.error("Message Id:{}", msgId, e);
       }
       log.warn("Message Id:{}transactionStatus:{}", msgId, transactionStatus.name());
       return transactionStatus;
   }
 }                        

常见问题:

  1. 发送事务消息为什么必须要实现回查Check机制?
    当步骤1中半事务消息发送完成,但本地事务返回状态为TransactionStatus.Unknow,或者应用退出导致本地事务未提交任何状态时,从Broker的角度看,这条半事务消息的状态是未知的。因此Broker会定期向消息发送方即消息生产者集群中的任意一生产者实例发起消息回查,要求发送方回查该Half状态消息,并上报其最终状态。

  2. Check被回调时,业务逻辑都需要做些什么?
    事务消息的Check方法里面,应该写一些检查事务一致性的逻辑。消息队列RocketMQ版发送事务消息时需要实现LocalTransactionChecker接口,用来处理Broker主动发起的本地事务状态回查请求,因此在事务消息的Check方法中,需要完成两件事情:
    检查该半事务消息对应的本地事务的状态(committed or rollback)。
    向Broker提交该半事务消息本地事务的状态。

  3. 如果本地事务(写db)执行成功但是消息发送还没成功这段时间内,db数据更改,如何回查接口判断本地事务状态?
    业务侧可以有多种实现方式,常用方案是在一个本地事务中同时写两条记录到db,一条是业务数据,一条记录本地事务执行成功数据(包含事务id字段)。回查的时候会根据事务id字段查询本地事务是否执行成功。

各个模式对比

模式一致性可用性使用成本适用性开源项目
2PCShardingSphere、Seata
TCCSeata
本地事务表
事务消息RocketMq

本文已参与「新人创作礼」活动,一起开启掘金创作之路。