既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新
一、为什么会出现消息乱序消费
先来看生产者,rocketmq的一个主题下可以创建多个队列,默认情况下生产者将消息轮询发送到各个不同的队列上,这就导致本来需要有序的多个消息进入了不同队列。
下面再来看一下消费者。如果消费者使用的是DefaultMQPushConsumer,下面分两种情况来看:
(1)、一种该消费组内只有一个消费者,所有的消息都会发送到该消费者,DefaultMQPushConsumer只有一个线程拉取消息,因此可以确保按照队列顺序拉取,但是消息拉到本地后消费的时候是多线程消费的,默认是20个线程同时消费,而且消费过程是异步的,这样便导致了乱序,如下图:
(2)、如果消费组内有多个消费者,那么队列会被分发给不同的消费者,导致顺序消费的消息也被分发给不同的消费者,每个消费者独立处理消息,导致消息乱序,如下图:
如果消费者使用的是DefaultMQPullConsumer,为了确保有序,开发人员需要控制只有一个线程轮询消费所有队列的消息,而且每次只能从一个队列里面拉取一个消息消费,尽管可以确保有序消费,但是极大的降低了消费者的性能,而且造成单点,一旦消费者挂掉,消息就无法消费了。
因此为了保证消息的顺序消费,需要满足以下条件:
- 生产者必须将需要保持有序的消息按顺序发送到同一个主题下的同一个队列,避免使用多线程发送顺序消息;
- 对于消费者来说,一个队列的消息同时只能有一个线程消费。
下面通过代码来看一下rocketmq如何保证消息顺序消费。
二、顺序消息代码示例
1、生产者生产顺序消息
下面展示一下生产者如何生产顺序消息。
下面的代码来源与官网。
public static void main(String[] args)throws Exception {
//创建生产者,生产者对象与普通消息的生产者对象一样,也是使用DefaultMQProducer
DefaultMQProducer producer = new DefaultMQProducer("order\_producer");
producer.setNamesrvAddr ("localhost:9876");//不知道为什么官网的示例代码没有这一行
producer.start();
String[] tags = new String[] {"TagA", "TagB", "TagC", "TagD", "TagE"};
for (int i = 0; i < 100; i++) {
int orderId = i % 10;
//创建一个消息,设置了tag、key
Message msg = new Message("topicTest", tags[i % tags.length], "KEY" + i,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
//发送消息,创建了一个队列选择器,使用队列选择器将消息发送到指定队列
//send()的入参orderId是用于做队列选择的参数,队列选择算法是orderId%队列个数
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Integer id = (Integer) arg;
int index = id % mqs.size();//队列选择算法
return mqs.get(index);
}
}, orderId);
System.out.printf("%s%n", sendResult);
}
producer.shutdown();
}
生产者通过MessageQueueSelector选择器确保需要顺序消费的消息都放入同一个队列,这样满足顺序消费的第一个条件。
2、基于DefaultMQPushConsumer顺序消费
下面展示的是DefaultMQPushConsumer如何顺序消费消息。
下面的代码来源与官网。
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order\_consumer");
//设置消费者从第一个消息开始消费
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
//指定nameserver地址,可以有多个,使用分号分隔
consumer.setNamesrvAddr("localhost:9876");
consumer.subscribe("topicTest", "TagA || TagC || TagD");
//与普通消费者不同,这里使用的监听器是MessageListenerOrderly,
//MessageListenerOrderly的区别是:
//MessageListenerOrderly保证一个队列只有一个线程消费
//MessageListenerConcurrently是多个线程同时消费多个队列
consumer.registerMessageListener(new MessageListenerOrderly() {
AtomicLong consumeTimes = new AtomicLong(0);
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs,
ConsumeOrderlyContext context) {
context.setAutoCommit(false);//设置非自动提交
System.out.printf(Thread.currentThread().getName() + " Receive New Messages: " + msgs + "%n");
this.consumeTimes.incrementAndGet();
if ((this.consumeTimes.get() % 2) == 0) {
return ConsumeOrderlyStatus.SUCCESS;//消息消费成功
} else if ((this.consumeTimes.get() % 3) == 0) {
return ConsumeOrderlyStatus.ROLLBACK;//用于binlog日志的消费
} else if ((this.consumeTimes.get() % 4) == 0) {
return ConsumeOrderlyStatus.COMMIT;//用于binlog日志的消费
} else if ((this.consumeTimes.get() % 5) == 0) {
context.setSuspendCurrentQueueTimeMillis(3000);
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;//将当前队列挂起
}
return ConsumeOrderlyStatus.SUCCESS;//消息消费成功
}
});
consumer.start();
System.out.printf("Consumer Started.%n");
}
与普通消费者不一样,顺序消息使用的监听器是MessageListenerOrderly,MessageListenerOrderly可以保证一个队列只有一个线程消费,这样满足了顺序消费的第二个条件。
3、基于DefaultMQPullConsumer顺序消费
DefaultMQPullConsumer顺序消费的代码和之前文章中介绍的代码基本上一样,只要保证一个队列同时只有一个线程消费即可。
代码可以参考文章:《RocketMQ-如何创建消费者》中关于DefaultMQPullConsumer的介绍。
三、顺序消费原理
1、生产者生产顺序消息原理
生产者生产的顺序消息需要放入同一个队列,这一点是怎么做到的?
答案就是MessageQueueSelector。
一般的可以不设置MessageQueueSelector,如果不设置的话,rocketmq使用默认的选择器MQFaultStrategy。MQFaultStrategy是轮询选择不同的队列。
如果设置了MessageQueueSelector,那么在发出消息前,rocketmq调用MessageQueueSelector.select(final List mqs, final Message msg, final Object arg)方法选择其中一个队列。这样可以通过自定义MessageQueueSelector,将顺序消息放入同一个队列中。
下面介绍一下MessageQueueSelector.select()三个入参:
- mqs:是该主题下的写队列集合,包含了主题,broker名和队列号;
- msg:消息对象;
- arg:DefaultMQProducer.send()方法的最后一个入参,作为选择队列的关键字,比如订单号,同一个订单号下的消息发送到同一个队列。
2、消费者顺序消费原理
DefaultMQPullConsumer顺序消费是开发人员自己通过编程实现的,只要满足一个队列同时只有一个线程消费即可,本文不再对DefaultMQPullConsumer进行介绍。下面主要介绍DefaultMQPushConsumer。
对于DefaultMQPushConsumer来说,通过将顺序消费代码与普通消费代码进行对比,可以发现两者的区别是监听器的不同。普通消费是MessageListenerConcurrently,顺序消费是MessageListenerOrderly。
MessageListenerConcurrently从名字就可以看出,该监听器是并发处理消息的。它内部有一个线程池,默认是20个线程,消费者将拉取请求发出去,后面的工作就交给异步线程了,异步线程收到broker返回的消息后将消息转发给MessageListenerConcurrently,MessageListenerConcurrently使用ConsumeRequest封装,然后将ConsumeRequest放入线程池中并发消费消息。
当把监听器换成MessageListenerOrderly后,异步线程收到消息,便将消息转发给MessageListenerOrderly处理,转发是通过调用MessageListenerOrderly.submitConsumeRequest()完成的,不过在转发之前,先要对消息按照位移排好序,代码如下:
//对拉取到的消息排序,dispatchToConsume表示是否启动线程池,为什么有这个参数,下文介绍
boolean dispatchToConsume = processQueue.putMessage(pullResult.getMsgFoundList());
//调用MessageListenerOrderly.submitConsumeRequest()方法,启动线程池处理消息
DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
pullResult.getMsgFoundList(),
processQueue,
pullRequest.getMessageQueue(),
dispatchToConsume);
//检查是否设置了拉取间隔,默认是0,如果是0,则调用
//executePullRequestImmediately方法新增一个拉取请求pullRequest,为下次拉取消息做准备
//否则,创建一个定时任务,延迟this.defaultMQPushConsumer.getPullInterval()毫秒后再新增一个拉取请求
if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
} else {
DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
}
上面的代码是拉取回消息后,异步线程对消息进行回调处理的部分代码,从上面的代码可以看到在将消息转发给监听器前,调用了processQueue.putMessage()进行排序。下面深入putMessage()方法来看一下如何排序的:
public boolean putMessage(final List<MessageExt> msgs) {
boolean dispatchToConsume = false;
try {
//加锁,确保只有一个线程操作msgTreeMap
this.lockTreeMap.writeLock().lockInterruptibly();
try {
//统计拉取回了多少个有效消息,或者说是拉取回了多少个与之前不重复的消息
int validMsgCnt = 0;
for (MessageExt msg : msgs) {
//遍历消息,将消息放入msgTreeMap,msgTreeMap是TreeMap类型的
//TreeMap会对传入的key进行排序,这里是根据消息的位移排序的,
//位移越小,排序越靠前
MessageExt old = msgTreeMap.put(msg.getQueueOffset(), msg);
if (null == old) {
validMsgCnt++;
this.queueOffsetMax = msg.getQueueOffset();
msgSize.addAndGet(msg.getBody().length);
}
}
//msgCount做一些数据统计,以及限流使用,
//如果msgCount过大,消费者会暂停从broker拉取消息
msgCount.addAndGet(validMsgCnt);
//consuming表示当前线程池是否正在对消息进行消费
if (!msgTreeMap.isEmpty() && !this.consuming) {
dispatchToConsume = true;
this.consuming = true;
}
if (!msgs.isEmpty()) {
MessageExt messageExt = msgs.get(msgs.size() - 1);
String property = messageExt.getProperty(MessageConst.PROPERTY_MAX_OFFSET);
if (property != null) {
long accTotal = Long.parseLong(property) - messageExt.getQueueOffset();
if (accTotal > 0) {
this.msgAccCnt = accTotal;//作为动态调整线程池个数的参数,当前版本暂未实现该功能
}
}
}
} finally {
this.lockTreeMap.writeLock().unlock();
}
} catch (InterruptedException e) {
log.error("putMessage exception", e);
}
return dispatchToConsume;
}
putMessage()将拉取回的消息放到TreeMap中,TreeMap会根据位移大小进行排序。这个方法保证了消息按照位移从小到大排好序,后拉取的消息一定排在之前拉取的消息的后面。
putMessage()方法里面有一个变量:consuming,这个变量用于控制并发处理消息,表示当前是否有线程正在处理消息。线程处理的消息是从msgTreeMap获取的,如果consuming=true,说明当前有线程正在处理消息,那么就没有必要再次启动一个线程继续处理,因此MessageListenerOrderly.submitConsumeRequest()方法里面根据putMessage()方法的返回值做了判断。当msgTreeMap中的消息处理完后,会将consuming设置为false。
消息排好序后便将消息转发给MessageListenerOrderly.submitConsumeRequest()方法,下面来看一下submitConsumeRequest():
public void submitConsumeRequest(
final List<MessageExt> msgs,//消息集合,不过方法里没有使用,因为消息是从TreeMap中获取的
final ProcessQueue processQueue,//对应的队列,相当于队列的状态对象
final MessageQueue messageQueue,//对应的队列,队列的一些静态属性,比如主题名
final boolean dispathToConsume) {
//dispathToConsume=true表示当前没有线程处理消息,那么便启动线程池的一个线程处理
//dispathToConsume=false表示当前有线程正在处理消息,那么无需再启动线程,因为消息都已经放入到TreeMap中了,线程直接从TreeMap中按顺序拉取消息
if (dispathToConsume) {
//使用ConsumeRequest封装消息
ConsumeRequest consumeRequest = new ConsumeRequest(processQueue, messageQueue);
//consumeExecutor是一个线程池,默认线程数是20
this.consumeExecutor.submit(consumeRequest);
}
}
到这里为止,消息已经排好序,而且已经调用submitConsumeRequest() 启动线程池处理消息了,下面来看一下ConsumeRequest的run()方法是如何处理消息的:
//下面代码比较多,我做了删减
public void run() {
if (this.processQueue.isDropped()) {
return;
}
//获得队列对应的锁对象,确保一个队列同时只能一个线程消费
final Object objLock = messageQueueLock.fetchLockObject(this.messageQueue);
synchronized (objLock) {
if (MessageModel.BROADCASTING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel())
//检查是否上锁成功,锁是否超时,这个锁位于broker上
|| (this.processQueue.isLocked() && !this.processQueue.isLockExpired())) {
final long beginTime = System.currentTimeMillis();
for (boolean continueConsume = true; continueConsume; ) {
if (this.processQueue.isDropped()) {
break;
}
//下面两个if判断,主要是检查当前队列是否在broker端已经加锁还有锁是否过期,
//如果没有加锁或者过期,则发起请求对队列进行加锁,并增加一个定时任务,稍后重新对消息进行消费
if (MessageModel.CLUSTERING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel())
![img](https://img-blog.csdnimg.cn/img_convert/e8795420dc3b60b83bc8c9e31d19cc0f.png)
![img](https://img-blog.csdnimg.cn/img_convert/5caf04ededbb6f1834472fea6b7b6945.png)
**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**
**[需要这份系统化的资料的朋友,可以添加戳这里获取](https://bbs.csdn.net/topics/618658159)**
**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**
CrC-1715707002274)]
[外链图片转存中...(img-e5oUEnH7-1715707002274)]
**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**
**[需要这份系统化的资料的朋友,可以添加戳这里获取](https://bbs.csdn.net/topics/618658159)**
**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**