RocketMQ Pull模式消费消息&向RocketMQ社区贡献代码

本文记录了我在学习的过程中遇到的一个小问题,以及我第一次参与到RocketMQ的社区,尝试向RocketMQ社区贡献代码的经过

背景

我在学习RocketMQ的Pull模式消费的过程中遇到了这样一个问题,先看我的代码

java 复制代码
public static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException {
	DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("pullTopicTest_consumerGroup");
	consumer.setNamesrvAddr("127.0.0.1:9876");
	consumer.start();
	Set<MessageQueue> messageQueues = consumer.fetchSubscribeMessageQueues("pulltopictest");

	for (MessageQueue messageQueue : messageQueues) {
		long offset = consumer.fetchConsumeOffset(messageQueue, true);
		System.out.println(offset);
		if (offset < 0) {
			continue;
		}
		PullResult result = consumer.pull(messageQueue, "*", offset, 1, 3000);
		System.out.println(result);
		long i = result.getNextBeginOffset();
		if (result.getPullStatus() == PullStatus.FOUND) {
			System.out.println(i);
			consumer.updateConsumeOffset(messageQueue, i);
		}
	}
	consumer.shutdown();
}

以上代码就是一个最简单的一个pull模式消费的样例

  • fetchSubscribeMessageQueues方法获取到topic的所有队列信息,一个MessageQueue对象就是一个队列,主要记录了三个信息queueID:队列ID,topic :队列所属的topic,brokerName:队列所在broker的brokerName
  • fetchConsumeOffset方法获取到队列最新的queueOfffset,如果这个队列中还没有消息那么就会返回-1
  • pull方法就会从broker拉取消息,返回一个PullResult对象,下文中有一个详细介绍
  • updateConsumeOffset提交最新的位点到broker

看起来没有什么问题,但是当我多次执行以后,我发现不管我执行多少次,最终下一次执行fetchConsumeOffset获取到的offset都是没有提交前的位点信息,对于我来说得到的都是0,这意味着我的位点提交是失败的。 于是我开始排查这个问题

PullResult(小插入一段可跳过)

java 复制代码
public enum PullStatus {
    // 有查到消息
    FOUND,
    // 没有新的消息
    NO_NEW_MSG,
    // 有消息但是tag不匹配
    NO_MATCHED_MSG,
    // 非法的Offset
    OFFSET_ILLEGAL
}

我们下面的代码来模拟一下这几种类型

  • NO_NEW_MSG
java 复制代码
PullResult result = consumer.pull(messageQueue, "*", 3, 16, 3000);

PullResult [pullStatus=NO_NEW_MSG, nextBeginOffset=3, minOffset=0, maxOffset=3, msgFoundList=0]
  • OFFSET_ILLEGAL
java 复制代码
PullResult result = consumer.pull(messageQueue, "*", 4, 16, 3000);

PullResult [pullStatus=OFFSET_ILLEGAL, nextBeginOffset=3, minOffset=0, maxOffset=3, msgFoundList=0]
// 看起来OFFSET_ILLEGAL和NO_NEW_MSG的判定是是否大于maxOffset
  • NO_MATCHED_MSG
java 复制代码
PullResult result = consumer.pull(messageQueue, "bbb", 0, 16, 3000);

PullResult [pullStatus=NO_MATCHED_MSG, nextBeginOffset=3, minOffset=0, maxOffset=3, msgFoundList=0]

排查过程

updateConsumeOffset方法做了什么

排查的第一步,当然就是updateConsumeOffset做了什么,这个方法最终也很简单,就是更新了客户端RemoteBrokerOffsetStore对象的一个offsetTable属性,这个属性也很简单

java 复制代码
private ConcurrentMap<MessageQueue, ControllableOffset> offsetTable = new ConcurrentHashMap<>();
// 可以看成
private ConcurrentMap<MessageQueue, AtomicLong> offsetTable = new ConcurrentHashMap<>();
// 实际上ControllableOffset里做的就是对AtomicLong value进行更新,保证原子性

