【微服务专题】深入理解与实践微服务架构(二十五)之集成RocketMQ消息生产者与消费者

2,328 阅读16分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第14天,点击查看活动详情

集成RocketMQ消息生产者

1. 创建服务消息生产者子模块

创建service-provider-rocketmq子模块:

image-20220729201628330

2. 添加RocketMQ依赖

这里可以添加rocketmq-stream依赖,这种方式是基于驱动的消息传递方式,更优;但我们先快速集成RocketMQ,后面再整合这种方式。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-cloud-alibaba-starter</artifactId>
        <groupId>com.deepinsea</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
​
    <artifactId>service-provider-rocketmq</artifactId>
​
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
​
    <dependencies>
        <!-- nacos 服务发现 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!-- rocketmq 消息队列 -->
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>2.2.2</version>
        </dependency>
        <!-- lombok 构造器代码生成器-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!-- web依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

3. 启动类服务发现声明

编写主启动类src/main/java/com/deepinsea/ServiceProviderRocketMQApplication.java

package deepinsea;
​
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
​
/**
 * Created by deepinsea on 2022/7/29.
 * 消息生产者主启动类
 */
@SpringBootApplication
@EnableDiscoveryClient
public class ServiceProviderRocketMQApplication {
​
    public static void main(String[] args) {
        SpringApplication.run(ServiceProviderRocketMQApplication.class, args);
    }
}

4. 编写消息生产者配置文件

server:
  # 服务运行端口
  port: 9090
spring:
  application:
    # 服务名称
    name: service-provider-rocketmq
  cloud:
    nacos:
      discovery:
        # 服务注册地址
        server-addr: localhost:8848
        # Nacos认证信息
        username: nacos
        password: nacos
        # 注册到 nacos 的指定 namespace,默认为 public
        namespace: public
rocketmq:
  name-server: localhost:9876
  producer:
    # 必须指定group,否则会启动失败
    group: rocketmq-group

注意:一定要添加rocketmq.producer.group配置参数,否则会启动失败,报以下错误:

Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2022-07-30 01:22:53.293 ERROR 4272 --- [           main] o.s.b.d.LoggingFailureAnalysisReporter   : 
​
***************************
APPLICATION FAILED TO START
***************************
​
Description:
A component required a bean of type 'org.apache.rocketmq.spring.core.RocketMQTemplate' that could not be found.
​
Action:
Consider defining a bean of type 'org.apache.rocketmq.spring.core.RocketMQTemplate' in your configuration.
Process finished with exit code 1
​

5. 创建消息体实体类

创建一个消息实体类,用于映射消息生产者投递的消息体内容:

package deepinsea.model;
​
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
​
import java.io.Serializable;
import java.util.Date;
​
/**
 * Created by deepinsea on 2022/7/29.
 * 消息体
 */
@Data
public class Message implements Serializable {
    private Integer id;
    private String name;
    private String status;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;
}

注意:这里的日期类型字段需要使用Jackson的@JsonFormat注解进行序列化处理,否则会出现与本机时间偏差几小时的问题。

不进行日期字段序列化处理的消息体,如下图所示:

image-20220730003242428

可以看到,消息体中的时间比StoreTime慢8小时,正是默认时区慢8小时造成的。

6. 创建消息生产Controller

package deepinsea.controller;
​
import deepinsea.model.Message;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import javax.annotation.Resource;
import java.util.Date;
​
/**
 * Created by deepinsea on 2022/7/30.
 * 消息生产者控制类
 */
