RocketMQ学习笔记
高级功能
消息投递机制
- RocketMQ消息发送及消费只有一种模式:topic主题模式
- RocketMQ的消息投递分为两种:
- 一种是生产者往MQ Broker中投递;
- 另外一种则是MQ broker 往消费者投递(这种投递的说法是从消息传递的⻆度阐述的,实际上底层是消费者从MQ broker 中Pull拉取的)。
- RocketMQ 的消息模型整体并不复杂,如下图所示:
- 一个Topic(消息主题)可能对应多个实际的消息队列(MessgeQueue) 在底层实现上,为了提高MQ的可用性和灵活性,一个Topic在实际存储的过程中,采用了多队列的方式,具体形式如上图所示。每个消息队列在使用中应当保证先入先出(FIFO:First In First Out)的方式进行消费。
- 那么,基于这种模型,就会引申出两个问题:
- 生产者在发送相同Topic的消息时,消息体应当被放置到哪一个消息队列(MessageQueue)中?
- 消费者在消费消息时,应当从哪些消息队列中拉取消息?
生产者投递消息的策略
默认投递方式:基于Queue队列轮询算法投递
- 默认情况下,采用了最简单的轮询算法,这种算法有个很好的特性就是,保证每一个Queue队列的消息投递数量尽可能均匀,Producer端,每个实例在发消息的时候,默认会轮询所有的message queue发送,以达到让消息平均落在在不同的queue上,而由于queue可以散落在不同的broker,所以消息就发送到不同的broker上,如下图:
- 图中箭头线条上的标号代表顺序,发布方会把第一条消息发送至Queue 0,然后第二条消息发送至Queue 1,依次类推。
- 算法如下:
public class TopicPublishInfo {
// ...
// 基于线程上下文的计数递增,用于轮询目的
private volatile ThreadLocalIndex sendWhichQueue = new ThreadLocalIndex();
public MessageQueue selectOneMessageQueue(final String lastBrokerName) {
if (lastBrokerName == null) {
return selectOneMessageQueue();
} else {
int index = this.sendWhichQueue.getAndIncrement();
for (int i = 0; i < this.messageQueueList.size(); i++) {
// 轮询计算
int pos = Math.abs(index++) % this.messageQueueList.size();
if (pos < 0)
pos = 0;
MessageQueue mq = this.messageQueueList.get(pos);
if (!mq.getBrokerName().equals(lastBrokerName)) {
return mq;
}
}
return selectOneMessageQueue();
}
}
public MessageQueue selectOneMessageQueue() {
int index = this.sendWhichQueue.getAndIncrement();
int pos = Math.abs(index) % this.messageQueueList.size();
if (pos < 0)
pos = 0;
return this.messageQueueList.get(pos);
}
// ...
}
默认投递方式的增强:基于Queue队列轮询算法和消息投递延迟最小的策略投递
- 默认的投递方式比较简单,但是也暴露了一个问题,就是有些Queue队列可能由于自身数量积压等原因,可能在投递的过程比较⻓,对于这样的Queue队列会影响后续投递的效果。
- 基于这种现象,RocketMQ在每发送一个MQ消息后,都会统计一下消息投递的时间延迟,根据这个时间延迟,可以知道往哪些Queue队列投递的速度快。
- 在这种场景下,会优先使用消息投递延迟最小的策略,如果没有生效,再使用Queue队列轮询的方式。
顺序消息的投递方式
- 上述两种投递方式属于对消息投递的时序性没有要求的场景,这种投递的速度和效率比较高。而在有些场景下,需要保证同类型消息投递和消费的顺序性。
- 例如,假设现在有TOPIC:TOPIC_SALE_ORDER,该 Topic下有4个Queue队列,该Topic用于传递订单的状态变迁,假设订单有状态:未支付、已支付、发货中(处理中)、发货成功、发货失败。
- 在时序上,生产者从时序上可以生成如下几个消息:
- 订单T0000001未支付
- 订单T0000001已支付
- 订单T0000001发货中(处理中)
- 订单T0000001发货失败
- 消息发送到MQ中之后,可能由于轮询投递的原因,消息在MQ的存储可能如下:
- 这种情况下,我们希望消费者消费消息的顺序和我们发送是一致的。然而,有上述MQ的投递和消费机制,我们无法保证顺序是正确的,对于顺序异常的消息,消费者 即使有一定的状态容错,也不能完全处理好这么多种随机出现组合情况。
- 基于上述的情况,RockeMQ采用了这种实现方案:对于相同订单号的消息,通过一定的策略,将其放置在一个 queue队列中,然后消费者再采用一定的策略(一个线程独立处理一个queue,保证处理消息的顺序性),能够保证消费的顺序性。
- 我们先看生产者是如何能将相同订单号的消息发送到同一个queue队列的:生产者在消息投递的过程中,使用了 MessageQueueSelector 作为队列选择的策略接口,其定义如下:
public interface MessageQueueSelector {
MessageQueue select(final List<MessageQueue> mqs, final Message msg, final Object arg);
}
- 相应地,目前RocketMQ提供了如下几种实现:
投递策略 | 策略实现类 | 说明 |
---|---|---|
随机分配策略 | SelectMessageQueueByRandom | 使用了简单的随机数选择算法 |
基于Hash分配策略 | SelectMessageQueueByHash | 根据附加参数的Hash值,按照消息队列列表的大小取余数,得到消息队列的index |
基于机器机房位置分配策略 | SelectMessageQueueByMachineRandom | 开源的版本没有具体实现,基本的目的应该是机器的就近原则分配 |
- 现在大概看下策略的代码实现:
如何为消费者分配队列
- RocketMQ对于消费者消费消息有两种形式:
- BROADCASTING 广播式消费:这种模式下,一个消息会被通知到每一个消费者。
- CLUSTERING 集群式消费:这种模式下,一个消息最多只会被投递到一个消费者上进行消费。
广播模式
- 广播模式下要求一条消息需要投递到一个消费组下面所有的消费者实例,所以也就没有消息被分摊消费的说法。
- 在实现上,就是在consumer分配queue的时候,会所有consumer都分到所有的queue。
MessageModel.BROADCASTING
集群模式
- 使用了消费模式为MessageModel.CLUSTERING进行消费时,需要保证一个消息在整个集群中只需要被消费一次。实际上,在RoketMQ底层,消息指定分配给消费者的实现,是通过queue队列分配给消费者的方式完成的:也就是说,消息分配的单位是消息所在的queue队列。即:将queue队列指定给特定的消费者后,queue队列内的所有消息将会被指定到消费者进行消费。
- 在集群消费模式下,每条消息只需要投递到订阅这个topic的Consumer Group下的一个实例即可。RocketMQ采用主动拉取的方式拉取并消费消息,在拉取的时候需要明确指定拉取哪一条message queue。
- RocketMQ定义了策略接口AllocateMessageQueueStrategy,对于给定的消费者分组,和消息队列列表、消费者列表,当前消费者应当被分配到哪些queue队列,定义如下:
- 相应地,RocketMQ提供了如下几种实现:
算法名称 | 含义 |
---|---|
AllocateMessageQueueAveragely | 平均分配算法 |
AllocateMessageQueueAveragelyByCircle | 基于环形平均分配算法 |
AllocateMachineRoomNearby | 基于机房临近原则算法 |
AllocateMessageQueueMachineRoom | 基于机房分配算法 |
AllocateMessageQueueConsistentHash | 基于一致性hash算法 |
AllocateMessageQueueByConfig | 基于配置分配算法 |
- 为了讲述清楚上述算法的基本原理,我们先假设一个例子,上面所有的算法将基于这个例子讲解。
- 假设当前同一个topic下有queue队列 10个,消费者共有4个,如下图所示:
AllocateMessageQueueAveragely——平均分配算法
- 这里所谓的平均分配算法,并不是指的严格意义上的完全平均,如上面的例子中,10个queue,而消费者只有4个,是无法整除关系,除了整除之外的多出来的queue,将依次根据消费者的顺序均摊。
- 按照上述例子来看,10/4=2,即表示每个消费者平均均摊2个queue;而10%4=2,即除了均摊之外,多出来2个queue还没有分配,那么,根据消费者的顺序consumer-1、consumer-2、consumer-3、consumer-4,则多出来的2个queue将分别给consumer-1和consumer-2。
- 最终,分摊关系如下:consumer-1是3个,consumer-2是3个,consumer-3是2个,consumer-4是2个,如下图所示:
- 其代码实现非常简单:
AllocateMessageQueueAveragelyByCircle——基于环形平均分配算法
- 环形平均算法,是指根据消费者的顺序,依次在由queue队列组成的环形图中逐个分配。具体流程如下所示:
- 这种算法最终分配的结果是:
- consumer-1:#0、#4、#8
- consumer-2:#1、#5、#9
- consumer-3:#2、#6
- consumer-4:#3、#7
- 其代码实现如下所示:
AllocateMachineRoomNearby——基于机房临近原则算法
- 该算法使用了装饰者设计模式,对分配策略进行了增强。一般在生产环境,如果是微服务架构下,RocketMQ集群的部署可能是在不同的机房中部署,其基本结构可能如下图所示:
- 对于跨机房的场景,会存在网络、稳定性和隔离性的原因,该算法会根据queue的部署机房位置和消费者consumer的位置,过滤出当前消费者consumer相同机房的queue队列,然后再结合上述的算法,如基于平均分配算法在queue队列子集的基础上再挑选。
AllocateMessageQueueMachineRoom——基于机房分配算法
- 该算法适用于属于同一个机房内部的消息,去分配queue。这种方式非常明确,基于上面的机房临近分配算法的场景,这种更彻底,直接指定基于机房消费的策略。这种方式具有强约定性,比如broker名称按照机房的名称进行拼接,在算法中通过约定解析进行分配。
AllocateMessageQueueConsistentHash——基于一致性hash算法
- 使用这种算法,会将consumer消费者作为Node节点构造成一个hash环,然后queue队列通过这个hash环来决定被分配给哪个consumer消费者。
- 其基本模式如下:
AllocateMessageQueueByConfig——基于配置分配算法
- 这种算法单纯基于配置的,非常简单,实际使用中可能用途不大。
消费者如何指定分配算法?
- 默认情况下,消费者使用的是AllocateMessageQueueAveragely算法,也可以自己指定:
public class DefaultMQPushConsumer extends ClientConfig implements MQPushConsumer {
// ...
/**
* Default constructor.
*/
public DefaultMQPushConsumer() {
// 默认采用AllocateMessageQueueAveragely
this(null, MixAll.DEFAULT_CONSUMER_GROUP, null, new AllocateMessageQueueAveragely());
}
// ...
public DefaultMQPushConsumer(final String namespace, final String consumerGroup, RPCHook rpcHook,
AllocateMessageQueueStrategy allocateMessageQueueStrategy) {
this.consumerGroup = consumerGroup;
this.namespace = namespace;
this.allocateMessageQueueStrategy = allocateMessageQueueStrategy;
defaultMQPushConsumerImpl = new DefaultMQPushConsumerImpl(this, rpcHook);
}
// ...
}
Consumer方式
- 在RocketMQ里,consumer被分为2类:MQPullConsumer和MQPushConsumer,其实本质都是拉方式pull,即consumer轮询从broker拉取消息。RocketMQ的push模式是基于pull式实现的,它没有实现真正的push。
两者区别
- push方式里,consumer把轮询过程封装了,并注册MessageListener监听器,取到消息后,唤醒MessageListener 的consumeMessage()来消费,对用户而言,感觉消息是被推送过来的。
- pull方式里,取消息的过程需要用户自己写,首先通过打算消费的Topic拿到MessageQueue的集合,遍历MessageQueue集合,然后针对每个MessageQueue批量取消息,一次取完后,记录该队列下一次要取的开始offset,直到取完了,再换另一个MessageQueue。
- 从下面这张简单的示意图也可以大致看出其中的差别,相当于是说:
- push的方式是消息发送到broker后,则broker会主动把消息推送给consumer即topic中。
- pull的方式是消息投递到broker后,消费端需要主动去broker上拉消息,即需要手动写代码实现。
优缺点对比
- push:实时性高,但增加服务端负载,消费端能力不同,如果push的速度过快,消费端会出现很多问题
- pull:消费者从server端拉消息,主动权在消费端,可控性好,但是时间间隔不好设置,间隔太短,则空请求会多,浪费资源,间隔太⻓,则消息不能及时处理
消息重试
- 集群消费模式下,消息消费失败后,Broker会通过消息重试机制重新投递消息。
- 只有消费模式为 MessageModel.CLUSTERING(集群模式) 时,Broker才会自动进行重试,广播模式不会重试。
什么情况属于消息消费失败?
- 消费端返回ConsumeConcurrentlyStatus.RECONSUME_LATER
- 消费端返回null
- 消费端抛出异常并且未被捕获
- 事实上,对于一直无法消费成功的消息,RocketMQ会在达到最大重试次数之后,将该消息投递至死信队列。然后我们需要关注死信队列,并对该死信消息业务做人工的补偿操作。
顺序消息的重试
- 对于顺序消息,当消费者消费消息失败后,消息队列 RocketMQ 版会自动不断地进行消息重试(每次间隔时间为 1 秒),这时,应用会出现消息消费被阻塞的情况。因此,建议您使用顺序消息时,务必保证应用能够及时监控并处理消费失败的情况,避免阻塞现象的发生。
无序消息的重试
- 对于无序消息(普通、定时、延时、事务消息),当消费者消费消息失败时,您可以通过设置返回状态达到消息重试的结果。
- 重试次数:消息队列 RocketMQ 版默认允许每条消息最多重试 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 次重试,超过这个时间范围消息将不再重试投递。
注意:一条消息无论重试多少次,这些重试消息的 Message ID 不会改变。
配置方式
- 消费失败后,重试配置方式:集群消费方式下,消息消费失败后期望消息重试,需要在消息监听器接口的实现中明确进行配置三种方式任选一种)
- 消费端返回ConsumeConcurrentlyStatus.RECONSUME_LATER(推荐)
- 返回 Null
- 抛出异常
- 示例代码:
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
doConsumeMessage(message);
// 方式 1: 返回RECONSUME_LATER,消息将重试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
// 方式 2: 返回 null,消息将重试
return null;
// 方式 3: 直接抛出异常,消息将重试
throw new RuntimeException("Consumer Message exception");
}
}
- 消费失败后,无需重试的配置方式:集群消费方式下,消息失败后期望消息不重试,需要捕获消费逻辑中可能抛出的异常,最终返回ConsumeConcurrentlyStatus.CONSUME_SUCCESS,此后这条消息将不会再重试
- 示例代码:
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
doConsumeMessage(message);
} cache (Throwable e) {
// 捕获消费逻辑中的所有异常,并返回 CONSUME_SUCCESS;
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// 消息处理正常,直接返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
}
自定义消息最大重试次数
- 消息队列 RocketMQ 版允许 Consumer 启动的时候设置最大重试次数,重试时间间隔将按照以下策略:
- 最大重试次数小于等于 16 次,则重试时间间隔同上表描述。
- 最大重试次数大于 16 次,超过 16 次的重试时间间隔均为每次 2 小时。
- 配置方式如下:
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");
// 配置对应 Group ID 的最大消息重试次数为 20 次
consumer.setMaxReconsumeTimes(20);
注意:
- 消息最大重试次数的设置对相同 Group ID 下的所有 Consumer 实例有效。
- 如果只对相同 Group ID 下两个 Consumer 实例中的其中一个设置了 MaxReconsumeTimes,那么该配置对两个 > Consumer 实例均生效。
- 配置采用覆盖的方式生效,即最后启动的 Consumer 实例会覆盖之前的启动实例的配置。
- 获取消息重试次数:消费者收到消息后,可按照以下方式获取消息的重试次数
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for(MessageExt msg:msgs){
System.out.println(msg.getReconsumeTimes());
}
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
消息重试与延迟消息的关系
- 消息重试的16个级别,实际上是把延迟消息18个级别的前两个level去掉了,事实上,RocketMQ的消息重试也是基于延迟消息来完成的。在消息消费失败的情况下,将其重新当做延迟消息投递回Broker。
死信队列
- 如果消息消费失败,消费者返回RECONSUME_LATER给RocketMQ broker,队列会按照重试时间窗口对消息进行重试。
- 当达到最大重试次数(默认16次),消息还是消费失败,RocketMQ不会将该消息丢弃而是会把它保存到死信队列中。
- 这种不能被消费者正常处理的消息我们一般称之为死信消息(Dead-Letter Message),将存储死信消息的队列称之为 死信队列(Dead-Letter Queue,DLQ)。
死信消息/队列特征
- 死信Topic的命名为:%DLQ% + Consumer组名,如:%DLQ%online-tst。
首先看下死信消息具备的特点:
- 死信队列中的消息不会再被消费者正常消费,也就是一般情况下DLQ是消费者不可⻅的。
- 死信存储有效期与正常消息相同,均为 3 天,3 天后会被自动删除。因此,我们要保证在死信消息产生后的 3 天内对其进行及时处理。
而死信队列则具有以下特性:
- 每个死信队列对应一个 Group ID,也就是每个消费者组都有一个死信队列; 而不是对应单个消费者实例。
- 如果一个 Group ID 未产生死信消息,消息队列 RocketMQ 不会为其创建相应的死信队列。
- 一个死信队列包含了对应 Group ID 下产生的所有死信消息,不论该消息属于哪个 Topic,也就是对于某个消费者组,它的所有的死信共享一个死信队列。
查看死信消息
- 可以通过console应用直观的查看死信队列中的消息:
- 在cnosole中查询出现死信队列的主题
- 在消息界面根据主题查询死信消息
如何处理死信
- 了解了什么是死信,以及如何在console中查看死信。接下来我们看一下开发最关心的问题,如何处理死信?
- 实际上,当一条消息进入死信队列,就意味着某些因素导致消费者无法正常消费该消息(比如,代码中存在bug/数据库宕机等)。
- 因此,对于死信消息,通常需要开发进行特殊处理。
- 最关键的步骤是要排查可疑因素并解决代码中存在的bug。然后我们通过:
- 控制台重新发送该消息,让消费者对该消息重新消费一次。
- 除了通过console手动推送消息进行消费,我们也可以查询死信中消息,将消息重新投递到原topic进行重新消费。
- 举个例子,我们想要重新消费 %DLQ%online-tst 中的一条死信消息,就可以先通过mqadmin命令查询到该消息,然后将消息重新投递到原topic中,等待业务逻辑进行消费处理即可。前提是消费过程中要保证消费幂等。
消费幂等
- 为了防止消息重复消费导致业务处理异常,消息队列 RocketMQ 版的消费者在接收到消息后,有必要根据业务上的唯一 Key 对消息做幂等处理。
什么是消息幂等
百度对 “幂等” 解释如下:
设f为一由X映射至X的一元运算,则f为幂等的,当对于所有在X内的x,f(f(x)) = f(x)。
特别的是,恒等函数一定是幂等的,且任一常数函数也都是幂等的。
- 这里的关键是 f(f(x)) = f(x), 翻译成通俗的解释就是:如果有一个操作,多次执行与一次执行所产生的影响是相同的,我们就称这个操作是幂等的。
- 当出现消费者对某条消息重复消费的情况时,重复消费的结果与消费一次的结果是相同的,并且多次消费并未对业务系统产生任何负面影响,那么这整个过程就可实现消息幂等。
- 例如,在支付场景下,消费者消费扣款消息,对一笔订单执行扣款操作,扣款金额为 100 元。如果因网络不稳定等原因导致扣款消息重复投递,消费者重复消费了该扣款消息,但最终的业务结果是只扣款一次,扣费 100 元,且用户的扣款记录中对应的订单只有一条扣款流水,不会多次扣除费用。那么这次扣款操作是符合要求的,整个消费过程实现了消费幂等。
适用场景
- 在互联网应用中,尤其在网络不稳定的情况下,消息队列 RocketMQ 版的消息有可能会出现重复。如果消息重复会影响您的业务处理,请对消息做幂等处理。
消息重复的场景如下:
- 发送时消息重复:当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或者客户端宕机,导致服务端对客户端应答失败。 如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。
- 投递时消息重复:消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。为了保证消息至少被消费一次,消息队列 RocketMQ 版的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。
- 负载均衡时消息重复:(包括但不限于网络抖动、Broker 重启以及消费者应用重启)当消息队列 RocketMQ 版的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息。
实现消息幂等
那么如何才能实现消息幂等呢?
首先我们要定义消息幂等的两要素:
- 幂等令牌
- 处理唯一性的确保:我们必须保证存在幂等令牌的情况下保证业务处理结果的唯一性,才认为幂等实现是成功的。
- 幂等令牌:幂等令牌是生产者和消费者两者中的既定协议,在业务中通常是具备唯一业务标识的字符串,如订单号、流水号等。且一般由生产者端生成并传递给消费者端。
- 处理唯一性的确保:即服务端应当采用一定的策略保证同一个业务逻辑一定不会重复执行成功多次。如:使用支付宝进行支付,买一个产品支付多次只会成功一笔。
- 较为常用的方式是采用缓存去重并且通过对业务标识添加数据库的唯一索引实现幂等。
- 具体的思路为:如支付场景下,支付的发起端生成了一个支付流水号,服务端处理该支付请求成功后,数据持久化成功。由于表中对支付流水添加了唯一索引,因此当重复支付时会因为唯一索引的存在报错 duplicate entry,服务端的业务逻辑捕获该异常并返回调用侧“重复支付”提示。这样就不会重复扣款。
- 在上面场景的基础上,我们还可以引入Redis等缓存组件实现去重:当支付请求打到服务端,首先去缓存进行判断,根据key=“支付流水号”去get存储的值,如果返回为空,表明是首次进行支付操作同时将当前的支付流水号作为key、value可以为任意字符串通过set(key,value,expireTime)存储在redis中。
- 当重复的支付请求到来时,尝试进行get(支付流水号)操作,这个操作会命中缓存,因此我们可以认为该请求是重复的支付请求,服务端业务将重复支付的业务提示返回给请求方。
- 由于我们一般都会在缓存使用过程中设置过期时间,缓存可能会失效从而导致请求穿透到持久化存储中(如:MySQL)。因此不能因为引入缓存而放弃使用唯一索引,将二者结合在一起是一个比较好的方案。
RocketMQ场景下如何处理消息幂等
- 作为一款高性能的消息中间件,RocketMQ能够保证消息不丢失但不保证消息不重复。如果在RocketMQ中实现消息去重实际也是可以的,但是考虑到高可用以及高性能的需求,如果做了服务端的消息去重,RocketMQ就需要对消息做额外的rehash、排序等操作,这会花费较大的时间和空间等资源代价,收益并不明显。RocketMQ考虑到正常情况下出现重复消息的概率其实是很小的,因此RocketMQ将消息幂等操作交给了业务方处理。
- 因为 Message ID 有可能出现冲突(重复)的情况,因此不建议通过MessageID作为处理依据,而最好的方式是以业务唯一标识作为幂等处理的关键依据如:订单号、流水号等作为幂等处理的关键依据。而业务的唯一标识可以通过消息 Key 设置。
- 以支付场景为例,可以将消息的 Key 设置为订单号,作为幂等处理的依据。具体代码示例如下:
Message message = new Message();
message.setKeys("ORDERID_100");
SendResult sendResult = producer.send(message);
- 消费者收到消息时可以根据消息的 Key,即订单号来实现消息幂等:
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for(MessageExt msg:msgs){
String key = msg.getKeys();
// 根据业务唯一标识的 Key 做幂等处理
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
- 消费者通过getKeys()能够读取到生产者设置的幂等依据(如:订单号等),然后业务逻辑围绕该id进行幂等处理即可。
消费端常见的幂等操作
业务操作之前进行状态查询
- 消费端开始执行业务操作时,通过幂等id首先进行业务状态的查询,如:修改订单状态环节,当订单状态为成功/失败则不需要再进行处理。
- 那么我们只需要在消费逻辑执行之前通过订单号进行订单状态查询,一旦获取到确定的订单状态则对消息进行提交,通知broker消息状态为:ConsumeConcurrentlyStatus.CONSUME_SUCCESS。
业务操作前进行数据的检索
- 逻辑和第一点相似,即消费之前进行数据的检索,如果能够通过业务唯一id查询到对应的数据则不需要进行再后续的业务逻辑。
- 如:下单环节中,在消费者执行异步下单之前首先通过订单号查询订单是否已经存在,这里可以查库也可以查缓存。如果存在则直接返回消费成功,否则进行下单操作。
唯一性约束保证最后一道防线
- 上述第二点操作并不能保证一定不出现重复的数据,如:并发插入的场景下,如果没有乐观锁、分布式锁作为保证的前提下,很有可能出现数据的重复插入操作,因此我们务必要对幂等id添加唯一性索引,这样就能够保证在并发场景下也能保证数据的唯一性。
引入锁机制
- 上述的第一点中,如果是并发更新的情况,没有使用悲观锁、乐观锁、分布式锁等机制的前提下,进行更新,很可能会出现多次更新导致状态的不准确。
- 如:对订单状态的更新,业务要求订单只能从初始化 => 处理中,处理中 => 成功,处理中 => 失败,不允许跨状态更新。如果没有锁机制,很可能会将初始化的订单更新为成功,成功订单更新为失败等异常的情况。
- 高并发下,建议通过状态机的方式定义好业务状态的变迁,通过乐观锁、分布式锁机制保证多次更新的结果是确定的,悲观锁在并发环境不利于业务吞吐量的提高因此不建议使用。
消息记录表
- 这种方案和业务层做的幂等操作类似,由于我们的消息id是唯一的,可以借助该id进行消息的去重操作,间接实现消费的幂等。
- 首先准备一个消息记录表,在消费成功的同时插入一条已经处理成功的消息id记录到该表中,注意一定要 与业务操作处于同一个事务 中,当新的消息到达的时候,根据新消息的id在该表中查询是否已经存在该id,如果存在则表明消息已经被消费过,那么丢弃该消息不再进行业务操作即可。
肯定还有更多的场景…,这里说到的操作均是互相之间有关联的,将他们配合使用更能够保证消费业务 的幂等性。
消息堆积
消息堆积的本质
- 消息堆积无在乎这个问题:生产者的生产速度 远大于 消费者的处理速度
- 生产者的生产速度骤增,比如生产者的流量突然骤增。
- 消费速度变慢,比如消费者实例 IO 阻塞严重或者宕机。
- 消息中间件的主要功能是异步解耦,迓有个重要功能是挡住前端的数据洪峰,保证后端系统的稳定性, 这就要求消息中间件具有一定的消息堆积能力。
如何处理消息堆积
如何处理消息堆积呢?可以从两个当面考虑
如何通过解决系统问题、优化代码来避免消息堆积
消息已经堆积了,线上如何快速处理
发送端性能优化
- 从消息堆积若干原因来看,消息堆积的原因主要在消费端处理上,本身生产者端应该遵循的原则应该是尽可能快的将消息发送到Broker中去,因此发送端除了业务处理时批量发送暂无好的手段优化,而且并不是所有的业务处理都支持批量发送和批量接收处理。
- 批量发送是发送端预防消息堆积的方式之一。
消费端性能优化
- 在设计系统的时候,一定要保证消费端的消费性能要高于生产端的发送性能,这样的系统才能健康的持续运行。
- 方式1 增加单个消费者处理能力:增加单个消费者的处理能力这块没有绝对的办法,只能尽可能的优化消息处理业务逻辑的能力,减少不必要的非业务相关处理时间消耗;如果消息处理业务已经优化到无法再优化了,那只能通过方式2水平扩展消费者个数来优化。
- 方式2 水平扩容消费者个数:消费端的性能优化除了优化消费业务逻辑以外,也可以通过水平扩容,增加消费端的并发数来提升总体的消费性能。
如何快速处理
- 如果消息已经堆积了,线上如何快速处理。对于系统发生消息积压的情况,需要先解决积压,再分析原因,毕竟保证系统的可用性是首先要解决的问题。
- 快速解决积压的方法就是通过水平扩容增加 Consumer 的实例数量,以及其他方式如下:
- 消费端扩容:通用方式
- 服务降级:快速失败,不一定适用所有业务场景
- 跳过非重要消息:发生消息堆积时,如果消费速度一直追不上发送速度,可以选择丢弃不重要的消息
- 异常监控:属于运维层面措施