updateConsumeOffset

java 复制代码
// increaseOnly这里固定传了false,表示后来的更新的offset如果小于当前offset也会进行更新,如果是true的话只会取的大的值
public void updateOffset(MessageQueue mq, long offset, boolean increaseOnly) {
	if (mq != null) {
		ControllableOffset offsetOld = this.offsetTable.get(mq);
		if (null == offsetOld) {
			offsetOld = this.offsetTable.putIfAbsent(mq, new ControllableOffset(offset));
		}

		if (null != offsetOld) {
			if (increaseOnly) {
				offsetOld.update(offset, true);
			} else {
				offsetOld.update(offset);
			}
		}
	}
}

可以看到updateConsumeOffset并没有和broker进行通信,这个位点信息只是被更新到了客户端的缓存中,真正的提交是由一个定时任务来进行的

client端的定时提交

消费者启动的时候,会生成一个MQClientInstance对象,这个对象的初始化会执行startScheduledTask方法,里面会执行很多的定时任务其中就有上报消费位点的任务

java 复制代码
this.scheduledExecutorService.scheduleAtFixedRate(() -> {
	try {
		MQClientInstance.this.persistAllConsumerOffset();
	} catch (Exception e) {
		log.error("ScheduledTask persistAllConsumerOffset exception", e);
	}
}, 1000 * 10, this.clientConfig.getPersistConsumerOffsetInterval(), TimeUnit.MILLISECONDS);

从日志里看是5s执行一次,消费者启动后10s开始执行, 那看起来问题就是我们的代码在执行完updateConsumeOffset以后就立刻执行了shutdown方法导致persistAllConsumerOffset方法没有执行导致位点没有真正提交到broker 但是真的这么简单吗?解决之路没有那么顺利

解决之路(实际未解决版)

方案一:在updateConsumeOffset后添加Sleep

于是代码被我改成了这样

java 复制代码
consumer.updateConsumeOffset(messageQueue, i);
// sleep 15s
Thread.sleep(15000);
consumer.shutdown();

方案二: 在shutdown方法中执行一次persistAllConsumerOffset

这种是我当时想出来比较优雅的一种解决方案,当时当我去尝试修改shutdown方法时我发现人家早就想到了在shutdown方法中执行一次persistAllConsumerOffset

java 复制代码
public synchronized void shutdown() {
	switch (this.serviceState) {
		case CREATE_JUST:
			break;
		case RUNNING:
			// 很明显这里是有过尝试去提交位点的,而且进去以后发现这个提交最终是异步执行的
			this.persistConsumerOffset();
			this.mQClientFactory.unregisterConsumer(this.defaultMQPullConsumer.getConsumerGroup());
			this.mQClientFactory.shutdown();
			log.info("the consumer [{}] shutdown OK", this.defaultMQPullConsumer.getConsumerGroup());
			this.serviceState = ServiceState.SHUTDOWN_ALREADY;
			break;
		case SHUTDOWN_ALREADY:
			break;
		default:
			break;
	}
}

很显然上面的两种方法看起来能解决问题实际上无法解决,最终多次尝试以后还是无法提交位点

排查2.0

persistConsumerOffset方法提交位点到broker

我们本轮排查就走到真实提交的代码中,看下为什么我们尝试sleep,尝试主动执行都无法提交位点,由于整体的代码逻辑不长,我就都贴出来了

如果提交成功会打印这段日志但是我在broker_default.log中没有看到这个日志,说明没有走到这个里面来,那么可能有问题的地方就是this.rebalanceImpl.getProcessQueueTable()并没有这个队列,我们加一个日志看一下这里获取到的值是什么

java 复制代码
log.info("[persistAll] Group: {} ClientId: {} updateConsumeOffsetToBroker {} {}",
                            this.groupName,
                            this.mQClientFactory.getClientId(),
                            mq,
                            offset.getOffset());

