万字长文讲透 RocketMQ 的消费逻辑 (下篇)

卡瓦格博之巅
发布于 2023-6-25 12:00
浏览
0收藏

6.2 顺序消费

顺序消息是指对于一个指定的 Topic ,消息严格按照先进先出(FIFO)的原则进行消息发布和消费,即先发布的消息先消费,后发布的消息后消费。

顺序消息分为分区顺序消息全局顺序消息

1、分区顺序消息

对于指定的一个 Topic ,所有消息根据 Sharding Key 进行区块分区,同一个分区内的消息按照严格的先进先出(FIFO)原则进行发布和消费。同一分区内的消息保证顺序,不同分区之间的消息顺序不做要求。

  • 适用场景:适用于性能要求高,以 Sharding Key 作为分区字段,在同一个区块中严格地按照先进先出(FIFO)原则进行消息发布和消费的场景。
  • 示例:电商的订单创建,以订单 ID 作为 Sharding Key ,那么同一个订单相关的创建订单消息、订单支付消息、订单退款消息、订单物流消息都会按照发布的先后顺序来消费。

2、全局顺序消息

对于指定的一个 Topic ,所有消息按照严格的先入先出(FIFO)的顺序来发布和消费。

  • 适用场景:适用于性能要求不高,所有的消息严格按照 FIFO 原则来发布和消费的场景。
  • 示例:在证券处理中,以人民币兑换美元为 Topic,在价格相同的情况下,先出价者优先处理,则可以按照 FIFO 的方式发布和消费全局顺序消息。

全局顺序消息实际上是一种特殊的分区顺序消息,即 Topic 中只有一个分区,因此全局顺序和分区顺序的实现原理相同

因为分区顺序消息有多个分区,所以分区顺序消息比全局顺序消息的并发度和性能更高

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

消息的顺序需要由两个阶段保证:

  • 消息发送如上图所示,A1、B1、A2、A3、B2、B3 是订单 A 和订单 B 的消息产生的顺序,业务上要求同一订单的消息保持顺序,例如订单 A 的消息发送和消费都按照 A1、A2、A3 的顺序。如果是普通消息,订单A 的消息可能会被轮询发送到不同的队列中,不同队列的消息将无法保持顺序,而顺序消息发送时 RocketMQ 支持将 Sharding Key 相同(例如同一订单号)的消息序路由到同一个队列中。下图是生产者发送顺序消息的封装,原理是发送消息时,实现 MessageQueueSelector 接口,根据 Sharding Key 使用 Hash 取模法来选择待发送的队列。

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

生产者顺序发送消息封装

  • 消息消费消费者消费消息时,需要保证单线程消费每个队列的消息数据,从而实现消费顺序和发布顺序的一致。

顺序消费服务的类是 ConsumeMessageOrderlyService ,在负载均衡阶段,并发消费和顺序消费并没有什么大的差别。

最大的差别在于:顺序消费会向 Borker 申请锁 。消费者根据分配的队列 messageQueue ,向 Borker 申请锁 ,如果申请成功,则会拉取消息,如果失败,则定时任务每隔20秒会重新尝试。

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

顺序消费核心流程如下:

1、 组装成消费对象

2、 将请求对象提交到消费线程池

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

和并发消费不同的是,这里的消费请求包含消费快照 processQueue ,消息队列 messageQueue 两个对象,并不对消息列表做任何处理。

3、 消费线程内,对消费队列加锁

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

顺序消费也是通过线程池消费的,synchronized 锁用来保证同一时刻对于同一个队列只有一个线程去消费它

4、 从消费快照中取得待消费的消息列表

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

消费快照 processQueue 对象里,创建了一个红黑树对象 consumingMsgOrderlyTreeMap 用于临时存储的待消费的消息。

5、 执行消息监听器

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

消费快照的消费锁 consumeLock 的作用是:防止负载均衡线程把当前消费的 MessageQueue 对象移除掉。

6、 处理消费结果

消费成功时,首先计算需要提交的偏移量,然后更新本地消费进度。

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

消费失败时,分两种场景:

  • 假如已消费次数小于最大重试次数,则将对象 consumingMsgOrderlyTreeMap 中临时存储待消费的消息,重新加入到消费快照红黑树 msgTreeMap中,然后使用定时任务尝试重新消费。
  • 假如已消费次数大于等于最大重试次数,则将失败消息发送到 Broker ,Broker 接收到消息后,会加入到死信队列里 , 最后计算需要提交的偏移量,然后更新本地消费进度。

