RocketMQ 消息消费

本章主要分析 RocketMQ 如何消费消息,重点剖析消息消费的过程中需要解决的问题 。
·消息队列负载与重新分布
·消息消费模式
·消息拉取方式
·消息进度反馈
·消息过滤
·顺序消息
1 RocketMQ 消息消费概述
消息消费以组的模式开展, 一个消费组内可以包含多个消费者,每一个消费组可订阅多个主题,消费组之间有集群模式与广播模式两种消费模式 。 集群模式,主题下的同一条消息只允许被其中一个消费者消费 。 广播模式,主题下的同一条消息将被集群内的所有消费者消费一次。 消息服务器与消费者之间的消息传送也有两种方式:推模式、拉模式 。 所谓的拉模式,是消费端主动发起拉消息请求,而推模式是消息到达消息服务器后,推送给消息消费者 。 RocketMQ 消息推模式的实现基于拉模式,在拉模式上包装一层,一个拉取任务完成后开始下一个拉取任务 。
集群模式下,多个消费者如何对消息队列进行负载呢?消息队列负载机制遵循一个用的思想 : 一个消息 队列同一时间 只允许被一个消费者消费,一个消费者可 以消费多个消息队列 。
RocketMQ 支持局部顺序消息消费,也就是保证同一个消息队列上的消息顺序消费 。 不支持消息全局顺序消 费, 如果要实现某一主题的全局顺序消息消费, 可以将该主题的队列数设置为 1 ,牺牲高可用性 。
RocketMQ 支持两种消息过滤模式:表达式( TAG 、 SQL92 )与类过滤模式 。

消息拉模式,主要是由客户端手动调用消息拉取 API ,而消息推模式是消息服务器主动将消息推送到消息消费端,本章将以推模式为突破口重点介绍 RocketMQ 消息消费实现原理 。

3 消费者启动流程
Step1 :构建主题订阅信息 SubscriptionData 并加入到 Rebalancelmpl 的订阅消息中 。 订阅关系来源主要有两个 。
1 )通过调用 DefaultMQPushConsumerlmpl#subscribe( String topic, String subExpression)方法 。
2 )订阅 重试主题消息 。 从这里可以 看 出, RocketMQ 消息重试是以消费组为单位,而不是主题,消息重试主题名为 %RETRY%+消费组名 。 消费者在启动的时候会自动订阅该主题,参与该主题的消息队列负载 。
Step2 :初始化 MQClientlnstance 、 Rebalancelmple (消息重新负载实现类)等 。
Step3 : 初始化消息进度 。 如果消息消费是集群模式,那么消息进度保存在 Broker 上;如果是广播模式,那么消息消费进度存储在消费端 。
Step4 :根据是否是顺序消费,创建消费端消费线程服务 。 ConsumeMessageService 主要负责消息消费,内部维护一个线程池 。
Step5 :向MQClientlnstance 注册消费者,并启动 MQClientlnstance ,在一个 NM 中的所有消费者、生产者持有同一个 MQClientlnstance, MQClientlnstance 只会启动一次。

4 消息拉取
本节将基于 PUSH 模式来详细分析消息拉取机制 。 消息消费有两种模式:广播模式与集群模式,广播模式 比较简单,每一个消费者需要去拉取订阅主题下所有消费队列的消息,本节主要基于集群模式 。 在集群模式下,同一个消费组内有多个消息消费者,同一个主题存在多个消费队列,那么消费者如何进行消息队列负载呢?从上文启动流程也知道,每一个消费组内维护一个线程池来消费消息
从 MQClientlnstance 的启动流程中可以看出 , RocketMQ 使用 一个单独的线程PullMessageService 来负责消息的拉取 。
4.1 PullMessageService 实现机制
PullMessageService 继承的是 ServiceThread ,从名称来看,它是服务线程,通过 run 方法启动
PullMessageService ,消息拉取服务线程, run 方法是其核心逻辑 。 run 方法的几个核心
要点如下 。
1 ) while ( !this . isStopped () )这是一种通用 的设计技巧, stopped 声明为 volatile, 每执行一次业务逻辑检测一下其运行状态,可以通过其他线程将 stopped 设置为 true 从而停止该线程 。
2 )从 pullRequestQueue 中获取 一 个 PullRequest 消息拉取任务,如果 pul!RequestQueue为空,则线程将阻塞,直到有拉取任务被放入 。
3 )调用 pullMessage 方法进行消息拉取 。

4.2 ProcessQueue 实现机制
ProcessQueue 是 MessageQueue 在消费端的重现、快照 。 PullMessageService 从消息服务器默认每次拉取 32 条消息,按消息的队列偏移量顺序存放在 ProcessQueue 中,PullMessageService 然后将消息提交到消 费者消费线程池,消息成功消费后从 ProcessQueue中移除 。
 

4.3 消息拉取基本流程
本节将以并发消息消费来探讨整个消息消费流程
消息拉取分为 3 个主要步骤 。
1 )消息拉取客户端消息拉取请求封装 。
2 ) 消息服务器查找并返回消息 。
3 )消息拉取客户端处理返回的消息 。
1 . 客户端封装消息拉取请求
消息拉取入口: DefaultMQPushConsumerImpl#pullMessage 。
Step1:从 PullRequest 中 获取 ProcessQueue ,如果处理队列当前状态未被丢弃,则更新ProcessQueue 的 lastPullTimestamp 为 当前时间戳;如果当前消费者被挂起,则将拉取任务延迟 1s 再次放入到 PullMessageService 的拉取任务队列中,结束本次消息拉取 。
Step2 :进行消息拉取流控 。 从消息消费数量与消费间隔两个维度进行控制 。
Step3 :拉取该主题订阅信息,如果为空 ,结束本次消息拉取,关于该队列的下一次拉取任务延迟 3s 。
Step4 :构建消息拉取系统标记
下面让我们一一来介绍 PullSysFlag 的枚举值含义 。
FLAG_COMMIT_OFFSET : 表示从内存中读取的消费进度大于 0 ,则设置该标记位。
FLAG_SUSPEND : 表示消息拉取时支持挂起 。
FLAG_SUBSCRIPTION :消息过滤机制为表达式, 则设置该标记位 。
FLAG_CLASS_FILTER:消息过滤机制为类过滤模式 。
Step5:调用 PullAPIWrapper扣illKernellmpl 方法后与服务端交互,调用 pullKernellmpl方法
1 ) MessageQueue mq :从哪个消息消费队列拉取消息 。
2 ) String subExpression :消息过滤表达式 。
3 ) String expressionType :消息表达式类型,分为 TAG 、 SQL92 。
4 ) long offset :消息拉取偏移量 。
5) int maxNums :本次拉取最大消息条数,默认 32 条 。
6 ) int sysFlag :拉取系统标记 。
7 ) long commitOffset : 当前 M巳ssageQueue 的消费进度(内存中) 。
8 ) long brokerSuspendMaxTimeMillis :消息拉取过程中允许 Broker 挂起时间,默认15s 。
9 ) long timeoutMillis :消息拉取超时时间 。
10 ) CommunicationMode communicationMode :消息拉取模式,默认为异步拉取 。
11 ) PullCallback pullCallback :从 Broker 拉取到消息后的回调方法 。