这里就说明rebalanceImpl的ProcessQueueTable就是空的,那么这个ProcessQueueTable是在什么时候进行添加的呢

rebalanceImpl的ProcessQueueTable的添加时机

java 复制代码
protected final ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable = new ConcurrentHashMap<>(64);

这一步我们搞清楚processQueueTable什么时候进行添加的,看了代码只有下面两个地方会对processQueueTable添加对象,查看调用的地方最终锁定的是doRebalance方法

那这下问题是不是简单了,只需要在在fetchSubscribeMessageQueues中主动调用doRebalance让processQueueTable完成初始化就好了,但是实际上当我查看到doRebalance以后我发现这样实际是不行的,pull方法和push方法不一样,push中的每一个消费者在订阅topic的时候就会初始化SubscriptionData对象(tag过滤的规则),但是pull模式不同哇,pull模式只会在执行pull方法的时候才会初始化SubscriptionData对象,所以如果我们只能给fetchSubscribeMessageQueues增加一个重载方法让pull模式也在获取topic队列的时候把tag过滤的规则上报上来

java 复制代码
public boolean doRebalance(final boolean isOrder) {
	boolean balanced = true;
	Map<String, SubscriptionData> subTable = this.getSubscriptionInner();
	if (subTable != null) {
	...
	}

继续看doRebalance会走到rebalanceByTopic方法

java 复制代码
Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
// 从RebalanceImpl的topicSubscribeInfoTable属性中获取到topic的messagequeue信息
// protected final ConcurrentMap<String/* topic */, Set<MessageQueue>> topicSubscribeInfoTable = new ConcurrentHashMap<>();

这里是我们的第二个需要在doRebalance之前完成的初始化,把topic的messagequeue信息缓存起来

java 复制代码
List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);
// findConsumerIdList最终会去执行TopicRouteData topicRouteData = this.topicRouteTable.get(topic);从客户端的缓存中拿到topic路由信息

这里是我们第三个需要在doRebalance之前完成的初始化,从namesvr中获取到topic的路由信息缓存在客户端 于是我们给fetchSubscribeMessageQueues加一个重载方法

增加fetchSubscribeMessageQueues的重载方法

java 复制代码
public Set<MessageQueue> fetchSubscribeMessageQueues(String topic,
	String subExpression) throws MQClientException, InterruptedException {
	this.isRunning();
	// check if has info in memory, otherwise invoke api.
	Set<MessageQueue> result = this.rebalanceImpl.getTopicSubscribeInfoTable().get(topic);
	if (null == result) {
		result = this.mQClientFactory.getMQAdminImpl().fetchSubscribeMessageQueues(topic);
	}

	// update topic route from namesvr
	this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic, false, null);
	this.rebalanceImpl.getTopicSubscribeInfoTable().put(topic, result);
	this.rebalanceImpl.getSubscriptionInner().put(topic, getSubscriptionData(topic, subExpression));

	this.rebalanceImpl.doRebalance(false);
	return parseSubscribeMessageQueues(result);
}

看起来没什么问题,执行一下测试代码

java 复制代码
public static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException {
	DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("pullTopicTest_consumerGroup");
	consumer.setNamesrvAddr("127.0.0.1:9876");
	consumer.start();
	// 相比较之前的代码,使用了自己写的fetchSubscribeMessageQueues的重载方法
	Set<MessageQueue> messageQueues = consumer.fetchSubscribeMessageQueues("pulltopictest", "*");

	for (MessageQueue messageQueue : messageQueues) {
		long offset = consumer.fetchConsumeOffset(messageQueue, true);
		System.out.println(offset);
		if (offset < 0) {
			continue;
		}
		PullResult result = consumer.pull(messageQueue, "*", offset, 1, 3000);
		System.out.println(result);
		long i = result.getNextBeginOffset();
		if (result.getPullStatus() == PullStatus.FOUND) {
			System.out.println(i);
			consumer.updateConsumeOffset(messageQueue, i);
		}
	}
	consumer.shutdown();
}

