RocketMQ系列之消费者源码入门

本文章基于开源rocketmq4.9.3版本进行分析,如无特殊说明,文中以集群、顺序消费为示例备注源码;如果错误请留言斧正~

rocketmq开源注释源码地址:点击跳转

1 前言

在过去一年的生产运维中,由于团队负责的业务具有高复杂度和大数据量特性,经过多次技术论证与架构设计,我们在系统响应时效与数据一致性之间采取了折中方案——基于MySQL Binlog转换实现最终一致性。这一方案衍生出大量的消息队列生产消费场景,并催生了一个专职消费MQ消息的服务。在服务运行过程中,我们发现了若干典型问题,现通过重新研读RocketMQ源码探究其根源。以下是核心问题梳理:

  • 问题一:某个 MessageQueue 一直不消费,但其他 MessageQueue 却能正常消费。这是为什么呢?

  • 问题二:线程数过多是否会导致线程饿死现象?

  • 问题三:每次启动消息拉取线程是按照 MessageQueue 还是按照 ConsumerGroup 进行的?

  • 问题四:单个 ConsumerGroup 消费时,需要启动多少个线程?

    • 定时拉取 NameServer 线程、定时更新 Topic 线程、定时持久化位点线程、定时发送心跳(清理下线 Broker)、定时 Rebalance 线程、Netty 线程(复杂发送和响应处理)、定时重新负载、消费者线程池(最多启动 20 个线程)等,至少启动 7 个线程 + 当前客户端分配的 MessageQueue 数量。是否有遗漏启动的线程呢?

  • 问题五MessageQueue 与 Topic 信息分别存储在哪里?是在 Broker 端吗?

  • 问题六:Consumer 心跳同步首次发送是在什么时机?多久同步一次?

    • 消费者上线时,默认触发一次 Rebalance,如果当前客户端分配的 MessageQueue 发生了变更,会默认触发一次心跳同步。

  • 问题七:Rebalance 的执行过程是怎样的?

  • 问题八:消息拉取过程,第一次消息拉取是在什么时机触发的?消费者上线时是否会触发消息拉取动作?

  • 问题九:以顺序消费的重试机制为例,顺序消费的消息挂起实现过程是怎样的?在重试过程中,如何保证 MessageQueue 中的消息顺序消费?

  • 问题十:顺序消费时最大重试次数是多少?

    • 顺序消费默认无限重试,可以在启动消费者时指定最大重试次数:DefaultMQPushConsumer#maxReconsumeTimes

  • 问题十一:顺序消费写入死信队列的逻辑是怎样的?

  • 问题十二:Broker 端根据消费重新消费次数超过最大重试次数后,替换 Topic 信息为死信队列。这个过程是如何实现的?

2 消费者注册与启动

2.1 启动示例

// 创建消费者
DefaultMQPushConsumer pushConsumer = new DefaultMQPushConsumer("CID_IM_USER_MESSAGE");
pushConsumer.setNamesrvAddr("127.0.0.1:9876");
// 设置订阅topic
pushConsumer.subscribe("IM_USER_MESSAGE", "*");
// 设置最大消费线程数(默认20)
pushConsumer.setConsumeThreadMax(10);
// 设置最大重试次数(顺序消费默认Integer.MAX_VALUE,并发消费默认16)
pushConsumer.setMaxReconsumeTimes(2);
// 每次拉取消息的数量,默认32,最大1024
pushConsumer.setPullBatchSize(1000);
// 每次批量消费消息的数量,默认1,最大1024
pushConsumer.setConsumeMessageBatchMaxSize(1024);
// 连续两次消息拉取间隔,默认不间隔(消费完立刻拉取下一批消息)
pushConsumer.setPullInterval(60*3*1000);
// 消费点位保存的间隔,默认5秒
pushConsumer.setPersistConsumerOffsetInterval(1000*60);
// 消息消费的实际处理逻辑,由业务方自行实现,有序消费和并发消费两种选择
pushConsumer.registerMessageListener(new MessageListenerOrderly() {
    @Override
    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
        return ConsumeOrderlyStatus.SUCCESS;
    }
});
pushConsumer.start();