Step6 :根据 brokerName 、 BrokerId 从 MQClientlnstance 中获取 Broker 地址,在整个RocketMQ Broker 的部署结构中,相同名称的 Broker 构成主从结构,其 BrokerId 会不一样,在每次拉取消息后,会给出一个建议,下次拉取从主节点还是从节点拉取
Step7 :如果消息过滤模式为类过滤, 则需要根据主题名称、 broker地址找到注册在Broker上的 FilterServer 地址,从 FilterServer 上拉取消息,否则从 Broker 上拉取消息 。上述步骤完成后, RocketMQ 通过 MQClientAPIImpl#pulIMessageAsync 方法异步向Broker 拉取消息 。

2 消息服务端 Broker 组装消息
根据消息拉取命令 Code : RequestCode.PULL_MESSAGE , 很容易找到 Brokder 端处理消息拉取的入口 : org.apache.rocketmq.broker.processor.PullMessageProcessor#processRequest。

Step1 :根据订阅信息,构建消息过滤器 。

Step2 :调用 MessageStore.getMessage 查找消息, 该方法参数的含义如下 。
1 ) String group : 消费组名称 。
2 ) String topic : 主题名称 。
3 ) int queueld :队列 ID 。
4 ) long offset : 待拉取偏移量 。
5 ) int maxMsgNums : 最大拉取消息条数 。
6 ) MessageFilter messageFilter :消息过滤器 。
Step3 :根据主题名称与队列编号获取消息消费队列 。
nextBeg inOffset :待查找的队列偏移量 。
minOffset : 当前消息队列最小偏移量 。
maxOffset : 当前消息队列最大偏移量。
maxOffsetPy :当前 commitlog 文件最大偏移量 。
Step4 :消息偏移量异常情况校对下一次拉取偏移量 。
Step5 :如果待拉取偏移量大于 minOffset 并且小于 maxOffset时,从 当前 offset 处尝试拉取 32 条消息,根据消息队列偏移量( ConsumeQueue )从 commitlog 文件中查找消息
Step6 :根据 PullResult 填充 responseHeader 的 nextBegionOffset 、 minOffset 、 maxOffset 。
Step7 : 根据主从同 步延迟,如果从节点数据包含下一次拉取的偏移量 ,设置下一次拉取任务的 brokerId
Step8 : 根据 GetMessageResult编码转换成关系 ,如表 5 - 1 所示 。

Step9 : 如果 commitlog 标记可用并且当前节点为主节点,则更新消息消费进度
 

3. 消息拉取客户端处理消息
回到消息拉取客户 端调用人口: MQCiientAPIImpl#pul!MessageAsync, NettyRemotingClient 在收到服端响 应结 构后会 回 调 PullCallback 的 onSuccess 或 onException, PullCallBack 对象在DefaultMQPushConsumerlmpl#pul!Message 中创建 。

Step1 :根据响应结果解码成 PullResultExt 对象,此时只 是从网络中读取消息列 表 到byte[] messageBinary 属性 。 先重点看一下拉取状态码转换,如表 5-2 所示 。

Step2 :调用 pullAPIWrapper 的 processPullResult 将消息字节数组解码成消息列表填充msgFoundList ,井对消息进行消息过滤( TAG )模式 。
Step3 :更新 PullRequest 的下 一 次拉取偏移量,如果 msgFoundList 为空, 则 立 即 将PullReqeuest 放入到 PullMessageService 的 pullRequestQueue,以便 PullMessag巳Serivce 能及时唤醒并再次执行消息拉取 。 为什么 Pu llStatus.FOUND,msgFoundList 还会为空呢?因为在 RocketMQ 根据 TAG 消息过滤,在服务端只是验证了 TAG 的 hashcode ,在客户端再次对消息进行过滤 , 故可能会出现 msgFoundList 为空的情况 。
Step4 :首先将拉取到的消息存入 ProcessQueue ,然后将拉取到的消息提交到ConsumeMessageService 中供消费者消费,该方法是一个异步方法,也就是 PullCallBack 将消息提交到 ConsumeMessageService 中就会立即返回,至于这些消息如何消费, PullCallBack 不关注 。
Step5 :将消息提交给消费者线程之后 PullCallBack 将立 即返回,可以说本次消息拉取顺利完成,然后根据 pullinterval 参数,如果 pulllnterval>0 ,则等待 pulllnterval 毫秒后将PullRequest 对象放入到 PullMessageService 的 pullRequestQueue 中 ,该消息队列的下次拉取即将被激活,达到持续消息拉取,实现准实时拉取消息的效果 。

4. 消息拉取长轮询机制分析
RocketMQ 并没有真正实现推模式,而是消费者主动向消息服务器拉取消息, RocketMQ推模式是循环向消息服务端发送消息拉取请求,如果消息消费者向 RocketMQ 发送消息拉取时,消息并未到达消费队列,如果不启用长轮询机制,则会在服务端等待 shortPollingTimeMills 时间后(挂起)再去判断消息是否已到消息队列,如果消息未到达则提示消息拉取客户端 PULL_NOT_FOUND (消息不存在),如果开启长轮询模式, RocketMQ 一方面会每5s 轮询检查一次消息是否可达 , 同时一有新消息到达后立马通知挂起线程再次验证新消息是否是自 己感兴趣的消息,如果是则从 commitlog 文件提取消息返回给消息拉取客户端,否则直到挂起超时,超时时间由消息拉取方在消息拉取时封装在请求参数中, PUSH 模式默认为15s, PULL 模式通过 DefaultMQPullConsumer#setBrokerSuspendMaxTimeMillis 设置。 RocketMQ 通过在 Broker 端配置 longPollingEnable 为true来开启长轮询模式 。
RocketMQ 轮询机制由 两个线程共 同来完成 。
1 ) PullRequestHoldService : 每隔 5s 重试一次 。
2 ) DefaultMessageStore#ReputMessageService , 每处理一次重新拉取, Thread.sleep ( 1 ),继续下一次检查 。
 

