RocketMQ

问题

背景是这样: 最近有个项目用MQ做消息流转,在RocketMQ集群模式下,一个消费者实例,订阅了两个Topic A、B

Topic A:存储的是批量业务消息。
Topic B:存储的是单个业务消息。

有个小伙伴问我:A 先存入了100万消息,消费端线程开始消费 Topic A 数据,消费速度较慢;之后 B 也存入了1万消息,A 的数据积压会阻塞 B 的消费吗?

先说结论:B 消息消费会有一定的延迟,但不会绝对阻塞。

分析

本文 RocketMQ 版本:4.4.0。

从一张类图入手。

同一个消费者组消费不同topic的java代码 一个消费者消费多个topic_网络

项目中,生产者 DefaultMQProducer 和消费者 DefaultMQPushConsumer 启动的时候,都会调用 MQClientInstance#start 方法来启动 MQClientInstance 组件。

MQClientInstance#start 主要工作:

  1. 启动NettyRemotingClient客户端,用于与NameServer和Broker进行通信。
  2. 定时更新Topic所对应路由信息、清除离线broker、向所有在线broker发送心跳包等。
  3. 启动拉取消息服务线程(PullMessageService),异步拉取消息。
  4. 启动负载均衡服务线程(RebalanceService),用于实现消息队列(Message Queue)的负载均衡。

在RocketMQ中,有ProcessQueue和MessageQueue两个概念。

  1. MessageQueue表示一个消息队列,它包含主题、队列ID、Broker地址信息。
  2. ProcessQueue 表示消费者正在消费的消息队列,是一个消费进度的抽象概念。在消费者消费消息时,将消息从MessageQueue中取出,并将消息保存在ProcessQueue中进行消费。包含了消费者消费的消息列表、消息偏移量以及一些状态信息等。

同一个消费者组消费不同topic的java代码 一个消费者消费多个topic_java_02

消费端 ProcessQueue 与 Broker 中的 MessageQueue 是一一对应的。

PullMessageService#run

在 PullMessageService 类中定义了 pullRequestQueue 属性,用于存储拉取消息请求(PullRequest)的队列。

PullRequest 拉取消息请求的数据结构定义了:

  • 消费者组。
  • 消费主题。
  • 消费队列。
  • 消费偏移量等。

PullMessageService#run 方法会从队列中取出 PullRequest 并执行,通过不断的发送 PullRequest来持续的拉取消息。

public class PullMessageService extends ServiceThread {
    private final LinkedBlockingQueue<PullRequest> pullRequestQueue = new LinkedBlockingQueue<PullRequest>();
  
  @Override
    public void run() {
      PullRequest pullRequest = this.pullRequestQueue.take();
                this.pullMessage(pullRequest);
    }
}

PullMessageService#pullMessage 方法找到消费组里面的消费实现类,执行对应的逻辑。

private void pullMessage(final PullRequest pullRequest) {
        final MQConsumerInner consumer = this.mQClientFactory.selectConsumer(pullRequest.getConsumerGroup());
        if (consumer != null) {
            DefaultMQPushConsumerImpl impl = (DefaultMQPushConsumerImpl) consumer;
            impl.pullMessage(pullRequest);
        } else {
            log.warn("No matched consumer for the PullRequest {}, drop it", pullRequest);
        }
    }

DefaultMQPushConsumerImpl#pullMessage主要功能:

  1. 消费者服务状态校验,判断消费者是否是运行态;
  2. 流控校验,判断ProcessMessage 队列缓存消息数是否超过阀值。
  3. 并发消费和顺序消费相关的校验,并发消费限制最大跨度偏移量(offset),顺序消费则判断是否锁定,未锁定设置消费点位。
  4. 创建匿名内部类PullCallback,后续拉取消息返回响应后会回调onSuccess完成消息的消费;
  5. 调用PullAPIWrapper#pullKernelImpl拉取消息。
public class DefaultMQPushConsumerImpl implements MQConsumerInner {
  public void pullMessage(final PullRequest pullRequest) {
    //校验
    //判断
    ...
    
    //创建匿名内部类
    PullCallback pullCallback = new PullCallback() {
      @Override
      public void onSuccess(PullResult pullResult) {
        switch (pullResult.getPullStatus()) {
          case FOUND:
            有新消息,封装请求提交到线程池,
            自定义消费者消费数据
            
            DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
              pullResult.getMsgFoundList(),
              processQueue,
              pullRequest.getMessageQueue(),
              dispatchToConsume);
          case NO_NEW_MSG:
          case NO_MATCHED_MSG:
            没有新消息或未匹配到消息时,继续往队列放入拉取消息请求(PullRequest),循环调用。
          ...
      }
    }
  }
}

