概述

当消息被存储后,消费者就会将其消费。

这句话简要的概述了一条消息的最总去向,也引出了本文将讨论的问题:

  • 消息什么时候才对被消费者可见?

    是在 page cache 中吗?还是在落盘后?还是像 Kafka 一样维护了一个 ISR 队列,等到副本都将消息也落盘后才可见?

  • 消息如何到达消费者手里?

    是由 Broker push 过去吗?还是由消费者自己 pull?

  • 怎样知道消息消费到哪里?进度由谁管理?是可靠的吗?

  • ...

本文接下来将从消费者的客户端开始介绍,逐步回答以上问题

Pull

Client

RocketMQ 中,具有三个客户端类:

  • DefaultMQPullConsumer(deprecated)
  • DefaultLitePullConsumer
  • DefaultMQPushConsumer

其中,前两个为 Pull 类型,即由自己去拉取消息;后面一个是 Push 类型,即只需要设置好回调方法等,然后等待消息到来后进行调用该方法即可。

但实际上,他们的底层实现都是 pull,即都是由客户端去 Broker 获取消息。

这是因为,使用 Push 需要额外考虑一些问题,如消费者的消费速率慢于 Broker 的发送速率时会导致消费者的缓冲区满,即使可以通过设置背压(BackPressure)机制来做流量控制,但这样毫无疑问会增加程序的复杂度。但如果是消费者按需拉取的话,则设计方法会简便很多,也可以将消费者缓冲区满的问题转化为 Broker 消息堆积的问题。

我们首先来看第二个客户端类 DefaultLitePullConsumer(第一个已经被 RocketMQ 标记为 deprecated,所以不进行介绍)

该 Pull 客户端有两种使用方式,第一种是 Assign,即由我们自己分配要拉取的 queue

  1. // 获取 Topic 的所有 Queue
  2. Collection<MessageQueue> mqSet = litePullConsumer.fetchMessageQueues("TopicTest");
  3. List<MessageQueue> list = new ArrayList<>(mqSet);
  4. // 将要订阅的 queue 加入到 List
  5. List<MessageQueue> assignList = new ArrayList<>();
  6. for (int i = 0; i < list.size() / 2; i++) {
  7. assignList.add(list.get(i));
  8. }
  9. // 手动为这个消费者分配一个消息队列列表
  10. litePullConsumer.assign(assignList);
  11. /*
  12. * 覆盖消费者对于一个 queue 的消费偏移量
  13. * 如果针对同一个消息队列多次调用此 API,则在下一次 poll() 中将使用最新的偏移量
  14. * 请注意,如果在消费过程中随意使用该API,可能会丢失数据
  15. */
  16. litePullConsumer.seek(assignList.get(0), 10);

第二种是订阅,我们只需要注册需要拉取的 Topic,对于从哪个 queue 拉取,我们并不关心,由系统自动分配

  1. // 选择从 queue 头部开始 pull 还是从尾部开始 pull
  2. litePullConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
  3. litePullConsumer.subscribe("TopicTestA", "*");

以下为两种操作的内部实现

  1. public synchronized void assign(Collection<MessageQueue> messageQueues) {
  2. setSubscriptionType(SubscriptionType.ASSIGN);
  3. assignedMessageQueue.updateAssignedMessageQueue(messageQueues);
  4. if (serviceState == ServiceState.RUNNING) {
  5. updateAssignPullTask(messageQueues);
  6. }
  7. }
  1. public synchronized void subscribe(String topic, String subExpression) throws MQClientException {
  2. setSubscriptionType(SubscriptionType.SUBSCRIBE);
  3. SubscriptionData subscriptionData = FilterAPI.buildSubscriptionData(topic, subExpression);
  4. this.rebalanceImpl.getSubscriptionInner().put(topic, subscriptionData);
  5. this.defaultLitePullConsumer.setMessageQueueListener(new MessageQueueListenerImpl());
  6. assignedMessageQueue.setRebalanceImpl(this.rebalanceImpl);
  7. if (serviceState == ServiceState.RUNNING) {
  8. this.mQClientFactory.sendHeartbeatToAllBrokerWithLock();
  9. updateTopicSubscribeInfoWhenSubscriptionChanged();
  10. }
  11. }

对于消息队列的内部的分配类 AssignedMessageQueue ,Assign 会直接设置要订阅的 queue,Subscribe 会通过设置 rebalance 服务来自动的更新订阅的 queue。

由此可见,两种模式的区别主要在于 rebalance 服务是否启动。

