RocketMQ 学习笔记

文章目录

MQ概述

传统项目架构下的问题:请求方向响应方发送请求,如果中间链路出现问题,那么信息无法送到,请求方也没法发送了。

MQ全称 Message Queue,是在消息的传输过程中保存消息的容器。多用于分布式系统之间进行通信。

中间放一个MQ,生产者把消息给MQ,MQ把消息往下分发给消费者。

优势

应用解耦

消费方存活与否不影响生产方

比如如下情况

当用户买商品时,库存系统、支付系统、物流系统都会接收到这个消息产生对应动作,各个系统干各自的任务,完成业务解耦。

这时候,比如我想收到订单信息后再进行一个大数据统计,这个时候就可以单独做一个模块就可以,然后让MQ把消息发送给大数据系统就可以了。这个时候订单系统、其他系统都没有发生变化。

异步提速

生产方发完消息,可以继续下一步业务逻辑

比如这里,原本如果按正常传统逻辑,需要先把数据存入数据库50ms,通知库存系统修改库存200ms,通知支付系统调用支付200ms,通知物流系统200ms,加起来有650ms,但是如果有MQ,我只需要先向数据库发送消息50ms,向MQ发送消息50ms,之后MQ将三分消息分别下发,这三个系统可以并行进行了。

削峰填谷

生产方发送完消息,可以继续下一步业务逻辑。

比如618节日,下单量剧增,达到10w/s,但是物流系统或者mysql系统他能处理的数据量只有1w/s。这个时候可以把下单信息全部存入MQ,物流系统慢慢的处理这些信息。而订单系统可以继续接收订单信息。

劣势

介绍

MQ产品

  • RabbitMQ 处理速度快,但是是 erlang 语言,需要配置环境
  • RocketMQ 吞吐量高

总体来说RabbitMQ用于中小型项目,RocketMQ用于大型项目,kafka 用于大数据。

RocketMQ

对于MQ的所有劣势RocketMQ都有它的解决方案

基础概念

核心组件

部署了MQ的都是一个Broker

  • NameServer Cluster(命名服务器集群)

    主要负责两件事:一是管理Broker(接收Broker的注册信息);二是提供路由信息(告诉Producer和Consumer应该去哪个Broker接收或发送目标消息)

  • Broker Cluster(消息服务器集群)

    负责实际数据的存储和转发,消息持久化,过滤消息(根据消费者的订阅规则对消息进行过滤转发),高可用(通常采用主从架构部署,保证一个倒下之后,其他服务仍然可用)

  • Producer Cluster(消息生产者集群)

    它会先向NameServer发送请求,然后直接与目标Broker建立连接发送业务数据

  • Consumer Cluster(消息消费者集群)

    消息的接收方和处理方

所有组件都向命名服务器集群发送心跳包来查看是否宕机。

核心工作流程

  1. Broker 注册:Broker 在启动时,会向集群中所有的 NameServer 节点发起 "注册",汇报自己的 IP 地址以及自己负责存储哪些业务主题(Topic)的数据。之后,Broker 会定时向 NameServer 发送心跳包以维持存活状态。

  2. 获取路由信息 (指向 NameServer 的箭头):

    • Producer 在 "发送消息" 前,会向 NameServer "获取 broker 信息",以此知道自己的消息该发给哪个具体的 Broker 节点。

    • Consumer 同理,在 "接收消息" 前,也会向 NameServer 查询,知道自己该去哪里拿数据。

  3. 消息发送:Producer 拿到路由信息后,直接通过网络向指定的 Broker "发送消息"。Broker 收到并持久化后,会向 Producer "返回接收结果"(比如发送成功或失败的 ACK 确认)。

  4. 消息消费 (Consumer 与 Broker 的交互):图中展示了两种常见的消费模式:

    • 拉模式 (Pull): 消费者主动向 Broker "拉取消息",Broker 随后 "返回消息"。这种模式消费者可以自主控制消费节奏。(比如每1s去拉取一下,看看有没有消息)

    • 推模式 (Push):Broker 发现有新消息后,主动向消费者 "推送消息"。图中的 "监听器" (Listener) 就是用来在客户端被动触发执行业务逻辑的代码组件。

发送消息的格式是

redis 复制代码
Message 消息
Topic   主题 比如 order
Tag     标题 细分主题, 比如普通用户下单,VIP下单,不一样的Tag

不同的Broker可能存放不同主题的消息,不同的Broker也可能存放同一种类型的消息,比如图中两个Broker都有 Topic A 主题,那么生产者发送消息的时候给谁呢?由命名服务器集群来决定,看怎么配置的,比如看谁比较空闲,就给谁。所以生产者发送消息之前先问问命名服务器集群发给谁,然后再去发送。

安装

下载配置

前往官网下载包并解压缩

配置系统环境变量,放入 ROCKETMQ_HOME

启动

CMD命令框执行进入MQ文件夹\bin下执行命令启动 nameserver

cmd 复制代码
start mqnamesrv.cmd

出现 The Name Server boot success 代表启动成功(不要退出)

启动 brokder

-n 127.0.0.1:9876 代表 nameserver 在端口 9876 上

cmd 复制代码
start mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true

测试

需要同时启动 nameserver、broker。

测试发送消息

之后我们做个测试,也需要配置一个环境变量

然后我们把 nameserver 黑窗口关闭然后重启一下

之后 bin 目录下执行命令

cmd 复制代码
tools.cmd org.apache.rocketmq.example.quickstart.Producer

如果报错,检查环境变量对不对,重启 nameserver、broker

结果如上,向 nameserver 发送1000条信息,查看结果

cmd 复制代码
SendResult [sendStatus=SEND_OK, msgId=0A040466067C339097526508425503E4, offsetMsgId=AC13400100002A9F000000000003AD1A, messageQueue=MessageQueue [topic=TopicTest, brokerName=Latte, queueId=0], queueOffset=249, recallHandle=null]

topic TopicTest

测试接收消息

cmd 复制代码
tools.cmd org.apache.rocketmq.example.quickstart.Consumer
cmd 复制代码
ConsumeMessageThread_please_rename_unique_group_name_4_20 Receive New Messages: [MessageExt [brokerName=Latte, queueId=3, storeSize=242, queueOffset=235, sysFlag=0, bornTimestamp=1773989440059, bornHost=/172.19.64.1:58048, storeTimestamp=1773989440059, storeHost=/172.19.64.1:10911, msgId=AC13400100002A9F0000000000037B00, commitLogOffset=228096, bodyCRC=1936861256, reconsumeTimes=0, preparedTransactionOffset=0, toString()=Message{topic='TopicTest', flag=0, properties={CONSUME_START_TIME=1773989716440, MSG_REGION=DefaultRegion, UNIQ_KEY=0A040466067C339097526508423B03AF, CLUSTER=DefaultCluster, MIN_OFFSET=0, TAGS=TagA, WAIT=true, TRACE_ON=true, MAX_OFFSET=250}, body=[72, 101, 108, 108, 111, 32, 82, 111, 99, 107, 101, 116, 77, 81, 32, 57, 52, 51], transactionId='null'}]]

第一行 Receive New Messages 接收到了消息,里面有 brokerName,body 就是消息。最后这个终端是没有停的,因为此时消费者和MQ建立了一个长连接,等待接收消息。

控制台

如果想有一个可视化界面,我们可以去下面这个链接下载源码

https://github.com/apache/rocketmq-dashboard?tab=readme-ov-file

然后,我们把它打成 jar 包,执行命令如下,在源代码目录

cmd 复制代码
mvn clean package "-Dmaven.test.skip=true"

打包成功后,在 cmd 窗口启动

cmd 复制代码
java -jar rocketmq-dashboard-2.1.0.jar

看是哪个端口,这里是 localhost:8082

简单入门

先创建一个空的 Maven 项目,导入依赖

xml 复制代码
<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-client</artifactId>
    <version>5.4.0</version>
</dependency>

简单生产者

java 复制代码
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;

public class Producer {

    public static void main(String[] args) throws Exception {
        // 1. 谁来发
        DefaultMQProducer producer = new DefaultMQProducer("group1"); // 默认的生产者
        // 2. 发给谁
        producer.setNamesrvAddr("localhost:9876");
        producer.start(); // 启动
        // 3. 怎么发

        // 4. 发什么
        String msg = "hello world";
        Message message = new Message("topic1", "tag1", msg.getBytes());
        SendResult sendResult = producer.send(message);
        // 5. 发的结果是什么
        System.out.println(sendResult);
        // 6. 打扫战场
        producer.shutdown(); // 关闭producer
    }
}

结果发送成功。

注意,启动brokder的时候,有个配置参数需要开启 autoCreateTopicEnable=true,因为 topic 如果不存在,默认是不创建的,发送会失败。

