我正在参加「掘金·启航计划」
本篇主要介绍RocketMQ消息消费流程
1、概念介绍
1.1、Consumer获取消息的方式
常见的两种方式:
方式一:Broker与Consumer建立长连接,Broker接收到消息后主动推送到Consumer
方式二:Consumer定时轮询Broker,主动获取消息
常见的两种方式可能存在的问题:
方式一 长连接:
- Consumer一直被动接收Broker推送的消息,如果本身消费消息很慢,会造成后续大批量消息挤压,超时
方式二 定时轮询(短轮询):
- 比如Consumer每0.5秒请求Broker主动拉取消息,很有可能Broker几个小时都没有消息,太多无用的请求
RocketMQ获取消息的方式:短连接-长轮训
简单点说是,Consumer主动从Broker拉取消息,Broker没有消息不会立即返回,会将连接挂起一段时间,等有消息后再唤醒连接返回消息,如果到指定时间一直没有消息则会返回。
短连接-长轮训的好处:
- Consumer主动拉取消息,控制消费速度,不会有大批量消息挤压问题
- Broker没有消息后会将连接挂起,不会造成频繁的请求浪费
我们实际使用时,消费消息会实现MessageListener子类接口,感觉消息像是主动推送过来的,其实是Consumer拉取回来后,再调用MessageListener接口,供我们进行消费
1.2、消费模式
通过获取消息方式,我们知道Consumer在保证自己可以消费的前提下,主动去Broker拉取消息。我们想到简单的实现方式:一个死循环,拉取消息回来然后消费,消费完再继续拉取。然后这种方式会将拉取、消费逻辑全部耦合在一起,不方便维护及扩展。
Consumer消费模式:生产者-消费者模式
有专门负责生产拉取请求的线程,有专门负责消费拉取请求的线程,两者之间互不干涉。
2、流程分析
2.1、拉取请求
PullRequest:拉取请求的封装,定义了拉取消息需要的参数,以及存放拉取回来的消息。
public class PullRequest implements MessageRequest {
// 消费组
private String consumerGroup;
// 消息队列数据
private MessageQueue messageQueue;
// 消息处理队列
private ProcessQueue processQueue;
// 下次消息拉取偏移量
private long nextOffset;
private boolean previouslyLocked = false;
}
/*
* 消息队列
*/
public class MessageQueue {
// broker名称
private String brokerName;
// topic名称
private String topic;
// 队列Id
private int queueId;
}
/*
* 处理队列
*/
public class ProcessQueue {
// 针对msgTreeMap的读写锁
private final ReadWriteLock treeMapLock = new ReentrantReadWriteLock();
// 存放拉取回来,未被消费的消息
private final TreeMap<Long, MessageExt> msgTreeMap = new TreeMap<Long, MessageExt>();
// 未被消费的消息总数
private final AtomicLong msgCount = new AtomicLong();
// 未被消费的消息总大小
private final AtomicLong msgSize = new AtomicLong();
// 顺序消息第2把锁:锁PrcessQueue
// 负载均衡生产拉取请求、消费消息时会使用
private final Lock consumeLock = new ReentrantLock();
// 顺序消息时使用
private final TreeMap<Long, MessageExt> consumingMsgOrderlyTreeMap = new TreeMap<Long, MessageExt>();
private final AtomicLong tryUnlockTimes = new AtomicLong(0);
private volatile long queueOffsetMax = 0L;
// 处理队列是否被移除
private volatile boolean dropped = false;
// 顺序消息第1把锁:分布式锁,每30s会去Broker获取锁,同一个messageQueue只能被一个Consume获取
// 拉取消息、消费消息时会判断
private volatile boolean locked = false;
// 当前是否正在消费【待办,什么时候被再次设为false】
private volatile boolean consuming = false;
}
2.2、生产拉取请求
Consumer生产拉取请求不是随便指定消息队列,需要根据Group下的Consumer实例数与Topic的消息队列数做负载,选择一个消息队列,进行拉取。
举个例子:
TopicA下有4个消息队列(q1~q4),ConsumerA(集群模式)订阅TopicA,同Group下有2个实例(C1、C2),因为要做到消费负载均衡,C1、C2实例会平分q1~q4队列进行消费,结果可能C1实例有q1、q2两个拉取请求,C2实例有q3、q4两个拉取请求。
生产拉取请求具体实现:
RebalanceService:负责负载均衡,生产拉取请求
执行流程:
-
RebalaceService#run,每20s进行一次负载均衡
-
MQClientInstance#doRebalance,循环每个消费组下的mqConsumerInner#doRebalance(MQConsumerInner是Consumer内部的实现类,MQConsumer暴露给外部使用)
-
mqConsumerInner.rebalanceImpl#doRebalance,循环topic下的订阅信息SubscriptionData( Consumer订阅的topic信息与重试topic:%RETRY%+GROUPNAME)
-
rebalanceImpl#rebalanceByTopic,根据Topic负载均衡
- 获取topic下全部MessageQueue(如果是集群会获取所有消息队列),根据topic,consumerGroup获取到所有的client
- 根据不同的负载策略AllocateMessageQueueStrategy,得到当前实例分配的MessageQueue
- 移除本地不存在的ProcessQueue,设置dropped为true。如果是顺序消费则不会删除
- 新创建PullRequest,并分发#dispatchPullRequest,DefaultMQPushConsumerImpl#executePullRequestImmediately,将拉取请求设置到 拉取队列 PullMessageService.messageRequestQueue中等待被消费
2.3、消费拉取请求
PullMessageService: 负责消费拉取请求
执行流程:
-
PullMessageService#run,while循环中消费 拉取请求队列中的拉取请求,如果没有则阻塞
-
如果有拉取请求,通过MQClinetInstance#selectConsumer获取到MQConsumerInner来拉取消息
-
拉取消息流程,DefaultMQPushConsumerImpl#pullMessage
-
判断PullRequest.processQueue 是否已被移除
-
判断是否需要限流,以下情况需要进行消息限流:
- 判断堆积消费数量是否大于1000,取值ProcessQueue.msgCount。延迟50ms再放入拉取请求队列
- 判断堆积消息大小是否大于100M,取值ProcessQueue.msgSize。延迟50ms再放入拉取请求队列
- 非顺序消费,判断最大 - 最小偏移量是否大于2000(避免造成大量重复消费,消费位点按照最小offset同步给broker),ProcessQueue.msgTreeMap 最后一个 - 第一个。延迟50ms再放入拉取请求队列
- 顺序消息,判断是否获取到消费第一把锁。没有的话延迟3s,将请求放回拉取请求队列
-
获取topic对用的订阅信息
-
创建拉取回调,PullCallback
-
封装请求参数,如:MessageQueue信息、消息偏移量、一次拉取多少条(32)、Broker挂起时间(15s)等
-
通过MQClientAPIImpl#pullMessage,向broker发送消息拉取请求
- 同步模式:RemotingClient#invokeSync,同步等待结果
- 异步模式:RemotingClient#invokeAsync,有结果后调用PullCallback
-
2.4、处理拉取回来的消息
PullCallback处理拉取回来的消息,分为两种情况,拉取成功与失败。
拉取失败:
拉取请求再次放到拉取请求队列中,等待下次消费。
拉取成功:
拉取成功分为几种状态:
- FOUND: 有符合的消息
- NO_NEW_MSG、NO_MATCHED_MSG: 没有新消息、消息在broker端被过滤掉,没有匹配的消息
- OFFSET_ILLEGAL:当请求的offset小于minOffset时,会返回该状态
不同拉取状态处理方式:
NO_NEW_MSG、NO_MATCHED_MSG:拉取请求再次放到拉取请求队列中,等待下次消费。
OFFSET_ILLEGAL:将ProcessQueue移除
FOUND:
-
将拉取返回的消息偏移量,更新到拉取请求大下次拉取偏移量中。
-
将拉取回来消息放到处理队列中,ProcessQueue#putMessage
- 将消息新增到msgTreeMap中
- 增加未消费的消息数量,msgCount
- 增加未消费的消息大小,msgSize
- 如果ProcessQueue没有正在消费,consuming为false,则返回dispatchToConsume=true且标记consuming=true
-
将拉取回来的消息,封装成ConsumeRequest,提交到线程池中进行处理,ConsumeMessageService#submitConsumeRequest
不同模式下dispatchToConsume值处理方式不同:
- 并发模式:不会关心dispatchToConsume的值
- 顺序模式:根据dispatchToConsume判断,如果是false则不进行处理
-
再次将拉取请求设置到拉取请求队列
ConsumeRequest处理流程
并发ConsumeRequest处理流程:
-
判断队列是否已被移出,ProcessQueue.dropped
-
消费消息:MessageListener#consumeMessage
-
处理消费结果,#processConsumeResult
-
是否需要再次消费消息,当#consumeMessage返回:重新消费标志、null、程序异常时
- Consumer发送延迟消息,Broker会为消费组生成%RETRY%consumerGroup的Topic(Consumer启动时会额外定义这个Topic)
- 延迟时间为10s(设置延迟级别delayLevel=0,broker端会+3),最多重试16次,之后会进入死信队列
- 延迟消息发送失败,等待5s后,本地再次消费ConsumeMessageConcurrentlyService#submitConsumeRequest
-
同步消费进度,OffsetStore#updateOffset,存储到一个Map中offsetTable,定时同步到broker
-
顺序ConsumeRequest处理流程:
-
判断队列是否已被移出,ProcessQueue.dropped
-
顺序消费第3把锁,对MessageQueue进行加锁,防止多线程同时处理一个MessageQueue
-
获取顺序消费第2把锁,对ProcessQueue进行加锁,ProcessQueue.consumeLock
-
消费消息
-
处理消费结果
- 消息重试,不会发送延迟消息,等待1s后,本地再次消费ConsumeMessageOrderlyService#submitConsumeRequest
- 同步消费进度,OffsetStore#updateOffset,存储到一个Map中offsetTable,定时同步到broker