CONSUME_FROM_LAST_OFFSET 失效问题

2,309 阅读7分钟

rocketmq版本:4.5.2

背景

broker中已存在的TopicA和consumerGroupB(简称groupB),他们之间原本没有订阅关系。现将groupB订阅TopicA,开始消费设为CONSUME_FROM_LAST_OFFSET,启动groupB后,马上告警:消费组:consumerGroupB , 消费延迟:313255条。第二天继续发布consumerGroupC,新订阅topicA,但是发布上去后一切正常,未从头消费。

前言

rocketmq有一个ConsumeFromWhere的配置,定义消息消费从哪里开始

   /**
     * 一个新的订阅组第一次启动从队列的最后位置开始消费<br>
     * 后续再启动接着上次消费的进度开始消费
     */
    CONSUME_FROM_LAST_OFFSET,

    @Deprecated
    CONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST,
    @Deprecated
    CONSUME_FROM_MIN_OFFSET,
    @Deprecated
    CONSUME_FROM_MAX_OFFSET,
    /**
     * 一个新的订阅组第一次启动从队列的最前位置开始消费<br>
     * 后续再启动接着上次消费的进度开始消费
     */
    CONSUME_FROM_FIRST_OFFSET,
    /**
     * 一个新的订阅组第一次启动从指定时间点开始消费<br>
     * 后续再启动接着上次消费的进度开始消费<br>
     * 时间点设置参见DefaultMQPushConsumer.consumeTimestamp参数
     */
    CONSUME_FROM_TIMESTAMP,

我们配置了CONSUME_FROM_LAST_OFFSET,第一次订阅却没有生效,于是准备翻源码来排查这个问题。

过程

过程篇幅较长,没耐性的同学可以直接看结论

consumer端:

我们首先看CONSUME_FROM_LAST_OFFSET被使用的地方,我们进入

org.apache.rocketmq.client.impl.consumer.RebalancePushImpl#computePullFromWhere


这边是一个获取消费偏移量offset的过程:首先去存储文件读取偏移量,如果偏移量>=0就直接返回进行消费,如果偏移量为-1,则去读取最大偏移量返回进行消费。

2. 接下来进入org.apache.rocketmq.client.consumer.store.RemoteBrokerOffsetStore#readOffset 


这边是读取偏移量的方法,我们看READ_FROM_STORE,返回的是fetchConsumeOffsetFromBroker方法的结果,并且对方法捕获异常,MQBrokerException异常情况下返回-1

3. 然后依次进入 org.apache.rocketmq.client.consumer.store.RemoteBrokerOffsetStore#fetchConsumeOffsetFromBroker -> org.apache.rocketmq.client.impl.MQClientAPIImpl#queryConsumerOffset


这边是由consumer发起请求,向broker获取偏移量的过程。如果请求结果是SUCCESS,直接返回偏移量,否则抛出MQBrokerException异常。这里如果抛出了MQBrokerException异常,上层会catch住,然后走mQClientFactory.getMQAdminImpl().maxOffset(mq)的逻辑

broker端

1. 接下来按RequestCode.QUERY_CONSUMER_OFFSET找到broker端的处理。依次由org.apache.rocketmq.broker.processor.ConsumerManageProcessor#processRequest 进入

org.apache.rocketmq.broker.processor.ConsumerManageProcessor#queryConsumerOffset


这边代码首先按照consumerGroup和topic查历史偏移量,查到大于等于0,直接返回。否则按topic和queueId查topic下当前队列的最小偏移量,如果小于等于0直接返回this.brokerController.getMessageStore().checkInDiskByConsumeOffset的结果不影响此次排查,忽略,该方法的作用是检查此队列的最老的数据是否还在内存,在内存则返回0),否则返回异常情况

2. 我们首先看org.apache.rocketmq.broker.offset.ConsumerOffsetManager#queryOffset方法


该方法在offsetTable中寻找consumerGroup的历史消费记录,key为topic@consumerGroup,value是消费队列的一个map,维护了每个队列的消费情况。offsetTable维护了所有topic与group之间的消费进度。我们的consumerGroupB对topicA之前并没有订阅关系,所以这边返回的是-1.

3. 返回-1后,上层方法走入org.apache.rocketmq.store.MessageStore#getMinOffsetInQueue方法。该方法是按topic与queueId返回当前队列的最小有效偏移量。如果偏移量小于等于0,则直接返回偏移量0,consumer将从0开始消费。否则就会返回QUERY_NOT_FOUND的code,标明请求失败。

4. 我们看下getMinOffsetInQueue方法的实现



非常简单,用minLogicOffset除上一个常量就可以了。minLogicOffset是当前队列的一个逻辑偏移量,非commitLog中的实际偏移量,接下来我们重点讲解下minLogicOffset的作用。