DefaultLitePullConsumer 客户端有以下重要的属性

  1. public class DefaultLitePullConsumer extends ClientConfig implements LitePullConsumer {
  2. /**
  3. * pull 客户端的实现类,几乎全数操作都交由该类执行
  4. */
  5. private final DefaultLitePullConsumerImpl defaultLitePullConsumerImpl;
  6. /**
  7. * 属于同一个消费者组的消费者共享一个组 ID。然后,组中的消费者通过建立每个队列
  8. * 仅由组中的单个消费者使用来尽可能公平地划分主题。如果所有的消费者都来自同一个组,
  9. * 它的作用就像一个传统的消息队列。每条消息只能由该组的一个消费者使用。
  10. * 当存在多个消费群体时,数据消费模型的流程与传统的发布订阅模型一致。
  11. * 消息被广播到所有消费者组。
  12. */
  13. private String consumerGroup;
  14. /**
  15. * 长轮询模式中,消费者连接最大挂起时间。
  16. * 不推荐修改
  17. */
  18. private long brokerSuspendMaxTimeMillis = 1000 * 20;
  19. /**
  20. * 长轮询模式中,消费者最长的超时时间
  21. * 不推荐修改
  22. */
  23. private long consumerTimeoutMillisWhenSuspend = 1000 * 30;
  24. /**
  25. * socket 超时时间
  26. */
  27. private long consumerPullTimeoutMillis = 1000 * 10;
  28. /**
  29. * 消费模式,默认为集群
  30. */
  31. private MessageModel messageModel = MessageModel.CLUSTERING;
  32. /**
  33. * 当前 Consumer 的消息监听器
  34. */
  35. private MessageQueueListener messageQueueListener;
  36. /**
  37. * 持久化维护的偏移量
  38. */
  39. private OffsetStore offsetStore;
  40. /**
  41. * 队列分配策略
  42. * - AllocateMachineRoomNearby,根据机房进行分配
  43. * - AllocateMessageQueueAveragely,均分哈希策略
  44. * - AllocateMessageQueueAveragelyByCircle,环形哈希策略
  45. * - AllocateMessageQueueByConfig,根据配置进行分配
  46. * - AllocateMessageQueueByMachineRoom,机房哈希
  47. * - AllocateMessageQueueConsistentHash,一致性哈希
  48. *
  49. * 默认为均分策略
  50. */
  51. private AllocateMessageQueueStrategy allocateMessageQueueStrategy = new AllocateMessageQueueAveragely();
  52. /**
  53. * 是否开启自动提交偏移量
  54. */
  55. private boolean autoCommit = true;
  56. /**
  57. * pull 的线程数量
  58. */
  59. private int pullThreadNums = 20;
  60. /**
  61. * 最小自动提交的时间间隔(毫秒)
  62. */
  63. private static final long MIN_AUTOCOMMIT_INTERVAL_MILLIS = 1000;
  64. /**
  65. * 最大自动提交的时间间隔(毫秒)
  66. */
  67. private long autoCommitIntervalMillis = 5 * 1000;
  68. /**
  69. * 每次拉取的最大消息数
  70. */
  71. private int pullBatchSize = 10;
  72. /**
  73. * 消费请求的流量控制阈值,每个消费者默认最多缓存 10000 个消费请求。
  74. * 缓存是指:已经拉取到了 Consumer, 但还没被消息完成的消息
  75. * 考虑到 {@code pullBatchSize},瞬时值可能会超过这个限制
  76. */
  77. private long pullThresholdForAll = 10000;
  78. /**
  79. * 消费的最大偏移跨度
  80. */
  81. private int consumeMaxSpan = 2000;
  82. /**
  83. * Queue 级别的流量控制阈值,每个 queue 默认最多缓存 1000 条消息
  84. * 考虑到 {@code pullBatchSize},瞬时值可能会超过限制
  85. */
  86. private int pullThresholdForQueue = 1000;
  87. /**
  88. * 在队列级别限制缓存的消息大小,每个 queue 默认最多缓存 100 MiB 消息
  89. * 考虑到 {@code pullBatchSize},瞬时值可能会超过限制
  90. *
  91. * 消息的大小仅包括消息的 body ,因此不准确
  92. */
  93. private int pullThresholdSizeForQueue = 100;
  94. /**
  95. * poll 的默认超时时间(毫秒)
  96. */
  97. private long pollTimeoutMillis = 1000 * 5;
  98. /**
  99. * 检查 Topic 元数据更改的时间间隔
  100. */
  101. private long topicMetadataCheckIntervalMillis = 30 * 1000;
  102. /**
  103. * 刚进入 queue 时的消费位置,默认从尾部进行消费
  104. */
  105. private ConsumeFromWhere consumeFromWhere = ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET;
  106. /**
  107. * 消费回溯时间(?)(秒精度).
  108. * 默认为半小时前
  109. */
  110. private String consumeTimestamp = UtilAll.timeMillisToHumanString3(System.currentTimeMillis() - (1000 * 60 * 30));
  111. }

启动

DefaultLitePullConsumer 在 Start 时,会先设置消费者组,然后调用刚刚说过的"内部实现类"的 start() 方法

