踩坑記:rocketmq-console 消費TPS爲0,但消息積壓數卻在降低是個什麼“鬼”

1、背景

當消息出現大量擠壓後,消費端將其代碼優化後,重啓消費端服務器,從 rocketmq-console 上發現 TPS 爲 0,如圖所示:
在這裏插入圖片描述
乍一看,第一時間得出應用還未恢復,就開始去查看相關的啓動日誌,通常查看的是應用服務器的 /home/baseuser/logs/rockemqlogs/rocketmq_client.logs,碰巧又看到如下的錯誤日誌:

RebalanceService - [BUG] ConsumerGroup: consumergourp-1 The consumerId: consumer-client-id-clusterA-192.168.x.x@21932 not in cidAll: [consumer-client-id-clusterA-192.168.x.x@22164]

上面的日誌顯示在隊列負載時候,當前節點竟然不屬於 consumergourp-1 消費組的活躍連接,導致一大片的報錯:

2019-11-02 19:29:17 WARN NettyClientPublicExecutor_1 - execute the pull request exception
org.apache.rocketmq.client.exception.MQBrokerException: CODE: 25  DESC: the consumer's subscription not latest
For more information, please visit the url, http://rocketmq.apache.org/docs/faq/
	at org.apache.rocketmq.client.impl.MQClientAPIImpl.processPullResponse(MQClientAPIImpl.java:639)
	at org.apache.rocketmq.client.impl.MQClientAPIImpl.access$200(MQClientAPIImpl.java:156)
	at org.apache.rocketmq.client.impl.MQClientAPIImpl$2.operationComplete(MQClientAPIImpl.java:592)
	at org.apache.rocketmq.remoting.netty.ResponseFuture.executeInvokeCallback(ResponseFuture.java:51)
	at org.apache.rocketmq.remoting.netty.NettyRemotingAbstract$2.run(NettyRemotingAbstract.java:275)
	at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
	at java.util.concurrent.FutureTask.run(FutureTask.java:266)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at java.lang.Thread.run(Thread.java:745)

乍一看確實是 rocketmq 相關的問題,導致上述 消費TPS 爲0,經過半個小時的日誌分析,發現這是RocketMQ 這是一種正常現象,最終會自動恢復,這裏我留一個伏筆,將在我的知識星球中與廣大星友討論,經過日誌分析得出 rocketmq 沒問題,故後面去查看消息積壓,發現消息積壓明顯在減少,那這就奇了怪了,咋消息積壓在快速減少,但爲啥消費TPS還是爲0呢?

接下來將該問題進行探討。

溫馨提示:在問題分析部分,作者沒有直接給出答案,而是一步一步探尋答案,因此會通過追蹤源碼來尋求答案,如果大家想急於答案,可以跳過問題分析,直接查看本文末尾的問題解答部分。
通過本文的閱讀,您將獲得如下信息:
1、RocketMQ 消費TPS的收集與計算邏輯。
2、RocketMQ 監控指標的設計思路。
3、RocketMQ 主從同步,消費者從主服務器拉取還是從從服務器拉取的判斷邏輯。

2、問題分析

2.1 rocketmq-console 數據獲獲取邏輯探討

要解開消費TPS 顯示爲0的問題,我們首先要來看一下 rocketmq-console 這個頁面的展示邏輯,即通過閱讀 rocketmq-console的源碼來解開其採集邏輯。
在這裏插入圖片描述
得知,【消費者】界面查詢各個消費組的基本信息的接口爲 /consumer/groupList.query,那接下來,我們首先從源碼的角度來分析該接口的實現邏輯。其入口如下:

org.apache.rocketmq.console.controller.ConsumerController#list
@RequestMapping(value = "/groupList.query")
@ResponseBody
public Object list() {
    return consumerService.queryGroupList();
}
就是調用消費服務處理類的 queryGroupList 方法,其實現代碼如下:
ConsumerServiceImpl#queryGroupList
public List<GroupConsumeInfo> queryGroupList() {
    Set<String> consumerGroupSet = Sets.newHashSet();
    try {
        ClusterInfo clusterInfo = mqAdminExt.examineBrokerClusterInfo();  // @1
        for (BrokerData brokerData : clusterInfo.getBrokerAddrTable().values()) {   // @2
            SubscriptionGroupWrapper subscriptionGroupWrapper = mqAdminExt.getAllSubscriptionGroup(brokerData.selectBrokerAddr(), 3000L);  // @3
            consumerGroupSet.addAll(subscriptionGroupWrapper.getSubscriptionGroupTable().keySet());                                                                 
        }
    } catch (Exception err) {
        throw Throwables.propagate(err);
    }
    List<GroupConsumeInfo> groupConsumeInfoList = Lists.newArrayList();
    for (String consumerGroup : consumerGroupSet) {                                                // @4
        groupConsumeInfoList.add(queryGroup(consumerGroup));                              
    }
    Collections.sort(groupConsumeInfoList);
    return groupConsumeInfoList;
}

代碼@1:獲取集羣的 broker 信息,主要是通過向 NameServer 發送 GET_BROKER_CLUSTER_INFO 請求,NameServer 返回集羣包含的所有 broker 信息,包含從節點的信息,返回的格式如下:

"clusterInfo": {
    "brokerAddrTable": {
	   "broker-a": {
	       "cluster": "DefaultCluster",
			"brokerName": "broker-a",
			"brokerAddrs": {
				"0": "192.168.0.168:10911",
				"1": "192.168.0.169:10911"
			}
		},
        "broker-b": {
	       "cluster": "DefaultCluster",
			"brokerName": "broker-b",
			"brokerAddrs": {
				"0": "192.168.0.170:10911",
				"1": "192.168.1.171:10911"
			}
		}
	},
	"clusterAddrTable": {
		"DefaultCluster": ["broker-a","broker-b"]
	}
}

代碼@2:遍歷集羣中的 brokerAddrTable 數據結構,即存儲了 broker 的地址信息的 Map 。

代碼@3:分別向集羣中的主節點(brokerData.selectBrokerAddr()) 獲取所有的訂閱關係(即消費組的訂閱信息)。然後將所有的消費者組名稱存入 consumerGroupSet。

代碼@4:遍歷代碼@3收集到的消費組,調用 queryGroup 依次請求消費組的運行時信息,後面接下來詳細分析。

接下來將重點分析 queryGroup方法的實現細節。

ConsumerServiceImpl#queryGroup

public GroupConsumeInfo queryGroup(String consumerGroup) {
    GroupConsumeInfo groupConsumeInfo = new GroupConsumeInfo();
    try {
        ConsumeStats consumeStats = null;
        try {
            consumeStats = mqAdminExt.examineConsumeStats(consumerGroup);  // @1
        } catch (Exception e) {
            logger.warn("examineConsumeStats exception, " + consumerGroup, e);
        }
        ConsumerConnection consumerConnection = null;
        try {
            consumerConnection = mqAdminExt.examineConsumerConnectionInfo(consumerGroup); 
        } catch (Exception e) {
            logger.warn("examineConsumerConnectionInfo exception, " + consumerGroup, e);
        }
        groupConsumeInfo.setGroup(consumerGroup);

        if (consumeStats != null) {
            groupConsumeInfo.setConsumeTps((int)consumeStats.getConsumeTps());    // @2
            groupConsumeInfo.setDiffTotal(consumeStats.computeTotalDiff());                   // @3
        }

        if (consumerConnection != null) {
            groupConsumeInfo.setCount(consumerConnection.getConnectionSet().size());
            groupConsumeInfo.setMessageModel(consumerConnection.getMessageModel());
            groupConsumeInfo.setConsumeType(consumerConnection.getConsumeType());
            groupConsumeInfo.setVersion(MQVersion.getVersionDesc(consumerConnection.computeMinVersion()));
        }
    } catch (Exception e) {
        logger.warn("examineConsumeStats or examineConsumerConnectionInfo exception, "
                + consumerGroup, e);
    }
    return groupConsumeInfo;
}

