文章目录
1.背景
顺序消息是消息队列 RocketMQ 提供的一种高级消息类型。
对于一个指定的Topic,消息严格按照先进先出(FIFO)的原则进行消息发布和消费。
即先发送的消息先消费,后发送的消息后消费。
顺序消息在发送、存储和投递的处理过程中,强调多条消息间的先后顺序关系。RocketMQ 顺序消息的顺序关系通过消息组(MessageGroup)判定和识别,发送顺序消息时需要为每条消息设置归属的消息组,相同消息组的多条消息之间遵循先进先出的顺序关系,不同消息组、无消息组的消息之间不涉及顺序性。
2.顺序消息的特性
1、消息消费失败或消费超时,会触发服务端重试逻辑,重试消息属于新的消息,原消息的生命周期已结束;
2、顺序消息消费失败进行消费重试时,为保障消息的顺序性,后续消息不可被消费,必须等待前面的消息消费完成后才能被处理。
3、顺序消息仅支持使用MessageType为FIFO的主题,即顺序消息只能发送至类型为顺序消息的主题中,发送的消息的类型必须和主题的类型一致。
3.顺序消息 VS 普通消息
顺序消息 | 普通消息 | |
---|---|---|
原子性 | 消息之间存在偏序关系 | 消息之间没有关联关系 |
顺序性 | 严格有序 | 大致有序 |
扩展性 | 具备一定的可拓展性 | 可拓展性强 |
吞吐 | 受到消息队列数量限制,容易导致消息积压 | 高 |
并发单元 | 同一MessageGroup的消息 | 单条消息 |
编码要求 | 相对较高 | 低 |
4.应用场景
RocketMQ 的顺序消费(也称为有序消息或顺序消息)在某些业务场景中是至关重要的,特别是在那些对消息处理顺序有严格要求的情况下。以下是几种会用到顺序消费的典型场景:
金融交易:例如,在处理用户的账户余额更新时,所有与该用户账户相关的操作必须按照发生的顺序进行处理。如果先处理了取款消息而后处理存款消息,可能会导致用户的账户余额出现错误。
订单处理系统:在一个电商环境中,从下单、支付到发货等步骤都需要保证按顺序执行。比如,不能在订单未创建之前就进行支付确认,也不能在未支付之前就开始发货。
工作流管理:当业务逻辑涉及多个步骤并且这些步骤之间存在依赖关系时,确保每个任务按照正确的顺序被执行是非常重要的。例如,文档审批流程可能需要依次通过不同级别的审核人员。
实时数据分析和日志处理:对于一些需要根据事件发生的时间序列来进行分析的应用来说,保持数据摄入的顺序性可以简化下游处理逻辑并提高准确性。
库存管理系统:产品入库、出库等操作也需要遵循一定的顺序,以确保库存数量的准确性。
为了实现顺序消费,RocketMQ 提供了两种类型的顺序消息:
- 全局顺序
- 分区顺序
5.全局顺序和分区顺序
5.1 全局有序
可以为Topic设置一个消息队列,使用一个生产者单线程发送数据,消费者端也使用单线程进行消费。
从而保证消息的全局有序,但是这种方式效率低,一般不使用。
5.2 分区有序
在同一 Topic 下的不同
分区(Queue)之间不保证顺序,但在Sett同一分区内的消息则保持严格的顺序er。
假设一个Topic分配了两个消息队列,生产者在发送消息的时候,可以对消息设置一个路由ID。
比如想保证一个订单的相关消息有序,那么就使用订单ID当做路由ID。
在发送消息的时候,通过订单ID对消息队列的个数取余,根据取余结果选择消息队列。
这样同一个订单的数据就可以保证发送到一个消息队列中。
消费者端使用MessageListenerOrderly处理有序消息。
这就是RocketMQ的局部有序,保证消息在某个消息队列中有序
在实际应用中,通常会选择分区顺序而非全局顺序,因为后者可能导致性能瓶颈(所有的消息都要经过同一个队列),而前者可以在一定程度上平衡顺序性和吞吐量。
为了确保顺序消费的有效性,RocketMQ 使用了特定的消息发送策略(如于哈希值基选择相同的 MessageQueue)以及特殊的消费者监听器(MessageListenerOrderly),它会在消费端加锁以确保同一队列中的消息被单线程顺序处理。
此外,顺序消息不适合使用广播模式,并且建议不要异步发送这类消息,以免破坏其顺序性。
下面用订单进行分区有序的示例。
5.3 分区有序示例
一个订单的顺序流程是:创建、付款、推送、完成。订单号相同的消息会被先后发送到同一个队列中,消费时,同一个 Orderld 获取到的肯定是同一个队列。
6.顺序消费的条件
需要注意的是 RocketMQ 消息的顺序性分为两部分:
- 生产顺序性
- 消费顺序性。
只有同时满足了生产顺序性和消费顺序性才能达到上述的FIFO效果。
6.1 生产顺序性
在分布式环境下,保证消息的全局顺序性是十分困难的,例如两个 RocketMQ Producer A 与 Producer B,它们在没有沟通的情况下各自向 RocketMQ 服务端发送消息 a 和消息 b,由于分布式系统的限制,我们无法保证 a 和 b 的顺序。
因此业界消息系统通常保证的是分区的顺序性,即保证带有同一属性的消息的顺序,我们将该属性称之为 MessageGroup。
如图所示:
ProducerA 发送了 MessageGroup 属性为 A 的两条消息 A1,A2 和 MessageGroup 属性为 B 的 B1,B2,而 ProducerB 发送了 MessageGroup 属性为 C 的两条属性 C1,C2。
同时,对于同一 MessageGroup,为了保证其发送顺序的先后性,比较简单的做法是构造一个单线程的场景,即不同的 MessageGroup 由不同的 Producer 负责,并且对于每一个 Producer 而言,顺序消息是同步发送的。
同步发送的好处是显而易见的,在客户端得到上一条消息的发送结果后再发送下一条,即能准确保证发送顺序,若使用异步发送或多线程则很难保证这一点。
我们简单总结一下顺序发送
RocketMQ 通过生产者和服务端的协议保障单个生产者串行地发送消息,并按序存储和持久化。如需保证消息生产的顺序性,则必须满足以下条件:
- 单一生产者: 消息生产的顺序性仅支持单一生产者,不同生产者分布在不同的系统,即使设置相同的分区键,不同生产者之间产生的消息也无法判定其先后顺序。
- 串行发送:生产者客户端支持多线程安全访问,但如果生产者使用多线程并行发送,则不同线程间产生的消息将无法判定其先后顺序。
满足以上条件的生产者,将顺序消息发送至服务端后,会保证设置了同一分区键的消息,按照发送顺序存储在同一队列中。服务端顺序存储逻辑如下:
6.2 消费顺序性
与顺序消息不同的是,普通消息的消费实际上没有任何限制,消费者拉取的消息是被异步、并发消费的,而顺序消息,需要保证对于同一个 MessageGroup,同一时刻只有一个客户端在消费消息,并且在该条消息被确认消费完成之前(或者进入死信队列),消费者无法消费同一 MessageGroup 的下一条消息,否则消费的顺序性将得不到保证。
因此这里存在着一个消费瓶颈,该瓶颈取决于用户自身的业务处理逻辑。
极端情况下当某一 MessageGroup 的消息过多时,就可能导致消费堆积。
当然也需要明确的是,这里的语境都指的是同一 MessageGroup,不同 MessageGroup 的消息之间并不存在顺序性的关联,是可以进行并发消费的。因此全文中提到的顺序实际上是一种偏序。
RocketMQ 通过消费者和服务端的协议保障消息消费严格按照存储的先后顺序来处理。
- 投递顺序
RocketMQ 通过客户端SDK和服务端通信协议保障消息按照服务端存储顺序投递。
消费者类型为PushConsumer时, RocketMQ 保证消息按照存储顺序一条一条投递给消费者,若消费者类型为SimpleConsumer,则消费者有可能一次拉取多条消息。此时,消息消费的顺序性需要由应用程序保证。
- 有限重试
RocketMQ 顺序消息投递仅在重试次数限定范围内,即一条消息如果一直重试失败,超过最大重试次数后将不再重试,跳过这条消息消费,不会一直阻塞后续消息处理。
对于严格保证消费顺序的场景,需要合理设置重试次数,避免消息乱序。
6.3 生产顺序性和消费顺序性组合
如果消息需要严格按照先进先出(FIFO)的原则处理,即先发送的先消费、后发送的后消费,则必须要同时满足生产顺序性和消费顺序性。
一般业务场景下,同一个生产者可能对接多个下游消费者,不一定所有的消费者业务都需要顺序消费,可以将生产顺序性和消费顺序性进行差异化组合,应用于不同的业务场景。
7.顺序消息的生命周期
8.实现原理
消费者在启动时会调用DefaultMQPushConsumerImpl的start方法。
public class DefaultMQPushConsumer extends ClientConfig implements MQPushConsumer {
public void start() throws MQClientException {
this.setConsumerGroup(NamespaceUtil.wrapNamespace(this.getNamespace(), this.consumerGroup));
this.defaultMQPushConsumerImpl.start();
if (null != this.traceDispatcher) {
try {
this.traceDispatcher.start(this.getNamesrvAddr(), this.getAccessChannel());
} catch (MQClientException var2) {
MQClientException e = var2;
this.log.warn("trace dispatcher start failed ", e);
}
}
}
}
defaultMQPushConsumerImpl.start()的方法中对消息监听器类型进行了判断。
如果类型是MessageListenerOrderly表示要进行顺序消费。
此时使用ConsumeMessageOrderlyService对ConsumeMessageService进行实例化。
然后调用它的start方法进行启动。
加锁定时任务
进入到ConsumeMessageOrderlyService的start方法中。
可以看到,如果是集群模式,会启动一个定时加锁的任务,周期性的对订阅的消息队列进行加锁。
具体是通过调用RebalanceImpl的lockAll方法实现的。
此处我们需要思考一下:为什么集群模式下需要加锁?
8.1 集群模式下的三把锁
因为广播模式下,消息队列会分配给消费者下的每一个消费者。
而在集群模式下,一个消息队列同一时刻只能被同一个消费组下的某一个消费者进行。
所以在广播模式下不存在竞争关系,也就不需要对消息队列进行加锁。
而在集群模式下,有可能因为负载均衡等原因将某一个消息队列分配到了另外一个消费者中。
因此在集群模式下就要加锁,当某个消息队列被锁定时,其他的消费者不能进行消费。
整个顺序消费过程涉及了三把锁,它们分别对应不同的情况。
8.1.1 向Broker申请的消息队列锁
集群模式下一个消息队列同一时刻只能被同一个消费组下的某一个消费者进行。
为了避免负载均衡等原因引起的变动,消费者会向Broker发送请求对消息队列进行加锁。
如果加锁成功,记录到消息队列对应的ProcessQueue中的locked变量中,它是boolean类型的。
public class ProcessQueue {
private volatile boolean locked = false;
}
8.1.2 消费者处理拉取消息时的消息队列锁
消费者在处理拉取到的消息时,由于可以开启多线程进行处理。
所以处理消息前通过MessageQueueLock中的mqLockTable获取到了消息队列对应的锁。
锁住要处理的消息队列,这里加消息队列锁主要是处理多线程之间的竞争。
public class MessageQueueLock {
private ConcurrentMap<MessageQueue, Object> mqLockTable =
new ConcurrentHashMap<MessageQueue, Object>();
}
8.1.3 消息消费锁
消费者在调用consumeMessage方法之前会加消费锁。
主要是为了避免在消费消息时,由于负载均衡等原因,ProcessQueue被删除。
public class ProcessQueue {
private final Lock consumeLock = new ReentrantLock();
}
8.2 普通消息发送原理
普通消息的发送的代码比较简单,如下所示:
org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#sendDefaultImpl
主要流程如下:
Step 1:根据Topic 获取TopicPublishInfo,TopicPublishInfo中有我们的Topic发布消息的信息(),这个数据先从本地获取如果本地没有,则从NameServer去拉取,并且定时每隔20s会去获取TopicPublishInfo。
Step 2:获取总共执行次数(用于重试),如果发送方式是同步,那么总共次数会有3次,其他情况只会有1次。
Step 3: 从MessageQueue中选取一个进行发送,MessageQueue的概念可以等同于Kafka的partion分区,看作发送消息的最小粒度。这个选择有两种方式:
- 根据发送延迟进行选择,如果上一次发送的Broker是可用的,则从当前Broker选择遍历循环选择一个,如果不可用那么需要选择一个延迟最低的Broker从当前Broker上选择MessageQueue。
- 通过轮训的方式进行选择MessageQueue。
Step 4: 将Message发送至选择出来的MessageQueue上的Broker。
Step 5: 更新Broker的延迟。
Step 6: 根据不同的发送方式来处理结果:
- Async: 异步发送,通过callBack关心结果,所以这里不进行处理。
- OneWay: 顾名思义,就是单向发送,只需要发给broker,不需要关心结果,这里连callback都不需要。
- Sync: 同步发送,需要关心结果,根据结果判断是否需要进行重试,然后回到Step3。
可以看见Rocketmq发送普通消息的流程比较清晰简单
9.顺序消息示例demo
在使用 RocketMQ 的 Spring Boot 集成时,RocketMQTemplate 提供了便捷的方法来发送顺序消息。要实现顺序消息的发送,你需要根据是全局有序还是局部有序来选择不同的方法,并确保消息被正确地路由到指定的分区(MessageQueue)。
9.1 局部有序消息
对于局部有序消息,可以使用 RocketMQTemplate 提供的 sendOrderly 方法。
这个方法允许你通过一个额外的参数(通常是一个键或标识符)来指定消息应该被发送到哪个队列,从而保证同一组消息总是按照顺序处理。
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void sendOrderlyMessage(String topic, String tag, String keys, Object payload) {
// 发送局部有序消息
rocketMQTemplate.syncSendOrderly(topic + ":" + tag, MessageBuilder.withPayload(payload).build(), keys);
}
消费逻辑:
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerOrderly;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.spring.annotation.ConsumeMode;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* @author ninesun
*/
@Service
@RocketMQMessageListener(
topic = "YOUR_ORDERLY_TOPIC",
consumerGroup = "ORDERLY_CONSUMER_GROUP",
consumeMode = ConsumeMode.ORDERLY, // 确保消费模式为有序消费
selectorExpression = "TAG" // 可选:根据标签选择消息,如果不需要可以省略
)
public class OrderlyMessageConsumer implements MessageListenerOrderly {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
try {
for (MessageExt message : msgs) {
// 处理接收到的消息
System.out.println("Received orderly message: " + message.getBody());
// 在这里执行你的业务逻辑
// 注意:由于是有序消费,因此这里是串行处理的,同一时刻只会有一个线程在处理该队列中的消息。
}
// 返回消费状态
return ConsumeOrderlyStatus.SUCCESS;
} catch (Exception e) {
// 异常处理逻辑
e.printStackTrace();
// 可能需要重新投递消息或者记录到死信队列中
// 返回消费失败状态
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
}
}
}
参数解释:
- MessageExt 类:这是 RocketMQ 原生的消息类,包含了更多关于消息的元数据信息,如消息体、属性等。你可以直接访问这些属性来进行更细粒度的操作。
- ConsumeOrderlyContext 对象:提供了对当前消费上下文的控制能力,例如设置暂停时间、标记是否自动提交偏移量等。
- 返回值:consumeMessage 方法应该返回一个 ConsumeOrderlyStatus 枚举值,以指示消费的结果。常见的返回值包括:
- SUCCESS:表示消息已经被成功消费。
- ROLLBACK:表示消费失败并且希望稍后重试。
- SUSPEND_CURRENT_QUEUE_A_MOMENT:暂停当前队列一段时间,之后再继续消费。