1、背景:
RockeyMQ
实现分布式事务(拿下单和扣减库存为例,如果下单和扣减库存在一个分布式系统中,需要维持一个事务性)
如果下单后发消息提示需要扣减库存,保持一个分布式事务的一致性,就会出现以下场景:
场景一:先下单后发消息
问题:先下单后如果消息发送失败,无法扣减库存业务无法订阅了此消息,但是却下了单;
场景二:先发消息后下单
问题:消息发送成功,但是下单失败,但是扣减库存的业务订阅了此消息,则扣减了库存却未下单。
RocketMQ
解决此问题的方法分为两个阶段:prepared
阶段和确认阶段(其中确认阶段分为Commit
和Rollback
,取决于事务是否执行成功)。
2、流程分析
prepared
阶段:主要发一个消息到Rocketmq
,但该消息只储存在commitlog
中,但consumeQueue
中不可见,也就是消费端(订阅端)无法看到此消息。
确认阶段:主要是把prepared
消息保存到consumeQueue
中,即让消费端可以看到此消息,也就是可以消费此消息。
上图说明了事务消息的大致方案,其中分为两种情况:正常事务消息的发送及提交、事务消息的补偿流程。
2.1、事务消息发送及提交
(1) 发送消息(half
消息);
(2) 服务端响应消息写入结果;
(3) 根据发送结果执行本地事务(如果写入失败,此时half
消息对业务不可见,本地逻辑不执行);
(4) 根据本地事务状态执行Commit
或者Rollback
(Commit
操作生成消息索引,消息对消费者可见)。
2.2、事务补偿
(1) 对没有Commit/Rollback
的事务消息(pending
状态的消息),从服务端发起一次“回查”;
(2) Producer
收到回查消息,检查回查消息对应的本地事务的状态;
(3) 根据本地事务状态,重新Commit
或者Rollback
;
其中,补偿阶段用于解决消息Commit
或者Rollback
发生超时或者失败的情况。
事务消息共有三种状态,提交状态、回滚状态、中间状态:
TransactionStatus.CommitTransaction
: 提交事务,它允许消费者消费此消息;TransactionStatus.RollbackTransaction
: 回滚事务,它代表该消息将被删除,不允许被消费;TransactionStatus.Unknown
: 中间状态,它代表需要检查消息队列来确定状态。
若本地事务执行了很多张表,那是不是我们要把那些表都要进行判断是否执行成功呢?这样是不是太麻烦了,而且和业务很耦合。
有没有更好的方式呢?就是设计一张Transaction
表,将业务表和Transaction
绑定在同一个本地事务中,如果扣款本地事务成功时,Transaction
中应当已经记录该TransactionId
的状态为「已完成」。当RocketMQ
回查时,只需要检查对应的**TransactionId
的状态是否是「已完成」就好**,而不用关心具体的业务数据。
3、发送事务消息
3.1、创建事务性生产者
使用 TransactionMQProducer
类创建生产者,并指定唯一的 ProducerGroup
,就可以设置自定义线程池来处理这些检查请求。执行本地事务后、需要根据执行结果对消息队列进行回复。回传的事务状态在请参考前一节。
public class Producer {
public static void main(String[] args) throws MQClientException, InterruptedException {
//创建事务监听器
TransactionListener transactionListener = new TransactionListenerImpl();
//创建消息生产者
TransactionMQProducer producer = new TransactionMQProducer("group6");
producer.setNamesrvAddr("192.168.25.135:9876;192.168.25.138:9876");
//生产者这是监听器
producer.setTransactionListener(transactionListener);
//启动消息生产者
producer.start();
String[] tags = new String[]{"TagA", "TagB", "TagC"};
for (int i = 0; i < 3; i++) {
try {
Message msg = new Message("TransactionTopic", tags[i % tags.length], "KEY" + i,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.sendMessageInTransaction(msg, null);
System.out.printf("%s%n", sendResult);
TimeUnit.SECONDS.sleep(1);
} catch (MQClientException | UnsupportedEncodingException e) {
e.printStackTrace();
}
}
//producer.shutdown();
}
}
3.2、实现事务的监听接口
当发送消息成功时,我们使用 executeLocalTransaction
方法来执行本地事务。它返回前一节中提到的三个事务状态之一。checkLocalTranscation
方法用于检查本地事务状态,并回应消息队列的检查请求。它也是返回前一节中提到的三个事务状态之一。
public class TransactionListenerImpl implements TransactionListener {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
System.out.println("执行本地事务");
if (StringUtils.equals("TagA", msg.getTags())) {
return LocalTransactionState.COMMIT_MESSAGE;
} else if (StringUtils.equals("TagB", msg.getTags())) {
return LocalTransactionState.ROLLBACK_MESSAGE;
} else {
return LocalTransactionState.UNKNOW;
}
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
System.out.println("MQ检查消息Tag【"+msg.getTags()+"】的本地事务执行结果");
return LocalTransactionState.COMMIT_MESSAGE;
}
}
4、使用限制与注意事项
(1) 事务消息不支持延时消息和批量消息。
(2)为了避免单个消息被检查太多次而导致半队列消息累积,我们默认将单个消息的检查次数限制为 15 次,但是用户可以通过 Broker
配置文件的 transactionCheckMax
参数来修改此限制。如果已经检查某条消息超过 N 次的话( N = transactionCheckMax
) 则 Broker
将丢弃此消息,并在默认情况下同时打印错误日志。用户可以通过重写 AbstractTransactionCheckListener
类来修改这个行为。
(3)事务消息将在 Broker
配置文件中的参数 transactionMsgTimeout
这样的特定时间长度之后被检查。当发送事务消息时,用户还可以通过设置用户属性CHECK_IMMUNITY_TIME_IN_SECONDS
来改变这个限制,该参数优先于 transactionMsgTimeout
参数。
(4)事务性消息可能不止一次被检查或消费。
(5)提交给用户的目标主题消息可能会失败,目前这依日志的记录而定。它的高可用性通过 RocketMQ
本身的高可用性机制来保证,如果希望确保事务消息不丢失、并且事务完整性得到保证,建议使用同步的双重写入机制。
(6)事务消息的生产者 ID
不能与其他类型消息的生产者ID
共享。与其他类型的消息不同,事务消息允许反向查询、MQ
服务器能通过它们的生产者ID
查询到消费者。