2.1 创建消费者实例

DefaultMQPushConsumer pushConsumer = new DefaultMQPushConsumer("CID_IM_USER_MESSAGE");
  • 创建消息消费的门面类:new DefaultMQPushConsumer()
  • 设置默认负载分配策略,默认平均分配new AllocateMessageQueueAveragely()
  • 创建用于实际执行消费拉取的类:new DefaultMQPushConsumerImpl(this, rpcHook)

DefaultMQPushConsumerImpl包含主要的属性如下:

  • 创建时初始化的属性:
    • DefaultMQPushConsumer defaultMQPushConsumer;临时暂存各种配置信息
    • RebalanceImpl rebalanceImpl = new RebalancePushImpl(this);处理重新负载的类
    • long consumerStartTimestamp = System.currentTimeMillis();消费起始点位
  • 实际启动时初始化的属性:
    • MQClientInstance mQClientFactory;
    • PullAPIWrapper pullAPIWrapper;启动netty线程,用于client与broker直接的数据交换

    • MessageListener messageListenerInner;业务方实现的消费者处理handler

    • OffsetStore offsetStore;点位保存实现类

    • ConsumeMessageService consumeMessageService;消息拉取成功后实际执行消息消费处理的类

2.3 设置消费基本参数信息

2.3.1 设置最大消费线程数

pushConsumer.setConsumeThreadMax(10);

消息按topic.messageQueue从broker拉取到后,启动线程池消费消息,每个messageQueue对应一个线程池处理消息,每个consumerGroup默认最多启动20线程; 

2.3.2 设置消息消费的最大重试次数

pushConsumer.setMaxReconsumeTimes(2);

顺序消费默认Integer.MAX_VALUE,即无限重试;并发消费默认16;

2.3.3 每次从broker拉取消息的数量

pushConsumer.setPullBatchSize(1000);

消费者每次从broker拉取消息的数量,默认32,最大1024


2.3.4 每次消费消息的数量

pushConsumer.setConsumeMessageBatchMaxSize(1024);

从broker批量拉取到消息后,异步批量消费消息的数量,默认1,最大1024;

2.3.5 消息消费节流间隔时长

pushConsumer.setPullInterval(60*3*1000);

从broker连续拉取消息的间隔时长,默认0,即消费完当前拉取批次消息后立即拉取下一批消息;

2.3.7  消费点位保存频率

pushConsumer.setPersistConsumerOffsetInterval(1000*60);

消费点位保存频率,默认5秒保存一次;


2.3.8 注册消费业务处理Listener

pushConsumer.registerMessageListener(new MessageListenerOrderly() {
    @Override
    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
        return ConsumeOrderlyStatus.SUCCESS;
    }
});

消息消费的实际处理逻辑,由业务方自行实现,有序消费和并发消费两种选择

其他参数不在列举;

2.4 启动消费

2.4.1 消费线程模型图

        以下消费模型按顺序消费逻辑梳理,其中有3个broker,每个broker有4个MessageQueue,4个消费者实例,使用默认的负载策略(平均分配),则最终messageQueue消费分配结果为:3 3 3 3,即每个消费者消费3个messageQueue;

启动消费者示例 DefaultMQPushConsumerImpl#start,主要以创建基础实例为主,其中包含配置参数检查、创建nettyClient实例、创建消费拉取实例、创建重新负载实例、注册消息过滤钩子、创建消费点位保存实例等;

2.4.2 检查配置参数

检查常见参数是否超过指定阈值、消费者参数是否完整等

this.checkConfig();


2.4.3 创建MQClientInstance

this.mQClientFactory = MQClientManager.getInstance().getOrCreateMQClientInstance(this.defaultMQPushConsumer, this.rpcHook);

