RocketMQ 入门

RocketMQ 介绍

消息队列 RocketMQ 是阿里巴巴集团基于高可用分布式集群技术,自主研发的云正式商用的专业消息中间件,既可为分布式应用系统提供异步解耦和削峰填谷的能力,同时也具备互联网应用所需的海量消息堆积、高吞吐、可靠重试等特性,是阿里巴巴双 11 使用的核心产品。

RocketMQ 的设计基于主题的发布与订阅模式,其核心功能包括消息发送、消息存储(Broker)、消息消费,整体设计追求简单与性能第一。

架构设计

  1. NameServer 设计及其简单,RocketMQ 摈弃了业界常用的 Zookeeper 充当消息管理的“注册中心”,而是使用自主研发的 NameServer 来实现各种元数据的管理(Topic 路由信息等)
  2. 高效的 I/O 存储,RocketMQ 追求消息发送的高吞吐量,RocketMQ 的消息存储设计成文件组的概念,组内单个文件固定大小,引入了内存映射机制,所有主题的消息存储基于顺序读写,极大提高消息写性能,同时为了兼顾消息消费与消息查找,引入消息消费队列文件与索引文件
  3. 容忍存在设计缺陷,适当将某些工作下放给 RocketMQ 的使用者,比如消息只消费一次,这样极大的简化了消息中间件的内核,使得 RocketMQ 的实现发送变得非常简单与高效。

      

RocketMQ 原先阿里巴巴内部使用,与 2017 年提交到 Apache 基金会成为 Apache 基金会的顶级开源项目
GitHub 代码库链接:https://github.com/apache/rocketmq.git
RocketMQ 的官网: http://rocketmq.apache.org/

NameServer

NameServer 是整个 RocketMQ 的“大脑”,它是 RocketMQ 的服务注册中心,所以 RocketMQ 需要先启动 NameServer 再启动 Rocket 中的 Broker。

Broker 在启动时会向所有 NameServer 注册(主要是服务器地址等),生产者在发送消息之前先从 NameServer 获取 Broker 服务器地址列表(消费者一样),然后根据负载均衡算法从列表中选择一台服务器进行消息发送。

NameServer 与每台 Broker 服务保持长连接,并间隔 30S 检查 Broker 是否存活,如果检测到 Broker 宕机,则从路由注册表中将其移除。

主题

主题,Topic,消息主题,一级消息类型,生产者向其发送消息,消费者负责从 Topic 接收并消费消息。

消费者

消费者:也称为消息订阅者,负责从 Topic 接收并消费消息。

消息

消息:生产者或消费者进行消息发送或消费的主题,对于 RocketMQ 来说,消息就是字节数组。

Rocket 的整体运转流程

  1. NameServer 先启动
  2. Broker 启动时向 NameServer 注册
  3. 生产者在发送某个主题的消息之前先从 NamerServer 获取 Broker 服务器地址列表(有可能是集群),然后根据负载均衡算法从列表中选择一台 Broker 进行消息发送。
  4. NameServer 与每台 Broker 服务器保持长连接,并间隔 30S 检测 Broker 是否存活,如果检测到 Broker 宕机(使用心跳机制,如果检测超过120S),则从路由注册表中将其移除。
  5. 消费者在订阅某个主题的消息之前从 NamerServer 获取 Broker 服务器地址列表(有可能是集群),但是消费者选择从 Broker 中 订阅消息,订阅规则由 Broker 配置决定。

RocketMQ 的设计理念和目标 

设计理念

基于主题的发布和订阅,其核心功能,消息发送、消息存储和消息消费。整体设计追求简单与性能。

NameServer 性能对比 Zookeeper 有极大的提升

高效的 IO 存储机制,基于文件顺序读写,内存映射机制

容忍设计缺陷,比如消息只消费一次,Rocket 自身不保证,从而简化 Rocket 的内核使得 Rocket 简单与高效,这个问题交给消费者去实现(幂等)。

设计目标

架构模式:发布订阅模式,主要组件:消息发送者、消息服务器(消息存储)、消息消费、路由发现。  

顺序消息:RocketMQ 可以严格保证消息有序性

消息过滤:消息消费是,消费者可以对同一主题下的消息按照规则只消费自己感兴趣的消息,可以支持在服务端和消费端的消息过滤功能

消息存储:一般 MQ 核心就是消息的存储,对存储一般来说两个维度:消息堆积能力消息存储性能。RocketMQ 追求消息存储的高性能,引入内存映射机制,所有的主题消息顺序存储在同一个文件中。同时为了防止无限堆积,引入消息文件过期机制文件存储空间报警机制

消息高可用:

  1. Rocket 关机、断电等情况下,Rokcet 可以确保不丢失消息(同步刷盘机制不丢失,异步刷盘会丢失少量)。
  2. 另外如果 Rocket 服务器因为 CPU、内存、主板、磁盘等关键设备损坏导致无法开机,这个属于单点故障,该节点上的消息全部丢失,如果开启了异步复制机制,Rocket 可以确保只丢失很少量消息。
  3. 如果引入双写机制,这样基本上可以满足消息可靠性要求极高的场景(毕竟两台主服务器同时故障的可能性还是非常小)   

