RocketMQ5源码(六)新架构下的顺序消息

前言

本章基于rocketmq5.1.1版本,分析新架构下的顺序消息特性。

5.x新架构下的顺序消息和4.x的区别还是很大的,以前那套都可以抛弃了。

相关历史文章

  1. 4.x顺序消息
  2. 5.x任意时间延迟消息
  3. 5.xPOP消费(依赖任意时间延迟消息)
  4. 5.x新架构下的普通消息收发(依赖POP消费)

一、4.x回顾

生产者侧

  1. 用户使用层面,需要通过MessageQueueSelector 指定发送MessageQueue
  2. 框架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需要从两方面保证:

  1. 进程间,rebalance需要向broker获取全局锁,只有获取全局锁成功才能消费;
  2. 进程内,同一个queue的消息同时只能由一个线程顺序处理;

顺序消费与并行消费的区别:

  1. rebalance阶段 ,对于新增的queue需要从broker获取全局锁 ,对于移除的queue需要从broker释放全局锁
  2. 收到消息阶段 ,消息不能直接分批丢到Consume线程池中,每个queue只能有一个线程消费
  3. consume阶段,先获取queue对应本地锁,执行用户MessageListenerOrderly逻辑;
  4. 消费成功,更新本地内存offset,异步同步offset到broker;
  5. 消费失败默认重试次数无上限,将所有消息重回ProcessQueue本地内存,延迟1s后再次消费;可选择设置重试次数上限,到达重试次数上限,将消息sendBack给broker,不挂起直接消费后续消息,但是破坏顺序语义;
  6. 锁续期每隔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侧,和普通消费一致

  1. ReceiveMessageQueueSelector,客户端针对每个broker提交一个receiveMessage请求,这里仅仅选择queueId=-1,还是交给broker做queue纬度负载均衡,这是pop消费的正常逻辑;
  2. 根据消费组配置,判断出fifo=true,调用broker,发起长轮询
  3. 收到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的拉消息处理逻辑,要保证队列独占

  1. queueLockManager.tryLock:同普通pop消费,topic+group+queue纬度互斥锁,保证对queue的单线程操作;
  2. getPopOffset:获取内存消费进度;
  3. 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请求(拉消息)之后的消费情况

  1. popTime:broker收到pop请求的时间戳;
  2. invisibleTime:不可见时间长度,默认60s;
  3. offsetList:返回消息的offset集合(为了方便理解,有一点空间上的优化忽略);
  4. offsetNextVisibleTime:如果发生过changeInvisibleTime(renew),记录offset对应下次可见时间;
  5. offsetConsumedCount:每个offset的消费次数;
  6. lastConsumeTimestamp:就是OrderInfo的创建时间;
  7. commitOffsetBit:一个位图,记录offset是否收到对应ack;
  8. 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请求

  1. 存在未ack的消息
  2. 未ack消息仍然处于不可见时间内,比如pop请求时间+60s之内;

通过这种方式,broker在consumer拉消息时保证队列独占有序。

六、消费消息

consumer侧

ProcessQueueImpl#onReceiveMessageResult:

和普通消费流程一致,提交消息到消费线程,发起下一次receiveMessage请求。

FifoConsumeService#consume:顺序消费,迭代消息列表按顺序处理

ProcessQueueImpl#eraseFifoMessage:处理顺序消费结果

  1. 如果消费失败,且失败次数小于17,延迟x时间再继续消费这条消息
  2. 如果消费失败,且失败次数大于16投递到死信队列DLQ;(这个和4.x的默认行为不同,4.x默认无限本地重试)
  3. 如果消费成功,发送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:

  1. 设置delayLevel=-1,标记进入死信,调用broker sendMessageBack;
  2. 死信发送成功,调用broker执行ack,即本条消息被跳过;(4.x顺序消息默认行为是没有跳过的)

broker侧

DLQ先不考虑,因为和本章内容无关(不会处理OrderInfo),只看消费成功ack。

AckMessageProcessor#processRequest:

broker侧根据receiptHandle中reviveTopic的queueId=999识别出是顺序消费情况,与普通消费逻辑完全区分开(无reviveTopic的ack消息)。

AckMessageProcessor#processRequest:

  1. 获取队列锁;
  2. commitAndNext:在OrderInfo中提交offset
  3. commitOffset:根据OrderInfo的提交情况,内存table提交offset(异步持久化,老逻辑了);
  4. checkBlock:判断OrderInfo是否都ack了
  5. checkBlock=false,本批pop返回的消息都ack了,唤醒长轮询客户端,即只有上次pop请求的所有消息都收到ack,才能继续从这个队列pop消息;