2.4.3.1 创建nettyClient

        创建MQClientAPIImpl,内部初始了nettyclient,用于与broker、nameServer进行消息同步,包含:获取brokerNameAddr、获取/更新topic信息、消息拉取、心跳同步等关于数据交换的全部逻辑;

// KR1:创建MQClientAPIImpl,内部初始了nettyclient,用于与broker进行消息同步,负责与broker进行消息交互,包含:获取brokerNameAddr、获取/更新topic信息
this.mQClientAPIImpl = new MQClientAPIImpl(this.nettyClientConfig, this.clientRemotingProcessor, rpcHook, clientConfig);
 2.4.3.2 创建消息拉取实例

创建从broker端拉取消息的线程实例,使用生产消费者模式实现消息拉取,主要包含:

  • 消息拉取信号队列:LinkedBlockingQueue<PullRequest> pullRequestQueue

  • 消费实例持有对象:MQClientInstance mQClientFactory

  • 异步定时线程池(单线程):ScheduledExecutorService scheduledExecutorService,线程名称:PullMessageServiceScheduledThread,主要负责如下任务

    • 延迟异步发送拉取消息信号

    • 执行延迟异步任务

// KR1:创建消息拉取线程,用于消息拉取
this.pullMessageService = new PullMessageService(this);
2.4.3.3 创建重新负载实例

创建重新负载线程实例,根据定时调度,按consumerGroup执行重新负载逻辑,主要包含:

  • 消费实例持有对象:MQClientInstance mQClientFactory,主要调用void doRebalance(),具体重新负载的实现逻辑参考下面章节;

// KR1:创建重新负载线程,用于重新负载
this.rebalanceService = new RebalanceService(this);

2.4.4 创建并注册消息过滤hook

this.pullAPIWrapper = new PullAPIWrapper(mQClientFactory,this.defaultMQPushConsumer.getConsumerGroup(), isUnitMode());
// KR2:注册消息过滤hook
this.pullAPIWrapper.registerFilterMessageHook(filterMessageHookList);

2.4.5 创建消费点位保存实例

switch (this.defaultMQPushConsumer.getMessageModel()) {
    case BROADCASTING:
        this.offsetStore = new LocalFileOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
        break;
        // KR1:集群消费的点位保存类
    case CLUSTERING:
        this.offsetStore = new RemoteBrokerOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
        break;
    default:
        break;
}
this.defaultMQPushConsumer.setOffsetStore(this.offsetStore)
this.offsetStore.load();

2.4.6 创建并启动消费实例

if (this.getMessageListenerInner() instanceof MessageListenerOrderly) {
    this.consumeOrderly = true;
    this.consumeMessageService =
        new ConsumeMessageOrderlyService(this, (MessageListenerOrderly) this.getMessageListenerInner());
} else if (this.getMessageListenerInner() instanceof MessageListenerConcurrently) {
    this.consumeOrderly = false;
    this.consumeMessageService =
        new ConsumeMessageConcurrentlyService(this, (MessageListenerConcurrently) this.getMessageListenerInner());
}
// KR3:不同的消费类型,启动周期性任务
this.consumeMessageService.start();
  • 顺序消费ConsumeMessageOrderlyService

    • ConsumeMessageOrderlyService.this.lockMQPeriodically();

    • RebalanceImpl#lockAll

      • 查询本地保存的【消息队列】与【队列处理process】关系缓存【ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable】,构建brokerName与当前消费的messageQueues关系

      • 按broker逐个上报【当前client+当前consuemrGroup】锁定【当前messageQueues】

      • 锁定成功后,更新本地缓存的队列处理process的锁定状态与锁定时间戳

      • 远程锁定结果更新成功后,本地重新更新锁定结果,保证本地缓存与broker端锁定结果一致性;

  • 并发消费ConsumeMessageOrderlyService

    • 定时周期性在本地锁定当前client需要消费的messageQueue;

2.4.7 缓存消费分组与消费实例

boolean registerOK = mQClientFactory.registerConsumer(this.defaultMQPushConsumer.getConsumerGroup(), this);
ConcurrentMap<String/* group */, MQConsumerInner> consumerTable