從上面@1,@2,@3這三處代碼可以得知,rocketmq-console 相關界面上的消費TPS主要來自 examineConsumeStats 方法,該方法我就不再繼續深入,我們只需找到該方法向 broker 發送的請求編碼,然後根據該請求編碼找到 broker 的處理邏輯即可,最後跟蹤發送的請求編碼爲:RequestCode.GET_CONSUME_STATS。

GET_CONSUME_STATS 命令在 broker 的處理邏輯如下:

AdminBrokerProcessor#getConsumeStats

private RemotingCommand getConsumeStats(ChannelHandlerContext ctx, RemotingCommand request) throws RemotingCommandException {
        final RemotingCommand response = RemotingCommand.createResponseCommand(null);
        final GetConsumeStatsRequestHeader requestHeader =
            (GetConsumeStatsRequestHeader) request.decodeCommandCustomHeader(GetConsumeStatsRequestHeader.class);
        ConsumeStats consumeStats = new ConsumeStats();
        Set<String> topics = new HashSet<String>();
        if (UtilAll.isBlank(requestHeader.getTopic())) {
            topics = this.brokerController.getConsumerOffsetManager().whichTopicByConsumer(requestHeader.getConsumerGroup());
        } else {
            topics.add(requestHeader.getTopic());
        }
        for (String topic : topics) {   // @1
            TopicConfig topicConfig = this.brokerController.getTopicConfigManager().selectTopicConfig(topic);
            if (null == topicConfig) {  // @2
                log.warn("consumeStats, topic config not exist, {}", topic);
                continue;
            }
            {                                
                SubscriptionData findSubscriptionData =
                    this.brokerController.getConsumerManager().findSubscriptionData(requestHeader.getConsumerGroup(), topic);   // @3
                if (null == findSubscriptionData //
                    && this.brokerController.getConsumerManager().findSubscriptionDataCount(requestHeader.getConsumerGroup()) > 0) {
                    log.warn("consumeStats, the consumer group[{}], topic[{}] not exist", requestHeader.getConsumerGroup(), topic);
                    continue;
                }
            }
            for (int i = 0; i < topicConfig.getReadQueueNums(); i++) {   // @4
                MessageQueue mq = new MessageQueue();
                mq.setTopic(topic);
                mq.setBrokerName(this.brokerController.getBrokerConfig().getBrokerName());
                mq.setQueueId(i);
                OffsetWrapper offsetWrapper = new OffsetWrapper();
                long brokerOffset = this.brokerController.getMessageStore().getMaxOffsetInQueue(topic, i);
                if (brokerOffset < 0)
                    brokerOffset = 0;
                long consumerOffset = this.brokerController.getConsumerOffsetManager().queryOffset(//
                    requestHeader.getConsumerGroup(), //
                    topic, //
                    i);
                if (consumerOffset < 0)
                    consumerOffset = 0;
                offsetWrapper.setBrokerOffset(brokerOffset);                                   // @5
                offsetWrapper.setConsumerOffset(consumerOffset);                       // @6
                long timeOffset = consumerOffset - 1;
                if (timeOffset >= 0) {
                    long lastTimestamp = this.brokerController.getMessageStore().getMessageStoreTimeStamp(topic, i, timeOffset);
                    if (lastTimestamp > 0) {
                        offsetWrapper.setLastTimestamp(lastTimestamp);                 // @7
                    }
                }
                consumeStats.getOffsetTable().put(mq, offsetWrapper);     // @8
            }
            double consumeTps = this.brokerController.getBrokerStatsManager().tpsGroupGetNums(requestHeader.getConsumerGroup(), topic); // @9
            consumeTps += consumeStats.getConsumeTps(); // @10
            consumeStats.setConsumeTps(consumeTps);
        }
        byte[] body = consumeStats.encode();
        response.setBody(body);
        response.setCode(ResponseCode.SUCCESS);
        response.setRemark(null);
        return response;
}

