一、背景
RocketMQ的分布式事务可以称为“半消息事务”。
二、原理
2.1原理
RocketMQ是靠半消息机制实现分布式事务:
流程:
1.发送方向 MQ 服务端发送事务消息;
2.MQ Server 将消息持久化成功之后,向发送方 ACK 确认消息已经发送成功,此时消息为半消息。
3.发送方开始执行本地事务逻辑。
4.发送方根据本地事务执行结果向 MQ Server 提交二次确认(Commit 或是 Rollback),MQ Server 收到 Commit 状态则将半消息标记为可投递,订阅方最终将收到该消息;MQ Server 收到 Rollback 状态则删除半消息,订阅方将不会接受该消息。
5.在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达 MQ Server,经过固定时间后 MQ Server 将对该消息发起消息回查。
6.发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
7.发送方根据检查得到的本地事务的最终状态再次提交二次确认,MQ Server 仍按照步骤4对半消息进行操作。
2.2 疑问
极端情况:
是否任何情况下MQ的事务性消息都可以保证双方的最终一致性?答案是否定的。
考虑上面提到的异常情况“情况2:MQ发送方在步骤(3)执行完本地事务之后commit之前异常退出”。在这种情况下如果如果MQ发送方由于运维上的失误长时间不重启MQ发送方,那么MQ在多次回查不成功之后将会丢弃该消息。最终分布式事务的双方是不能达到最终一致性了。当然这个回查的最大值可以通过修改broker的参数transactionCheckMax来调整。但是过大的transactionCheckMax参数将会导致MQ堆积过多的半包消息,从而危害MQ的稳定性,是个需要权衡的参数。
三、使用
如上图所示,使用者只需要实现紫色+绿色模块:
- 紫色代表业务方自定义实现,
- 绿色代表RocketMQ定义业务需要实现的方法。
具体步骤如下:
一、生产者
1.业务方保存本地事务记录,并初始化状态。
2.业务方调用sendMessageInTransaction发送半消息到MQ的RMQ_SYS_TRANS_HALF_TOPIC队列。
3.MQ执行成功,回调业务方executeLocalTransaction方法,也就是业务方的业务逻辑。
4.业务方返回事务状态给MQ,
- commit: 塞一条消息进REAL_TOPIC真实队列,等待消费者消费。
- commit/rollback:添加一条消息进RMQ_SYS_TRANS_OP_HALF_TOPIC队列,代表已处理消息。
- unknow:根据一定的频率回查业务方本地事务状态。
5.MQ内部有定时任务,轮询比较halfoffset、opset,判定哪些未处理(无结果)消息,并回查业务方本地事务状态。
6.MQ->业务方, 执行checkLocalTransaction方法,查询本地事务状态。返回事务状态给MQ就是步骤4.
需要业务方实现的也就3个方法。
二、消费者
初始化:
自定义实现CommandLineRunner接口,执行startConsumer(): spring 容器启动完毕后,执行初始化过程。
1. XXConsumerEntry extends ConsumerEntry。init()子类实现,addConsumerAction()添加具体业务操作。指定一个tag,一个ConsumerExecutor().
2.DefaultMQPushConsumer定义消费者,MessageModel=集群消费,指定消费群组。
(注:这里还可以设置很多参数,例如:consumeMessageBatchMaxSize:一次派发消费多少条(默认1),pullBatchSize:一次拉取多少条(默认32))
3.指定消息监听器:使用base包提供的TracingRocketMQSingleConsumerr。注册监听器TracingRocketMQSingleConsumer.SingleMessageListenerConcurrently。实际上就是封装的RocketMQ的MessageListener接口,定义了consumeMessage()接口,最终会调用步骤1定义的ConsumerAction的execute()。执行消息的消费。
拉取消费
消费者会从MQ长轮询并发拉取消息,并根据初始化的MessageLister接口执行业务消费逻辑。
4.MQ根据返回的状态,如果是RECONSUME_LATER重试,就会入SCHEDULE延迟队列、RETRY重试队列、DLQ死信队列。要注意的是:进入死信队列的消息,需要管理员手动排查问题。
需要业务方实现1个方法。
四、 其它细节
4.1.从哪里开始消费
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
CONSUME_FROM_LAST_OFFSET:一个新的订阅组第一次启动从队列的最后位置开始消费,后续再启动接着上次消费的进度开始消费
CONSUME_FROM_FIRST_OFFSET:一个新的订阅组第一次启动从队列的最前位置开始消费,后续再启动接着上次消费的进度开始消费
CONSUME_FROM_TIMESTAMP:一个新的订阅组第一次启动从指定时间点开始消费,后续再启动接着上次消费的进度开始消费
4.2.一些问题排查思路
理解了RocketMQ原理,数据流转,对排查问题可以提供思路。
1.队列数据膨胀
RMQ_SYS_TRANS_HALF_TOPIC膨胀:可能是死循环了。定时任务反查事务状态,一直消费不完。
RMQ_SYS_TRANS_OP_HALF_TOPIC膨胀:业务量暴增,接口被刷。
RETRY重试、DLQ死信队列膨胀:可能是服务不可用。
2.rocketMQ业务异常日志,具体判断。
3.broker延迟可能reblance失衡。
4.3 唯一消息ID
msgId transacctionId
MessageExt extends Message :transacctionId是Message字段,msgId是MessageExt的拓展字段。
MessageExt的transactionId就是RocketMQ认为的唯一ID,消息在RocketMQn内部流转,transactionId不变,msgId 会变。看下图就明白了:
下图是生产环境rocketMQ 异常时的日志总结,注意图中newMsgId=msgId realMsgId=transactionId
注意:这里transacctionId就是RocktMQ认定的唯一事务ID。这里是说对应一个事务,但是不一定适合做接口幂等性(消息重复消费问题)。接口幂等性是与业务耦合的,保证多次执行,同一结果。
幂等性如何实现?
- 天然幂等性:纯读接口
- 后天校验型:状态机校验、业务key校验,等等。