@RestController
@RequestMapping("/provider-rocketmq")
public class RocketMQProducerController {
​
    /**
     * 用于发送消息到RocketMQ Broker的API
     */
    @Resource
    public RocketMQTemplate rocketMQTemplate;
​
    // 模板消息发送
    @GetMapping("/sendMsg")
    public String sendMsg() {
        String topic = "test-topic";
        Message message = new Message();
        message.setId(1);
        message.setName("白羊");
        message.setStatus("default");
        message.setCreateTime(new Date());
        // 发送消息
        rocketMQTemplate.convertAndSend(topic, message);
        return "send message success";
    }
​
    // 实时消息发送
    @GetMapping("/send")
    public String send(String topic, String msg){
        rocketMQTemplate.convertAndSend(topic, msg);
        return "send live message success";
    }
}

事实上,我在上面的消息生产者中添加两个接口:一个是模板消息发送接口,另一个是实时消息发送接口。生产中,这两种方式一般是结合使用,下面我们分别测试这两种消息发送方式。

7. 测试消息生产接口

模板消息投递测试

我们点击"▶️"启动项目以后,可以使用curl命令访问消息发送的接口:

C:\Users\deepinsea>curl http://localhost:9090/provider-rocketmq/sendMsg
send message success

因为RocketMQ的消息Producer(生产者)客户端发送的消息,将会存储在RocketMQ Broker中,因此我们可以通过RocketMQ Dashboard查看历史消息记录:

image-20220730020811739

注意:一定要点击搜索才会显示消息记录,不点击搜索的话切换到对应的topic也不会去查询消息投递记录的

我们可以点击到RocketMQ控制台的消息选项,然后切换到自定义的test-topic订阅主题,点击搜索后出现对应的消息记录。我们可以点击消息详情查看到具体的消息体(这里我们查看一个序列化消息体日期字段后的消息详细):

image-20220730021002391

可以看到,消息体信息与预期投递的消息结果一致,并且日期也没有时区错位,模板消息投递测试成功!

实时消息投递测试

我们可以使用PostMan请求接口,来测试实时消息的投递:

http://localhost:9090/provider-rocketmq/send?topic=test-topic&msg=hello

image-20220730022823978

可以看到,实时消息投递接口请求成功!

我们还可以使用更轻量级的curl,来进行实时消息投递接口测试:

C:\Users\deepinsea>curl "http://localhost:9090/provider-rocketmq/send?topic=test-topic&msg=hello"
send live message success

同样的,使用curl进行实时消息投递接口请求也是成功的!

注意:这里测试实时消息投递时,添加的消息参数时text/html的,但是Jackson/FastJson序列化的格式为application/json的,因此消费者监听并消费Broker中存储的消息时会因为格式不正确出现反序列错误的问题。因此消息生产者和消费者,最好都采用标准Json的格式传输消息数据,否则就需要通过兼容不同格式的消息数据来解决反序列化的问题了。

我们打开RocketMQ控制台,可以看到Broker中存储了投递的实时消息:

image-20220730023339470

我们打开同一个topic下,通过实时消息投递接口投递的消息详情:

image-20220730023503548

可以看到,消息体内容符合预期结果。这说明只要是同一个Topic主题下的消息,都会放在一起进行存储,类似于RabbitMQ的主题交换机;RocketMQ将其定义为主题订阅的概念,只要订阅了同一个主题的消息都会有相同的投递路径。

RocketMQ消息删除方面的扩展

我们发现生产者已投递到Broker但没有被消费的消息,我们不能直接删除。只能通过停止RocketMQ进程,然后再删除日志和自定义的topic信息,重启RocketMQ后才能成功删除生产的消息。不然的话,只能等待3天后,RocketMQ自动删除存储到Broker的topic消息。

消息积压问题

这种没有被消息消费者消费的消息,积累太多之后会出现消息堆积问题

解决方案

为了避免生产环境流量暴增引起的短时间消息堆积,有如下解决方案:

  • 建议业务系统加强消费能力,不要让消息堆积,消息文件不被占用就可以更安全的被删除;
  • fileReservedTime设置合理的时间,保持可用磁盘空间在一定程度,可以挡住短时间的流量冲击;
  • 发生堆积时优先删除同一个磁盘空间的其它无用日志;
  • 找到消息量大的topic,进行精准删除;
  • 动态扩容broker;
  • update消息量大的topic的broker。