該方法比較長,重點關注如下關鍵點:
代碼@1:遍歷該消費組訂閱的所有主題。消費TPS將是所有主題消費TPS的總和,其他的信息按主題、隊列信息單獨存放。

代碼@2:如果 topic 的元信息不存在,則跳過該主題。

代碼@3:如果消費組的訂閱信息不存在,則跳過該訂閱關係。

代碼@4:收集該主題所有的讀隊列,以messagequeue爲鍵,OffsetWrapper爲值存儲在 consumeStats.getOffsetTable() ,見代碼@8。

代碼@5:設置該隊列的最新偏移量。

代碼@6:設置該消費組對該隊列的消費進度,設置爲consumeOffset。

代碼@7:lastTimestamp 上一次消費的消息的存儲時間,實現邏輯爲:取消費組對於隊列的消息消費進度 -1 的消息,存儲在 broker 的時間,如果對應的消息已過期被刪除,則在界面上顯示的時間就會爲1970-01-01 08:00:00。

代碼@9:通過 BrokerStatsManager 的 tpsGroupGetNums 方法從統計數據中獲取該消費組針對該隊列的消費TPS。

代碼@10:累積消費TPS,並最終作爲該消費組的總TPS。

上面這個方法非常關鍵,是返回給前段頁面核心的數據組裝邏輯,以隊列、消費組爲緯度給出 brokerOffset、consumeOffset、lastTimestamp。然後將數據返回給前段頁面進行展示。

接下將聚焦到消費組消費TPS的統計處理,其入口爲 tpsGroupGetNums

2.2 rocketmq 消費TPS統計實現原理

2.2.1 消費TPS計算邏輯

首先我們還是從 tpsGroupGetNums 方法入手,探究一下 tps 的獲取邏輯,然後再探究數據的採集原理(這也是 rocketmq 監控相關)。

BrokerStatsManager#tpsGroupGetNums

public double tpsGroupGetNums(final String group, final String topic) {
    final String statsKey = buildStatsKey(topic, group); // @1
    return this.statsTable.get(GROUP_GET_NUMS).getStatsDataInMinute(statsKey).getTps(); // @2
}

代碼@1:構建統計key,其邏輯爲:其鍵爲:topic@consumerGroup,即消息主題@消費組名。

要讀懂 代碼@2 的代碼,先來看一下 rocketmq 監控指標的存儲數據結構,如下圖所示:
在這裏插入圖片描述
正如上圖所示:RocketMQ 使用 HashMap< String, StatusItemSet> 來存儲監控收集的數據,其中Key 爲監控指標的類型,例如 topic 發送消息數量、topic 發送消息大小、消費組獲取消息個數等信息,每一項使用 StatsItemSet 存儲,該存儲結構內部又維護一個HashMap:ConcurrentMap,key 代表某一個具體的統計目標,例如記錄消費組拉取消息的數量監控指標,那其統計的對象即 topic@consumer_group,最終數據的載體是 StatsItem,使用如下幾個關鍵字段來記錄統計信息:

  • AtomicLong value = new AtomicLong(0)
    總數量,統計指標TOPIC_GET_NUMS 指標爲例,記錄的是消息拉取的總條數,例如一次消息拉取操作獲取了32條消息,則該數量增加32。
  • AtomicLong times = new AtomicLong(0)
    改變上述 value 的次數,還是以統計指標TOPIC_GET_NUMS 指標爲例,記錄的是增加 value 的次數。
  • LinkedList< CallSnapshot> csListMinute
    一分鐘的快照信息,該 List 只會存儲6個元素,每10s記錄一次調用快照,超過6條,則移除第一條,這個將在下文介紹。
  • LinkedList< CallSnapshot> csListHour
    一小時的快照信息,該 List 只會存儲6個元素,每10分鐘記錄一次快照,超過6條,則移除第一條。
  • LinkedList< CallSnapshot> csListDay
    一天的快照新,該List 只會存儲24個元素,每1小時記錄一次快照,超過24條,則移除第一條。