5 消息队列负载与重新分布机制
PullMessageService 在启动 时由于 LinkedBlockingQueue<PullRequest> pullRequestQueue中
没有 PullRequest对象 ,故 PullMessageService 线程将阻塞。
问 题1: PullRequest 对象在 什 么 时 候 创 建并 加 入 到 pullRequestQueue 中以便 唤醒PullMessage Service 线程 。
问题 2 : 集群 内 多个消费者是如何负载主题下的多个消费队列 , 并且如果有新的消费者加入时 ,消息队列又会如何重新分布 。

RocketMQ 消息队列重新分布是由 RebalanceService 线程来实现的 。一 个 MQClientInstance持有一个 RebalanceService 实现,并随着 MQClientlnstance 的启动而启动 。

每 个 DefaultMQPushConsumerlmpl 都 持有一 个 单独的 Rebalancelmpl 对 象,该方法主要是遍历订阅 信息对每个主题 的队列进行重新负载 。 Rebalancelmpl的 Map<String,SubscriptionData> subTable 在调用消费者 DefaultMQPushConsumerlmpl#subscribe 方法时填充 。 如果订阅信息发送变化 ,例如调用了 unsubscribe 方法 ,则需要将不关心的主题消费队列从 processQueueTable 中移除 。 接下来重点分析 Rebalancelmpl#rebalanceByTopic 来分析RocketMQ 是如何针对单个主题进行消息 队列重新负 载( 以集群模式)。

Step1:从主题订阅信息缓存表 中 获取主题的队列 信 息; 发送请求从 Broker 中该消费组内 当 前所有 的消 费者客户端 ID , 主题 topic 的队列可能分布在多个 Broker 上 , 那请求发往哪个 Broker 呢 ? RocketeMQ 从主题的路由信息表 中随机选择一个 Broker。 Broker 为什么 会存在消费组内所有 消 费者的信息呢? 我们不妨回忆一下消 费者在启 动的时候会 向MQClientlnstance 中 注册消费者 , 然 后 MQC!ientlnstance 会 向所有 的 Broker 发送心跳包 ,心跳包中包含 MQC!ientlnstance 的消费者信息 。 如果 mqSet 、 cidAll 任意一个为空则忽略本次消息队列负载 。
Step2 :首先对 cidAll,mqAll 排序,这个很重要,同一个消费组内看到的视 图保持一致,确保同一个消费队列不会被多个消费者分配 。 RocketMQ 消息队列分配算法接口 。
RocketMQ 默认提供 5 种分配算法 。
1 ) AllocateMessageQueueAveragely :平均分配,推荐指数为 5 颗星 。
2 ) AllocateMessageQueueAveragelyByCircle :平均轮询分配 ,推荐指数为 5 颗星 。
3 ) AllocateMessageQueueConsistentHash : 一致性 hash 。 不推荐使用,因为消息队列负载信息不容易跟踪 。
4 ) AllocateMessageQueueByConfig :根据配置,为每一个消费者配置固定的消息队列 。
5 ) AllocateMessageQueueByMachineRoom :根据 Broker 部署机房名,对每个消费者负责不同的 Broker 上的队列 。
对比消息队列是否发生变化,主要思路是遍历当前负载队列集合,如果队列不在新分配队列集合中,需要将该队列停止消费并保存消费进度;遍历已分配的队列,如果队列不在队列负载表中( processQueueTable ) 则需要创建该队列拉取任务 PullRequest , 然后添加到 PullMessageService 线程的 pullRequestQueue 中, PulIMessageService 才会继续拉取任务 。


Step3 : ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable ,当前消费者负载的消息队列缓存表,如果缓存表中的 MessageQueue 不包含在 mqSet 中,说明经过本次消息队列负载后,该 mq 被分配给其他消费者,故需要暂停该消息队列消息的消费,方法是将 ProccessQueue 的状态设置为 draped=true,该 ProcessQueue 中的消息将不会再被消费,调用 removeUnnecessaryMessageQueue 方法判断是否将 MessageQueue 、 ProccessQueue 缓存表中移除 。 removeUnnecessaryMessageQueue 在 Rebalancelmple 定义为抽象方法 。 removeUnnecessaryMessageQueue 方法主要持久化待移除MessageQueue 消息消费进度 。 在 Push模式下,如果是集群模式并且是顺序消息消费时,还需要先解锁队列
Step4 :遍历本次负载分配到的队列 集合,如果 processQueueTable 中没有包含该消息队列,表明这是本次新增加的消息队列, 首先从内存 中移除该消息队列的消费进度,然后从磁盘中读取该消息队列的消费进度,创建 PullRequest 对象 。 这里有一个关键,如果读取到的消费进度小于 0 ,则需要校对消费进度 。 RocketMQ 提供 CONSUME_FROM_LAST_OFFSET 、 CONSUME_FROM_FIRST OFFSET 、 CONSUME_FROM_TIMESTAMP 方式,在创建消费者时可以通过调用DefaultMQPushConsumer#setConsumeFromWhere 方法设置 。PullRequest 的 nextOffset 计算逻辑位于 : RebalancePushlmpl#computePullFromWhere

Step5 :将 PullRequest 加入到 PullMessageService中 , 以便唤醒 PullMessageService 线程 。
问题 1 : PullRequest 对 象在什么时候创 建并 加入到 pullRequestQueue 中以便唤醒PullMessageService 线程 。

Rebalanceservice 线程每隔 20s 对 消 费者订 阅 的主题进行一次 队列重新分配 , 每一次分配都会获取主题的所有队列、从 Broker 服务器实时查询当前该主题该消费组内消费者列表 , 对新分配的消息队列会创建对应的PullRequest 对象 。 在一个 JVM 进程中,同一个消费组同一个队列只会存在一个 PullRequest 对象 。
问题 2 : 集群内多个消费者是如何负载主题下的多个消费队列 ,并且如果有新的消费者加入时,消息队列又会如何重新分布 。
由于每次进行队列重新负载时会从 Broker 实时查询出当前消费组内所有消费者,并且对消息队列、消费者列表进行排序,这样新加入的消费者就会在队列重新分布时分配到消费队列从而消费消息 。