以下是实际的启动方法:

  1. public synchronized void start() throws MQClientException {
  2. switch (this.serviceState) {
  3. case CREATE_JUST:
  4. this.serviceState = ServiceState.START_FAILED;
  5. this.checkConfig();
  6. if (this.defaultLitePullConsumer.getMessageModel() == MessageModel.CLUSTERING) {
  7. this.defaultLitePullConsumer.changeInstanceNameToPID();
  8. }
  9. // 向 MQClintFactory 注册自己的消费者组
  10. initMQClientFactory();
  11. // 初始化再均衡服务
  12. initRebalanceImpl();
  13. initPullAPIWrapper();
  14. // 初始化内部偏移量持久化服务
  15. initOffsetStore();
  16. mQClientFactory.start();
  17. // 开启调度服务
  18. startScheduleTask();
  19. this.serviceState = ServiceState.RUNNING;
  20. log.info("the consumer [{}] start OK", this.defaultLitePullConsumer.getConsumerGroup());
  21. // 启动后置处理
  22. operateAfterRunning();
  23. break;
  24. case RUNNING:
  25. case START_FAILED:
  26. case SHUTDOWN_ALREADY:
  27. throw new MQClientException("The PullConsumer service state not OK, maybe started once, "
  28. + this.serviceState
  29. + FAQUrl.suggestTodo(FAQUrl.CLIENT_SERVICE_NOT_OK),
  30. null);
  31. default:
  32. break;
  33. }
  34. }
  1. 向全局的 MQClientFactroy 实例注册自己的消费者组和实例

  2. 初始化 RebalanceImpl。向其注册 消费者组、消费模式(集群或广播)、队列分配策略

  3. 创建消息 pull 执行类

  4. 初始化并加载消费偏移量持久化类

    • 广播模式下使用 LocalFileOffsetStore 类,其会从本地文件加载存储 queue 的消费偏移量

    • 集群模式下使用 RemoteBrokerOffsetStore 类,其会从 Broker 获取 queue 的消费偏移量

  5. 启动可能未启动的 MQClientFactory

  6. 开启调度服务

  1. private void startScheduleTask() {
  2. // 监听 Topic 下的 Queue 发生变化
  3. scheduledExecutorService.scheduleAtFixedRate(
  4. new Runnable() {
  5. @Override
  6. public void run() {
  7. try {
  8. fetchTopicMessageQueuesAndCompare();
  9. } catch (Exception e) {
  10. log.error("ScheduledTask fetchMessageQueuesAndCompare exception", e);
  11. }
  12. }
  13. }, 1000 * 10, this.getDefaultLitePullConsumer().getTopicMetadataCheckIntervalMillis(), TimeUnit.MILLISECONDS);
  14. }
  1. 将状态标记为已启动
  2. 进行后置操作
  1. private void operateAfterRunning() throws MQClientException {
  2. // 如果在启动之前就已经进行了 subscribe,则在初始化后更新 Topic 订阅信息
  3. if (subscriptionType == SubscriptionType.SUBSCRIBE) {
  4. updateTopicSubscribeInfoWhenSubscriptionChanged();
  5. }
  6. // 如果在启动之前就已经进行了 assign ,则在初始化后更新 pull 任务。
  7. if (subscriptionType == SubscriptionType.ASSIGN) {
  8. updateAssignPullTask(assignedMessageQueue.messageQueues());
  9. }
  10. // 获取所有注册了 queue change 监听器的 Topic
  11. // 然后获取 Topic 的 Queue, 并添加到本地
  12. for (String topic : topicMessageQueueChangeListenerMap.keySet()) {
  13. Set<MessageQueue> messageQueues = fetchMessageQueues(topic);
  14. messageQueuesForTopic.put(topic, messageQueues);
  15. }
  16. // 获取并存储所有需要订阅的 Topic 所在的所有 Broker 的地址
  17. this.mQClientFactory.checkClientInBroker();
  18. }

然后,该 Consumer 就初始化完成开始工作了

Poll