瞭解了上述存儲結構後,代碼@2,最終其實調用的就是 StatsItemSet 的 getStatsDataInMinute 方法。

StatsItemSet#getStatsDataInMinute

public StatsSnapshot getStatsDataInMinute(final String statsKey) {
    StatsItem statsItem = this.statsItemTable.get(statsKey);
    if (null != statsItem) {
        return statsItem.getStatsDataInMinute();
    }
    return new StatsSnapshot();
}

從代碼上最終調用 StatesItem 的 getStatsDataInMinute 方法。

StatesItem#getStatsDataInMinute

public StatsSnapshot getStatsDataInMinute() {
    return computeStatsData(this.csListMinute);
}
private static StatsSnapshot computeStatsData(final LinkedList<CallSnapshot> csList) {
    StatsSnapshot statsSnapshot = new StatsSnapshot();
    synchronized (csList) {
        double tps = 0;
        double avgpt = 0;
        long sum = 0;
        if (!csList.isEmpty()) {
            CallSnapshot first = csList.getFirst();   // @1
            CallSnapshot last = csList.getLast();    // @2
            sum = last.getValue() - first.getValue();  // @3
            tps = (sum * 1000.0d) / (last.getTimestamp() - first.getTimestamp());   // @4
            long timesDiff = last.getTimes() - first.getTimes();
            if (timesDiff > 0) {                                                                                   // @5
                avgpt = (sum * 1.0d) / timesDiff;
            }
        }

        statsSnapshot.setSum(sum);
        statsSnapshot.setTps(tps);
        statsSnapshot.setAvgpt(avgpt);                                                          
    }
    return statsSnapshot;
}

代碼@1:首先取快照中的第一條消息。

代碼@2:取快照列表中的最後一條消息。

代碼@3:計算這兩個時間點 value 的差值,即這段時間內新增的總數。

代碼@4:計算這段時間內的tps,即每秒處理的消息條數。

代碼@5:計算 avgpt ,即平均一次操作新增的消息條數(即平均一次操作,value 新增的個數)。

消費組的消費TPS的計算邏輯就介紹到這裏了,那還有一個疑問,即 StatsItem 中 csListMinute 中的數據從哪來呢?

2.2.2 如何採集消費TPS原始數據

StatsItem#init

public void init() {
  this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                try {
                    samplingInSeconds();
                } catch (Throwable ignored) {
                }
            }
        }, 0, 10, TimeUnit.SECONDS);
   // 省略其他代碼
}

原來在創建一個新的StatsItem 的時候,就會啓動一個定時任務,每隔 10s 調用 samplingInSeconds 方法進行抽樣,那我們簡單看一下這個方法:

StatsItem#samplingInSeconds

public void samplingInSeconds() {
    synchronized (this.csListMinute) {
        this.csListMinute.add(new CallSnapshot(System.currentTimeMillis(), this.times.get(), this.value
                .get()));
        if (this.csListMinute.size() > 7) {
            this.csListMinute.removeFirst();
        }
    }
}

就是將當前StatsItem 中的 value 與 變更次數(time ) 存入封裝成 CallSnapshot ,然後存儲在快照列表中。這裏的關鍵是times values 這些值在什麼情況下會改變呢?

接着往下看,源碼在消息拉取的時候,會將本次拉取的信息加入到統計信息中,其入口爲:

PullMessageProcessor#processRequest

switch (response.getCode()) {
    case ResponseCode.SUCCESS:
        this.brokerController.getBrokerStatsManager().incGroupGetNums(requestHeader.getConsumerGroup(), requestHeader.getTopic(),
                        getMessageResult.getMessageCount());
        this.brokerController.getBrokerStatsManager().incGroupGetSize(requestHeader.getConsumerGroup(), requestHeader.getTopic(),
                        getMessageResult.getBufferTotalSize());
        this.brokerController.getBrokerStatsManager().incBrokerGetNums(getMessageResult.getMessageCount());
        
    // 省略其他代碼
}