并且默认要求磁盘剩余空间要在10%以上才能正常运行。

简单消费者

java 复制代码
public class Consumer {

    public static void main(String[] args) throws Exception {
        // 1. 谁来收
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("group1");
        // 2. 从哪里接收
        consumer.setNamesrvAddr("localhost:9876");
        // 3. 监听哪个消息队列
        consumer.subscribe("topic1", "*");
        // 4. 处理业务流程
        consumer.registerMessageListener(new MessageListenerConcurrently() {

            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                // 写业务逻辑
                for (MessageExt msg : list) {
                    System.out.println(new String(msg.getBody()));
                }

                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; // 枚举型常量
            }
        });
        consumer.start();

        System.out.println("消费者启动了");
        // 消费者不要关, 因为要接收消息!!!
    }
}

因为前面我把 Producer 运行了三次,所以这里输出了三个。

消费者窗口不用关,等生产者再发送的时候,消费者可以收到。

多消费者模型

我们创建新的 Producer 和新的 Consumer

java 复制代码
public class Producer {

    public static void main(String[] args) throws Exception {
        DefaultMQProducer producer = new DefaultMQProducer("group1"); // 默认的生产者
        producer.setNamesrvAddr("localhost:9876");
        producer.start(); // 启动
        for (int i = 0; i < 10; i++) { // 发送10条消息
            String msg = "hello world " + i;
            Message message = new Message("topic2", "tag1", msg.getBytes());
            SendResult sendResult = producer.send(message);
            System.out.println(sendResult);
        }
        producer.shutdown(); // 关闭producer
    }
}
java 复制代码
public class Consumer {

