RocketMQ学习与总结
一、基本介绍
1、应用场景
消息队列是一种先进先出的数据结构,常见的应用场景:
-
应用解耦:系统的耦合性越高,容错性就越低
实例:用户创建订单后,耦合调用库存系统、物流系统、支付系统,任何一个子系统出了故障都会造成下单异常,影响用户使用体验。使用消息队列解耦合,比如物流系统发生故障,需要几分钟恢复,将物流系统要处理的数据缓存到消息队列中,用户的下单操作正常完成。等待物流系统正常后处理存在消息队列中的订单消息即可,终端系统感知不到物流系统发生过几分钟故障

- 流量削峰:应用系统如果遇到系统请求流量的瞬间猛增,有可能会将系统压垮,使用消息队列可以将大量请求缓存起来,分散到很长一段时间处理,这样可以提高系统的稳定性和用户体验

- 数据分发:让数据在多个系统更加之间进行流通,数据的产生方不需要关心谁来使用数据,只需要将数据发送到消息队列,数据使用方直接在消息队列中直接获取数据

2、RocketMq的优势
- 说rocketMq的优势,一定是有对比的说明,比较常见的三大哥:rocketmq、rabbitmq、kfaka,它们各自擅长的领域是不同的

3、RocketMq的架构设计

二、安装测试
安装需要 Java 环境,下载解压后进入安装目录,进行启动:
-
启动 NameServer
1.启动 NameServer nohup sh bin/mqnamesrv & 2.查看启动日志 tail -f ~/logs/rocketmqlogs/namesrv.log
RocketMQ 默认的虚拟机内存较大,需要编辑如下两个配置文件,修改 JVM 内存大小
编辑runbroker.sh和runserver.sh修改默认JVM大小
vi runbroker.sh
vi runserver.sh
-
启动 Broker
1.启动 Broker nohup sh bin/mqbroker -n localhost:9876 autoCreateTopicEnable=true & 2.查看启动日志 tail -f ~/logs/rocketmqlogs/broker.log -
发送消息
1.设置环境变量 export NAMESRV_ADDR=localhost:9876 2.使用安装包的 Demo 发送消息 sh bin/tools.sh org.apache.rocketmq.example.quickstart.Producer -
接受消息
1.设置环境变量 export NAMESRV_ADDR=localhost:9876 2.接收消息 sh bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer -
关闭 RocketMQ
1.关闭 NameServer sh bin/mqshutdown namesrv 2.关闭 Broker sh bin/mqshutdown broker
三、相关概念
RocketMQ 主要由 Producer、Broker、Consumer 三部分组成,其中 Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息,NameServer 负责管理 Broke
-
代理服务器(Broker Server):消息中转角色,负责存储消息、转发消息。在 RocketMQ 系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备,也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等
-
名字服务(Name Server):充当路由消息的提供者。生产者或消费者能够通过名字服务查找各主题相应的 Broker IP 列表
-
消息生产者(Producer):负责生产消息,把业务应用系统里产生的消息发送到 Broker 服务器。RocketMQ 提供多种发送方式,同步发送、异步发送、顺序发送、单向发送,同步和异步方式均需要 Broker 返回确认信息,单向发送不需要;可以通过 MQ 的负载均衡模块选择相应的 Broker 集群队列进行消息投递,投递的过程支持快速失败并且低延迟
-
消息消费者(Consumer):负责消费消息,一般是后台系统负责异步消费,一个消息消费者会从 Broker 服务器拉取消息、并将其提供给应用程序。从用户应用的角度而提供了两种消费形式:
-
拉取式消费(Pull Consumer):应用通主动调用 Consumer 的拉消息方法从 Broker 服务器拉消息,主动权由应用控制,一旦获取了批量消息,应用就会启动消费过程
-
推动式消费(Push Consumer):该模式下 Broker 收到数据后会主动推送给消费端,实时性较高
-
-
生产者组(Producer Group):同一类 Producer 的集合,发送同一类消息且发送逻辑一致。如果发送的是事务消息且原始生产者在发送之后崩溃,则 Broker 服务器会联系同一生产者组的其他生产者实例以提交或回溯消费
-
消费者组(Consumer Group):同一类 Consumer 的集合,消费者实例必须订阅完全相同的 Topic,消费同一类消息且消费逻辑一致。消费者组使得在消息消费方面更容易的实现负载均衡和容错。RocketMQ 支持两种消息模式:
-
集群消费(Clustering):相同 Consumer Group 的每个 Consumer 实例平均分摊消息
-
广播消费(Broadcasting):相同 Consumer Group 的每个 Consumer 实例都接收全量的消息
-
每个 Broker 可以存储多个 Topic 的消息,每个 Topic 的消息也可以分片存储于不同的 Broker,Message Queue(消息队列)是用于存储消息的物理地址,每个 Topic 中的消息地址存储于多个 Message Queue 中
-
主题(Topic):表示一类消息的集合,每个主题包含若干条消息,每条消息只属于一个主题,是 RocketMQ 消息订阅的基本单位
-
消息(Message):消息系统所传输信息的物理载体,生产和消费数据的最小单位,每条消息必须属于一个主题。RocketMQ 中每个消息拥有唯一的 Message ID,且可以携带具有业务标识的 Key,系统提供了通过 Message ID 和 Key 查询消息的功能
-
标签(Tag):为消息设置的标志,用于同一主题下区分不同类型的消息。标签能够有效地保持代码的清晰度和连贯性,并优化 RocketMQ 提供的查询系统,消费者可以根据 Tag 实现对不同子主题的不同消费逻辑,实现更好的扩展性
-
普通顺序消息(Normal Ordered Message):消费者通过同一个消息队列(Topic 分区)收到的消息是有顺序的,不同消息队列收到的消息则可能是无顺序的
-
严格顺序消息(Strictly Ordered Message):消费者收到的所有消息均是有顺序的
四、普通消息操作
1、消息发送方式分类
-
同步发送消息
-
异步发送消息
-
单向发送消息
-
批量发送 (Batch Send)
-
延迟发送消息
-
顺序发送消息
导入 MQ 客户端依赖:
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.4.0</version>
</dependency>
消息发送者步骤分析:
1、创建消息生产者 Producer,并制定生产者组名
2、指定 Nameserver 地址
3、启动 Producer
4、创建消息对象,指定主题 Topic、Tag 和消息体
5、发送消息
6、关闭生产者 Producer
消息消费者步骤分析:
1、创建消费者 Consumer,制定消费者组名
2、指定 Nameserver 地址
3、订阅主题 Topic 和 Tag
4、设置回调函数,处理消息
5、启动消费者 Consumer
2、发送消息
同步发送:同步发送指消息发送方发出数据后会在收到接收方发回响应之后才发下一个数据包。一般用于重要通知消息,例如重要通知邮件、营销短信
特点:
-
发送线程阻塞等待Broker响应
-
强一致性,确保消息成功送达
-
吞吐量最低但最可靠

public class SyncProducer {
public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message(
"TopicTest" /* Topic */,
"TagA" /* Tag */,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */);
// 发送消息到一个Broker
SendResult sendResult = producer.send(msg);
// 通过sendResult返回消息是否成功送达
System.out.printf("%s%n", sendResult);
}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}
异步发送:异步发送指发送方发出数据后,不等接收方发回响应,接着发送下个数据包,一般用于可能链路耗时较长而对响应时间敏感的业务场景,例如用户视频上传后通知启动转码服务
特点:
-
非阻塞方式,通过回调返回结果
-
高性能,适合高并发场景
-
需要处理发送失败情况

public class AsyncProducer {
public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
producer.setRetryTimesWhenSendAsyncFailed(0);
int messageCount = 100;
// 根据消息数量实例化倒计时计算器
final CountDownLatch2 countDownLatch = new CountDownLatch2(messageCount);
for (int i = 0; i < messageCount; i++) {
final int index = i;
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest", "TagA", "OrderID188",
"Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));
// SendCallback接收异步返回结果的回调
producer.send(msg, new SendCallback() {
// 发送成功回调函数
@Override
public void onSuccess(SendResult sendResult) {
countDownLatch.countDown();
System.out.printf("异步发送成功:%s%n", sendResult);
System.out.printf("%-10d OK %s %n", index, sendResult.getMsgId());
}
@Override
public void onException(Throwable e) {
countDownLatch.countDown();
System.out.printf("异步发送失败:%s%n", e.getMessage());
System.out.printf("%-10d Exception %s %n", index, e);
e.printStackTrace();
}
});
}
// 等待5s
countDownLatch.await(5, TimeUnit.SECONDS);
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}
单向发送:单向发送是指只负责发送消息而不等待服务器回应且没有回调函数触发,适用于某些耗时非常短但对可靠性要求并不高的场景,例如日志收集
特点:
-
发送后立即返回,不关心结果
-
最高吞吐量,但可能丢失消息
-
适合日志收集等低可靠性要求的场景

public class OnewayProducer {
public static void main(String[] args) throws Exception{
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest","TagA",
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
// 发送单向消息,没有任何返回结果
producer.sendOneway(msg);
}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}
批量发送 (Batch Send)
特点:
-
单次发送多条消息减少网络开销
-
消息总大小不超过1MB(默认4MB需调整参数)
代码示例:
List<Message> messages = new ArrayList<>();
for (int i = 0; i < 100; i++) {
messages.add(new Message("BatchTopic", "TagA", ("Hello_" + i).getBytes()));
}
// 批量发送
SendResult result = producer.send(messages);
// 精确分批(避免超过大小限制)
ListSplitter splitter = new ListSplitter(messages);
while (splitter.hasNext()) {
List<Message> subList = splitter.next();
producer.send(subList);
}
大小限制配置:
// 调整Broker端最大消息大小(需同步修改Broker配置)
producer.setMaxMessageSize(1024 * 1024); // 1MB
延迟发送消息
特点:
-
支持18个固定延迟级别
-
商业版支持任意时间延迟
代码示例:
Message msg = new Message("DelayTopic", "订单超时提醒".getBytes());
// 设置延迟级别3(对应10秒)
msg.setDelayTimeLevel(3);
producer.send(msg);
延迟级别对应表:

顺序发送消息
特点:
-
保证相同业务ID的消息发送到同一队列
-
消费者按顺序消费
代码示例:
MessageQueueSelector selector = (mqs, msg, arg) -> {
Long orderId = (Long) arg;
long index = orderId % mqs.size();
return mqs.get((int) index);
};
for (long orderId = 0; orderId < 100; orderId++) {
Message msg = new Message("OrderTopic", "订单创建".getBytes());
producer.send(msg, selector, orderId);
}
3、消费消息
public class Consumer {
public static void main(String[] args) throws InterruptedException, MQClientException {
// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");
// 设置NameServer的地址
consumer.setNamesrvAddr("localhost:9876");
// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("TopicTest", "*");
// 注册消息监听器,回调实现类来处理从broker拉取回来的消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
// 接受消息内容
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
// 标记该消息已经被成功消费
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者实例
consumer.start();
System.out.printf("Consumer Started.%n");
}
}
4、springboot整合rocketmq实现消息发送的不同方式
application.yml 配置
# 端口
server:
port: 8083
# 配置 rocketmq
rocketmq:
name-server: 127.0.0.1:9876
#生产者
producer:
#生产者组名,规定在一个应用里面必须唯一
group: test-transation
#消息发送的超时时间 默认3000ms
send-message-timeout: 3000
#消息达到4096字节的时候,消息就会被压缩。默认 4096
compress-message-body-threshold: 4096
#最大的消息限制,默认为128K
max-message-size: 4194304
#同步消息发送失败重试次数
retry-times-when-send-failed: 3
#在内部发送失败时是否重试其他代理,这个参数在有多个broker时才生效
retry-next-server: true
#异步消息发送失败重试的次数
retry-times-when-send-async-failed: 3
RocketMQController
/**
* 普通信息的三种方式:同步、异步、单向
* @author qzz
*/
@RestController
public class RocketMQCOntroller {
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 发送普通消息
* convertAndSend(String destination, Object payload) 发送字符串比较方便
*/
@RequestMapping("/send")
public void send(){
rocketMQTemplate.convertAndSend("test-topic","test-message");
}
/**
* 发送同步消息
*/
@RequestMapping("/testSyncSend")
public void testSyncSend(){
//参数一:topic 如果想添加tag,可以使用"topic:tag"的写法
//参数二:消息内容
SendResult sendResult = rocketMQTemplate.syncSend("test-topic","同步消息测试");
System.out.println(sendResult);
}
/**
* 发送异步消息
*/
@RequestMapping("/testASyncSend")
public void testASyncSend(){
//参数一:topic 如果想添加tag,可以使用"topic:tag"的写法
//参数二:消息内容
//参数三:回调
rocketMQTemplate.asyncSend("test-topic", "异步消息测试", new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.println(sendResult);
}
@Override
public void onException(Throwable throwable) {
System.out.println("消息发送异常");
throwable.printStackTrace();
}
});
}
/**
* 发送单向消息
*/
@RequestMapping("/testOneWay")
public void testOneWay(){
//参数一:topic 如果想添加tag,可以使用"topic:tag"的写法
//参数二:消息内容
rocketMQTemplate.sendOneWay("test-topic","单向消息测试");
}
}
RocketMQTransationListener
/**
* 消费消息
* 配置RocketMQ监听
* @author qzz
*/
@Service
@RocketMQMessageListener(consumerGroup = "test",topic = "test-topic")
public class RocketMQConsumerListener implements RocketMQListener<String> {
@Override
public void onMessage(String s) {
System.out.println("消费消息:"+s);
}
}
五、生产者发送消息的顺序性
消息的顺序需要由两个阶段保证:
-
消息发送有序
-
消息消费有序
1、原理解析
消息有序指的是一类消息消费时,能按照发送的顺序来消费。例如:一个订单产生了三条消息分别是订单创建、订单付款、订单完成。消费时要按照这个顺序消费才能有意义,但是同时订单之间是可以并行消费的,RocketMQ 可以严格的保证消息有序
根据有序范围的不同,RocketMQ可以严格地保证两种消息的有序性:分区有序与全局有序:
-
全局顺序:当消息的发送和消费只有一个Queue时整个Topic中消息的顺序, 称为全局有序
在创建Topic时指定Queue的数量。有三种指定方式: (1)、在代码中创建Producer时,可以指定其自动创建的Topic的Queue数量 (2)、在RocketMQ可视化控制台中手动创建Topic时指定Queue数量 (3)、使用mqadmin命令手动创建Topic时指定Queue数量