但是我们从日志可以看到doRebalance还是报错了,说在broker上没有找到消费者的信息,这个是因为消费者启动以后还没有上报心跳到broker,我们就执行doRebalance了,所以我们需要主动上报一次心跳

主动上报heartbeat

于是最终fetchSubscribeMessageQueues被改写成了这样

java 复制代码
public Set<MessageQueue> fetchSubscribeMessageQueues(String topic,
	String subExpression) throws MQClientException, InterruptedException {
	this.isRunning();
	// check if has info in memory, otherwise invoke api.
	Set<MessageQueue> result = this.rebalanceImpl.getTopicSubscribeInfoTable().get(topic);
	if (null == result) {
		result = this.mQClientFactory.getMQAdminImpl().fetchSubscribeMessageQueues(topic);
	}

	// update topic route from namesvr
	this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic, false, null);
	this.rebalanceImpl.getTopicSubscribeInfoTable().put(topic, result);
	this.rebalanceImpl.getSubscriptionInner().put(topic, getSubscriptionData(topic, subExpression));
	// send heart beat
	for (Map.Entry<String, HashMap<Long, String>> addressEntry : this.mQClientFactory.getBrokerAddrTable().entrySet()) {
		for (Map.Entry<Long, String> entry : addressEntry.getValue().entrySet()) {
			String addr = entry.getValue();
			long id = entry.getKey();
			String brokerName = addressEntry.getKey();
			if (this.mQClientFactory.sendHeartbeatToBroker(id, brokerName, addr)) {
				this.mQClientFactory.rebalanceImmediately();
			}
			break;
		}
	}
	this.rebalanceImpl.doRebalance(false);
	return parseSubscribeMessageQueues(result);
}

我们再次执行测试代码。

至此整个问题被完美解决,不需要在增加sleep了

解决方案2.0

方案一:修改sdk代码

整个代码修改过程已经在上面的排查中展示这里就不赘述

方案二:增加Sleep

其实问题的本质就是有以下几个:

  • heartbeat没有上报到broker
  • 没有从namesvr上获取到topic的路由信息
  • 没有tag过滤相关的信息
  • 没有执行doRebalance

那么只需要在pull方法执行后等个1分钟再去提交位点就可以了

java 复制代码
PullResult result = consumer.pull(messageQueue, "*", offset, 1, 3000);
Thread.sleep(60000);
consumer.updateConsumeOffset(messageQueue, i);

向RocketMQ社区贡献代码

我也是第一次向RocketMQ社区提交了我的这段代码,但是这个pull模式其实也是一个被废弃的模式,合并的概率不大

  • fork到自己的仓库
  • 我的分支
相关推荐
yellowatumn15 小时前
RocketMq\Kafka如何保障消息不丢失?
分布式·kafka·rocketmq
liangblog2 天前
将RocketMQ集成到了Spring Boot项目中,实现站内信功能
spring boot·rocketmq·java-rocketmq
rgrgrwfe3 天前
从零到一:Spring Boot 与 RocketMQ 的完美集成指南
spring boot·rocketmq·java-rocketmq
是小崔啊3 天前
RocketMQ - 常见问题
rocketmq
G_whang4 天前
RocketMQ 5.0安装部署
rocketmq
chudaxiakkk5 天前
rocketmq-netty通信设计-request和response
rocketmq·netty通信
天天向上杰7 天前
浅聊MQ之Kafka、RabbitMQ、ActiveMQ、RocketMQ持久化策略
java·kafka·rabbitmq·rocketmq·activemq
一个儒雅随和的男子7 天前
RocketMQ与kafka如何解决消息丢失问题?
分布式·kafka·rocketmq
天天向上杰8 天前
浅识MQ的 Kafka、ActiveMQ、RabbitMQ、RocketMQ区别
kafka·rabbitmq·rocketmq·activemq·java-activemq
一个儒雅随和的男子8 天前
RocketMQ和Kafka如何实现顺序写入和顺序消费?
分布式·kafka·rocketmq