PullStatus状态定义值,消息的中间状态,枚举:

  1. FOUND:拉取到消息。
  2. NO_NEW_MSG:没有新消息。
  3. NO_MATCHED_MSG:没有匹配的消息。
  4. OFFSET_ILLEGAL:非法的偏移量,可能设置的太大或大小。

ConsumeMessageConcurrentlyService#submitConsumeRequest 消费线程入口是这里,封装一个consumerRequest对象来执行业务 初始化消费者线程池,会根据配置来创建消费线程数。

public class ConsumeMessageConcurrentlyService implements ConsumeMessageService {
  //线程池,用于执行自定义消费逻辑
  private final ThreadPoolExecutor consumeExecutor;

  @Override
  public void submitConsumeRequest(
    提交任务,如定义了消费消息最大值时,会根据设置的值进行分割,然后提交到线程池待执行。
  )
  
  class ConsumeRequest implements Runnable {
    @Override
    public void run() {
      //找到自定义消息监听类
      MessageListenerConcurrently listener = ConsumeMessageConcurrentlyService.this.messageListener;
      ...
      //执行对应的实现类,消费数据
      status = listener.consumeMessage(Collections.unmodifiableList(msgs), context);
      ...
    }
  }
}

回到PullAPIWrapper#pullKernelImpl拉取消息方法中,最终会往Broker发送请求。

NettyRemotingAbstract#invokeAsyncImpl

public void invokeAsyncImpl(final Channel channel, final RemotingCommand request, final long timeoutMillis,
    final InvokeCallback invokeCallback){
    //获取信号量,最大为65535个
    boolean acquired = this.semaphoreAsync.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS);
    if (acquired) {
      final ResponseFuture responseFuture = new ResponseFuture(channel, opaque, timeoutMillis - costTime, invokeCallback, once);
      //缓存正在执行的请求
      this.responseTable.put(opaque, responseFuture);
      
      channel.writeAndFlush(request).addListener(new ChannelFutureListener() {
        @Override
        public void operationComplete(ChannelFuture f) throws Exception {
            if (f.isSuccess()) {
                //设置响应状态
                responseFuture.setSendRequestOK(true);
                return;
            }
            requestFail(opaque);
            log.warn("send a request command to channel <{}> failed.", RemotingHelper.parseChannelRemoteAddr(channel));
        }
      });
    }
}

上面代码中创建了ResponseFuture对象,放在了responseTable Map对象中,后续会获取后回调对应的方法。

也就是回调: MQClientAPIImpl#pullMessageAsync

private void pullMessageAsync(
    final String addr,
    final RemotingCommand request,
    final long timeoutMillis,
    final PullCallback pullCallback) {
  this.remotingClient.invokeAsync(addr, request, timeoutMillis, new InvokeCallback() {
    @Override
    public void operationComplete(ResponseFuture responseFuture) {
        RemotingCommand response = responseFuture.getResponseCommand();
        if (response != null) {
          PullResult pullResult = MQClientAPIImpl.this.processPullResponse(response);
          assert pullResult != null;
          //最终会回调PullMessageService#pullMessage方法中定义的回调函数,完成消息的分发
          pullCallback.onSuccess(pullResult);
        }
    }
  });
}

拉取到的消息会调用PullMessageService#pullMessage方法中定义的回调函数,完成消息的分发。

RebalanceService#run

定时任务,重分配消息队列。

public class RebalanceService extends ServiceThread {
@Override
    public void run() {
        while (!this.isStopped()) {
            //20s轮询
            this.waitForRunning(waitInterval);
            this.mqClientFactory.doRebalance();
        }
    }
}

最终会调用RebalanceImpl#updateProcessQueueTableInRebalance 重新分配 topic 队列。

