前两篇文章我们已经知道了消息是如何存储和发送的,这篇我们就学习下消费者消费的一些细节。
消息是存储在Broker上的,那么消费者是如何获取的呢?是Broker主动推给消费者的还是消费者主动去Broker上拉的呢?
一、推(push)VS 拉(pull)
先举个例子:
平时我们一般都是网上购物,快递到货后,一般取件的方式有两种:
送货上门:这种就比较舒服,但是假设你当时没在家,快递被偷了咋办?再者要是买的东西比较贵重,那你在外也会提心吊胆的。
暂存快递柜:这种就需要我们多跑一趟去取,但好处是随时都可以去取,也不用担心被偷。
那么上述两种方式对应的就是推和拉,下面我们消息队列中的情况。
1.1 推消息
这种就是Broker一旦收到消息,不管三七二十一,立马就把消息推给消费者,那么优缺点也很明显。
优点:
消费者啥也不用做,等消息上门就行了,实现起来比较简单。
时效性很高,Broker一收到消息就立马推给消费者。
缺点:
假设消费者现在已经很忙了,Broker还一直推消息,很有可能把消费者的CPU给干冒烟了,这也违背了消息队列的“削峰添谷”的初衷。
那么有同学可能会想到,那Broker在推消息之前可以先看下消费者的处理情况,处理的快就多推点,处理的慢就少推点,反正Broker已经持久化消息了。
理论上是可行的,但是消费者可能很多,每个消费者的消费速率又不一样,那么Broker就需要维护住每个消费者的情况,这就大大增加了Broker的复杂度了,性能也会下降。就好比快递小哥在送之前先打个电话询问在不在家,如果快递很多,那岂不是很累。
1.2 拉消息
就是“解放”Broker,消费者可以根据自身的情况主动去Broker拉消息,这种的话优缺点也很明显:
优点:
消费者就比较灵活,如果现在很忙,那就等会再拉,如果现在很闲,那么就去Broker拉消息,而且可以根据自身情况选择一次性拉取的数量。
缺点:
消息消费有可能会延迟,这种拉模式的话一般就是定时任务,比如1分钟拉一次,发现没有消息,此时恰巧有一条消息再消费者拉取请求后存储到了Broker,那么即使消费者此时空闲,刚到的那条消息也得等一分钟后才能被消费。
如果把定时任务的周期调短一点呢,比如1s拉一次?这就会带来另一个问题,就是消息忙请求,假设几个小时内都没消息来,那么消费者也要持续性去请求Broker尝试拉取消息,白白浪费资源。
1.3 RocketMQ的长轮询
企业级消息队列有用拉的也有用推的,比如ActiveMQ就是用的推模式,不过现在基本没啥人在用了,而Kafka和RocketMQ则用的是拉模式,不过这种拉模式不是上面的介绍的拉模式,而是变种的“拉”“,他有一个好听的名词叫”长轮询“。
何为长轮询?
消费者发送拉去请求到Broker,如果此时有消息,则Broker直接响应返回消息,如果没消息就hold住这个请求,比如等15s,在15s内如果有消息就立马响应返回消息,如果没消息就返回无消息。
这样既避免了忙请求的情况,也进一步提升了消息的实时性,是不是很巧妙😄
ps:不仅kafka和RocketMQ采用这种方式来获取消息,Nacos1.x版本也是采用长轮询的方式进行数据交互的。
二、消费者负载均衡
根据官方,消费者负载均衡分为队列粒度和消息粒度的负载均衡,重点了解下队列粒度!
2.1 队列粒度(RocketMQ 3.x/4.x 默认)
我们知道Topic下有队列的概念,消费者实际是去Topic下的某个队列获取消息的,假设有4个队列,1个消费者,那么这个消费者就需要消费4个队列的消息:
如果此时消费者组A来了个消费者-2,那么此时4个队列就会分2个给新来的消费者,这就是所谓的重平衡。也叫客户端的负载均衡:
如果再来俩消费者呢?那么每个消费者只负责一个队列就好:
如果再来一个呢?重平衡后,新来的消费者还是空闲的,因为每个队列都已经有负责的消费者了,没有更多个队列给新来的消费者了:
这就涉及一个面试题:消息堆积时,增加消费者有用吗?
不一定。如果这个消费者组的消费者数量比Topic的队列数量小,那么增加消费者在一定程度上确实可以缓解消费堆积,如果此时消费者数量已经大于等于队列数量,那再加多少都是没用的。
那么重平衡触发的时机呢?
消费者启动的时候,
定时任务,每20s主动重平衡一下。
如果有消费者下线了,由于消费者和Broker建立了连接,所以此时Broker也会通知所有消费者进行重平衡。
这样一来就实现了动态的负载均衡功能,可以灵活的操作消费者上下线。
不过这种模式在提升消费能力的同时,也带来一些问题:
消费暂停:在只有一个消费者时,它负责所有队列;在新增一个消费者后会触发重平衡。此时原消费者就需要暂停部分队列的消费,等新的消费者分配好队列后,才能继续消费。
消费重复:消费者在消费新的队列时,比如按照之前消费者的消费进度消费,在默认情况下这些offset是异步提交的,这就可能导致提交到Broker的offset跟实际消费进度不一致,从而导致重复消费。
消费突刺:由于重平衡可能导致重复消费,如果需要消费的消息过多,或者因为重平衡暂停时间过长(可能达到秒级),就会导致消息积压,在重平衡后瞬间就需要消费很多消息。
2.2 消息粒度(RocketMQ 5.x 默认)
ps:这个可以简单了解一下,目前业界用的不多。
针对上述“队列粒度重平衡”机制导致的重复消费和消费延迟等问题,RocketMQ 5.0提出了消息粒度的负载均衡机制。
在这个策略中,同一个队列的消息可以被同一组内的多个消费者消费,服务端会确保消息不重不漏的被客户端消费到:
消息粒度的负载均衡机制,是基于内部的单条消息确认语义实现的,消费者在获取到某条消息后,服务端会将该消息加锁,保证这条消息对其它消费者不可见,直到该消息消费成功或消费超时。
因此,即使多个消费者同时消费同一队列的消息,服务端也可以保证消息不会被多个消费者重复消费。
同时RocketMQ在消息维度的负载均衡的基础上也实现了顺序消费的语义:不同消费者处理同一个消费组内的消息时,会严格按照先后顺序锁定消息状态,确保同一消费组的消息串行消费:
如上图所述,队列Queue1中有4条顺序消息,这4条消息属于同一消息组G1,存储顺序由M1到M4。在消费过程中,前面的消息M1、M2被消费者Consumer A1处理时,只要消费状态没有提交,消费者A2是无法并行消费后续的M3、M4消息的,必须等前面的消息提交消费状态后才能消费后面的消息。
三、如何拉消息
重平衡后,每个消费者都知道它负责了哪个队列,下面的任务就是去Broker拉取对应队列的消息就行,那么具体如何拉呢?
最简单的办法就是一个队列启一个线程去拉,但是如果队列很多,那么对应的线程就很多,我们知道频繁的线程上下文切换是有损耗的,而且重平衡机制会使得消费者者负责的队列数量也变化,队列变,那么对应的线程也会变,这时候就得上线程池了。
但实际不用那么麻烦。
RocketMQ只用了一个线程(PullMessageService)来执行拉消息的操作,所有拉取消息的动作都被封装成PullRequest,然后扔到pullRequestQueue这个阻塞队列里面:
然后PullMessageService会不断地从pullRequestQueue拉取PullRequest,最后根据pullRequest里面的内容构建请求去对应的Broker里拉消息:
pullRequest里面包含消费者组的信息、消费点位、Topic、队列id等,当上一个pullRequest拉取到结果后,里面会根据响应构建新的pullRequest塞到pullRequestQueue里,以此来达到消息的不间断拉取。
四、消息位点(offset)
前面文章我们提到过,每个消费者组都要维护Topic下的每个队列被消费的点位,那么这个点位该怎么保存呢?
在不同模式下,保存的方式不同:
广播模式:消费点位就存储在消费者本地磁盘上,因为广播模式是将消息广播给每个消费者,它不需要有个统一的地方来管理这个位置,每个消费者自己维护就行。
集群模式:这就不能本地维护了,因为集群模式下,同个消费者组内的消费者是“互帮互助”的关系,如果某个消费者下线了,就需要另一个消费者顶上,这时候顶上来的消费者就需要知道下线的消费者的消费进度,所以在集群模式下,消费点位是存储在Broker上的,这样新顶上来的消费者就可以从Broker来获取消费点位。
那消费者是如何告诉Broker自己的消费点位的呢?
其实也简单,就是去拉消息的时候会顺带把此时的消费点位提交给Broker,这样Broker就能得知最新的消费进度了。但是有一点需要提一下:那假设消费完了,还没来得及去拉消息(最新的消费点位没提交给Broker),消费者就挂了,那么此时如果发生重平衡,新的消费者顶上来,就会导致重复消费消息!
所以,消费者也无法保证消息只会被消费一次,这里也是重复消费的问题,我们能做的就是做好消费者端的幂等。
五、消费消息
消费者是如何消费消息的呢?
有两种,一种是并发消费,另一种则是顺序消费,一般情况下都是并发消费。那假设消费失败了咋办?重试!重试还是失败呢?如果一直重试失败就会阻塞住后面的消息了。
所以RokcetMQ的实现是将消费失败的消息,返回发送给Broker,这个消息会被发送到特定的重试Topic:“%RETRY%+consumerGroup,这样就不会阻塞原Topic上的其它消息了;当然如果重试多次还是失败的话,就会把它打入到死信队列中,这时候就需要人工介入去处理这些消息了。
ps:默认16次重试失败后,打入死信队列。
End:希望对大家有所帮助,如果有纰漏或者更好的想法,请您一定不要吝啬你的赐教🙋。