目录
消息发送分类
Producer对于消息的发送方式也有多种选择,不同的方式会产生不同的系统效果。
同步发送消息
同步发送消息是指,Prodecer发出一条消息后,会在收到MQ返回的ACK之后才发下一条消息。该方式的消息可靠性最高,但消息发送效率较低。
异步发送消息
异步发送消息是指,Producer发出消息后无需等待MQ返回ACK,直接发送下一条消息。该方式的消息可靠性可以得到保障,消息发送效率也可以。
单向发送消息
单向发送消息是指,Producer仅负责发送消息,不等待、不处理MQ的ACK。该发送方式时MQ也不返回ACK。该方式的消息发送效率最高,但消息可靠性较差。
/**
* 同步生产消息
*/
public static void main(String[] args) throws Exception {
/创建一个producer,参数为Producer Group名称
DefaultMQProducer producer new DefaultMQProducer("pg");
//指定nameServer址
producer. setNamesrvAddr("rocketmqos: 9876");
//开启生产者
producer.start();
//生产并发送100条消息
for (int i = 0; i < 100; i++) {
byte[] body = ("Hi," + i).getBytes();
Message msg new Message("someTopic", "someTag", body);
//发送消息
SendResult sendResult producer.send(msg);
System.out.println(sendResult);
}
//关producer
producer.shutdown();
}
//消息发送的状态
public enum Sendstatus {
SEND_OK, // 发送成功
FLUSH_DISK_TIMEOUT, // 刷盘超时。当Broker设置的刷盘策略为同步刷盘时才可能出现这种异常状态。异步刷盘不会出现
FLUSH_SLAVE_TIMEOUT, //Slave同步超时。当Broker集群设置的Master-slave的复制方式为同步复制时才可能出现这种异常状态。异步复制不会出现
SLAVE_NOT_AVAILABLE, //没有可用的Slave。当Broker集群设置为Master-slave的复制方式为同步复制时才可能出现这种异常状态。异步复制不会出现
}
/**
* 异步发送消息
*/
public static void main(String[] args) throws Exception {
//创建生产者
DefaultMQProducer producer = new DefaultMQProducer("pg1");
//设置nameserver地址
producer.setNamesrvAddr("192.168.18.128:9876");
//设置重试
producer.setRetryTimesWhenSendAsyncFailed(0);
//设置该topic的默认Queue数量
producer.setDefaultTopicQueueNums(2);
//开启生产者
producer.start();
//生产者发送消息
for (int i = 0; i < 100; i++) {
byte[] body = ("body:" + 1).getBytes();
Message message = new Message("mytopic", "tag", body);
producer.send(message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.println(sendResult);
}
@Override
public void onException(Throwable throwable) {
throwable.printStackTrace();
}
});
}
//睡眠一会,因为采用的是异步发送,Producer无需接收到MQ发送的ACK,所以Producer不能判断当前消息是否发送成功,所以,如果不在此处做睡眠操作,
// 就会造成消息尚未发送成功,生产者就关闭的情况
TimeUnit.SECONDS.sleep(3);
//关闭生产者
producer.shutdown();
}
/**
* 单向发送消息
*/
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("oneWay");
producer.setNamesrvAddr("192.168.18.128:9876");
producer.start();
for (int i = 0; i < 100; i++) {
byte[] body = ("body:" + 1).getBytes();
Message message = new Message("oneWay", "test", body);
producer.sendOneway(message);
}
producer.shutdown();
}
/**
* 消费者消费消息
*/
public static void main(String[] args) throws Exception {
//定义一个消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("cg");
//设置Nameserver地址
consumer.setNamesrvAddr("192.168.18.128:9876");
//设置从第一条开始消费
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
//指定消费topic和tag
consumer.subscribe("someTopic", "*");
//指定广播消费,默认为集群模式
consumer.setMessageModel(MessageModel.BROADCASTING);
//注册监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list,
ConsumeConcurrentlyContext consumeConcurrentlyContext) {
for (MessageExt item : list) {
System.out.println(item);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
//开始消费
consumer.start();
System.out.println("Consumer Started");
//关闭消费者
//consumer.shutdown();
}
顺序消息
什么是顺序消息
顺序消息指的是,严格按照消息的发送顺序进行消费的消息。
默认情况下生产者会把消息以Round Robin轮询方式发送到不同的Queue分区队列中;
而消费消息时会从多个Queue上拉取消息,这种情况下的发送和消费不能保证顺序的。
如果将消息仅发送到同一Queue中,消费时也只从这个Queue上拉取消息,就严格保证了消息的顺序性。
为什么要顺序消息
例如,现在有TOPIC ORDER_STATUS,其下有4个Queue队列,该Topic中的不同消息用于描述当前订单的不同状态。假设订单有状态:
- 未支付
- 已支付
- 发货中
- 发货成功
- 发货失败
根据以上订单状态,生产者从时序上可以生成如下几个消息:
汀单T0000001 : 未支付 --> 订单T0000001 : 已支付 --> 订单T0000001 : 发送中 --> 订单T0000001 : 发货失败消息发送到MQ中之后,Queue的选择如果采用轮询策略,消息在MQ的存储可能如下:
这种情况下,我们希望Consumer消费消息的顺序和我们发送是一致的,然而上述MQ的投递和消费方式,我们无法保证顺序是正确的。对于顺序异常的消息,Consumer即使设置有一定的状态容错,也不能完全处理好这么多种随机出现组合情况
基于上述的情况,可以设计如下方案:对于相同订单号的消息,通过一定的策略,将其放置在一个Queue中,然后消费再采用一定的策略(例如,一个线程独立处理一个queue,保证处理消息的顺序性),能够保证消费的顺序性。
有序性分类
根据有序范围的不同,RocketMQ可以严格地保证两种消息的有序性:
- 分区有序
- 全局有序
全局有序
![](https://i-blog.csdnimg.cn/blog_migrate/bb7b8d91dabdf1341558c4931f13c391.png)
当发送和消费参与的Queue只有一个时所保证的有序是整个Topic中消息的顺序,称为全局有序。
- 在创建Topic时指定Queue的数量。有三种指定方式:
- 在代码中创建Producer时,可以指定其自动创建的Topic的Queue数量
- 使用mqadmin命令手动创建Topic时指定Queue数量
自动创建Topic
/指定异步发送失败后不进行重试发送
producer.setRetryTimesWhenSendAsyncFailed(0);
//指定新创建的Topic的Queue数量为2,默认为4
producer.setDefaultTopicQueueNums(2);
手动创建Topic
分区有序
如果有多个Queue参与,其仅可保证在该Queue分区队列上的消息顺序,则称为分区有序。
如何实现Queue的选择?
在定义Producer时我们可以指定消息队列选择器,而这个选择器是我们自己实现了MessageQueueSelector接口定义的,在定义选择器的选算法时,一般需要使用选择key。这个选择key可以是消息key也可以是其它数据。
但无论谁做选择key,都不能重复,都是唯一的。
一般性的选择算法是,让选择key(或其hash值)与该Topic所包含的Queue的数量取模,其结果即为选择出的 Queue的 Queueld。
取模算法存在一个问题:
不同选择key与Queue数量取模结果可能会是相同的,即不同选择key的消息可能会出现在相同的Queue,即同一个Consuemr可能会消费到不同选择key的消息。
这个问题如何解决?
一般性的作法是,从消息中获取到选择key,对其进行判断。若是当前Consumer需要消费
的消息,则直接消费,否则,什么也不做。这种做法要求选择key要能够随着消息一起被Consumer获取到。此时使用消息key作为选择key是比较好的做法。
以上做法会不会出现如下新的问题呢?
不属于那个Consumer的消息被拉取走了
那么应该消费该消息的Consumer是否还能再消费该消息呢?
同一个Queue中的消息不可能被同一个Group中的不同Consumer同时消费。所以,消费同一个Queue的不同选择key的消息的Consumer一定属于不同的Group。而不同的Group中的Consumer间的消费是相互隔离的,互不影响的。
arg = orderId ,选择key是 orderId,存在重复,不同的选择key最后选择的queue是相同的,接下来就是consumer对不同的选择key选择出来的queue处理
/**
* 消费顺序消息
*/
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("og");
consumer.setNamesrvAddr("192.168.18.128:9876");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.subscribe("orderTopic", "*");
consumer.setMessageModel(MessageModel.BROADCASTING);
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
list.stream().filter(item -> item.getKeys().equals("1")) //对应的业务key
.forEach(item -> {
System.out.println("当前消息的内容为:" + new String(item.getBody()));
});
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
System.out.println("消费完成");
}
28
延时消息
什么是延时消息
当消息写入到Broker后,在指定的时长后才可被消费处理的消息,称为延时消息。
采用RocketMQ的延时消息可以实现定时任务的功能,而无需使用定时器。典型的应用场景是,电商交易中超时未支付关闭订单的场景,12306平台订票超时未支付取消订票的场景。
在电商平台中,订单创建时会发送一条延迟消息。这条消息将会在30分钟后投递给后台业务系统
(Consumer),后台业务系统收到该消息后会判断对应的订单是否已经完成支付。如果未完成,
则取消订单,将商品再次放回到库;如果完成支付,则忽略。
在12306平台中,车票预订成功后就会发送一条延迟消息。这条消息将会在45分钟后投递给后台业
务系统(Consumer),后台业务系统收到该消息后会判断对应的订单是否已经完成支付。如果未
完成,则取消预订,将车票再次放回到票池;如果完成支付,则忽略。
延时等级
延时消息的延迟时长不支持随意时长的延迟,是通过特定的延迟等级来指定的。延时等级定义在
RocketMQ服务端的MessagestoreConfig类中的如下变量中:
即,若指定的延时等级为3,则表示延迟时长为10s。
当然,如果需要自定义的延时等级,可以通过在broker加载的配置中新增如下配置(例如下面增加了1天这个等级1d)。配置文件在RocketMQ安装目录下的conf目录中。
单机版在broker.config里面配置
如果是集群式的在2m-2s-async配置
messageDelayLevel
- 1s
- 5s
- 10s
- 30s
- 1m
- 2m
- 3m
- 4m
- 5m
- 6m
- 7m
- 8m
- 9m
- 10m
- 20m
- 30m
- 1h
- 2h
- 1d
延时消息实现原理
- Producer将消息发送到Broker后,Broker会首先将消息写入到commitLog文件
- 不过,在这之前,系统会先判断消息中是否带有延时等级
- 若没有,则正常分发到相应的consumeQueue
- 若有否则需要经历一个复杂的过程:
- 修改消息的Topic为SCHEDULE_TOPIC_XXXX
- 根据延时等级,在consumequeue目录中SCHEDULE_TOPIC_XXXX主题下创建出相应的queueid目录与consumequeue文件(如果没有的情况下)
![]()
延迟等级delayLevel 与queueld的对应关系为queueld = delayLevel -1
需要注意,在创建queueld目录时,并不是一次性地将所有延迟等级对应的目录全部创建完毕,而是用到哪个延迟等级创建哪个目录
修改消息索引单元的内容。索引单元中的Message Tag HashCode部分原本存放的是消息的Tag的Hash值。先修改为消息的投递时间。投递时间是指该消息被重新修改为原Topic后再次写入到commitlog中的时间。
投递时间 = 消息存储时间 + 延时时间。消息存储时间指的是消息被发送到Broker的时间戳。
、
将消息索引写入到SCHEDULE_TOPIC_XXXXX主题下相应的consumequeue中。
SCHEDULE_TOPIC _XXXX目录中各个延时等级Queue中的消息是如何排序的?
是按照消息投递时间排序的。
一个Broker中同一等级的所有延时消息会被写入到consumequeue目
录中SCHEDULE_TOPIC_XX目录下相同Queue中。即一个Queue中消息投递时间的延迟等级时间
是相同的。那么投递时间就取决于于消息存储时间了。即按照消息被发送到Broker的时间进行排
序的。
投递延时消息
Broker内部有一个延迟消息服务类,其会消费SCHEDULE TOPIC_XXXX中的消息,即按照每条消息的投递时间,将延时消息投递到目标Topic中。不过,在投递之前会从commitlog中将原来写入的消息再次读出,并将其原来的延时等级设置为0,即原消息变为了一条不延迟的普通消息。然后再次将消息投递到目标Topic中。
- ScheuleMessageService在Broker启动时,会创建并启动一个定时器TImer,用于执行相应的定时任务。
- 系统会根据延时等级的个数,定义相应数量的 TimerTask,每个 TimerTask负责一个延迟等级消息的消费与投递。
- 每个TimerTask都会检测相应Queue队列的第一条消息是否到期。
- 若第一条消息未到期,则后面的所有消息更不会到期(消息是按照投递时间排序的)
- 若第一条消息到期了,则将该消息投递到目标Topic,即消费该消息。
将消息重新写入commitlog
延迟消息服务类将延迟消息再次发送给了commitlog,并再次形成新的消息索引条目,分发到相应
Queue.
这其实就是一次普通消息发送。只不过这次的消息Producer是延迟消息服务类
ScheuleMessageService.
/**
* 生产延时消息
*/
public static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException {
DefaultMQProducer producer = new DefaultMQProducer("dpg");
producer.setNamesrvAddr("ip:端口");
producer.start();
byte[] body = ("延时消息").getBytes();
Message message = new Message("delayTopic", "test", body);
message.setDelayTimeLevel(3); //指定消息延迟等级为3 10s
SendResult result = producer.send(message);
//输出消息被发送的时间
System.out.println("当前时间为:" + LocalDateTime.now());
System.out.println(result);
producer.shutdown();
}
生产消息之后,10s钟之后消费者才开始消费
/**
* 消费延时消息
*/
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("dcg");
consumer.setNamesrvAddr("192.168.18.128:9876");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.setMessageModel(MessageModel.BROADCASTING);
consumer.subscribe("delayTopic", "*");
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
System.out.println(new String(list.get(0).getBody()));
System.out.println("消费时间为:" + LocalDateTime.now());
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
System.out.println("结束时间为:" + LocalDateTime.now());
}
事务消息
问题引入
这里的一个需求场景是:工行用户A向建行用户B转账1万元。
我们可以使用同步消息来处理该需求场景:
- 工行系统发送一个给B增款1万元的同步消息M给Broker
- 消息被Broker成功接收后,向工行系统发送成功ACK
- 工行系统收到成功ACK后从用户A中扣款1万元
- 建行系统从Broker中获取到消息M
- 建行系统消费消息M,即向用户B中增加1万元
这其中是有问题的:若第3步中的扣款操作失败,但消息已经成功发送到了Broker。对于MQ来说,只要消息写入成功,那么这个消息就可以被消费。此时建行系统中用B增加了1万元。出现了数据不一致问题。
解决思路
解决思路是:让第1、2、3步具有原子性,要么全部成功,要么全部失败,即消息发送成功后,必须要保证扣款成功。如果扣款失败,则回滚发送成功的消息。而该思路就是使用事务消息。这里使用分布式事务解决方案
- 事务管理器TM向事务协调器TC发起指令,开启全局事务
- 工行系统发一个给B增款1万元的事务消息M给TC
- TC会向Broker发送半事务消息prepareHalf,将消息M预提交到Broker。此时的建行系统是看不到Broker中的消息M的
- Broker会将预提交执行结果Report给TC。
- 如果预提交失败,则TC会向TM上报预提交失败的响应,全局事务结束;如果预提交成功,TC会调用工行系统的回调操作,去完成工行用户A的预扣款1万元的操作
- 工行系统会向TC发送预扣款执行结果,即本地事务的执行状态
- TC收到预扣款执行结果后,会将结果上报给TM。
- TM会根据上报结果向TC发出不同的确认指令
- 若预扣款成功(本地事务状态为COMMIT_MESSAGE),则TM向TC发送Global Commit指令
- 若预扣款失败(本地事务状态为ROLLBACK_MESSAGE),则TM向TC发送Global Rollback指令
- 若现未知状态(本地事务状态为UNKNOW),则会触发工行系统的本地事务状态回查操作。回查操作会将回查结果,即COMMIT_MESSAGE或ROLLBACK_MESSAGE Report给TC
- TC在接收到指令后会向Broker与工行系统发出确认指令
- TC接收的若是Global Commit指令,则向Broker与工行系统发送Branch Commit指令。此时Broker中的消息M才可被建行系统看到;此时的工行用户A中的扣款操作才真正被确认
- TC接收到的若是Global Rollback指令,则向Broker与工行系统发送Branch Rollback指令。此时Broker中的消息M将被撤销;工行用户A中的扣款操作将被回滚
- TM会根据上报结果向TC发出不同的确认指令
- TM会根据上报结果向TC发出不同的确认指令
- TC在接收到指令后会向Broker与工行系统发出确认指令
以上方案就是为了确保消息投递与扣款操作能够在一个事务中,要成功都成功,有一个失败,则全部回滚。
以上方案并不是一个典型的XA模式。因为XA模式中的分支事务是异步的,而事务消息方案中的消息预提交与预扣款操作间是同步的。
基础
分布式事务
对于分布式实物,通俗地说就是,一次操作有若干分支操作组成,这些分支操作分属不同应用,分布在不同服务器上。分布式事务需要保证这些分支操作要么全部成功,要么全部失败。分布式事务与普通事务一样,就是为了保证操作结果的一致性。
事务消息
RockerMQ提供了类似X/Open XA的分布式事务功能,通过事务消息能达到分布式事务的最终一致。XA是一个分布式事务的解决方案,一种分布式事务处理模式。
半事务消息
暂不能投递的消息,发送发已经成功地将消息发送到了Broker,但是Broker未收到最终确认指令,此时该消息被标记成"暂不能投递”状态,即不能被消费者看到。处于这种状态下的消息即半事务消息。
本地事务状态
Produer回调操作执行的结果为本地事务状态,其会发送给TC,而TC会再发送给TM。TM会根据TC发送来的本地事务状态来确定全局事务确认指令。
消息回查
消息回查,即重新查询本地事务的执行状态。本例就是重新到DB中查看预扣款操作是否执行成功。
注意:消息回查不是重新执行回调操作。
回调操作是进行预扣款操作,而消息回查则是查看预扣款操作执行的结果。
引发消息回查的原因最常见的有两个:
- 回调操作返回UNKNWON
- TC没有接收到TM的最终全局事务确认指令
RocktMO中消息回查设置
关于消息回查,有三个常见的属性设置。它们都在broker加载的配置文件中设置
例如:
- transactionTimeout=20,指定TM在20秒内应将最终确认状态发送给TC,否则引发消息回查。默认为60秒
- transactionCheckMax=5,指宝最多回查5次,超过后将丢弃消息并记录错误日志。默认15次。
- transactionCheckInterval=10,指定设置的多次消息回查的时间间隔为10秒。默认,为60秒。
XA模式三剑客
XA(Unix Transaction)是一种分布式事务解决方案,一种分布式事务处理模式,是基于XA协议的。XA协议由Tuxedo (Transaction for Unix has been Extended for Distributed Operation,分布式操作扩展之后的Unix事务系统)首先提出的,并交给X/Open组织,作为资源管理器与事务管理器的接口标准。
XA模式中有三个重要组件:
- TC
- TM
- RM
TC
Transaction Coordinator,事务协调者。维持全局和分支事物的状态,驱动全局事务提交或回滚。RocketMQ中Broker充当着TC。
TM
Transaction Manager,事务管理器,定义全局事务的范围:开始全局事务、提交或回滚全局事务。它实际是全局事务的发起者。
RocketMQ中事务消息的Producer充当着TM
RM
Resource Manager,资源管理器。管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回归。
XA模式架构
XA模式是一个典型的2PC,其执行原理如下:
2PC 指的是两阶段提交(Two-Phase Commit)协议,是一种用于分布式系统中实现事务的一致性的协议。在分布式环境中,由于涉及多个节点和资源,确保事务的原子性、一致性、隔离性和持久性(ACID 特性)变得更加复杂。2PC 协议旨在解决分布式系统中的事务一致性问题。
2PC 协议主要分为两个阶段:
准备阶段(Prepare Phase):在这个阶段,协调者(通常是事务管理器)向所有参与者(涉及到事务的节点或资源)发送准备请求,询问是否可以执行事务操作。每个参与者会执行事务的准备操作,并向协调者发送准备就绪的响应(可以提交或中止)。
提交阶段(Commit Phase):如果所有参与者都准备就绪,协调者会向所有参与者发送提交请求,要求它们执行事务的提交操作。如果有任何一个参与者在准备阶段失败或者拒绝提交,协调者会发送中止请求,要求所有参与者回滚事务。
- TM向TC发起指令,开启一个全局事务。
- 根据业务要求,各个RM会逐个向TC注册分支事务,然后TC会逐个向RM发出预执行指令。
- 各个RM在接收到指令后会在进行本地事务预执行。
- RM将预执行结果Report给TC。当然,这个结果可能是成功,也可能是失败。
- TC在接收到各个RM的Report后会将汇总结果上报给TM,根据汇总结果TM会向TC发出确认指令。
- 若所有结果都是成功响应,则向TC发送GlobalCommit指令。
- 只要有结果是失败响应,则向TC发送Global Rollback指令。
- TC在接收到指令后再次向RM发送确认指令。
事务消息方案并不是一个典型的XA模式。因为XA模式中的分支事务是异步的,而事务消息方案中的消息预提交与预扣款操作间是同步的。
注意
- 事务消息不支持延时消息
- 对于事物消息要做好幂等性检查,因为事务消息可能不止一次被消费(因为存在回滚后再提交的情况)
批量消息
批量发送消息
发送限制
生产者进行消息发送时可以一次发送多条消息,这可以大大提升Producer的发送效率。
不过需要注意以下几点:
- 批量发送的消息必须具有相同的Topic
- 批量发送的消息必须具有相同的刷盘策略
- 批量发送的消息不能是延时消息与事务消息
批量发送大小
默认情况下,批发送的消息总大小不能超过4MB字节,如果想超出该值,有两种解决方案:
- 方案一:将批量消息进行拆分,拆分为若干不大于4M的消息集合分多次批量发送
- 方案二:在Producer端和Broker端修改属性
- producer端需要在发送之前设置Producer的maxMessageSize属性
- Broker端需要修改其加载的配置文件中maxMessageSize属性
生产者发送的消息的大小
生产者通过send()发送的Message,并不是直接将Message序列化后发送到网网络上的,而是通过这个Message生成一个字符串发送出去的。
这个字符串由四部分组成:
- Topic
- 消息Body
- 消息日志(占20字节)
- 及用于描述消息的一堆属性key-value。
- 这些属性中包含列入生产者地址、生产时间、要发送的QueueId等。最终写入到Broker中消息单元的数据都是来自于这些属性。
批量消费消息
Consumer的MessageListenerConcurrent监听接口的consume Message方法的第一个参数为消息列表,但默认情况下每次只能消费一条消息。
若要使其一次可以消费多条消息,则可以通过修改Consumer的ConsumeMessageBatchMaXSize属性来指定,不过,该值不能超过32。(消费是消费的消息,拉取是拉取的消息,拉取一次最大限制是32条)
因为默认情况下消费者每次可以拉取的消息最多是32条,若要修改一次拉去的最大值,则可通过修改Consumer的pullBatchSize属性来指定。
存在的问题
Consumer的Pull Batch Sizes属性与ConsumerMessageBatchMaxSize属性是否设置的越大越好?当然不是。
- pullBatchSize值设置的越大
- Consumer每次拉取一次需要的时间就会越长,且在网路上传输问题的可能性就越高。
- 若在拉去过程中若出现了问题,那么本批次所有消息都需要重新拉去。
- consumeMessageBatchMaxSize值设置的越大
- Consumer的消息并发消费能力越低,且这批被消费的消息就有相同的消费结果,因为consumeMessageBatchMaxSize指定的一批消息只会使用一个线程进行处理
- 且在处理工程中只要有一个消息处理异常,则这批消息需要全部重新再次消费处理。
producer.setMaxMessageSize(); //设置发送消息的大小
consumer.setPullBatchSize(13);//设置每次拉取消息的大小
consumer.setConsumeMessageBatchMaxSize(13);//设置批量消费消息的最大值
由于生产者批量发送消息的大小不能大于4M(默认,可以修改)所以需要对大于4M的批量消息进行分割
class ListSplitter implements Iterator<List<Message>> {
private int sizeLimit = 1000 * 1000;
private final List<Message> messages;
private int currIndex;
public ListSplitter(List<Message> messages) {
this.messages = messages;
}
@Override
public boolean hasNext() {
return currIndex < messages.size();
}
@Override
public List<Message> next() {
int nextIndex = currIndex;
int totalSize = 0;
for (; nextIndex < messages.size(); nextIndex++) {
Message message = messages.get(nextIndex);
int tmpSize = message.getTopic().length() + message.getBody().length;
Map<String, String> properties = message.getProperties();
for (Map.Entry<String, String> entry : properties.entrySet()) {
tmpSize += entry.getKey().length() + entry.getValue().length();
}
tmpSize = tmpSize + 20; // 用于日志开销
if (tmpSize > sizeLimit) {
// 单个消息超过 sizeLimit 是意外情况
// 这里只是让它继续,否则会阻塞拆分过程
if (nextIndex - currIndex == 0) {
// 如果下一个子列表没有元素,则添加此元素然后中断,否则直接中断
nextIndex++;
}
break;
}
if (tmpSize + totalSize > sizeLimit) {
break;
} else {
totalSize += tmpSize;
}
}
List<Message> subList = messages.subList(currIndex, nextIndex);
currIndex = nextIndex;
return subList;
}
@Override
public void remove() {
throw new UnsupportedOperationException("不允许删除");
}
}
消息过滤
消费者进行消息订阅时,除了可以指定要订阅消息的Topic外,还可以对指定Topic中的消息根据指定条件进行过滤,即可以订阅比Topic更加细粒度的消息类型。
对于指定Topic消息的过滤有两种过滤方式:
- Tag过滤
- SQL过滤
Tag过滤
通过consumer的subscribe()方法指定要订阅消息的tag。如果订阅多个Tag的消息,Tag间使用或运算符(双竖线||)连接
SQL过滤
SQL过滤是一种通过特定表达式对事先埋入到消息中的用户属性进行筛选过滤的方式。(在生产者发送message的Properties)通过SQL过滤,可以实现对消息的复杂过滤,不过,只有使用PUSH模式的消费者才能使用SQL过滤。
SQL过滤表达式中支持多种常量类型和运算符。
支持的常量类型:
- 数值:比如:123,3.1415
- 字符:必须用单引号包裹起来,比如:'abc'
- 布尔:TRUE或FALSE
- NULL:特殊的常量,表示空
支持的运算符有:
- 数值比较:>,>=,<,<=,BETWEEN,=
- 字符较:=,<>,IN
- 逻辑运算:AND,OR,NOT
- NULL判断:IS NULL或者IS NOTNULL
首先要将Broker的SQLPropertiesFileter开启 enablePropertyFilter=true
/**
* 生产者
*/
public static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException {
DefaultMQProducer producer = new DefaultMQProducer("tagpg");
producer.setNamesrvAddr("192.168.18.128:9876");
producer.start();
String[] tags = {"myTagA", "myTagB", "myTagc"};
for (int i = 0; i < 100; i++) {
byte[] body = ("Hi," + i).getBytes();
String tag = tags[i%tags.length];
Message message = new Message("myTopic", tag, body );
SendResult result = producer.send(message);
System.out.println(result);
}
producer.shutdown();
}
/**
* 生产者
*/
public static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException {
DefaultMQProducer producer = new DefaultMQProducer("sqlpg");
producer.setNamesrvAddr("192.168.18.128:9876");
producer.start();
for (int i = 0; i < 100; i++) {
Message message = new Message("topicA", "filter", String.valueOf(i).getBytes());
message.putUserProperty("age", String.valueOf(i));
SendResult result = producer.send(message);
System.out.println(result);
}
producer.shutdown();
}
/**
* 消费者
*/
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("fcg");
consumer.setNamesrvAddr("192.168.18.128:9876");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.setMessageModel(MessageModel.BROADCASTING);
consumer.subscribe("topicA", MessageSelector.bySql("age > 90"));
//仅订阅Tag为 myTagA与 imyTagB的消息
consumer.subscribe("myTopic", "myTagA myTagB");
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
list.forEach(System.out::println);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
System.out.println("结束");
}
消息发送重试机制
说明:Producer对发送失败的消息进行重新发送的机制,称为消息发送重试机制,也成为消息重投机制。
对于消息重投,需要注意以下几点:
- 生产者再发送消息时,若采用同步或者异步方式,发送失败会重试,但oneway(单项)消息发送方式发送消息失败是没有重试机制的。
- 只有普通消息具有发送重试机制,顺序消息是没有的
- 消息重投机制可以保证消息尽可能发送成功、不丢失,但可能会造成消息重复。消息重复在RocketMQ中是无法避免的问题
- 消息重复在一般情况下不会发生,当出现消息量大、网络抖动,消息重复就会成为大概率事件
- producer主动重发,consumer负载变化(发送rebalance 不会导致消息重复,但可能会出现重复消费)也会导致重复消息(Rebalance)
- 消息重复无法避免,但要避免消息的重复消费
- 避免消息重复消费的解决方案是,为消息添加唯一标识(消息key),是消费者对消息进行消费判断来避免重复消费
- 消息发送重试有三种策略可以选择:
- 同步发送失败策略
- 异步发送失败策略
- 消息刷盘失败策略
同步发送策略
对于普通消息,消息发送默认采用round-robin策略来选择所发送到的队列。如果发送失败,默认重试2次,但在重试时是不会选择上次发送失败的Broker,而是选择其他Broker,当然,若只有一个Broker其也只能发送到该Broker,但其会尽量发送到该Broker上的其他Queue。
producer.setRetryTimesWhenSendFailed(2);
producer.setSendMsgTimeout(5000);
同时,Broker还具有失败隔离功能,使Producer尽量选择未发送过发送失败的Broker作为目标Broker。其可以保证其他消息尽量不发送到问题Broker,为了提高消息发送效率,降低消息发送耗时。
思考:让我们自己实现失败隔离功能,如何来做?
- 方案一:Producer中维护某JUC的Map集合,其key是发生失败的时间戳,value为Broker实例。Producer中还维护着一个Set集合,其中存放着所有未发生发送异常的Broker实例。选择目标Broker是从该Set集合中选择的。再定义一个定时任务,定期从Map集合中将长期未发生发送异常的Broker清理出去,并添加到Set集合。
- 方案二:为Producer中的Broker实例添加一个标识,例如是一个AtomicBoolean属性。只要该Broker上发生过发送异常,就将其置为true。选择目标Broker就是选择该属性值为false的Broker。再定义一个定时任务,定期将Broker的该属性置为false。
- 方案三:为Producer中的Broker实例添加一个标识,例如是一个AtomicLong属性。只要该Broker上发生过发送异常,就使其值增一。选择目标Broker就是选择该属性值最小的Broker。若该值相同,采用轮询方式选择。
如果超过重试次数,则抛出异常,由Producer去保证消息不丢,当然当生产者出现RemotingException,MQClientEeception和MQBrokerException时,Producer会自动重投消息。
异步发送失败策略
异步发送失败重试时,异步重试不会选择其他Broker,仅在同一个Broker上做重试,所以该策略无法保证消息不丢失
DefaultMQProducer producer = new DefaultMQProducer("pg");
producer.setNamesrvAddr("rocketmqos: 9876");
//指定异步发送失败后不进行重试发送
producer.setRetryTimeswhensendAsyncFailed(0):
消息刷盘失败策略
消息刷盘超时(Master或Slave)或slave不可用(slave在做数据同步时向master返回状态不是SEND_OK)时,默认是不会将消息尝试发送到其他Broker的。不过,对于重要消息可以通过在Broker的配置文件设置retryAnotherBrokerWHenNotStoreOK属性为true来开启。
消息消费重试机制
顺序消息的消息重试
对于顺序消息,当consumer消费消息失败后,为了保证消息的顺序性,其会自动不断地保证消息重试,直到消费成功。消费成功默认间隔时间为1000毫秒,重试期间应用会出现消息消费被阻塞的情况。
由于对顺序消息的重试是无休止的,不间断的,直至消费成功,所以,对于顺序消息的消费,务必要保证应用能够及时监控并处理消费失败的情况,避免消费被永久性阻塞。
//顺序消息消费失败的消费重试时间间隔,默认为1000毫秒,其取值范围为【10,30000】 consumer.setSuspendCurrentQueueTimeMillis(100);
注意,顺序消息没有发送失重试机制,但具有消费失重试机制
无序消息的消息重试
对与无须消息(
- 普通消息
- 延时消息
- 事务消息)
当Consumer消费消息失败时,可以通过设置返回状态达到重试的效果,不过需要注意,无序消息的重试只对集群消费方式生效,广播消费方式不提供失败重试特性,即对于广播消费,消费失败后,失败消费不再重试,继续消费后续消息。{广播消费,消费失败对整体没太大影响}
消费重试次数与间隔
对于无序消息集群消费下的重试消费,每条消息默认最多重试16次,但每次重试的间隔时间是不同的,会逐渐变长,每次重试的间隔时间如下表:
若一条消息在一直消费失败的前提下,将会在正常消费后的第4小时46分后进行第16次重试。若仍然失败,则将消息投递到死信队列
对于修改过的重试次数,将按照以下策略执行:
- ·若修改值小于16,则按照指定间隔进行重试
- 若修改值大于16,则超过16次的重试时间间隔均为2小时
对于Consumer Group,若仅修改了一个Consumer的消费重试次数,则会应用到该Group中所有其它 Consumer实例。若出现多个 Consumer均做了修改的情况,则采用覆盖方式生效。即最后被修改的值会覆盖前面设置的值。
重试队列
对于需要重试消费的消息,并不是Consumer在等待了指定时长后再次去拉去原来的消息进行消费,而是将这些需要重试消费的消息放入到了一个特殊Topic的队列中,而后进行再次消费的,这个特殊的队列就是重试队列。
当出现需要进行重试消费的消息时,Broker会为每个消费组设置一个Topic名称%RETRY%consumerGroup@consumerGroup的重试队列
- 这个重试队列是针对消息者组的,而不是针对每个Topic设置的(一个Topic的消息可以让多个消费者组进行消费,所以会为这些消费者组各创建一个重试队列)
- 只有当出现需要重试消费的消息时,才会为该消费者组创建重试队列
注意,消费重试的时间间隔与延时消费的延时等级十分相似,除了没有延时等级的前两个时间外,其它的时间都是相同的
Broker对于重试消息的处理是通过延时消息实现的,先将消息保存在SCHEDULE_TOPIC_XXXXX演示队列中,延时时间到后,会将消息投递到%RETRY%consumerGroup%consumerGroup重试队列中。
消息重试配置方式
集群消费方式下,消息消费失败后若希望消费重试,则需要在消息监听器接口中明确进行如下三种方式之一的配置:
- 方式一:返回ConsumeConcurrentlyStatus.RECONSUME_LATER(推荐)
- 方式二:返回null
- 方式三:抛出异常
消费不重试配置方式
集群消费方式下,消息消费失败后若不希望消息重试,则在捕获异常后同样返回与消费成功后相同的结果,即ConsumeConcurrentlyStatus.CONSUME_SUCCESS,则不进行消费重试。
死信队列
什么是死信队列
当一条消息初次消费失败后,消息队列会自动进行消费重试;
达到最大重试次数后,若消费依然失败,则表明消费者无法在正常情况下无法正确地消费该消息,此时,消息队列不会立即将消息丢弃,而是将其发送到该消费者对应的特殊队列,这个队列就是死信队列(Dead-Latter Queue,DLQ),而其中的消息则称为死信消息(Dead-Letter Message,DLM)
死信队列的特征
死信队列具有如下特征:
- 死信队列的消息不会再被消费者正常消费,即DLQ对消费者是不可见的
- 死信储存时间和正常消息时间相同,均为3天,3天后会自动删除
- 死信队列就是一个特殊的Topic,名称为%DLQ%consumerGroup@consumerGroup,即每个消费者组都会有一个死信队列
- 如果一个消费者组未产生死信消息,则不会为其创建相应的死信队列。
死信消息的处理
实际上,当一条消息进入到死信队列,就意味着系统中某些地方出现了问题,从而导致消费者无法正常消费该消息,比如代码中原本就存在Bug,因此,对于死信消息,通常需要开发人员进行特殊处理,最关键的步骤是排查可以因素,解决代码中存在的bug,然后将原来的死信消息再次进行投递消费。