- 分区顺序:如果有多个Queue参与,其仅可保证在该Queue分区队列上的消息顺序,则称为分区有序


在默认的情况下消息发送会采取 Round Robin 轮询方式把消息发送到不同的 queue(分区队列),而消费消息是从多个 queue 上拉取消息,这种情况发送和消费是不能保证顺序。但是如果控制发送的顺序消息只依次发送到同一个 queue 中,消费的时候只从这个 queue 上依次拉取,则就保证了顺序。当发送和消费参与的 queue 只有一个,则是全局有序;如果多个queue 参与,则为分区有序,即相对每个 queue,消息都是有序的
队列分区机制

-
消息分配规则:相同订单号的消息通过哈希算法分配到同一队列
-
顺序保障基础:单个队列内的消息天然有序(FIFO)
顺序消费三要素
-
顺序发送:确保相同业务ID的消息进入同一队列
-
顺序存储:队列内消息物理存储有序
-
顺序消费:消费者单线程处理单个队列
2、代码实现
一个订单的顺序流程是:创建、付款、推送、完成,订单号相同的消息会被先后发送到同一个队列中,消费时同一个 OrderId 获取到的肯定是同一个队列
-
订单的步骤
private static class OrderStep { private long orderId; private String desc; // set + get } private List<OrderStep> buildOrders() { List<OrderStep> orderList = new ArrayList<OrderStep>(); OrderStep orderDemo = new OrderStep(); orderDemo.setOrderId(15103111039L); orderDemo.setDesc("创建"); orderList.add(orderDemo); orderDemo = new OrderStep(); orderDemo.setOrderId(15103111039L); orderDemo.setDesc("付款"); orderList.add(orderDemo); orderDemo = new OrderStep(); orderDemo.setOrderId(15103111039L); orderDemo.setDesc("推送"); orderList.add(orderDemo); orderDemo = new OrderStep(); orderDemo.setOrderId(15103111039L); orderDemo.setDesc("完成"); orderList.add(orderDemo); orderDemo = new OrderStep(); orderDemo.setOrderId(15103111065L); orderDemo.setDesc("创建"); orderList.add(orderDemo); orderDemo = new OrderStep(); orderDemo.setOrderId(15103111065L); orderDemo.setDesc("付款"); orderList.add(orderDemo); orderDemo = new OrderStep(); orderDemo.setOrderId(15103111065L); orderDemo.setDesc("推送"); orderList.add(orderDemo); orderDemo = new OrderStep(); orderDemo.setOrderId(15103111065L); orderDemo.setDesc("完成"); orderList.add(orderDemo); return orderList; } } -
生产者
public class Producer { public static void main(String[] args) throws Exception { DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); producer.setNamesrvAddr("127.0.0.1:9876"); producer.start(); // 标签集合 String[] tags = new String[]{"TagA", "TagC", "TagD"}; // 订单列表 List<OrderStep> orderList = new Producer().buildOrders(); Date date = new Date(); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String dateStr = sdf.format(date); for (int i = 0; i < 10; i++) { // 加个时间前缀 String body = dateStr + " Hello RocketMQ " + orderList.get(i); Message msg = new Message("OrderTopic", tags[i % tags.length], "KEY" + i, body.getBytes()); /** * 参数一:消息对象 * 参数二:消息队列的选择器 * 参数三:选择队列的业务标识(订单 ID) */ SendResult sendResult = producer.send(msg, new MessageQueueSelector() { @Override /** * mqs:队列集合 * msg:消息对象 * arg:业务标识的参数 */ public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) { Long id = (Long) arg; long index = id % mqs.size(); // 根据订单id从mqs集合中选择要发送queue return mqs.get((int) index); } }, orderList.get(i).getOrderId());//订单id System.out.println(String.format("SendResult status:%s, queueId:%d, body:%s", sendResult.getSendStatus(), sendResult.getMessageQueue().getQueueId(), body)); } producer.shutdown(); }
new MessageQueueSelector() 返回的是该消息需要发送到的队列。
是以OrderId 作为分区分类标准,对所有队列个数取余,来对将相同OrderId 的消息发送到同一个队列中,确保消息的顺序性
3、springboot整合rocketmq实现消息的顺序性
@RequestMapping("mq/template/send/order")
public void sendMessageOrderly(){
rocketMQTemplate.setMessageQueueSelector(new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message message, Object o) {
Long id = (Long) arg;
long index = id % mqs.size(); // 根据订单id选择发送queue
return mqs.get((int) index);
}
});
rocketMQTemplate.syncSendOrderly("order-topic","this is order message",orderList.get(i).getOrderId());
}
六、消费者接收消息的顺序性
RocketMQ 消费过程包括两种,分别是并发消费和有序消费
-
并发消费
-
并发消费的接口 MessageListenerConcurrently
-
并发消费是 RocketMQ 默认的处理方法,
-
并发消费 场景,消费者使用线程池技术,可以并发消费多条消息,提升机器的资源利用率。
-
默认配置是 20 个线程,所以一台机器默认情况下,同一瞬间可以消费 20 个消息。
-
-
有序消费 MessageListenerOrderly
有序消费模式 的接口是,MessageListenerOrderly。 在消费的时候,还需要保证消费者注册MessageListenerOrderly类型的回调接口,去实现顺序消费,如果消费者采用MessageListenerConcurrently并行消费,则仍然不能保证消息消费顺序
MessageListenerOrderly 有序消息监听器

下面是一个例子:
// 顺序消息消费,带事务方式(应用可控制Offset什么时候提交)
public class ConsumerInOrder {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
consumer.setNamesrvAddr("127.0.0.1:9876");
// 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费
// 如果非第一次启动,那么按照上次消费的位置继续消费
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
// 订阅三个tag
consumer.subscribe("OrderTopic", "TagA || TagC || TagD");
consumer.registerMessageListener(new MessageListenerOrderly() {
Random random = new Random();
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
context.setAutoCommit(true);
for (MessageExt msg : msgs) {
// 可以看到每个queue有唯一的consume线程来消费, 订单对每个queue(分区)有序
System.out.println("consumeThread=" + Thread.currentThread().getName() + "queueId=" + msg.getQueueId() + ", content:" + new String(msg.getBody()));
String content = new String(msg.getBody());
OrderStep orderStep = JsonUtil.jsonToPojo(content, OrderStep.class);
try{
// 业务代码
}catch (Exception e) {
e.printStackTrace();
}
}
return ConsumeOrderlyStatus.SUCCESS;
}
});
consumer.start();
System.out.println("Consumer Started.");
}
}
顺序消费的事件监听器为 MessageListenerOrderly,表示顺序消费。
-
并发消费消息时,当消费失败时,会默认延迟重试16次。
-
有序消费消息时,重试次数为 Integer.MAX_VALUE,而且不延迟。
换言之,有序消费场景,如果某一条消息消费失败且重试始终失败,将会导致后续的消息无法消费,产生消息的积压。所以,顺序消费消息时,一定要谨慎处理异常情况。防止消息队列积压。
生产者发送消息的顺序性和消费者接收消息的顺序性总结:
但是这里有一个问题:目前的queue是固定的,那如果后面broker进行了维护、扩容等操作导致队列数量变化了,这时的数据就会有问题,它们会分散在不同的队列中,就无法保证顺序了!这里要考虑业务场景是否容忍在集群下消息短暂丢失顺序的问题。如果可以,使用分区有序性比较合适!
有分区顺序,就有全局顺序,如果业务对顺序的要求是零容忍,那么我们也可以采取牺牲分布式特性failover,就是说如果有一个broker出现问题或者不可用,整个集群不可用!
如果付款消息消费异常,我们保证顺序的前提下,我们是不能消费推送消息的!所以仅仅有分区顺序还不够,还需要再消费时避免异步并发消费,并且如果消费失败立即重试,阻塞住当前的消费队列!造成消息队列积压!
A、生产者顺序保障
1、队列选择器(MessageQueueSelector)
// 订单ID哈希取模选择队列
MessageQueueSelector selector = (mqs, msg, arg) -> {
String orderId = (String) arg;
int index = Math.abs(orderId.hashCode()) % mqs.size();
return mqs.get(index);
};
// 发送顺序消息
producer.send(msg, selector, orderId);
关键点:
-
相同orderId的消息总是进入同一队列
-
不同orderId的消息均匀分布到不同队列
2、发送模式控制
-
同步发送:确保消息成功写入队列
-
失败重试:自动重试保证最终成功
B、Broker存储保障
1、顺序写入CommitLog
graph LR
Msg1 --> CommitLog
Msg2 --> CommitLog
Msg3 --> CommitLog
-
所有消息顺序追加到CommitLog文件
-
写入操作使用全局锁保证严格顺序
2、消费队列索引
consumequeue/
TopicA/
0/00000000000000000000
1/00000000000000000000
-
每个队列对应独立的ConsumeQueue文件
-
存储消息在CommitLog的物理位置(顺序不变)
C、消费者顺序处理
1、顺序消费模式
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs,
ConsumeOrderlyContext context) {
// 单线程处理队列消息
return ConsumeOrderlyStatus.SUCCESS;
}
});
关键机制:
-
每个队列对应独立消费线程
-
队列消费加分布式锁(Broker端控制)
-
失败时挂起队列而非重试乱序
D、异常情况处理
1、消费失败处理
public ConsumeOrderlyStatus consumeMessage(...) {
try {
// 业务处理
return ConsumeOrderlyStatus.SUCCESS;
} catch (Exception e) {
// 挂起当前队列
context.setSuspendCurrentQueueTimeMillis(1000);
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
}
}
2、消息重试机制
-
不自动重试:避免破坏顺序
-
人工干预:修复数据后重置offset
七、延迟消息
延迟消息就是指生产者发送消息之后,消息不会立马被消费,而是等待一定的时间之后再被消息
RocketMQ的延迟消息用起来非常简单,只需要在创建消息的时候指定延迟级别,之后这条消息就成为延迟消息了
Message message = new Message("sanyouTopic", "三友的java日记 0".getBytes());
//延迟级别
message.setDelayTimeLevel(1);
1、底层原理
RocketMQ延迟消息的延迟时间默认有18个级别,不同的延迟级别对应的延迟时间不同

RocketMQ内部有一个 Topic,专门用来表示是延迟消息的,叫 SCHEDULE_TOPIC_XXXX,XXXX不是占位符,就是XXXX
RocketMQ会根据 DelayTimeLevel(延迟级别)的个数为 SCHEDULE_TOPIC_XXXX 这个Topic创建相对应数量的队列
比如默认延迟级别是18,那么 SCHEDULE_TOPIC_XXXX 这个 Topic 里就有18个队列,队列的id从0开始,所以延迟级别为1时,对应的队列id【queueId】就是0,为2时对应的就是1,依次类推;延迟级别delayLevel与queueId的对应关系为:queueId = delayLevel -1

那SCHEDULE_TOPIC_XXXX这个Topic有什么作用呢?
这就得从消息存储时的一波偷梁换柱的包装骚操作了说起了
当服务端接收到消息的时候,判断 DelayTimeLevel(延迟级别)> 0 的时候,说明是延迟消息,此时会干下面三件事:
-
将消息的Topic改成SCHEDULE_TOPIC_XXXX
-
将消息的队列id设置为延迟级别对应的队列id;延迟级别delayLevel与queueId的对应关系为:queueId = delayLevel -1
-
将消息真正的Topic和队列id存到前面提到的消息存储时的额外信息中
之后消息就按照正常存储的步骤存到CommitLog文件中
由于消息存到的是 SCHEDULE_TOPIC_XXXX 这个Topic中,而不是消息真正的目标Topic中,所以消费者此时是消费不到消息的
举个例子,比如有条消息,Topic为sanyou,所在的队列id = 1,延迟级别 = 1,那么偷梁换柱之后的结果如下图所示




2、延时消息实现原理

