概述
RocketMq事务消息是众多MQ中最具特点的一个功能,通过事务消息,去保证MQ上下游数据的一致性,要么同时成功,要么同时失败。
以下单为例,⽤户⽀付订单这⼀核⼼操作的同时会涉及到下游物流发货、积分变更、购物⻋状态清空等多个⼦系统的变更。这种场景,⾮常适合使⽤RocketMQ的解耦功能来进⾏串联。
考虑到事务的安全性,即要保证相关联的这⼏个业务⼀定是同时成功或者同时失败的。如果要将四个服务⼀起作为⼀个分布式事务来控制,可以做到,但是会⾮常麻烦。⽽使⽤RocketMQ在中间串联了之后,事情可以得到⼀定程度的简化。由于RocketMQ与消费者端有失败重试机制,所以,只要消息成功发送到RocketMQ了,那么可以认为Branch2.1,Branch2.2,Branch2.3这⼏个分⽀步骤,是可以保证最终的数据⼀致性的。这样,⼀个复杂的分布式事务问题,就变成了MinBranch1和Branch2两个步骤的分布式事务问题。
然后,在此基础上,RocketMQ提出了事务消息机制,采⽤两阶段提交的思路,保证Main Branch1和Branch2之间的事务⼀致性。
RocketMQ事务消息流程
1. ⽣产者将消息发送⾄Apache RocketMQ服务端。
2. Apache RocketMQ服务端将消息持久化成功之后,向⽣产者返回Ack确认消息已经发送成功,此时消息被标记为"可以投递",这种状态下的消息即为半事务消息。
3. ⽣产者开始执⾏本地事务逻辑。
4. ⽣产者根据本地事务执⾏结果向服务端提交⼆次确认结果(Commit或是Rollback),服务端收到确认结果后
处理逻辑如下:
⼆次确认结果为Commit:服务端将半事务消息标记为可投递,并投递给消费者。
⼆次确认结果为Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
⼆次确认结果为Unknown:服务端将回滚事务,不会将半事务消息投递给消费者。
5. 在断⽹或者是⽣产者应⽤重启的特殊情况下,若服务端未收到发送者提交的⼆次确认结果,或服务端收到的⼆次确认结果为Unknown未知状态,经过固定时间后,服务端将对消息⽣产者即⽣产者集群中任⼀⽣产者实例发起消息回查。
6. ⽣产者收到消息回查后,需要检查对应消息的本地事务执⾏的最终结果。
7. ⽣产者根据检查到的本地事务的最终状态再次提交⼆次确认,服务端仍按照步骤4对半事务消息进⾏处理。
代码样例
1. 导入依赖
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.1</version>
</dependency>
2. 添加配置
rocketmq.name-server=localhost:9876
rocketmq.producer.group=test-group
3. 添加MQ消息发送实体
@Service
public class RocketMqSender implements IMqService<Order> {
private static final Logger logger = LoggerFactory.getLogger(RocketMqSender.class);
public static final String TOPIC_NAME = "order";
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Override
public boolean sendMqMsg(Order order, Map<String,Object> ext) {
Message<String> message = MessageBuilder.withPayload(Jacksons.writeJson(order)).build();
TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(TOPIC_NAME, message, null);
String status = result.getSendStatus().name();
if (SendStatus.SEND_OK.name().equals(status)) {
logger.info("预发送消息成功 id={} status={} ",order.getId(),status);
return true;
}
return false;
}
}
4. 添加生产者
下单时进行MQ消息的发送,在这里就不做展示了,实际就是调用“3”中的sendMqMsg方法
5. 添加MQ监听器,保证本地事务和MQ消息的一致性
@Component
@RocketMQTransactionListener
public class TransactionMsgListener implements RocketMQLocalTransactionListener {
private static final Logger logger = LoggerFactory.getLogger(TransactionMsgListener.class);
@Autowired
HandlingOrderDataService handlingOrderDataService;
@Autowired
private OrderInfoRepository orderInfoRepository;
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
Order order = null;
try {
order = new ObjectMapper().readValue(new String((byte[])message.getPayload()), Order.class);
logger.info("Start to execute local transaction"+ DateUtils.dateTime(new Date()));
// 生成订单本地事务
handlingOrderDataService.maintainOrderData(order);
return RocketMQLocalTransactionState.UNKNOWN;
}catch (Throwable throwable){
logger.error("Send half message unsuccessfully",throwable);
return RocketMQLocalTransactionState.ROLLBACK;
}
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
Order order = null;
try {
order = new ObjectMapper().readValue(new String((byte[])message.getPayload()), Order.class);
logger.info("re-query id: "+order.getId()+" re-query time: "+ DateUtils.dateTime(new Date()));
// TODO 如果本地订单状态为未成功支付,需要去查询WxPay订单状态,在此就简化为以本地数据库订单为准
OrderResultDto orderResult = orderInfoRepository.queryOrderByMoid(order.getId());
if(orderResult!=null) {
//超时15分钟后,就会把未支付的订单进行删除
if (DateUtils.isTimeExceeded(order.getTrxnTime(),"yyyyMMddHHmmss",15) && !orderResult.getOrderStatus().equals(OrderStatus.SUCCEEDED.code)) {
orderInfoRepository.deleteOrder(order.getId());
logger.warn("delete order which id is " + order.getId());
//订单,库存等一系列操作
return RocketMQLocalTransactionState.ROLLBACK;
}
if (orderResult.getOrderStatus().equals(OrderStatus.SUCCEEDED.code)) {
return RocketMQLocalTransactionState.COMMIT;
}
return RocketMQLocalTransactionState.UNKNOWN;
}
else
return RocketMQLocalTransactionState.ROLLBACK;
}catch (Exception e) {
logger.error("Checking local state happened exception",e);
return RocketMQLocalTransactionState.UNKNOWN;
}
}
}
其中,当“3”中的sendMqMsg方法返回“成功”的时候,就需要执行本地事务,如果想保证MQ事务消息,需要实现RocketMQLocalTransactionListener接口,并且在executeLocalTransaction方法中执行本地事务逻辑,比如插入订单等sql操作,如果executeLocalTransaction返回
Commit:服务端将半事务消息标记为可投递,并投递给消费者。
Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
Unknown:MQ会重试,定时执行checkLocalTransaction,根据checkLocalTransaction返回的状态决定是rollback还是commit
6. 创建消费者
简单模拟消费者消费逻辑,当消息成功commit后,就会被消费者消费。
@Component
@RocketMQMessageListener(consumerGroup = "consumerTestGroup",topic = "order",consumeMode= ConsumeMode.CONCURRENTLY,messageModel = MessageModel.BROADCASTING)
public class RocketMqConsumer implements RocketMQListener<Order> {
private static final Logger logger = LoggerFactory.getLogger(TransactionMsgListener.class);
@Override
public void onMessage(Order order) {
System.out.println(order);
}
}
只有executeLocalTransaction或者checkLocalTransaction返回commit,消息才会成功发送到RocketMQ broker中,最终被消费者消费。
注意点:
1、半消息是对消费者不可⻅的⼀种消息。实际上,RocketMQ的做法是将消息转到了⼀个系统Topic, RMQ_SYS_TRANS_HALF_TOPIC。
2、事务消息中,本地事务回查次数通过参数transactionCheckMax设定,默认15次。本地事务回查的间隔通过参数transactionCheckInterval设定,默认60秒。超过回查次数后,消息将会被丢弃。
通过以上样例,我们实现了下单逻辑,如果订单成功支付则通过MQ下发给下游服务,如果订单未支付,则移除订单。
一般而言,我们都是通过本地定时任务,来完成订单的删除逻辑,但是在引入了rocketmq之后,我们可以利用rocketmq事务消息的定时检查机制,可以替换本地定时任务完成上述逻辑。
说到这里,Rocketmq消息是基于两阶段提交的,我们都知道它在异常情况下会出现一些问题,比如本地服务或者RocketMq挂了,订单会受到什么样的影响?
①如果rocketmq挂了
如果订单在其挂之后进来的,那么就会直接报错,订单根本不回插入。
如果订单是在其挂之前进来的,本地有订单,用户没有支付,这时需要删除订单,显然订单无法删除。
②本地服务挂了
如果订单在其挂之后进来的,那么就会直接报错,订单根本不回插入。
如果订单是在其挂之前进来的,本地有订单,用户没有支付,这时需要删除订单,显然订单无法删除。
如是就需针对超时订单删除场景做出补偿机制,比如使用elasticjob分布式定时任务,删除订单,当然由于都是高可用集群,以上场景出现的概率非常小,如果业务允许的场景下,也可以无需处理。
测试结果
开始下单时,数据库中存在订单,当订单超过15分钟未支付就会删除该订单,同时回退MQ中的消息;当订单在15分钟完成支付,会向下游发送消息,