6 消息消费过程
回顾一下消息拉取, PullMessageService 负责对消息队列进行消息拉取,从远端服务器拉取消息后将消息存入 ProcessQueue 消息队列处理队列中,然后调用 ConsumeMessageService#submitConsumeRequest 方法进行消息消费,使用线程池来消费消息,确保了消息拉取与消息消费的解耦 。 RocketMQ 使用ConsumeMessageService 来实现消息消费的处理逻辑 。RocketMQ 支持顺序消费与并发消费,本节将重点关注并发消费的消费流程
6.1 消息消费
消费者消息消费服务 ConsumeMessageConcurrentlyService 的主要方法是 submitConsumeRequest提交消费请求
Step 1 : consumeMessageBatchMaxSize ,消息批次,在这里看来也就是一次消息消费任务 ConsumeRequest 中包含的消息条数,默认为 1 , msgs . size()默认最多为 32 条,受DefaultMQPushConsumer.pullBatchSize 属性控制,如果 ms gs.size()小于consumeMessageBatchMaxSize , 则直接将拉取到的消息放入到 ConsumeRequest中,然后consumeRequest提交到消息消费者线程池中,如果提交过程中出现拒绝提交异常则延迟 5s 再提交,这里其实是给出一种标准的拒绝提交实现方式,实际过程中由于消费者线程池使用的任务队列为LinkedBlockingQueue无界队列,故不会出现拒绝提交异常 。

Step2 :如果拉取的消息条数大于 consumeMessageBatchMaxSize , 则对拉取消息进行分页,每页 consumeMessageBatchMaxSize 条消息,创建多个ConsumeRequest 任务并提交到消费线程池 。
Step3 :进入具体消息消费时会先检查 processQueue 的 dropped ,如果设置为 true , 则停止该队列的消费,在进行消息重新负载时如果该消息 队列被分配给消费组 内其他消费者后,需要 droped 设置为 true , 阻止消费者继续消费不属于自己的消息队列 。
Step4 :执行消息消费钩子函数 ConsumeMessageHook#consumeMessageBefore 函数 ,通过 consumer.getDefaultMQPushConsumerlmpl().registerConsumeMessageHook (hook)方法消息消费执行钩子函数 。

Step5 :恢复重试消息主题名 。这是为什么呢?这是由消息重试机制决定的, RocketMQ将消息存入 commitlog 文件时,如果发现消息的延时级别 delayTimeLevel 大于 0 , 会首先将重试主题存人在消息的属性中,然后设置主题名称为 SCHEDULE_TOPIC ,以便时间到后重新参与消息消费 。

Step6 :执行具体的消息消费,调用应用程序消息监昕器的 consumeMessage 方法,进入到具体的消息消费业务逻辑,返回该批消息的消费结果 。 最终将返回CONSUME_SUCCESS (消费成功)或 RECONSUME_LATER (需要重新消费) 。
Step7 :执行消息消费钩子函数 ConsumeMessageHook#consumeMessageAfter 函数 。
Step8 :执行业务消息消费后,在处理结果前再次验证一 下 ProcessQueue 的 isDroped状态值,如果设置为 true,将不对结果进行处理,也就是说如果在消息消费过程中进入到Step4 时,如果由于由新的消费者加入或原先的消费者出现若机导致原先分给消费者的队列在负载之后分配给别的消费者,那么在应用程序的角度来看的话,消息会被重复消费 。

Step9 :根据消息监昕器返回的结果 , 计算 acklndex ,如果返回 C ONSUME_SUCCESS,acklndex 设置为 msgs.s i ze ()-1 ,如果返回 RECONSUME_LATER, acklndex= -1 , 这是为下文发送 msg back ( ACK)消息做准备的 。

Step10 :如 果是集群模式, 业务方返回 RECONSUME_LATER ,消息并不会重新被消费,只是以警告级别输出到日志文件 。 如果是集群模式, 消息消费成功,由于acklndex=consumeRequest.getMsgs().size ()-1 , 故 i=acklndex+ 1 等于 consumeRequest.getMsgs().size() ,并不会执行 sendMessageBack。 只有在业务方返回 RECONSUME_LATER 时,该批消息都需要发 ACK 消息,如果消息发送 ACK 失败,则直接将本批 ACK 消费发送失败的消息再次封装为 ConsumeRequest ,然后延迟 5s 后重新消费 。 如果 ACK 消息发送成功,则该消息会延迟消费 。
 

Step 11 :从 ProcessQueue中移除这批消息, 这里返回的偏移量是移除该批消息后最小的偏移量,然后用该偏移量更新消息消费进度 ,以便在消费者重启后能从上一次的消费进度开始消费,避免消息重复消费 。值得重点注意的是当消息监听器返回 RECONSUME_LATER ,消息消 费进度也会向前推进,用 Proces sQueue 中 最小的队列偏移量调用消息消费进度存储器 OffsetStore 更新消费进度,这是因为当返回 RECONSUME_LATER, RocketMQ会创建一条与原先消息属性相同的消息,拥有一个唯一 的新 msgld ,并存储原消息 ID ,该消息会存入到 commitlog 文件中,与原先的消息没有任何关联,那该消息当然也会进入到ConsuemeQueue 队列中,将拥有一个全新的队列偏移量 。
 

