原理后面讲:
自定义了两个线程,每个线程循环往topic_order_test主题中,发送10条信息。
package org.apache.rocketmq.example.ordermessage;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.MessageQueueSelector;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.remoting.common.RemotingHelper;
import java.util.List;
public class Producer {
public static void main(String[] args) {
try {
final DefaultMQProducer mqProducer = new DefaultMQProducer("producer_order_test");
mqProducer.setNamesrvAddr("192.168.0.11:9876;192.168.0.13:9876");
mqProducer.start();
new Thread(new Runnable() {
Integer hashKey = 123;
@Override
public void run() {
sendMessage(mqProducer, hashKey);
}
}, "线程A").start();
new Thread(new Runnable() {
Integer hashKey = 124;
@Override
public void run() {
sendMessage(mqProducer, hashKey);
}
}, "线程B").start();
Thread.sleep(3000);
mqProducer.shutdown();
} catch (MQClientException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static void sendMessage(DefaultMQProducer mqProducer, Integer hashKey) {
try {
for (int i = 0; i < 10; i++) {
Message msg = new Message("topic_order_test",
"FILTER",i+"",
(hashKey+"").getBytes(RemotingHelper.DEFAULT_CHARSET)
);
mqProducer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Integer key = (Integer) arg;
int index = key % mqs.size();
return mqs.get(index);
}
}, hashKey);
System.out.println("message send,hashKey:" + hashKey);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
我们会发现有2个对列分别容纳10条信息,问题来了为什么没有发到一个队列?或者为什么8个队列没有均分?
为什么会发到0号和3号队列?见下图
消费线程:
package org.apache.rocketmq.example.ordermessage;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.*;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.MessageExt;
import java.util.List;
public class Consumer {
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
consumer.subscribe("topic_order_test", "FILTER");
consumer.setNamesrvAddr("192.168.0.11:9876;192.168.0.13:9876");
//设置并行消费线程数量
consumer.setConsumeThreadMin(3);
consumer.setConsumeThreadMax(6);
//一个消费组 多个线程去并行消费生产者发送的消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
for (MessageExt messageExt : msgs) {
System.out.println(Thread.currentThread().getName() + "消费队列id " + messageExt.getQueueId() + ",发送消息内容:" +
new String(messageExt.getBody()) + ",消息索引:" + messageExt.getKeys());
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
System.out.printf("Consumer Started.%n");
}
}
消费线程并行消费的时候,不能保证同一队列中的消息被顺序消费?怎么解决呢?
rocketMq官方给我们提供了MessageListenerOrderly
package org.apache.rocketmq.example.ordermessage;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.*;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.MessageExt;
import java.util.List;
public class Consumer {
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
consumer.subscribe("topic_order_test", "FILTER");
consumer.setNamesrvAddr("192.168.0.11:9876;192.168.0.13:9876");
consumer.setConsumeThreadMin(3);
consumer.setConsumeThreadMax(6);
//一个消费组 多个线程去并行消费生产者发送的消息
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
for (MessageExt messageExt : msgs) {
System.out.println(Thread.currentThread().getName() + "消费队列id " + messageExt.getQueueId() + ",发送消息内容:" +
new String(messageExt.getBody()) + ",消息索引:" + messageExt.getKeys());
}
return ConsumeOrderlyStatus.SUCCESS;
}
});
consumer.start();
System.out.printf("Consumer Started.%n");
}
}
每个线程消费队列中20条消息,确实按顺序消费。
顺序:首先什么叫做顺序?
一组数字 1,5,7,9,0,2,8
其实这些都是人为定义的,比如老师叫小朋友A将一组数字按照从小到大的顺序排列。
答案:0 ,1,2, 5,7,8,9
老师让小朋友B将一组数字按照从大到小的顺序排列。
答案:9,8,7,5,2,1,0
老师让小朋友c将一组数字按照乱序的顺序排列。
答案:0,2,7,9,8,1,5
有人可能会认为乱序叫什么顺序,其实我认为乱序也可理解为是一种顺序,只是这种排列方式毫无规律可言。
再比如生活中的点外卖:
1.进入点餐系统,比如用户点了一份酸菜鱼,然后加了购物车,点击结算,选择支付方式,微信支付,
支付成功,生成订单。此时这一系列操作可能会引发下游多个系统。
假设在用户支付成功后,
-
生成微信支付成功订单
-
商家接单操作
-
在包括骑手接单
-
骑手送货完成
-
订单签收
我上面的列举的顺序就是符合日常生活逻辑的。假设我们采用mq,如何处理这种逻辑呢?
当然这种场景举的不是太好,因为周期比较长,现在假设默认这几步是瞬间需要完成的。
生产端保证(producer)
1.消息按顺序发送。
2.消息按顺序存储。
消费端保证(consumer)
3.消息按顺序消费。
第一种情况:如果用多线程去发送上面的几个步骤消息,那么很难保证顺序。所以只能采用单线程同步发送,就是上面一条信息发送成功,才能发送下一条信息。简称同步发送,但是这种方式有去缺陷。
比如:每条消息,可能被纷发到broker中不同的队列中去了,消息a在queue-1,消息b在queue2中,这样消费的时候,即使一个主题只有一个消费者,多个线程环境下怎么保证消费顺序呢。
第二种情况:想办法,将同一批消息发送到指定队列中,比如生产者在生产这5条消息时,给每一条消息都指定一个hashkey(用来计算该条消息应该被分配在哪个队列中),上文截图也有所涉及,这样就能保证5条消息处在同一个queue(队列满足FIFO先进先出原则)
第三种情况:我们考虑这样一种情况,同一批次消息A,B,C,D,E发送到xxTopic中,但是此topic被多个消费者订阅,这样就会出现问题。A消费者消费了A,B,C,B消费者消费D,C,E(好像类似多线程下卖票,同一张票卖多次的情况,线程不安全的,重复消费)。
- 那么就至少要保证同一个队列同一个时刻,只能被一个消费者消费。
- 队列中的每一条消息,即同一时刻只能有一个线程进行消费。
如何做到上面的两点?
一个队列同时只有一个消费者消费,那就是给这个queue对象上锁。
源码RebalanceImpl#updateProcessQueueTableInRebalance中,针对顺序消息的消息拉取,mq做了如下判断,
保证了一个队列同一时刻只有一个消费者线程占有。
实现目的:消费端从broker中拉取消费信息前,先向broker端发起对messageQueue的加锁请求,只有加锁成功,才能构建请求进行消费。
lock()的实现:
那么broker端收到消费端申请加锁的请求是怎么处理的?
在ReblanceImp中 lock方法调用了lockBatchMQ()方法,断点戳进去
然后我们具体看下broker端的tryLockBatch方法
broker端收到加锁请求的处理逻辑在RebalanceLockManager#tryLockBatch方法中,
RebalanceLockManager中关键属性如下:
LockEntry对象中关键属性如下:
对messageQueue进行加锁的关键逻辑如下:
如果messageQueue对应的lockEntry为空,标志队列未加锁,返回加锁成功。
如果此队列当前已经被此客户端线程锁定,更新最新时间,并添加到已上锁的集合中(可重入加锁)
如果锁已经过期,返回加锁成功
总而言之,broker端通过对ConcurrentMap<String, ConcurrentHashMap<MessageQueue, LockEntry>> mqLockTable的维护来达到messageQueue加锁的目的,使得同一时刻,一个messageQueue只能被一个消费者消费。
synchronized申请线程独占锁
假设消费者对messageQueue的加锁已经成功,那么就进入到了第二个步骤,创建pullRequest进行消息拉取,消息拉取部分的代码实现在PullMessageService中,消息拉取完后,需要提交到ConsumeMessageService中进行消费,顺序消费的实现为ConsumeMessageOrderlyService,提交消息进行消费的方法为ConsumeMessageOrderlyService#submitConsumeRequest,具体实现如下:
可以看到,构建了一个ConsumeRequest对象,并提交给了ThreadPoolExecutor来并行消费,看下顺序消费的ConsumeRequest的run方法实现
里面先从messageQueueLock中获取了messageQueue对应的一个锁对象,看下messageQueueLock的实现
其中维护了一个ConcurrentMap<MessageQueue, Object> mqLockTable,使得一个messageQueue对应一个锁对象object
获取到锁对象后,使用synchronized尝试申请线程级独占锁。
-
如果加锁成功,同一时刻只有一个线程进行消息消费
-
如果加锁失败,会延迟100ms重新尝试向broker端申请锁定messageQueue,锁定成功后重新提交消费请求。
至此,第三个关键点的解决思路也清晰了,基本上就两个步骤
- 创建消息拉取任务时,消息客户端向broker端申请锁定MessageQueue,使得一个MessageQueue同一个时刻只能被一个消费客户端消费。
- 消息消费时,多线程针对同一个消息队列的消费先尝试使用synchronized申请独占锁,加锁成功才能进行消费,使得一个MessageQueue同一个时刻只能被一个消费客户端中一个线程消费。
以上部分源码解析:参考https://blog.csdn.net/hosaos/article/details/90675978。谢谢