通过上一篇我们已经知道只有先从ConsumerQueue里获取到了CommitLog物理偏移量后才可以快速的从CommintLog里找到对应的message。那ConsumerQueue里的信息又是如何定位的呢?这里就涉及到我们今天的主题:offset管理
在RocketMQ中,消息消费完成后需要将offset存储下来,offset用来管理每个消费队列的不同消费组的消费进度,根据消费模式的不同又有所差异:

  • 在广播模式下,因为每条消息会被消费组内所有的消费者消费,同消费组的消费者相互独立,消费进度要单独存储,会以文本文件的形式存储在客户端,对应的数据结构为LocalFileOffsetStore
  • 在集群模式下,同一条消息只会被同一个消费组消费一次,消费进度会参与到负载均衡中,故消费进度是需要共享的,另外,消费者发生异常或重启为了保证可以从上一次消费的地方继续进行消费,这时的offset是统一保存到broker服务端的。对应的数据结构为RemoteBrokerOffsetStore。

实际使用RocketMQ大多是集群模式,这里也只针对集群模式下的offset管理描述。

一,Broker服务端

offset存储与加载

rocketMQ的broker端中,offset的是以json的形式持久化到磁盘文件中,文件路径为${user.home}/store/config/consumerOffset.json。其内容示例如下:

{
    "offsetTable": {
        "test-topic@test-group": {
            "0": 88526, 
            "1": 88528
        }
    }
}

broker端启动后,会调用BrokerController.initialize()方法,方法中会对offset进行加载,加载方法consumerOffsetManager.load()。获取文件内容后,最后会将其序列化为一个ConsumerOffsetManager对象,这个对象中关键的一个属性是

ConcurrentMap<String,ConcurrentMap<Integer, Long>> offsetTable

offsetTable的,key的形式为topic@group(每个topic下不同消费组的消费进度),value也是一个ConcurrentMap,key为queueId,value为消费位移(这里不是offset而是consumerQueue里的位移)。通过对全局ConsumerOffsetManager对象就可以对各个topic下不同消费组的消费位移进行获取与管理。

commitLog与offset

image.png

producer发送消息到broker之后,会将消息具体内容持久化到commitLog文件中,再分发到topic下的消费队列consume Queue,消费者提交消费请求时,broker从该consumer负责的消费队列中根据请求参数起始offset获取待消费的消息索引信息,再从commitLog中获取具体的消息内容返回给consumer。在这个过程中,consumer提交的offset为本次请求的起始消费位置,即beginOffset;consume Queue中的offset定位了commitLog中具体消息的位置。

nextBeginOffset

对于consumer的消费请求处理(PullMessageProcessor.processRequest()),除了待消费的消息内容,broker在responseHeader(PullMessageResponseHeader)附带上当前消费队列的最小offset(minOffset)、最大offset(maxOffset)、及下次拉取的起始offset(nextBeginOffset,也就是上图里的consumerOffset)。

  • minOffset、maxOffset是当前消费队列consumeQueue记录的最小及最大的offset信息。
  • nextBeginOffset是consumer下次拉取消息的offset信息,即consumer对该consumeQueue的消费进度。

其中nextBeginOffset是consumer在下一轮消息拉取时offset的重要依据,无论当次拉取的消息消费是否正常,nextBeginOffset都不会回滚,这是因为rocketMQ对消费异常的消息的处理是将消息重新发回broker端的重试队列(会为每个topic创建一个重试队列,以%RERTY%开头),达到重试时间后将消息投递到重试队列中进行消费重试,不能因为当前消息消费失败而影响后面的消息消费。对消费异常的处理不是通过offset回滚,这使得客户端简化了offset的管理。

二,Consumer客户端

offset初始化

consumer启动过程中(Consumer主函数默认调用DefaultMQPushConsumer.start()方法)根据MessageModel(广播与集群模式)选择对应的offsetStore,然后调用offsetStore.load()对offset进行加载,LocalFileOffsetStore是对本地文件的加载,而RemotebrokerOffsetStore是没有本地文件的,因此load()方法没有实现。在rebalance完成对messageQueue的分配之后会对messageQueue对应的消费位置offset进行更新。