那么我们投递到Broker的消息被消费之后,会被清理掉吗? 答案是:不会

原理

消息是被顺序存储在commitlog文件的,且消息大小不定长,所以消息的清理是不可能以消息为单位进行清理的,而是以commitlog文件为单位进行清理的。否则会急剧下降清理效率,并实现逻辑复杂。

commitlog文件存在一个过期时间,默认为72小时,即三天。除了用户手动清理外,在以下情况下也 会被自动清理,无论文件中的消息是否被消费过:

  • 文件过期,且到达清理时间点(默认为凌晨4点)后,自动清理过期文件
  • 文件过期,且磁盘空间占用率已达过期清理警戒线(默认75%)后,无论是否达到清理时间点, 都会自动清理过期文件
  • 磁盘占用率达到清理警戒线(默认85%)后,开始按照设定好的规则清理文件,无论是否过期。 默认会从最老的文件开始清理
  • 磁盘占用率达到系统危险警戒线(默认90%)后,Broker将拒绝消息写入

需要注意以下几点:

  • 对于RocketMQ来说,删除一个1G大小的文件,是一个压力巨大的IO操作。在删除过程中,系统性能会骤然下降。所以,其默认清理时间点为凌晨4点,访问量最小的时间。也正因如果,我们要保障磁盘空间的空闲率,不要使系统出现在其它时间点删除commitlog文件的情况。
  • 官方建议RocketMQ服务的Linux文件系统采用ext4。因为对于文件删除操作,ext4要比ext3性能更好

实际测试

测试消费过的消息不会被删除

  1. 使用Topic为test-topic,设置生产者往RocketMQ中以test-topic作为Topic发送10条测试消息;
  2. 消费者监听Topic为test-topic,且Consumer Group为test-consumer-a,那么消费者为test-consumer-a的会消费掉10条消息;
  3. 此时我们可以在启动一个消费者,这时我们这个新的消费者的Consumer Group不再是test-consumer-a,而是test-consumer-new;
  4. 那么这个test-consumer-new启动后也会拿到这10条消息。

重置消费位点

RocketMQ中有两种消费模式:

  • 集群模式:消费者为同一个组下的,订阅的是同一个topic和tag的情况

这样的就是消费者组成了一个集群,有多个实例,之后就是topic中的消息只会被这些实例中的其中一个消费;

  • 广播模式:多个不同组的消费者订阅同一个topic和tag的情况。

这样的就是所谓的广播模式,每个消费者都会接收到topic中的消息。

两种消费模式适用场景以及注意事项

负载均衡(集群)模式:适用场景&注意事项

  • 消费端集群化部署,每条消息只需要被处理一次。
  • 由于消费进度在服务端维护,可靠性更高。
  • 集群消费模式下,每一条消息都只会被分发到一台机器上处理。如果需要被集群下的每一台机器都处理,请使用广播模式。
  • 集群消费模式下,不保证每一次失败重投的消息路由到同一台机器上,因此处理消息时不应该做任何确定性假设。

广播模式:适用场景&注意事项

  • 每条消息都需要被相同逻辑的多台机器处理。
  • 消费进度在客户端维护,出现重复的概率稍大于集群模式。
  • 广播模式下,消息队列 RocketMQ 保证每条消息至少被每台客户端消费一次,但是并不会对消费失败的消息进行失败重投,因此业务方需要关注消费失败的情况。
  • 广播模式下,客户端每一次重启都会从最新消息消费。客户端在被停止期间发送至服务端的消息将会被自动跳过,请谨慎选择。
  • 广播模式下,每条消息都会被大量的客户端重复处理,因此推荐尽可能使用集群模式。
  • 目前仅 Java 客户端支持广播模式。
  • 广播消费模式下不支持顺序消息。
  • 广播消费模式下不支持重置消费位点。
  • 广播模式下服务端不维护消费进度,所以消息队列 RocketMQ 控制台不支持消息堆积查询、消息堆积报警和订阅关系查询功能。