6.2 消息确认(ACK)
如果消息监听器返回的消费结果为 RECONSUME_LATER ,则需要将这些消息发送给 Broker 延迟消息 。 如果发送 ACK 消息失败,将延迟 5s 后提交线程池进行消费 。ACK消息发送的网络客户端人口: MQClientAPIImpl#consumerSendMessageBack ,命令编码:RequestCode. CONSUMER_SEND_MSG_BACK,协议头部如图 5-12 所示 。
下面让我们一一来分析 ConsumerSendMsgBackR巳questH巳ader 的核心属性 。
1 ) offset :消息物理偏移量 。
2) group :消费组名 。
3 ) delayLevel :延迟级别, RcketMQ 不支持精确的定时消息调度,而是提供几个延时
级别, Messages toreConfig#messageDelayLevel = ” ls 5s 10s 30s lm 2m 3m 4m Sm 6m 7m 8m
9m 10m 20m 30m lh 2h”,如果 delayLevel= I 表示延迟缸,delayLevel=2 则表示延迟 10s 。
4 ) originMsgld : 消息 ID 。
5 ) originTopic :消息主题。
6 ) maxReconsumeTimes : 最大重新消费次数,默认为 16 次 。
Step 1:获取消 费组的订阅配置信息, 如果配置信息为空返回配置组信息不存在错误 ,如果重试队列数量小于1 ,则直接返回成功 , 说明该消费组不支持重试
Step2 :创建重试主题,重试主题名称 : %RETRY%+消费组名称,并从重试队列中随机选择一个队列 ,并构建 TopicConfig 主题配置信息 。
Step3 :根据消息物理偏移量从 commitlog 文件中 获取消息, 同时将消息的主题存入属性中 。
Step4 :设置消息重试次数, 如果消息已重试次数超过 maxReconsumeTimes ,再次改变newTopic 主题为 DLQ (”%DLQ%”),该主题的权限为只写,说明消息一旦进入到 DLQ 队列中, RocketMQ 将不负责再次调度进行消费了, 需要人工干预 。
Step5 : 根据原先的消息创 建一个新的消息对象,重试消息会拥 有自 己 的唯一消息 ID( msgld )并存人到 comm itlog 文件中,并不会去更新原先消息 , 而是会将原先的主题、 消息ID 存入消息的属性中 , 主题名称为重试主题 , 其他属性与原先消息保持相同 。
Step6 :将消息存入到 CommitLog 文件中 。 该部分逻辑在前面章节中己 详细介绍,这里想再重点突 出一个机制,消息重试机制依托于定时任务实现。
在存入 Commitlog 文件之前,如果消息的延迟级别 delayTimeLevel 大于 0 , 替换消息的主题与队列为定时任务主题“ SCHEDULE_TOPIC_XXXX ”,队列 ID 为延迟级别减1 。 再次将消息主题、队列存入消息的属性中,键分别为 : PROPERTY_REAL_TOPIC 、PROPERTY_REAL QUEUE_ID 。

ACK 消息存入 CommitLog 文件后 ,将依托 RocketMQ 定时消息机制在延迟时间到期后再次将消息拉取,提交消费线程池
 

6.3 消费进度管理
消息消费者在消费一批消息后,需要记录该批消息已经消费完毕,否则 当消费者重新启动时又得从消息消费队列的开始消费,这显然是不能接受的 。一次消息消费后会从 Proce巳Queue 处理队列中移除该批消息,返回 ProceeQueue 最小偏移量 ,并存入消息进度表中 。 那消息进度文件存储在哪合适呢?
广播模式 : 同一个消费组的所有消息消费者都需要消费主题下的所有消息,也就是同组内的消费者的消息消费行为是独立的,互相不影响,故消息进度需要独立存储,最理想的存储地方应该是与消费者绑定 。
集群模式:同一个消费组内的所有消息消费者共享消息主题下的所有消息, 同一条消息(同一个消息消费队列)在同一时间只会被消费组内的一个消费者消费,并且随着消费队列的动态变化重新负载,所以消费进度需要保存在一个每个消费者都能访问到的地方 。
 

1 . 广播模式消费进度存储
广播模式消息消 费进度存储在消费者本地 ,其实现类 org .apache.rocketmq.client.consumer. s tore .LocalFileOffsetStore 。
广播模式消费进度与消费组没啥关系 ,直接保存 MessageQueue:Offset。
persistAll(Set<MessageQueue> mqs) 持久化消息进度
持久化消息进度就 是将 ConcurrentMap<MessageQueue, AtomicLong> offsetTable 序列化到磁盘文件中 。 关键是什么时候持久化消息消费进度 。 原来在MQClientlnstance 中会启动一个定时务,默认每 5s 持久化一 次,可通过 persistConsumerOffsetlnterval 设置 。
 

2. 集群模式消费进度存储
集群模式消息进度存储文件存放在消息服务端 Broker。消息消费进度集群模式实现类:org.apache.rocketmq.client.consumer.store.RemoteBrokerOffsetStore 。

消息消费进度的读取,持久化与广播模式实现细节差不多,集群模式消息进度如果从内存中读取消费进度则从 RemoteBrokerOffsetStore的 ConcurrentMap<MessageQueue, AtomicLong> offsetTable =new ConcurrentHashMap<Messag巳Qu巳ue, AtomicLong>()中根据消息消费队列获取其消息消费进度;如果从磁盘读取,则发送网络请求,请求命令为 QUERY_CONSUMER OFFSET。 持久化消息进度,则请求命令为 UPDATE_CONSUMER_OFFSET,更新 ConsumerOffsetManager的 ConcurrentMap<String/* topic@group */, ConcurrentMap<Integer/*消息队列 ID*/, Long/*消息消费进度*/> offsetTable, Broker 端默认 10s 持久化一次消息进度,存储文件名:${RocketMQ_HOME}/store/config/consumerOffset.json。 

3 消费进度设计思考
消息消费进度的存储,广播模式与消费组无关,集群模式下以主题与消费组为键保存该主题所有队列的消费进度 。 结合并发消息消费的整个流程,思考一下并发消息消费关于消息进度更新的问题
1 )消费者线程池每处理完一个消息消费任务( ConsumeRequest)时会从 ProceeQueue中移除本批消费的消息 ,并返回 ProceeQueue 中最小的偏移量,用该偏移量更新消息队列消费进度,也就是说更新消费度与消费任务中的消息没什么关系 。
2 )触发消息消费进度更新的另外一个是在进行消息负载时,如果消息消费队列被分配给其他消费者时,此时会将该 ProceeQueue 状态设置为 drop时,持久化该消息队列的消费进度,并从内存中移除 。
 

7 定时消息机制
定时消息是指消息发送到 Broker 后,并不立即被消费者消费而是要等到特定的时间后才能被消费, RocketMQ 并不支持任意的时间 精度, 如果要支持任 意 时间精度的定时调度,不可避免地需要在 Broker 层做消息排序(可以参考 JDK 并发包调度线程池ScheduledExecutorService 的实现原理),再加上持久方面的考量,将不可避免地带来具大的性能消耗,所以 RocketMQ 只支持特定级别的延迟消息 。
消息延迟级别在 Broker 端通过messageDelayLevel 配置,默认为 ” ls 5s 10s 30s lm 2m 3m 4m 5m 6m 7m 8m 9m l0m 20m 30m lh 2h
上文提到的消息重试正是借助定时任务实现的,在将消息存入 commitlog 文件之前需要判断消息的重试次数 ,如果大于 0 ,则会将消息的 主题设置为 SCHEDULE TOPIC XXXX 。RocketMQ 定时消息 实现类为 org.apache .rocketmq. store.schedule. ScheduleMessageService 。该类的实例在 DefaultMessageStore 中创建 ,通过在 DefaultMessageStore 中调用 load 方法加载并调用 start 方法进行启动 。