我们做一个关于顺序消费的总结 :

  1. 顺序消费需要由两个阶段消息发送消息消费协同配合,底层支撑依靠的是 RocketMQ 的存储模型;
  2. 顺序消费服务启动后,队列的数据都会被消费者实例单线程的执行消费;
  3. 假如消费者扩容,消费者重启,或者 Broker 宕机 ,顺序消费也会有一定几率较短时间内乱序,所以消费者的业务逻辑还是要保障幂等

7 保存进度

RocketMQ 消费者消费完一批数据后, 会将队列的进度保存在本地内存,但还需要将队列的消费进度持久化。

1、 集群模式

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

集群模式下,分两种场景:

  • 拉取消息服务会在拉取消息时,携带该队列的消费进度,提交给 Broker 的拉取消息处理器
  • 消费者定时任务,每隔5秒将本地缓存中的消费进度提交到 Broker 的消费者管理处理器

Broker 的这两个处理器都调用消费者进度管理器 consumerOffsetManager 的 commitOffset 方法,定时任务异步将消费进度持久化到消费进度文件 consumerOffset.json 中。

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

2、 广播模式

广播模式消费进度存储在消费者本地,定时任务每隔 5 秒通过 LocalFileOffsetStore 持久化到本地文件​​offsets.json​​​ ,数据格式为 ​​MessageQueue:Offset ​​。

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

广播模式下,消费进度和消费组没有关系,本地文件 ​​offsets.json​​ 存储在配置的目录,文件中包含订阅主题中所有的队列以及队列的消费进度。

8 重试机制

集群消费下,重试机制的本质是 RocketMQ 的延迟消息功能。

消费消息失败后,消费者实例会通过 CONSUMER_SEND_MSG_BACK 请求,将失败消息发回到 Broker 端。

Broker 端会为每个 topic 创建一个重试队列 ,队列名称是:%RETRY% + 消费者组名 ,达到重试时间后将消息投递到重试队列中进行消费重试(消费者组会自动订阅重试 Topic)。最多重试消费 16 次,重试的时间间隔逐渐变长,若达到最大重试次数后消息还没有成功被消费,则消息将被投递至死信队列。

第几次重试

与上次重试的间隔时间

第几次重试

与上次重试的间隔时间

1

10 秒

9

7 分钟

2

30 秒

10

8 分钟

3

1 分钟

11

9 分钟

4

2 分钟

12

10 分钟

5

3 分钟

13

20 分钟

6

4 分钟

14

30 分钟

7

5 分钟

15

1 小时

8

6 分钟

16

2 小时

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

开源 RocketMQ 4.X 支持延迟消息,默认支持18 个 level 的延迟消息,这是通过 broker 端的 messageDelayLevel 配置项确定的,如下:

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

Broker 在启动时,内部会创建一个内部主题:SCHEDULE_TOPIC_XXXX,根据延迟 level 的个数,创建对应数量的队列,也就是说18个 level 对应了18个队列。

我们先梳理下延迟消息的实现机制。

1、生产者发送延迟消息

Message msg = new Message();
msg.setTopic("TopicA");
msg.setTags("Tag");
msg.setBody("this is a delay message".getBytes());
//设置延迟level为5,对应延迟1分钟
msg.setDelayTimeLevel(5);
producer.send(msg);

2、Broker端存储延迟消息

延迟消息在 RocketMQ Broker 端的流转如下图所示:

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

第一步:修改消息 Topic 名称和队列信息

Broker 端接收到生产者的写入消息请求后,首先都会将消息写到 commitlog 中。假如是正常非延迟消息,MessageStore 会根据消息中的 Topic 信息和队列信息,将其转发到目标 Topic 的指定队列 consumequeue 中。

但由于消息一旦存储到 consumequeue 中,消费者就能消费到,而延迟消息不能被立即消费,所以 RocketMQ 将 Topic 的名称修改为SCHEDULE_TOPIC_XXXX,并根据延迟级别确定要投递到哪个队列下。

同时,还会将消息原来要发送到的目标 Topic 和队列信息存储到消息的属性中。

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

第二步:构建 consumequeue 文件时,计算并存储投递时间

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

上图是 consumequeue 文件一条消息的格式,最后 8 个字节存储 Tag 的哈希值,此时存储消息的投递时间。

第三步:定时调度服务启动

ScheduleMessageService 类是一个定时调度服务,读取 SCHEDULE_TOPIC_XXXX 队列的消息,并将消息投递到目标 Topic 中。

定时调度服务启动时,创建一个定时调度线程池 ,并根据延迟级别的个数,启动对应数量的 HandlePutResultTask ,每个 HandlePutResultTask 负责一个延迟级别的消费与投递。

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

第四步:投递时间到了,将消息数据重新写入到 commitlog