3、延时等级
延时消息的延迟时长不支持随意时长的延迟,是通过特定的延迟等级来指定的。延时等级定义在RocketMQ服务端的MessageStoreConfig类中的如下变量中:

即,若指定的延时等级为 3 ,则表示延迟时长为10s,即延迟等级是从 1 开始计数的。
当然,如果需要自定义的延时等级,可以通过在broker加载的配置中新增如下配置(例如下面增加了 1天这个等级1d)然后重启broke生效。配置文件在RocketMQ安装目录下的conf目录中。

4、代码案例实现
提交了一个订单就可以发送一个延时消息,1h 后去检查这个订单的状态,如果还是未付款就取消订单释放库存
-
生产者
public class ScheduledMessageProducer { public static void main(String[] args) throws Exception { // 实例化一个生产者来产生延时消息 DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup"); producer.setNamesrvAddr("127.0.0.1:9876"); // 启动生产者 producer.start(); int totalMessagesToSend = 100; for (int i = 0; i < totalMessagesToSend; i++) { Message message = new Message("DelayTopic", ("Hello scheduled message " + i).getBytes()); // 设置延时等级3,这个消息将在10s之后发送(现在只支持固定的几个时间,详看delayTimeLevel) message.setDelayTimeLevel(3); // 发送消息 producer.send(message); } // 关闭生产者 producer.shutdown(); } } -
消费者
public class ScheduledMessageConsumer { public static void main(String[] args) throws Exception { // 实例化消费者 DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("ExampleConsumer"); consumer.setNamesrvAddr("127.0.0.1:9876"); // 订阅Topics consumer.subscribe("DelayTopic", "*"); // 注册消息监听者 consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> messages, ConsumeConcurrentlyContext context) { for (MessageExt message : messages) { // 打印延迟的时间段 System.out.println("Receive message[msgId=" + message.getMsgId() + "] " + (System.currentTimeMillis() - message.getBornTimestamp()) + "ms later");} return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); // 启动消费者 consumer.start(); } }
八、批量消息
1、发送限制
生产者进行消息发送时可以一次发送多条消息,这可以大大提升Producer的发送效率。不过需要注意以下几点:
-
批量发送的消息必须具有相同的Topic
-
批量发送的消息必须具有相同的刷盘策略【waitStoreMsgOK】
-
批量发送的消息不能是延时消息与事务消息
2、批量发送大小
默认情况下,一批发送的消息总大小不能超过4MB字节。如果想超出该值,有两种解决方案:
-
方案一:将批量消息进行拆分,拆分为若干不大于4M的消息集合分多次批量发送
-
方案二:在Producer端与Broker端修改属性
Producer端需要在发送之前设置Producer的maxMessageSize属性
Broker端需要修改其加载的配置文件中的maxMessageSize属性
3、生产者发送的消息大小

生产者通过send()方法发送的Message,并不是直接将Message序列化后发送到网络上的,而是通过这个Message生成了一个字符串发送出去的。这个字符串由四部分构成:Topic、消息Body、消息日志(占 20 字节),
及用于描述消息的一堆属性key-value。这些属性中包含例如生产者地址、生产时间、要发送的QueueId等。最终写入到Broker中消息单元中的数据都是来自于这些属性。
4、一批消息的总大小不应超过 4M 案例
public class Producer {
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup")
producer.setNamesrvAddr("127.0.0.1:9876");
//启动producer
producer.start();
List<Message> msgs = new ArrayList<Message>();
// 创建消息对象,指定主题Topic、Tag和消息体
Message msg1 = new Message("BatchTopic", "Tag1", ("Hello World" + 1).getBytes());
Message msg2 = new Message("BatchTopic", "Tag1", ("Hello World" + 2).getBytes());
Message msg3 = new Message("BatchTopic", "Tag1", ("Hello World" + 3).getBytes());
msgs.add(msg1);
msgs.add(msg2);
msgs.add(msg3);
// 发送消息
SendResult result = producer.send(msgs);
System.out.println("发送结果:" + result);
// 关闭生产者producer
producer.shutdown();
}
}
4、当发送大批量数据时,可能不确定消息是否超过了大小限制(4MB),所以需要将消息列表分割一下
实现的方式一
public class ListSplitter implements Iterator<List<Message>> {
private final int SIZE_LIMIT = 1024 * 1024 * 4;
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 startIndex = getStartIndex();
int nextIndex = startIndex;
int totalSize = 0;
for (; nextIndex < messages.size(); nextIndex++) {
Message message = messages.get(nextIndex);
int tmpSize = calcMessageSize(message);
// 单个消息超过了最大的限制
if (tmpSize + totalSize > SIZE_LIMIT) {
break;
} else {
totalSize += tmpSize;
}
}
List<Message> subList = messages.subList(startIndex, nextIndex);
currIndex = nextIndex;
return subList;
}
private int getStartIndex() {
Message currMessage = messages.get(currIndex);
int tmpSize = calcMessageSize(currMessage);
while (tmpSize > SIZE_LIMIT) {
currIndex += 1;
Message message = messages.get(curIndex);
tmpSize = calcMessageSize(message);
}
return currIndex;
}
private int calcMessageSize(Message message) {
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; // 增加⽇日志的开销20字节
return tmpSize;
}
public static void main(String[] args) {
//把大的消息分裂成若干个小的消息
ListSplitter splitter = new ListSplitter(messages);
while (splitter.hasNext()) {
try {
List<Message> listItem = splitter.next();
producer.send(listItem);
} catch (Exception e) {
e.printStackTrace();
//处理error
}
}
}
}
实现的方式二
定义消息列表分割器
// 消息列表分割器:其只会处理每条消息的大小不超4M的情况。
// 若存在某条消息,其本身大小大于4M,这个分割器无法处理,
// 其直接将这条消息构成一个子列表返回。并没有再进行分割
public class MessageListSplitter implements Iterator<List<Message>> {
// 指定极限值为4M
private final int SIZE_LIMIT = 4 * 1024 * 1024 ;
// 存放所有要发送的消息
private final List<Message> messages;
// 要进行批量发送消息的小集合起始索引
private int currIndex;
public MessageListSplitter(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);
// 统计当前遍历的message的大小
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 ;
// 判断当前消息本身是否大于4M
if (tmpSize > SIZE_LIMIT) {
if (nextIndex - currIndex == 0 ) {
nextIndex++;
}
break;
}
if (tmpSize + totalSize > SIZE_LIMIT) {
break;
} else {
totalSize += tmpSize;
}
} // end-for
// 获取当前messages列表的子集合[currIndex, nextIndex)
List<Message> subList = messages.subList(currIndex, nextIndex);
// 下次遍历的开始索引
currIndex = nextIndex;
return subList;
}
}
定义批量消息生产者
public class BatchProducer {
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("pg");
producer.setNamesrvAddr("rocketmqOS:9876");
// 指定要发送的消息的最大大小,默认是4M
// 不过,仅修改该属性是不行的,还需要同时修改broker加载的配置文件中的
// maxMessageSize属性
// producer.setMaxMessageSize(8 * 1024 * 1024);
producer.start();
// 定义要发送的消息集合
List<Message> messages = new ArrayList<>();
for (int i = 0 ; i < 100 ; i++) {
byte[] body = ("Hi," + i).getBytes();
Message msg = new Message("someTopic", "someTag", body);
messages.add(msg);
}
// 定义消息列表分割器,将消息列表分割为多个不超出4M大小的小列表
MessageListSplitter splitter = new MessageListSplitter(messages);
while (splitter.hasNext()) {
try {
List<Message> listItem = splitter.next();
producer.send(listItem);
} catch (Exception e) {
e.printStackTrace();
}
}
producer.shutdown();
}
}
定义批量消息消费者
public class BatchConsumer {
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("cg");
consumer.setNamesrvAddr("rocketmqOS:9876");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.subscribe("someTopicA", "*");
// 指定每次可以消费 10 条消息,默认为 1
consumer.setConsumeMessageBatchMaxSize( 10 );
// 指定每次可以从Broker拉取 40 条消息,默认为 32
consumer.setPullBatchSize( 40 );
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,ConsumeConcurrentlyContext context) {
for (MessageExt msg : msgs) {
System.out.println(msg);
}
// 消费成功的返回结果
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
// 消费异常时的返回结果
// return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
});
consumer.start();
System.out.println("Consumer Started");
}
}
九、过滤消息
消息者在进行消息订阅时,除了可以指定要订阅消息的Topic外,还可以对指定Topic中的消息根据指定条件进行过滤,即可以订阅比Topic更加细粒度的消息类型
对于指定Topic消息的过滤有两种过滤方式:Tag过滤与SQL过滤
1、Tag过滤(最常用)
通过consumer的subscribe()方法指定要订阅消息的Tag。如果订阅多个Tag的消息,Tag间使用或运算符(双竖线||)连接
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_EXAMPLE");
consumer.subscribe("TOPIC", "TAGA || TAGB || TAGC");
代码示例一
a、生产者发送Tag消息
// 发送带Tag的消息
Message msg = new Message("OrderTopic", "PaySuccess", "订单支付成功".getBytes());
producer.send(msg);
// 发送多个Tag的消息
Message msg2 = new Message("OrderTopic", "Create|Pay", "订单创建".getBytes());
producer.send(msg2);
b、消费者订阅指定Tag
// 只消费PaySuccess标签的消息
consumer.subscribe("OrderTopic", "PaySuccess");
// 消费多个Tag的消息(用||分隔)
consumer.subscribe("OrderTopic", "PaySuccess || Refund");
// 消费所有Tag的消息
consumer.subscribe("OrderTopic", "*");
代码示例二
@Service
@RocketMQMessageListener(topic = "OrderTopic",
selectorExpression = "PaySuccess || Refund", // 指定过滤Tag
consumerGroup = "order-consumer-group"
)
public class TagFilterConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
System.out.println("收到Tag过滤消息: " + message);
}
}
2、SQL过滤
SQL过滤是一种通过特定表达式对事先埋入到消息中的用户属性进行筛选过滤的方式。通过SQL过滤,可以实现对消息的复杂过滤。不过,只有使用PUSH模式的消费者才能使用SQL过滤。
SQL过滤表达式中支持多种常量类型与运算符。
支持的常量类型:
-
数值:比如: 123 ,3.1415
-
字符:必须用单引号包裹起来,比如:'abc'
-
布尔:TRUE 或 FALSE
-
NULL:特殊的常量,表示空
支持的运算符有:
-
数值比较:>,>=,<,<=,BETWEEN,=
-
字符比较:=,<>,IN
-
逻辑运算 :AND,OR,NOT
-
NULL判断:IS NULL 或者 IS NOT NULL
默认情况下Broker没有开启消息的SQL过滤功能,需要在Broker加载的配置文件中添加如下属性,以开启该功能:
enablePropertyFilter = true
在启动Broker时需要指定这个修改过的配置文件。例如对于单机Broker的启动,其修改的配置文件是conf/broker.conf,启动时使用如下命令:
sh bin/mqbroker -n localhost:9876 -c conf/broker.conf &
代码举例
定义Tag过滤Producer
public class FilterByTagProducer {
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("pg");
producer.setNamesrvAddr("rocketmqOS:9876");
producer.start();
String[] tags = {"myTagA","myTagB","myTagC"};
for (int i = 0 ; i < 10 ; i++) {
byte[] body = ("Hi," + i).getBytes();
String tag = tags[i%tags.length];
Message msg = new Message("myTopic",tag,body);
SendResult sendResult = producer.send(msg);
System.out.println(sendResult);
}
producer.shutdown();
}
}
定义Tag过滤Consumer
public class FilterByTagConsumer {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("pg");
consumer.setNamesrvAddr("rocketmqOS:9876");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.subscribe("myTopic", "myTagA || myTagB");
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,ConsumeConcurrentlyContext context) {
for (MessageExt me:msgs){
System.out.println(me);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
System.out.println("Consumer Started");
}
}
定义SQL过滤Producer
public class FilterBySQLProducer {
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("pg");
producer.setNamesrvAddr("rocketmqOS:9876");
producer.start();
for (int i = 0 ; i < 10 ; i++) {
try {
byte[] body = ("Hi," + i).getBytes();
Message msg = new Message("myTopic", "myTag", body);
msg.putUserProperty("age", i + "");
SendResult sendResult = producer.send(msg);
System.out.println(sendResult);
} catch (Exception e) {
e.printStackTrace();
}
}
producer.shutdown();
}
}
定义SQL过滤Consumer
public class FilterBySQLConsumer {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("pg");
consumer.setNamesrvAddr("rocketmqOS:9876");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.subscribe("myTopic", MessageSelector.bySql("age between 0 and 6"));
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for (MessageExt me:msgs){
System.out.println(me);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
System.out.println("Consumer Started");
}
}
代码示例
1、生产者设置消息属性
Message msg = new Message("OrderTopic", "订单消息".getBytes());
// 设置消息属性
msg.putUserProperty("orderType", "VIP");
msg.putUserProperty("amount", "1000");
msg.putUserProperty("region", "Shanghai");
producer.send(msg);
2、消费者使用SQL过滤
// 使用SQL表达式订阅
consumer.subscribe("OrderTopic",
MessageSelector.bySql("orderType = 'VIP' AND amount > 100"));
// 复杂条件示例
consumer.subscribe("OrderTopic",
MessageSelector.bySql("(orderType = 'VIP' AND amount > 1000) OR region = 'Shanghai'"));
3、支持的SQL语法
-- 比较操作
a > 100 AND b < 1000
c = 'VIP' OR d IS NOT NULL
-- 范围查询
e BETWEEN 100 AND 200
f IN ('A', 'B', 'C')
-- 空值判断
g IS NULL
h IS NOT NULL
-- 模糊查询
i LIKE '%test%'
4、Spring Boot 集成示例
@Service
@RocketMQMessageListener(
topic = "OrderTopic",
selectorType = SelectorType.SQL92,
selectorExpression = "orderType = 'VIP' AND amount > 1000",
consumerGroup = "vip-order-group"
)
public class SqlFilterConsumer implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt message) {
System.out.println("收到VIP大额订单: " + new String(message.getBody()));
}
}
十、事务消息
1、事务消息原理
RocketMQ 在 4.3 版本之后实现了完整的事务消息,基于MQ的分布式事务方案,本质上是对本地消息表的一个封装,整体流程与本地消息表一致,唯一不同的就是将本地消息表存在了MQ内部,而不是业务数据库,事务消息解决的是生产端的消息发送与本地事务执行的原子性问题,这里的界限一定要清楚,是确保 MQ 生产端正确无误地将消息发送出来,没有多发,也不会漏发,至于发送后消费端有没有正常的消费消息,这种异常场景将由 MQ 消息消费失败重试机制来保证
RocketMQ 设计中的 broker 与 producer 端的双向通信能力,使得 broker 天生可以作为一个事务协调者;而 RocketMQ 本身提供的存储机制则为事务消息提供了持久化能力;RocketMQ 的高可用机制以及可靠消息设计则为事务消息在系统发生异常时依然能够保证达成事务的最终一致性
2、RocketMQ 实现事务一致性的原理