/** RebalanceImpl */
/**
doRebalance() -> rebalanceByTopic() -> updateProcessQueueTableInRebalance() 
-> computePullFromWhere()
*/
private boolean updateProcessQueueTableInRebalance(final String topic, final Set<MessageQueue> mqSet,
        final boolean isOrder) {
    // (省略部分代码)负载均衡获取当前consumer负责的消息队列后对processQueue进行筛选,删除processQueue不必要的messageQueue
    
    // 获取topic下consumer消息拉取列表,List<PullRequest>
    List<PullRequest> pullRequestList = new ArrayList<PullRequest>();
    for (MessageQueue mq : mqSet) {
        if (!this.processQueueTable.containsKey(mq)) {
                if (isOrder && !this.lock(mq)) {
                    log.warn("doRebalance, {}, add a new mq failed, {}, because lock failed", consumerGroup, mq);
                    continue;
                }

                // 删除messageQueue旧的offset信息
                this.removeDirtyOffset(mq);
                ProcessQueue pq = new ProcessQueue();
                // 获取nextOffset,即更新当前messageQueue对应请求的offset
                long nextOffset = this.computePullFromWhere(mq);
                if (nextOffset >= 0) {
                    ProcessQueue pre = this.processQueueTable.putIfAbsent(mq, pq);
                    if (pre != null) {
                        log.info("doRebalance, {}, mq already exists, {}", consumerGroup, mq);
                    } else {
                        log.info("doRebalance, {}, add a new mq, {}", consumerGroup, mq);
                        PullRequest pullRequest = new PullRequest();
                        pullRequest.setConsumerGroup(consumerGroup);
                        pullRequest.setNextOffset(nextOffset);
                        pullRequest.setMessageQueue(mq);
                        pullRequest.setProcessQueue(pq);
                        pullRequestList.add(pullRequest);
                        changed = true;
                    }
                } else {
                    log.warn("doRebalance, {}, add new mq failed, {}", consumerGroup, mq);
                }
            }
    }
    
}

Push模式下,computePullFromWhere()方法的实现类为RebalancePushImpl.class。根据配置信息consumeFromWhere进行不同的操作。ConsumeFromWhere 这个参数的含义是,初次启动从何处开始消费。更准确的表述是,如果查询不到消息消费进度时,从什么地方开始消费。

ConsumeFromWhere的类型枚举如下,其中有三个已经被标记为Deprecated(本文基于4.5.0)

public enum ConsumeFromWhere {
    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,
    CONSUME_FROM_FIRST_OFFSET,
    CONSUME_FROM_TIMESTAMP,
}
  • CONSUME_FROM_LAST_OFFSET 从最新的offset开始消费。
    获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset>=0,从lastOffset开始消费;如果lastOffset小于0说明是first start,没有offset信息,topic为重试topic时从0开始消费,否则请求获取该消息队列对应的消费队列consumeQueue的最大offset(maxOffset),从maxOffset开始消费
  • CONSUME_FROM_FIRST_OFFSET 从第一个offset开始消费。
    获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset>=0,从lastOffset开始消费;否则从0开始消费。
  • CONSUME_FROM_TIMESTAMP 根据时间戳请求查找offset。
    获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset>=0,从lastOffset开始消费;
    当lastOffset<0,如果为重试topic,获取consumeQueue的最大offset;否则获取ConsumeTimestamp(consumer启动时间),根据时间戳请求查找offset。

上述三种消费位置的设置流程有一个共同点,都请求获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset不小于0,则从lastOffset开始消费。这也是有时候设置了CONSUME_FROM_FIRST_OFFSET却不是从0开始重新消费的原因,rocketMQ减少了由于配置原因造成的重复消费。