2.4.8 启动一组线程

mQClientFactory.start();

启动一组线程,用于定时执行消费拉取、点位保存、重新负载、心跳同步等逻辑;

2.4.8.1 启动数据交换层线程
this.mQClientAPIImpl.start();
NettyRemotingClient#start
  • 启动netty作为数据交换层:与broker端进行数据交互的操作,全部通过当前NettyRemotingClient异步执行;

  • 启动周期性线程:

    • 延迟3秒,每隔1秒,异步从response中获取channel响应,并分发给对应的线程处理;

    • 如果响应时间超时,则放弃当前异步请求响应channel;

this.timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
    try {
        NettyRemotingClient.this.scanResponseTable();
    } catch (Throwable e) {
        log.error("scanResponseTable exception", e);
    }
}
}, 1000 * 3, 1000);
2.4.8.2 启动一组线程执行周期性任务
 this.startScheduledTask();
  • 延迟10秒,间隔2秒更新本地缓存的nameServer列表

MQClientInstance.this.mQClientAPIImpl.fetchNameServerAddr();
  • 延迟10秒,间隔30秒,定时从NameServer拉取Topic信息

MQClientInstance.this.updateTopicRouteInfoFromNameServer();
  • 延迟1秒,间隔30秒,定时清理下线的broker并发送心跳信息

// KR3:删除离线的broker信息
MQClientInstance.this.cleanOfflineBroker();
  •  发送心跳信息

// KR3:向远程broker发送心跳
MQClientInstance.this.sendHeartbeatToAllBrokerWithLock();

 消费者心跳信息包含:当前ClientId、ConsumerGroup、ConsumerType、MessageModel、ConsumerFromWhere、subscriptions

this.sendHeartbeatToAllBroker();

心跳信息==>
HeartbeatData heartbeatData = new HeartbeatData();
// clientID
heartbeatData.setClientID(this.clientId);

// Consumer
for (Map.Entry<String, MQConsumerInner> entry : this.consumerTable.entrySet()) {
    MQConsumerInner impl = entry.getValue();
    if (impl != null) {
        ConsumerData consumerData = new ConsumerData();
        consumerData.setGroupName(impl.groupName());
        consumerData.setConsumeType(impl.consumeType());
        consumerData.setMessageModel(impl.messageModel());
        consumerData.setConsumeFromWhere(impl.consumeFromWhere());
        consumerData.getSubscriptionDataSet().addAll(impl.subscriptions());
        consumerData.setUnitMode(impl.isUnitMode());

        heartbeatData.getConsumerDataSet().add(consumerData);
    }
}

2.4.9 启动消息拉取实例线程

