【❧消息队列】解析 RocketMQ 业务消息——“事务消息”
在分布式系统调用场景中存在这样一个通用问题,即在执行一个核心业务逻辑的同时,还需要调用多个下游做业务处理,而且要求多个下游业务和当前核心业务必须同时成功或者同时失败,进而避免部分成功和失败的不一致情况出现。简单来说,消息队列中的“事务”,主要解决的是消息生产者和消费者的数据一致性问题。本篇文章通过拆解 RocketMQ 事务消息的使用场景、基本原理、实现细节,帮助大家更好的理解和使用 RocketMQ 的事务消息。
为什么需要事务消息
以电商交易场景为例,用户支付订单这一核心操作的同时会涉及到下游物流发货、积分变更、购物车状态清空等多个子系统的变更。当前业务的处理分支包括:
- 主分支订单系统状态更新:由未支付变更为支付成功;
- 物流系统状态新增:新增待发货物流记录,创建订单物流记录;
- 积分系统状态变更:变更用户积分,更新用户积分表;
- 购物车系统状态变更:清空购物车,更新用户购物车记录。
分布式系统调用的特点是:一个核心业务逻辑的执行,同时需要调用多个下游业务进行处理。因此,如何保证核心业务和多个下游业务的执行结果完全一致,是分布式事务需要解决的主要问题。
传统 XA 事务方案:性能不足
为了保证上述四个分支的执行结果一致性,典型方案是基于XA协议的分布式事务系统来实现。将四个调用分支封装成包含四个独立事务分支的大事务,基于XA分布式事务的方案可以满足业务处理结果的正确性,但最大的缺点是多分支环境下资源锁定范围大,并发度低,随着下游分支的增加,系统性能会越来越差。
基于普通消息方案:一致性保障困难
将上述基于 XA 事务的方案进行简化,将订单系统变更作为本地事务,剩下的系统变更作为普通消息的下游来执行,事务分支简化成普通消息+订单表事务,充分利用消息异步化的能力缩短链路,提高并发度。
该方案中消息下游分支和订单系统变更的主分支很容易出现不一致的现象,例如:
- 消息发送成功,订单没有执行成功,需要回滚整个事务;
- 订单执行成功,消息没有发送成功,需要额外补偿才能发现不一致;
- 消息发送超时未知,此时无法判断需要回滚订单还是提交订单变更。
基于RocketMQ分布式事务消息:支持最终一致性
上述普通消息方案中,普通消息和订单事务无法保证一致的本质原因是普通消息无法像单机数据库事务一样,具备提交、回滚和统一协调的能力。
而基于消息队列 RocketMQ 版实现的分布式事务消息功能,在普通消息基础上,支持二阶段的提交能力。将二阶段提交和本地事务绑定,实现全局提交结果的一致性。
消息队列 RocketMQ 版事务消息的方案,具备高性能、可扩展、业务开发简单的优势。
概念介绍
- 事务消息 :RocketMQ 提供类似 XA 或 Open XA 的分布式事务功能,通过 RocketMQ 事务消息能达到分布式事务的最终一致;
- 半事务消息 :暂不能投递的消息,生产者已经成功地将消息发送到了 RocketMQ 服务端,但是 RocketMQ 服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,处于该种状态下的消息即半事务消息;
- 消息回查 :由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,RocketMQ 服务端通过扫描发现某条消息长期处于“半事务消息”时,需要主动向消息生产者询问该消息的最终状态(Commit 或是 Rollback),该询问过程即消息回查。
事务消息生命周期
- 初始化:半事务消息被生产者构建并完成初始化,待发送到服务端的状态;
- 事务待提交 :半事务消息被发送到服务端,和普通消息不同,并不会直接被服务端持久化,而是会被单独存储到事务存储系统中,等待第二阶段本地事务返回执行结果后再提交。此时消息对下游消费者不可见;
- 消息回滚 :第二阶段如果事务执行结果明确为回滚,服务端会将半事务消息回滚,该事务消息流程终止;
- 提交待消费 :第二阶段如果事务执行结果明确为提交,服务端会将半事务消息重新存储到普通存储系统中,此时消息对下游消费者可见,等待被消费者获取并消费;
- 消费中 :消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。
- 消费提交 :消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败);RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
- 消息删除 :当消息存储时长到期或存储空间不足时,RocketMQ 会按照滚动机制清理最早保存的消息数据,将消息从物理文件中删除。
事务消息基本流程
事务消息交互流程如下图所示:
1)生产者将消息发送至 RocketMQ 服务端;
2)RocketMQ 服务端将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息被标记为“暂不能投递”,这种状态下的消息即为半事务消息;
3)生产者开始执行本地事务逻辑;
4)生产者根据本地事务执行结果向服务端提交二次确认结果(Commit 或是 Rollback),服务端收到确认结果后处理逻辑如下:
- 二次确认结果为 Commit:服务端将半事务消息标记为可投递,并投递给消费者;
- 二次确认结果为 Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
5)在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为Unknown未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查;
6)生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果;
7)生产者根据检查到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4 对半事务消息进行处理。
RocketMQ 事务消息如何实现
根据发送事务消息的基本流程的需要,实现分为三个主要流程:接收处理 Half 消息、Commit 或 Rollback 命令处理、事务消息 check。
处理 Half 消息
发送方第一阶段发送 Half 消息到 Broker 后,Broker 处理 Half 消息。Broker 流程参考下图:
具体流程是首先把消息转换 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC,其余消息内容不变,写入 Half 队列。具体实现参考 SendMessageProcessor 的逻辑处理。
Commit 或 Rollback 命令处理
发送方完成本地事务后,继续发送 Commit 或 Rollback 到 Broker。由于当前事务已经完结,Broker 需要删除原有的 Half 消息,由于 RocketMQ 的 appendOnly 特性,Broker通过 OP 消息实现标记删除。Broker 流程参考下图:
- Commit。Broker 写入 OP 消息,OP 消息的 body 指定 Commit 消息的 queueOffset,标记之前 Half 消息已被删除;同时,Broker 读取原 Half 消息,把 Topic 还原,重新写入 CommitLog,消费者则可以拉取消费;
- Rollback。Broker 同样写入 OP 消息,流程和 Commit 一样。但后续不会读取和还原 Half 消息。这样消费者就不会消费到该消息。
具体实现在 EndTransactionProcessor 中。
事务消息 check
如果发送端事务时间执行过程,发送 UNKNOWN 命令,或者 Broker/发送端重启发布等原因,流程 2 的标记删除的 OP 消息可能会缺失,因此增加了事务消息 check 流程,该流程是在异步线程定期执行(transactionCheckInterval 默认 30s 间隔),针对这些缺失 OP 消息的 Half 消息进行 check 状态。具体参考下图:
事务消息 check 流程扫描当前的 OP 消息队列,读取已经被标记删除的 Half 消息的 queueOffset。如果发现某个 Half 消息没有 OP 消息对应标记,并且已经超时(transactionTimeOut 默认 6 秒),则读取该 Half 消息重新写入 half 队列,并且发送 check 命令到原发送方检查事务状态;如果没有超时,则会等待后读取 OP 消息队列,获取新的 OP 消息。
另外,为了避免发送方的异常导致长期无法确定事务状态,如果某个 Half 消息的 bornTime 超过最大保留时间(transactionCheckMaxTimeInMs 默认 12 小时),则会自动跳过此消息,不再 check。
具体实现细节参考:TransactionalMessageServiceImpl#check 方法。
Rocket事务代码实战
我们使用RocketMQ事务消息来模拟下单减库存的场景,代码仅包含核心功能代码。
发送订单的事务消息,预提交
@RestController
public class TransactionalController {
@Autowired
private Source source;
public String transactional() {
Order order = new Order("123", "test");
String transactionId = UUID.randomUUID().toString();
MessageBuilder builder = MessageBuilder.withPayload(order).setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId);
Message message = builder.build();
source.output().send(message);
return "OK";
}
}
server.port=8081
spring.cloud.stream.rocketmq.binder.name-server=192.168.0.118:9876
spring.cloud.stream.bindings.output.destination=TopicTest
spring.cloud.stream.rocketmq.bindings.output.producer.group=demo-group
Order对象保存了订单信息,随机生成一个ID作为消息的事务ID。此时消息已经发送到Broker中,但还未投递出去,Consumer暂时还不能消费这条消息。
执行订单信息入库的事务操作,提交或回滚事务消息
@RocketMQTransactionListener(txProducerGroup = "OrderTransactionGroup")
public class TransactionalMsgListener implements RocketMQLocalTransactionListener {
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
try {
// 获取前面生成的事务ID
String transactionId = (String) message.getHeaders().get(RocketMQHeaders.TRANSACTION_ID);
// 以事务ID为主键,执行本地事务
Order order = (Order) message.getPayload();
boolean result = this.saveOrder(order, transactionId);
return result ? RocketMQLocalTransactionState.COMMIT : RocketMQLocalTransactionState.ROLLBACK;
} catch (Exception e) {
return RocketMQLocalTransactionState.ROLLBACK;
}
}
private boolean saveOrder(Order order, String transactionId) {
// 将事务ID设置为唯一键
// 调用数据库Insert into 订单表
return true;
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
// 获取事务ID
String transactionId = (String) message.getHeaders().get(RocketMQHeaders.TRANSACTION_ID);
// 以事务ID为主键,查询本地事务执行情况
if (isSuccess(transactionId)) {
return RocketMQLocalTransactionState.COMMIT;
}
return RocketMQLocalTransactionState.ROLLBACK;
}
private boolean isSuccess(String transactionId) {
// 查询数据库 select from 订单表
return true;
}
}
实现RocketMQLocalTransactionListener接口,使用@RocketMQTransactionListener注解用于接收本地事务的监听,txProducerGroup是事务组名称,RocketMQLocalTransactionListener接口有两个实现方法:
- executeLocalTransaction:执行本地事务,在第一步中消息发送成功会回调执行,一旦事务提交成功,下游应用的Consumer能收到该消息,在这里demo的本地事务就是保存订单信息入库
- checkLocalTransaction:检查本地事务执行状态,如果executeLocalTransaction方法中返回的状态是未知UNKNOWN或者未返回状态,默认会在预处理发送的1分钟后由Broker通知Producer检查本地事务,在Producer中回调本地事务监听器中的checkLocalTransaction方法。检查本地事务时,可以根据事务ID查询本地事务的状态,再返回具体事务状态给Broker。
消费订单消息
@EnableBinding({Sink.class})
@SpringBootApplication
public class App
{
public static void main( String[] args )
{
SpringApplication.run(App.class);
}
@StreamListener(Sink.INPUT)
public void receive(String msg) {
System.out.println("TopicTest receive: " + msg + ", receiveTime= " + System.currentTimeMillis());
}
}
server.port=8091
spring.cloud.stream.rocketmq.binder.name-server=192.168.0.118:9876
spring.cloud.stream.bindings.input.destination=TopicTest
spring.cloud.stream.bindings.input.group=test-group1
消费事务消息与消费普通消息的代码是一样的,无需做任何修改。