RocketMQ源码分析----Consumer消费进度相关

在Consumer消费的时候总有几个疑问:

  • 消费完成后,这个消费进度存在哪里
  • 消费完成后,还没保存消费进度就挂了,会不会导致重复消费

Consumer

消费进度保存

消费完成后,会返回一个ConsumeConcurrentlyStatus.CONSUME_SUCCESS告诉MQ消费成功,以MessageListener的consumeMessage为入口分析。
消费的时候,是以ConsumeRequest类为Runnable对象,在线程池中进行处理的,即ConsumeRequest的run方法会处理这个状态

        @Override
        public void run() {

            //....
            status = listener.consumeMessage(Collections.unmodifiableList(msgs), context);
            // 如果这个ProcessQueue废弃了,则不处理
            if (!processQueue.isDropped()) {
                ConsumeMessageConcurrentlyService.this.processConsumeResult(status, context, this);
            }
        }

在消费完成后,将status交给processConsumeResult处理,代码如下

    public void processConsumeResult(//
                                     final ConsumeConcurrentlyStatus status, //
                                     final ConsumeConcurrentlyContext context, //
                                     final ConsumeRequest consumeRequest//
    ) {
         //....消费成功或者失败的处理
        
        // 将这批消息从ProcessQueue中移除,代表消费完毕,并返回当前ProcessQueue中的消息最小的offset
        long offset = consumeRequest.getProcessQueue().removeMessage(consumeRequest.getMsgs());
        if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) {
            // 更新消费进度
            this.defaultMQPushConsumerImpl.getOffsetStore()
                .updateOffset(consumeRequest.getMessageQueue(), offset, true);
        }
    }

在分析ProcessQueue的时候,说过removeMessage返回有两种情况:

  1. 如果移除这批消息之后已经没有消息了,那么返回ProcessQueue中最大的offset+1
  2. 如果还有消息,那么返回treeMap中最小的key,即未消费的消息中最小的offset

getOffsetStore返回RemoteBrokerOffsetStore,看下其实现

    @Override
    public void updateOffset(MessageQueue mq, long offset, boolean increaseOnly) {
        if (mq != null) {
            // 通过MessageQueue获取本地的对应的消费进度
            AtomicLong offsetOld = this.offsetTable.get(mq);
            if (null == offsetOld) {
                offsetOld = this.offsetTable.putIfAbsent(mq, new AtomicLong(offset));
            }

            if (null != offsetOld) {
                //increaseOnly 为false则直接覆盖
                //increaseOnly为true则会判断更新的值比老的值大才会进行更新
                if (increaseOnly) {
                    MixAll.compareAndIncreaseOnly(offsetOld, offset);
                } else {
                    offsetOld.set(offset);
                }
            }
        }
    }

这里的increaseOnly参数根据不同的情况传入不同的值,有些情况下会出现并发修改的情况,那么需要传入true,内部会进行CAS的操作,能保证正确的赋值,而一些场景下,只需要进行直接覆盖或者说没有并发修改的问题那么传入false就行了。

消费进度持久化

offsetTable是一个Map,其保存了消费进度,这只一个内存的结构,在Consumer启动的时候,会启动一个定时任务将本地的数据同步到broker,每persistConsumerOffsetInterval(默认为5)秒进行一次操作

    // mqs为需要持久化的队列集合
    public void persistAll(Set<MessageQueue> mqs) {
        if (null == mqs || mqs.isEmpty())
            return;

        final HashSet<MessageQueue> unusedMQ = new HashSet<MessageQueue>();
        if (mqs != null && !mqs.isEmpty()) {
            // 遍历本地的消费进度
            for(Map.Entry<MessageQueue, AtomicLong> entry:this.offsetTable.entrySet()){
                MessageQueue mq = entry.getKey();
                AtomicLong offset = entry.getValue();
                if (offset != null) {
                    // 如果该队列在需要持久化的队列中
                    if (mqs.contains(mq)) {
                        try {
                            // 将消费进度发送到broker
                            this.updateConsumeOffsetToBroker(mq, offset.get());
                        } catch (Exception e) {
                            log.error("updateConsumeOffsetToBroker exception, " + mq.toString(), e);
                        }
                    } else {//废弃的消费进度
                        unusedMQ.add(mq);
                    }
                }
            }
        }
        // 如果有废弃的MQ,则将其消费进度废弃
        if (!unusedMQ.isEmpty()) {
            for (MessageQueue mq : unusedMQ) {
                this.offsetTable.remove(mq);
            }
        }
    }

传入的是当前Consumer分配的MessageQueue列表,rebalance之后,可能分配的MessageQueue已经变化,所以offsetTable里有些消费进度的队列时不需要的,所以将它的消费进度废弃
updateConsumeOffsetToBroker方法就是简单的网络请求,将offset发送给Broker

消费进度提交

除了定时提交消费进度之外,在拉取消息的时候,会顺便将本地的消费进度一起传到broker,例如查看拉取消息的方法DefaultMQPushConsumerImpl#pullMessage中的一段代码