接下来我们分析下 ScheduleMessageService 实现原理 。
下面让我们一一来分析 ScheduleMessageService 的核心属性。
I ) SCHEDULE_TOPIC :定时消息统一主题。
2) FIRST_DELAY_TIME :第一次调度时延迟的时间,默认为 ls 。
3) DELAY_FOR_A_WHILE : 每一延时级别调度一次后延迟该时间间隔后再放入调度池 。
4) DELAY FOR A PERIOD :发送异常后延迟该时间后再继续参与调度 。
5 ) ConcurrentMap<Integer /* level */, Long/* delay timeMillis */> delayLevelTable :延迟级别,将” 1s 5s 10s 30s lm 2m 3m 4m Sm 6m 7m 8m 9m 10m 20m 30m lh 2h”字符串解析成 delayLevelTable , 转换后的数据结构类似{ 1: 1000,2 :5000立 30000,...} 。
6 ) ConcurrentMap<Integer /* level */, Long/* offset*/> offsetTable :延迟级别消息消费进度 。
7 ) DefaultMessageStore defaultMessageStore :默认消息存储器 。
8) int maxDelayLevel: MessageStoreConfig#messageDelayLevel 中最大消息延迟级别 。
ScheduleMessageService 方法的调用顺序 : 构造方法-> load()->start()方法 。
 

Step1 :根据延迟队列创建定 时任务,遍历延迟级别 ,根据延迟级别 level 从 offsetTable中获取消费队列的消费进度, 如果不存在, 则使用 0。 也就是说每一个延迟级别对应一个消息消费队列 。 然后创建定时任务,每一个定时任务第一次启动时默认延迟 ls 先执行一次定时任务,第二次调度开始才使用相应的延迟时间 。 延迟级别与消息消费队列 的映射关系为 :消息 队列 ID=延迟级别 - 1 。
 

定时消息的第 一 个设计关键点是 , 定时消息单独一个 主 题 : SCHEDULE_TOPIC_xxxx , 该主题 下 队列数量等于 MessageStoreConfig#messageDelayLevel 配置的延迟级别数量 , 其对应关系为 queueld 等于延迟级别减 1 。 ScheduleMessageS巳rvice 为每 一 个延迟级别创建 一个定时 Timer 根据延迟级别对应的延迟时间进行延迟调度 。 在消息发送时 , 如果消息的延迟级别 delayLevel 大于 0 , 将消息的原主题名称、队列 ID 存入消息的属性中,然后改变消息的 主 题、队列与延迟主题与延迟 主 题所属队列 , 消息将最终转发到延迟队列的消费队列 。
 

Step2 :创建定时任务,每隔 10s 持久化一次延迟队列的消息消 费进度(延迟消息调进度),持久化频率可以过 flushDelayOffsetlnterval 配置属性进行设置 。
7.3 定时调度逻辑
ScheduleMess ageService的start方法启动后,会为每一个延迟级别创建一个调度任务,每一个延迟级别其实对SCHEDULE_TOPIC_XXXX 主题下的一个消息消费队列 。 定时调度任务的实现类为DeliverDelayedMessageTimerTask ,其核心实现为 executeOnTimeup 。

Step1:根据队列 ID 与延迟主题查找消息消费 队列,如 果未找 到 ,说明目前并不存在该延时级别的消息,忽略本次任务,根据延时级别创建下一次调度任务即可 。
Step2 : 根据 offset 从消 息消费队列中获取当前队列中所有有效的消息 。 如果未找到,则更新一下延迟队列定时拉取进度并创建定时任务待下一次继续尝试 。
Step3 : 遍历 ConsumeQueue ,每一个标准 ConsumeQueue 条目为 2 0 个字节 。 解析出消
息的物理偏移量、 消息长度 、 消息 tag hashcode ,为从 commitlog 加载具体的消息做准备 。
Step4 :根据消息物理偏移量与消息大小从 commitlog 文件 中查找消 息 。 如果未找到 消息,打印错误 日 志,根据延迟时间创建下一个定时器 。
Step5 : 根据消息重新构建新的消息对象,清除消息的延迟级别属性( delayLevel )、并恢复消息原先的消息主题与消息消费队列,消息的消费次数 reconsumeTimes 并不会丢失 。
Step6 :将消息再次存入到 commitlog ,并转发到主题对应的消息队列上,供消费者再次消费 。
Step7: 更新延迟队列拉取进度 。
 

上述就是定时消息的实现原理,其整个流程如图 5-20 所示 。
1 )消息消费者发送消息 ,如 果发送消息 的 delayLevel 大于 0 ,则改 变消息主题为SCHEDULE_TOPIC_XXXX ,消息队列为 delayLevel 减 1 。
2 )消息经由 commitlog 转发到消息消费队列 SCHEDULE_TOPIC_XXXX 的消息消费队列 0。
3 )定时任务 Time 每隔 1s 根据上次拉取偏移量从消费 队列中取出所有消息 。
4 )根据消息的物理偏移量与消息大小从 CommitLog 中拉取消息 。
5 )根据消息属性重新创 建消息,并恢复原主题 topicA 、 原队列ID ,清除 delayLevel属性,存入 commitlog 文件 。
6 )转发到原主题 topicA 的消息消 费 队列,供消息消 费者消费 。

 

8 消息过滤机制
RocketMQ 支持表达式过滤与类过滤两种模式 ,其 中 表达式又分为 TAG 和 SQL92 。
类过滤模式允许提交一个过滤类到 FiIterServer ,消息消费者从 FilterServer 拉取消息,消息经过 FilterServer 时会执行过滤逻辑 。 表达式模式分为 TAG 与 SQL92 表达式, SQL92 表达式以消息属性过滤上下文, 实现 SQL 条件过滤表达式而 TAG 模式就是简单为消息定义标签,根据消息属性 tag 进行匹配 。