(1)正常情况:在事务主动方服务正常,没有发生故障的情况下,发消息流程如下:
步骤①:MQ 发送方向 MQ Server 发送 half 消息,MQ Server 标记消息状态为 prepared(预处理状态),此时该消息 MQ 订阅方是无法消费到的
步骤②:MQ Server 将消息持久化成功之后,向发送方 ACK 确认消息已经成功接收
步骤③:发送方开始执行本地事务逻辑
步骤④:发送方根据本地事务执行结果向 MQ Server 提交二次确认,commit 或 rollback
最终步骤:MQ Server 如果收到的是 commit 操作,则将半消息标记为可投递,MQ订阅方最终将收到该消息;若收到的是 rollback 操作则删除 half 半消息,订阅方将不会接受该消息;如果本地事务执行结果没响应或者超时,则 MQ Server 回查事务状态,具体见步骤(2)的异常情况说明
(2)异常情况:在断网或者应用重启等异常情况下,图中的步骤④提交的二次确认超时未到达 MQ Server,此时的处理逻辑如下:
步骤⑤:MQ Server 对该消息进行消息回查,默认回查次数为15次
步骤⑥:发送方收到消息回查后,检查该消息的本地事务执行结果
步骤⑦:发送方根据检查得到的本地事务的最终状态再次提交二次确认
最终步骤:MQ Server基于 commit/rollback 对消息进行投递或者删除
3、RocketMQ事务消息的实现流程:
以 RocketMQ 4.5.2 版本为例,事务消息有专门的一个Tope(主题) RMQ_SYS_TRANS_HALF_TOPIC,所有的 prepare (预处理消息)都先往这里放,当消息收到 Commit 请求后,就将消息转移到真实的 Topic 中的队列里,供 Consumer 消费:

当应用模块的事务因为中断或者其他的网络原因导致无法立即响应的,RocketMQ 会当做 UNKNOW 处理,对此 RocketMQ 事务消息提供了一个补救方案:定时回查事务消息的事务执行状态,简易流程图如下:

4、基本使用
事务消息共有三种状态:提交状态、回滚状态、中间状态:
-
TransactionStatus.CommitTransaction:提交事务,允许消费者消费此消息
-
TransactionStatus.RollbackTransaction:回滚事务,代表该消息将被删除,不允许被消费
-
TransactionStatus.Unknown:中间状态,代表需要检查消息队列来确定状态
使用限制:
1、事务消息不支持延时消息和批量消息
2、Broker 配置文件中的参数 transactionTimeout 为特定时间,事务消息将在特定时间长度之后被检查。当发送事务消息时,还可以通过设置用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS 来改变这个限制,该参数优先于 transactionTimeout 参数
3、为了避免单个消息被检查太多次而导致半队列消息累积,默认将单个消息的检查次数限制为 15 次,开发者可以通过 Broker 配置文件的 transactionCheckMax 参数来修改此限制。如果已经检查某条消息超过 N 次(N = transactionCheckMax), 则 Broker 将丢弃此消息,在默认情况下会打印错误日志。可以通过重写 AbstractTransactionalMessageCheckListener 类来修改这个行为
4、事务性消息可能不止一次被检查或消费
5、提交给用户的目标主题消息可能会失败,可以查看日志的记录。事务的高可用性通过 RocketMQ 本身的高可用性机制来保证,如果希望事务消息不丢失、并且事务完整性得到保证,可以使用同步的双重写入机制
6、事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询,MQ 服务器能通过消息的生产者 ID 查询到消费者
5、析一下异常场景
异常1:如果发送 prepare(预备消息)失败,下面的流程不会走下去;这个是正常的
异常2:如果发送 prepare(预备消息)成功,但执行本地事务失败;这个也没有问题,因为此预备消息不会被消费端订阅到,消费端不会执行业务
异常3:如果发送 prepare(预备消息)成功,执行本地事务成功,但发送确认消息失败;这个就有问题了,因为用户A扣款成功了,但加钱业务没有订阅到确认消息,无法加钱。这里出现了数据不一致
6、创建事务消息表,解决异常3问题
7、案例
生产者投递消息阶段
订单服务
在订单服务中,我们接收前端的请求创建订单,保存相关数据到本地数据库
1、事务日志表
在订单服务中,除了有一张订单表之外,还需要一个事务日志表。 它的定义如下:
CREATE TABLE `transaction_log` (
`id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '事务ID',
`business` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '业务标识',
`foreign_key` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '对应业务表中的主键',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
这张表专门作用于事务状态回查。当提交业务数据时,此表也插入一条数据,它们共处一个本地事务中。通过事务ID查询该表,如果返回记录,则证明本地事务已提交;
如果未返回记录,则本地事务可能是未知状态或者是回滚状态。
2、TransactionMQProducer
我们知道,通过 RocketMQ发送消息,需先创建一个消息发送者。值得注意的是,如果发送事务消息,
在这里我们的创建的实例必须是 TransactionMQProducer
@Component
public class TransactionProducer {
private String producerGroup = "order_trans_group";
private TransactionMQProducer producer;
//用于执行本地事务和事务状态回查的监听器
@Autowired
OrderTransactionListener orderTransactionListener;
//执行任务的线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 60,
TimeUnit.SECONDS, new ArrayBlockingQueue<>(50));
@PostConstruct
public void init(){
producer = new TransactionMQProducer(producerGroup);
producer.setNamesrvAddr("127.0.0.1:9876");
producer.setSendMsgTimeout(Integer.MAX_VALUE);
producer.setExecutorService(executor);
producer.setTransactionListener(orderTransactionListener);
this.start();
}
private void start(){
try {
this.producer.start();
} catch (MQClientException e) {
e.printStackTrace();
}
}
//事务消息发送
public TransactionSendResult send(String data, String topic) throws MQClientException {
Message message = new Message(topic,data.getBytes());
return this.producer.sendMessageInTransaction(message, null);
}
}
上面的代码中,主要就是创建事务消息的发送者。
在这里,我们重点关注 OrderTransactionListener,它负责执行本地事务和事务状态回查。
3、OrderTransactionListener
@Component
public class OrderTransactionListener implements TransactionListener {
@Autowired
OrderService orderService;
@Autowired
TransactionLogService transactionLogService;
Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public LocalTransactionState executeLocalTransaction(Message message, Object o) {
logger.info("开始执行本地事务....");
LocalTransactionState state;
try{
String body = new String(message.getBody());
OrderDTO order = JSONObject.parseObject(body, OrderDTO.class);
orderService.createOrder(order,message.getTransactionId());
state = LocalTransactionState.COMMIT_MESSAGE;
logger.info("本地事务已提交。{}",message.getTransactionId());
}catch (Exception e){
logger.info("执行本地事务失败。{}",e);
state = LocalTransactionState.ROLLBACK_MESSAGE;
}
return state;
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
logger.info("开始回查本地事务状态。{}",messageExt.getTransactionId());
LocalTransactionState state;
String transactionId = messageExt.getTransactionId();
if (transactionLogService.get(transactionId)>0){
state = LocalTransactionState.COMMIT_MESSAGE;
}else {
state = LocalTransactionState.UNKNOW;
}
logger.info("结束本地事务状态查询:{}",state);
return state;
}
}
在通过 producer.sendMessageInTransaction发送事务消息后,如果消息发送成功,
就会调用到这里的executeLocalTransaction方法,来执行本地事务。在这里,它会完成订单数据和事务日志的插入。
该方法返回值 LocalTransactionState 代表本地事务状态,它是一个枚举类。
public enum LocalTransactionState {
//提交事务消息,消费者可以看到此消息
COMMIT_MESSAGE,
//回滚事务消息,消费者不会看到此消息
ROLLBACK_MESSAGE,
//事务未知状态,需要调用事务状态回查,确定此消息是提交还是回滚
UNKNOW;
}
那么, checkLocalTransaction 方法就是用于事务状态查询。在这里,我们通过事务ID查询transaction_log这张表,
如果可以查询到结果,就提交事务消息;如果没有查询到,就返回未知状态。
注意,这里还涉及到另外一个问题。如果是返回未知状态,RocketMQ Broker服务器会以1分钟的间隔时间不断回查,直至达到事务回查最大检测数,
如果超过这个数字还未查询到事务状态,则回滚此消息。
当然,事务回查的频率和最大次数,我们都可以配置。在 Broker 端,可以通过这样来配置它
brokerConfig.setTransactionCheckInterval(10000); //回查频率10秒一次
brokerConfig.setTransactionCheckMax(3); //最大检测次数为3
业务实现类
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
OrderMapper orderMapper;
@Autowired
TransactionLogMapper transactionLogMapper;
@Autowired
TransactionProducer producer;
Snowflake snowflake = new Snowflake(1,1);
Logger logger = LoggerFactory.getLogger(this.getClass());
//执行本地事务时调用,将订单数据和事务日志写入本地数据库
@Transactional
@Override
public void createOrder(OrderDTO orderDTO,String transactionId){
//1.创建订单
Order order = new Order();
BeanUtils.copyProperties(orderDTO,order);
orderMapper.createOrder(order);
//2.写入事务日志
TransactionLog log = new TransactionLog();
log.setId(transactionId);
log.setBusiness("order");
log.setForeignKey(String.valueOf(order.getId()));
transactionLogMapper.insert(log);
logger.info("订单创建完成。{}",orderDTO);
}
//前端调用,只用于向RocketMQ发送事务消息
@Override
public void createOrder(OrderDTO order) throws MQClientException {
order.setId(snowflake.nextId());
order.setOrderNo(snowflake.nextIdStr());
producer.send(JSON.toJSONString(order),"order");
}
}
在订单业务服务类中,我们有两个方法。一个用于向RocketMQ发送事务消息,一个用于真正的业务数据落库。
至于为什么这样做,其实有一些原因的,我们后面再说。
Controller层
@RestController
public class OrderController {
@Autowired
OrderService orderService;
Logger logger = LoggerFactory.getLogger(this.getClass());
@PostMapping("/create_order")
public void createOrder(@RequestBody OrderDTO order) throws MQClientException {
logger.info("接收到订单数据:{}",order.getCommodityCode());
orderService.createOrder(order);
}
}
目前已经完成了订单服务的业务逻辑。我们总结流程如下:

考虑到异常情况,这里的要点如下:
第一次调用createOrder,发送事务消息。如果发送失败,导致报错,则将异常返回,此时不会涉及到任何数据安全。
如果事务消息发送成功,但在执行本地事务时发生异常,那么订单数据和事务日志都不会被保存,因为它们是一个本地事务中。
如果执行完本地事务,但未能及时的返回本地事务状态或者返回了未知状态。那么,会由Broker定时回查事务状态,然后根据事务日志表,就可以判断订单是否已完成,并写入到数据库。
基于这些要素,我们可以说,已经保证了订单服务和事务消息的一致性。那么,接下来就是积分服务如何正确的消费订单数据并完成相应的业务操作
消费者消费消息阶段
积分服务
在积分服务中,主要就是消费订单数据,然后根据订单内容,给相应用户增加积分
1、积分记录表
CREATE TABLE `t_points` (
`id` bigint(16) NOT NULL COMMENT '主键',
`user_id` bigint(16) NOT NULL COMMENT '用户id',
`order_no` bigint(16) NOT NULL COMMENT '订单编号',
`points` int(4) NOT NULL COMMENT '积分',
`remarks` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
在这里,我们重点关注order_no字段,它是实现幂等消费的一种选择
2、消费者启动
@Component
public class Consumer {
String consumerGroup = "consumer-group";
DefaultMQPushConsumer consumer;
@Autowired
OrderListener orderListener;
@PostConstruct
public void init() throws MQClientException {
consumer = new DefaultMQPushConsumer(consumerGroup);
consumer.setNamesrvAddr("127.0.0.1:9876");
consumer.subscribe("order","*");
consumer.registerMessageListener(orderListener);
consumer.start();
}
}
3、消费者监听器
@Component
public class OrderListener implements MessageListenerConcurrently {
@Autowired
PointsService pointsService;
Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
logger.info("消费者线程监听到消息。");
try{
for (MessageExt message:list) {
logger.info("开始处理订单数据,准备增加积分....");
OrderDTO order = JSONObject.parseObject(message.getBody(), OrderDTO.class);
pointsService.increasePoints(order);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}catch (Exception e){
logger.error("处理消费者数据发生异常。{}",e);
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
}
监听到消息之后,调用业务服务类处理即可。处理完成则返回CONSUME_SUCCESS以提交,
处理失败则返回RECONSUME_LATER来重试
4、增加积分
在这里,主要就是对积分数据入库。但注意,入库之前需要先做判断,来达到幂等性消费
@Service
public class PointsServiceImpl implements PointsService {
@Autowired
PointsMapper pointsMapper;
Snowflake snowflake = new Snowflake(1,1);
Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public void increasePoints(OrderDTO order) {
//入库之前先查询,实现幂等
if (pointsMapper.getByOrderNo(order.getOrderNo())>0){
logger.info("积分添加完成,订单已处理。{}",order.getOrderNo());
}else{
Points points = new Points();
points.setId(snowflake.nextId());
points.setUserId(order.getUserId());
points.setOrderNo(order.getOrderNo());
Double amount = order.getAmount();
points.setPoints(amount.intValue()*10);
points.setRemarks("商品消费共【"+order.getAmount()+"】元,获得积分"+points.getPoints());
pointsMapper.insert(points);
logger.info("已为订单号码{}增加积分。",points.getOrderNo());
}
}
}
5、幂等性消费
实现幂等性消费的方式有很多种,具体怎么做,根据自己的情况来看。
比如,在本例中,我们直接将订单号和积分记录绑定在同一个表中,在增加积分之前,就可以先查询此订单是否已处理过。
或者,我们也可以额外创建一张表,来记录订单的处理情况。
再者,也可以将这些信息直接放到redis缓存里,在入库之前先查询缓存。
不管以哪种方式来做,总的思路就是在执行业务前,必须先查询该消息是否被处理过。那么这里就涉及到一个数据主键问题,
在这个例子中,我们以订单号为主键,也可以用事务ID作主键,如果是普通消息的话,我们也可以创建唯一的消息ID作为主键。
6、消费异常
我们知道,当消费者处理失败后会返回 RECONSUME_LATER ,让消息来重试,默认最多重试16次。
那,如果真的由于特殊原因,消息一直不能被正确处理,那怎么办 ?
我们考虑两种方式来解决这个问题。
第一,在代码中设置消息重试次数,如果达到指定次数,就发邮件或者短信通知业务方人工介入处理。
@Component
public class OrderListener implements MessageListenerConcurrently {
Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
logger.info("消费者线程监听到消息。");
for (MessageExt message:list) {
if (!processor(message)){
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
/**
* 消息处理,第3次处理失败后,发送邮件通知人工介入
* @param message
* @return
*/
private boolean processor(MessageExt message){
String body = new String(message.getBody());
try {
logger.info("消息处理....{}",body);
int k = 1/0;
return true;
}catch (Exception e){
if(message.getReconsumeTimes()>=3){
logger.error("消息重试已达最大次数,将通知业务人员排查问题。{}",message.getMsgId());
sendMail(message);
return true;
}
return false;
}
}
}
十一、消息发送重试机制
Producer对发送失败的消息进行重新发送的机制,称为消息发送重试机制,也称为消息重投机制。
对于消息重投,需要注意以下几点:
-
生产者在发送消息时,若采用 同步 或 异步 发送方式,发送失败会重试,但oneway(单向消息)发送方式发送失败是没有重试机制的
-
只有普通消息具有发送重试机制,顺序消息是没有的
-
消息重投机制可以保证消息尽可能发送成功、不丢失,但可能会造成消息重复。消息重复在RocketMQ中是无法避免的问题
-
消息重复在一般情况下不会发生,当出现消息量大、网络抖动,消息重复就会成为大概率事件
-
producer主动重发、consumer负载变化(发生Rebalance,不会导致消息重复,但可能出现重复消费)也会导致重复消息
-
消息重复无法避免,但要避免消息的重复消费。
-
避免消息重复消费的解决方案是,为消息添加唯一标识(例如消息key),使消费者对消息进行消费判断来避免重复消费
-
消息发送重试有三种策略可以选择:同步发送失败策略、异步发送失败策略、消息刷盘失败策略
1、同步发送失败策略(retryTimesWhenSendFailed)
对于普通消息,消息发送默认采用round-robin策略来选择所发送到的队列。如果发送失败,默认重试 2次。但在重试时是不会选择上次发送失败的Broker,而是选择其它Broker。当然,若只有一个Broker其也只能发送到该Broker,但其会尽量发送到该Broker上的其它Queue
// 创建一个producer,参数为Producer Group名称
DefaultMQProducer producer = new DefaultMQProducer("pg");
// 指定nameServer地址
producer.setNamesrvAddr("rocketmqOS:9876");
// 设置同步发送失败时重试发送的次数,默认为 2 次
producer.setRetryTimesWhenSendFailed( 3 );
// 设置发送超时时限为5s,默认3s
producer.setSendMsgTimeout( 5000 );
同时,Broker还具有失败隔离功能,使Producer尽量选择未发生过发送失败的Broker作为目标Broker。其可以保证其它消息尽量不发送到问题Broker,为了提升消息发送效率,降低消息发送耗时
如果超过重试次数,则抛出异常,由Producer保证消息不丢。当然当生产者出现RemotingException、MQClientException和MQBrokerException时,Producer会自动重投消息
2、异步发送失败策略(retryTimesWhenSendAsyncFailed)
异步发送失败重试时,异步重试不会选择其他broker,仅在同一个broker上做重试,所以该策略无法保证消息不丢。
DefaultMQProducer producer = new DefaultMQProducer("pg");
producer.setNamesrvAddr("rocketmqOS:9876");
// 指定异步发送失败后不进行重试发送
producer.setRetryTimesWhenSendAsyncFailed( 0 );
3、retryAnotherBrokerWhenNotStoreOK
消息刷盘超时(Master或Slave)或slave不可用(slave在做数据同步时向master返回状态不是SEND_OK)时,默认是不会将消息尝试发送到其他Broker的。不过,
对于重要消息可以通过在Broker的配置文件设置retryAnotherBrokerWhenNotStoreOK属性为true来开启
注意点:
-
如果同步模式发送失败,则选择到下一个 Broker,如果异步模式发送失败,则只会在当前 Broker 进行重试
-
发送消息超时时间默认 3000 毫秒,就不会再尝试重试
十二、消息消费重试机制
1、顺序消息的消费重试
对于顺序消息,当Consumer消费消息失败后,为了保证消息的顺序性,其会自动不断地进行消息重试,直到消费成功。消费重试默认间隔时间为 1000 毫秒。重试期间应用会出现消息消费被阻塞的情况。
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("cg");
// 顺序消息消费失败的消费重试时间间隔,单位毫秒,默认为 1000 ,其取值范围为[10,30000]
consumer.setSuspendCurrentQueueTimeMillis( 100 );
由于对顺序消息的重试是无休止的,不间断的,直至消费成功,所以,对于顺序消息的消费,务必要保证应用能够及时监控并处理消费失败的情况,避免消费被永久性阻塞
注意:顺序消息没有发送失败重试机制,但具有消费失败重试机制
2、无序消息的消费重试
对于无序消息(普通消息、延时消息、事务消息),当Consumer消费消息失败时,可以通过设置返回状态达到消息重试的效果。不过需要注意,无序消息的重试只对集群消费方式生效,广播消费方式不提供失败重试特性。即对于广播消费,消费失败后,失败消息不再重试,继续消费后续消息
3、消费重试次数与间隔
对于无序消息集群消费下的重试消费,每条消息默认最多重试 16 次,但每次重试的间隔时间是不同的,会逐渐变长。每次重试的间隔时间如下表
| 第几次重试 | 与上次重试的间隔时间 | 第几次重试 | 与上次重试的间隔时间 |
|---|---|---|---|
| 1 | 10 秒 | 9 | 7 分钟 |
| 2 | 30 秒 | 10 | 8 分钟 |
| 3 | 1 分钟 | 11 | 9 分钟 |
| 4 | 2 分钟 | 12 | 10 分钟 |
| 5 | 3 分钟 | 13 | 20 分钟 |
| 6 | 4 分钟 | 14 | 30 分钟 |
| 7 | 5 分钟 | 15 | 1 小时 |
| 8 | 6 分钟 | 16 | 2 小时 |
如果消息重试 16 次后仍然失败,消息将不再投递,如果严格按照上述重试时间间隔计算,某条消息在一直消费失败的前提下,将会在接下来的 4 小时 46 分钟之内进行 16 次重试,超过这个时间范围消息将不再重试投递
修改消费重试次数:
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("cg");
// 修改消费重试次数
consumer.setMaxReconsumeTimes( 10 );
对于修改过的重试次数,将按照以下策略执行:
-
1、若修改值小于 16 ,则按照指定间隔进行重试
-
2、若修改值大于 16 ,则超过 16 次的重试时间间隔均为 2 小时
说明:一条消息无论重试多少次,消息的 Message ID 是不会改变的
4、重试队列
对于需要重试消费的消息,并不是Consumer在等待了指定时长后再次去拉取原来的消息进行消费,而是将这些需要重试消费的消息放入到了一个特殊Topic的队列中,而后进行再次消费的。这个特殊的队列就是重试队列
当出现需要进行重试消费的消息时,Broker会为每个消费组都设置一个Topic名称为%RETRY%consumerGroup@consumerGroup的重试队列。
-
1、这个重试队列是针对消息才组的,而不是针对每个Topic设置的(一个Topic的消息可以让多个消费者组进行消费,所以会为这些消费者组各创建一个重试队列)
-
2、只有当出现需要进行重试消费的消息时,才会为该消费者组创建重试队列

注意:消费重试的时间间隔与延时消费的延时等级十分相似,除了没有延时等级的前两个时间外,其它的时间都是相同的
Broker对于重试消息的处理是通过延时消息实现的。先将消息保存到SCHEDULE_TOPIC_XXXX延迟队列中,延迟时间到后,会将消息投递到%RETRY%consumerGroup@consumerGroup重试队列中
5、消费重试配置方式
集群消费方式下,消息消费失败后期望消息重试,需要在消息监听器接口的实现中明确进行配置(三种方式任选一种):
-
返回 ConsumeConcurrentlyStatus.RECONSUME_LATER(推荐)
-
表示消息消费失败,稍后将重试消费。
-
当消费者在处理消息时出现异常或未能成功处理时,返回此状态。
-
RocketMQ 会将该消息重新放回队列,等待后续的重试。
-
-
返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS
-
表示消息消费成功。
-
当消费者成功处理完一条消息后,应该返回此状态。
-
RocketMQ 会认为该消息已被处理,可以从消息队列中移除。
-
-
返回 null
-
抛出异常
十三、死信队列
1、什么是死信队列
当一条消息初次消费失败,消息队列会自动进行消费重试;达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息,
此时,消息队列不会立刻将消息丢弃,而是将其发送到该消费者对应的特殊队列中。这个队列就是死信队列(Dead-Letter Queue,DLQ),
而其中的消息 则称为死信消息(Dead-Letter Message,DLM)【死信队列是用于处理无法被正常消费的消息的】
2、死信队列的特征
死信队列具有如下特征:
-
死信队列中的消息不会再被消费者正常消费,即DLQ对于消费者是不可见的
-
死信存储有效期与正常消息相同,均为 3 天(commitlog文件的过期时间), 3 天后会被自动删除
-
死信队列就是一个特殊的Topic,名称为%DLQ%consumerGroup@consumerGroup,即每个消费者组都有一个死信队列
-
如果一个消费者组未产生死信消息,则不会为其创建相应的死信队列
3、查看死信信息
A、在控制台查询出现死信队列的主题信息

B、在消息界面根据主题查询死信消息

C、在消息界面根据主题查询死信消息

4、选择重新发送消息
实际上,当一条消息进入死信队列,就意味着系统中某些地方出现了问题,从而导致消费者无法正常消费该消息,比如代码中原本就存在Bug。因此,对于死信消息,通常需要开发人员进行特殊处理。最关键的步骤是要排查可疑因素,解决代码中可能存在的Bug,排查可疑因素并解决问题后,可以在消息队列 RocketMQ 控制台重新发送该消息,让消费者重新消费一次。
十四、消费幂等
1、什么是消费幂等
当出现消费者对某条消息重复消费的情况时,重复消费的结果与消费一次的结果是相同的,并且多次消费并未对业务系统产生任何负面影响,那么这个消费过程就是消费幂等的
幂等:若某操作执行多次与执行一次对系统产生的影响是相同的,则称该操作是幂等的
在互联网应用中,尤其在网络不稳定的情况下,消息很有可能会出现重复发送或重复消费。如果重复的消息可能会影响业务处理,那么就应该对消息做幂等处理
2、什么情况下可能会出现消息被重复消费呢?最常见的有以下三种情况
发送时消息重复:
当一条消息已被成功发送到Broker并完成持久化,此时出现了网络闪断,从而导致Broker对Producer应答失败。 如果此时Producer意识到消息发送失败并尝试再次发送消息,此时Broker中就可能会出现两条内容相同并且Message ID也相同的消息,那么后续Consumer就一定会消费两次该消息
消费时消息重复:
消息已投递到Consumer并完成业务处理,当Consumer给Broker反馈应答时网络闪断,Broker没有接收到消费成功响应。为了保证消息至少被消费一次的原则,Broker将在网络恢复后再次尝试投递之前已被处理过的消息。此时消费者就会收到与之前处理过的内容相同、Message ID也相同的消息
Rebalance时消息重复:
当Consumer Group中的Consumer数量发生变化时,或其订阅的Topic的Queue数量发生变化时,会触发Rebalance,此时Consumer可能会收到曾经被消费过的消息
3、通用解决方案
两要素
幂等解决方案的设计中涉及到两项要素:幂等令牌,与唯一性处理。只要充分利用好这两要素,就可以设计出好的幂等解决方案:
-
幂等令牌:是生产者和消费者两者中的既定协议,通常指具备唯一业务标识的字符串。例如,订单号、流水号。一般由Producer随着消息一同发送来的。
-
唯一性处理:服务端通过采用一定的算法策略,保证同一个业务逻辑不会被重复执行成功多次。例如,对同一笔订单的多次支付操作,只会成功一次。
解决方案
对于常见的系统,幂等性操作的通用性解决方案是:
-
1、首先通过缓存去重。在缓存中如果已经存在了某幂等令牌,则说明本次操作是重复性操作;若缓存没有命中,则进入下一步。
-
2、在唯一性处理之前,先在数据库中查询幂等令牌作为索引的数据是否存在。若存在,则说明本次操作为重复性操作;若不存在,则进入下一步。
-
3、在同一事务中完成三项操作:唯一性处理后,将幂等令牌写入到缓存,并将幂等令牌作为唯一索引的数据写入到DB中。
第 1 步已经判断过是否是重复性操作了,为什么第 2 步还要再次判断?能够进入第 2 步,说明已经不是重复操作了,第 2 次判断是否重复?
当然不重复。一般缓存中的数据是具有有效期的。缓存中数据的有效期一旦过期,就是发生缓存穿透,使请求直接就到达了DBMS。
以支付场景为例:
-
1、当支付请求到达后,首先在Redis缓存中却获取key为支付流水号的缓存value。若value不空,则说明本次支付是重复操作,业务系统直接返回调用侧重复支付标识;若value为空,则进入下一步操作
-
2、到DBMS中根据支付流水号查询是否存在相应实例。若存在,则说明本次支付是重复操作,业务系统直接返回调用侧重复支付标识;若不存在,则说明本次操作是首次操作,进入下一步完成唯一性处理
-
3、在分布式事务中完成三项操作:
-
完成支付任务
-
将当前支付流水号作为key,任意字符串作为value,通过set(key, value, expireTime)将数据写入到Redis缓存
-
将当前支付流水号作为主键,与其它相关数据共同写入到DBMS
-
4、消费幂等的实现
消费幂等的解决方案很简单:为消息指定不会重复的唯一标识。因为Message ID有可能出现重复的情况,所以真正安全的幂等处理,不建议以Message ID作为处理依据。最好的方式是以业务唯一标识作为幂等处理的关键依据,而业务的唯一标识可以通过消息Key设置。
以支付场景为例,可以将消息的Key设置为订单号,作为幂等处理的依据。具体代码示例如下:
Message message = new Message();
message.setKey("ORDERID_100");
SendResult sendResult = producer.send(message);
消费者收到消息时可以根据消息的Key即订单号来实现消费幂等:
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt>msgs,ConsumeConcurrentlyContext context) {
for(MessageExt msg:msgs){
String key = msg.getKeys();
// 根据业务唯一标识Key做幂等处理
// ......
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
十五、消息堆积与消费延迟
1、概念
消息处理流程中,如果Consumer的消费速度跟不上Producer的发送速度,MQ中未处理的消息会越来越多(进的多出的少),这部分消息就被称为堆积消息。消息出现堆积进而会造成消息的消费延迟。
以下场景需要重点关注消息堆积和消费延迟问题:
-
业务系统上下游能力不匹配造成的持续堆积,且无法自行恢复。
-
业务系统对消息的消费实时性要求较高,即使是短暂的堆积造成的消费延迟也无法接受。
2、产生原因分析

Consumer使用长轮询Pull模式消费消息时,分为以下两个阶段:
消息拉取
Consumer通过长轮询Pull模式批量拉取的方式从服务端获取消息,将拉取到的消息缓存到本地缓冲队列中。对于拉取式消费,在内网环境下会有很高的吞吐量,所以这一阶段一般不会成为消息堆积的瓶颈。
消息消费
Consumer将本地缓存的消息提交到消费线程中,使用业务消费逻辑对消息进行处理,处理完毕后获取到一个结果。这是真正的消息消费过程。此时Consumer的消费能力就完全依赖于消息的消费耗时和消费并发度了。如果由于业务处理逻辑复杂等原因,导致处理单条消息的耗时较长,则整体的消息吞吐量肯定不会高,此时就会导致Consumer本地缓冲队列达到上限,停止从服务端拉取消息。
结论
消息堆积的主要瓶颈在于客户端的消费能力,而消费能力由消费耗时和消费并发度决定。注意,消费耗时的优先级要高于消费并发度。即在保证了消费耗时的合理性前提下,再考虑消费并发度问题。
3、消费耗时
影响消息处理时长的主要因素是代码逻辑。而代码逻辑中可能会影响处理时长代码主要有两种类型:CPU内部计算型代码和外部I/O操作型代码。
通常情况下代码中如果没有复杂的递归和循环的话,内部计算耗时相对外部I/O操作来说几乎可以忽略。所以外部IO型代码是影响消息处理时长的主要症结所在。
外部IO操作型代码举例:
-
1、读写外部数据库,例如对远程MySQL的访问
-
2、读写外部缓存系统,例如对远程Redis的访问
-
3、下游系统调用,例如Dubbo的RPC远程调用,Spring Cloud的对下游系统的Http接口调用
关于下游系统调用逻辑需要进行提前梳理,掌握每个调用操作预期的耗时,这样做是为了能够判断消费逻辑中IO操作的耗时是否合理。通常消息堆积是由于下游系统出现了服务异常或达到了DBMS容量限制,导致消费耗时增加。
服务异常,并不仅仅是系统中出现的类似 500 这样的代码错误,而可能是更加隐蔽的问题。例如,网络带宽问题。
达到了DBMS容量限制,其也会引发消息的消费耗时增加。
4、消费并发度
一般情况下,消费者端的消费并发度由单节点线程数和节点数量共同决定,其值为单节点线程数*节点数量。不过,通常需要优先调整单节点的线程数,若单机硬件资源达到了上限,则需要通过横向扩展来提高消费并发度。
单节点线程数,即单个Consumer所包含的线程数量
节点数量,即Consumer Group所包含的Consumer数量
对于普通消息、延时消息及事务消息,并发度计算都是单节点线程数*节点数量。但对于顺序消息则是不同的。顺序消息的消费并发度等于Topic的Queue分区数量。
-
1、全局顺序消息:该类型消息的Topic只有一个Queue分区。其可以保证该Topic的所有消息被顺序消费。为了保证这个全局顺序性,Consumer Group中在同一时刻只能有一个Consumer的一个线程进行消费。所以其并发度为 1 。
-
2、分区顺序消息:该类型消息的Topic有多个Queue分区。其仅可以保证该Topic的每个Queue分区中的消息被顺序消费,不能保证整个Topic中消息的顺序消费。为了保证这个分区顺序性,每个Queue分区中的消息在Consumer Group中的同一时刻只能有一个Consumer的一个线程进行消费。即,在同一时刻最多会出现多个Queue分蘖有多个Consumer的多个线程并行消费。所以其并发度为Topic的分区数量。
单机线程数计算
对于一台主机中线程池中线程数的设置需要谨慎,不能盲目直接调大线程数,设置过大的线程数反而会带来大量的线程切换的开销。理想环境下单节点的最优线程数计算模型为:C *(T1 + T2)/ T1。
-
C:CPU内核数
-
T1:CPU内部逻辑计算耗时
-
T2:外部IO操作耗时
最优线程数 = C *(T1 + T2)/ T1 = C * T1/T1 + C * T2/T1 = C + C * T2/T1
注意:该计算出的数值是理想状态下的理论数据,在生产环境中,不建议直接使用。而是根据当前环境,先设置一个比该值小的数值然后观察其压测效果,然后再根据效果逐步调大线程数,直至找到在该环境中性能最佳时的值。
5、如何避免在业务使用时出现非预期的消息堆积和消费延迟问题
为了避免在业务使用时出现非预期的消息堆积和消费延迟问题,需要在前期设计阶段对整个业务逻辑进行完善的排查和梳理。其中最重要的就是梳理消息的消费耗时和设置消息消费的并发度。
梳理消息的消费耗时
通过压测获取消息的消费耗时,并对耗时较高的操作的代码逻辑进行分析。梳理消息的消费耗时需要关注以下信息:
-
消息消费逻辑的计算复杂度是否过高,代码是否存在无限循环和递归等缺陷。
-
消息消费逻辑中的I/O操作是否是必须的,能否用本地缓存等方案规避。
-
消费逻辑中的复杂耗时的操作是否可以做异步化处理。如果可以,是否会造成逻辑错乱。
设置消费并发度
对于消息消费并发度的计算,可以通过以下两步实施:
-
逐步调大单个Consumer节点的线程数,并观测节点的系统指标,得到单个节点最优的消费线程数和消息吞吐量。
-
根据上下游链路的流量峰值计算出需要设置的节点数
节点数 = 流量峰值 / 单个节点消息吞吐量
十六、工作流程
1、模块介绍
NameServer(名字服务):是一个简单的 Topic路由 注册中心,支持 Broker 的动态注册与发现,生产者或消费者能够在 NameServer 中查找各 Topic(主题)相应的 Broker IP 列表
NameServer 主要包括两个功能:
-
Broker 管理,NameServer 接受 Broker 集群的注册信息,保存下来作为路由信息的基本数据,提供心跳检测机制检查 Broker 是否还存活,每 10 秒清除一次两小时没有活跃的 Broker
-
路由信息管理,每个 NameServer 将保存关于 Broker 集群的整个路由信息和用于客户端查询的队列信息,然后 Producer 和 Conumser 通过 NameServer 就可以知道整个 Broker 集群的路由信息,从而进行消息的投递和消费
NameServer 特点:
-
NameServer 通常是集群的方式部署,
各实例间相互不进行信息通讯 -
Broker开启时,向集群中的每一台 NameServer注册自己的路由信息,所以每个 NameServer 实例上面都保存一份完整的
Broker的路由信息【包括 Broker地址、Topic、Queue等信息】 -
当某个 NameServer 因某种原因下线了,Broker 仍可以向其它 NameServer 同步其路由信息
2、Broker
BrokerServer 主要负责消息的存储、投递和查询以及服务高可用保证,在 RocketMQ 系统中接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备,也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等
Broker 包含了以下几个重要子模块:
-
Remoting Module:整个 Broker 的实体,负责处理来自 Clients 端的请求
-
Client Manager:负责管理客户端(Producer/Consumer)和维护 Consumer 的 Topic 订阅信息
-
Store Service:提供方便简单的 API 接口处理消息存储到物理硬盘和查询功能
-
HA Service:高可用服务,提供 Master Broker 和 Slave Broker 之间的数据同步功能
-
Index Service:根据特定的 Message key 对投递到 Broker 的消息进行索引服务,以提供消息的快速查询

3、总体流程
RocketMQ 的工作流程:
1、 当启动 NameServer时,也启动其监听端口,等待 Broker、Producer、Consumer 连上来,相当于一个路由控制中心
2、 Broker 启动, 跟所有的 NameServer 保持长连接,然后每 30 秒向 NameServer 定时发送心跳包。心跳包中包含当前 Broker 信息(IP、端口等)以及存储所有 Topic 路由信息。注册成功后,NameServer 集群中就有 Topic 跟 Broker 的映射关系
3、 发送消息前,可以先创建Topic,创建Topic时需要指定该Topic要存储在哪些Broker上,当然,在创建Topic时也会将Topic与Broker的映射关系写入到NameServer中。不过,这步是可选的,也可以在发送消息时自动创建Topic
4、 Producer启动时,先跟 NameServer 集群中的其中一台建立长连接,并从NameServer中获取路由信息,从 NameServer 中获取当前发送的 Topic 存在哪些 Broker 上,在获取到路由信息后,将其缓存到本地的 TopicPublishInfoTable,再每 30 秒从 NameServer拉取一次Top信息,更新一次本地缓存的路由信息
5、 Producer 发送消息时,根据消息的 Topic 从本地缓存的 TopicPublishInfoTable 获取路由信息,如果没有,则会从 NameServer 上重新拉取并更新,轮询队列列表并选择一个队列 MessageQueue,然后与队列所在的 Broker 建立长连接,向 Broker 发消息
6、 Consumer跟Producer类似,跟其中一台NameServer建立长连接,获取其所订阅 Topic 的路由信息,然后根据算法策略从路由信息中获取到其所要消费的 Queue,然后直接跟Broker建立长连接,开始消费其中的消息。Consumer在获取到路由信息后,同样也会每 30 秒从NameServer拉取一次Top信息,更新一次本地缓存路由信息。不过不同于Producer的是,Consumer还会向Broker发送心跳,以确保Broker的存活状态
NameServer 注册与发现
-
Broker 注册:Broker 启动时会向所有 NameServer 注册自己的路由信息(如 Topic 路由、队列数量、Broker 地址等),并定时发送心跳保持连接
-
客户端(生产者/消费者)查询路由:生产者和消费者启动时从 NameServer 获取 Broker 的路由信息(例如 Topic 对应的 Broker IP 和队列列表)并将其缓存到本地的TopicPublishInfoTable,NameServer 是无状态的,仅提供路由发现服务
消息发送流程(生产者 端)
1、选择 Topic 和队列:
-
生产者根据消息的 Topic 从本地缓存的路由表中找到对应的 Broker 和队列列表
-
默认通过轮询或其他负载均衡策略(如哈希)选择队列发送消息
2、消息发送:
-
消息发送到选中的 Broker Master 节点(同步/异步/单向发送)
-
Broker 接收消息后,写入 CommitLog(持久化日志文件),然后异步分发到对应的 ConsumeQueue(消费队列索引文件)
消息存储(Broker 端)
-
CommitLog:所有消息按顺序追加写入 CommitLog,保证高吞吐
-
ConsumeQueue:每个 Topic 的队列对应一个 ConsumeQueue,存储消息在 CommitLog 中的偏移量(索引),便于快速检索
-
索引文件(IndexFile):支持按 Key 或时间范围查询消息
消息消费流程(消费者 端)
1、拉取消息:
-
消费者从本地路由表找到 Broker 地址,根据Topic向 Broker 拉取指定队列的消息。
-
支持集群模式(负载均衡)或广播模式(全量消费)
2、消费位点管理:
- 消费者维护消费进度(Offset),集群模式下 Offset 由 Broker 存储;广播模式下由消费者本地存储。
3、ACK 机制:
- 消费成功后,消费者提交 Offset 到 Broker;若失败,消息会重新投递(重试队列机制)
消息重试 与 死信队列
1、重试队列:
- 若消费失败,消息会被暂存到重试队列(%RETRY%+Topic),延迟后重新投递
2、死信队列:
- 超过最大重试次数(默认 16 次)的消息转入死信队列(%DLQ%+Topic),需人工处理
高可用与故障转移
1、Broker 主从同步:
- Master 接收消息后,通过同步/异步方式复制到 Slave,确保故障时 Slave 可升级为 Master
2、生产者容错:
- 若 Master 不可用,生产者可自动切换到 Slave(需配置适当策略)
流程图简示

Producer 发送消息流程
以下是 Producer 发送消息时创建 Topic 并将 Topic 与 Broker 绑定的详细流程:
Producer 启动
1、Producer 启动:Producer 根据配置文件中的 NameServer 地址与 NameServer 建立连接
发送消息
2、Producer 发送消息:Producer 调用 send 方法发送消息到指定的 Topic
请求 Topic 路由信息
3、查询 Topic 路由信息:Producer 向 NameServer 请求指定 Topic 的路由信息。如果 Topic 不存在,NameServer 会返回一个错误
Broker 自动创建 Topic
4、Broker 自动创建 Topic:如果 autoCreateTopicEnable 参数为 true,Broker 接收到创建 Topic 的请求后,会自动创建该 Topic,并为该 Topic 分配多个 Queue(消息队列)。默认情况下,每个 Topic 会有 4 个 Queue
Broker 更新路由信息
5、Broker 更新路由信息:Broker 将新创建的 Topic 的路由信息(如 Topic 名称、Queue 数量、Broker 地址等)通过心跳机制发送给 NameServer
NameServer 更新路由信息
6、NameServer 更新路由信息:NameServer 接收到 Broker 的心跳包后,会更新存储在内存中的 Topic 路由信息
NameServer 返回路由信息
7、NameServer 返回路由信息:NameServer 将最新的 Topic 路由信息返回给 Producer,并将 Topic 路由信息缓存到本地
Producer 发送消息
8、Producer 发送消息:Producer 根据最新的 Topic 路由信息将消息发送到指定的 Broker 和 Queue


4、消息的生产过程
Producer可以将消息写入到某Broker中的某Queue中,其经历了如下过程:
-
Producer发送消息之前,会先向NameServer发出获取消息Topic的路由信息的请求
-
NameServer返回该Topic的路由表及Broker列表
-
Producer根据代码中指定的Queue选择策略,从Queue列表中选出一个队列,用于后续存储消息
-
Produer对消息做一些特殊处理,例如,消息本身超过4M,则会对其进行压缩
-
Producer向选择出的Queue所在的Broker发出RPC请求,将消息发送到选择出的Queue
路由表:实际是一个Map,key为Topic名称,value是一个QueueData实例列表。QueueData并不是一个Queue对应一个QueueData,而是一个Broker中该Topic的所有Queue对应一个
QueueData。即,只要涉及到该Topic的Broker,一个Broker对应一个QueueData。QueueData中包含brokerName。简单来说,路由表的key为Topic名称,value则为所有涉及该Topic的BrokerName列表。
Broker列表:其实际也是一个Map。key为brokerName,value为BrokerData。一个Broker对应一个BrokerData实例,对吗?不对。一套brokerName名称相同的Master-Slave小集群对应一
个BrokerData。BrokerData中包含brokerName及一个map。该map的key为brokerId,value为该broker对应的地址。brokerId为 0 表示该broker为Master,非 0 表示Slave。
5、Queue选择算法
对于无序消息,其Queue选择算法,也称为消息投递算法,常见的有两种:
-
轮询算法:默认选择算法。该算法保证了每个Queue中可以均匀的获取到消息。
- 该算法存在一个问题:由于某些原因,在某些Broker上的Queue可能投递延迟较严重。从而导致Producer的缓存队列中出现较大的消息积压,影响消息的投递性能
-
最小投递延迟算法:该算法会统计每次消息投递的时间延迟,然后根据统计出的结果将消息投递到时间延迟最小的Queue。如果延迟相同,则采用轮询算法投递。该算法可以有效提升消息的投递性能
- 该算法也存在一个问题:消息在Queue上的分配不均匀。投递延迟小的Queue其可能会存在大量的消息。而对该Queue的消费者压力会增大,降低消息的消费能力,可能会导致MQ中消息的堆积
6、生产消费
At least Once:至少一次,指每个消息必须投递一次,Consumer 先 Pull 消息到本地,消费完成后才向服务器返回 ACK,如果没有消费一定不会 ACK 消息
回溯消费:指 Consumer 已经消费成功的消息,由于业务上需求需要重新消费,Broker 在向 Consumer 投递成功消息后,消息仍然需要保留。并且重新消费一般是按照时间维度,例如由于 Consumer 系统故障,恢复后需要重新消费 1 小时前的数据,RocketMQ 支持按照时间回溯消费,时间维度精确到毫秒
分布式队列因为有高可靠性的要求,所以数据要进行持久化存储
1、消息生产者发送消息
2、MQ 收到消息,将消息进行持久化,在存储中新增一条记录
3、返回 ACK 给生产者
4、MQ push 消息给对应的消费者,然后等待消费者返回 ACK
5、如果消息消费者在指定时间内成功返回 ACK,那么 MQ 认为消息消费成功,在存储中删除消息;如果 MQ 在指定时间内没有收到 ACK,则认为消息消费失败,会尝试重新 push 消息,重复执行 4、5、6 步骤
6、MQ 删除消息

十七、存储机制
1、存储结构
RocketMQ 中 Broker 负责存储消息转发消息,所以以下的结构是存储在 Broker Server 上的,生产者和消费者与 Broker 进行消息的收发是通过主题对应的 Message Queue 完成,类似于通道
RocketMQ 消息的存储是由 ConsumeQueue 和 CommitLog 配合完成 的,CommitLog 是消息真正的物理存储文件,ConsumeQueue 是消息的逻辑队列,类似数据库的索引节点,存储的是指向物理存储的地址。每个 Topic 下的每个 Message Queue 都有一个对应的 ConsumeQueue 文件
每条消息都会有对应的索引信息,Consumer 通过 ConsumeQueue 这个结构来读取消息实体内容

-
abort:该文件在Broker启动后会自动创建,正常关闭Broker,该文件会自动消失。若在没有启动Broker的情况下,发现这个文件是存在的,则说明之前Broker的关闭是非正常关闭。
-
checkpoint:其中存储着commitlog、consumequeue、index文件的最后刷盘时间戳
-
commitlog:消息主体以及元数据的存储主体,存储 Producer 端写入的消息内容,消息内容不是定长的。消息主要是顺序写入日志文件,单个文件大小默认 1G,偏移量代表下一次写入的位置,当文件写满了就继续写入下一个文件
-
config:存放着Broker运行期间的一些配置数据
-
consumequeue:消息消费队列,存储消息在 CommitLog 的索引。RocketMQ 消息消费时要遍历 CommitLog 文件,并根据主题 Topic 检索消息,这是非常低效的。引入 ConsumeQueue 作为消费消息的索引,保存了指定 Topic 下的队列消息在 CommitLog 中的起始物理偏移量 offset,消息大小 size 和消息 Tag 的 HashCode 值,每个 ConsumeQueue 文件大小约 5.72M
-
index:其中存放着消息索引文件indexFile
-
lock:运行期间使用到的全局资源锁
RocketMQ 采用的是混合型的存储结构,即为 Broker 单个实例下所有的队列共用一个日志数据文件(CommitLog)来存储,多个 Topic 的消息实体内容都存储于一个 CommitLog 中。混合型存储结构针对 Producer 和 Consumer 分别采用了数据和索引部分相分离的存储结构,Producer 发送消息至 Broker 端,然后 Broker 端使用同步或者异步的方式对消息刷盘持久化,保存至 CommitLog 中。只要消息被持久化至磁盘文件 CommitLog 中,Producer 发送的消息就不会丢失,Consumer 也就肯定有机会去消费这条消息
服务端支持长轮询模式,当消费者无法拉取到消息后,可以等下一次消息拉取,Broker 允许等待 30s 的时间,只要这段时间内有新消息到达,将直接返回给消费端。RocketMQ 的具体做法是,使用 Broker 端的后台服务线程 ReputMessageService 不停地分发请求并异步构建 ConsumeQueue(逻辑消费队列)和 IndexFile(索引文件)数据
2、commitlog
为了保证 RocketMQ 的写性能,最好是将所有消息都放在一个文件,并顺序读、顺序写。
如果不放在一个文件,硬盘的存储物理位置就不是连续的,能无法保证顺序写,这个时候写入后的物理位置其实就是随机的了。
顺序读写也是为了追求极致的性能,这个其实和 MySQL 的 redo log 有异曲同工之妙。
而这个存储消息的本地文件,就叫做 commitlog。位于 rocketmq/data 文件夹下面。

当你用你粗壮的手准备打开这个文件时,发现是个二进制文件。

这也很好理解,使用二进制存储,一来节省存储空间,二来也能提高读取性能。
当然也可以使用开源工具如 rocketmq-dump 来查看,小伙伴们可以亲自试试。
3、consumequeue
细心的小伙伴可能已经注意到除了 commitlog 还有一个文件 consumequeue,那这里面存的是啥子呢?
我们知道 commitlog 存的是消息的全量数据,那对于消费者来说,想要知道自己该消费哪条消息,我们在之前的文章中分析过,靠的是消费位点(comsumerOffset),也就是消息的记录。
这个 comsumerOffset 也是会落在磁盘持久化的。
commitlog 里面只有一个文件,里面存的是消息的全量信息,为了追求极致的性能体验,肯定是需要索引来进行查询。
consumequeue 存放的是起始偏移量和长度,相当于这个索引。
通过起始偏移量和长度可以查询 commitlog 中的全量消息信息。


消费者消费消息的时候,通过消费位点(comsumerOffset)找到 consumequeue 里面的起始偏移量和长度,通过索引找到 commitlog 对应的全量消息,然后再消费。

4、索引条目

5、对文件的读写

消息写入
一条消息进入到Broker后经历了以下几个过程才最终被持久化
-
Broker根据queueId,获取到该消息对应索引条目要在consumequeue目录中的写入偏移量,即QueueOffset
-
将queueId、queueOffset等数据,与消息一起封装为消息单元
-
将消息单元写入到commitlog
-
同时,形成消息索引条目
-
将消息索引条目分发到相应的consumequeue
消息拉取

十八、rocketmq如何保存消息不丢失
一条消息从生产到被消费,将会经历三个阶段:

-
生产阶段,Producer 新建消息,然后通过网络将消息投递给 MQ Broker
-
存储阶段,消息将会存储在 Broker 端磁盘中
-
消息阶段, Consumer 将会从 Broker 拉取消息
1、生产者端保证
a、同步发送:该方式是最可靠的发送方式,它会等待broker的确认响应
RocketMQ 发送同步消息示例代码如下:
DefaultMQProducer mqProducer=new DefaultMQProducer("test");
// 设置 nameSpace 地址
mqProducer.setNamesrvAddr("namesrvAddr");
mqProducer.start();
Message msg = new Message("test_topic","Hello World".getBytes(RemotingHelper.DEFAULT_CHARSET));
// 发送消息到一个Broker
try {
SendResult sendResult = mqProducer.send(msg);
} catch (RemotingException e) {
// 失败重试
e.printStackTrace();
}
send 方法是一个同步操作,只要这个方法不抛出任何异常,就代表消息已经「发送成功」
消息发送成功仅代表消息已经到了 Broker 端,Broker 在不同配置下,可能会返回不同响应状态:
-
SendStatus.SEND_OK
-
SendStatus.FLUSH_DISK_TIMEOUT
-
SendStatus.FLUSH_SLAVE_TIMEOUT
-
SendStatus.SLAVE_NOT_AVAILABLE

b、异步发送:它是通过回调处理发送结果,并可以设置重试次数
RocketMQ 发送异步消息示例代码如下:
try {
// 异步发送消息到,主线程不会被阻塞,立刻会返回
mqProducer.send(msg, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
// 消息发送成功,
}
@Override
public void onException(Throwable e) {
// 消息发送失败,可以持久化这条数据,后续进行补偿处理
}
});
} catch (RemotingException e) {
e.printStackTrace();
}
异步发送消息一定要「注意重写」回调方法,在回调方法中检查发送结果。
小总结:不管是同步还是异步的方式,都会碰到网络问题导致发送失败的情况。针对这种情况,我们可以设置合理的重试次数,当出现网络问题,可以自动重试。设置方式如下
// 同步发送消息重试次数,默认为 2
mqProducer.setRetryTimesWhenSendFailed(3);
// 异步发送消息重试次数,默认为 2
mqProducer.setRetryTimesWhenSendAsyncFailed(3);
2、Broker端保证

两种持久化的方案:
-
关系型数据库 DB:IO 读写性能比较差,如果 DB 出现故障,则 MQ 的消息就无法落盘存储导致线上故障,可靠性不高
-
文件系统:消息刷盘至所部署虚拟机/物理机的文件系统来做持久化,分为异步刷盘和同步刷盘两种模式。消息刷盘为消息存储提供了一种高效率、高可靠性和高性能的数据持久化方式,除非部署 MQ 机器本身或是本地磁盘挂了,一般不会出现无法持久化的问题
RocketMQ 采用文件系统的方式,无论同步还是异步刷盘,都使用顺序 IO,因为磁盘的顺序读写要比随机读写快很多:
-
同步刷盘:只有在消息真正持久化至磁盘后 RocketMQ 的 Broker 端才会真正返回给 Producer 端一个成功的 ACK 响应,保障 MQ 消息的可靠性,但是性能上会有较大影响,一般适用于金融业务应用该模式较多
-
异步刷盘:利用 OS 的 PageCache,只要消息写入内存 PageCache 即可将成功的 ACK 返回给 Producer 端,降低了读写延迟,提高了 MQ 的性能和吞吐量。消息刷盘采用后台异步线程提交的方式进行,当内存里的消息量积累到一定程度时,触发写磁盘动作
通过修改配置broker.conf文件,可以启用同步刷盘
## 默认情况为 ASYNC_FLUSH // 异步
flushDiskType = SYNC_FLUSH // 同步
「集群部署」
为了保证可用性,Broker 通常采用一主(「master」)多从(「slave」)部署方式。为了保证消息不丢失,消息还需要复制到 slave 节点
默认方式下,消息写入 「master」 成功,就可以返回 ACK(确认响应)给生产者,接着消息将会异步复制到 「slave」 节点;此时若 master 突然「宕机且不可恢复」,那么还未复制到 「slave」 的消息将会丢失。
为了进一步提高消息的可靠性,我们可以采用同步的复制方式,「master」 节点将会同步等待 「slave」 节点复制完成,才会返回 ACK (确认响应)。
Broker master 节点 同步复制配置如下:
## 默认为 ASYNC_MASTER
brokerRole=SYNC_MASTER
如果 「slave」 节点未在指定时间内同步返回响应,生产者将会收到 SendStatus.FLUSH_SLAVE_TIMEOUT 返回状态
小结:结合生产阶段与存储阶段,若需要「严格保证消息不丢失」,broker 需要采用如下配置
## master 节点配置
flushDiskType = SYNC_FLUSH
brokerRole=SYNC_MASTER
## slave 节点配置
brokerRole=slave
flushDiskType = SYNC_FLUSH
3、消费端保证
a、手动提交消息:使用手动方式提交可以确保消息被正确处理后再提交
// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test_consumer");
// 设置NameServer的地址
consumer.setNamesrvAddr("namesrvAddr");
// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("test_topic", "*");
// 注册回调实现类来处理从broker拉取回来的消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for(MessageExt msg : msgs){
try {
// 执行业务逻辑
// 处理成功后手动提交
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (RemotingException e) {
// 处理失败,稍后重试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
}
});
// 启动消费者实例
consumer.start();
以上消费消息过程的,我们需要「注意返回消息状态」。只有当业务逻辑真正执行成功,我们才能返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS。否则我们需要返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,稍后再重试
b、幂等性消费:在消费实现幂等性处理,确保重复消费不会导致业务问题
// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test_consumer");
// 设置NameServer的地址
consumer.setNamesrvAddr("namesrvAddr");
// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("test_topic", "*");
// 注册回调实现类来处理从broker拉取回来的消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for(MessageExt msg : msgs){
// 先做幂等处理,再执行业务
try {
// 执行业务逻辑
// 处理成功后手动提交
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (RemotingException e) {
// 处理失败,稍后重试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
}
});
// 启动消费者实例
consumer.start();
十九、消息的消费
消费者从Broker中获取消息的方式有两种:pull拉取方式和push推动方式。
消费者组对于消息消费的模式又分为两种:集群消费Clustering和广播消费Broadcasting
1、获取消息的方式
拉取式消费:Consumer主动从Broker中拉取消息,主动权由Consumer控制。一旦获取了批量消息,就会启动消费过程。不过,该方式的实时性较弱,即Broker中有了新的消息时消费者并不能及时发现并消费
注意:由于拉取时间间隔是由用户指定的,所以在设置该间隔时需要注意平稳:间隔太短,空请求比例会增加;间隔太长,消息的实时性太差
推送式消费:该模式下Broker收到数据后会主动推送给Consumer。该获取方式一般实时性较高
推送式获取方式有典型的发布-订阅模式,即Consumer向其关联的Queue注册了监听器,一旦发现有新的消息到来就会触发回调的执行,回调方法是Consumer去Queue中拉取消息。而这些都是基于Consumer与Broker间的长连接的。长连接的维护是需要消耗系统资源的。
2、消息消费的模式
广播消费

集群消费

集群消费模式下,相同Consumer Group的每个Consumer实例对同一个Topic的消息进行平均分摊。即每条消息只会被发送到Consumer Group中的某个Consumer。
3、消息进度保存
-
广播模式:消费进度保存在consumer端。因为广播模式下consumer group中每个consumer都会消费所有消息,但它们的消费进度是不同。所以consumer各自保存各自的消费进度。
-
集群模式:消费进度保存在broker中。consumer group中的所有consumer共同消费同一个Topic中的消息,同一条消息只会被消费一次。消费进度会参与到了消费的负载均衡中,故消费进度是需要共享的。下图是broker中存放的各个Topic的各个Queue的消费进度
4、重平衡(Rebalance)机制
Rebalance机制讨论的前提是:集群消费
什么是Rebalance
Rebalance即再均衡,指的是,将一个Topic下的多个Queue在同一个Consumer Group中的多个Consumer间进行重新分配的过程。

Rebalance机制的本意是为了提升消息的并行消费能力。例如,一个Topic下 5 个队列,在只有 1 个消费者的情况下,这个消费者将负责消费这 5 个队列的消息。如果此时我们增加一个消费者,那么就可以给其中一个消费者分配 2 个队列,给另一个分配 3 个队列,从而提升消息的并行消费能力。
Rebalance限制
由于一个队列最多分配给一个消费者,因此当某个消费者组下的消费者实例数量大于队列的数量时,多余的消费者实例将分配不到任何队列。
Rebalance危害
Rebalance的在提升消费能力的同时,也带来一些问题:
-
消费暂停:在只有一个Consumer时,其负责消费所有队列;在新增了一个Consumer后会触发Rebalance的发生。此时原Consumer就需要暂停部分队列的消费,等到这些队列分配给新的Consumer后,这些暂停消费的队列才能继续被消费。
-
消费重复:Consumer 在消费新分配给自己的队列时,必须接着之前Consumer 提交的消费进度的offset继续消费。然而默认情况下,offset是异步提交的,这个异步性导致提交到Broker的offset与Consumer实际消费的消息并不一致。这个不一致的差值就是可能会重复消费的消息。
-
同步提交:consumer提交了其消费完毕的一批消息的offset给broker后,需要等待broker的成功ACK。当收到ACK后,consumer才会继续获取并消费下一批消息。在等待ACK期间,consumer是阻塞的。
-
异步提交:consumer提交了其消费完毕的一批消息的offset给broker后,不需要等待broker的成功ACK。consumer可以直接获取并消费下一批消息。
-
-
消费突刺:由于Rebalance可能导致重复消费,如果需要重复消费的消息过多,或者因为Rebalance暂停时间过长从而导致积压了部分消息。那么有可能会导致在Rebalance结束之后瞬间需要消费很多消息。
Rebalance产生的原因
导致Rebalance产生的原因,无非就两个:消费者所订阅Topic的Queue数量发生变化,或消费者组中消费者的数量发生变化。
1、Queue数量发生变化的场景:
-
Broker扩容或缩容
-
Broker升级运维
-
Broker与NameServer间的网络异常
-
Queue扩容或缩容
2、消费者数量发生变化的场景:
-
Consumer Group扩容或缩容
-
Consumer升级运维
-
Consumer与NameServer间网络异常
Rebalance过程

二十、Producer 和 Consumer与Queue之间的负载均衡策略
在RocketMQ架构中,我们都知道一个topic下可以创建多个queue,生产者通过负载均衡策略可以将消息均匀的分发在各个queue中,而这些queue 可以通过负载均衡给多个消费者订阅从而提升消费效率,
本文将从以下两个方面从源码角度分析 producer 和 consumer的负载均衡原理:
-
Producer如何将消息负载均衡发送给 queue ?
-
Consumer如何通过负载均衡并发消费 queue的消息 ?
1、Producer下的负载均衡
A、轮询算法
在Producer下主要指的是通过负载均衡算法去选择一个queue去发送消息,这里默认使用的策略是轮询的方式按顺序选择Queue

B、最小投递延迟算法
该算法会统计每次消息投递的时间延迟,然后根据统计出的结果将消息投递到时间延迟最小的Queue。如果延迟相同,则采用轮询算法投递。该算法可以有效提升消息的投递性能
2、Consumer下的负载均衡
一个Topic中的一个Queue只能由Consumer Group中的一个Consumer进行消费,而一个Consumer可以同时消费多个Queue中的消息。那么Queue与Consumer间的配对关系是如何确定的,
即Queue要分配给哪个Consumer进行消费,也是有算法策略的。常见的有四种策略。这些策略是通过在创建Consumer时的构造器传进去的
A、平均分配策略 (AllocateMessageQueueAveragely)
平均算法: 算出平均值,将连续的队列按平均值分配给每个消费者。 如果能够整除,则按顺序将平均值个Queue分配,如果不能整除,则将多余出的Queue按照Consumer顺序逐个分配

B、环形平均策略 (AllocateMessageQueueAveragelyByCircle)
环形平均算法:将消费者按顺序形成一个环形,然后按照这个环形顺序逐个给消费者分配一个Queue

C、一致性hash算法 (AllocateMessageQueueConsistentHash)
一致性hash算法:先将消费端的hash值放于环上,同时计算队列的hash值,以顺时针方向,分配给离队列hash值最近的一个消费者节点

D、同机房策略

该算法会根据queue的部署机房位置和consumer的位置,过滤出当前consumer相同机房的queue。然后按照平均分配策略或环形平均策略对同机房queue进行分配。如果没有同机房queue,则按照平均分配策略或环形平均策略对所有queue进行分配。
对比
一致性hash算法存在的问题:两种平均分配策略的分配效率较高,一致性hash策略的较低。因为一致性hash算法较复杂。另外,一致性hash策略分配的结果也很大可能上存在不平均的情况
一致性hash算法存在的意义:其可以有效减少由于消费者组扩容或缩容所带来的大量的Rebalance

浙公网安备 33010602011771号