至于具体使用哪一种方式需要根据自己的业务场景来定。我们可以考虑下面的几种情形:

  1. 如果一个消费者启动运行了一段时间,由于版本发布等原因需要先停掉消费者,代码更新后,再启动消费者时消费者还能使用上面这三种策略,从新的一条消息消费吗?如果是这样,在发版期间新发送的消息将全部丢失,这显然是不可接受的,要从上一次开始消费的时候消费,才能保证消息不丢失。
  2. 如果对于一个设置为 CONSUME_FROM_FIRST_OFFSET 的运行良久的消费者,当前版本的业务逻辑进行了重大重构,而且业务希望是从最新的消息开始消费。如果使用了下面的设置是不会成功的。

    consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
    

    (第2点有点不明白)
    可以使用 RocketMQ 提供的重置位点,其命令如下:

    sh ./mqadmin resetOffsetByTime -n 127.0.0.1:9876  -g CID_CONSUMER_TEST -t TopicTest -s now
    

    或者在控制台重置位点。

还有在有的情形下,CONSUME_FROM_LAST_OFFSET不生效,可以参考rocketmq集群消费模式下,消费offset的管理文章里的说明。

offset提交更新

consumer从broker拉取消息后,会将消息的扩展信息MessageExt存放到ProcessQueue的属性TreeMap<Long, MessageExt> msgTreeMap中,key值为消息对应的queueOffset,value为扩展信息(包括queueID等)。并发消费模式下(Concurrently),获取的待消费消息会分批提交给消费线程进行消费,默认批次为1,即每个消费线程消费一条消息。消费完成后调用ConsumerMessageConcurrentlyService.processConsumeResult()方法对结果进行处理:消费成功确认ack,消费失败发回broker进行重试。之后便是对offset的更新操作。
首先是调用ProcessQueue.removeMessage()方法,将已经消费完成的消息从msgTreeMap中根据queueOffset移除,然后判断当前msgTreeMap是否为空,不为空则返回当前msgTreeMap第一个元素,即offset最小的元素,否则返回-1。
如果removeMessage()返回的offset大于0,则更新到offsetTable中。offsetTable的结构为ConcurrentMap<MessageQueue, AtomicLong> offsetTable,是一个线程安全的Map,key为MessageQueue,value为AtomicLong对象,值为offset,记录当前messageQueue的消费位移。

/** ConsumeMessageConcurrentlyService.class */
public void processConsumeResult(
        final ConsumeConcurrentlyStatus status,final ConsumeConcurrentlyContext context,final ConsumeRequest consumeRequest) {
    // .... (省略部分代码)根据消费结果判断是否需要发回broker重试
    
    // 在msgTreeMap中删除msg,标记当前消息已被消费,msgTreeMap不为空返回当前msgTreeMap中最小的offset
    long offset = consumeRequest.getProcessQueue().removeMessage(consumeRequest.getMsgs());
    
    // 更新offsetTable中的消费位移,offsetTable记录每个messageQueue的消费进度
    // updateOffset()的最后一个参数increaseOnly为true,表示单调增加,新值要大于旧值
    if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) {
        this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(consumeRequest.getMessageQueue(), offset, true);
     }
}

/** ProcessQueue.class */
public long removeMessage(final List<MessageExt> msgs) {
        long result = -1;
        final long now = System.currentTimeMillis();
        try {
            this.lockTreeMap.writeLock().lockInterruptibly();
            this.lastConsumeTimestamp = now;
            try {
                if (!msgTreeMap.isEmpty()) {
                    result = this.queueOffsetMax + 1;
                    int removedCnt = 0;
                    // // 从msgTreeMap中删除该批次的msg
                    for (MessageExt msg : msgs) {
                        MessageExt prev = msgTreeMap.remove(msg.getQueueOffset());
                        if (prev != null) {
                            removedCnt--;
                            msgSize.addAndGet(0 - msg.getBody().length);
                        }
                    }
                    msgCount.addAndGet(removedCnt);

                    // 删除后当前msgTreeMap不为空,返回第一个元素,即最小的offset
                    if (!msgTreeMap.isEmpty()) {
                        result = msgTreeMap.firstKey();
                    }
                }
            } finally {
                this.lockTreeMap.writeLock().unlock();
            }
        } catch (Throwable t) {
            log.error("removeMessage exception", t);
        }

        return result;
    }