updateProcessQueueTableInRebalance方法主要功能:

  1. 获取当前消费者的订阅信息,包括订阅的主题、消费者组等。
  2. 获取当前消费者的消费进度,包括已消费的消息队列和消息偏移量等。
  3. 获取当前主题的所有消息队列。
  4. 根据消费者组和消息队列数量进行负载均衡,将消息队列分配给消费者。
  5. 更新消费者的消费进度,将已消费的消息队列和消息偏移量更新为新分配的消息队列。
  6. 往pullRequestQueue队列中写入PullRequest请求数据。

最终也会生成拉取消息请求,放入队列中,等待PullMessageService线程执行。

答案?

至此,我们简单分析了消息的拉取、消费流程,那么回到我们之前的问题来。

假设单消费者实例定义了4个消费线程,那么线程池会创建4个核心线程用来执行任务。

同一个消费者组消费不同topic的java代码 一个消费者消费多个topic_网络_03

生产者往Topic A先写入100万消息后,拉取线程会从这8个队列(Topic A和Topic B各4个)拉取消息,此时拉取到A的数据会放入消费者线程池等待消费者线程消费。

pull线程会持续的拉取,所以会持续的往线程池队列尾部写入Msg任务,按照我们分析的这种场景,Topic B消息虽然后面写入了,但是Topic B消息拉取后的数据放在了队列的中部或者后尾部的位置。

假如Thread 拿到消息后处理较慢,则会导致线程池队列的数据出现积压,也就是会最终会对B数据的消费产生影响,但不是绝对阻塞。

结论:

1. 会存在一定的消费延迟,但不是绝对的阻塞。也不是必须等A消费完,才会消费B的数据。

上测试代码

Topic A的消费类,线程睡眠1s,模拟正常业务处理耗时。

@Slf4j
@MQConsumeService(topic = "A")
public class A extends AbstractMQMsgProcessor {
    @Override
    protected boolean consumeMessage(String tag, String keys, MessageExt messageExt) {
        try {
            Thread.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        }
        String message = new String(messageExt.getBody(), StandardCharsets.UTF_8);
        log.info("A message:{}, {}, {}, {}", message, Thread.currentThread().getName(), LocalDateTime.now(), keys);
        return true;
    }
}

Topic B的消费类。

@Slf4j
@MQConsumeService(topic = "B")
public class B extends AbstractMQMsgProcessor {
    @Override
    protected boolean consumeMessage(String tag, String keys, MessageExt messageExt) {
        String message = new String(messageExt.getBody(), StandardCharsets.UTF_8);
        log.info("B message:{}, {}, {}, {}", message, Thread.currentThread().getName(), LocalDateTime.now(), keys);
        return true;
    }
}

模拟先写入A类消息5000条,再写入B类消息100条。

@GetMapping(value = "/send")
public void reportList() throws Exception {
    for (int i = 0; i<5000; i++) {
        processor.aysncSend("A", "*", "你好" + i);
    }
    Thread.sleep(10000);
    for (int i = 0; i<100; i++) {
        processor.aysncSend("B", "*", "hello" + i);

    }
}

16:20左右A类消息发送完毕,开始消费。

同一个消费者组消费不同topic的java代码 一个消费者消费多个topic_消息队列_04

16:21左右B类消息发送完毕,此时待消费线程池队列中任务数在持续增加,可以看到在持续拉取A类消息,放入队列中待消费。

同一个消费者组消费不同topic的java代码 一个消费者消费多个topic_网络_05

16:31分B类消息被消费,因为消费者线程是批量消费消息的,所以此时B类的10条消息都是由 ConsumeMessageThread_2 执行的。

同一个消费者组消费不同topic的java代码 一个消费者消费多个topic_消息队列_06

后面A、B类消息会持续消费,最终在 16:46分所有消息消费完成。

同一个消费者组消费不同topic的java代码 一个消费者消费多个topic_网络_07

总结

本文分析的版本是4.x,假如是最新版本,结论可能不一样,RockeMQ 5.0有了全新的消费模式-POP,对原有的消费模型的更新。

5.0之前的客户端架构中,拉取到消息之后会先将消息缓存到 ProcessQueue 中,当需要消费时,会从 ProcessQueue 中取出对应的消息进行消费,当消费成功之后再将消息从 ProcessQueue 中 remove 走。其中重试消息的发送,位点的更新在这个过程中穿插。

新版本在ProcessQueue 中维护了2个队列,有兴趣的同学可以去了解下,这里就不展开了。