RocketMQ中的消息被消费了并不会被删除(保存3天后滚动删除,一般都需要持久化),消息被消费只是订阅了这个topic的消费者的指针的移动。

虽然我们无法正常删除生产了但没有被消费的消息,但是按照阿里云官方文档,我们可以重置消费位点来删除堆积的消息。

由于消费消息也只是对应的消费者的指针的偏移,消息也并没有消失,所以这个重置消费位点也就是指针的来回移动偏移而已。

而指针的偏移值offset并不能精确到其中的一条消息,只能通过时间来定位一个范围区间内的消息。底层是通过ResetOffsetByTimeCommand.execute() 来实现的。

下面是RocketMQ控制台的重置位点功能(需要对应同一个Topic下的同一个Group):

image-20220731022706323

可通过重置消费位点,按需清除堆积的或不想消费的这部分消息再开始消费,或直接跳转到某个时间点消费该时间点之后的消息(不论是否消费过该时间点之前的消息)。

使用重置消费位点功能有以下注意事项:

  • 广播消费模式不支持重置消费位点。
  • 目前不支持指定 Message ID、Message Key 和 Tag 来重置消息的消费位点。

按时间点进行消费位点重置:选择该选项后会出现时间点选择的控件,然后选择一个时间点,这个时间点之后发送的消息才会被消费。可选时间范围中的起始和终止时间分别是该 Topic 中储存的最早的和最晚的一条消息的生产时间,不能选择超过可选时间范围的时间点。

还有一点 最重要的一点,这个重置消费位点你点过重置后它不会立马重置,你的消费者也不会立马接收到消息,点过重置后,好像会导致消息短暂的失效(大概是0-3分钟)

消息删除部分的扩展就先到这里,正常请求我们是有默认的消息清理策略的,如果需要优化的话才需要进行调整。我们先进行正常的消息消费,下面集成RocketMQ消息消费者客户端:

集成RocketMQ消息消费者

1. 创建服务消息消费者子模块

image-20220730210515261

2. 添加RocketMQ依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-cloud-alibaba-starter</artifactId>
        <groupId>com.deepinsea</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
​
    <artifactId>service-consumer-rocketmq</artifactId>
​
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
​
    <dependencies>
        <!-- nacos 服务发现 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!-- rocketmq 消息队列 -->
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>2.2.2</version>
        </dependency>
        <!-- lombok 构造器代码生成器-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!-- web依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

3. 启动类服务发现声明

编写主启动类src/main/java/com/deepinsea/ServiceConsumerRocketMQApplication.java

package deepinsea;
​
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
​
/**
 * Created by deepinsea on 2022/7/30.
 * 消息消费者主启动类
 */
@SpringBootApplication
@EnableDiscoveryClient
public class ServiceConsumerRocketMQApplication {
​
    public static void main(String[] args) {
        SpringApplication.run(ServiceConsumerRocketMQApplication.class, args);
    }
}

4. 编写消息消费者配置文件

server:
  # 服务运行端口
  port: 10000
spring:
  application:
    # 服务名称
    name: service-consumer-rocketmq
  cloud:
    nacos:
      discovery:
        # 服务注册地址
        server-addr: localhost:8848
        # Nacos认证信息
        username: nacos
        password: nacos
        # 注册到 nacos 的指定 namespace,默认为 public
        namespace: public
rocketmq:
  name-server: localhost:9876
  producer:
    # 必须指定group,否则会启动失败
    group: rocketmq-group

5. 创建消息消费者监听器

这里需要指定MQ监听器的topic和group name,对应两大消息消费模式中的订阅模式。下面是阿里云文档中的说明:

订阅关系一致:指的是同一个消费者Group ID下所有Consumer实例所订阅的Topic、Tag必须完全一致。如果订阅关系不一致,消息消费的逻辑就会混乱,甚至导致消息丢失。

因此,可以了解到:Topic订阅关系路由范围 > Group ID。

这里需要注意一下,消息反序列化类型错误的问题

package deepinsea.common.listener;
​
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
​
/**
 * Created by deepinsea on 2022/7/30.
 * 消息消费者监听器
 */
@Component
@RocketMQMessageListener(topic = "test-topic", consumerGroup = "rocketmq-group")
// topic需要和生产者的topic一致,consumerGroup属性是必须指定的,内容可以随意
//public class RocketMQConsumerListener implements RocketMQListener<Message> {
public class RocketMQConsumerListener implements RocketMQListener<String> {
//public class RocketMQConsumerListener implements RocketMQListener<Object> {
​
    private static final Logger log = LoggerFactory.getLogger(RocketMQConsumerListener.class);
​
    /**
     * 监听到消息就会调用该方法
     */
    @Override
//    public void onMessage(Message message) {
    public void onMessage(String str) {
//    public void onMessage(Object obj) {
//        log.info("从test-topic订阅主题中监听到消息");
//        log.info(JSON.toJSONString(message)); //json对象转为json字符串(一般推荐是json字符串传输,但大部分时候都是对象直接传输)
​
        log.info("从test-topic订阅主题中监听到消息: ");
        log.info(str);
​
//        log.info("从test-topic订阅主题中监听到消息");
//        log.info(obj);
    }
}

注意:这里监听类重写的消息接收方法,接收的参数类型最好为String,不然会出现Json数据反序列化异常的问题。另外,也不推荐使用Object类来兼容所有类型的传输数据,因为Object作为接收参数会导致频繁装箱拆箱,造成性能问题。

如果定义三个监听器,分别采用Message、String和Object作为接收参数类型,那么只有Object类型的监听器会生效。如果没有Object类型的监听器,只有其他两种监听器时,Message类型的监听器会不兼容格式的反序列化错误。因此,这种分别接收的方式不推荐。

因为application/json和text/html这两种类型的数据,都是字符串,因此采用String类型接收可以兼容。

另外如果需要统一模板消息和实时消息数据传输的格式,最好是消息生产传输时设置一个key,将text/html文本字符串设置为key的值,将key-value转为application/json的格式。最好是有一个消息发送平台,可以定义消息模板、输入消息内容、批量推送消息。

6. 测试消息消费者消费消息

我们下面使用curl命令分别生产模板和实时两种消息,然后测试消息消费者对两种消息的接收(消费)情况:

先测试消息生产者,不对实时消息序列化处理的情况

C:\Users\deepinsea>curl http://localhost:9090/provider-rocketmq/sendMsg
send message success
C:\Users\deepinsea>curl "http://localhost:9090/provider-rocketmq/send?topic=test-topic&msg=hello123"
send live message success

控制台输出如下:

2022-07-31 05:53:26.218  INFO 54416 --- [ocketmq-group_9] d.c.listener.RocketMQConsumerListener    : 从test-topic订阅主题中监听到消息: 
2022-07-31 05:53:26.218  INFO 54416 --- [ocketmq-group_9] d.c.listener.RocketMQConsumerListener    : {"id":1,"name":"白羊","status":"default","createTime":"2022-07-31 05:53:26"}
2022-07-31 05:53:28.527  INFO 54416 --- [cketmq-group_10] d.c.listener.RocketMQConsumerListener    : 从test-topic订阅主题中监听到消息: 
2022-07-31 05:53:28.528  INFO 54416 --- [cketmq-group_10] d.c.listener.RocketMQConsumerListener    : hello123

可以看到,消息消费成功!并且,使用String类型接收消息体,兼容application/json和text/html这两种类型的Json字符串格式。

欢迎点赞,谢谢各位大佬了ヾ(◍°∇°◍)ノ゙