消息到期后,需要投递到目标 Topic 。第一步已经记录了原来的 Topic 和队列信息,这里需要重新设置,再存储到 commitlog 中。

第五步:将消息投递到目标 Topic 中

Broker 端的后台服务线程会不停地分发请求并异步构建 consumequeue(消费文件)和 indexfile(索引文件)。因此消息会直接投递到目标 Topic 的 consumequeue 中,之后消费者就可以消费到这条消息。


回顾了延迟消息的机制,消费消息失败后,消费者实例会通过 CONSUMER_SEND_MSG_BACK 请求,将失败消息发回到 Broker 端。

Broker 端 SendMessageProcessor 处理器会调用 asyncConsumerSendMsgBack 方法。

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

首先判断消息的当前重试次数是否大于等于最大重试次数,如果达到最大重试次数,或者配置的重试级别小于0,则重新创建 Topic ,规则是 %DLQ% + consumerGroup,后续处理消息发送到死信队列。

正常的消息会进入 else 分支,对于首次重试的消息,默认的 delayLevel 是 0 ,RocketMQ 会将 delayLevel + 3,也就是加到 3 ,这就是说,如果没有显示的配置延时级别,消息消费重试首次,是延迟了第三个级别发起的重试,也就是距离首次发送 10s 后重试,其主题的默认规则是 %RETRY% + consumerGroup

当延时级别设置完成,刷新消息的重试次数为当前次数加 1 ,Broker 端将该消息刷盘,逻辑如下:

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

延迟消息写入到 commitlog 里 ,这里其实和延迟消息机制的第一步类似,后面按照延迟消息机制的流程执行即可(第二步到第六步)。

9 总结

下图展示了集群模式下消费者并发消费流程 :

万字长文讲透 RocketMQ 的消费逻辑 (下篇)-鸿蒙开发者社区

核心流程如下:

  1. 消费者启动后,触发负载均衡服务 ,负载均衡服务为消费者实例分配对应的队列 ;
  2. 分配完队列后,负载均衡服务会为每个分配的新队列创建一个消息拉取请求​​pullRequest​​​  ,  拉取请求保存一个处理队列 ​​processQueue​​​,内部是红黑树(​​TreeMap​​),用来保存拉取到的消息 ;
  3. 拉取消息服务单线程从拉取请求队列​​pullRequestQueue​​ 中弹出拉取消息,执行拉取任务 ,拉取请求是异步回调模式,将拉取到的消息放入到处理队列;
  4. 拉取请求在一次拉取消息完成之后会复用,重新被放入拉取请求队列​​pullRequestQueue​​ 中 ;
  5. 拉取完成后,调用消费消息服务​​consumeMessageService ​​​的  ​​submitConsumeRequest ​​方法 ,消费消息服务内部有一个消费线程池;
  6. 消费线程池的消费线程从消费任务队列中获取消费请求,执行消费监听器​​listener.consumeMessage​​ ;
  7. 消费完成后,若消费成功,则更新偏移量​​updateOffset​​​,先更新到内存 ​​offsetTable​​,定时上报到 Broker ;若消费失败,则将失败消费发送到 Broker 。
  8. Broker 端接收到请求后, 调用消费进度管理器的​​commitOffset​​​ 方法修改内存的消费进度,定时刷盘到  ​​consumerOffset.json​​。

RocketMQ 4.X 的消费逻辑有两个非常明显的特点:

  1. 客户端代码逻辑较重。假如要支持一种新的编程语言,那么客户端就必须实现完整的负载均衡逻辑,此外还需要实现拉消息、位点管理、消费失败后将消息发回 Broker 重试等逻辑。这给多语言客户端的支持造成很大的阻碍。
  2. 保证幂等非常重要。当客户端升级或者下线时,或者 Broker 宕机,都要进行负载均衡操作,可能造成消息堆积,同时有一定几率造成重复消费。

参考资料:

1、RocketMQ 4.9.4 Github 文档

​https://github.com/apache/rocketmq/tree/rocketmq-all-4.9.4/docs​

2、RocketMQ 技术内幕

3、消息队列核心知识点

​https://mp.weixin.qq.com/s/v7_ih9X5mG3X4E4ecfgYXA​

4、消息ACK机制及消费进度管理

​https://zhuanlan.zhihu.com/p/25265380​

如果我的文章对你有所帮助,还请帮忙点赞、在看、转发一下,你的支持会激励我输出更高质量的文章,非常感谢!




文章转载自公众号:勇哥java实战分享

标签
已于2023-6-25 12:00:57修改
收藏
回复
举报
回复
    相关推荐