    public static void main(String[] args) throws Exception {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("group1");
        consumer.setNamesrvAddr("localhost:9876");
        consumer.subscribe("topic2", "*"); // 更改为 topic2
        consumer.registerMessageListener(new MessageListenerConcurrently() {

            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                // 写业务逻辑
                for (MessageExt msg : list) {
                    System.out.println(new String(msg.getBody()));
                    System.out.println("=========================");
                }

                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        consumer.start();

        System.out.println("消费者启动了");
    }
}

这个时候生产者是生产 10 个消息的,如果有多个消费者会怎么接收这个消息呢?

我们开启两个消费者

默认只能开启一个实例,需要配置如下

生产者

消息发送成功

两个消费者各接收了五个。

为什么会一人一半?(负载均衡机制)

RocketMQ 会把 Topic 里的消息队列平均分配给同一个组内的所有消费者。(这两个消费者都是 group1

  • 队列分配:默认情况下,一个Topic有4个队列(Queue0,1,2,3)
  • 分配逻辑:当启动两个消费者A和B的时候,负载均衡组件会把队列0和1给A,2和3给B。
  • Producer会轮询着把10条消息写入4个队列,A和B就各自守着这几个队列,他们就各自拿到其中一部分

之后我们尝试两个消费者,一个 group1,一个 group2 试试

java 复制代码
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("group2"); // 一个是group1启动,一个是group2启动

然后Producer、Consumer主题我们都换为 Topic3。

十条消息发送成功

Consumer1和Consumer2都接收到了10条消息。

集群模式下,同个主题对于不同组是广播模式,每个组都会消费同个消息,会重复消费;对于同个组是集群模式,每个消费者平摊消息,不会重复消费。 但是如果有三个 Conusumer,默认四个队列,那就有两个 Consumer 会各监听一个,有一个 Consumer 监听两个队列。

当有两个Group,第一个Group有两个Consumer时,RocketMq往Topic1发送10条消息,结果如图。

但是如果你想 Group1 里面所有的 Consumer 都收到这10条消息怎么办?设置消息的消费模式

java 复制代码
// 设置消费模式
consumer.setMessageModel(MessageModel.BROADCASTING); // 广播模式

// 默认有两种
public enum MessageModel {
    BROADCASTING("BROADCASTING"), // 广播
    CLUSTERING("CLUSTERING"); // 集群
}

消息

消息类型

同步消息

即时性较强,重要的消息,且必须有回执的消息,例如短信,通知(转账成功)

异步消息

即时性较弱,但需要回执的消息,例如订单中的某些消息。(生产者发送完消息就做自己的事就可以了,消费者得到结果慢慢进行回执就可以)

Producer 代码里面,原来我们的写法其实就是同步消息

java 复制代码
// 4. 发什么
for (int i = 0; i < 10; i++) {
    String msg = "hello world " + i;
    Message message = new Message("topic3", "tag1", msg.getBytes());
    SendResult sendResult = producer.send(message);
    // 5. 发的结果是什么
    System.out.println(sendResult);
}
// 6. 打扫战场
producer.shutdown(); // 关闭producer

producer.send(message); 执行完之后需要等待返回结果,然后输出返回结果。

我们改成异步

java 复制代码
for (int i = 0; i < 10; i++) {
    String msg = "hello world yibu " + i;
    Message message = new Message("topic4", "tag1", msg.getBytes());
    producer.send(message, new SendCallback() {

        @Override
        public void onSuccess(SendResult sendResult) {
            System.out.println(sendResult);
        }

        @Override
        public void onException(Throwable throwable) {
            System.out.println(throwable);
        }
    });
    System.out.println("异步消息发送成功");
}

// 6. 打扫战场
producer.shutdown(); // 关闭producer

但是执行完之后报错了

打印了消息发送成功,返回的 exit code 也是0,说明执行没有问题,是回调函数打印的 Exception

原因在于因为是异步执行,在 for 循环里面执行完 producer.send 后是继续向下执行的,所以会打印发送成功的消息,10次循环结束后,会再向下执行关闭 producer,那么无论是 onSuccess 还是 onException,其实结果都没有人接收了,所以需要把 producer.shutdown(); 这行代码删除。

单向消息

不需要有回执的消息,例如日志类消息

java 复制代码
for (int i = 0; i < 10; i++) {
    String msg = "hello world " + i;
    Message message = new Message("topic4", "tag1", msg.getBytes());
    producer.sendOneway(message);
}

延时消息

消息发送时不直接发送到消息服务器,而是根据设定的等待时间到达,起到延时到达的缓冲作用。(比如七天不登陆游戏就给你发送消息提示你回归领礼包)

设置方式

java 复制代码
message.setDelayTimeLevel(1); // 在消息发送前设置延时时间
SendResult sendResult = producer.send(message);

这是 RocketMQ 4.X 支持的方式,只支持设置等级,没办法灵活设置时间,一共有如下等级,下标从 1 开始,如果设置 level 等级为 0,代表是非时延消息,即普通消息,立即发出。

1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h

java 复制代码
message.setDelayTimeMs(15000L); // 15秒
message.setDelayTimeSec(15L);

从 RocketMQ 5.X 开始,可以灵活设置时间了。如上

批量消息

一次发送多条消息,节约网络开销

java 复制代码
DefaultMQProducer producer = new DefaultMQProducer("group1");
producer.setNamesrvAddr("localhost:9876");
producer.start();

List<Message> messages = new ArrayList<>(); // 批量消息
String msg1 = "hello world piliang 1";
messages.add(new Message("topic5", "tag1", msg1.getBytes()));
String msg2 = "hello world piliang 2";
messages.add(new Message("topic5", "tag1", msg2.getBytes()));
String msg3 = "hello world piliang 3";
messages.add(new Message("topic5", "tag1", msg3.getBytes()));

SendResult sendResult = producer.send(messages);
System.out.println(sendResult);
producer.shutdown(); // 关闭producer

注意点:

  • 相同的 waitStoreMsgOK 就是说相同的消息类型

消息过滤

对于同一个主题,不同的 tag,Consumer 在消费的时候可以进行过滤

java 复制代码
// 生产者
public class Producer {

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

        String msg = "hello world";
        Message message = new Message("topic8", "tag1", msg.getBytes());
        SendResult sendResult = producer.send(message);
        System.out.println(sendResult);

        producer.shutdown();
    }
}
java 复制代码
// 消费者
public class Consumer {

    public static void main(String[] args) throws Exception {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("group1");
        consumer.setNamesrvAddr("localhost:9876");
        consumer.subscribe("topic8", "tag1"); // 只接受哪个 tag 的消息
        
        consumer.registerMessageListener(new MessageListenerConcurrently() {

            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                for (MessageExt msg : list) {
                    System.out.println(new String(msg.getBody()));
                }

                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        consumer.start();

        System.out.println("消费者启动了");
    }
}

比如这里 consumer.subscribe("topic8", "tag1"); 代表只接收这个主题下的这个 tag 的消息。想接收多个 tag 可以写表达式

java 复制代码
consumer.subscribe("topic8", "*"); // 对该 topic 下的任意消息都感兴趣,不进行过滤
consumer.subscribe("topic8", "tag1 || tag2"); // 对 tag1 和 tag2 消息感兴趣

语法过滤

同时,还支持高级过滤 SQL92表达式 过滤

之前说消息长度的计算包括消息追加的属性,比如如下:

java 复制代码
Message message = new Message("topic8", "tag1", msg.getBytes());
message.putUserProperty("age", "18");
message.putUserProperty("name", "ergou"); // 放入 name, age 属性
SendResult sendResult = producer.send(message);

之后呢,在 Consumer 上我就可以根据这些属性进行过滤,使用 MessageSelector.bySql()。原来默认是 byTag()

java 复制代码
consumer.subscribe("topic8", MessageSelector.bySql("age > 16 and name='ergou'"));
// 如果想在这个里面加 Tag, 可以如下写法, 使用 TAGS 关键字
consumer.subscribe("topic8", MessageSelector.bySql("age > 16 and TAGS = 'tag1'"));
consumer.subscribe("topic8", MessageSelector.bySql("age > 16 and TAGS in ('tag1', 'tag2')"));

但是 RocketMQ 默认这种根据属性过滤是关闭的,我们可以同个 Dashboard 查看

可以看到是 false,默认是关闭的,需要我们进行配置。

我们需要打开安装目录下 conf 文件夹下的 broker.conf,在末尾加入

properties 复制代码
enablePropertyFilter=true

保存之后重启 nameserver,broker。到这里其实就能用了,但是 Windows 还是有问题(其他系统没问题),这里还需要用别的命令执行一下

cmd 复制代码
mqadmin.cmd updateBrokerConfig -blocalhost:10911 -kenablePropertyFilter -vtrue

执行成功后,可以在 dashboard 查看是否开启成功

开启成功后就可以测试一下了,发送两条消息,一条是 age=19 的,一条是 age=14

然后看一下消费者只接收到了一条消息,说明SQL过滤成功了。

弹幕有人说用这个过滤得看场景,因为这里 Consumer 确实过滤了,但是消息还是 age=14 的消息还在MQ里面,是堆积的,所以除非你所有消息都能被消费掉,不然其实你可以用 if 去决定要不要发送

SpringBoot 整合

你想前面每次执行是不是都是 new DefaultMQProducer,都会创建一个生产者,但是实际上是不是每次想发消息,就 producer.send(message) 就可以了,不需要每次都创建(Bean容器)

这里直接创建一个 SpringBoot 项目,只勾选 SpringWeb 模块就可以。然后导入 rocketmq 依赖

xml 复制代码
<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
    <version>2.3.5</version>
</dependency>

Producer&Consumer

我们在 appliation.yaml 文件里面配置一些属性,这个 group 注意是生产着的 group。

yaml 复制代码
rocketmq:
  name-server: localhost:9876 # 命名服务器地址
  producer:
    group: group1 # Producer 生产者组

之后,我们先创建生产者,在 Controller,我们实现访问这个地址就发送一个消息。我们不再发送一个 String 字符串,而是发送一个对象

java 复制代码
public class User implements Serializable { // 注意实现了序列化,因为要网络传输

    String username;
    int age;

    // getter、setter、构造函数、toString
}

Serializable 是为了保证 user 对象能够顺利通过网络发送到 RocketMQ Broker 并存储到磁盘。

Controller 如下,模板类 RocketMQTemplate,和 jdbcTemplate,redisTemplate命名都类似。

java 复制代码
@RestController
@RequestMapping("demo")
public class SendController {
    @Autowired
    RocketMQTemplate rocketMQTemplate; // 模板类:和第三方建立连接、断开连接

    @GetMapping("/send")
    public String send() {
        // 发送逻辑
        User user = new User("ergou", 23);
        rocketMQTemplate.convertAndSend("topic1", user); // convert 将消息转为底层的字节数组
        return "success";
    }
}

方法使用 converAndSend

然后是消费者,通常消费者和生产者不在一个服务器上。这里我们定义在 service 层

java 复制代码
@Service
@RocketMQMessageListener(topic = "topic1", consumerGroup = "group1")
public class DemoConsumer implements RocketMQListener<User> {

    // 业务逻辑
    @Override
    public void onMessage(User user) {
        System.out.println(user);
    }
}

实现 RocketMQListener,里面接收实体类泛型,在注解中配置要监听的主题、过滤的消息、所在组。

其余设置

对于不同类型的消息发送如下

java 复制代码
// 同步消息
SendResult syncSend = rocketMQTemplate.syncSend("topic1", user);

// 异步消息
rocketMQTemplate.asyncSend("topic1", user, new SendCallback() {

    @Override
    public void onSuccess(SendResult sendResult) {
        System.out.println(sendResult);
    }

    @Override
    public void onException(Throwable throwable) {
        System.out.println(throwable);
    }
}, 1000);

// 单向消息
rocketMQTemplate.sendOneWay("topic1", user);

// 延时消息, destination, Message<>, timeout, delayLevel
rocketMQTemplate.syncSend("topic1", MessageBuilder.withPayload("hello").build(), 2000, 2);

// 批量消息
List<Object> msgList = new ArrayList<>();
String msg1 = "hello world";
Message message1 = new Message("topic1", msg1.getBytes());
String msg2 = "hello world";
Message message2 = new Message("topic1", msg2.getBytes());
String msg3 = "hello world";
Message message3 = new Message("topic1", msg3.getBytes());
msgList.add(message1);
msgList.add(message2);
msgList.add(message3);
rocketMQTemplate.syncSend("topic1", msgList, 1000);

对于消息过滤,在消费者上使用注解

java 复制代码
@RocketMQMessageListener(topic = "topic1", consumerGroup = "group1", selectorExpression = "tag1")
@RocketMQMessageListener(topic = "topic1", consumerGroup = "group1", selectorType = SelectorType.SQL92, selectorExpression = "age > 18")
public class DemoConsumer implements RocketMQListener<User> {

两种方式,第一个注解是 tag 形式的过滤,第二种是 SQL 形式的过滤,不过需要选择类型,因为默认是TAG。

如果想设置接收消息的类型是广播模式

java 复制代码
@RocketMQMessageListener(topic = "topic1", consumerGroup = "group1", 
                         selectorType = SelectorType.SQL92, 
                         selectorExpression = "age > 18",
                         messageModel = MessageModel.BROADCASTING)

在注解上多加一个参数即可。

消息的特殊处理

消息顺序

我们分析一下,其实前面能察觉到,前面发送 1~10,但是读取的时候顺序完全是乱的。这是因为一个Topic里面是有多个队列的(前面说过,默认四个),这里我们演示三个。三个订单,并发进行,订单1消息依次进入 queue0、1、2,然后订单2消息依次进入queue1、2,然后订单3消息依次进入queue0、1、2。但是消费者是线程,每个消费者在集群模式下是根据负载均衡均匀分配队列的,如果某个消费者负责读取 queue0,而且它处理速度快,那就会出现 queue0 很快被执行完,其他两个队列还没动的情况。这个时候消息处理顺序是错误的!

我们想要的效果如下

同一个订单消息进入同一个队列,这样即使某个消费者处理速度快也是按顺序执行的。也就是消息队列内有序,队列外无序

如果我们能选择让同一个订单的消息进入同一个队列是不是就可以了?

下面我们创建一个订单类

java 复制代码
public class OrderStep implements Serializable { // 注意序列化
    private long orderId;
    private String desc;
   
}

然后数据上我们模拟高并发下的订单消息

java 复制代码
List<OrderStep> orderList = new ArrayList<>();
OrderStep orderDemo = new OrderStep();
orderDemo.setOrderId(1L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(2L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(1L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(3L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(2L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(2L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(1L);
orderDemo.setDesc("推送");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(3L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(1L);
orderDemo.setDesc("完成");
orderList.add(orderDemo); // 它给的数据,也挺乱说白了

我们发送消息的时候如下(未使用 SpringBoot 哈)

java 复制代码
for(OrderStep orderStep : orderList){
    Message msg = new Message("topic1", orderStep.toString().getBytes());
    // 发送时指定对应的消息队列选择器
    producer.send(msg, new MessageQueueSelector() {
        // 设置当前消息发送时使用哪一个消息队列
        @Override
        public MessageQueue select(List<MessageQueue> list, Message message, Object o) {
            // 根据发送消息的Id选择不同的消息队列
            long orderId = orderStep.getOrderId();
            long mqIndex = orderId % list.size();
            return list.get((int)mqIndex);
        }
    }, null);
}

这里消费者也进行修改,用 MessageListenerOrderly

java 复制代码
// 消费者 起一个顺序监听,一个线程只监听一个 Queue
consumer.registerMessageListener(new MessageListenerOrderly() {

    @Override
    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext consumeOrderlyContext) {
        for (MessageExt msg : msgs) {
            System.out.println(msg);
            System.out.println(new String(msg.getBody()));
        }
        return ConsumeOrderlyStatus.SUCCESS;
    }
});

订单1,2,3都是按照顺序来做的,并且同一个订单进入的同一个队列 queueId

❓ 为什么消费者也要改?

MessageListenerConcurrently(并发模式) 改为 MessageListenerOrderly(顺序模式)

因为原来我们知道一个 Topic 默认是四个 Queue,消息的放入负载均衡会均匀放到四个队列,所以我们在生产者模块指定放到哪个队列。然后,我们不是有Group吗,假如一个Group里面有两个消费者(其实在实际场景中,两个消费者就是两个机器了),RocketMQ会把队列均分到两个消费者手里,一个消费者负责两个线程了,然后在消费者这里,会开启一个线程池(默认20个线程),这20个线程会进行无序拉取,比如两个队列Queue0、Queue1,只要是来消息了,这20个线程就去抢夺,谁拉都行,就可能出现比如订单任务(创建、付款、推送、完成),线程1抢了创建,但是这个完成慢,线程2抢了付款,这个完成快,导致先付款再创建订单了。

所以改为了顺序模式,20个线程变为值班模式,假如这个消费者只分到了两个Queue,那同一时刻最多只有2个线程在干活,剩下的18个都闲着,严格的1:1绑定,保证顺序。

事务消息

概念

为什么存在这个东西呢?你看前面的消息都是直接发送,但事实上,真正变动数据库操作之后,成功才去发送消息,不成功便不发送。所以下面图里面的本地事务代表操作数据库的事务。

  • 红色就是正常发送流程 (1-4)

    1. 发送 half 消息到 Brokder,这个消息消费者既看不见,也不能消费
    2. 返回状态OK,Broker告诉生产者,消息我收到并且存下了,你可以开始本地业务了
    3. 执行本地事务,生产者操作自己的数据库
    4. 提交或者回滚,如果本地数据库成功,那么就告诉Broker可以把消息放行了,消费者可以消费了。如果本地数据库失败,就告诉Broker,那个half消息可以删了,就当没发过
  • 蓝色是事务补偿过程 (5-7)

    假如生产者的机器突然断电,或者网络断了,Broker一直等不到确认指令,会触发这个机制

    1. Broker 发现这个 half 消息很久没收到回应,就会发送消息问生产者
    2. 生产者查看数据库事务状态
    3. 根据事务状态提交或者回滚

共有四种状态

  • 提交状态:允许进入队列,此消息与非事务消息没有区别
  • 回滚状态:不允许进入队列,此消息等同于未发送过
  • 中间状态:完成了half消息的发送,未对MQ进行二次状态确认

事务消息仅与生产者有关,与消费者无关。

❓ 为什么先发 half 消息?

因为如果先做数据库操作,万一数据库成功了,但之后发消息的时候网络没了,那消息永远没了,所以先发 half 消息确保 Broker 是能连通的。还能检测 Broker 权限、Topic是否存在、磁盘空间。如果 Broker 挂了,half 发不出去,生产者会直接报错,本地数据库事务根本不会开启。(你想如果你下单了,钱扣了,但是没有任何通知,界面也不显示,,,)

❓ half 消息存在哪里?

half 消息不会先存入 topic 的,而是存到一个系统主题 RMQ_SYS_TRANS_HALF_TOPIC(简写 HALF_TOPIC),等 Commit 之后,其实 Broker 是原封不动写一条消息到目标 Topic(其实是写新内容到磁盘末尾,然后目标 topic 的索引文件多了一个指向这个位置的指针,消费者就可以看到他们了),把原来的 half 消息标记为已处理。为什么这么做?因为在 RocketMQ 的世界里,磁盘只准追加、不准修改。

更深层次的理解,发送了 half 消息之后,这个消息被存到 CommitLog 里面,指针上被存储到 RMQ_SYS_TRANS_HALF_TOPIC 上,所以消费者盯着 topic1 是看不到的,当 Commit 指令下达的时候,会原封不动把这个 half 消息内容读出来在 CommitLog 的末尾新写一遍,然后 topic1 的索引文件新添加一个指向这里的指针,消费者就可以看到它了,之后把这个处理完的 half 消息在主题 RMQ_SYS_TRANS_OP_HALF_TOPIC (简写 OP_HALF_TOPIC)里面记录下来,这样回查线程就对比(OP_HALF_TOPIC 和 HALF_TOPIC),如果发现某个 half 消息在 OP_HALF_TOPIC 里面没有,说明它没有提交或者回滚,这就是回查的目标。
❓ 如果MySQL数据库发生死锁了,事务既没有提交也没有回滚怎么办?

RocketMQ 除了 COMMITROLLBACK 还设置了第三种状态 UNKNOWN,正常情况下,第一次回查是从收到 half 消息开始 1 分钟(默认配置)之后,如果死锁没解开(或者可能是逻辑非常耗时),会过一会来问。等到15次回查(也就是说15分钟,这是默认配置)都是 UNKONWN,Broker会强制回滚。这个时候就需要运维人员介入来处理了

并且回查的时候,是向与生产者同组的另一台生产者机器上去问,所以需要通过某种方式(比如查数据库订单表)确认那个事务的最终结果。

代码实现
java 复制代码
public class Producer {
    public static void main(String[] args) throws MQClientException {
		// 生产者不是普通的生产者了
        TransactionMQProducer producer = new TransactionMQProducer();

        producer.setNamesrvAddr("localhost:9876");
        // 设置事务监听
        producer.setTransactionListener(new TransactionListener() {
            // 正常事务过程
            @Override
            public LocalTransactionState executeLocalTransaction(Message message, Object o) {
                // 消息保存到 MySQL 数据库
                // sql insert
                System.out.println("执行了正常的事务过程");
                return LocalTransactionState.COMMIT_MESSAGE;
            }
            // 事务补偿过程
            @Override
            public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
                return null;
            }
        });

        producer.start();
        String msg = "hello world";
        Message message = new Message("topic1", "tag1", msg.getBytes());
        // 发送事务消息
        TransactionSendResult sendResult = producer.sendMessageInTransaction(message, null);
        System.out.println(sendResult);
        // 生产者不要关闭
    }
}

最后生产者不要关闭,因为可能还有回查之类的,不能关了

LocalTransactionState 是一个枚举类

java 复制代码
public enum LocalTransactionState {
    COMMIT_MESSAGE,
    ROLLBACK_MESSAGE,
    UNKNOW;

    private LocalTransactionState() {
    }
}

正常流程是

  1. producer.sendMessageInTransaction(message, null); 发送消息,这里先发 half,生产者收到 OK 之后,触发监听器

java 复制代码
producer.setTransactionListener(new TransactionListener() {
    // 正常事务过程
    @Override
    public LocalTransactionState executeLocalTransaction(Message message, Object o) {
        // 消息保存到 MySQL 数据库
        // sql insert
        System.out.println("执行了正常的事务过程");
        return LocalTransactionState.COMMIT_MESSAGE;
    }
    // 事务补偿过程
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
        return null;
    }
});

执行正常的事务流程,进行提交
3. 消息成功发送,消费者接收消息进行消费

如果数据库出现问题,发生回滚,那么返回状态修改为 ROLLBACK_MESSAGE 即可

java 复制代码
producer.setTransactionListener(new TransactionListener() {
    // 正常事务过程
    @Override
    public LocalTransactionState executeLocalTransaction(Message message, Object o) {
        // 消息保存到 MySQL 数据库
        // sql insert
        System.out.println("执行了正常的事务过程");
        return LocalTransactionState.ROLLBACK_MESSAGE;
    }
    // 事务补偿过程
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
        return null;
    }
});

这样消费者就接收不到消息了

发生回查

我们修改返回状态为 UNKNOWN,正常情况不会这么写哈,我们只是做测试演示,事务执行怎么会UNKNOWN,要么成功要么失败,如果写 UNKNOWN,那说明你代码有问题。

java 复制代码
producer.setTransactionListener(new TransactionListener() {
    // 正常事务过程
    @Override
    public LocalTransactionState executeLocalTransaction(Message message, Object o) {
        // 消息保存到 MySQL 数据库
        // sql insert 比如插入订单
        System.out.println("执行了正常的事务过程");
        return LocalTransactionState.UNKNOWN;
    }
    // 事务补偿过程
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
        System.out.println("执行事务补偿过程");
        // 再去数据库检查一下,不如这里从message里面获取订单编号,数据库查询一下订单看是不是插入了,如果插入了就说明提交成功了,没有查到说明失败
        if(sql select){
            
        }
        else{
            
        }
        return LocalTransactionState.COMMIT_MESSAGE;
    }
});

这里有问题,在回查的时候,我怎么判断数据库事务成功了还是失败了,是还在执行吗

其实成功了好判断,数据存在就代表成功了。关键不知道是失败了还是正在执行,因为这种情况下都查不到数据

方案一:引入"时间维度"(简单实用)

这是最常用的逻辑,核心逻辑是:"给子弹飞一会儿"。

checkLocalTransaction 中,通过判断消息的"出生时间"来决定返回什么:

  • 逻辑 1:count > 0 → \rightarrow → 肯定成功,直接 COMMIT
  • 逻辑 2:count == 0 且 消息出生 < 5分钟 → \rightarrow → 可能还在跑,返回 UNKNOWN
    • 作用:让 Broker 下一分钟再来问,循环往复。
  • 逻辑 3:count == 0 且 消息出生 > 5分钟 → \rightarrow → 确定失败了,返回 ROLLBACK
    • 依据:一个正常的数据库事务不可能跑 5 分钟还没出结果(除非数据库挂了)。

方案二:引入"事务记录表"(严谨进阶)

如果觉得靠时间猜不稳,那就用"状态表"。在业务代码里,开启一个数据库事务,同时操作两张表:

  1. 业务表(订单表)
  2. 事务记录表(记录消息 ID、状态、创建时间)
事务阶段 操作内容 记录表状态
execute 开始 insert order + insert trans_log PROCESSING (处理中)
execute 结束 update trans_log (事务提交时一起生效) SUCCESS (成功)

如果查事务记录表查不到,说明这个记录没有了,代表回滚了,返回 ROLLBACK

方案三:利用Broker的回查次数

利用 MessageExt 里一个隐藏属性,getReconsumeTimes() 查看这是第几次回查,我们手动规定,超过多少次进行 ROLLBACK,否则返回 UNKNOWN

集群搭建

集群分类:

单机

  • 一个 broker 提供服务(宕机后服务瘫痪)

集群

  • 多个 broker 提供服务(单机宕机后消息无法及时被消费)
  • 多个 master 多个 slave
    • master 到 slave 消息同步方式为同步(较异步方式性能略低,消息无延迟)
    • master 到 slave 消息同步方式为异步(较同步方式性能略高,数据略有延迟)

这里配置的是一主三从,启动 nameserver 之后,主机和从机都会向 nameserver 上去注册。

通过 brokerName 设置为同一组,通过 brokerId 设置主机,一组只能有一个主机,谁的 Id=0 谁就是主机。

工作流程

双主双从

系统配置

两个nameserver,分别在一台主机上,master1 和 slave1,主从分开,分别在一台机器上。

搭建是在虚拟机上,虚拟机下载就不说了,我用的是 CentOS7,需要配置网络,看末尾。构建两台虚拟机,并且设置静态IP地址分别是 192.168.200.129192.168.200.130

用 XShell 工具连接

环境配置

!两台机器都操作!

  1. 配置服务器环境:

    shell 复制代码
    vi /etc/hosts

    末尾添加

    shell 复制代码
    # nameserver
    192.168.200.129 rocketmq-nameserver1
    192.168.200.130 rocketmq-nameserver2
    # broker
    192.168.200.129 rocketmq-master1
    192.168.200.129 rocketmq-slave2
    192.168.200.130 rocketmq-master2
    192.168.200.130 rocketmq-slave1
  2. 配置完毕后重启网卡,应用配置

    shell 复制代码
    systemctl restart network
  3. 关闭防火墙或者开发指定端口对外提供服务

    shell 复制代码
    # 关闭防火墙 -- 生产环境可不会这样
    systemctl stop firewalld.service
    # 查看防火墙的状态
    firewall-cmd --state
    # 禁止firewall开机启动
    systemctl disable firewalld.service
  4. 配置服务器环境变量

    shell 复制代码
    vi /etc/profile

    末尾添加(按 shift+g 移动到末尾)

    shell 复制代码
    # set rocketmq
    ROCKETMQ_HOME=/rocketmq
    PATH=$PATH:$ROCKETMQ_HOME/bin
    export ROCKETMQ_HOME PATH
  5. 配置完毕后重启网卡,应用配置

    shell 复制代码
    source /etc/profile
  6. rocketmq 解压到 /rocketmq

    上传上去,并且重命名为 rocketmq。

  7. 创建服务器的数据存储目录

    shell 复制代码
    # master 数据存储目录
    mkdir /rocketmq/store
    mkdir /rocketmq/store/commitlog
    mkdir /rocketmq/store/consumequeue
    mkdir /rocketmq/store/index
    
    # slave 数据存储目录
    mkdir /rocketmq/store-slave
    mkdir /rocketmq/store-slave/commitlog
    mkdir /rocketmq/store-slave/consumequeue
    mkdir /rocketmq/store-slave/index

    注意 master 与 slave 如果在同一个虚拟机中部署,需要将存储目录分开

想配置两主两从以及nameserver,肯定需要配置文件,在 /rocketmq/conf 目录下的 2m-2s-sync 里面可以去配置

这个文件夹里面有四个文件

分别代表 a主、a从、b主、b从。

那么 node1 主机要设置 a主、b从,那 a从、b主文件就可以删除了。node2 主机就相反删除另外两个。

node1如下

node2如下

node1 的 broker-a.properties 内容(原来里面的内容全部删除就可以,vi 命令里面 :%d 删除全部内容)

properties 复制代码
#所属集群名字
brokerClusterName=rocketmq-cluster
#broker名字,注意此处不同的配置文件填写的不一样
brokerName=broker-a
#0 表示 Master, >0 表示 Slave
brokerId=0
#nameserver地址,分号分割
namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876
#在发送消息时,自动创建服务器不存在的topic,默认创建的队列数
defaultTopicQueueNums=4
#是否允许 Broker 自动创建Topic,建议线下开启,线上关闭
autoCreateTopicEnable=true
#是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭
autoCreateSubscriptionGroup=true
#Broker 对外服务的监听端口
listenPort=10911
#删除文件时间点,默认凌晨 4点
deleteWhen=04
#文件保留时间,默认 48 小时
fileReservedTime=120
#commitLog每个文件的大小默认1G
mapedFileSizeCommitLog=1073741824
#ConsumeQueue每个文件默认存30W条,根据业务情况调整
mapedFileSizeConsumeQueue=300000
#destroyMapedFileIntervalForcibly=120000
#redeleteHangedFileInterval=120000
#检测物理文件磁盘空间, 占用超过88%磁盘就存储报警了
diskMaxUsedSpaceRatio=88
#存储路径
storePathRootDir=/rocketmq/store
#commitLog 存储路径
storePathCommitLog=/rocketmq/store/commitlog
#消费队列存储路径
storePathConsumeQueue=/rocketmq/store/consumequeue
#消息索引存储路径
storePathIndex=/rocketmq/store/index
#checkpoint 文件存储路径
storeCheckpoint=/rocketmq/store/checkpoint
#abort 文件存储路径
abortFile=/rocketmq/store/abort
#限制的消息大小
maxMessageSize=65536
#flushCommitLogLeastPages=4
#flushConsumeQueueLeastPages=2
#flushCommitLogThoroughInterval=10000
#flushConsumeQueueThoroughInterval=60000
#Broker 的角色
#- ASYNC_MASTER 异步复制Master
#- SYNC_MASTER 同步双写Master
#- SLAVE
brokerRole=SYNC_MASTER
#刷盘方式
#- ASYNC_FLUSH 异步刷盘
#- SYNC_FLUSH 同步刷盘
flushDiskType=SYNC_FLUSH
#checkTransactionMessageEnable=false
#发消息线程池数量
#sendMessageThreadPoolNums=128
#拉消息线程池数量
#pullMessageThreadPoolNums=128

broker-b-s.properties

properties 复制代码
#所属集群名字
brokerClusterName=rocketmq-cluster
#broker名字,注意此处不同的配置文件填写的不一样
brokerName=broker-b
#0 表示 Master, >0 表示 Slave
brokerId=1
#nameserver地址,分号分割
namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876
#在发送消息时,自动创建服务器不存在的topic,默认创建的队列数
defaultTopicQueueNums=4
#是否允许 Broker 自动创建Topic,建议线下开启,线上关闭
autoCreateTopicEnable=true
#是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭
autoCreateSubscriptionGroup=true
#Broker 对外服务的监听端口
listenPort=11011
#删除文件时间点,默认凌晨 4点
deleteWhen=04
#文件保留时间,默认 48 小时
fileReservedTime=120
#commitLog每个文件的大小默认1G
mapedFileSizeCommitLog=1073741824
#ConsumeQueue每个文件默认存30W条,根据业务情况调整
mapedFileSizeConsumeQueue=300000
#destroyMapedFileIntervalForcibly=120000
#redeleteHangedFileInterval=120000
#检测物理文件磁盘空间, 占用超过88%磁盘就存储报警了
diskMaxUsedSpaceRatio=88
#存储路径
storePathRootDir=/rocketmq/store-slave
#commitLog 存储路径
storePathCommitLog=/rocketmq/store-slave/commitlog
#消费队列存储路径存储路径
storePathConsumeQueue=/rocketmq/store-slave/consumequeue
#消息索引存储路径
storePathIndex=/rocketmq/store-slave/index
#checkpoint 文件存储路径
storeCheckpoint=/rocketmq/store-slave/checkpoint
#abort 文件存储路径
abortFile=/rocketmq/store-slave/abort
#限制的消息大小
maxMessageSize=65536
#flushCommitLogLeastPages=4
#flushConsumeQueueLeastPages=2
#flushCommitLogThoroughInterval=10000
#flushConsumeQueueThoroughInterval=60000
#Broker 的角色
#- ASYNC_MASTER 异步复制Master
#- SYNC_MASTER 同步双写Master
#- SLAVE
brokerRole=SLAVE
#刷盘方式
#- ASYNC_FLUSH 异步刷盘
#- SYNC_FLUSH 同步刷盘
flushDiskType=ASYNC_FLUSH
#checkTransactionMessageEnable=false
#发消息线程池数量
#sendMessageThreadPoolNums=128
#拉消息线程池数量
#pullMessageThreadPoolNums=128

node2 结点 broker-b.properties

properties 复制代码
#所属集群名字
brokerClusterName=rocketmq-cluster
#broker名字,注意此处不同的配置文件填写的不一样
brokerName=broker-b
#0 表示 Master, >0 表示 Slave
brokerId=0
#nameserver地址,分号分割
namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876
#在发送消息时,自动创建服务器不存在的topic,默认创建的队列数
defaultTopicQueueNums=4
#是否允许 Broker 自动创建Topic,建议线下开启,线上关闭
autoCreateTopicEnable=true
#是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭
autoCreateSubscriptionGroup=true
#Broker 对外服务的监听端口
listenPort=10911
#删除文件时间点,默认凌晨 4点
deleteWhen=04
#文件保留时间,默认 48 小时
fileReservedTime=120
#commitLog每个文件的大小默认1G
mapedFileSizeCommitLog=1073741824
#ConsumeQueue每个文件默认存30W条,根据业务情况调整
mapedFileSizeConsumeQueue=300000
#destroyMapedFileIntervalForcibly=120000
#redeleteHangedFileInterval=120000
#检测物理文件磁盘空间, 占用超过88%磁盘就存储报警了
diskMaxUsedSpaceRatio=88
#存储路径
storePathRootDir=/rocketmq/store
#commitLog 存储路径
storePathCommitLog=/rocketmq/store/commitlog
#消费队列存储路径
storePathConsumeQueue=/rocketmq/store/consumequeue
#消息索引存储路径
storePathIndex=/rocketmq/store/index
#checkpoint 文件存储路径
storeCheckpoint=/rocketmq/store/checkpoint
#abort 文件存储路径
abortFile=/rocketmq/store/abort
#限制的消息大小
maxMessageSize=65536
#flushCommitLogLeastPages=4
#flushConsumeQueueLeastPages=2
#flushCommitLogThoroughInterval=10000
#flushConsumeQueueThoroughInterval=60000
#Broker 的角色
#- ASYNC_MASTER 异步复制Master
#- SYNC_MASTER 同步双写Master
#- SLAVE
brokerRole=SYNC_MASTER
#刷盘方式
#- ASYNC_FLUSH 异步刷盘
#- SYNC_FLUSH 同步刷盘
flushDiskType=SYNC_FLUSH
#checkTransactionMessageEnable=false
#发消息线程池数量
#sendMessageThreadPoolNums=128
#拉消息线程池数量
#pullMessageThreadPoolNums=128

broker-a-s.properties

properties 复制代码
#所属集群名字
brokerClusterName=rocketmq-cluster
#broker名字,注意此处不同的配置文件填写的不一样
brokerName=broker-a 
#0 表示 Master, >0 表示 Slave
brokerId=1
#nameserver地址,分号分割
namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876
#在发送消息时,自动创建服务器不存在的topic,默认创建的队列数
defaultTopicQueueNums=4
#是否允许 Broker 自动创建Topic,建议线下开启,线上关闭
autoCreateTopicEnable=true
#是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭
autoCreateSubscriptionGroup=true
#Broker 对外服务的监听端口
listenPort=11011
#删除文件时间点,默认凌晨 4点
deleteWhen=04
#文件保留时间,默认 48 小时
fileReservedTime=120
#commitLog每个文件的大小默认1G
mapedFileSizeCommitLog=1073741824
#ConsumeQueue每个文件默认存30W条,根据业务情况调整
mapedFileSizeConsumeQueue=300000
#destroyMapedFileIntervalForcibly=120000
#redeleteHangedFileInterval=120000
#检测物理文件磁盘空间, 占用超过88%磁盘就存储报警了
diskMaxUsedSpaceRatio=88
#存储路径
storePathRootDir=/rocketmq/store-slave
#commitLog 存储路径
storePathCommitLog=/rocketmq/store-slave/commitlog
#消费队列存储路径存储路径
storePathConsumeQueue=/rocketmq/store-slave/consumequeue
#消息索引存储路径
storePathIndex=/rocketmq/store-slave/index
#checkpoint 文件存储路径
storeCheckpoint=/rocketmq/store-slave/checkpoint
#abort 文件存储路径
abortFile=/rocketmq/store-slave/abort
#限制的消息大小
maxMessageSize=65536
#flushCommitLogLeastPages=4
#flushConsumeQueueLeastPages=2
#flushCommitLogThoroughInterval=10000
#flushConsumeQueueThoroughInterval=60000
#Broker 的角色
#- ASYNC_MASTER 异步复制Master
#- SYNC_MASTER 同步双写Master
#- SLAVE
brokerRole=SLAVE
#刷盘方式
#- ASYNC_FLUSH 异步刷盘
#- SYNC_FLUSH 同步刷盘
flushDiskType=ASYNC_FLUSH
#checkTransactionMessageEnable=false
#发消息线程池数量
#sendMessageThreadPoolNums=128
#拉消息线程池数量
#pullMessageThreadPoolNums=128

启动集群

检查启动内存

shell 复制代码
vi /rocketmq/bin/runbroker.sh

开发环境配置,找到 JAVA_OPT,因为咱们虚拟机没给那么大内存所以,需要修改这里分配的

shell 复制代码
JAVA_OPT="${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m"

还要配置Java环境,这里放在后面

同理因为我用的是 Java17,所以还需要调整其他内容

shell 复制代码
vi /rocketmq/bin/runserver.sh

修改 runserver.sh

之后启动

shell 复制代码
nohup sh mqnamesrv &

然后通过 jps 可以看到已经启动了,两台服务器都是如此

nameserver 启动了,该到broker了

对于第一个结点 192.168.200.129 启动a主、b从

shell 复制代码
nohup sh mqbroker -c /rocketmq/conf/2m-2s-sync/broker-a.properties &

nohup sh mqbroker -c /rocketmq/conf/2m-2s-sync/broker-b-s.properties &

对于第二个结点 192.168.200.130 启动b主、a从

shell 复制代码
nohup sh mqbroker -c /rocketmq/conf/2m-2s-sync/broker-b.properties &

nohup sh mqbroker -c /rocketmq/conf/2m-2s-sync/broker-a-s.properties &

测试集群

因为 tools.sh 需要 Nameserver 地址才能找到 Broker,所以我们需要先把这个变量放进环境变量里面

shell 复制代码
export NAMESRV_ADDR=rocketmq-nameserver1:9876

然后启动生产者和消费者

然后 node1 进入 /rocketmq/bin 目录下

shell 复制代码
sh tools.sh org.apache.rocketmq.example.quickstart.Producer

发送是没有问题的

shell 复制代码
sh tools.sh org.apache.rocketmq.example.quickstart.Consumer

消费也是没有问题的

高级特性

消息存储

  1. 消息生产者发送消息到 MQ

  2. MQ 收到消息,将消息进行持久化,存储该消息

  3. MQ 返回 ACK 给生产者

  4. MQ push 消息给对应的消费者

  5. 消息消费者返回 ACK 给 MQ

  6. MQ 删除消息

注意:

  • 第 5 步 MQ 在指定时间内接收到消息消费者返回 ACK,MQ 认定消息消费成功,执行 6
  • 第 5 步 MQ 在指定时间内未接到消息消费者返回 ACK,MQ 认定消息消费失败,重新执行 4,5,6

1,2,3 步,消息持久化,是为了防止,比如 Broker 接收到消息后,写入内存了,即便回了ACK,但是如果 Broker 挂掉了导致内存丢失了,这个时候 Consumer 就接收不到了,消息丢失了。如果写到磁盘,哪怕写完发送了 ACK 停电,重启之后会扫描磁盘文件,继续投递没有消费的消息。

如果写到磁盘了,但是第三步 ACK 因为网络或者 Broker 崩溃没发出去,Producer 会触发自动重试机制(这样如果没写到磁盘也不怕了),这个时候其实磁盘就会出现两个内容一样但 OffsetMsgID 不同的消息,这就需要消费者根据业务ID自己去重了。

其实 ActiveMQ 它就是用的数据库作为持久化的存储介质,但是你想数据库的数据最后也是存到磁盘,干嘛还要通过数据库,而 ActiveMQ 因为数据库也成为了它的瓶颈。

所以 RocketMQ/Kafka/RabbitMQ 都是直接存到文件系统,采用消息刷盘机制进行数据存储。

读写方式

为了提升读写速度,RocketMQ采用了两种机制

  1. 顺序写,原来磁盘空间因为有磁盘碎片的原因,所以如果写入一个比较大的数据的时候,基本在磁盘都是随机写,速度大概 100k/s 非常慢。如下,蓝色是数据

    而 RocketMQ 会向 Linux 每次申请 1G 的连续空间,这些就是顺序写,600-3000m/s,速度超级快

  2. 利用 Linux 的零拷贝

    这里经历了四次拷贝

    1. 硬盘数据 --> 内核态
      操作系统通过 DMA 引擎将磁盘数据读取到内核缓冲区。
    2. 内核态 --> 用户态
      由于应用程序(Java 进程)不能直接访问内核空间,必须把数据从内核缓存拷贝到用户空间(JVM 内存)。
    3. 用户态 --> 网络驱动内核
      程序调用发送接口,数据又从用户空间拷贝回内核里的 Socket 缓冲区。
    4. 网络驱动内核 --> 网卡
      最后,DMA 引擎将数据从 Socket 缓冲区拷贝到网卡硬件,发送出去。

    但这里第 2 和第 3 步都是用 CPU 来进行数据搬运,如果消息非常多,就会慢喽,并且同样的数据在内存里面好几份。

    所以采用了零拷贝技术

    • 把数据传输从 4 次复制减少到 3 次。直接把内核态的缓冲区地址映射到用户态(mmap技术)
    • 缺点是需要预先分配好一块连续的、大块的磁盘空间进行映射。(比如 CommitLog 文件,存消息的物理文件,默认大小就是 1G)
    • Java 语言中使用 MappedByteBuffer 类实现了该技术。

第二个先大概知道吧,底层知识了

存储结构

MQ 数据存储区域包括如下内容

  • 消息数据存储区域
    • topic
    • queueId
    • message
  • 消费逻辑队列
    • minOffset
    • maxOffset
    • consumerOffset
  • 索引
    • key 索引
    • 创建时间索引
    • 。。。

第一个我们可以来到 /rocketmq/store/commitlog 文件夹下,查看信息

可以看到,第一个 1G 是咱们配置文件申请的默认 1G 的存储空间,第二个 1G 是 RocketMQ 的预分配机制,就是预创建的下一个空文件,这是为了防止第一个文件写满的时候,还要去请求操作系统分配磁盘空间(很耗时),从而保证发消息的低延迟。

每个topic的每个queue都存在这个 commitlog 里面,里面存储了 topic,queueId,message。

❓ 怎么存到 commitlog 里面的?是按照 topic、queueId 进行划分吗?

前面说过什么?顺序写,如果想顺序写,那就不能按照 topic、queueId 进行划分了,所以来了一个消息,要把它的 Topic、QueueID、消息内容、长度等所有元数据都存进去。

如果只有 CommitLog,那如果消费者想找一个 TopicA 的消息,得从头到尾搜索才行,所以有了 ConsumeQueue,它不断扫描 CommitLog 的末尾,发现新消息之后,就把这个消息的物理位置偏移、长度提取出来进行存储。ConsumeQueue 是按照 store/consumequeue/{Topic}/{QueueID} 进行物理隔离划分的。每个文件由固定长度(20字节)的索引单元组成(固定长度,也是查询快的原因)。

minOffset 当前 Topic 队列里面,最早消息的逻辑序号;maxOffset 最新消息的逻辑序号;consumerOffset 代表消费者上次读到的逻辑序号。这里其实是只记录顺序,不是记内存地址,比如 0 就代表第 1 个消息。

因为索引单元长度是固定的,那是不是比如说我想读 TopicTest Queue_0 的第 100 个,那就去这个文件夹下面去取物理地址,因为这个文件是固定大小的索引单元,所以直接 20 * 100 就是去 2000 的位置处去看看这个消息真正的物理索引位置在哪,哎取出来就找到了真实的消息。

那么 Index 有啥用呢?正常消费的时候,是按照消息顺序1,2,3依次往下读,这也是 ConsumeQueue 的作用,但是如果说想根据 MessageID 或者 MessageKey(通过 message.setKeys("") 来设置) 查呢?就需要这个了,它存的是一个哈希表,记录Key的哈希值,物理偏移量(物理地址),时间戳(消息存入的时间),可以一下就查到是否入库。

比如前面不是说比如宕机又重启了,不确定某个消息发没发过,我就可以从这里根据业务ID查询,看看有没有入库。此外,在排查分布式事务、由于网络抖动导致的重复投递等问题时,IndexFile 也是唯一的"后悔药"。

刷盘机制

同步刷盘

  1. 生产者发送消息到 MQ,MQ 接收到消息数据
  2. MQ 挂起生产者发送消息的线程
  3. MQ 将消息数据写入内存
  4. 内存数据写入磁盘
  5. 磁盘存储后返回 SUCCESS
  6. MQ 恢复挂起的生产者线程
  7. 发送 ACK 到生产者

需要等待数据写入磁盘

异步刷盘

  1. 生产者发送消息到 MQ,MQ 接收到消息数据
  2. MQ 将消息数据写入内存
  3. 发送 ACK 到生产者

只有这三步,只写入内存,先不存入磁盘。等积攒一定量的消息或者过了比如几分钟再一起刷到磁盘里面。

同步刷盘:安全性高,效率低,速度慢(适用于对数据安全要求较高的业务)

异步刷盘:安全性低,效率高,速度快(适用于对数据处理速度要求较高的业务)

配置方式如下(之前 a.properties 写过)

properties 复制代码
#刷盘方式
#- ASYNC_FLUSH 异步刷盘
#- SYNC_FLUSH 同步刷盘
flushDiskType=SYNC_FLUSH

高可用性

  • nameserver:无状态 + 全服务器注册

    前面说的这里就是,每个 broker 都要向所有的 nameserver 注册(全服务器注册),并且三个 nameserver 之间是没有关联的,互相不认识(无状态)。
  • 消息服务器:主从架构(2M-2S)
  • 消息生产:生产者将相同的 topic 绑定到多个 group 组,保障 master 挂掉后,其他 master 仍可正常进行消息接收
    就是比如 Broker1 和 Broker2 都接收 Topic1,按照正常来说半对半,当一个挂掉之后,这个 Topic1 的消息都发给剩下一个
  • 消息消费:RocketMQ 自身会根据 master 的压力确认是否由 master 承担消息读取的功能,当 master 繁忙时候,自动切换由 slave 承担数据读取的工作,因为从机也会同步主机的消息(这不实现读写分离了)

主从数据复制

  • 同步复制
    • Master 接收到消息后,必须先将消息成功复制到 Slave 节点,然后才会向生产者(Producer)反馈写操作成功。
    • 优点:数据安全,不丢数据,出现故障容易恢复(主机宕机,从机有完整数据,可以直接切换)
    • 缺点:影响数据吞吐量,整体性能低
  • 异步复制
    • Master 接收到消息并写入本地磁盘后,立即返回成功给生产者。随后,再通过后台线程异步地将消息同步到 Slave。
    • 优点:数据吞吐量大,操作延迟低,性能高(适合对实时性要求极高、并发量大的场景)
    • 缺点:数据不安全,会出现数据丢失的现象,一旦 master 故障,从上次数据同步到故障时间的数据将丢失

配置方式

properties 复制代码
# Broker 的角色
# 	ASYNC_MASTER 异步复制 Master
# 	SYNC_MASTER 同步双写 Master
# 	SLAVE
brokerRole=SYNC_MASTER

负载均衡

这里不考虑广播模式哈,广播模式,所有消费者都收到消息,就不存在负载均衡问题了。

Producer 负载均衡:内部实现了不同 Broker 集群中对同一 topic 对应消息队列的负载均衡

比如两个 Broker(分别在一台机器上)都有 TopicA,每个 TopicA 都有四个队列,消息来了之后,是一个一个轮询放入的。

Consumer 负载均衡

  • 平均分配
  • 循环平均分配(解决宕机问题)

下面这个就是平均分配,比如两个 Broker 都有一个 TopicA,每个 Topic 有三个队列,有三个消费者,他们一人两个队列

但是这有个缺点,就是加入 Broker 宕机了,那么还是保持消费者A没有消息接收了,消费者B接收一个队列,消费者C接收两个队列,这样相当于本来A,B,C每人三分之一的消息,A不接收了,B收1/3,C收2/3。不均衡了

下面就是循环平均分配,每个 Broker 的 Queue 平均分配到消费者集群,如果有一个宕掉,负载还是很均衡的

当出现 8 个 queue,他们三个分配就会是 3,3,2 的情况,尽可能做到平衡。可以通过增大 Queue 数量尽可能实现平衡

生产环境下的黄金法则:

宁多勿少:Queue 的数量一定要大于或等于你的 Consumer 机器数。防止 Consumer 分配不到 Queue,出现空转。

倍数关系:建议 Queue 总数是 Consumer 数量的整数倍(比如 16 个 Queue,4 个 Consumer),这样每个 Consumer 拿到的任务量是完全相等的,不会出现有人干 3 份活,有人干 2 份活的情况。
那么到底怎么算呢?

怎么算?

  • Consumer 会从 nameserver 那里获取 Topic 的元数据(看 Topic1 在所有 Broker 加起来有多少 Queue)
  • Consumer 启动后每隔 30 秒向所有 Broker 发送心跳包(包含 Consumer 的 ClientID 和所在组 group1,因为同一个组的才会平均消息,不同组的都会接收同一份消息),所以 Broker 内存会记录所有的组(比如 group1)有哪些活跃的 ClientID
  • 当 Consumer 想要均衡的时候,就问 Broker 要一份 ClientID 的名单

这样有多少队列和ClinetID就知道了,这个ClientID排好序就行,一般是 客户端IP@进程PID,按照字符串排序,队列Queue也是,先按照 Broker 的名字排序,然后按照队列序号0,1,2,3排序。这样就好分了

消息重试

当消息消费后未正常返回消费成功的信息将启动消息重试机制,非为两种,顺序消息、无序消息

顺序消息

就是前面说的消息顺序,某些消息必须按照局部顺序进行发送,比如必须是 1.创建订单 -> 2.支付成功 -> 3.物流发货 的顺序

比如当发送第一个消息给Consumer的时候,在返回ACK的时候网络不好断了,那么 MQ 就会自动进行消息重发(每次间隔时间为 1 秒)。因为消息A没成功发送之前,消息B,C,D都不能动。

注意:应用会出现消息消费被阻塞的情况,因此,要对顺序消息的消费情况进行监控,避免阻塞现象发生。因为一直重发,某个队列消息就会堆积,达到一定阈值或者某条消息重试次数过多就会报警。就得人工介入了

无序消息

  • 无序消息包括普通消息、定时消息、延时消息、事务消息
  • 无序消息重试仅适用负载均衡(集群)模型下的消息消费,不适用于广播模式下的消息消费
  • 为保障无序消息的消费,MQ 设定了合理的消息重试间隔时长

无序消息发送后没收到 ACK 是不影响后续消息的执行的,会把消息放到重试队列里面。

死信队列

当消息消费重试到达了指定次数(默认16次)后,MQ 将无法被正常消费的消息称为死信消息,死信消息不会被抛弃,而是保存到了一个全新的队列中,该队列成为死信队列

死信队列特征

  • 归属于某一个组 (Group ID),而不归属于 Topic,也不归属于消费者。
  • 一个死信队列中可以包含同一个组下的多个 Topic 中的死信消息。
  • 死信队列不会进行默认初始化。当第一个死信出现后,此队列才会首次初始化。

死信队列中消息特征

  • 死信队列中的消息不会被再次重复消费。
  • 死信队列中的消息有效期为 3 天,达到时限后消息将被清除。

死信处理

如果消息不重要那就不管了呗,比如 Info 日志,如果比较重要

在监控平台中,通过查找死信,获取死信的 messageId,然后通过 id 对死信进行精准消费。

消息重复消费

出现原因

  1. 生产者发送了重复的消息

    • 网络闪断

      比如这里,生产者发送了消息给Broker,Broker存到了文件系统,发送ACK回去网络出现了问题,导致Producer没接收到

    • 生产者宕机

      或者在返回ACK时,生产者宕机了,没接收到,再重启的时候又重新发送消息了。

  2. 消息服务器投递了重复的消息

    • 网络闪断
  3. 动态的负载均衡过程

    • 网络闪断/抖动
    • broker 重启
    • 订阅方应用重启(消费者)
    • 客户端扩容
    • 客户端缩容

消息幂等

对同一条消息,无论消费多少次,结果保持一致,称为消息幂等性。(因为消息重复消费是存在的,但是需要保持幂等性,比如用户进行账号注册,连续点击10次,但只能注册一个。用户下单一个商品,不能因为ACK丢失之类的,重复下单两次)

解决方案:

  • 使用业务 ID 作为消息的 key。
  • 在消费消息时,客户端对 key 做判定:未使用过则放行,使用过则抛弃。

注意:messageId 由 RocketMQ 产生,messageId 并不具有唯一性,不能作为幂等判定条件。

常见的幂等方法示例:

  • 新增:不幂等 ------ insert into order values (......)
  • 查询:幂等
  • 删除:幂等 ------ delete from 表 where id = 1
  • 修改:不幂等 ------ update account set balance = balance + 100 where no = 1
  • 修改:幂等 ------ update account set balance = 100 where no = 1

同一个内容执行一次和执行十次效果都一样(幂等)

问题

虚拟机网络配置

需要把虚拟机的网络网段和本机的配置到一个网段

虚拟机点击编辑->虚拟机网络编辑器,点击 VMnet8,更改设置

将子网IP、掩码配置好,NAT 设置的网关IP配置上

DHCP设置的起始IP地址和结束IP地址配置好

在高级网络设置里面配置

双击 Internet 协议版本4(TCP/IPv4)

修改下面三个位置

完成

我的网卡名是 ens33,根据这个我想在CentOS上配置静态IP地址

在终端输入以下命令

shell 复制代码
vi /etc/sysconfig/network-scripts/ifcfg-ens33

进入编辑器后,按 i 键进入插入模式。

修改这两项:

  • BOOTPROTO=static (把原来的 dhcp 改为 static,表示静态 IP)
  • ONBOOT=yes (确保系统启动时自动激活网卡)

在文件末尾添加以下四行:

  • IPADDR=192.168.200.128 (固定 IP)
  • NETMASK=255.255.255.0 (子网掩码)
  • GATEWAY=192.168.200.2 (VMware NAT 模式默认网关通常是 .2
  • DNS1=114.114.114.114 (或者使用 8.8.8.8

然后保存并退出

  • Esc 键退出编辑模式。
  • 输入 :wq 然后按回车保存。

重启生效

输入以下命令: systemctl restart network

配置JDK17

cmd 复制代码
mkdir -p /usr/local/java && cd /usr/local/java
cmd 复制代码
curl -O https://mirrors.huaweicloud.com/openjdk/17.0.2/openjdk-17.0.2_linux-x64_bin.tar.gz

然后解压

shell 复制代码
tar -zxvf openjdk-17.0.2_linux-x64_bin.tar.gz

然后要加入系统变量

shell 复制代码
vi /etc/profile

文件末尾插入

shell 复制代码
export JAVA_HOME=/usr/local/java/jdk-17.0.2
export CLASSPATH=.:$JAVA_HOME/lib
export PATH=$JAVA_HOME/bin:$PATH

然后刷新配置

shell 复制代码
source /etc/profile

查看是否安装成功

相关推荐
尽兴-1 天前
RocketMQ核心源码深度解读:架构原理与核心机制剖析
架构·rocketmq·netty·架构原理·消息持久化
恼书:-(空寄2 天前
RocketMQ 事务消息实现及核心使用场景(完整实战指南)
rocketmq
乐观的Terry2 天前
RocketMQ 使用指南
rocketmq
Nandeska2 天前
1、RocketMQ核心概念详解
中间件·rocketmq
殷紫川2 天前
RocketMQ 两大核心特性深度拆解:事务消息与延时消息,从原理到实战全打通
架构·rocketmq
程序员Terry3 天前
RocketMQ 使用指南
后端·rocketmq
耗子会飞3 天前
小白学习springboot项目如何连接RocketMQ
后端·rocketmq
IT界的老黄牛3 天前
RocketMQ 5.x 集群部署实战:3 台机器搞定 2 主 2 从,Docker Host 模式一把梭
docker·容器·rocketmq
乐观的Terry3 天前
Docker 部署 RocketMQ 5.1.0 踩坑实录:从超时到 Console 连不上的完整解决之路
docker·容器·rocketmq