該方法會最終更新 StatsItem 中的 values ,而 times 是 每調用一次,加1。

理論基礎講解完畢後,接下來我們來回答一下題目中的現象。

3、問題解答

按照上面的講解,通過 rocketmq-console 發起查看消費組的TPS時,Broker 會根據過去一分鐘內採集的快照數據進行計算。快照信息的採集機制是 broker 端會每10s 會記錄一下消費組對應的拉取消息數量與拉取次數。

那既然消息延遲(堆積數量在不斷減少),說明消費端正在消費,按道理來說,通過上述機制進行計算,TPS 不可能會是0?那又是什麼原因呢?

如果TPS爲0,可以說明消費端並沒有向 broker 拉取消息,因爲一旦從 broker 拉取消息,有關 StatsItem 的 拉取消息總數(value) 與 拉取次數(times) 再兩次採集國產中肯定不會相等,只要兩者有差距,其TPS就不可能爲0,那消費組在消費消息,但又不從主節點上拉取消息,這種情況會出現嗎?

答案是會的,在 RocketMQ 主從同步架構中,如果需要訪問的消息偏移量與當前 commitlog 最大偏移的之間的差距超過了內存的40%,消息消費將由從節點接管,故此時消費的拉取不會去主節點拉取,故上面返回的TPS就會爲0。這樣就能完美解答了。

經過上面的分析,我相信大家已經非常認可這個原因了,其實我們還有一個重要的論據,大家可以分別去查看 Rocketmq 主從節點 /home/{username}/logs/rocketmqlogs/stats.log,裏面會每隔1分鐘在日誌中打印各個消費組的消費TPS.

從服務器(rocketmq-slave)對應的日誌如下:

INFO - [GROUP_GET_NUMS] [t1@c1] Stats In One Minute, SUM: 785717 TPS: 15714.34 AVGPT: 8.14
INFO - [GROUP_GET_NUMS] [t1@c1] Stats In One Minute, SUM: 940522 TPS: 15675.37 AVGPT: 8.06

主服務器(rocketmq-master)對應的日誌如下:

INFO - [GROUP_GET_NUMS] [t1@c1] Stats In One Minute, SUM: 0 TPS: 0.00 AVGPT: 0.00
INFO - [GROUP_GET_NUMS] [t1@c1] Stats In One Minute, SUM: 0 TPS: 0.00 AVGPT: 0.00

主服務器上的TPS一定會0嗎?不一定,其實也不一定。這裏藉着這波日誌,再來總結一下 RocketMQ 主從同步時的切換邏輯。

1、如果消費端請求的消息物理偏移量與 broker 當前最新的物理偏移量之間的差距查過內存的40%,下一次拉取會往從節點發送(當然前提是slaveReadEnable = true)。

2、當從節點開始接管消息消費時,下一次拉取請求一定會往從節點發送碼?答案也是不一定:

  • 如果待拉取的消息偏移量與從節點最新的物理偏移量之間的差距超過內存的30%,下一次拉取請求還是會發往從節點。
  • 如果待拉取的消息偏移量與從節點最新的物理偏移量之際的差距少於內存的30%,下一次拉取請求將發送到主節點。

關於RocketMQ 主從同步若干問題答疑,可以參考筆者的另外一篇文章:RocketMQ 主從同步若干答疑

本文就介紹到這裏了,如果覺得對您有幫助的話,麻煩幫忙點個贊,非常感謝您的鼓勵與支持。


歡迎加筆者微信號(dingwpmz),加羣探討,筆者優質專欄目錄:
1、源碼分析RocketMQ專欄(40篇+)
2、源碼分析Sentinel專欄(12篇+)
3、源碼分析Dubbo專欄(28篇+)
4、源碼分析Mybatis專欄
5、源碼分析Netty專欄(18篇+)
6、源碼分析JUC專欄
7、源碼分析Elasticjob專欄
8、Elasticsearch專欄(20篇+)
9、源碼分析MyCat專欄

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章