store相关

1. 我们首先看下minLogicOffset是在哪里被写的


我们找到了correctMinOffset方法,该方法作用在mq启动,或者删除commitLog,consumeQueue的时候,做一个逻辑索引索引修正作用的。

2. 我们先看下索引文件结构。


每个topic下有多个逻辑队列,我的topicA下有16个队列,对应16个文件夹0~15。每个文件夹下面存储索引文件,每个索引文件每个文件由30W条数据组成,存满30W条数据就创建下一个索引文件进行存储。索引文件名由第一条索引offset命名,比如00000000000000000000文件,它的第一条数据的offset就是00000000000000000000。commitlog文件的命名规范也类似。

3. 再回到上述代码,首先mappedFileQueue.getFirstMappedFile()获取当前队列下第一个索引文件。入参phyMinOffset为第一个commitlog文件第一条数据的offset。然后在for循环中读取索引文件的内容,每次读20字节。

这里说下索引文件的结构:


一共20字节,前8字节存的是commitlog的offset,中间size存储了该消息的大小,最后8位消息标签对应的hashCode。所以rocketmq在进行消息消费的时候,首先查询索引文件,然后再根据索引文件查询commitlog。

我们再回到上面的代码,long offsetPy = result.getByteBuffer().getLong();获取索引前8位commitlog的offset(因为long是8位)。比较offsetPy是否大于等于入参phyMinOffset,满足就调用this.minLogicOffset = mappedFile.getFileFromOffset() + i,将minLogicOffset设置为满足条件的索引偏移量这边是为了修正minLogicOffset,使它必须是一个有效的偏移量。比如commitlog被删除,如果这时候minLogicOffset的commitlog offset还是指向被删除的commitlog offset,那消费就会有问题,这里保证了索引文件中的最小偏移量,指向的commitlog offset肯定是有效的。

结合实际情况

1. 所以可以想一下,minLogicOffset什么时候会是0呢?首先索引文件的第一个文件必须是00000000000000000000,其次索引文件第一条数据指向的commitlog offset必须大于commitlog第一个文件的最小偏移量。于是我下载了服务器上的索引文件00000000000000000000,读取第一条数据的commitlog偏移量。



又看了下服务器上commitlog文件

9月4日下午的commitlog文件


9月6日的commitlog文件


可以看到2天时间,commitlog已经清理了大概10G的内容(每个commitlog文件1g大小)。commitlog清理机制:如果72小时未往文件中进行写入,则判定文件为过期时间,默认配置每日凌晨4点进行清理。所以按我公司的情况,每天清理的数量在5G左右。所以凌晨9月4日凌晨发布的时候,commitlog最小偏移量应该是在7122xxxxxxx左右,正好小于索引文件对应的最小偏移量。这时候代码返回的consumeFromWhere返回的是0,于是就从头消费。这也解释了我9月5日凌晨发布的时候为什么没有进行从0消费的问题。

rocketmq机制:

  1. 配置了CONSUME_FROM_LAST_OFFSET,首先consumer会向broker请求历史偏移量(此偏移量为逻辑队列的偏移量),拿到历史偏移量就从历史位置进行消费。
  2. 如果没有历史偏移量,则查consumequeue逻辑队列的最小有效偏移量。如果最小有效偏移量大于0,则返回QUERY_NOT_FOUND状态,否则返回SUCCESS。
  3. consumer的MQClientAPIImpl类没有获取SUCCESS状态,就会抛出MQBrokerException异常,上层捕获到该异常后,会走请求队列最大偏移量的分支。consumer会再次向broker发送请求获取最大偏移量,拿到最大偏移量后,consumer就从最近位置进行消费。
  4. 如果2步骤中最小有效偏移量小于等于0,则返回偏移量0,consumer将从头消费
  5. 最小有效偏移量的计算逻辑为:索引文件中,指向commitlog有效offset的第一条索引的下标。最小有效偏移量会在文件删除、mq重启的时候重新计算。

结论

  1. 配置了CONSUME_FROM_LAST_OFFSET,正常情况下,如果group与topic之间不存在订阅关系,则第一次从最新的offset进行消费,否则从上次消费的地方进行消费。
  2. 如果topic还比较新,刚上线不久,新的概念体现在topic第一条消息所在的commitlog还没被清理过,并且topic的第一个索引文件也没清理过,那么rocketmq会认为这个消息队列消息量不大,可以从头进行消费。

写在末尾的话

  1. rocketmq我没有完整的全部看过,也许上述讲的地方会有不对的地方,欢迎指正。
  2. 从问题看源码,其实是一个很好的学习源码的过程。