消息发送者在消息发送时如果设置了消息的 tags 属性,存储在消息属性中,先存储在CommitLog 文件中,然后转发到消息消费队列,消息消费队列会用 8 个字节存储消息 tag的 hashcode ,之所以不直接存储 tag 字符串,因为将 ConumeQueue 设计为定长结构,加快消息消费的加载性能 。在Broker 端拉取消息时午,遍历 ConsumeQueue ,只对 比消息tag的hashcode ,如果匹配则返回,否则忽略该消息 Consume 在收到消息后,同样需要先对消息进行过滤,只是此时比较的是消息 tag 的值而不再是 hashcode。
Step1: 消费者订阅消息主题与消息过滤表达式 。 构建订阅信息并加入到 Rebalancelmpl中,以便 Rebalancelmpl 进行消息队列负载
下面让我们一一来介绍 SubscriptionData 的核心属性 。
1 ) String SUB_ALL :过滤模式,默认为全匹配 。
2 ) boolean classFilterMode : 是否是类过滤模式,默认为 false 。
3 ) String topic :消息主题名称 。
4 ) String subString :消息过滤表达式,多个用双竖线隔开,例如“ TAGA l l TAGB ” 。
5 ) Set<String> tagsSet :消息过滤 tag集合 ,消费端过滤时进行消息过滤的依据 。
6) Set<String> codeSet : 消息过滤 tag hashcode集合 。
7 ) String expressionType :过滤类型, TAG 或 SQL92 。
Step2 :根据订阅 消息构建消息拉取标记,设置 subExpression 、 classFilter 等与消息过滤相关。
Step3 :根据主题、消息过滤表达式构建订阅消息实体 。 如果是不是 TAG 模式,构建过滤数据 ConsumeFiIterData。
Step4 :构建消息过滤对象, ExpressionForRetryMessageFilter,支持对重试主题的过滤,ExpressionMessageFilter ,不支持对重试主题的属性过滤 ,也就是如果是 tag 模式,执行 isMatchedByCommitLog 方法将直接返回 true 。
Step5 :根据偏移量拉取消息后 , 首先根据 ConsumeQueue 条目进行消息过滤,如果不匹配则直接跳过该条消息,继续拉取下一条消息 。
Step6 :如果消息根据 ConsumeQueue 条目通过过滤,则需要从 CommitLog 文件中加载整个消息体,然后根据属性进行过滤 。 当然如果过滤方式是 TAG 模式,该方法默认返回true
如果订阅消息为空,返回 true ,不过滤;如果是类过滤模式,返回 true ;如果是 TAG过滤模式,并且消息的 tagsCode 为空或 tagsCode 小于 0 ,返回 true ,说明消息在发送时没有设置 tag 。 如果订阅消息的 TAG hashcodes 集合中包含消息的 tagsCode ,返回 true 。 基于TAG 模式,根据 ConsumeQueue 进行消息过滤时只对比 tag 的 hash code ,所以基于 TAG 模式消息过滤,还需要在消息消费端对消息 tag 进行精确匹配 。
 

从消息拉取流程知道,消息拉取线程 PullMessageService 默认会使用异步方式从服务器拉取消息,消息消费端会通过 PullAPIWrapper 从响应结果解析出拉取到的消息 。 如果消息过滤模式为 TAG 模式,并且订阅 TAG 集合不为空,则对消息的 tag 进行判断,如果集合中包含消息的 TAG 则返回给消费者消费,否则跳过 。
 

9 顺序消息
RocketMQ 支持局部消息顺序消费,可以确保同 一个消息消 费 队列中的消息被顺序消费,如果需要做到全局顺序消费则可以将主题配置成一个队列,例如数据库 BinLog 等要求严格顺序的场景 。 根据并发消息消费的流程,消息消费包含如下 4 个步骤 : 消息队列负载、消息拉取、消息消费、消息消费进度存储。
9.1 消息队列负载
RocketMQ 首先需要通过 RebalanceService 线程实现消息队列的负载, 集群模式下同一个消 费组内的消 费者共同 承担其订阅 主题下消息队列的消 费, 同一个消息消费队列在同一时刻只会被消费组内一个消费者消费, 一个消费者同一时刻可以分配多个消费队列 。
 

如果经过消息 队列 重新负载(分配)后 ,分配到新的消息 队列时 ,首先需要尝试 向Broker 发起锁定该消 息队列的请求,如果返 回加锁成功则创建该消息 队列的拉取任务,否则将跳过 ,等待其他消费者释放该消息 队列的锁 ,然后在下一次队列重新负载时再尝试加锁 。
顺序消息消费与并发消息消费的第 一个关键区别 : 顺序消息在创建消息队列拉取任务时需要在 Broker 服务器锁定该消息队列。
9.2 消息拉取
RocketMQ 消息拉取由 PullMessageService 线程负责,根据消息拉取任务循环拉取消息 。
9.3 消息消费
顺序消息消费的实现类 : org.apache.rocketmq.client.impl .consumer.ConsumeMessageOrderlyServiCe 。

1. ConsumeMessageOrderlyService 构造方法
2 . ConsumeMessageOrderlyService 启动方法

