RocketMQ Reset Offset命令引发频繁FullGC、消费线程Blocked
1. 问题背景
1.1 问题一:频繁Full GC
在一次线上环境中,因为消息不断生产,消费能力不足,然后采用每10s定时执行Reset Offset命令,一段时间后,业务应用频繁Full GC。RocketMQ客户端版本为4.2.0,消费模式为:并发消费。
1.2 问题二:消费线程Blocked
在一次测试环境中,通过Rocket Console Web上,连续点击Reset Offset,导致消费线程Blocked。RocketMQ客户端版本为4.4.0,消费模式为:顺序消费。下图为线程被Blocked的Arthas截图。
2. 频繁Full GC分析
以下分析内容基于RocketMQ源码4.2.0版本
对于线上场景在测试环境进行复现,当Full GC次数在40多次时,执行dump命令,将内存信息dump出来,通过MAT工具进行分析。
2.1 MAT分析
1.进入Leak Supects分析,得到以下截图信息,其中可以发现,大量的MessageClientExt被PullMessageServiceScheduledThread线程持有。
通过RocketMQ源码分析可以得知,该线程是:延迟拉取消息线程。
2.通过OQL分析内存中的数据,查看PullMessageServiceScheduledThread线程持有哪些数据。可以通过”select * from org.apache.rocketmq.client.impl.consumer.PullMessageService“获取,分析属性得到以下截图:
发现几个重要信息说明:
- MessageClientExt对象在ProcessQueue中,ProcessQueue在PullRequest中,PullReqeust在PullMessageService里的scheduledExecutorService线程中,线程名为:PullMessageServiceScheduledThread。
- ProcessQueue中的dropped为false。
- msgCount数量为1024,因为ProcessQueue还未被消费或消费不过来,所以再拉取消息时,会触发拉取数量阈值(默认为1000),此时会延迟拉取。
3.再查看RebalancePushImpl中的processQueueTable数据,查看ProcessQueue对象。和上一张在PullMessageService里的scheduledExecutorService线程里的ProcessQueue的对象不是同一个,上面接入的对象地址为0x91c3f088,说明ProcessQueue为新增对象,正常情况下ProcessQueue是固定。
2.2 RocketMQ源码分析
分析RocketMQ Client resetOffset的代码,如下图所示
- 首先会执行suspend方法,该方法会使Consumer会停止从Broker拉取消息。
- 然后会把ProcessQueue设置为dropped,dropped状态下将会停止拉取消息和停止消费;然后再把ProcessQueue的数据给清理掉。
结合前面从PullRequest中的ProcessQueue对象来看,dropped=fase,说明上面这两步骤肯定是有执行的,内存中的数据有清理,不然的话dropped应该为true,目前这里的逻辑是没有问题
继续往下resetOffset逻辑
- 会做个延迟10S的等待,猜测是让那些还在内存中消费的消息逻辑处理完。
- 会把ProcessQueue从processQueueTable中移除
- 最后会调用resume方法,Consumer会恢复,继续从Broke拉取消息消费;同时还会重新rebalance,重新构造新的PullRequest。
rebalance核心逻辑如下:代码在RebalanceImpl#updateProcessQueueTableInRebalance
- 因为前面已经将processQueueTable移除了,所以前面有一堆逻辑可以先不用考虑。
- 会new 一个ProcessQueue
- 会向processQueueTable添加ProcessQueue
- 会构造PullRequest然后进行消息拉取处理。
此时会有一个场景,如果已经构造了PullRequest请求拉取消息,然后另外一个Reset命令又到来的情况下,可能会将新增的这些ProcessQueue从processQueueTable移除,因为已经过了drop和clear()方法的判断,所以构造的PullReqeust能被正常处理。
最后结合前面提到msgCount数量为1024,说明消息已经放到消费线程进行消费了(因为从Broker拉取到消息后,就会放到消费线程池进行消费),但是消费线程池有个队列并且是个无界队列,这些PullRequest里拉取的消息还没被处理,然后又因为触发了限制拉取消息的逻辑,会一直停留在PullMessageServiceScheduledThread线程里。拉取限制逻辑源码如下:
2.3 猜想并验证
2.3.1 猜想一
ProcessQueue添加到processQueueTable后,又被另外一个Reset命令给remove掉,导致有额外的PullRequest请求,正常一个MessageQueue一个PullRequest,此时会出现一个MessageQueue有多个PullRequest也就会有多个ProcessQueue。
验证
主要验证resetOffset逻辑中processQueueTable remove掉的ProcessQueue是否存在dropped为false的数据,如果有,就会产生额外的PullRequest请求
1.创建reset_offset_test topic,设置为队列数为1,目的是为了便于测试。
/usr/local/rocketmq/bin/mqadmin updateTopic -n 127.0.0.1:9876 -b 127.0.0.1:10911 -t reset_offset_test -r 1 -w 1
2.创建定时执行reset offset任务
通过crontab -e添加以下数据,每隔10s执行一次。
* * * * * /root/mq_reset_offset_test_clean.sh >>/root/mq_reset_offset_test_clean_log.txt 2>&1
* * * * * sleep 10; /root/mq_reset_offset_test_clean.sh >>/root/mq_reset_offset_test_clean_log.txt 2>&1
* * * * * sleep 20; /root/mq_reset_offset_test_clean.sh >>/root/mq_reset_offset_test_clean_log.txt 2>&1
* * * * * sleep 30; /root/mq_reset_offset_test_clean.sh >>/root/mq_reset_offset_test_clean_log.txt 2>&1
* * * * * sleep 40; /root/mq_reset_offset_test_clean.sh >>/root/mq_reset_offset_test_clean_log.txt 2>&1
* * * * * sleep 50; /root/mq_reset_offset_test_clean.sh >>/root/mq_reset_offset_test_clean_log.txt 2>&1
其中mq_reset_offset_test_clean.sh逻辑如下:
#/bin/bash
export JAVA_HOME=/usr/local/jdk
/usr/local/rocketmq/bin/mqadmin resetOffsetByTime -n 127.0.0.1:9876 -g reset_offset_test -t reset_offset_test -s now
3.源码修改(基于4.2.0分支上),增加日志打印
在remove的情况下,看看remove的的ProcessQueue是不是dropped=true,按照正确逻辑dropped应该为true
4.增加生产和消费的代码
生产5ms生产一次,消费1000ms消费一条
public class ResetOffsetCauseExtraPullRequestTest {
private static final String NAME_SERVER_ADDRESS = "127.0.0.1:9876";
public static void main(String[] args) throws InterruptedException, RemotingException, MQClientException, MQBrokerException {
ExecutorService executorService = Executors.newFixedThreadPool(1);
executorService.submit(new Runnable() {
@Override
public void run() {
try {
produce();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (RemotingException e) {
e.printStackTrace();
} catch (MQClientException e) {
e.printStackTrace();
} catch (MQBrokerException e) {
e.printStackTrace();
}
}
});
consume();
}
public static void produce() throws InterruptedException, RemotingException, MQClientException, MQBrokerException {
//生产消息
DefaultMQProducer producer = new DefaultMQProducer();
producer.setNamesrvAddr(NAME_SERVER_ADDRESS);
producer.setProducerGroup("test");
producer.start();
for (int i = 0; i < 1000000; i++) {
TimeUnit.MILLISECONDS.sleep(5);
String body = "test-" + i;
producer.send(new Message("reset_offset_test", body.getBytes()));
}
}
public static void consume() throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer();
consumer.setConsumeThreadMin(1);
consumer.setNamesrvAddr(NAME_SERVER_ADDRESS);
consumer.subscribe("reset_offset_test", "*");
consumer.setConsumerGroup("reset_offset_test");
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
for (MessageExt msg : msgs) {
System.out.println(new String(msg.getBody()));
}
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
}
}
5.观察增加的日志打印情况,结果如下图所示。
remove掉的ProcessQueue有可能dropped为false,此时会导致额外的PullRequest产生。另外可以观察到,dropped为true的数据下,msgSize为0,也就是说正常的逻辑下,ProcessQueue的数据是有被清理的。
2.3.2 猜想二
额外的PullRequest请求一直堆积在内存中(当还没开始消费时会触发消息拉取限流;开始消费后会不断拉取消息),因为消费线程来及不消费,导致内存数据越来越多,最后FULL GC频繁。
验证
验证开始频繁FULL GC后,将压测客户端和定时Reset逻辑去掉,在无其他消息生产的情况下,业务是否还会持续的在消费消息。
根据上述描述验证:通过命令查看无消息堆积,但Consume TPS还在持续变化。MQ命令如下图:
3. 消费线程Blocked分析
根据分析得到的结论:并发执行Reset Offset命令有可能出现额外的PullRequest请求。
在顺序消费情况下,会根据MessageQueue加锁进行消费:
因为有额外的PullRequest请求,所以同时存在多个MessageQueue在不同线程消费,而顺序消费又会根据MessageQueue加锁,这种情况下会导致消费线程被Blocked住。同时消息还在不断拉取,所以消费还在进行中,会一直影响消息的消费。
4. RocketMQ官方问题修复
针对Reset Offset在并发情况下导致的问题,RocketMQ官方版本在4.9.0以上版本有进行修复
Issue记录:github.com/apache/rock…
代码修复截图:
对4.9.7版本进行问题一中的猜想一的方式进行验证:
执行一段时间后,没有出现dropped为false的ProcessQueue;另外也能看出,加锁后,消息基本上消费不动了。再一直处理消息清理、rebalance等逻辑