本文记录了我在学习的过程中遇到的一个小问题,以及我第一次参与到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的brokerNamefetchConsumeOffset
方法获取到队列最新的queueOfffset,如果这个队列中还没有消息那么就会返回-1pull
方法就会从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到自己的仓库

- 我的分支

- 提交前需要的配置 rocketmq.apache.org/zh/docs/con...
- 执行git push origin yyf提交到远程仓库
- 写一个Issue
- 生成一个PullRequest关联这个Issue 这个是我本次生成的PullRequest,也是走一遍流程 github.com/apache/rock...