DefaultLitePullConsumerImpl#poll 方法的主要逻辑是这样的:

  1. public synchronized List<MessageExt> poll(long timeout) {
  2. // 自动提交选择
  3. if (defaultLitePullConsumer.isAutoCommit()) {
  4. maybeAutoCommit();
  5. }
  6. // 等待消费请求
  7. ConsumeRequest consumeRequest = consumeRequestCache.poll(endTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
  8. // 更新内存中的偏移量
  9. if (consumeRequest != null && !consumeRequest.getProcessQueue().isDropped()) {
  10. List<MessageExt> messages = consumeRequest.getMessageExts();
  11. long offset = consumeRequest.getProcessQueue().removeMessage(messages);
  12. assignedMessageQueue.updateConsumeOffset(consumeRequest.getMessageQueue(), offset);
  13. return messages;
  14. }
  15. return Collections.emptyList();
  16. }

可以看出,这个方法主要是等待 consumeRequestCache 这个同步阻塞队列被放入消息。那么是在哪里被放入的呢?

对于 Assign 模式,在上面讲到的消费者的启动中,第 8 步的后置操作的 updateAssignPullTask 方法中,会执行 startPullTask 方法。它为所有被分配的 Queue 都添加一个 Pull 任务

  1. private void startPullTask(Collection<MessageQueue> mqSet) {
  2. for (MessageQueue messageQueue : mqSet) {
  3. if (!this.taskTable.containsKey(messageQueue)) {
  4. PullTaskImpl pullTask = new PullTaskImpl(messageQueue);
  5. this.taskTable.put(messageQueue, pullTask);
  6. this.scheduledThreadPoolExecutor.schedule(pullTask, 0, TimeUnit.MILLISECONDS);
  7. }
  8. }
  9. }

对于 Subscribe 模式,他会由之前注册的默认的 MessageQueueListener 来管理,其通过监听 Queue 的变化来更新 Pull 任务

  1. class MessageQueueListenerImpl implements MessageQueueListener {
  2. @Override
  3. public void messageQueueChanged(String topic, Set<MessageQueue> mqAll, Set<MessageQueue> mqDivided) {
  4. MessageModel messageModel = defaultLitePullConsumer.getMessageModel();
  5. switch (messageModel) {
  6. case BROADCASTING:
  7. updateAssignedMessageQueue(topic, mqAll);
  8. updatePullTask(topic, mqAll);
  9. break;
  10. case CLUSTERING:
  11. updateAssignedMessageQueue(topic, mqDivided);
  12. updatePullTask(topic, mqDivided);
  13. break;
  14. default:
  15. break;
  16. }
  17. }
  18. }

updatePullTask 方法中,最后也是调用 startPullTask 来启动任务。

所以最后,poll 实现的重点位置就在于 PullTaskImpl

而这块的代码比较长,所以我们跳过上半部的代码,只简单概括下:

上半部主要是对当前 Queue 的状态进行多个判断,当可能发生风险的时候直接添加下一次的延时任务,跳过当前状况。

这里的需要自己添加任务的原因是:该任务调度线程池并不是一个定时触发的线程池,而是一个延时任务线程池,所以需要在一次任务执行完成后再继续添加新的任务

会发生跳过的状况如下:

  1. Pull 任务已经取消
  2. 队列长度 * 批量消息最大长度 > 最大缓存请求数量,说明可能超过阈值
  3. queue 的消息缓存数量超过限制
  4. queue 的消息缓存大小超过限制
  5. queue 的消息的最大时间与最小时间的差值,超过了指定了允许的差值
  6. 获取偏移量失败

当将所有的情况都判断完成后,就开始执行 pull 和 将返回的消息包装成 ConsumeRequest

  1. long pullDelayTimeMills = 0;
  2. try {
  3. // 创建订阅信息
  4. SubscriptionData subscriptionData;
  5. String topic = this.messageQueue.getTopic();
  6. if (subscriptionType == SubscriptionType.SUBSCRIBE) {
  7. subscriptionData = rebalanceImpl.getSubscriptionInner().get(topic);
  8. } else {
  9. subscriptionData = FilterAPI.buildSubscriptionData(topic, SubscriptionData.SUB_ALL);
  10. }
  11. // 根据订阅信息远程 pull 消息
  12. PullResult pullResult = pull(messageQueue, subscriptionData, offset, defaultLitePullConsumer.getPullBatchSize());
  13. if (this.isCancelled() || processQueue.isDropped()) {
  14. return;
  15. }
  16. switch (pullResult.getPullStatus()) {
  17. case FOUND:
  18. // 获取 queue 的锁
  19. final Object objLock = messageQueueLock.fetchLockObject(messageQueue);
  20. synchronized (objLock) {
  21. if (pullResult.getMsgFoundList() != null && !pullResult.getMsgFoundList().isEmpty() && assignedMessageQueue.getSeekOffset(messageQueue) == -1) {
  22. processQueue.putMessage(pullResult.getMsgFoundList());
  23. // 提交消费请求
  24. submitConsumeRequest(new ConsumeRequest(pullResult.getMsgFoundList(), messageQueue, processQueue));
  25. }
  26. }
  27. break;
  28. case OFFSET_ILLEGAL:
  29. log.warn("The pull request offset illegal, {}", pullResult.toString());
  30. break;
  31. default:
  32. break;
  33. }
  34. // 更新偏移量
  35. updatePullOffset(messageQueue, pullResult.getNextBeginOffset(), processQueue);
  36. } catch (Throwable e) {
  37. pullDelayTimeMills = pullTimeDelayMillsWhenException;
  38. log.error("An error occurred in pull message process.", e);
  39. }
  40. if (!this.isCancelled()) {
  41. scheduledThreadPoolExecutor.schedule(this, pullDelayTimeMills, TimeUnit.MILLISECONDS);
  42. } else {
  43. log.warn("The Pull Task is cancelled after doPullTask, {}", messageQueue);
  44. }

在拉取消息,包装为 ConsumeRequest 后,就会投入 consumeRequestCache,然后阻塞在 poll 那边的消息就会被唤醒,然后将其返回给上层应用。这也就回答了我们上面提出的那个问题。

而上面的那块的 pull() 方法,则会使用 PullAPIWrapper 来构造一个 pull 请求,然后交给 MQClientFactory 进入底层的 Netty 组件发送,具体的底层发送流程与逻辑我们已经在以下几篇文章中讨论过了

Commit

Client 的偏移量的提交实现比较简单,这里只简单进行描述。

在 comsumer 调用 commitSync 函数后,会根据当前的消费模式(广播 or 集群)来做不同的操作

  • 集群

    此时,会将所有的消费队列的偏移量存储到另一个偏移量管理器,只是保存到内存。

  • 广播

    广播除了会保存到偏移量管理器外,还会像 Broker 的偏移量管理一样持久化到磁盘。

集群模式下的持久化只会在 shutdown 的时候和 pull 时提交到 Broker

Broker

然后来看 Broker 端,在这边,Broker 将 Pull 消息的请求码注册到了 PullMessageProcessor

该类对于 Pull 请求的处理的主要流程如下:

  1. private RemotingCommand processRequest(final Channel channel, RemotingCommand request, boolean brokerAllowSuspend)
  2. throws RemotingCommandException {
  3. /* pass */
  4. // 取出订阅配置
  5. SubscriptionGroupConfig subscriptionGroupConfig =
  6. this.brokerController.getSubscriptionGroupManager().findSubscriptionGroupConfig(requestHeader.getConsumerGroup());
  7. if (null == subscriptionGroupConfig) {
  8. response.setCode(ResponseCode.SUBSCRIPTION_GROUP_NOT_EXIST);
  9. response.setRemark(String.format("subscription group [%s] does not exist, %s", requestHeader.getConsumerGroup(), FAQUrl.suggestTodo(FAQUrl.SUBSCRIPTION_GROUP_NOT_EXIST)));
  10. return response;
  11. }
  12. /* pass */
  13. // 通过消费者组、Topic、Queue、offset、最大消息数、消息过滤器,从 MessageStore 取出消息
  14. final GetMessageResult getMessageResult =
  15. this.brokerController.getMessageStore().getMessage(requestHeader.getConsumerGroup(),
  16. requestHeader.getTopic(),
  17. requestHeader.getQueueId(),
  18. requestHeader.getQueueOffset(),
  19. requestHeader.getMaxMsgNums(),
  20. messageFilter);
  21. if (getMessageResult != null) {
  22. response.setRemark(getMessageResult.getStatus().name());
  23. responseHeader.setNextBeginOffset(getMessageResult.getNextBeginOffset());
  24. responseHeader.setMinOffset(getMessageResult.getMinOffset());
  25. responseHeader.setMaxOffset(getMessageResult.getMaxOffset());
  26. // 设置建议的拉取 Broker 地址
  27. // 这是因为主 Broker 发生了消息堆积,所以交由从 Broker 去接管读请求
  28. if (getMessageResult.isSuggestPullingFromSlave()) {
  29. responseHeader.setSuggestWhichBrokerId(subscriptionGroupConfig.getWhichBrokerWhenConsumeSlowly());
  30. } else {
  31. responseHeader.setSuggestWhichBrokerId(MixAll.MASTER_ID);
  32. }
  33. switch (this.brokerController.getMessageStoreConfig().getBrokerRole()) {
  34. case ASYNC_MASTER:
  35. case SYNC_MASTER:
  36. break;
  37. case SLAVE:
  38. if (!this.brokerController.getBrokerConfig().isSlaveReadEnable()) {
  39. response.setCode(ResponseCode.PULL_RETRY_IMMEDIATELY);
  40. responseHeader.setSuggestWhichBrokerId(MixAll.MASTER_ID);
  41. }
  42. break;
  43. }
  44. // 允许从 salve broker 读的话
  45. if (this.brokerController.getBrokerConfig().isSlaveReadEnable()) {
  46. // 消费进度慢,重定向到另一台机器
  47. if (getMessageResult.isSuggestPullingFromSlave()) {
  48. responseHeader.setSuggestWhichBrokerId(subscriptionGroupConfig.getWhichBrokerWhenConsumeSlowly());
  49. }
  50. // consume ok
  51. else {
  52. responseHeader.setSuggestWhichBrokerId(subscriptionGroupConfig.getBrokerId());
  53. }
  54. } else {
  55. responseHeader.setSuggestWhichBrokerId(MixAll.MASTER_ID);
  56. }
  57. }
  58. /* pass */
  59. // 持久化偏移量
  60. boolean storeOffsetEnable = brokerAllowSuspend;
  61. storeOffsetEnable = storeOffsetEnable && hasCommitOffsetFlag;
  62. storeOffsetEnable = storeOffsetEnable
  63. && this.brokerController.getMessageStoreConfig().getBrokerRole() != BrokerRole.SLAVE;
  64. if (storeOffsetEnable) {
  65. this.brokerController.getConsumerOffsetManager().commitOffset(RemotingHelper.parseChannelRemoteAddr(channel),
  66. requestHeader.getConsumerGroup(),
  67. requestHeader.getTopic(),
  68. requestHeader.getQueueId(),
  69. requestHeader.getCommitOffset());
  70. }
  71. return response;
  72. }

我们主要来看 取出消息 和 持久化偏移量 的实现

取出消息

取出消息的主要流程如下

  1. int i = 0;
  2. // 最大扫描消息大小为 16000 与 最大允许消息数量*20 的最大值
  3. final int maxFilterMessageCount = Math.max(16000, maxMsgNums * ConsumeQueue.CQ_STORE_UNIT_SIZE);
  4. final boolean diskFallRecorded = this.messageStoreConfig.isDiskFallRecorded();
  5. ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();
  6. for (; i < bufferConsumeQueue.getSize() && i < maxFilterMessageCount; i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {
  7. // 先从 ConsumerQueue 获取需要消费的消息索引
  8. long offsetPy = bufferConsumeQueue.getByteBuffer().getLong();
  9. int sizePy = bufferConsumeQueue.getByteBuffer().getInt();
  10. long tagsCode = bufferConsumeQueue.getByteBuffer().getLong();
  11. maxPhyOffsetPulling = offsetPy;
  12. if (nextPhyFileStartOffset != Long.MIN_VALUE) {
  13. if (offsetPy < nextPhyFileStartOffset)
  14. continue;
  15. }
  16. boolean isInDisk = checkInDiskByCommitOffset(offsetPy, maxOffsetPy);
  17. // 检查消息的总大小是否到达上限
  18. if (this.isTheBatchFull(sizePy, maxMsgNums, getResult.getBufferTotalSize(), getResult.getMessageCount(),
  19. isInDisk)) {
  20. // 达到上限后即可直接返回
  21. break;
  22. }
  23. /* pass */
  24. // 消息过滤
  25. if (messageFilter != null
  26. && !messageFilter.isMatchedByConsumeQueue(isTagsCodeLegal ? tagsCode : null, extRet ? cqExtUnit : null)) {
  27. if (getResult.getBufferTotalSize() == 0) {
  28. status = GetMessageStatus.NO_MATCHED_MESSAGE;
  29. }
  30. continue;
  31. }
  32. // 从 CommitLog 获取消息
  33. SelectMappedBufferResult selectResult = this.commitLog.getMessage(offsetPy, sizePy);
  34. if (null == selectResult) {
  35. if (getResult.getBufferTotalSize() == 0) {
  36. status = GetMessageStatus.MESSAGE_WAS_REMOVING;
  37. }
  38. nextPhyFileStartOffset = this.commitLog.rollNextFile(offsetPy);
  39. continue;
  40. }
  41. /* pass */
  42. this.storeStatsService.getGetMessageTransferedMsgCount().incrementAndGet();
  43. // 添加到结果集
  44. getResult.addMessage(selectResult);
  45. status = GetMessageStatus.FOUND;
  46. nextPhyFileStartOffset = Long.MIN_VALUE;
  47. }
  48. /* pass */
  49. // 当消费进度落后物理内存的 40% 时,调换到从库去处理读
  50. long diff = maxOffsetPy - maxPhyOffsetPulling;
  51. long memory = (long) (StoreUtil.TOTAL_PHYSICAL_MEMORY_SIZE
  52. * (this.messageStoreConfig.getAccessMessageInMemoryMaxRatio() / 100.0));
  53. getResult.setSuggestPullingFromSlave(diff > memory);

其中我们可以发现,我们必须要先从 ConsumerQueue 取出对应的消息,然后才进行拉取消息。

而我们在上一章讨论过,ConsumerQueue 由 CommitLogDispatcher 分发后进行维护,即最快可以还在内存中时就可以构建出索引。

这便回答了我们在开头提出的第一个问题:

  • 如果没有同步的刷盘策略和副本同步策略的话,我们很快就能对其进行消费(即使消息还在 Page Cache 中)
  • 如果存在以上两种策略任一的话,则需要先完成设定的策略,然后才能消费

持久化偏移量

然后是持久化偏移量,消费进度的管理由 Broker 内部维护,但在 Consumer 本地也会有进行管理,且以消费者的消费进度为主

这是因为我们在上面看到的一个特性所导致的,即在消费进度落后物理内存 40% 的时候,会交由从库去读。

这样的特性导致了在主 Broker 中维护的偏移量发生了延迟,即使 salve broker 会通过定期上报偏移量的方法来维护,但难免存在落后。

偏移量的维护比较简单,在 ConsumerOffsetManager 类内部具有一个 并发安全的 Map 来保存

  1. private void commitOffset(final String clientHost, final String key, final int queueId, final long offset) {
  2. ConcurrentMap<Integer, Long> map = this.offsetTable.get(key);
  3. if (null == map) {
  4. map = new ConcurrentHashMap<Integer, Long>(32);
  5. map.put(queueId, offset);
  6. this.offsetTable.put(key, map);
  7. } else {
  8. Long storeOffset = map.put(queueId, offset);
  9. if (storeOffset != null && offset < storeOffset) {
  10. log.warn("[NOTIFYME]update consumer offset less than store. clientHost={}, key={}, queueId={}, requestOffset={}, storeOffset={}", clientHost, key, queueId, offset, storeOffset);
  11. }
  12. }
  13. }

而持久化管理则是在其继承的 ConfigManager 抽象类中实现的

  1. public synchronized void persist() {
  2. String jsonString = this.encode(true);
  3. if (jsonString != null) {
  4. String fileName = this.configFilePath();
  5. try {
  6. MixAll.string2File(jsonString, fileName);
  7. } catch (IOException e) {
  8. log.error("persist file " + fileName + " exception", e);
  9. }
  10. }
  11. }

且为了保证修改时发生宕机后不会错误,其是在备份源文件后再进行写入的

  1. public static void string2File(final String str, final String fileName) throws IOException {
  2. String tmpFile = fileName + ".tmp";
  3. string2FileNotSafe(str, tmpFile);
  4. String bakFile = fileName + ".bak";
  5. String prevContent = file2String(fileName);
  6. if (prevContent != null) {
  7. string2FileNotSafe(prevContent, bakFile);
  8. }
  9. File file = new File(fileName);
  10. file.delete();
  11. file = new File(tmpFile);
  12. file.renameTo(new File(fileName));
  13. }

最后持久化完成后,则可以在你的本地的持久化目录中,中找到一个 json 文件,打开后就是下面这样

  1. {
  2. "offsetTable":{
  3. "%RETRY%my-consumer@my-consumer":{0:0},
  4. "RMQ_SYS_TRANS_HALF_TOPIC@CID_RMQ_SYS_TRANS":{0:22},
  5. "RMQ_SYS_TRANS_OP_HALF_TOPIC@CID_RMQ_SYS_TRANS":{0:4},
  6. "TopicTestA@lite_pull_consumer_test":{0:0}
  7. }
  8. }

可以看到有我们自己创建的测试 Topic,和系统创建的重试 Topic 与事务有关 Topic

Push

Client

然后进入到第三个客户端类 DefaultMQPushConsumer

对于这个客户端类,其与上一个的区别在于,它注册了一个 MessageListener ,且会在我们在之前看到的 start 中执行并发消费和顺序消费的选择。

  1. // 消息的并行消费和顺序消费
  2. if (this.getMessageListenerInner() instanceof MessageListenerOrderly) {
  3. this.consumeOrderly = true;
  4. this.consumeMessageService =
  5. new ConsumeMessageOrderlyService(this, (MessageListenerOrderly) this.getMessageListenerInner());
  6. } else if (this.getMessageListenerInner() instanceof MessageListenerConcurrently) {
  7. this.consumeOrderly = false;
  8. this.consumeMessageService =
  9. new ConsumeMessageConcurrentlyService(this, (MessageListenerConcurrently) this.getMessageListenerInner());
  10. }

并行消费和顺序消费的区别只在于,顺序消费会对消息所在 Queue 加锁,等待上一批消息消费完成后,才会消费下一批消息;并行消费则不会加锁

我们注册的消息监听器则会被 MQClintInstance 在初始化时启动的 PullMessageService 服务所调用。

  1. @Override
  2. public void run() {
  3. while (!this.isStopped()) {
  4. try {
  5. PullRequest pullRequest = this.pullRequestQueue.take();
  6. this.pullMessage(pullRequest);
  7. } catch (InterruptedException ignored) {
  8. } catch (Exception e) {
  9. log.error("Pull Message Service Run Method exception", e);
  10. }
  11. }
  12. }

上面的 pullMessage() 方法就是 Push 实现的重点了(也很长)

该方法首先会根据当前度量标准判断是否需要延迟拉取。延迟拉取的实现是,设置定时器将新的 PullRequest 在一段时间后重新投入 pullRequestQueue

具体的判断方法就是我们在 Pull 已经讲过的:

  1. Pull 任务已经取消
  2. ...

等状况

pull 和 push 在这点的逻辑上是相同的

然后开始构建回调函数,该回调函数会在拉取消息后被调用,其主要做的是错误处理和提交消费请求

下面这段就是在回调函数中的"提交消费请求"的方法

  1. // 提交消费请求
  2. DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
  3. pullResult.getMsgFoundList(),
  4. processQueue,
  5. pullRequest.getMessageQueue(),
  6. dispatchToConsume);

该方法根据消费是顺序的还是并发的来做具体的实现

构建发送消息所需要的环境后,便和 Pull 一样调用底层 Netty 组件发送

Push 模式构建的请求与 Pull 模式下构建的请求,区别几乎只在这一段

  1. // Pull 模式下构建的请求
  2. PullSysFlag.buildSysFlag(false, block, true, false);
  1. // Push 模式下构建的请求
  2. PullSysFlag.buildSysFlag(
  3. commitOffsetEnable, // commitOffset
  4. true, // suspend
  5. subExpression != null, // subscription
  6. classFilter // class filter
  7. );

可以看出,Pull 不会提交偏移量,且可能会 suspend,同时使用过滤表达式但不支持过滤器类模式

且由于发送的请求码都是一致的,所以我们可以确定,Pull 和 Push 的实质上的区别在于 Push 会使用长轮询

Broker

由于请求码一样,所以在 Broker 端的处理也和上面讲过的代码一样。

在 Broker 对于 Push 的处理中,如果拉取的消息达到了要求的数量的话,则会直接返回,否则会进入到以下代码

实际上,正如我们刚刚说的,Broker 并不能区分 Push 请求和 Pull 请求,它只是根据 SysFlags 上是否有 suspend 标识来选择是否进入以下代码块,而 Pull 请求也是可以使用这个标识的

  1. if (brokerAllowSuspend && hasSuspendFlag) {
  2. long pollingTimeMills = suspendTimeoutMillisLong;
  3. // 开启长轮询则使用,否则为短轮询
  4. if (!this.brokerController.getBrokerConfig().isLongPollingEnable()) {
  5. pollingTimeMills = this.brokerController.getBrokerConfig().getShortPollingTimeMills();
  6. }
  7. String topic = requestHeader.getTopic();
  8. long offset = requestHeader.getQueueOffset();
  9. int queueId = requestHeader.getQueueId();
  10. // 构建 PullRequest
  11. PullRequest pullRequest = new PullRequest(request, channel, pollingTimeMills,
  12. this.brokerController.getMessageStore().now(), offset, subscriptionData, messageFilter);
  13. this.brokerController.getPullRequestHoldService().suspendPullRequest(topic, queueId, pullRequest);
  14. response = null;
  15. break;
  16. }

该块会构建 PullRequest 然后等待 Broker 的获取

Broker 的 存储组件层 会在将 reputed 指针推进时获取 PullReqeust 并进行处理

reputed 指针的介绍与推进 可以看这篇文章:RocketMQ源码详解 | Broker篇 · 其三:CommitLog、索引、消费队列

RocketMQ源码详解 | Consumer篇 · 其一:消息的 Pull 和 Push的更多相关文章

  1. RocketMQ源码详解 | Broker篇 · 其五:高可用之主从架构

    概述 对于一个消息中间件来讲,高可用功能是极其重要的,RocketMQ 当然也具有其对应的高可用方案. 在 RocketMQ 中,有主从架构和 Dledger 两种高可用方案: 第一种通过主 Brok ...

  2. RocketMQ源码详解 | Producer篇 · 其二:消息组成、发送链路

    概述 在上一节 RocketMQ源码详解 | Producer篇 · 其一:Start,然后 Send 一条消息 中,我们了解了 Producer 在发送消息的流程.这次我们再来具体下看消息的构成与其 ...

  3. RocketMQ源码详解 | Broker篇 · 其三:CommitLog、索引、消费队列

    概述 上一章中,已经介绍了 Broker 的文件系统的各个层次与部分细节,本章将继续了解在逻辑存储层的三个文件 CommitLog.IndexFile.ConsumerQueue 的一些细节.文章最后 ...

  4. RocketMQ源码详解 | Broker篇 · 其一:线程模型与接收链路

    概述 在上一节 RocketMQ源码详解 | Producer篇 · 其二:消息组成.发送链路 中,我们终于将消息发送出了 Producer,在短暂的 tcp 握手后,很快它就会进入目的 Broker ...

  5. RocketMQ源码详解 | Broker篇 · 其四:事务消息、批量消息、延迟消息

    概述 在上文中,我们讨论了消费者对于消息拉取的实现,对于 RocketMQ 这个黑盒的心脏部分,我们顺着消息的发送流程已经将其剖析了大半部分.本章我们不妨乘胜追击,接着讨论各种不同的消息的原理与实现. ...

  6. RocketMQ源码详解 | Broker篇 · 其二:文件系统

    概述 在 Broker 的通用请求处理器将一个消息进行分发后,就来到了 Broker 的专门处理消息存储的业务处理器部分.本篇文章,我们将要探讨关于 RocketMQ 高效的原因之一:文件结构的良好设 ...

  7. RocketMQ源码详解 | Producer篇 · 其一:Start,然后 Send 一条消息

    概述 DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); ...

  8. Linux内核源码详解——命令篇之iostat[zz]

    本文主要分析了Linux的iostat命令的源码,iostat的主要功能见博客:性能测试进阶指南——基础篇之磁盘IO iostat源码共563行,应该算是Linux系统命令代码比较少的了.源代码中主要 ...

  9. [转]Linux内核源码详解--iostat

    Linux内核源码详解——命令篇之iostat 转自:http://www.cnblogs.com/york-hust/p/4846497.html 本文主要分析了Linux的iostat命令的源码, ...