/** RemoteBrokerOffsetStore */
public void updateOffset(MessageQueue mq, long offset, boolean increaseOnly) {
        if (mq != null) {
            AtomicLong offsetOld = this.offsetTable.get(mq);
            if (null == offsetOld) {
                // offsetTable中不存在mq对应的记录
                // putIfAbsent 如果传入key对应的value已存在,则返回存在的value,不替换;如果不存在,则新增,返回null
                offsetOld = this.offsetTable.putIfAbsent(mq, new AtomicLong(offset));
            }

            // offsetTable存在记录,替换,这里increaseOnly为true,offsetOld<offset才替换
            if (null != offsetOld) {
                if (increaseOnly) {
                    MixAll.compareAndIncreaseOnly(offsetOld, offset);
                } else {
                    offsetOld.set(offset);
                }
            }
        }
    }

到这里一条消息的消费流程已经结束,offset更新到了本地缓存offsetTable,而将offset上传到broker是由定时任务执行的。MQClientInstance.start()会启动客户端相关的定时任务,包括NameService通信、offset提交等。

/** MQClientInstance.startScheduledTask() */
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {

            @Override
            public void run() {
                try {
                    // 提交offset至broker
                    MQClientInstance.this.persistAllConsumerOffset();
                } catch (Exception e) {
                    log.error("ScheduledTask persistAllConsumerOffset exception", e);
                }
            }
        }, 1000 * 10, this.clientConfig.getPersistConsumerOffsetInterval(), TimeUnit.MILLISECONDS);
  • LocalFileOffsetStore模式下,将offset信息转化成json保存到本地文件中;
  • RemoteBrokerOffsetStore则offsetTable将需要提交的MessageQueue的offset信息通过MQClientAPIImpl提供的接口updateConsumerOffsetOneway()提交到broker进行持久化存储。

当应用正常关闭时,consumer的shutdown()方法会主动触发一次持久化offset到broker的操作。

client对offset的更新是在消息消费完成后将offset更新到offsetTable,再由定时任务进行持久化。这个过程有需要注意的地方:

  1. 由于是先消费再更新offset,因此存在消费完成后更新offset失败,但这种情况出现的概率比较低,更新offset只是写到缓存中,是一个简单的内存操作,出错的可能性较低。
  2. 由于offset先存到内存中,再由定时任务每隔10ms提交一次,存在丢失的风险,比如当前client宕机等,从而导致更新后的offset没有提交到broker,再次负载时之前已消费的消息可能还会再次被拉取到客户端从而造成重复消费。因此consumer的消费业务逻辑需要保证幂等性。

并发消费时offset的更新

问题:consumer从broker拉取的待消费消息时批量的(默认情况下pullBatchSize=32),并发消费时,offset的更新不是按大小顺序的,比如拉取消息m1到m10,m1可能是最后消费完成的,那提交的offset的正确性如何保证?m10 offset的更新不会导致m1会误认为已消费完成。
上一小节提到消费完成后,会将线程消费的批次消息从msgTreeMap中删除,并返回当前msgTreeMap的第一个元素,也就是拉取批次最小的offset,offsetTable更新的offset一直会是拉取批次中未消费的最小的offset值。也就是m1未消费完成,m10消费完成的情况下,更新到offsetTable的当前messageQueue的消费进度为m1对应的offset值。

image.png

因此,offsetTable中存放的可能不是messageQueue真正消费的offset的最大值,但是consumer拉取消息时使用的是上一次拉取请求返回的nextBeginOffset,并不是依据offsetTable,正常情况下不会重复拉取数据。当发生宕机等异常时,与offsetTable未提交宕机异常一样,需要通过业务流程来保证幂等性。业务流程的幂等性是rocketMQ一直强调的。

本文转载于rocketMQ -- offset管理
也参考了:DefaultMQPushConsumer 使用示例与注意事项
通过这三个文件彻底搞懂rocketmq的存储原理


步履不停
38 声望13 粉丝

好走的都是下坡路