一.知识回顾
【0.RocketMQ专栏的内容在这里哟,帮你整理好了,更多内容持续更新中】
【1.Docker安装部署RocketMQ消息中间件详细教程】
【2.RocketMQ生产者发送消息的三种方式:发送同步消息、异步消息、单向消息&案例实战&详细学习流程】
【3.RocketMQ消费者进行消费消息的二种方式:集群消费、广播消费&案例实战&详细学习流程&集群消费模、广播模式的适用场景&注意事项】
【4.RocketMQ中的顺序消息、生产者顺序生产消息、消费者顺序消费消息、顺序包括全局有序和分块有序、代码样例实战】
【5.RocketMQ中延时消息的生产与消费、批量消息的生产与消费、消息的过滤、消息的Tag过滤和SQL过滤、SQL过滤解决SQL92问题,代码样例实战】
二.RocketMQ分布式事务的发展流程
2.1 举个栗子
来说明分布式事务发展的来龙去脉
业务场景:用户A转账100元给用户B,具体的步骤:
1、用户A的账户先扣除100元
2、再把用户B的账户加100元
如果在同一个数据库中进行,事务可以保证这两步操作,要么同时成功,要么同时不成功。这样就保证了转账的数据一致性。如下图所示:
但是在微服务架构中因为各个服务都是独立的模块,都是远程调用,没法在同一个事务中,所以就会遇到分布式事务问题。如下图所示:
2.2 从单体架构的事务向微服务架构事务的发展流程
三.RocketMQ分布式事务处理流程
3.1 通过上面的案例来学习分布式事务存在的问题
RocketMQ分布式事务方式:把扣款业务和加钱业务异步化,扣款成功后,发送“扣款成功消息”到RocketMQ,加钱业务订阅“扣款成功消息”,再对用户B加钱(扣款消息中包含了源账户和目标账户ID,以及钱数)
对于扣款业务来说,需规定是先扣款还是先向MQ发消息
场景一:先扣款,后向MQ发消息
先扣款再发送消息,万一发送消息失败了,那用户B就没法加钱,存在问题。
场景二:先向MQ发像消息,后扣款
扣款成功消息发送成功,但用户A扣款失败,可加钱业务订阅到了消息,用户B加了钱,同样存在问题。
3.2 RocketMQ解决分布式事务的方案(俩阶段提交)
3.2.1 分析原因,痛点所在
通过上面我们的分析知道问题所在:也就是没法保证扣款和发送消息,同时成功,或同时失败;导致数据不一致。 为了解决以上问题,RocketMq把消息分为两个阶段:半事务阶段和确认阶段。
3.2.2 俩阶段提交—半事务阶段:
半事务阶段主要发一个消息到RocketMQ中,但该消息只储存在commotlog文件当中,但ConsumeQueue中不可见,也就是消费端无法看到此消息。
3.2.3 俩阶段提交—确认阶段(commit/rollback):
确认阶段主要是把半事务消息保存到ConsumeQueue中,即让消费端可以看到此消息,也就是可以消费此消息。如果是rollback就不保存。
3.2.4 俩阶段提交的整个流程
整个流程如下:
- A在扣款之前,先发送半事务消息
- 发送预备消息成功后,执行本地扣款事务
- 扣款成功后,再发送
确认消息
- B消息端(加钱业务)可以看到确认消息,消费此消息,进行加钱业务逻辑
确认消息注意事项:
- 确认消息可以为Commit消息,可以被订阅者消费;
- 也可以是Rollback消息,即执行本地扣款事务失败后,提交Rollback消息,即删除那个半事务消息,订阅者无法消费。
Rollback可以解决以下问题:
- 问题1:如果发送半事务消息失败,下面的流程不会走下去,这个是正常的。
- 问题2:如果发送半事务消息成功,但执行本地事务失败。这个也没有问题,因为此半事务消息不会被消费端订阅到,消费端不会执行业务。(后续的Rollback事务回查也可以解决这个问题,如果本地事务没有执行成功,RocketMQ回查业务,发现没有执行成功,就会发送RollBack确认消息,把消息进行删除。)
Rollback不可以解决的问题:
- 问题1:如果发送半事务消息成功,并且执行本地事务成功,但发送确认消息失败了,这个就有问题了,因为用户A扣款成功了,但加钱业务没有订阅到A发送来的确认消息,就无法完成加钱的业务逻辑。这里出现了数据不一致。
解决方案:
RocketMQ解决上面问题的核心思路就是通过事务回查
,也就是RocketMQ会定时遍历commitlog中的半事务消息。
对于上述的问题,发送半事务消息成功,本地扣款事务成功,但发送确认消息失败;因为RocketMQ会进行回查半事务消息,在回查后发现业务已经扣款成功了,就补发发送commit确认消息
;这样加钱业务就可以订阅此消息了。
消费端进行消息消费的时候需要注意的点:
- RocketMQ不能保障消息的重复,所以在消费端一定要做幂等性处理。
- 如果消费端发生消费失败,同时也需要做重试,如果重试多次,消息会进入死信队列,这个时候也需要进行特殊的处理。(一般就是把A已经处理完的业务进行回退)
3.2.5 事务回查的流程?或者说事务回查找的是哪张表呢?有没有什么好的解决方案呢?
背景分析:如果本地事务执行了很多张表,难道我们需要对所有表都要进行判断是否执行成功呢?这样的操作不仅效率慢,而且都是再做同样的事情,耦合度过高。那我们该怎么解决呢?方案如下:
解决方案:
设计一张Transaction表,将业务表和Transaction绑定在同一个本地事务中,如果扣款本地事务成功,Transaction中应当已经记录该TransactionId的状态为【已完成】。当RocketMQ事务回查时,只需要检查对应的TransactionId的状态是否是【已完成】就好,而不用关心具体的业务数据。
四.光说不练假把式~~~分布式事务实战学习
4.1 生产者案例代码
/**
* A系统---消息生产者
*/
public class TransactionProducer {
public static void main(String[] args) throws MQClientException, InterruptedException {
//创建事务监听器,对我们的事务进行监听,需要负责执行本地事务以及一些事务的回查操作
TransactionListener transactionListener = new TransactionListenerImpl();
TransactionMQProducer producer = new TransactionMQProducer("TransactionProducer");
producer.setNamesrvAddr("121.5.139.205:9876");
//创建线程池
ExecutorService executorService = new ThreadPoolExecutor(4, 8, 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;
}
});
//设置生产者回查线程池
producer.setExecutorService(executorService);
//生产者设置监听器
producer.setTransactionListener(transactionListener);
//启动消息生产者
producer.start();
//1、半事务的发送
try {
Message msg = new Message("TransactionTopic", null, ("A向B系统转1314元").getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.sendMessageInTransaction(msg, null);
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//设置日期格式
System.out.println(sendResult.getSendStatus()+"-"+df.format(new Date()));//半事务消息是否成功
} catch (MQClientException | UnsupportedEncodingException e) {
//TODO 如果半事务还没有提交成功,那么直接ROllback
//LocalTransactionState.ROLLBACK_MESSAGE;
e.printStackTrace();
}
//2、半事务的发送成功
//一些长时间等待的业务,需要通过事务回查来处理
for (int i = 0; i < 100; i++) {
Thread.sleep(1000);
}
producer.shutdown();
}
}
/*
* 事务监听类
*/
class TransactionListenerImpl implements TransactionListener {
//执行本地事务
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//设置日期格式
//TODO 执行本地事务 update A...
System.out.println("update A ... where transactionId:"+msg.getTransactionId() +":"+df.format(new Date()));
//todo 情况1:本地事务成功
//return LocalTransactionState.COMMIT_MESSAGE;
//TODO 情况2:本地事务失败
//System.out.println("rollback");
//return LocalTransactionState.ROLLBACK_MESSAGE;
//TODO 情况3:业务复杂,还处于中间过程或者依赖其他操作的返回结果,UNKNOW
System.out.println("业务比较复杂,没有处理完,无法确定是Success >or< Fail!");
return LocalTransactionState.UNKNOW;
}
//事务回查 默认是60s,一分钟检查一次
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
//打印每次回查的时间
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//设置日期格式
System.out.println("checkLocalTransaction:"+df.format(new Date()));// new Date()为获取当前系统时间
//TODO 情况3.1:业务回查成功!
System.out.println("业务回查:执行本地事务成功,确认消息");
return LocalTransactionState.COMMIT_MESSAGE;
//TODO 情况3.2:业务回查回滚!
//System.out.println("业务回查:执行本地事务失败,删除消息");
//return LocalTransactionState.ROLLBACK_MESSAGE;
//TODO 情况3.3:业务回查还是UNKNOW!
//System.out.println("业务比较复杂,没有处理完,无法确定是Success >or< Fail!");
//return LocalTransactionState.UNKNOW;
}
}
4.2 消费者案例代码
/**
* 事务消息-消费者 B
*/
public class TranscationComuser {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("TranscationComsuer");
consumer.setNamesrvAddr("121.5.139.205:9876");
consumer.subscribe("TransactionTopic", "*");
consumer.setMessageModel(MessageModel.CLUSTERING);
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
try {
//todo 开启事务
for(MessageExt msg : msgs) {
//todo 执行本地事务 update B...(幂等性)
System.out.println("update B ... where transactionId:"+msg.getTransactionId());
//todo 本地事务成功
System.out.println("commit:"+msg.getTransactionId());
System.out.println("执行本地事务成功,确认消息");
}
} catch (Exception e) {
e.printStackTrace();
System.out.println("执行本地事务失败,重试消费,尽量确保B处理成功");
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
//启动消息者
consumer.start();
System.out.printf("Consumer Started.%n");
}
}
4.3 运行结果
五.RocketMQ分布式事务使用的注意事项
- 事务消息不支持延时消息和批量消息。
- 通过Broker的配置文件可以配置事务回查的间隔时间:BrokerConfig. transactionCheckInterval。
- 为了避免单个消息被检查太多次而导致半队列消息累积,我们默认将单个消息的检查次数限制为 15 次,但是用户可以通过 Broker 配置文件的 transactionCheckMax参数来修改此限制。如果已经检查某条消息超过 N 次的话( N = transactionCheckMax ) 则 Broker 将丢弃此消息,并在默认情况下同时打印错误日志。用户可以通过重写 AbstractTransactionCheckListener 类来修改这个行为。
- 事务消息将在 Broker 配置文件中的参数 transactionMsgTimeout 这样的特定时间长度之后被检查。当发送事务消息时,用户还可以通过设置用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS 来改变这个限制,该参数优先于 transactionMsgTimeout 参数。
- 事务性消息可能不止一次被检查或消费。
- 事务性消息中用到了生产者群组,这种就是一种高可用机制,用来确保事务消息的可靠性。
- 提交给用户的目标主题消息可能会失败,目前这依日志的记录而定。它的高可用性通过 RocketMQ 本身的高可用性机制来保证,如果希望确保事务消息不丢失、并且事务完整性得到保证,建议使用同步的双重写入机制。
- 事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询、MQ服务器能通过它们的生产者 ID 查询到消费者。
好了,到这里【RocketMQ分布式事务消息、RocketMQ分布式事务的发展流程、RocketMQ分布式事务二阶段提交解决方案、分布式案例实操学习、RocketMQ分布式事务使用场景以及注意事项】就先学习到这里,关于RocketMQ更多知识,持续学习创作中。