RocketMQ 两种消费方式
一、 概述
在 RocketMQ里消费方式虽有PUSH与PULL两种,但实现机制实为 PULL 模式,PUSH 模式是一种伪推送,是对 PULL 模式的封装,每拉去一批消息后,提交到消费端的线程池(异步),然后马上向 Broker 拉取消息,即实现类似“推”的效果。下面是消息拉取示意图:
拉取式消费(Pull Consumer)
Consumer消费的一种类型,应用通常主动调用Consumer的拉消息方法从Broker服务器拉消息、主动权由应用控制。一旦获取了批量消息,应用就会启动消费过程。Pull指的是客户端主动向服务端请求,拉取数据。
由消费者客户端主动向消息中间件(MQ消息服务器代理)拉取消息;采用Pull方式,如何设置Pull消息的频率需要重点去考虑,举个例子来说,可能1分钟内连续来了1000条消息,然后2小时内没有新消息产生(概括起来说就是“消息延迟与忙等待”)。如果每次Pull的时间间隔比较久,会增加消息的延迟,即消息到达消费者的时间加长,MQ中消息的堆积量变大;若每次Pull的时间间隔较短,但是在一段时间内MQ中并没有任何消息可以消费,那么会产生很多无效的Pull请求的RPC开销,影响MQ整体的网络性能;
推动式消费(Push Consumer)
Consumer消费的一种类型,该模式下Broker收到数据后会主动推送给消费端,该消费模式一般实时性较高。Push指的是客户端与服务端建立好网络长连接,当服务端有数据时立即通过连接将数据推送给客户端。
由消息中间件(MQ消息服务器代理)主动地将消息推送给消费者;采用Push方式,可以尽可能实时地将消息发送给消费者进行消费。但是,在消费者的处理消息的能力较弱的时候(比如,消费者端的业务系统处理一条消息的流程比较复杂,其中的调用链路比较多导致消费时间比较久。概括起来地说就是“慢消费问题”),而MQ不断地向消费者Push消息,消费者端的缓冲区可能会溢出,导致异常;
二、Push与Pull的特点
Push的一个特点是及时,一旦有数据服务端立即将数据推送给客户端;对客户端来说比较友好,无须处理无数据的情形;不过服务端并不知道客户端的处理能力,如果客户端处理能力低会造成消息堆积在客户端的问题。
Pull因为是客户端主动去服务端拉取数据,所以不存在消息堆积问题;但什么时候有数据客户端是无法感知的,所以拉取时间间隔不好控制,间隔长消息消费不及时;间隔短会出现无效拉取的请求。
在PULL模式下为了保证消费的实时性,采起了长轮询消息服务器拉取消息的方式,每隔一定时间客户端向服务端发起一次请求,如果有数据则取回进行消费,如果服务端没数据客户端线程会阻塞,阻塞时间为15S,有数据了就会被唤醒。长轮询还是由consumer发起的,因此就算broker端有大量数据也不会主动推送给consumer。
关于长轮询的实现在PullRequestHoldService
类里。
三、PUSH与PULL的实现
先看看Pull使用的一个示例:
import org.apache.rocketmq.client.consumer.DefaultMQPullConsumer; import org.apache.rocketmq.client.consumer.PullResult; import org.apache.rocketmq.common.message.MessageExt; import org.apache.rocketmq.common.message.MessageQueue; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; public class PullConsumerTest { public static void main(String[] args) throws Exception { Semaphore semaphore = new Semaphore(); Thread t = new Thread(new Task(semaphore)); t.start(); CountDownLatch cdh = new CountDownLatch(1); try { //程序运行 120s cdh.await(120 * 1000, TimeUnit.MILLISECONDS); } finally { semaphore.running = false; } } /** * 消息拉取核心实现逻辑 */ static class Task implements Runnable { Semaphore s = new Semaphore(); public Task(Semaphore s ) { this.s = s; } public void run() { try { DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("dw_pull_consumer"); consumer.setNamesrvAddr("127.0.01:9876"); consumer.start(); Map<MessageQueue, Long> offsetTable = new HashMap<MessageQueue, Long>(); Set<MessageQueue> msgQueueList = consumer. fetchSubscribeMessageQueues("TOPIC_TEST"); // 获取该 Topic 的所有队列 if(msgQueueList != null && !msgQueueList.isEmpty()) { boolean noFoundFlag = false; while(this.s.running) { if(noFoundFlag) { // 没有找到消息,暂停一下消费 Thread.sleep(1000); } for( MessageQueue q : msgQueueList ) { PullResult pullResult = consumer.pull(q, "*", decivedPulloffset(offsetTable , q, consumer) , 3000); System.out.println("pullStatus:" + pullResult.getPullStatus()); switch (pullResult.getPullStatus()) { case FOUND: doSomething(pullResult.getMsgFoundList()); break; case NO_MATCHED_MSG: break; case NO_NEW_MSG: case OFFSET_ILLEGAL: noFoundFlag = true; break; default: continue ; } //提交位点 consumer.updateConsumeOffset(q, pullResult.getNextBeginOffset()); } System.out.println("balacne queue is empty: " + consumer. fetchMessageQueuesInBalance("TOPIC_TEST").isEmpty()); } } else { System.out.println("end,because queue is enmpty"); } consumer.shutdown(); System.out.println("consumer shutdown"); } catch (Throwable e) { e.printStackTrace(); } } } /** 拉取到消息后具体的处理逻辑 */ private static void doSomething(List<MessageExt> msgs) { System.out.println("本次拉取到的消息条数:" + msgs.size()); } public static long decivedPulloffset(Map<MessageQueue, Long> offsetTable, MessageQueue queue, DefaultMQPullConsumer consumer) throws Exception { long offset = consumer.fetchConsumeOffset(queue, false); if(offset < 0 ) { offset = 0; } System.out.println("offset:" + offset); return offset; } static class Semaphore { public volatile boolean running = true; } }
消息的拉取实现主要在任务 Task 的 run 方法中,重点看下:
- 首先根据 MQConsumer 的 fetchSubscribeMessageQueues 的方法获取 Topic 的所有队列信息
- 然后遍历所有队列,依次通过 MQConsuemr 的 PULL 方法从 Broker 端拉取消息。
- 对拉取的消息进行消费处理
- 通过调用 MQConsumer 的 updateConsumeOffset 方法更新位点,但需要注意的是这个方法并不是实时向 Broker 提交,而是客户端会启用以线程,默认每隔 5s 向 Broker 集中上报一次。
上面的示例逻辑倒是挺清晰,不过以下这些问题我们在使用时需要考虑的:
- 从broker拉取了一批消息后多个消费者需要手动完成队列的分配。上例是只是一个消费组且组里只有一个消费者,如果是多个我们需要考虑队列的分配情况
- 消费完消息后我们需要主动上报消费进度,然后拉取下一批。
- 如果遇到消息消费失败,需要告知 Broker,该条消息消费失败,后续需要重试,通过手动调用 sendMessageBack 方法实现
说明下,在MQPullConsumer这个类里面,有一个MessageQueueListener,它的目的就是当queue发生变化的时候,通知Consumer。也正是这个接口帮助我们在Pull模式里面实现负载均衡。
/** * A MessageQueueListener is implemented by the application and may be specified when a message queue changed */ public interface MessageQueueListener { /** * @param topic message topic * @param mqAll all queues in this message topic * @param mqDivided collection of queues,assigned to the current consumer */ void messageQueueChanged(final String topic, final Set<MessageQueue> mqAll, final Set<MessageQueue> mqDivided); }
我们再来看一下Push使用的示例
public static void main(String[] args) throws InterruptedException, MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("dw_test_consumer_6"); consumer.setNamesrvAddr("127.0.0.1:9876"); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET); consumer.subscribe("TOPIC_TEST", "*"); consumer.setAllocateMessageQueueStrategy(new AllocateMessageQueueAveragelyByCircle()); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { try { System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } catch (Throwable e) { e.printStackTrace(); return ConsumeConcurrentlyStatus.RECONSUME_LATER; } } }); consumer.start(); System.out.printf("Consumer Started.%n"); }
理下流程:
- 首先 new DefaultMQPushConsumer 对象,并指定一个消费组名。
- 然后设置相关参数,例如 nameSrvAdd、消费失败重试次数、线程数等
- 通过调用 setConsumeFromWhere 方法指定初次启动时从什么地方消费,默认是最新的消息开始消费。
- 通过调用 setAllocateMessageQueueStrategy 指定队列负载机制,默认平均分配。
- 通过调用 registerMessageListener 设置消息监听器,即消息处理逻辑,最终返回 CONSUME_SUCCESS(成功消费)或 RECONSUME_LATER(需要重试)。
相较于PULL方式,我们在使用PUSH方式时只需指定好相关策略然后在MessageListener的回调里进行消息处理就行。PUSH消息方式由于返回了消息的状态,服务端会维护每个消费端的消费进度,内部会记录消费进度,消息发送成功后会更新消费进度。另外队列的负载我们也无须干预太多,这些问题都被封装了。
四、总结
可以看到 PUSH 模式和 PULL 模式本质上都是客户端主动拉取,RocketMQ并没有真正实现 Broker 推送消息的 PUSH 模式。RocketMQ 中 PULL 模式和 PUSH 模式的区别如下:
PULL 模式是从 Broker 拉取消息后放入缓存,然后消费端不停地从缓存取出消息来执行客户端定义的处理逻辑,而 PUSH 模式是在死循环中不停的从 Broker 拉取消息,拉取到后调用回调函数进行处理,回调函数中调用客户端定义的处理逻辑;消费者订阅主题,然后自动进行集群内消息队列的动态负载,自动拉取消息。准实时。
PUSH 模式拉取消息依赖死循环来不停唤起业务,而 PULL 模式拉取消息是通过 MessageQueue 监听器来触发消息拉取线程,消息拉取线程会在拉取完一次后接着下一次拉取。消费者无需订阅主题,由业务方(应用程序)直接根据MessageQueue拉取消息。