消息消费低延迟:RocketMQ 在消息不发生消息堆积时,以长轮询模式实现准实时的消息推送模式。 

确保消息必须被消费一次:消息确认机制(ACK)来确保消息至少被消费一次,一般 ACK 机制只能做到消息只被消费一次,有重复消费的可能。

消息回溯:已经消费完的消息,可以根据业务要求重新消费消息。

消息堆积:消息中间件的主要功能是异步解耦,还有个重要功能是挡住前端的数据洪峰,保证后端系统的稳定性,这就要求消息中间件具有一定的消息堆积能力,RocketMQ 采用磁盘文件存储,所以堆积能力比较强,同时提供文件过期删除机制

定时消息:定时消息,定时消息是指消息发送到 Rocket Broker 上之后,不被消费者理解消费,要到等待一定的时间才能进行消费,apache 的版本目前只支持等待指定的时间才能被消费,不支持任意精度的定时消息消费。(一个说法是任意精度的定时消息会带来性能损耗,但是阿里云版本的 RocketMQ 却提供这样的功能,充值收费优先策略?

消息重试机制:消息重试是指在消息消费时,如果发送异常,那么消息中间件需要支持消息重新投递,RocketMQ 支持消息重试机制。

RocketMq 中消息的发送

普通消息是指消息队列 RocketMQ 中无特性的消息,区别于有特性的定时/延时消息顺序消息事务消息

RocketMQ 发送普通消息有三种实现方式:

  • 单向(OneWay)发送
  • 可靠同步发送
  • 可靠异步发送。

消息生产的客户端依赖如下:

    

broker 配置文件:broker.conf

# 是否允许 Broker 自动创建 Topic,建议线下开启,线上关闭autoCreateTopicEnable=true
# 是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭autoCreateSubscriptionGroup=true

单向(OneWay)发送

单向(Oneway)发送特点为发送方只负责发送消息,不等待服务器回应且没有回调函数触发,即只发送请求不等待应答。此方式发送消息的过程耗时非常短,一般在微秒级别。

     

代码演示

import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.common.RemotingHelper;

public class OnewayProducer {

    public static void main(String[] args) throws Exception{

        DefaultMQProducer producer = new DefaultMQProducer("tl_message_group");
        // Specify name server addresses.
        producer.setNamesrvAddr("192.168.116.100:9876;192.168.241.199:9876");
        producer.setSendMsgTimeout(10000);
        producer.start();
        for (int i = 0; i < 1; i++) {
            Message msg = new Message("TopicTest" /* Topic */,
                    "TagSendOne" /* Tag */,
                    "OrderID198",
                    ("Hello RocketMQ test i " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
            );
            producer.sendOneway(msg);
        }
        //Shut down once the producer instance is not longer in use.
        producer.shutdown();
    }
}

Producer Group(生产者分组):简单来说就是多个发送同一类消息的生产者称之为一个生产者组。在这里可以不用关心,只要知道有这么一个概念即可。RocketMQ 中的生产者组只能有一个在用的生产者。分组的作用如下(简单的场景不需要了解这个概念):

  1. 标识一类 Producer
  2. 可以通过运维工具查询这个发送消息应用下有多个 Producer 实例
  3. 发送分布式事务消息时,如果 Producer 中途意外宕机,Broker 会主动回调 Producer Group 内的任意一台机器来确认事务状态。

Producer 实 例Producer 的一个对象实例,不同的 Producer 实例可以运行在不同进程内或者不同机器上。Producer 实例线程安全,可在同一进程内多线程之间共享。

Message Key:一般用于消息在业务层面的唯一标识。对发送的消息设置好 Key,以后可以根据这个 Key 来查找消息。比如消息异常,消息丢失,进行查找会很方便。RocketMQ 会创建专门的索引文件,用来存储 Key 与消息的映射,由于是 Hash 索引,应尽量使 Key 唯一,避免潜在的哈希冲突。

Tag 和 Key 的主要差别是使用场景不同,Tag 用在 Consumer 代码中,用于服务端消息过滤,Key 主要用于通过命令进行查找消息

RocketMQ 并不能保证 message id 唯一,在这种情况下,生产者在 push 消息的时候可以给每条消息设定唯一的 key,消费者可以通过 message key 保证对消息幂等处理。

Tag:消息标签,二级消息类型,用来进一步区分某个 Topic 下的消息分类。

Topic 与 Tag 都是业务上用来归类的标识,区分在于 Topic 是一级分类,而 Tag 可以理解为是二级分类。

以天猫交易平台为例,订单消息和支付消息属于不同业务类型的消息,分别创建 Topic_Order 和 Topic_Pay,其中订单消息根据商品品类以不同的 Tag

再进行细分,如电器类、男装类、女装类、化妆品类,最后他们都被各个不同的系统所接收。通过合理的使用 Topic 和 Tag,可以让业务结构清晰,更可以提高效率。

到底什么时候该用 Topic,什么时候该用 Tag?

  1. 消息类型是否一致:如普通消息、事务消息、定时(延时)消息、顺序消息,不同的消息类型使用不同的 Topic,无法通过 Tag 进行区分。
  2. 业务是否相关联:没有直接关联的消息,如淘宝交易消息,京东物流消息使用不同的 Topic 进行区分;而同样是天猫交易消息,电器类订单、女装类订单、化妆品类订单的消息可以用 Tag 进行区分。
  3. 消息优先级是否一致:如同样是物流消息,盒马必须小时内送达,天猫超市 24 小时内送达,淘宝物流则相对会慢一些,不同优先级的消息用不同的 Topic 进行区分。
  4. 消息量级是否相当:有些业务消息虽然量小但是实时性要求高,如果跟某些万亿量级的消息使用同一个 Topic,则有可能会因为过长的等待时间而“饿死”,此时需要将不同量级的消息进行拆分,使用不同的 Topic。

可靠同步发送

同步发送是指消息发送方发出数据后,同步等待,直到收到接收方发回响应之后才发下一个请求。

     

代码演示

import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.common.RemotingHelper;
import org.apache.rocketmq.remoting.exception.RemotingException;

import java.io.UnsupportedEncodingException;

/**
 * 同步生产者
 */
@Slf4j
public class SyncProducer {

    public static void main(String[] args) throws MQClientException, UnsupportedEncodingException, RemotingException, InterruptedException, MQBrokerException {
        //Instantiate with a producer group name.
        DefaultMQProducer producer = new DefaultMQProducer("msg_group");

        producer.setNamesrvAddr("192.168.116.100:9876");
        //producer.setSendMsgTimeout(10000);

        producer.start();
        /*for (int i = 0; i < 1; i++) {
            Message msg = new Message("TopicSync"  , "TagS"  , ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
            SendResult sendResult = producer.send(msg);
            System.out.printf("%s%n", sendResult);
        }*/
        Message msg = new Message("TopicSync"  ,"Tag"  , "tag" , ("Hello RocketMQ ").getBytes(RemotingHelper.DEFAULT_CHARSET));
        SendResult sendResult = producer.send(msg);
        log.info("SendResult:{}" + sendResult);

        producer.shutdown();
    }
}

Message ID:消息的全局唯一标识(内部机制的 ID 生成是使用机器 IP 和消息偏移量的组成,所以有可能重复,如果是需要做幂等性最好考虑 Message Key),由消息队列 MQ系统自动生成,唯一标识某条消息。

SendStatus:发送的标识。成功,失败等

Queue:RocketMQ 收到消息后,所有主题的消息都存储在 commitlog 文件中,当消息到达 commitlog 后,将会采用异步转发到消息队列,也就是 consumerqueue()

Queue 是数据分片的产物,数据分片可以提高消费者的效率。

# 在发送消息时,自动创建服务器不存在的 topic,默认创建的队列数 defaultTopicQueueNums = 4

     

如下图,当生产者发送消息到达 commitlog 后,rocketmq 将会自动采用异步转发到消息队列 consumerqueue,通过这种方式将消息进行了分片,同一个消费者可以订阅多个 consumerqueue,但是不能多个消费者同时订阅同一个 consumerqueue

可靠异步发送

消息发送方在发送了一条消息后,不等接收方发回响应,接着进行第二条消息发送。发送方通过回调接口的方式接收服务器响应,并对响应结果进行处理

代码演示

import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.common.RemotingHelper;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

@Slf4j
public class AsyncProducer {

    public static void main(String[] args) throws Exception {
        DefaultMQProducer producer = new DefaultMQProducer("message_group");
        producer.setNamesrvAddr("192.168.116.100:9876");
        producer.start();

        //设置发送失败重试机制次数
        producer.setRetryTimesWhenSendAsyncFailed(5);

        int messageCount = 10;
        final CountDownLatch countDownLatch = new CountDownLatch(messageCount);
        for (int i = 0; i < messageCount; i++) {
            final int index = i;
            Message msg = new Message("TopicTest", "TagSendOne","OrderID188", ("AsyncProducer sending msg content is " + index).getBytes(RemotingHelper.DEFAULT_CHARSET));
            //消息发送成功后,执行回调函数
            producer.send(msg, new SendCallback() {
                @Override
                public void onSuccess(SendResult sendResult) {
                    countDownLatch.countDown();
                    log.info("%-10d OK %s %n", index, sendResult.getMsgId());
                }
                @Override
                public void onException(Throwable e) {
                    countDownLatch.countDown();
                    log.error("%-10d Exception %s %n", index, e);
                }
            });
        }
        countDownLatch.await(5, TimeUnit.SECONDS);

        producer.shutdown();
    }
}

RocketMQ 中消息发送的权衡,三种发送方式的对比

RocketMQ 消息消费

集群消费和广播消费

基本概念

消息队列 RocketMQ 是基于发布/订阅模型的消息系统。消息的订阅方订阅关注的 Topic,以获取并消费消息。由于订阅方应用一般是分布式系统,以集群方式部署有多台机器。因此消息队列 RocketMQ 约定以下概念。

集群:使用相同 Group ID 的订阅者属于同一个集群。同一个集群下的订阅者消费逻辑必须完全一致(包括 Tag 的使用),这些订阅者在逻辑上可以认为是一个消费节点。

集群消费:当使用集群消费模式时,消息队列 RocketMQ 认为任意一条消息只需要被集群内的任意一个消费者处理即可。

广播消费:当使用广播消费模式时,消息队列 RocketMQ 会将每条消息推送给集群内所有注册过的客户端,保证消息至少被每台机器消费一次。

场景对比 - 集群消费模式

   

适用场景&注意事项

消费端集群化部署,每条消息只需要被处理一次。 由于消费进度在服务端维护,可靠性更高。

集群消费模式下,每一条消息都只会被分发到一台机器上处理。如果需要被集群下的每一台机器都处理,请使用广播模式。 集群消费模式下,不保证每一次失败重投的消息路由到同一台机器上,因此处理消息时不应该做任何确定性假设。

相同的消费者代码进行测试截图:

从上图中可以明显看到,生产者的消息被异步转发到4个(默认是4个队列)消息队列 consumerqueue,再分别被四个不同的消费者消费

场景对比 - 广播消费模式

实例代码

import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.common.protocol.heartbeat.MessageModel;

import java.util.List;

public class BroadcastConsumer {

    public static void main(String[] args) throws Exception {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer_model_group");
        consumer.setNamesrvAddr("192.168.116.100:9876");
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
        //广播,全量消费
        consumer.setMessageModel(MessageModel.CLUSTERING);
        consumer.subscribe("TopicTest", "TagA || TagC || TagD");
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                for (MessageExt ext : msgs){
                    System.out.printf(Thread.currentThread().getName() + " Receive New Message: " + new String(ext.getBody()) + "%n");
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        consumer.start();
        System.out.printf("Broadcast Consumer Started.%n");
    }
}

适用场景&注意事项

广播消费模式下不支持顺序消息。

广播消费模式下不支持重置消费位点。

每条消息都需要被相同逻辑的多台机器处理。

消费进度在客户端维护,出现重复的概率稍大于集群模式。

广播模式下,消息队列 RocketMQ 保证每条消息至少被每台客户端消费一次,但是并不会对消费失败的消息进行失败重投,因此业务方需要关注消费失败的情况。

广播模式下,客户端每一次重启都会从最新消息消费。客户端在被停止期间发送至服务端的消息将会被自动跳过,请谨慎选择。

广播模式下,每条消息都会被大量的客户端重复处理,因此推荐尽可能使用集群模式。  目前仅 Java 客户端支持广播模式。

广播模式下服务端不维护消费进度,所以消息队列 RocketMQ 控制台不支持消息堆积查询、消息堆积报警和订阅关系查询功能。

场景对比 -使用集群模式模拟广播

如果业务需要使用广播模式,也可以创建多个 Group ID,用于订阅同一个 Topic。

适用场景&注意事项

每条消息都需要被多台机器处理,每台机器的逻辑可以相同也可以不一样。  消费进度在服务端维护,可靠性高于广播模式。

对于一个 Group ID 来说,可以部署一个消费端实例,也可以部署多个消费端实例。 当部署多个消费端实例时,实例之间又组成了集群模式(共同分担消费消息)。 假设 Group ID 1 部署了三个消费者实例 C1、C2、C3,那么这三个实例将共同分担服务器发送给 Group ID 1 的消息。 同时,实例之间订阅关系必须保持一致。

消费方式

推模式

代码上使用 DefaultMQPushConsumer

这种模型下,系统收到消息后自动调用处理函数来处理消息,自动保存 Offset,并且加入新的消费者后会自动做负载均衡。

底层实现上,推模式还是使用的 pull 来实现的,pull 就是拉取,push 方式是 Server 端接收到消息后,主动把消息推给 Client 端,实时性高。但是使用 Push 方式有很多弊端,首先加大 Server 端的工作量,其次不同的 Client 端处理能力不同,Client 的状态不受 Server 控制,如果 Client 不能及时处理 Server 推送过来的消息,会造成各种潜在问题。

所以 RocketMQ 是通过“长轮询”的方式,同时通过 Client 端和 Server 端的配合,达到既拥有 Pull 的优点,又能达到确保实时性的目的。

长轮询

所以 RocketMQ 使用“长轮询”的方式来解决生产者生产的消息超出了消费者的处理能力范围的问题,核心思想还是客户端拉取消息,Broker 端 HOLD 住客户端发过来的请求一小段时间,在这个时间内(5s)有新消息达到,就利用现有的连接立刻返回消息给 Consunmer。“长轮询”的主动权还是掌握在 Consumer 手中,Broker 即使有大量消息积压,也不会主动推送给 Consumer。因为长轮询方式的有局限性是在 HOLD 住 Comsumer 请求的时候需要占用资源,所以它适合在消息队列这种客户端连接数可控的场景中。

拉模式

代码上使用 DefaultMQPullConsumer

import org.apache.rocketmq.client.consumer.DefaultMQPullConsumer;
import org.apache.rocketmq.client.consumer.PullResult;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.common.message.MessageQueue;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class PullConsumer {

    private static final Map<MessageQueue, Long> offsetTable = new HashMap<MessageQueue, Long>();

    public static void main(String[] args) throws MQClientException {
        DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("message_group");
        consumer.setNamesrvAddr("192.168.116.100:9876");
        consumer.start();

        Set<MessageQueue> mqs = consumer.fetchSubscribeMessageQueues("TopicTest");
        for (MessageQueue mq : mqs) {
            System.err.println("Consume from the queue: " + mq);
            SINGLE_MQ:
            while (true) try {
                PullResult pullResult = consumer.pullBlockIfNotFound(mq, null, getMessageQueueOffset(mq), 32);
                System.out.println(pullResult);
                putMessageQueueOffset(mq, pullResult.getNextBeginOffset());

                switch (pullResult.getPullStatus()) {
                    case FOUND:
                        List<MessageExt> messageExtList = pullResult.getMsgFoundList();
                        for (MessageExt m : messageExtList) {
                            System.out.println(new String(m.getBody()));
                        }
                        break;
                    case NO_MATCHED_MSG:
                        break;
                    case NO_NEW_MSG:
                        break SINGLE_MQ;
                    case OFFSET_ILLEGAL:
                        break;
                    default:
                        break;
                }

            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        consumer.shutdown();
    }

    private static void putMessageQueueOffset(MessageQueue mq, long offset) {
        offsetTable.put(mq, offset);
    }

    private static long getMessageQueueOffset(MessageQueue mq) {
        Long offset = offsetTable.get(mq);
        if (offset != null)
            return offset;
        return 0;
    }
}

使用方式类似,但是更加复杂,除了像推模式一样需要设置各种参数之外,还需要处理额外三件事情:

  1. 获取 MessageQueues 并遍历(一个 Topic 包括多个 MessageQueue),如果是特殊情况,也可以选择指定的 MessageQueue 来读取消息
  2. 维护 Offsetstore,从一个 MessageQueue 里拉取消息时,要传入 Offset 参数,随着不断的读取消息,Offset 会不断增长。这个时候就需要用户把 Offset 存储起来,根据实际的情况存入内存、写入磁盘或者数据库中。
  3. 根据不同的消息状态做不同的处理。

总结:这种模式下用户需要自己处理 Queue,并且自己保存偏移量,所以这种方式太过灵活,往往我们业务的关注重点不在内部消息的处理上,所以一般情况下我们会使用推模式 

流量控制

Push 模式基于拉取,消费者会判断获取但还未处理的消息个数、消息总大小、Offset 的跨度 3 个维度来控制,如果任一值超过设定的大小就隔一段时间再拉取消息,从而达到流量控制的目的。

限流的做法是放弃本次拉取消息的动作,并且这个队列的下一次拉取任务将在 50 毫秒后才加入到拉取任务队列,触发限流有以下两个条件

  1. 当前的 ProcessQueue(一个主题有多个队列,每一个队列会对应有一个 ProcessQueue 来处理消息)正在处理的消息数量>1000
  2. 队列中最大最小偏移量差距>2000,这个是为了避免一条消息堵塞,消息进度无法向前推进,可能造成大量消息重复消费。造成这种问题的原因可能是在处理其中一条消息时耗时过长,造成处理消息的能力收到了阻塞,导致消息的在消费端堆积

消息队列负载

在集群消费模式中,往往会有很多个消费者,对应消费一个主题(topic),一个主题中有很多个消费者队列(queue),我们要考虑的问题是,集群内多个消费者是如何负载主题下的多个消费者队列,并且如果有新的消费者加入是,消息队列又会如何重新分布。

RocketMQ 默认提供 5 中分配算法

如果有 8 个消息队列(q1,q2,q3,q4,q5,q6,q7,q8),有 3 个消费者(c1,c2,c3)

  1. 平均分配(AllocateMessageQueueAveragely)默认值:c1:q1,q2,q3 / c2:q4,q5,q6 / c3:q7,q8,
  2. 平均轮询分配(AllocateMessageQueueAveragelyByCircle):c1:q1,q4,q7 / c2:q2,q5,q8 / c3:q3,q6
  3. 一直性 Hash(AllocateMessageQueueConsistentHash):不推荐使用,因为消息队列负载均衡信息不容易跟踪
  4. 根据配置(AllocateMessageQueueByConfig)为每一个消费者配置固定的消费队列
  5. 根据 Broker 部署机房名(AllocateMessageQueueByMachineRoom)对每一个消费者负载不同 Broker 上的队列

一般尽量使用“平均分配”“平均轮询分配”,因为分配算法比较直观。无论哪种算法,遵循的原则是一个消费者可以分配多个消息队列,同一个消息队列只会分配一个消费者,所以如果消费者个数大于消息队列数量,则有些消费者无法消费消息。

重新分布机制

从源码的角度上看,RocketMQ 消息队列重新分布是由 RebalanceService 线程来实现的,一个 MQClientInstance 持有一个 RebalanceService 实现,并且随着 MQClientInstance 的启动而启动。

备注:(MQClientInstance 是生产者和消费者中最大的一个实例,作为生产者或者消费者引用 RocketMQ 客户端,在一个 JVM 中所有消费者、生产者都持有同一个 MQClientInstance,MQClientInstance 只会启动一次,即 MQClientInstance 是单例的)

RebalanceService 每隔 20S 进行一次队列负载,每次进行队列重新负载时会查询出当前所有的消费者,并且对消息队列、消费者列表进行排序。因为在一个 JVM 中只会有一个pullRequestQueue 对象,具体可见源码中PullMessageService。

消息确认(ACK)

PushConsumer 为了保证消息肯定消费成功,只有使用方明确表示消费成功,RocketMQ 才会认为消息消费成功。中途断电,抛出异常等都不会认为成功 -- 即都会重新投递。

业务实现消费回调的时候,当且仅当此回调函数返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS,RocketMQ 才会认为这批消息(默认是 1 条) 是消费完成的

如果某时候消息消费失败,例如数据库异常 或者 余额不足扣款失败等一切业务认为消息需要重试的场景,只要返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,RocketMQ 就会认为这批消息消费失败了。返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,rocketmq 会将消息放到重试队列,这个重试 TOPIC 的名字自动变成了 %RETRY%+consumergroup 的名字

为了保证消息是肯定被至少消费成功一次,RocketMQ 会把这批消息重发回 Broker(topic 不是原 topic 而是这个消费者的 RETRY topic),在延迟的某个时间点(默认是 10 秒,业务可设置 maxReconsumeTimes)后,再次投递到这个 ConsumerGroup。而如果一直这样重复消费都持续失败到一定次数(默认 16 次),就会投递到 DLQ 死信队列。应用可以监控死信队列来做人工干预。

 消息ACK 机制 

RocketMQ 是以 consumer group+queue 为单位是管理消费进度的,以一个 consumer offset 标记这个这个消费组在这条 queue 上的消费进度。如果某已存在的消费组出现了新消费实例的时候,依靠这个组的消费进度,就可以判断第一次是从哪里开始拉取的。

每次消息成功后,本地的消费进度会被更新,然后由定时器定时同步到 broker,以此持久化消费进度。但是每次记录消费进度的时候,只会把一批消息中最小的 offset 值作为消费进度值

这钟方式和传统的一条 message 单独 ack 的方式有本质的区别。性能上提升的同时,会带来一个潜在的重复问题 -- 由于消费进度只是记录了一个下标,就可能出现拉取了 100 条消息,如 2101-2200 的消息,后面 99 条都消费结束了,只有 2101 消费一直没有结束的情况, 在这种情况下,RocketMQ 为了保证消息肯定被消费成功,消费进度职能维持在 2101,直到 2101 也消费结束了,本地的消费进度才能标记 2200 消费结束了(注:consumerOffset=2201)。

在这种设计下,就有消费大量重复的风险。如 2101 在还没有消费完成的时候消费实例突然退出机器断电,或者被 kill。这条 queue 的消费进度还是维持在 2101, 当 queue 重新分配给新的实例的时候,新的实例从 broker 上拿到的消费进度还是维持在 2101,这时候就会又从 2101 开始消费,2102-2200 这批消息实际上已经被消费过还是会投递一次。

对于这个场景,RocketMQ 暂时无能为力,所以这种消息重复的问题需要业务根据 message Key 作幂等处理,这也是 RocketMQ 官方多次强调的态度。

消息进度存储

广播模式

同一个消费组的所有消费者都需要消费主题下的所有消息,因为消费者的行为都是独立的,互不影响,固消息进度需要独立存储,所以这种模式下消息进度存储在消费者本地。windows 存储为止如下:

   

集群模式

 

集群模式消息进度存储文件存放在服务器 Broker 上。

顺序消息

顺序消息(FIFO 消息)是消息队列 RocketMQ 提供的一种严格按照顺序来发布和消费的消息。顺序发布和顺序消费是指对于指定的一个 Topic,生产者按照一定的先后顺序发布消息;消费者按照既定的先后顺序订阅消息,即先发布的消息一定会先被客户端接收到。

顺序消息分为全局顺序消息分区顺序消息

全局顺序消息

RocketMQ 在默认情况下不保证顺序,要保证全局顺序,需要把 Topic 的读写队列数设置为 1,然后生产者和消费者的并发设置也是 1。所以这样的话高并发,高吞吐量的功能完全用不上。

适用场景

适用于性能要求不高,所有的消息严格按照 FIFO 原则来发布和消费的场景。

示例

要确保全局顺序消息,需要先把 Topic 的读写队列数设置为 1,然后生产者和消费者的并发设置也是 1。

mqadmin update Topic -t AllOrder -c DefaultCluster -r 1 -w 1 -n 127.0.0.1:9876

在证券处理中,以人民币兑换美元为 Topic,在价格相同的情况下,先出价者优先处理,则可以按照 FIFO 的方式发布和消费全局顺序消息。

部分顺序消息

对于指定的一个 Topic,所有消息根据 Sharding Key 进行区块分区。同一个分区内的消息按照严格的 FIFO 顺序进行发布和消费。Sharding Key 是顺序消息中用来区分不同分区的关键字段,和普通消息的 Key 是完全不同的概念。

将同一个 Topic 的消息安装 tagId 的不同进行区分,那么每个 tag 中的消息就是有序的,从而实现了部分顺序消息

import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.MessageQueueSelector;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.remoting.common.RemotingHelper;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class OrderedProducer {
    public static void main(String[] args) throws Exception {
        DefaultMQProducer producer = new DefaultMQProducer("ordered_group_name");
        producer.setNamesrvAddr("192.168.116.100:9876");
        producer.start();
        String[] tags = new String[]{"TagA", "TagC", "TagD"};
        // 订单列表
        List<OrderStep> orderList = buildOrders();
        // 生产环境不能使用SimpleDateFormat,有线程安全问题
        String dateStr = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
        for (int i = 0; i < 10; i++) {
            // 加个时间前缀
            String body = dateStr + " Hello RocketMQ "+ i + " " + orderList.get(i);
            Message msg = new Message("TopicTest", tags[i % tags.length], "KEY" + i, body.getBytes(RemotingHelper.DEFAULT_CHARSET));
            SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
                // 根据定制化来实现选择发送的哪个 messageQueue,从而实现顺序发送
                @Override
                public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                    Long id = (Long) arg;  // 根据订单id选择发送queue
                    long index = id % mqs.size();
                    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();
    }

    /**
     * 生成模拟订单数据
     */
    private static 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(15103117235L);
        orderDemo.setDesc("付款");
        orderList.add(orderDemo);

        orderDemo = new OrderStep();
        orderDemo.setOrderId(15103111065L);
        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);

        return orderList;
    }

    /**
     * 订单的步骤
     */
    private static class OrderStep {
        private long orderId;
        private String desc;

        public long getOrderId() {
            return orderId;
        }

        public void setOrderId(long orderId) {
            this.orderId = orderId;
        }

        public String getDesc() {
            return desc;
        }

        public void setDesc(String desc) {
            this.desc = desc;
        }

        @Override
        public String toString() {
            return "OrderStep{" +
                    "orderId=" + orderId +
                    ", desc='" + desc + '\'' +
                    '}';
        }
    }
}
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
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.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.remoting.common.RemotingHelper;

import java.io.UnsupportedEncodingException;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class OrderedConsumer {
    public static void main(String[] args) throws Exception {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("ordered_group_name");
        consumer.setNamesrvAddr("192.168.116.100:9876");
        // 设置消费位置
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        consumer.subscribe("TopicTest", "*");
        consumer.registerMessageListener(new MessageListenerOrderly() {

            @Override
            public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
                context.setAutoCommit(true);
                Random random = new Random();
                for (MessageExt msg : msgs) {
                    try {
                        // 可以看到每个queue有唯一的consume来消费, 订单对每个queue(分区)有序
                        System.out.println("consumeThread=" + Thread.currentThread().getName() + ", queueId=" + msg.getQueueId() + ", content:" + new String(msg.getBody(), RemotingHelper.DEFAULT_CHARSET));
                    } catch (UnsupportedEncodingException e) {
                        e.printStackTrace();
                    }
                }

                /*try {
                    //模拟业务逻辑处理中...
                    TimeUnit.SECONDS.sleep(random.nextInt(10));
                } catch (Exception e) {
                    e.printStackTrace();
                }*/
                return ConsumeOrderlyStatus.SUCCESS;

            }
        });
        consumer.start();
        System.out.printf("Consumer Started.%n");
    }
}

延时消息

延时消息:Producer 将消息发送到消息队列 RocketMQ 服务端,但并不期望这条消息立马投递,而是延迟一定时间后才投递到 Consumer 进行消费,该消息即延时消息。通过设置下面的属性实现

// 延时消费
message.setDelayTimeLevel(6);

适用场景

消息生产和消费有时间窗口要求:比如在电商交易中超时未支付关闭订单的场景,在订单创建时会发送一条 延时消息。这条消息将会在 30 分钟以后投递给消费者,消费者收到此消息后需要判断对应的订单是否已完成支付。 如支付未完成,则关闭订单。如已完成支付则忽略。

使用方式

Apache RocketMQ 目前只支持固定精度的定时消息,因为如果要支持任意的时间精度,在 Broker  层面,必须要做消息排序,如果再涉及到持久化, 那么消息排序要不可避免的产生巨大性能开销。(阿里云 RocketMQ 提供了任意时刻的定时消息功能,Apache RocketMQ 并没有阿里并没有开源)

发送延时消息时需要设定一个延时时间长度,消息将从当前发送时间点开始延迟固定时间之后才开始投递。延迟消息是根据延迟队列的 level 来的,延迟队列默认是msg.setDelayTimeLevel(5) 代表延迟一分钟。

延时消息级别分为以下18个等级:"1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h",(秒(s)、分(m)、小时(h)),level 为 1,表示延迟 1 秒后消费,level 为 5 表示延迟 1 分钟后消费,level 为 18 表示延迟 2 个小时消费。生产消息跟普通的生产消息类似,只需要在消息上设置延迟队列的 level 即可。消费消息跟普通的消费消息一致。

消息过滤

RocketMQ 分布式消息队列的消息过滤方式有别于其它 MQ 中间件,可以实现服务端的过滤。

表达式过滤

主要支持如下 2 种的过滤方式

  1. Tag 过滤方式:Consumer 端在订阅消息时除了指定 Topic 还可以指定 TAG,如果一个消息有多个 TAG,可以用||分隔。其中,Consumer 端会将这个订阅请求构建成一个 SubscriptionData,发送一个 Pull 消息的请求给 Broker 端。Broker 端从 RocketMQ 的文件存储层—Store 读取数据之前,会用这些数据先构建一个 MessageFilter,然后传给 Store。Store 从 ConsumeQueue 读取到一条记录后,会用它记录的消息 tag hash 值去做过滤,由于在服务端只是根据 hashcode 进行判断,无法精确对 tag 原始字符串进行过滤,故在消息消费端拉取到消息后,还需要对消息的原始 tag 字符串进行比对,如果不同,则丢弃该消息,不进行消息消费。
  2. SQL92 的过滤方式:这种方式的大致做法和上面的 Tag 过滤方式一样,只是具体过滤过程不太一样,真正的 SQL expression 的构建和执行由rocketmq-filter 模块负责的。具体使用见 http://rocketmq.apache.org/docs/filter-by-sql92-example/

注意如果开启 SQL 过滤的话,Broker 需要开启参数 enablePropertyFilter=true,然后服务器重启生效。

类过滤

新版本(>=4.3.0)已经不支持(代码中 FilterServerConsumer 新版本已经不支持了)

消息发送

消息生产者流程

生产者的流程主要讲述 DefaultMQProducer 类的具体实现。

消息发送的主要流程:验证消息、查找路由、消息发送(包含异常机制)

验证消息:主要是要求主题名称、消息体不能为空、消息长度不能等于 0,且不能超过消息的最大的长度 4M(生产者对象中配置maxMessageSize=1024*1024*4)

查找路由:客户端(生产者)会缓存 topic 路由信息(如果是第一次发送消息,本地没有缓存,查询 NameServer 尝试获取),路由信息主要包含了消息队列(queue 相关信息),

消息发送:选择消息队列,发送消息,发送成功则返回。选择消息队列两种方式(一般有两种,这里不做详细讲解,后续做详细讲解)

批量消息发送

注意单批次不能超过消息的最大的长度 4M(生产者对象中配置 maxMessageSize=1024*1024*4)

import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;

import java.util.ArrayList;
import java.util.List;

public class BatchProducer {

    public static void main(String[] args) throws Exception {
        /**
         * rocketMq 支持消息批量发送
         * 同一批次的消息应具有:相同的主题,相同的 waitStoreMsgOK,并且不支持定时任务。
         * <strong> 同一批次消息建议大小不超过~1M </strong>,消息最大不能超过4M,需要对msg进行拆分
         */
        DefaultMQProducer producer = new DefaultMQProducer("batch_group");
        producer.setNamesrvAddr("192.168.116.100:9876");
        producer.start();

        String topic = "BatchTest";
        List<Message> messages = new ArrayList<>();
        messages.add(new Message(topic, "TagA", "OrderID001", "Hello world 0".getBytes()));
        messages.add(new Message(topic, "TagA", "OrderID002", "Hello world 1".getBytes()));
        messages.add(new Message(topic, "TagA", "OrderID003", "Hello world 2".getBytes()));
        ListSplitter splitter = new ListSplitter(messages);

        /**
         * 对批量消息进行拆分
         */
        while (splitter.hasNext()) {
            try {
                List<Message>  listItem = splitter.next();
                producer.send(listItem);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        producer.shutdown();
    }
}
import org.apache.rocketmq.common.message.Message;

import java.util.Iterator;
import java.util.List;
import java.util.Map;

public class ListSplitter implements Iterator<List<Message>> {

    private final int SIZE_LIMIT = 1000 * 1000 * 1;//1MB
    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; //for log overhead
            if (tmpSize > SIZE_LIMIT) {

                if (nextIndex - currIndex == 0) {
                    nextIndex++;
                }
                break;
            }
            if (tmpSize + totalSize > SIZE_LIMIT) {
                break;
            } else {
                totalSize += tmpSize;
            }

        }
        List<Message> subList = messages.subList(currIndex, nextIndex);
        currIndex = nextIndex;
        return subList;
    }
}

消息重试机制

// 设置发送失败重试机制次数
producer.setRetryTimesWhenSendAsyncFailed(5);

通过代码和源码,一般发送并忘记没有重试。注意重试的原则,一般会采用规避原则(规避原则就是上一次消息发送过程中发现错误,在某一段时间内,消息生产者不会选择该 Broker 上的消息队列,这样可以提高发送消息的成功率)

消息队列负载机制

规避原则

实际的生产过程中,我们的 RocketMQ 有几台服务器构成的集群。其中有可能是一个主题 TopicA 中的 4 个队列分散在 Broker1、Broker2、Broker3 服务器上。

如果某个时刻 Nameserver 通过心跳(默认心跳检测间隔是 10S)检测到 Broker2 挂了,但是生产者不知道,因为生产者客户端是每隔 30S 更新一次路由表,那么发送到 queue2 的消息就会失败,RocketMQ 发现这次消息发送失败后,就会将 Broker2 排除在消息的选择范围,下次再次发送消息时就不会发送到 Broker2,这样做的目的就是为了提高发送消息的成功率。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值