Step 1 : ConcurrentMap< MessageQueue, ProcessQueue> processQueueTable ,将消息队列
按照 Broker 组织成 Map<String/*brokerName* , Set<MessageQueue>> ,方便下一步 向 Broker发送锁定消息、队列请求 。

Step2 :向 Broker ( Master 主节点)发送锁定消息队列, 该方法返回成功被 当 前消 费者锁定的消息消费队列 。
Step3 :将成功锁定的消息消费队列相对应的处理队列设置为锁定状态,同时更新加锁时间 。
Step4 :遍历当前处理队列中的消息消费队列,如果当前消费者不持有该消息队列的锁 ,将处理队列锁状态设置为 false ,暂停该消息消费队列的消息拉取与消息消费 。


3. ConsumeMessageOrderlyService 提交消费任务
Step1:如果消息处理队列为丢弃, 则停止本次消费任务 。
Step2 :根据消息队列获取一个对象 。 然后消息消费时先申请独占 objLock 。 顺序消息消费的并发度为消息队列 。 也就是一个消息消费队列同一时刻只会被一个消费线程池中一个线程消费 。
Step3 :如果是广播模式 的话,直接进入消费,无须锁定处理队列,因为相互直接无竞争; 如果是集群模式,消息消费的前提条件是 proceessQueue被锁定并且锁未超时 。
Step4 : 顺序消息消费处理逻辑,每一个 ConsumeRequest 消费任务不是以消费消
息条数来计算的,而是根据消费时间,默认当消费时长大于 MAX_TIME_CONSUME_CONTINUOUSLY ,默认 60s 后,本次消费任务结束,由消费组内其他线程继续消费 。
Step5 : 每次从处理队列中按顺序取出 consumeBatchSize 消 息,如果未取到 消息,则设置 continueConsume 为 false ,本次消费任务结束 。 顺序消息消费时,从 ProceessQueue 中取出的消息,会临时存储在 ProceeQueue 的 consumingMsgOrderlyTre巳Map 属性中 。
Step6 : 执行消息消费钩子函数(消息消费之前 before 方法),通过DefaultMQPushConsumerimpl#registerConsumeMessageHook ( ConsumeMessageHookconsumeMessagehook)注册消息消费钩子函数并可以注册多个 。
Step7 :申请消息 消费锁,如果消息 队列被丢弃,放弃该消息消费 队列的消 费,然后执行消息消费监听器,调业务方具体消息监听器执行真正的消息消费处理逻辑,并通知RocketMQ 消息消费结果 。
Step8 :执行消息消 费钩子函数, 计算消 息消 费过程中应用程序抛出异常,钩子函数的后处理逻辑也会被调用 。
Step9 :如 果消息消费结果为 Consum巳OrderlyStatus .SUCCESS ,执行 ProceeQueue 的commit 方法,并返回待更新的消息消费进度 。
Step10 : 存储消息消费进度 。
 

9.4 消息队列锁实现
顺序消息消费的各个环节基本都是围绕消息消费队列( MessageQueue)与消息处理队列 ( ProceeQueu巳) 展开的 。 消息消费进度拉取,消息进度消费都要判断 ProceeQueue 的locked 是否为 true ,设置 ProceeQueu巳为 true 的前提条件是消息消费者( cid )向 Broker 端发送锁定消息队列的请求并返回加锁成功 。 服务端关于 MessageQueue 加锁处理类: org.apache.rocketmq.broker.client.rebalance.RebalanceLockManager 。
 

10 本章小结
本章主要介绍了消息消费的实现细节 。 其主要关注点包括消息消费方式、消息队列负载、消息拉取、消息消费、消息消费进度存储、消息过滤、定时消息、顺序消息 。
RocketMQ 消息消费方式分别为集群模式与广播模式 、 集群模式 。
消息队列负载由 Rebalanceservice 线程默认每隔 20s 进行一次消息队列负载,根据当前消费组内消费者个数与主题队列数量按照某一种负载算法进行队列分配,分配原则为同一个消费者可以分配多个消息消费队列,同一个消息消费队列同一时间只会分配给一个消费者 。


消息拉取由 PullMessageService 线程根据 RebalanceService 线程创建的拉取任务进行拉取,默认一批拉取 32 条消息,提交给消费者消费线程池后继续下一次的消息拉取 。 如果消息消费过慢产生消息堆积会触发消息消费拉取流控 。
 

并发消息消费指消费线程池中的线程可以并发地对同一个消息消费队列的消息进行消费,消费成功后,取出消息处理队列中最小的消息偏移量作为消息消费进度偏移量存在于消息消费进度存储文件中,集群模式消息进度存储在 Broker (消息服务器),广播模式消息进度存储在消费者端 。 如果业务方返回 RECONSUME_LATER,则 RocketMQ 启用消息消费重试机制,将原消息的主题与队列存储在消息属性中,将消息存储在主题名为SCHEDULE_TOPIC_XXXX 的消息消费队列中,等待指定时间后, RocketMQ 将自动将该消息重新拉取并再次将消息存储在 commitlog 进而转发到原主要的消息消费队列供消费者消费,消息消费重试主题为 %RETRY% 消费者组名 。
 

RocketMQ 不支持任意精度的定时调度消息,只支持自定义的消息延迟级别,例如1s 2s 5s 等,可通过在 broker 配置文件中设置 messageDelayLevel 。 其实现原理是 RocketMQ为这些延迟级别定义对应的消息消费队列,其主题为 SCHEDULE_TOPIC_XXXX ,然后创建对应延迟级别的定时任务从消息消费队列中将消息拉取并恢复消息的原主题与原消息消费队列再次存入commitlog 文件并转发到相应的消息消费队列以便消息消费者拉取消息并消费 。
RocketMQ 消息消费支持表达式与类过滤模式,本章重点分析了基于表达式的消息过滤,其中表达式消息过滤又分为基于 TAG 模式与 SQL92 表达式, TAG 模式就是为消息设定一个 TAG ,然后消息消费者订阅 TAG ,如果消费者订阅的 TAG 列表包含消息的 TAG 则消费该消息 。 SQL92 表达式基于消息属性实现 SQL 条件表达式的过滤模式 。
顺序消息消费一般使用集群模式,是指消息消费者内的线程池中的线程对消息消费队列只能串行消费 。 与并发消息消费最本质的区别是消费消息时必须成功锁定消息消费队列,在 Broker 端会存储消息消费队列的锁占用情况 。
 

文章参考:《RocketMQ技术内幕》,作者:丁威,周继锋。

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
RocketMQ提供了消息过滤功能,通过生产者和消费者对消息的属性、标签进行定义,并在服务端根据过滤条件进行筛选匹配,将符合条件的消息投递给消费者进行消费。\[1\] 在消费者订阅了某个主题后,RocketMQ会将该主题中的所有消息投递给消费者。如果消费者只需要关注部分消息,可以通过设置过滤条件在服务端进行过滤,只获取到需要关注的消息子集,避免接收到大量无效的消息。这在一些应用场景中非常有用。\[2\] 然而,有时候会出现同一个tag分布在不同的队列中,而消费者只分配到了部分队列。这可能导致某些消息消费者过滤掉,但其他消费者却无法消费这些消息,从而造成消息丢失。为了解决这个问题,可以采取一些方案,例如使用广播模式,让所有消费者都能接收到所有的消息,或者使用消息过滤的方式,确保消息被正确地投递给对应的消费者。具体的解决方案可以根据实际情况进行选择和实施。\[3\] #### 引用[.reference_title] - *1* *2* *3* [rocketmq消息过滤](https://blog.csdn.net/xixingzhe2/article/details/128154159)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值