boolean commitOffsetEnable = false;
        long commitOffsetValue = 0L;
        // 集群消费模式
        if (MessageModel.CLUSTERING == this.defaultMQPushConsumer.getMessageModel()) {
            // 通过offsetStore获取当前消费进度
            // ReadOffsetType.READ_FROM_MEMORY表示从本地获取(即offsetTable)
            commitOffsetValue = this.offsetStore.readOffset(pullRequest.getMessageQueue(), ReadOffsetType.READ_FROM_MEMORY);
            if (commitOffsetValue > 0) {//
                // 传给Broker,让其判断是否需要保存消费进度
                commitOffsetEnable = true;
            }
        }
        // 构造一些标志位,这里主要看commitOffsetEnable值
        // 将commitOffsetEnable放到一个int类型的值中,让broker判断是否需要保存消费进度
                int sysFlag = PullSysFlag.buildSysFlag(//
                commitOffsetEnable, // commitOffset
                true, // suspend
                subExpression != null, // subscription
                classFilter // class filter
        );
        //....
            // 通过拉取消息请求,将commitOffsetValue和sysFlag传给broker
            this.pullAPIWrapper.pullKernelImpl(//
                    pullRequest.getMessageQueue(), // 1
                    subExpression, // 2
                    subscriptionData.getSubVersion(), // 3
                    pullRequest.getNextOffset(), // 4
                    this.defaultMQPushConsumer.getPullBatchSize(), // 5
                    sysFlag, // 6
                    commitOffsetValue, // 7
                    BrokerSuspendMaxTimeMillis, // 8
                    ConsumerTimeoutMillisWhenSuspend, // 9
                    CommunicationMode.ASYNC, // 10
                    pullCallback// 11
            );

具体broker对消费进度的处理看后面分析

Broker

消费进度保存

RocketMQ的网络请求都有一个RequestCode,更新消费进度的Code为UPDATE_CONSUMER_OFFSET,通过查到其使用的地方,找到对应的Processor为ClientManageProcessor,其processRequest处理对应的请求

    public RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand request)
            throws RemotingCommandException {
        switch (request.getCode()) {
            case RequestCode.HEART_BEAT:
                return this.heartBeat(ctx, request);
            case RequestCode.UNREGISTER_CLIENT:
                return this.unregisterClient(ctx, request);
            case RequestCode.GET_CONSUMER_LIST_BY_GROUP:
                return this.getConsumerListByGroup(ctx, request);
            case RequestCode.UPDATE_CONSUMER_OFFSET:
                return this.updateConsumerOffset(ctx, request);
            case RequestCode.QUERY_CONSUMER_OFFSET:
                return this.queryConsumerOffset(ctx, request);
            default:
                break;
        }
        return null;
    }

更新消费进度的方法为updateConsumerOffset,里面解析了请求体之后又调用了ConsumerOffsetManager.commitOffset方法

    public void commitOffset(final String clientHost, final String group, final String topic, final int queueId, final long offset) {
        // topic@group 
        String key = topic + TOPIC_GROUP_SEPARATOR + group;
        this.commitOffset(clientHost, key, queueId, offset);
    }

    private void commitOffset(final String clientHost, final String key, final int queueId, final long offset) {
        ConcurrentHashMap<Integer, Long> map = this.offsetTable.get(key);
        if (null == map) {
            map = new ConcurrentHashMap<Integer, Long>(32);
            map.put(queueId, offset);
            this.offsetTable.put(key, map);
        } else {
            Long storeOffset = map.put(queueId, offset);
            if (storeOffset != null && offset < storeOffset) {
                log.warn("[NOTIFYME]update consumer offset less than store. clientHost={}, key={}, queueId={}, requestOffset={}, storeOffset={}",
               clientHost, key, queueId, offset, storeOffset);
            }
        }
    }

逻辑也很简单就不多说了,有意思的是,Broker的保存消费进度的结构和Consumer类似,Broker多了一个维度,因为Broker接收的是所有消费者的进度,而Consumer保存的是自己的
在Consumer的消费进度上报到Broker之后,Broker只是保存到内存,这并不可靠,大概也能猜出,和Consumer一样,也有一个定时任务将消费进度持久化。这时,先看下ConsumerOffsetManager这个类的继承关系,他的父类是ConfigManager,这个东西很重要,是几个重要配置信息持久化类,看下其继承关系:


image.png

分别是订阅关系管理,消费进度管理,Topic信息管理,和延迟队列信息管理,这4个配置信息都需要通过ConfigManager去持久化和加载,看下ConfigManager的几个方法

public abstract class ConfigManager {
    // 将对象转换成json串
    public abstract String encode();

    //将文件里内容(json格式)的转换成对象
    public boolean load() {
        String fileName = null;
            // 获取文件地址
            fileName = this.configFilePath();
            // 将文件里的内容读取出来
            String jsonString = MixAll.file2String(fileName);
            // json转换成指定对象的数据
            this.decode(jsonString);
    }
    // 配置文件地址
    public abstract String configFilePath();
    
