前言
本章基于rocketmq5.1.1版本,分析新架构下的顺序消息特性。
5.x新架构下的顺序消息和4.x的区别还是很大的,以前那套都可以抛弃了。
相关历史文章:
- 4.x顺序消息
- 5.x任意时间延迟消息
- 5.xPOP消费(依赖任意时间延迟消息)
- 5.x新架构下的普通消息收发(依赖POP消费)
一、4.x回顾
生产者侧
- 用户使用层面,需要通过MessageQueueSelector 指定发送MessageQueue;
- 框架api层面,不支持重试;
ini
Long orderId = 10000L;
Message msg = new Message("TopicABC", (orderId + "").getBytes());
producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Integer id = (Integer) arg; // orderId
int index = id % mqs.size();
return mqs.get(index);
}
}, orderId);
broker侧
broker提供全局锁 相关api给consumer使用,包括批量获取锁LOCK_BATCH_MQ 和批量释放锁UNLOCK_BATCH_MQ。
RebalanceLockManager 维护了一个mqLockTable ,维护group-queue-clientId(LockEntry)的映射关系。
获取全局锁就是将consumer实例自己的group-queue-clientId放入这个table中。
为了防止consumer非正常下线,每个锁有超时时间60s,如果客户端没有及时续期,组内其他consumer可以争抢这个queue。
consumer侧
java
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order_consumer_test");
consumer.setNamesrvAddr("localhost:9876");
consumer.subscribe("TopicABC", "*");
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
return ConsumeOrderlyStatus.SUCCESS;
}
});
consumer.start();
为了保证queue顺序消费,consumer需要从两方面保证:
- 进程间,rebalance需要向broker获取全局锁,只有获取全局锁成功才能消费;
- 进程内,同一个queue的消息同时只能由一个线程顺序处理;
顺序消费与并行消费的区别:
- rebalance阶段 ,对于新增的queue需要从broker获取全局锁 ,对于移除的queue需要从broker释放全局锁;
- 收到消息阶段 ,消息不能直接分批丢到Consume线程池中,每个queue只能有一个线程消费;
- consume阶段,先获取queue对应本地锁,执行用户MessageListenerOrderly逻辑;
- 消费成功,更新本地内存offset,异步同步offset到broker;
- 消费失败 ,默认重试次数无上限,将所有消息重回ProcessQueue本地内存,延迟1s后再次消费;可选择设置重试次数上限,到达重试次数上限,将消息sendBack给broker,不挂起直接消费后续消息,但是破坏顺序语义;
- 锁续期 ,每隔20s会对所有ProcessQueue重新获取全局锁;
二、案例
topic配置 ,顺序消息 ,设置message.type=FIFO。
4.x,topic没有TopicMessageType属性,可以随便怎么用;
5.x,producer侧客户端会做校验,设置了messageGroup的消息,topic必须是FIFO类型。
sh
mqadmin updateTopic -n 127.0.0.1:9876 -c DefaultCluster -t MyOrderTopic -a +message.type=FIFO
消费组配置 ,顺序消费能力,设置**-o true**。
4.x,consumer端侧编码时registerMessageListener决定是否具备顺序消费能力,与消费组配置无关。
sh
sh mqadmin updateSubGroup -n 127.0.0.1:9876 -c DefaultCluster -g fifoConsumer1 -o true
生产者 ,消息设置messageGroup用于选择queue。
客户端无法自定义MessageQueueSelector,queue的选择交给proxy处理。
ini
Long orderId = 10000L;
Message message = provider.newMessageBuilder()
.setTopic("MyOrderTopic")
.setMessageGroup(orderId + "")
.setBody(body)
.build();
SendReceipt sendReceipt = producer.send(message);
消费者 ,和普通消费api完全一致,只需要指定前面配置的顺序topic和顺序消费组即可。
ini
ClientServiceProvider provider = ClientServiceProvider.loadService();
ClientConfiguration clientConfiguration = ClientConfiguration.newBuilder()
.setEndpoints("proxy端点")
.build();
Map<String, FilterExpression> subscription = new HashMap<>();
subscription.put("MyOrderTopic", FilterExpression.SUB_ALL); // 顺序topic
PushConsumer pushConsumer = provider.newPushConsumerBuilder()
.setClientConfiguration(clientConfiguration)
.setConsumerGroup("fifoConsumer1") // 顺序消费组
.setSubscriptionExpressions(subscription)
.setMessageListener(messageView -> {
return ConsumeResult.SUCCESS;
})
.build();
三、发消息
producer侧
ProducerImpl#send:虽然producer侧会选队列,但是实际上选队列还是在proxy侧完成的。
ProducerImpl#send0:producer发送失败,4.x顺序消息在框架api层面是没重试的,5.x是有重试的 ,和普通消息一致最多3次。
proxy侧
SendMessageActivity.SendMessageQueueSelector#select:
proxy侧按照当前topic路由视图,根据hash(messageGroup) 选择一个queue发送。
四、消费准备工作
PushConsumerImpl#startUp:消费者实例启动,需要先获取路由和Settings,才能决定是普通消费者还是顺序消费者。
Settings同步
PushSubscriptionSettings#sync:
consumer侧,收到Settings才能确定当前消费者实例是否顺序消费。
GrpcClientSettingsManager#mergeSubscriptionData:
proxy侧,Settings的fifo来源于SubscriptionGroupConfig消费组配置。
创建ConsumeService
PushConsumerImpl#createConsumeService:
如果是顺序消费组创建FifoConsumeService ,消费线程池consumptionExecutor还是20线程。
queryAssignment
PushConsumerImpl#startUp:和普通消费一样,每隔5s向proxy查询Assignments。
PushConsumerImpl#syncProcessQueue:
和普通消费一样,对于每个queue(注意是broker纬度,queueId都是-1),提交一个receiveMessage请求到proxy,开始拉消息。
注意:5.x顺序消费在客户端不需要执行任何lock请求,都在服务端处理。
五、拉消息
consumer侧
ProcessQueueImpl#receiveMessageImmediately:
每个ProcessQueue对应一个broker,向proxy发起拉消息请求(grpc ServerStream),逻辑同普通消费。
proxy侧
ReceiveMessageActivity#receiveMessage:proxy侧,和普通消费一致
- ReceiveMessageQueueSelector,客户端针对每个broker提交一个receiveMessage请求,这里仅仅选择queueId=-1,还是交给broker做queue纬度负载均衡,这是pop消费的正常逻辑;
- 根据消费组配置,判断出fifo=true,调用broker,发起长轮询;
- 收到broker消息后,包含renew逻辑;
broker侧
顺序消费拉消息的大部分工作都是在broker侧完成的。
PopMessageProcessor#processRequest:
对于顺序消费,revive topic的queueId为999 ,实际上默认revive topic只有8个queue,这是一个特殊标志。
PopMessageProcessor#processRequest:
对于顺序消费,没有pop重试topic(topic=%RETRY%{group}_{topic},queueId=0),没有拉重试topic消息逻辑。
思考:顺序pop消费的重试如何实现?
PopMessageProcessor#processRequest:
queueId一定是-1,循环所有队列拉消息(客户端决定最多32条)。如果没拉到任何消息,开启长轮询。
PopMessageProcessor#popMsgFromQueue:针对一个queue的拉消息处理逻辑,要保证队列独占
- queueLockManager.tryLock:同普通pop消费,topic+group+queue纬度互斥锁,保证对queue的单线程操作;
- getPopOffset:获取内存消费进度;
- ConsumerOrderInfoManager#checkBlock :顺序消费逻辑,如果队列有尚未ack消息,表明队列被其他消费者占用,为了保证顺序不会拉取消息,直接返回,这其实是新架构下的lock api的体现;
PopMessageProcessor#popMsgFromQueue:拉到消息之后,
顺序消费并不会发送checkpoint消息 ,只会ConsumerOrderInfoManager#update更新队列消费情况OrderInfo。
broker侧引入OrderInfo
新架构下,consumer侧不会再使用lock api,broker侧需要在拉消息时处理顺序消费队列独占。
broker侧,ConsumerOrderInfoManager 维护了topic+group+queueId纬度的顺序消费情况OrderInfo。
ConsumerOrderInfoManager 继承ConfigManager,内存table的数据会定期(每10s)持久化 到config/consumerOrderInfo.json中,重启后需要恢复内存table。
注:这个文件不参与HA复制。
OrderInfo记录了队列在一次pop请求(拉消息)之后的消费情况:
- popTime:broker收到pop请求的时间戳;
- invisibleTime:不可见时间长度,默认60s;
- offsetList:返回消息的offset集合(为了方便理解,有一点空间上的优化忽略);
- offsetNextVisibleTime:如果发生过changeInvisibleTime(renew),记录offset对应下次可见时间;
- offsetConsumedCount:每个offset的消费次数;
- lastConsumeTimestamp:就是OrderInfo的创建时间;
- commitOffsetBit:一个位图,记录offset是否收到对应ack;
- attemptId:无用,忽略;
kotlin
public static class OrderInfo {
private long popTime;
@JSONField(name = "i")
private Long invisibleTime;
@JSONField(name = "o")
private List<Long> offsetList;
@JSONField(name = "ot")
private Map<Long, Long> offsetNextVisibleTime;
@JSONField(name = "oc")
private Map<Long, Integer> offsetConsumedCount;
@JSONField(name = "l")
private long lastConsumeTimestamp;
@JSONField(name = "cm")
private long commitOffsetBit;
@JSONField(name = "a")
private String attemptId;
}
ConsumerOrderInfoManager#update:
对于顺序消费,每次pop请求,不记录checkpoint,而是记录OrderInfo。
包括:本次pop请求的时间戳popTime、不可见时间invisibleTime、拉到的消息offset集合offsetList。
ConsumerOrderInfoManager#checkBlock:
每个pop请求进来,都需要校验这个queue是否被block。
OrderInfo#needBlock:同时满足下面两个条件的情况,block本次pop请求
- 存在未ack的消息;
- 未ack消息仍然处于不可见时间内,比如pop请求时间+60s之内;
通过这种方式,broker在consumer拉消息时保证队列独占有序。
六、消费消息
consumer侧
ProcessQueueImpl#onReceiveMessageResult:
和普通消费流程一致,提交消息到消费线程,发起下一次receiveMessage请求。
FifoConsumeService#consume:顺序消费,迭代消息列表按顺序处理。
ProcessQueueImpl#eraseFifoMessage:处理顺序消费结果
- 如果消费失败,且失败次数小于17,延迟x时间再继续消费这条消息;
- 如果消费失败,且失败次数大于16 ,投递到死信队列DLQ;(这个和4.x的默认行为不同,4.x默认无限本地重试)
- 如果消费成功,发送ack给proxy;
注:重试策略由Settings同步确定,底层是消费组配置 ,默认最多重试16次 ,延迟时间从1s到30m,大概1小时45分钟;
proxy侧
ack
AckMessageActivity#processAckMessage:
如果消费成功,ack,取消renew,转换remoting协议调用broker。
sendDLQ
ForwardMessageToDLQActivity#forwardMessageToDeadLetterQueue:
如果消费失败(重试n次还是失败),取消renew,调用broker发送DLQ。
ProducerProcessor#forwardMessageToDeadLetterQueue:
- 设置delayLevel=-1,标记进入死信,调用broker sendMessageBack;
- 死信发送成功,调用broker执行ack,即本条消息被跳过;(4.x顺序消息默认行为是没有跳过的)
broker侧
DLQ先不考虑,因为和本章内容无关(不会处理OrderInfo),只看消费成功ack。
AckMessageProcessor#processRequest:
broker侧根据receiptHandle中reviveTopic的queueId=999识别出是顺序消费情况,与普通消费逻辑完全区分开(无reviveTopic的ack消息)。
AckMessageProcessor#processRequest:
- 获取队列锁;
- commitAndNext:在OrderInfo中提交offset;
- commitOffset:根据OrderInfo的提交情况,内存table提交offset(异步持久化,老逻辑了);
- checkBlock:判断OrderInfo是否都ack了;
- checkBlock=false,本批pop返回的消息都ack了,唤醒长轮询客户端,即只有上次pop请求的所有消息都收到ack,才能继续从这个队列pop消息;
ConsumerOrderInfoManager#commitAndNext:在OrderInfo中标记offset被提交
- 如果popTime不匹配,一般可能是消息超出不可见时间,已经生成新的OrderInfo(队列发生了一次新的pop),忽略本次ack;
- 将消息在对应commitOffsetBit位图中标记为1,代表已经ack;
七、renew
在4.x中,顺序consumer对于分配给自己的queue,定时调用broker执行锁续期。如果queue超时未续期,broker允许其他consumer占用这个queue。
而在5.x中,这个事情交给了proxy和broker,只不过队列锁超时时间变成消息纬度的不可见时间。如果consumer在invisibleTime内不能及时ack消息,可以允许其他consumer消费。
锁超时(broker发起)
ConsumerOrderInfoLockManager ,broker侧维护了一个Timer时间轮 ,时间轮里存放了queue级别的超时任务Timeout。
ConsumerOrderInfoLockManager#updateLockFreeTimestamp:
每次pop请求,都会提交一个新的超时任务NotifyLockFreeTimerTask,比如默认invisibleTime=60s,延迟60s执行。
NotifyLockFreeTimerTask 的作用就是消费超时唤醒长轮询客户端执行pop。
锁续期(proxy发起)
4.x,锁续期由consumer发起,定时对所有独占队列执行lock api;
5.x,锁续期由proxy发起 ,定时对单条消息执行changeInvisibleTime。
proxy侧
在上一章讲过proxy侧的renew逻辑
- receiveMessage会将每次pop请求返回n消息的receiptHandle缓存到本地(receiptHandleGroupMap);
- 后台每5s扫描一次,如果handle快过期了(可见前10s),向broker发送changeInvisibleTime请求,得到新handle;
- 如果consumer拿着老handle执行ack,proxy可以通过老handle映射到新handle,向broker发送ack;
ReceiptHandleProcessor#scheduleRenewTask:定时renew逻辑,顺序和普通消费一致,对于快消费超时的消息,向broker发送changeInvisibleTime请求。
broker侧
普通消息的changeInvisibleTime逻辑是:发送一个新的checkpoint消息,针对老checkpoint发送一个ack消息;
顺序消息的changeInvisibleTime处理逻辑不同,只要处理OrderInfo。
ChangeInvisibleTimeProcessor#processRequest:
还是根据extraInfo中revive topic的queueId=999判断是顺序消费,与普通消费走完全不同的逻辑。
ChangeInvisibleTimeProcessor#processChangeInvisibleTimeForOrder:
获取队列独占锁,计算新的可见时间,更新OrderInfo,重新调度锁超时任务。
ConsumerOrderInfoManager#updateNextVisibleTime:
- updateOffsetNextVisibleTime,更新OrderInfo中的offsetNextVisibleTime 记录的offset对应可见时间戳,这就是锁续期 (见OrderInfo#needBlock实现);
- updateLockFreeTimestamp,取消前一个超时任务,创建新的超时任务;
总结
本章分析了5.x新架构下的顺序消息实现原理。
使用方式
- 配置topic为FIFO类型;
- 配置消费组为FIFO类型;
- producer需要为Message设置messageGroup属性;
- consumer不需要做任何改动;
发消息
- producer 只需要设置messageGroup,不需要选择队列;
- proxy需要hash(messageGroup)选队列;
- producer有重试机制 ,由Settings同步确定,默认3次;(4.x顺序消息没重试)
消费前提
- consumer启动阶段必须完成Settings同步,拿到fifo标志,才能决定是普通消费还是顺序消费。言外之意,要先配置消费组开启顺序消费,再启动consumer;
- queryAssignment,分配ProcessQueue和普通消费一致(master broker数量个queueId=-1的队列);
OrderInfo
4.x,consumer调用broker lock api,获取queue独占锁,定时执行锁续期;
5.x,完全抛弃了lock api ,取而代之的是OrderInfo;
5.x普通消费和顺序消费在consumer侧看起来api一致,但是在broker侧完全走不同逻辑:
- 普通消费,消费失败是消息级别不可见,pop请求需要发送checkpoint,ack请求需要发送ack,unack(changeInvisibleTime)需要发送checkpoint和ack,checkpoint和ack都依赖任意级别延迟消息;
- 顺序消费,消费失败是队列级别不可见 ,内存记录topic+group+queue级别的pop消费情况OrderInfo ,每隔10s持久化到config/consumerOrderInfo.json文件中,重启后可恢复;
OrderInfo关键字段:
- popTime:broker收到pop请求的时间戳;
- invisibleTime:不可见时间长度,默认60s;
- offsetList:返回消息的offset集合;
- offsetNextVisibleTime:如果发生过changeInvisibleTime(renew),记录offset对应下次可见时间;
- commitOffsetBit:一个位图,记录offset是否收到对应ack;
OrderInfo代替了原来的锁api,consumer不再需要关心队列独占问题,逻辑提升到proxy和broker:
- 拉消息 ,broker处理pop请求,需要独占队列;
- 消费成功 ,broker处理ack请求,需要释放队列;
- 消费失败,抛开客户端重试,最终还是交给broker ack;
- 消费超时 ,proxy执行锁续期 ,向broker发送changeInvisibleTime,broker延长队列独占时间;
拉消息
- consumer侧,与普通消息一致;
- proxy侧
-
- 根据消费组配置得到fifo=true,转发broker带上了fifo标志;
- 同普通消费一致,包含renew逻辑,broker返回的每条消息的handle都缓存在本地;
- broker侧
-
- 根据fifo=true,走顺序消费逻辑;
- 循环n个队列捞消息;
- 获取队列级别锁(同普通消费);
- OrderInfo判断 ,如果队列中没有未ack消息,或未ack消息都超出不可见时间,才能拉消息;
- 拉到消息,记录OrderInfo(offsetList、popTime、invisibleTime);
消费消息
- consumer侧:
-
- 消费线程池默认20;
- 一个queue单线程消费;
- 消费成功,发送ack;
- 消费失败,可重试16次,根据Settings同步的策略,延迟1s-30m时长再次消费该消息;重试超过16次,发送消息到DLQ;
- proxy侧:
-
- 取消renew;
- 消费成功,转发ack;
- 消费失败,先发送消息到DLQ,再执行ack;(这是和4.x比较大的区别,4.x默认不会跳过顺序消息,会在客户端无限延迟1s重试)
- broker侧收到ack:
-
- 获取队列独占锁;
- 更新OrderInfo中的ack情况;
- 根据OrderInfo中的ack情况,更新内存消费进度;
- 如果OrderInfo所有offset都ack了,可以唤醒拉消息的长轮询客户端;
锁超时
锁超时收口到broker侧。
ConsumerOrderInfoLockManager 管理队列级别 的超时任务NotifyLockFreeTimerTask ,通过时间轮单线程调度。
pop请求拿到消息,都会提交超时任务到ConsumerOrderInfoLockManager。
如果发生超时,NotifyLockFreeTimerTask唤醒长轮询客户端,允许所有consumer尝试独占queue。
锁续期
4.x锁续期由consumer定时发起,5.x交给proxy处理,称为renew。
- proxy,收到pop消息,缓存每条消息的handle;
- proxy,定时扫描handles,如果接近超时时间(popTime+invisibleTime),向broker发起changeInvisibleTime请求,延长消息不可见时间;
- broker,收到changeInvisibleTime请求,更新OrderInfo 中对应消息的可见时间(offsetNextVisibleTime ),重新调度锁超时任务;
比如:
2024-01-05 11:00:00:一次pop请求返回了offset=10-14共5条消息,队列再次可见时间是2024-01-05 11:01:00;
2024-01-05 11:00:05:10和11都ack了;
2024-01-05 11:00:50:12-14都因为快超时被renew了,按照当前时间延长了1分钟,那么最终队列的可见时间变为2024-01-05 11:01:50;
欢迎大家评论或私信讨论问题。
本文原创,未经许可不得转载。
欢迎关注公众号【程序猿阿越】。