1 消息发送分类
1.1 普通消息
1.1.1 同步发送
- 原理
同步发送是指消息发送方发出一条消息后,会在收到服务端返回响应之后才发下一条消息的通讯方式。
- 测试代码
public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException {
// procedureGroup全局唯一
String producerGroup = "demo-procedure";
DefaultMQProducer producer = new DefaultMQProducer(producerGroup);
producer.setNamesrvAddr(namesrv);
// 设置消息发送超时时间
producer.setSendMsgTimeout(1000);
// 设置发送异步消息重试次数
producer.setRetryTimesWhenSendAsyncFailed(3);
producer.start();
for (int i = 0; i < 10; i ++) {
SendResult result = producer.send(new Message("demo-topic", "demo-tag", ("msg-" + i).getBytes()));
System.out.println("send msg end:" + result);
}
producer.shutdown();
}
1.1.2 异步发送
- 原理
异步发送是指发送方发出一条消息后,不等服务端返回响应,接着发送下一条消息的通讯方式。RocketMQ异步发送,需要实现异步发送回调接口(SendCallback)。消息发送方在发送了一条消息后,不需要等待服务端响应即可发送第二条消息。发送方通过回调接口接收服务端响应,并处理响应结果。
- 测试代码
public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException {
// procedureGroup全局唯一
String producerGroup = "demo-procedure";
DefaultMQProducer producer = new DefaultMQProducer(producerGroup);
producer.setNamesrvAddr(namesrv);
// 设置消息发送超时时间
producer.setSendMsgTimeout(1000);
// 设置发送异步消息重试次数
producer.setRetryTimesWhenSendAsyncFailed(3);
producer.start();
for (int i = 0; i < 10; i ++) {
producer.send(new Message("demo-topic", "demo-tag", String.valueOf("msg-" + i).getBytes()), new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.println(sendResult);
}
@Override
public void onException(Throwable e) {
e.printStackTrace();
}
});
}
producer.shutdown();
}
1.1.3 单向发送消息
-
原理
单向发送消息是指Procedure仅负责发送消息,不等待,不处理MQ的ACK,该发送方式时MQ也不会返回ACK。该方式发送效率最高,但是消息可靠性没有保障
-
测试代码
public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException {
// procedureGroup全局唯一
String producerGroup = "demo-procedure";
DefaultMQProducer producer = new DefaultMQProducer(producerGroup);
producer.setNamesrvAddr(namesrv);
// 设置消息发送超时时间
producer.setSendMsgTimeout(1000);
// 设置发送异步消息重试次数
producer.setRetryTimesWhenSendAsyncFailed(3);
producer.start();
for (int i = 0; i < 10; i ++) {
SendResult result = producer.sendOneway(new Message("demo-topic", "demo-tag", ("msg-" + i).getBytes()));
System.out.println("send msg end:" + result);
}
producer.shutdown();
}
1.1.4 消费消息
public class DemoConsumer implements MessageListenerConcurrently {
public static void main(String[] args) throws MQClientException {
// consumerGroup
String consumerGroup = "demo-consumer";
String subscribeTopic = "demo-topic";
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(consumerGroup);
consumer.setNamesrvAddr(namesrv);
// 从第一个消息开始消费
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.subscribe(subscribeTopic, "*");
// 设置每次最大拉取消息量。仅在mq发生消息堆积时候有效
consumer.setConsumeMessageBatchMaxSize(1);
consumer.registerMessageListener(new DemoConsumer());
consumer.start();
}
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
MessageExt message = msgs.get(0);
String msg;
try {
msg = new String(message.getBody(), StandardCharsets.UTF_8);
System.out.println("receive msg:" + msg);
} catch (Exception e) {
e.printStackTrace();
int retryTimes = message.getReconsumeTimes();
// 超过3次不再重试
return retryTimes >= 3? ConsumeConcurrentlyStatus.CONSUME_SUCCESS: ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
}
1.2 顺序消息
1.2.1 全局顺序消息
7. 概念
对于指定的一个Topic,所有消息按照严格的先入先出(FIFO)的顺序来发布和消费。这个时候一个Topic中只有一个Queue。可以通过三种方式指定Queue数量
1 通过生产者指定:producer.setDefaultTopicQueueNums(4);
2 RocketMQ控制台创建Topic时候指定
3 使用mqadmin命令创建Topic时候指定
8. 适用场景
适用于性能要求不高,所有的消息严格按照FIFO原则来发布和消费的场景。
9. 示例
在证券处理中,以人民币兑换美元为Topic,在价格相同的情况下,先出价者优先处理,则可以按照FIFO的方式发布和消费全局顺序消息。
1.2.2 分区顺序消息
10. 概念
对于指定的一个Topic,仅保证在该Queue分区队列上的消息顺序,称为分区有序。
Queue选择一般通过创建自定义选择器,利用key或key的hashCode或者发送消息时候的自定义参数对该Topic包含的队列数取模
11. 适用场景
适用于性能要求高,以Sharding Key作为分区字段,在同一个区块中严格地按照FIFO原则进行消息发布和消费的场景。
12. 示例
用户注册需要发送发验证码,以用户ID作为Sharding Key,那么同一个用户发送的消息都会按照发布的先后顺序来消费。
电商的订单创建,以订单ID作为Sharding Key,那么同一个订单相关的创建订单消息、订单支付消息、订单退款消息、订单物流消息都会按照发布的先后顺序来消费。
1.2.3 测试代码
- 生产者
public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException {
// procedureGroup全局唯一
String producerGroup = "orderly-procedure";
DefaultMQProducer producer = new DefaultMQProducer(producerGroup);
producer.setNamesrvAddr(namesrv);
// 设置消息发送超时时间
producer.setSendMsgTimeout(1000);
// 设置发送异步消息重试次数
producer.setRetryTimesWhenSendAsyncFailed(3);
producer.start();
// 模拟10个订单
for (int orderId = 0; orderId < 10; orderId++) {
// 每一份订单包含的3个步骤都是顺序消费的
for (int j = 0; j < 3; j++) {
Message message = new Message("orderly-topic", "orderly-tag", ("msg-" + orderId + "-" + j).getBytes());
// send的第三个参数arg会传给messageSelector的第三个参数arg
producer.send(message,
(mqs, msg, arg) -> {
int len = mqs.size();
int id = (int) arg;
// 若希望全局有序,则设置queue数量只有一个或者这里仅选择第一个queue
MessageQueue queue = mqs.get(id % len);
System.out.println(new String(msg.getBody(), StandardCharsets.UTF_8) + "->" + queue.getQueueId());
return queue;
}, orderId);
}
}
producer.shutdown();
}
14. 消费者
public class OrderlyConsumer implements MessageListenerOrderly {
public static void main(String[] args) throws MQClientException {
// consumerGroup
String consumerGroup = "orderly-consumer";
String subscribeTopic = "orderly-topic";
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(consumerGroup);
consumer.setNamesrvAddr(namesrv);
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
consumer.subscribe(subscribeTopic, "*");
// 设置每次最大拉取消息量。仅在mq发生消息堆积时候有效
consumer.setConsumeMessageBatchMaxSize(1);
// 设置消费线程数最小值和最大值
consumer.setConsumeThreadMin(5);
consumer.setConsumeThreadMax(10);
consumer.registerMessageListener(new OrderlyConsumer());
consumer.start();
}
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
MessageExt message = msgs.get(0);
String msg;
try {
msg = new String(message.getBody(), StandardCharsets.UTF_8);
System.out.println("receive msg:" + msg + "->" + message.getQueueId());
} catch (Exception e) {
e.printStackTrace();
int retryTimes = message.getReconsumeTimes();
// 超过3次不再重试
return retryTimes >= 3? ConsumeOrderlyStatus.SUCCESS: ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
}
return ConsumeOrderlyStatus.SUCCESS;
}
}
可以看到5、8、9号订单都是有序处理的
1.3 延时消息
1.3.1 概述
- 概念
Producer将消息发送到消息队列RocketMQ服务端,但并不期望立马投递这条消息,而是延迟一定时间后才投递到Consumer进行消费,该消息即延时消息。 - 适用场景
消息生产和消费有时间窗口要求,例如在电商交易中超时未支付关闭订单的场景,在订单创建时会发送一条延时消息。这条消息将会在30分钟以后投递给消费者,消费者收到此消息后需要判断对应的订单是否已完成支付。如支付未完成,则关闭订单。如已完成支付则忽略。
1.3.2 延时等级
延时消息延长时间不支持随意时常的延时,通过特定的延时等级来指定的。延时等级在RocketMQ服务端的MessageStoreClient中。如果指定等级为3则延时是时长为10s
messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
1.3.3 实现原理
Procedure将消息发送给Broker后,Broker首先会将消息写入commitlog文件,然后需要将其分发到相应的consumequeue。在分发前,先判断消息是否带有延时等级。如果没有则正常分发,如果有延时等级则会有如下过程
- 修改消息的Topic为SCHEDULE_TOPIC_XXX
- 根据延时等级,在consumequeue目录中SCHEDULE_TOPIC_XXX主题下创建对应的queueId目录与consumequeue文件(queueId=延时等级-1)
- 修改消息索引单元内容。索引单元中的Message Tag HashCode部分原本放的是消息Tag的Hash值。现修改为消息的投递时间(消息存储时间+延时时间)
- 将消息索引写入到SCHEDULE_TOPIC_XXX主题下对应的consumequeue,按照消息存储时间排序
- Broker内部有一个延时消息服务类ScheuleMessageService,会消费SCHEDULE_TOPIC_XXX主题下的消息,即按照每条消息的投递时间,将延时消息投到目标topic中。在投递之前会从commitlog中将原来写入的消息再次取出,并将延时等级设置为0,即变成一条不延时的消息。
- Broker启动时,会创建并启动一个定时器Timer,用于执行相应的定时任务。系统会根据延时等级个数创建TimerTask,每个TimerTask会负责一个延时等级队列消息的消费与投递。每个TimerTask会检查队列中第一条消息是否到达投递时间,若到达,则开始消费队列里的消息
- ScheuleMessageService将延迟发送消息再一次发送给commitlog,并再次形成新的消息索引条目,分发到对应的Queue
1.3.4 代码实现
只需要在发送消息的时候设置延时时间即可
msg.setDelayTimeLevel(3);
1.4 事务消息
2 批量消息
2.1 批量发送
2.1.1 发送限制
生产者发送消息时可以一次发送多条消息,提高消息发送效率。不过需要注意一下几点
- 批量发送消息必须具有相同Topic
- 批量发送消息必须具有相同的刷盘策略
- 批量发送消息只能是普通消息,不能是延时消息或者事务消息
2.1.2 批量发送大小
默认情况下一批消息大小不能超过4M,如果超过4M有两种解决方案
- 消息拆分,将大消息拆分成多个4M以内的消息
- 修改Procedure和Broker的maxMessageSize属性
2.1.3 消息大小计算
Procedure调用send()方法发送的Message,并不只是将Message序列化后发送到网络上, 而是通过这个Message生成一个字符串发送出去。这个字符串由四部分构成:Topic,Body,Log,Properties(k-v形式,包括生产者地址,生产时间,要发送的queueId等)构成,最终写道Broker中消息单元中的数据都来自于这些属性
2.2 批量消费
2.2.1 批量消费数量
MessageListenerConcurrently监听接口默认每次只能消费一条消息,若要一次消费多条消息可以修改setConsumeMessageBatchMaxSize属性来指定,不能超过32,因为一次拉取消息最多32条。若要修改拉取大小可以设置setPullBatchSize
2.2.2 消费数量控制
setConsumeMessageBatchMaxSize和setPullBatchSize并不是越大越好。
- setPullBatchSize值越大,Consumer每次拉取消息时间越长,网络传输出现的问题越高。若拉取出现问题那么本批消息都得重新拉取
- setConsumeMessageBatchMaxSize值越大,Consumer并发消费能力越低,且这批消息具有相同的处理结果。因为每批消息只会用一个线程来处理,只要一个消息处理异常那么整批消息都需要重新再次消费
2.3 代码示例
2.3.1 消息分割器
public class MessageSplitter implements Iterator<List<Message>> {
// 指定批次大小4M
private final Integer SIZE_LIMIT = 4 * 1024 * 1024;
// 要发送的消息
private List<Message> messageList;
// 消息批次索引
private Integer currIndex = 0;
public MessageSplitter(List<Message> messageList) {
this.messageList = messageList;
}
@Override
public boolean hasNext() {
return currIndex < messageList.size();
}
@Override
public List<Message> next() {
int nextIndex = currIndex;
int totalSize = 0;
while (nextIndex < messageList.size()) {
Message message = messageList.get(nextIndex);
// 计算消息大小
// 统计topic & body
final int[] msgSize = {message.getTopic().length() + message.getBody().length};
// 统计properties大小
if (!CollectionUtils.isEmpty(message.getProperties())) {
message.getProperties().forEach((k, v) -> msgSize[0] += k.length() + v.length());
}
// 统计日志
msgSize[0] += 20;
// 阈值判断
if (totalSize + msgSize[0] > SIZE_LIMIT) {
break;
} else {
totalSize += msgSize[0];
nextIndex ++;
}
}
List<Message> targetList = messageList.subList(currIndex, nextIndex);
currIndex = nextIndex;
return targetList;
}
}
2.3.2 消息生产者
public class BatchProcedure {
public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException {
// procedureGroup全局唯一
String producerGroup = "batch-procedure";
DefaultMQProducer producer = new DefaultMQProducer(producerGroup);
producer.setNamesrvAddr(namesrv);
// 设置消息发送超时时间
producer.setSendMsgTimeout(1000);
// 设置发送异步消息重试次数
producer.setRetryTimesWhenSendAsyncFailed(3);
producer.start();
List<Message> messageList = new ArrayList<>();
for (int i = 0; i < 100; i ++) {
messageList.add(new Message("demo-topic", "demo-tag", ("msg-" + i).getBytes()));
}
MessageSplitter messageSplitter = new MessageSplitter(messageList);
while (messageSplitter.hasNext()) {
List<Message> list = messageSplitter.next();
System.out.println("send msg list size:" + list.size());
producer.send(list);
}
producer.shutdown();
}
}
2.3.3 消息消费者
public class BatchConsumer implements MessageListenerConcurrently {
public static void main(String[] args) throws MQClientException {
// consumerGroup
String consumerGroup = "batch-consumer";
String subscribeTopic = "demo-topic";
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(consumerGroup);
consumer.setNamesrvAddr(namesrv);
// 从第一个消息开始消费
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.subscribe(subscribeTopic, "*");
// 设置每次最大拉取消息量。仅在mq发生消息堆积时候有效
consumer.setConsumeMessageBatchMaxSize(16);
consumer.registerMessageListener(new BatchConsumer());
consumer.start();
}
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.println("receive msg size:" + msgs.size());
for (MessageExt message: msgs) {
String msg = new String(message.getBody(), StandardCharsets.UTF_8);
System.out.println("receive msg:" + msg);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
}
3 消息过滤
3.1 Tag过滤
consumer.subscribe("topic", "tagA || tagB");
3.2 SQL过滤
SQL过滤是通过特定的表达式来对消息的properties属性进行筛选过滤。只有Push消费模式才能使用SQL过滤
需要在broker配置文件中开启SQL过滤功能
enablePropertityFilter = true
- 代码示例
consumer.subscribe(subscribeTopic, MessageSelector.bySql("age between 0 and 6"));
4 消息重试
4.1 生产者重试
4.1.1 重试机制
Procedure对发送失败的消息进行重试,需要注意以下几点
- 生产者发送消息时,若采用同步或者异步发送会重试,oneway方式不会重试
- 顺序消息没有重试机制,因为消息重试的时候会选择其他的broker尝试,而顺序消息只会发在特定Queue上
- 消息重试尽量保证消息发送成功,但是当网络抖动,消息量大时可能发生消息重复,消费端需要做幂等处理
- 消息发送重试有三种机制,同步发送失败、异步发送失败、消息刷盘失败
4.1.2 同步重试机制
对于普通消息,消息发送默认采用round-robin策略来选择所发送到的队列。如果发送失败,默认重试两次。但在重试的时候不会选择上次发送失败的broker,而是选择其他的broker。如果只有一个broker,则尝试发送到别的Queue
producer.setRetryTimesWhenSendFailed(3);
4.1.3 异步重试机制
异步发送失败重试时,重试机制不会选择其他broker,仅在同一个broker上重试,无法保证消息不丢。
producer.setRetryTimesWhenSendAsyncFailed(3);
4.1.4 消息刷盘失败重试
消息刷盘超时或slave不可用(slave返回master状态不是SEND_OK)时,默认是不会尝试将该消息发送给其他broker的,不过对于重要消息可以在broker的配置文件中设置retryAnotherBrokerWhenNotStoreOK=true开启
4.2 消息消费重试机制
4.2.1 顺序消息消费重试
顺序消息没有发送重试机制,但是有消费重试。对于顺序消息,Consumer消费失败后,为了保证消息顺序性 ,其会自动不断地进行消费重试,直到消费成功。消费重试默认时间间隔1000ms,重试期间会出现消息消费被阻塞的情况。
设置重试间隔时间
consumer.setSuspendCurrentQueueTimeMillis(100);
4.2.2 无序消息消费重试
对于无序消息(普通、延时、事务),当Consumer消费失败后可以通过返回重试状态(ConsumeConcurrentlyStatus.RECONSUME_LATER)来达到重试效果。不过消息重试仅对集群消费模式有效,广播消费模式不生效、
4.2.3 无序消息消费间隔
对于无序消息集群消费模式下的重试消费,每条消息默认最多重试16次,重试间隔逐步变长
修改消息重试次数
consumer.setMaxReconsumeTimes(10);
对于修改后的重试间隔,如果<16,则按照指定间隔进行重试
如果>16,则超过16次的重试时间间隔均为两小时
对于consumerGroup,如果仅修改了一个Consumer的消费重试次数,则会应用到该Group中所有Consumer实例。如果多个Consumer均作了修改则采用覆盖方式生效,即最后被修改的值会覆盖前面设置的值
4.2.4 重试队列
对于需要重试消费的消息,并不是Consumer等待了指定时长后再去拉取消息消费,而是将这些重试消息放到特殊的Topic中,然后再次消费,这些队列就是重试队列。这个队列是针对消费者组设置的,当出现需要重试的消息时才会为该消费者组创建重试队列
Broker对重试消息的处理是通过延时消息实现的,先将消息保存到SCHEDULE_TOPIC_XXXX延时队列中,达到延时时间后会投递到重试队列。
5 死信队列
消息重试达到最大次数后若依旧消费失败则会进入到死信队列中,死信队列具有如下特征
- 死信队列的消息不会被消费,即死信队列对于消费者来说是不可见的
- 死信队列存储有效期与正常消息相同,都是3天,即commitlog文件的过期时间,3天后会被自动删除
- 每个消费者组都有一个死信队列,在发生死信消息的时候创建