    // 与load类似
    private boolean loadBak() {
        String fileName = null;
            fileName = this.configFilePath();
            String jsonString = MixAll.file2String(fileName + ".bak");
            this.decode(jsonString);
        return true;
    }
    // json转换成指定对象的数据
    public abstract void decode(final String jsonString);
    // 将对象里的数据转换成json并持久化到configFilePath()文件中
    public synchronized void persist() {
        String jsonString = this.encode(true);
            String fileName = this.configFilePath();
                MixAll.string2File(jsonString, fileName);
        
    }

    public abstract String encode(final boolean prettyFormat);

那么ConsumerOffsetManager会实现encode和decode方法并在某个地方定时调用persist方法,查看其使用的地方,找到BrokerController的initialize方法,有段定时任务如下:

this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
        try {
            BrokerController.this.consumerOffsetManager.persist();
        } catch (Throwable e) {
            log.error("schedule persist consumerOffset error.", e);
        }
    }
}, 1000 * 10, this.brokerConfig.getFlushConsumerOffsetInterval(), TimeUnit.MILLISECONDS);

可以看到,每flushConsumerOffsetInterval(默认5000)毫秒会进行一次持久化

拉取消息的时候保存消费进度

拉取消息的Code为RequestCode.PULL_MESSAGE,对应的Processor为PullMessageProcessor,找到其中消费进度处理的地方

// 上面说的consumer传过来的commitOffsetEnable
// 当Consumer本地消费进度大于0的时候这个参数为true
final boolean hasCommitOffsetFlag = PullSysFlag.hasCommitOffsetFlag(requestHeader.

// brokerAllowSuspend在处理消息请求的时候为true,hold请求自己处理是false
boolean storeOffsetEnable = brokerAllowSuspend;
storeOffsetEnable = storeOffsetEnable && hasCommitOffsetFlag;
// Master才需要保存进度,slave只是同步broker的消息
storeOffsetEnable = storeOffsetEnable
        && this.brokerController.getMessageStoreConfig().getBrokerRole() != BrokerRole.SLAVE;
if (storeOffsetEnable) {
    this.brokerController.getConsumerOffsetManager().commitOffset(
        RemotingHelper.parseChannelRemoteAddr(channel),
        requestHeader.getConsumerGroup(), 
        requestHeader.getTopic(), 
        requestHeader.getQueueId(), 
        requestHeader.getCommitOffset());//consumer传上来的offset
}

总的来说:
当broker为master的时候,且Consumer消费进度大于0则在拉取消息的时候顺便将消费进度保存到broker

问题分析

重复消费问题

在ProcessQueue的removeMessage的第二种情况有个问题,假设有如下情况:
批量拉取了4条消息ABCD,分别对应的offset为400|401|402|403,此时consumeBatchSize(批量消费数量,默认为1,即一条一条消费),那么会分4个线程去消费这几个消息,出现下面消费次序
消费D -> removeMessage -> 返回400(情况2)
消费C -> removeMessage -> 返回400(情况2)
消费B -> removeMessage -> 返回400(情况2)
消费A -> removeMessage -> 返回404(情况1)

在消费A之前,本地消费进度持久化到Broker之后,应用宕机了,那么此时Broker保存的是offset=400(准确来说,在消费完A且保存消费进度到broker之前,offset都是400)。那么会有什么问题呢?
先假设消费完DCB且消费进度上传完成宕机,然后重启应用,这时候会先从broker获取应该从哪里消费(),因为DCB消费完成后都是保存400这个消费进度,那么返回的是400,这时候consumer会请求offset为400的消费,到这里,已经重复消费了DCB。

消费进度保存在哪里

  1. consumer保存在内存,定时上传broker
  2. broker保存在内存,定时刷新到磁盘文件

:以上没有特别声明的都是并发消费模式

整体流程图

image.png
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容

  • 姓名:周小蓬 16019110037 转载自:http://blog.csdn.net/YChenFeng/art...
    aeytifiw阅读 34,526评论 13 425
  • metaq是阿里团队的消息中间件,之前也有用过和了解过kafka,据说metaq是基于kafka的源码改过来的,他...
    菜鸟小玄阅读 32,536评论 0 14
  • consumer 1.启动 有别于其他消息中间件由broker做负载均衡并主动向consumer投递消息,Rock...
    veShi文阅读 4,862评论 0 2
  • 连日数阴晴, 新芽临寒风。 春晓寂寂冷, 晨鹊恰恰鸣。
    枫之然阅读 117评论 6 16
  • 在广袤的森林尽头,伫立着一座古老巍峨的城堡。城堡里住着一对姐妹,姐姐凯莉和妹妹雪莉。 雪莉有一双乌黑明亮的大眼睛,...
    喜乐圆子阅读 370评论 1 9