随机推荐

  1. 【大咖直播】Elastic 企业搜索实战工作坊(第一期)

    借助 App Search 提供的内置功能,您可轻松打造卓越的搜索体验.直观的相关度调整以及开箱即用的搜索分析,不仅可以优化所提供的内容,其提供的 API 还可帮助您将位于各处的所有内容源关联在一起. ...

  2. 【vue】两个页面间传参 - props

    目录 Step1 设置可以 props 传递数据 Step2 跳转前页面中传递数据 Step3 跳转后的页面接收数据 从 A 页面跳转到 B 页面, 参数/数据通过 props 传递到 B 页面,这种 ...

  3. ArcToolbox工具箱

    3D Analyst 工具 Data Interoperability Tools Geostatistical Analyst 工具 Network Analyst 工具 Schematics 工具 ...

  4. Geocoding Tools(地理编码工具)

    地理编码工具 # Process: 创建地址定位器 arcpy.CreateAddressLocator_geocoding("", "", "&qu ...

  5. java设计模式_单例模式

    懒汉式 非线程安全 特点:Lazy 初始化.非多线程安全.易实现 描述:这种方式是最基本的实现方式,这种实现最大的问题就是不支持多线程.因为没有加锁 synchronized,所以严格意义上它并不算单 ...

  6. Markdown Syntax Images

    Markdown Syntax Images Admittedly, it's fairly difficult to devise a "natural" syntax for ...

  7. Bug概述、状态、类型、级别、优先级提交和Bug生命周期管理

    缺陷概述: 1)缺陷(Defect):是指存在于软件之中偏差,可被激活,以静态形式存在于软件内部,相当于Bug. 2)故障(Fault):当缺陷被激活后,软件运⾏中出现的状态,可引起意外情况,若不加处 ...

  8. SharkCTF2021 BabyGame

    web类题. 访问题给页面,页面里没啥信息.抓包,发现: 访问它,发现是一个游戏. F12之后看调试器里的js代码,发现: console.log("balabalabala"); ...

  9. 【Java虚拟机3】类加载器

    前言 Java虚拟机设计团队有意把类加载阶段中的"通过一个类的全限定名来获取描述该类的二进制字节流"这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类. ...

  10. vue3.x相对于vue2.x生命周期改动

    vue3.x已经正式发布了,部分小伙伴已经用了vue3.x开发,部分小伙伴还在观望中,下面是两个影响比较大的改动 1.beforeDestroy和destroyed不能用了. 这个应该是vue2.x项 ...