ConsumerOrderInfoManager#commitAndNext:在OrderInfo中标记offset被提交

  1. 如果popTime不匹配,一般可能是消息超出不可见时间,已经生成新的OrderInfo(队列发生了一次新的pop),忽略本次ack;
  2. 将消息在对应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逻辑

  1. receiveMessage会将每次pop请求返回n消息的receiptHandle缓存到本地(receiptHandleGroupMap);
  2. 后台每5s扫描一次,如果handle快过期了(可见前10s),向broker发送changeInvisibleTime请求,得到新handle;
  3. 如果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:

  1. updateOffsetNextVisibleTime,更新OrderInfo中的offsetNextVisibleTime 记录的offset对应可见时间戳,这就是锁续期 (见OrderInfo#needBlock实现);
  2. updateLockFreeTimestamp,取消前一个超时任务,创建新的超时任务;

总结

本章分析了5.x新架构下的顺序消息实现原理。

使用方式

  1. 配置topic为FIFO类型;
  2. 配置消费组为FIFO类型;
  3. producer需要为Message设置messageGroup属性;
  4. consumer不需要做任何改动;

发消息

  1. producer 只需要设置messageGroup,不需要选择队列
  2. proxy需要hash(messageGroup)选队列
  3. producer有重试机制 ,由Settings同步确定,默认3次;(4.x顺序消息没重试)

消费前提

  1. consumer启动阶段必须完成Settings同步,拿到fifo标志,才能决定是普通消费还是顺序消费。言外之意,要先配置消费组开启顺序消费,再启动consumer;
  2. queryAssignment,分配ProcessQueue和普通消费一致(master broker数量个queueId=-1的队列);

OrderInfo

4.x,consumer调用broker lock api,获取queue独占锁,定时执行锁续期;

5.x,完全抛弃了lock api ,取而代之的是OrderInfo

5.x普通消费和顺序消费在consumer侧看起来api一致,但是在broker侧完全走不同逻辑:

  1. 普通消费,消费失败是消息级别不可见,pop请求需要发送checkpoint,ack请求需要发送ack,unack(changeInvisibleTime)需要发送checkpoint和ack,checkpoint和ack都依赖任意级别延迟消息;
  2. 顺序消费,消费失败是队列级别不可见内存记录topic+group+queue级别的pop消费情况OrderInfo ,每隔10s持久化到config/consumerOrderInfo.json文件中,重启后可恢复;

OrderInfo关键字段

  1. popTime:broker收到pop请求的时间戳;
  2. invisibleTime:不可见时间长度,默认60s;
  3. offsetList:返回消息的offset集合;
  4. offsetNextVisibleTime:如果发生过changeInvisibleTime(renew),记录offset对应下次可见时间;
  5. commitOffsetBit:一个位图,记录offset是否收到对应ack;

OrderInfo代替了原来的锁api,consumer不再需要关心队列独占问题,逻辑提升到proxy和broker

  1. 拉消息 ,broker处理pop请求,需要独占队列
  2. 消费成功 ,broker处理ack请求,需要释放队列
  3. 消费失败,抛开客户端重试,最终还是交给broker ack;
  4. 消费超时proxy执行锁续期 ,向broker发送changeInvisibleTime,broker延长队列独占时间

拉消息

  1. consumer侧,与普通消息一致;
  2. proxy侧
    1. 根据消费组配置得到fifo=true,转发broker带上了fifo标志;
    2. 同普通消费一致,包含renew逻辑,broker返回的每条消息的handle都缓存在本地;
  1. broker侧
    1. 根据fifo=true,走顺序消费逻辑;
    2. 循环n个队列捞消息;
    3. 获取队列级别锁(同普通消费);
    4. OrderInfo判断 ,如果队列中没有未ack消息,或未ack消息都超出不可见时间,才能拉消息
    5. 拉到消息,记录OrderInfo(offsetList、popTime、invisibleTime);

消费消息

  1. consumer侧:
    1. 消费线程池默认20;
    2. 一个queue单线程消费
    3. 消费成功,发送ack;
    4. 消费失败,可重试16次,根据Settings同步的策略,延迟1s-30m时长再次消费该消息;重试超过16次,发送消息到DLQ;
  1. proxy侧:
    1. 取消renew
    2. 消费成功,转发ack;
    3. 消费失败,先发送消息到DLQ,再执行ack;(这是和4.x比较大的区别,4.x默认不会跳过顺序消息,会在客户端无限延迟1s重试)
  1. broker侧收到ack:
    1. 获取队列独占锁;
    2. 更新OrderInfo中的ack情况
    3. 根据OrderInfo中的ack情况,更新内存消费进度
    4. 如果OrderInfo所有offset都ack了,可以唤醒拉消息的长轮询客户端

锁超时

锁超时收口到broker侧。

ConsumerOrderInfoLockManager 管理队列级别 的超时任务NotifyLockFreeTimerTask ,通过时间轮单线程调度

pop请求拿到消息,都会提交超时任务到ConsumerOrderInfoLockManager

如果发生超时,NotifyLockFreeTimerTask唤醒长轮询客户端,允许所有consumer尝试独占queue。

锁续期

4.x锁续期由consumer定时发起,5.x交给proxy处理,称为renew。

  1. proxy,收到pop消息,缓存每条消息的handle;
  2. proxy,定时扫描handles,如果接近超时时间(popTime+invisibleTime),向broker发起changeInvisibleTime请求,延长消息不可见时间;
  3. 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;

欢迎大家评论或私信讨论问题。

本文原创,未经许可不得转载。

欢迎关注公众号【程序猿阿越】。

相关推荐
Amor风信子几秒前
华为OD机试真题---跳房子II
java·数据结构·算法
虽千万人 吾往矣20 分钟前
golang gorm
开发语言·数据库·后端·tcp/ip·golang
杨荧26 分钟前
【JAVA开源】基于Vue和SpringBoot的洗衣店订单管理系统
java·开发语言·vue.js·spring boot·spring cloud·开源
陈逸轩*^_^*43 分钟前
Java 网络编程基础
java·网络·计算机网络
这孩子叫逆1 小时前
Spring Boot项目的创建与使用
java·spring boot·后端
星星法术嗲人1 小时前
【Java】—— 集合框架:Collections工具类的使用
java·开发语言
一丝晨光1 小时前
C++、Ruby和JavaScript
java·开发语言·javascript·c++·python·c·ruby
天上掉下来个程小白1 小时前
Stream流的中间方法
java·开发语言·windows
xujinwei_gingko2 小时前
JAVA基础面试题汇总(持续更新)
java·开发语言
liuyang-neu2 小时前
力扣 简单 110.平衡二叉树
java·算法·leetcode·深度优先