美文网首页
rocketMQ -- offset管理

rocketMQ -- offset管理

作者: 晓鑫_ | 来源:发表于2020-02-12 13:55 被阅读0次

    rocketMQ--offset

    offset

    在rocketMQ中,offset用来管理每个消费队列的不同消费组的消费进度。对offset的管理分为本地模式和远程模式,本地模式是以文本文件的形式存储在客户端,而远程模式是将数据保存到broker端,对应的数据结构分别为LocalFileOffsetStore和RemoteBrokerOffsetStore。
    默认情况下,当消费模式为广播模式时,offset使用本地模式存储,因为每条消息会被所有的消费者消费,每个消费者管理自己的消费进度,各个消费者之间不存在消费进度的交集;当消费模式为集群消费时,则使用远程模式管理offset,消息会被多个消费者消费,不同的是每个消费者只负责消费其中部分消费队列,添加或删除消费者,都会使负载发生变动,容易造成消费进度冲突,因此需要集中管理。同时,RocketMQ也提供接口供用户自己实现offset管理(实现OffsetStore接口)。
    生产环境上一般使用集群模式,本文主要记录集群模式下offset的管理,即RemoteBrokerOffsetStore。

    broke端

    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的数据结构为ConcurrentMap,是一个线程安全的容器,key的形式为topic@group(每个topic下不同消费组的消费进度),value也是一个ConcurrentMap,key为queueId,value为消费位移(这里不是offset而是位移)。通过对全局ConsumerOffsetManager对象就可以对各个topic下不同消费组的消费位移进行获取与管理。

    /**ConsumerOffsetManager.offsetTable*/
    private ConcurrentMap<String/* topic@group */, ConcurrentMap<Integer, Long>> offsetTable =
            new ConcurrentHashMap<String, ConcurrentMap<Integer, Long>>(512);
    
    /**ConsumerOffsetManager.decode*/
    public void decode(String jsonString) {
            if (jsonString != null) {
                // 序列化成功后复制给全局ConsumerOffsetManager对象
                ConsumerOffsetManager obj = RemotingSerializable.fromJson(jsonString, ConsumerOffsetManager.class);
                if (obj != null) {
                    this.offsetTable = obj.offsetTable;
                }
            }
        }
    

    commitLog与offset

    如下图所示,producer发送消息到broker之后,会将消息具体内容持久化到commitLog文件中,再分发到topic下的消费队列consume Queue,消费者提交消费请求时,broker从该consumer负责的消费队列中根据请求参数起始offset获取待消费的消息索引信息,再从commitLog中获取具体的消息内容返回给consumer。在这个过程中,consumer提交的offset为本次请求的起始消费位置,即beginOffset;consume Queue中的offset定位了commitLog中具体消息的位置。
    consume Queue中每个消息索引信息长度为20bytes,包括8位长度的offset,记录commitLog中消息内容的位移;4位长度的size,记录具体消息内容的长度;8位长度的tagHashCode,记录消息的tag的哈希值(订阅时如果指定tag,会根据HashCode快速查找订阅的消息)


    临时文件.png临时文件.png

    nextBeginOffset

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

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

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

    client端

    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的类型枚举如下,其中有三个已经被标记为Deprecated(基于rocketmq-all 4.6.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

    获取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减少了由于配置原因造成的重复消费。

    对于lastOffset、maxOffset、时间戳查找offset都是通过MQClientAPIImpl提供的接口进行查询的,MQClientAPIImplclient对broker请求的封装类,使用Netty进行异步请求,对应的RequestCode分别为RequestCode.QUERY_CONSUMER_OFFSET、RequestCode.GET_MAX_OFFSET、RequestCode.SEARCH_OFFSET_BY_TIMESTAMP。

    /** RebalancePushImpl */
    public long computePullFromWhere(MessageQueue mq) {
            long result = -1;
            final ConsumeFromWhere consumeFromWhere = this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeFromWhere();
            final OffsetStore offsetStore = this.defaultMQPushConsumerImpl.getOffsetStore();
            switch (consumeFromWhere) {
                case CONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST:
                case CONSUME_FROM_MIN_OFFSET:
                case CONSUME_FROM_MAX_OFFSET:
                case CONSUME_FROM_LAST_OFFSET: {
                    // 从broker获取当前消费队列offset
                    long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
                    if (lastOffset >= 0) {
                        result = lastOffset;
                    }
                    // First start,no offset
                    else if (-1 == lastOffset) {
                        if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                            result = 0L;
                        } else {
                            try {
                                // 获取消费队列最大offset
                                result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);
                            } catch (MQClientException e) {
                                result = -1;
                            }
                        }
                    } else {
                        result = -1;
                    }
                    break;
                }
                case CONSUME_FROM_FIRST_OFFSET: {
                    // 先查询当前消费队列消费进度
                    long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
                    if (lastOffset >= 0) {
                        result = lastOffset;
                    }
                    // 当前消费队列消费进度小于0,则从0开始
                    else if (-1 == lastOffset) {
                        result = 0L;
                    } else {
                        result = -1;
                    }
                    break;
                }
                case CONSUME_FROM_TIMESTAMP: {
                    // 同样也是先查询当前消费队列消费进度
                    long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
                    if (lastOffset >= 0) {
                        result = lastOffset;
                    } else if (-1 == lastOffset) {
                        if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                            try {
                                result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);
                            } catch (MQClientException e) {
                                result = -1;
                            }
                        } else {
                            try {
                                // 获取consumer启动时间
                                long timestamp = UtilAll.parseDate(this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeTimestamp(),
                                    UtilAll.YYYYMMDDHHMMSS).getTime();
                                // 根据时间戳获取offset信息
                                result = this.mQClientFactory.getMQAdminImpl().searchOffset(mq, timestamp);
                            } catch (MQClientException e) {
                                result = -1;
                            }
                        }
                    } else {
                        result = -1;
                    }
                    break;
                }
    
                default:
                    break;
            }
    
            return result;
        }
    

    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,再由定时任务进行持久化。这个过程有需要注意的地方。

    • 由于是先消费再更新offset,因此存在消费完成后更新offset失败,但这种情况出现的概率比较低,更新offset只是写到缓存中,是一个简单的内存操作,出错的可能性较低。
    • 由于offset先存到内存中,再由定时任务每隔10s提交一次,存在丢失的风险,比如当前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.pngimage.png

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

    相关文章

      网友评论

          本文标题:rocketMQ -- offset管理

          本文链接:https://www.haomeiwen.com/subject/bwtcfhtx.html