2.4.9.1 启动消息拉取实例线程
  • 启动消息拉取线程 this.pullMessageService.start();

  • 线程内逻辑:同步阻塞的从本地缓存队列(PullMessageService#pullRequestQueue)中获取请求拉取信号,执行消息拉取与消费动作;

// KR2:生产者消费者模型,如果有生产者产出消息,则执行消息拉取消费,否则阻塞等待【触发rebalance时发起第一次消息拉取】
PullRequest pullRequest = this.pullRequestQueue.take();
this.pullMessage(pullRequest);

顺序消费:本地缓存的client与messageQueue关系中,当前client锁定了当前messageQueue的processQueue才能执行消费,即if (processQueue.isLocked())

消息拉取与消费过程大致如下:

  • 查询本地缓存的消费点位

    this.rebalanceImpl.computePullFromWhereWithException(pullRequest.getMessageQueue())

  • 定义消息拉取成功后pullCallback

  • 注册pullCallback,发起消息拉取动作

  • 执行pullCallback

DefaultMQPushConsumerImpl#pullMessage
=>
// KR2:消息拉取成功后,处理的回调逻辑
PullCallback pullCallback = new PullCallback() {}
// KR1:触发(异步)消息拉取动作
this.pullAPIWrapper.pullKernelImpl();
2.4.9.2 执行pullCallback
  1. 消息填充至processQueue
  2. 顺序消费启动消费线程池,提交异步消费任务
    1. ConsumeMessageOrderlyService#consumeExecutor线程池名称前缀:ConsumeMessageThread_
    2. 默认情况下,当前线程池默认最多创建20个核心线程(仅在提交或创建消费任务时,根据是否存在空闲线程决定是否创建新线程
      1. DefaultMQPushConsumer#consumeThreadMin默认20

      2. DefaultMQPushConsumer#consumeThreadMax默认20

  3. 是否设置了消息拉取间隔参数
    DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
        DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval())
  4. 发送消息拉取信号PullMessageService#pullRequestQueue

2.4.9.4 执行消息消费

ConsumeMessageOrderlyService.ConsumeRequest#run

  1. messageQueue加锁

  2. 从processQueue中获取一批(DefaultMQPushConsumer#consumeMessageBatchMaxSize)消息,

    List<MessageExt> msgs = this.processQueue.takeMessages(consumeBatchSize);

  3. 执行消费前hook方法ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.executeHookBefore(consumeMessageContext);

  4. 执行业务方实现的consumeMessageListener
    status = messageListener.consumeMessage(Collections.unmodifiableList(msgs), context);

  5. 执行消费后的hook方法,无论本次本次消费是否成功,默认都会执行一次hookafter方法(需要业务方自行判断消费结果,决定是否可以执行hook方法)ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.executeHookAfter(consumeMessageContext);

  6. 本批次消息消费成功后,更新本地缓存的消费点位兵器返回是否继续拉取下一批消息标识continueConsume = ConsumeMessageOrderlyService.this.processConsumeResult(msgs, status, context, this);

消息消费流程图如下:

 消费点位更新流程图如下:

 2.4.10 启动rebalance实例线程

2.4.10.1 启动重新负载实例线程
this.rebalanceService.start();


log.info(this.getServiceName() + " service started");

while (!this.isStopped()) {
// 间隔20秒执行rebalance
    this.waitForRunning(waitInterval);
    this.mqClientFactory.doRebalance();
}

log.info(this.getServiceName() + " service end");

2.4.10.2 执行重新负载逻辑

执行重新负载方法:MQClientInstance#doRebalance

  1. 从本地缓存的consuemrGroup与MQConsumerInner关系(MQClientInstance#consumerTable)遍历consumerGroup

  2. 遍历当前consumerGroup下的全部topic(RebalanceImpl#subscriptionInner)执行重新负载 impl.doRebalance();=》RebalanceImpl#doRebalance

  3. 按topic执行重新负载RebalanceImpl#rebalanceByTopic

  4. 在本地缓(RebalanceImpl#topicSubscribeInfoTable)中查询当前topic下全部的messageQueue

  5. 从远程broker查询当前consumerGroup最新的clientId

    List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);

  6. 按重新负载(默认平均分配)策略计算得出分配结果allocateResult = strategy.allocate(this.consumerGroup, this.mQClientFactory.getClientId(), mqAll, cidAll);

  7. 根据本次计算得出的负载结果,判断当前消费者消费的messageQueue是否发生了变更

    boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);

  8. 更新远端当前消费者client在当前topic下最新的负载情况this.messageQueueChanged(topic, mqSet, allocateResultSet);触发心跳同步this.getmQClientFactory().sendHeartbeatToAllBrokerWithLock();

整体流程图如下:

2.4.11 更新本地缓存topic与messageQueue关系

this.updateTopicSubscribeInfoWhenSubscriptionChanged();

2.4.12 校验client在broker上状态是否可用

this.mQClientFactory.checkClientInBroker();

2.4.13 发送心跳

this.mQClientFactory.sendHeartbeatToAllBrokerWithLock();

2.4.14 立即重新负载

this.mQClientFactory.rebalanceImmediately();

3 消费者流程大图

高清图片:https://www.yuque.com/chiguo/sg50s7/sbagvgkb0qu024ic?#《rocketmq消费端逻辑大图》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值