消息队列—RabbitMQ

笔记整理自 【涛哥】最适合小白入门的RabbitMQ教程

1. 消息队列介绍

Ⅰ. 消息队列需求场景

在基于微服务开发的电商项目中,商品的查询和商品的修改是通过两个服务实现的,如果修改了商品的价格,如何保证商品查询服务查询出来的商品价格同步更新呢?

服务与服务之间的通信方式有两种:同步调用异步调用

  • 同步调用

    ➢ A服务调用B服务,需要等待B服务执行完毕的返回值,A服务才可以继续往下执行。

    ➢ 通过远程过程调用:REST(Ribbon、Feign)RPC(Dubbo) 实现同步调用。

  • 异步调用

    ➢ A服务调用B服务,而无需等待B服务的执行结果,也就是说在B服务执行的同时A服务可以继续往下执行。

    ➢ 通过:消息队列 实现异步调用。

image-20220914130231177

  1. 为了保证数据的一致性,当 “商户商品修改服务” 在完成对A库中商品信息的修改之后,需要调用 “商户商品查询服务” 及 “自媒体商品查询服务” 同步完成B库及C库中商品信息的修改;
  2. 如果 “商户商品修改服务” 使用Ribbon或者Feign同步调用 “商户商品查询服务” 及 “自媒体商品查询服务” 虽然能够实现数据的同步修改,但是大大增加了 “商户商品修改服务” 对用户的响应时间
  3. 为了缩短 “商户商品修改服务” 对用户的响应时间,我们可以在 “商户商品修改服务” 完成对A库修改之后,通过异步消息队列通知 “商户商品查询服务” 及 “自媒体商品查询服务”。

image-20220914130913513

Ⅱ. 消息队列概念

  • MQ全称为Message Queue,消息队列(MQ)是一种应用程序对应用程序的通信方法。应用程序通过读写出入队列的消息(针对应用程序的数据)来通信,而无需专用连接来链接它们。
  • 消息传递指的是程序之间通过在消息中发送数据进行通信,而不是通过直接调用彼此来通信,直接调用通常是用于诸如远程过程调用的技术。排队指的是应用程序通过队列来通信。队列的使用除去了接收和发送应用程序同时执行的要求。

Ⅲ. 常见消息队列中间件

  • RabbitMQ、ActiveMQ、RocketMQ、Kafka

    image-20220914121543424

  • RabbitMQ 稳定可靠,数据一致,支持多协议,有消息确认,基于Erlang语言(专门处理高并发的语言)。

  • ActiveMQ 不够灵活轻巧,对队列较多情况支持不好。

  • RocketMQ 性能好,高吞吐,高可用性,支持大规模分布式,协议支持单一。

  • Kafka 高吞吐,高性能,快速持久化,无消息确认,无消息遗漏,可能会有有重复消息,依赖于zookeeper,成本高。

2. 消息队列作用

Ⅰ. 解耦

场景说明:用户下单后,订单系统需要通知库存系统。

  • 传统做法

    ➢ 传统的做法是,订单系统调用库存系统的接口。如下图:

    image-20220914123238427

    ➢ 传统模式的缺点:假如库存系统无法访问,则订单减库存将失败,从而导致订单失败,订单系统与库存系统耦合。

    ➢ 如何解决以上问题呢?

  • 使用消息队列

    ➢ 引入应用消息队列后的方案,如下图:

    image-20220914123301203

    ➢ 订单系统:用户下单后,订单系统完成持久化处理,将消息写入消息队列,返回用户订单下单成功。

    ➢ 库存系统:订阅下单的消息,采用拉/推的方式,获取下单信息,库存系统根据下单信息,进行库存操作。

    ➢ 在下单时库存系统不能正常使用。也不影响正常下单,因为下单后,订单系统写入消息队列就不再关心其他的后续操作了。实现订单系统与库存系统的应用解耦。

Ⅱ. 异步

场景说明:用户注册后,需要发注册邮件和注册短信。

  • 传统做法

    ➢ a.串行:将注册信息写入数据库成功后,发送注册邮件,再发送注册短信。

    image-20220914123337111

    ➢ b.并行:将注册信息写入数据库成功后,发送注册邮件的同时,发送注册短信。

    image-20220914123347784

  • 使用消息队列

    ➢ 将不是必须的业务逻辑,异步处理。改造后的架构如下:

    image-20220914123400239

    这就是RabbitMQ工作模式中的订阅模式(通过交换机广播发送消息到所有队列中)。

Ⅲ. 流量削峰

场景说明:商品秒杀业务,一般会因为流量过大,导致流量暴增,应用挂掉。

  • 传统做法

    ➢ 限制用户数量

  • 使用消息队列

    ➢ 大量的请求不会主动请求秒杀业务,而是存放在消息队列(缓存)。

    ➢ 用户的请求,服务器接收后,首先写入消息队列,秒杀业务根据消息队列中的请求信息,再做后续处理。

    image-20220914123410938

    ➢ 假如消息队列长度超过最大数量,则直接抛弃用户请求或跳转到错误页面。

Ⅳ. 消息通讯

消息通讯是指,消息队列一般都内置了高效的通信机制,因此也可以用在纯的消息通讯。比如实现点对点消息队列,或者聊天室等。

  • 使用消息队列实现点对点通信

    ➢ 客户端A和客户端B使用同一队列,进行消息通讯。

    image-20220914123424953

  • 使用消息队列实现聊天室通信

    ➢ 客户端A,客户端B,客户端N订阅同一主题,进行消息发布和接收。实现类似聊天室效果。

    image-20220914123433480

Ⅴ. 日志处理

日志处理是指将消息队列用在日志处理中,比如Kafka的应用,解决大量日志传输的问题。

  • 使用消息队列完成日志处理

    ➢ 日志采集客户端,负责日志数据采集,定时写受写入Kafka队列。

    ➢ Kafka消息队列,负责日志数据的接收,存储和转发。

    ➢ 日志处理应用:订阅并消费kafka队列中的日志数据。

    image-20220914123450323

3. RabbitMQ介绍

Ⅰ. RabbitMQ概述

  • RabbitMQ是一个在AMQP基础上完成的,可复用的企业消息系统。它遵循Mozilla Public License开源协议。

  • AMQP,即Advanced Message Queuing Protocol,一个提供统一消息服务的应用层标准高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件不同产品,不同的开发语言等条件的限制。Erlang中的实现有RabbitMQ等。

  • 主要特性

    ➢ 保证可靠性:使用一些机制来保证可靠性,如持久化、传输确认、发布确认

    ➢ 灵活的路由功能

    ➢ 可伸缩性:支持消息集群,多台RabbitMQ服务器可以组成一个集群

    ➢ 高可用性:RabbitMQ集群中的某个节点出现问题时队列仍然可用

    ➢ 支持多种协议

    ➢ 支持多语言客户端

    ➢ 提供良好的管理界面

    ➢ 提供跟踪机制:如果消息出现异常,可以通过跟踪机制分析异常原因

    ➢ 提供插件机制:可通过插件进行多方面扩展

Ⅱ. RabbitMQ逻辑结构

RabbitMQ中的逻辑结构,可以类比MySQL:

  • virtual host => database
  • queue => table

image-20220914154724070

4. RabbitMQ安装及配置

基于 CentOS 安装 RabbitMQ 3.7

说明:推荐使用本地安装,减少网络依赖。

Ⅰ. 安装前准备

  • 如果之前安装过erlang,先删除

    yum remove erlang*
    
  • 安装C++编译环境

    # yum -y install make gcc gcc-c++
    yum -y install make gcc gcc-c++ kernel-devel m4 ncurses-devel openssl-devel unixODBC unixODBC-devel httpd python-simplejson
    
  • 下载erlang和rabbitMQ

    # 下载erlang
    wget http://www.erlang.org/download/otp_src_20.1.tar.gz
    
    # 下载rabbitMQ
    wget https://github.com/rabbitmq/rabbitmq-server/releases/download/v3.7.0/rabbitmq-server-generic-unix-3.7.0.tar.xz
    

Ⅱ. 安装erlang

  • 解压erlang安装包

    tar -xvf otp_src_20.1.tar.gz
    
  • 进入解压文件夹

    cd otp_src_20.1
    
  • 指定安装目录及安装配置(需要先安装并配置JDK)

    # erlang指定安装在/usr/local/erlang目录 
    ./configure --prefix=/usr/local/erlang --enable-smp-support --enable-threads --enable-sctp --enable-kernel-poll --enable-hipe --with-ssl --without-javac
    
  • 编译与安装

    make && make install
    
  • 配置erlang环境变量

    vi /etc/profile
    

    ➢ 将 export PATH=$PATH:/usr/local/erlang/bin 添加到文件末尾

    image-20220914134217525

  • 重新加载profile文件

    source /etc/profile
    

Ⅲ. 安装RabbitMQ

  • 解压RabbitMQ安装包

    ➢ 由于下载的安装包为xz文件,先将xz解压为tar

    xz -d rabbitmq-server-generic-unix-3.7.0.tar.xz
    

    image-20220914134230021

    ➢ 再解压缩tar文件

    tar -xvf rabbitmq-server-generic-unix-3.7.0.tar
    
  • 启动RabbitMQ

    ➢ 进入到解压的RabbitMQ的sbin目录

    cd rabbitmq_server-3.7.0/sbin
    

    ➢ 启动

    ./rabbitmq-server -detached
    

    ➢ 查看进程

    ps aux|grep rabbit
    #ps a 显示现行终端机下的所有程序,包括其他用户的程序。
    #ps u   以用户为主的格式来显示程序状况。
    #ps x   显示所有程序,不以终端机来区分。
    

    image-20220914134243541

Ⅳ. 启动管理界面

  • 启动RabbitMQ的管理系统插件(需进入sbin目录)

    ./rabbitmq-plugins enable rabbitmq_management
    

    image-20220914134253004

    RabbitMQ的端口是5672,RabbitMQ管理系统的端口是15672。

    image-20220914152406829

Ⅴ. 放行端口

如果没有网络指令需要先安装:yum install net-tools

  • 查看并放行端口

    netstat -tlnp
    firewall-cmd --add-port=15672/tcp --permanent
    firewall-cmd --add-port=5672/tcp --permanent
    

    image-20220914134302717

  • 也可以直接关闭防火墙

    ➢ CentOS7

    #关闭防火墙 
    systemctl stop firewalld
    #开机禁用 
    systemctl disable firewalld
    #查看状态
    systemctl status firewalld
    

    image-20220914134316542

    ➢ CentOS6

    #1.永久性生效,重启后不会复原
    #开启: 
    chkconfig iptables on
    #关闭: 
    chkconfig iptables off
    
    #2.即时生效,重启后复原
    #开启: 
    service iptables start
    #关闭: 
    service iptables stop
    
    #3.查询TCP连接情况:
    netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
    
    #4.查询端口占用情况:
    netstat   -anp   |   grep  portno(例如:netstat –apn | grep 80)
    
  • 云服务器需要在控制台添加 “安全组设置”

    ➢ 阿里云服务器

    image-20220914134332541

    image-20220914134349756

    ➢ 华为云服务器

    image-20220914134402352

  • 访问页面

    http://192.168.232.102:15672/

    image-20220914155603573

5. RabbitMQ管理

用户管理

Ⅰ. 用户级别

  • 超级管理员administrator:可以登录控制台,查看所有信息,可以对用户和策略进行操作;
  • 监控者monitoring:可以登录控制台,可以查看节点的相关信息,比如进程数,内存磁盘使用情况;
  • 策略制定者policymaker:可以登录控制台,制定策略,但是无法查看节点信息;
  • 普通管理员management:仅能登录控制台;
  • 其他:无法登录控制台,一般指的是提供者和消费者。

Ⅱ. 添加用户(命令模式)

  • 在Linux中使用命令行创建用户

    # 进入到rabbitmy的sbin目录
    cd /usr/local/rabbitmq_server-3.7.0/sbin
    
  • 添加/配置用户

    # rabbitmqctl中的命令
    ./rabbitmqctl add_user ytao admin123
    
  • 设置用户权限

    # 设置admin为administrator级别
    ./rabbitmqctl set_user_tags ytao administrator
    

    image-20220914155304426

Ⅲ. 添加用户(web方式)

  • 浏览器访问:http://47.96.11.185:15672/(使用guest guest 登录,guest 具有最高权限,只能在本机登录;先使用命令行创建一个用户)

    image-20220914160232521

  • 添加用户

    image-20220914161302471

  • 删除用户

    image-20220914161952536

    image-20220914162017491

  • 为用户分配可以访问的虚拟主机

    ➢ 默认情况下没有任何可以访问的,我们可以添加一个主机(相当于添加一个数据库),然后分配权限

    image-20220914160312560

    ➢ 创建虚拟主机

    image-20220914160323336

    ➢ 给指定用户分配虚拟主机

    image-20220914160336879

    image-20220914160357883

    ➢ 设置完成后,回到用户界面确认

    image-20220914160412506

6. RabbitMQ工作模式

RabbitMQ提供了多种消息的通信方式—工作模式

参考文档:http://www.rabbitmq.com/getstarted.html

消息通信是由两个角色完成:

  • 消息生产者(Producer => P)
  • 消息消费者(Comsumer => C)

image-20221211175409074

工作模式就是消息生产者和消息消费者之间是如何通过队列来进行通信的。

Ⅰ. 简单模式(“Hello World”)

简单模式就是我们的生产者将消息发到队列,消费者从队列中取消息,一条消息对应一个消费者。

消费者相当于一个监听器,一直监听着队列,只要生产者把消息发到队列中,那么消费者就会从队列中得到消息。

可以有多个生产者,但一个队列只有一个消费者

image-20220914162441886

Ⅱ. 工作模式(Work queues)

Work模式就是一条消息可以被多个消费者尝试接收,但是最终只能有一个消费者能获取

多个消费者监听一个队列。

image-20220914162453463

image-20220914172934135

Ⅲ. 订阅模式(Publish/Subscribe)

一条消息可以被多个消费者同时获取,生产者将消息发送到交换机,消费者将自己对应的队列注册到交换机,交换机广播发送消息,发送消息后所有注册的队列的消费者都可以收到消息。

多个消息队列,每个消息队列有一个消费者监听。

交换机有四种工作模式:

There are a few exchange types available: direct, topic, headers and fanout. We’ll focus on the last one – the fanout.

  • fanout:扇出(分发交换机,给每个队列都发送消息)

image-20220914162506878

image-20220914162517663

image-20220914171556728

Ⅳ. 路由模式(Routing)

生产者将消息发送到了type为direct模式的交换机,消费者的队列在将自己绑定到路由的时候会给自己绑定一个key,只有消费者发送对应key格式的消息时候队列才会收到消息。

多个消息队列,每个消息队列有一个消费者监听。

We will use a direct exchange instead.

  • direct:直接(指定转发交换机)

image-20220914162539831

image-20220914162552792

image-20220914172657847

Ⅴ. Topics模式

主题模式,通过通配符发给某些队列。

image-20220914162605225

Ⅵ. RPC模式

image-20220914162614582

Ⅶ. 总结

自定义示意图

image-20220914162629208

后两种模式:Topic模式、RPC模式不是本次重点。

7. RabbitMQ交换机和队列管理

  • 创建队列

    image-20221211175516331

    队列参数会影响队列的特性,后续死信队列会讲其中的三个参数。

    Message TTL:消息在队列存活的时间。

  • 创建交换机

    这里如果出现 Access refused 访问被拒绝字样的话,可以给登录的账户赋予权限:

    ./rabbitmqctl set_permissions -p / lyc ".*" ".*" ".*"
    

    image-20220914181730543

  • 交换机绑定队列

    image-20220914182818857

    image-20220914182636260

图示

image-20220914183545085

8. 普通Maven使用RabbitMQ

Ⅰ. 简单模式

一个生产者和一个消费者。

image-20220915150619014

基础配置

  • 创建Maven项目

  • 建两个Module

    producer:生产者

    consumer:消费者

    image-20220914223228025

  • 添加依赖

    <!-- 连接RabbitMQ -->
    <dependency>
        <groupId>com.rabbitmq</groupId>
        <artifactId>amqp-client</artifactId>
        <version>4.10.0</version>
    </dependency>
    <!-- 日志 -->
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-log4j12</artifactId>
        <version>1.7.25</version>
    </dependency>
    <!-- 工具类 -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.9</version>
    </dependency>
    
    <!-- 整合到spring项目需要导入此依赖 -->
    <dependency>
        <groupId>org.springframework.amqp</groupId>
        <artifactId>spring-rabbit</artifactId>
        <version>1.7.6.RELEASE</version>
    </dependency>
    
  • 在resources下创建日志配置文件

    log4j.properties

    log4j.rootLogger=DEBUG,A1
    log4j.logger.com.taotao = DEBUG
    log4j.logger.org.mybatis = DEBUG
    log4j.appender.A1=org.apache.log4j.ConsoleAppender
    log4j.appender.A1.layout=org.apache.log4j.PatternLayout
    log4j.appender.A1.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss,SSS} [%t] [%c]-[%p] %m%n
    
  • 创建MQ连接工具类

    package com.qfedu.utils;
    
    import com.rabbitmq.client.Connection;
    import com.rabbitmq.client.ConnectionFactory;
    
    public class ConnectionUtil {
         public static Connection getConnection() throws Exception {
             // 1.创建连接工厂
             ConnectionFactory factory = new ConnectionFactory();
             // 2.设置MQ的连接信息
             // 2.1 设置服务地址
             factory.setHost("47.96.11.185");
             // 2.2 设置端口
             factory.setPort(5672);
             // 2.3 设置账号信息,vhost、用户名、密码
             factory.setVirtualHost("host1");
             factory.setUsername("lyc");
             factory.setPassword("admin123");
             // 3.通过工厂对象获取连接
             Connection connection = factory.newConnection();
             return connection;
         }
    }
    

    image-20220914195319296

消息生产者

发送消息

producer生产者Module下

image-20220914221235243

package com.lyc.mq.service;

import com.qfedu.utils.ConnectionUtil;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;

public class SendMsg {

    public static void main(String[] argv) throws Exception {
        // 获取到连接以及mq通道
        Connection connection = ConnectionUtil.getConnection(); // 相当于数据库中的创建连接
        // 从连接中创建通道
        Channel channel = connection.createChannel(); // 相当于数据库中的 statement
        // 消息内容
        String message = "Hello World!";
        // 参数1 交换机,如果直接发送信息到队列,则交换机名称为""
        // 参数2 目标队列名称
        // 参数3 设置当前这条消息的属性(如:设置过期时间为10)一般对消息不设置
        // 参数4 消息的内容
        channel.basicPublish("", "queue1", null, message.getBytes());
        System.out.println("Send Message '" + message + "'");
        // 关闭通道和连接
        channel.close();
        connection.close();
    }
}

image-20220914222351779

queue1存储了 1 1 1 条消息:

image-20220914200642286

只要没有消费者 队列中的数据就不会丢失。

使用Java代码创建队列:

// 声明(创建)队列,使用Java代码在MQ中新建一个队列,如果存在就不创建,不存在就创建
// 参数1 创建的队列名称
/*
   参数2 durable:队列中的数据是否持久化, 队列的声明默认是存放到内存中的,如果rabbitmq重启会丢失;
   如果想重启之后还存在就要使队列持久化,保存到Erlang自带的Mnesia数据库中,当rabbitmq重启之后会读取该数据库
   true:做持久化 | false:不做持久化,一般消息是即时消费的。
 */
/*
   参数3 exclusive:是否排外的,有两个作用:
   1.当连接关闭时connection.close()该队列是否会自动删除;(true则代表仅为当前连接所有 close后会删除)
   2.该队列是否是私有的private,如果不是排外的,可以使用两个消费者都访问同一个队列,没有任何问题
                             如果是排外的,会对当前队列加锁,其他通道channel是不能访问的,如果强制访问会报异常:
                             com.rabbitmq.client.ShutdownSignalException: channel error;
                             一般等于true的话 用于一个队列只能有一个消费者来消费的场景。
 */
/*
   参数4 autoDelete:是否自动删除,当最后一个消费者断开连接之后队列是否自动被删除
   可以通过RabbitMQ Management,查看某个队列的消费者数量,当consumers = 0时队列就会自动删除(无论队列中是否还有数据)。
 */
// 参数5 Map<String, Object> arguments:设置队列参数属性
channel.queueDeclare("queue7", false, false, false, null);

image-20220914204414899

消息消费者

接受消息

consumer消费者Module下

image-20220914221258517

package com.lyc.mq.service;

import com.qfedu.utils.ConnectionUtil;
import com.rabbitmq.client.*;

import java.io.IOException;

public class ReceiveMsg {

    public static void main(String[] args) throws Exception {
        // 创建一个新的连接
        Connection connection = ConnectionUtil.getConnection();
        // 创建一个通道
        Channel channel = connection.createChannel();
        
        // DefaultConsumer类实现了Consumer接口,通过传入一个频道,
        // 告诉服务器我们需要那个频道的消息,如果频道中有消息,就会执行回调函数handleDelivery
        Consumer consumer = new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope,
                                       AMQP.BasicProperties properties, byte[] body) // body就是从队列中获取的数据
                    throws IOException {
                String message = new String(body, "UTF-8");
                System.out.println("Customer Received '" + message + "'");
            }
        };
        // 自动回复队列应答 -- RabbitMQ中的消息确认机制
        channel.basicConsume("queue1", true, consumer);
    }
}

image-20220914222833336

queue1中没有数据了:

image-20220914222915950

Ⅱ. 工作模式

一个生产者和多个消费者,但一条消息只能由一个消费者获取。

image-20221211175704216

  • 创建Maven项目

  • 建三个Module

    image-20220915134420575

  • 具体配置跟上面简单模式的一样

  • 发送消息

    producer

    package com.lyc.mq.service;
    
    import com.lyc.mq.utils.ConnectionUtil;
    import com.rabbitmq.client.Channel;
    import com.rabbitmq.client.Connection;
    
    import java.util.Scanner;
    
    public class SendMsg {
    
        public static void main(String[] args) throws Exception {
            System.out.print("请输入消息:");
            Scanner sc = new Scanner(System.in);
            String msg = null;
            while (!(msg = sc.nextLine()).equals("quit")) {
                Connection connection = ConnectionUtil.getConnection();
                Channel channel = connection.createChannel();
    
                channel.basicPublish("", "queue2", null, msg.getBytes());
                System.out.println("Producer Send Message '" + msg + "'");
    
                channel.close();
                connection.close();
            }
        }
    }
    
  • 接受消息

    两个消费者代码没什么区别,只是类名和输出语句不一样,为了区分consumer1/2

    consumer1

    package com.lyc.mq.service;
    
    import com.lyc.mq.utils.ConnectionUtil;
    import com.rabbitmq.client.*;
    
    import java.io.IOException;
    
    public class ReceiveMsg2 {
    
        public static void main(String[] args) throws Exception {
            Connection connection = ConnectionUtil.getConnection();
            Channel channel = connection.createChannel();
            Consumer consumer = new DefaultConsumer(channel) {
                @Override
                public void handleDelivery(String consumerTag, Envelope envelope,
                                           AMQP.BasicProperties properties, byte[] body)
                        throws IOException {
                    String message = new String(body, "UTF-8");
                    System.out.println("Customer1 Received '" + message + "'");
                }
            };
            // 获取queue2的信息
            channel.basicConsume("queue2", true, consumer);
        }
    }
    

    consumer2

    package com.lyc.mq.service;
    
    import com.lyc.mq.utils.ConnectionUtil;
    import com.rabbitmq.client.*;
    
    import java.io.IOException;
    
    public class ReceiveMsg2 {
    
        public static void main(String[] args) throws Exception {
            Connection connection = ConnectionUtil.getConnection();
            Channel channel = connection.createChannel();
            Consumer consumer = new DefaultConsumer(channel) {
                @Override
                public void handleDelivery(String consumerTag, Envelope envelope,
                                           AMQP.BasicProperties properties, byte[] body)
                        throws IOException {
                    String message = new String(body, "UTF-8");
                    System.out.println("Customer2 Received '" + message + "'");
                }
            };
            // 获取queue2的信息
            channel.basicConsume("queue2", true, consumer);
        }
    }
    

测试

  • 先启动consumer1,2,再启动producer,观察是哪个消费者会获取到信息:

    ➢ 此时两个消费者都在等待中:

    image-20220915140631249

    producer发送消息:msg1

    image-20220915140903884

    consumer1抢到了:

    image-20220915140918456

    producer发送消息:msg2

    image-20220915141113049

    consumer2抢到了:

    image-20220915141122734

    此时我们猜测,接受消息是轮询接收吗?我们多发几条数据看看:

    image-20220915141210859

    image-20220915141308932

    RabbitMQ默认是使用轮询来分发消息的。

    如果消息是轮询分发,如果每个消费者执行消费代码的时长不同,处理速度不同,那么很容易造成执行速度快的消费者会闲置,而执行比较慢的消费者会一直处在高消耗的状态。这样显然是不合理的,为了解决这个问题,我们可以采用不公平分发参考1参考2

    原则上来讲多个消费者轮流消费消息,因为这些消费者完成的都是同一功能,做的事是一样的,所以我们才需要工作模式。

Ⅲ. 订阅模式

一个生产者和多个消费者,一个条消息可以被多个消费者获取。

image-20220915150908570

  • 改造工作模式的案例

    consumer1监听queue3

    channel.basicConsume("queue3", true, consumer);
    

    consumer2监听queue4

    channel.basicConsume("queue4", true, consumer);
    

    ➢ 发送者要把消息发送到**交换机ex1**中:

    // 参数1填上交换机名ex1 参数2去掉队列名改为null
    // 当参数1不为null,且参数2为null,则说明这是订阅模式。
    channel.basicPublish("ex1", "", null, msg.getBytes());
    
  • 启动消费者

  • 启动生产者

  • 发送消息:hello world

    534543

    两个消费者都接收到消息了。

Ⅳ. 路由模式

一个生产者和多个消费者,发消息给交换机时需要一个key,通过key指定某个队列接受消息。

image-20220915150943506

  • 改造工作模式的案例

    consumer1监听queue5

    channel.basicConsume("queue5", true, consumer);
    

    consumer2监听queue6

    channel.basicConsume("queue6", true, consumer);
    

    ➢ 发送者要把消息发送到交换机ex2中,并指定一个key

    // 参数1填上交换机名ex2 参数2添加值"a" 如果第1个参数(交换机的名字)为null 那么第2个参数代表队列 反之则代表key
    // 当参数1和参数2都不为null,则说明这是路由模式。
    if (msg.startsWith("a")) {
        channel.basicPublish("ex2", "a", null, msg.getBytes());
    } else if (msg.startsWith("b")) {
        channel.basicPublish("ex2", "b", null, msg.getBytes());
    }
    
  • 启动消费者

  • 启动生产者

  • 发送消息

    43634

    两个消费者分别按照指定的key逻辑接收消息。

代码架构整体图

image-20220915150229750

9. SpringBoot整合RabbitMQ

SpringBoot应用可以完成自动配置及依赖注入——可以通过Spring直接提供与MQ的连接对象。

Ⅰ. 消息发送者

  • 创建SpringBoot项目"mq-send-demo",使用Spring Initializr快速启动,导入以下依赖:

    ➢ Lombok

    ➢ Spring Web

    ➢ Thymeleaf

    ➢ Spring for RabbitMQ
    image-20220915160238155

  • 如果是在项目中新增RabbitMQ支持导入以下依赖:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    
    <dependency>
        <groupId>org.springframework.amqp</groupId>
        <artifactId>spring-rabbit-test</artifactId>
        <scope>test</scope>
    </dependency>
    
  • application.yml配置RabbitMQ服务器连接属性

    server:
      port: 9001
    
    spring:
      application:
        name: producer
      rabbitmq:
        host: 192.168.232.102
        port: 5672
        username: lyc
        password: admin123
        virtual-host: host1
    
  • 配置RabbitMQ创建队列(Quence)

    这里没用到这个,使用的是管理系统创建的队列。

    import org.springframework.amqp.core.*;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class RabbitMQConfiguration {
    
        @Bean
        public Queue queue() {
            return new Queue("wfx-quence");
        }
    
        @Bean
        public Queue fanoutQuence() {
            return new Queue("wfx-fanout-quence");
        }
        /**
         * 声明交换机,fanout 类型
         */
        @Bean
        public FanoutExchange fanoutExchange() {
            FanoutExchange fanoutExchange = new FanoutExchange("fanoutExchange");
            return fanoutExchange;
        }
        /**
         * 将队列和交换机绑定
         */
        @Bean
        public Binding bindingFanoutExchange(Queue fanoutQuence, FanoutExchange fanoutExchange) {
            return BindingBuilder.bind(fanoutQuence).to(fanoutExchange);
        }
    
    
        @Bean
        public Queue directQuence1() {
            return new Queue("wfx-direct-quence1");
        }
        @Bean
        public Queue directQuence2() {
            return new Queue("wfx-direct-quence2");
        }
        /**
         * 声明交换机,direct 类型
         */
        @Bean
        public DirectExchange directExchange() {
            DirectExchange directExchange = new DirectExchange("directExchange");
            return directExchange;
        }
        /**
         * 将队列和交换机绑定
         */
        @Bean
        public Binding bindingDirectExchange(Queue directQuence1, DirectExchange directExchange) {
           return BindingBuilder.bind(directQuence1).to(directExchange).with("rk1");
        }
    
        @Bean
        public Binding bindingDirectExchange2(Queue directQuence2, DirectExchange directExchange) {
            return BindingBuilder.bind(directQuence2).to(directExchange).with("rk2");
        }
    }
    
  • 在消息发送者中注入AmqpTemplate对象即可发送消息

    @Service
    public class SendMsgService {
    
        @Autowired
        private AmqpTemplate amqpTemplate;
    
        public void sendMsg(String message){
            // 1.发送消息到队列
            amqpTemplate.convertAndSend("queue1", message);
    
            // 2.将消息发送到交换机(订阅交换机)
            //amqpTemplate.convertAndSend("fanout", "", message);
    
            // 3.将消息发送到交换机(路由交换机)
            //amqpTemplate.convertAndSend("direct", "rk1", message);
        }
    }
    
  • 实操案例

    Service

    package com.lyc.producer.service;
    
    import org.springframework.amqp.core.AmqpTemplate;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    
    @Service
    public class TestService {
    
        @Resource
        private AmqpTemplate amqpTemplate;
    
        public void sendMsg(String msg) {
            if (msg.startsWith("q_")) {
                // 1.发送消息到队列
                amqpTemplate.convertAndSend("queue1", msg);
            } else if (msg.startsWith("f_")) {
                // 2.将消息发送到交换机(订阅交换机)
                amqpTemplate.convertAndSend("ex1", "", msg);
            } else if (msg.startsWith("r_")) {
                // 3.将消息发送到交换机(路由交换机)
                if (msg.startsWith("r_a")) {
                    amqpTemplate.convertAndSend("ex2", "a", msg);
                } else if (msg.startsWith("r_b")) {
                    amqpTemplate.convertAndSend("ex2", "b", msg);
                }
            }
        }
    }
    

    Controller

    package com.lyc.producer.controller;
    
    import com.lyc.producer.service.TestService;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    
    @RestController
    public class TestController {
    
        @Resource
        TestService testService;
    
        @RequestMapping("test")
        public String test(String msg) {
            testService.sendMsg(msg);
            return "success";
        }
    }
    
  • 测试

    ➢ 发送消息:q_helloworld(简单模式)

    image-20220915170240886

    此时应该只有queue1收到消息:

    image-20220915170227472

    ➢ 发送消息:f_helloworld(订阅模式)

    image-20220915170301684

    此时queue3、4都应该收到消息:

    image-20220915170321301

    ➢ 发送消息:r_a_helloworld(路由模式)

    image-20220915170402442

    此时queue5应该收到消息:

    image-20220915170453534

    ➢ 发送消息:r_b_helloworld(路由模式)

    image-20220915170607296

    此时queue6应该收到消息:

    image-20220915170634286

Ⅱ. 消息接收者

  • 配置跟发送者完全一致

    记得把application.yml的端口号改成9002。

  • 监听队列

    @RabbitListener => 标记在类上,监听哪个队列(一般是一个service对应一个队列)

    @RabbitHandler => 标记在方法上,接到的数据传到这个方法中

    @Service
    @RabbitListener(queues = "queue1")
    public class ReceiveMsgService {
    
        @RabbitHandler // 标记当前方法是用来处理消息的
        public void receiveMsg(String message) { // 接受的参数要跟发过来的类型进行匹配 可以是byte[]、Object(转成json字符串)等。
            System.out.println("收到queue1消息: =>" + message);
        }
    }
    

    因为之前queue1中有 1 1 1 条消息,所以consumer启动直接消费:

    image-20220915182207668

    此时会监听所有从queue1中发过来的消息:

    image-20220915182530189

订阅模式与路由模式配置可参考:Spring cloud 使用 RabbitMq 04 发布与订阅 (路由模式)

@RabbitListener@Queue的注解参数参考:RabbitMq Queue/RabbitListener注解参数详解

10. 使用RabbitMQ传递对象

  • 本周就是将对象转换成字符串进行传递

Ⅰ. 对象序列化实现

要求

  • bean实现序列化接口
  • 生产者消费者的bean包名、类名、属性名必须一致
  • 消息生产者

    @Service
    public class SendMsgService {
    
        @Autowired
        private AmqpTemplate amqpTemplate;
    
        public void sendMsg(Goods goods) {
            // 消息队列可以发送:字符串(String)、字节数组(byte[])、序列化对象(Serializable)
            amqpTemplate.convertAndSend("queue1", goods);
        }
    }
    
  • 消息消费者

    @Service
    @RabbitListener(queues = "queue1")
    public class ReviceMsgService {
    
        @RabbitHandler
        public void receiveMsg(Goods goods) { // 传的是什么 接收的参数就是什么(String、byte[]、序列化对象)
            System.out.println(goods);
        }
    }
    

Ⅱ. 序列化字节数组实现

本质也是序列化。

  • 上面是通过convertAndSend()方法转换
  • 这个是手动转换
  • 消息生产者

    @Service
    public class SendMsgService {
    
        @Autowired
        private AmqpTemplate amqpTemplate;
    
        public void sendMsg(Goods goods) {
            byte[] bytes = SerializationUtils.serialize(goods);
            // 消息队列可以发送:字符串(String)、字节数组(byte[])、序列化对象(Serializable)
            amqpTemplate.convertAndSend("queue1", bytes);
        }
    }
    
  • 消息消费者

    @Service
    @RabbitListener(queues = "queue1")
    public class ReviceMsgService {
    
        @RabbitHandler
        public void receiveMsg(byte[] bs) { // 传的是什么 接收的参数就是什么(String、byte[]、序列化对象)
            Goods goods = (Goods) SerializationUtils.deserialize(bs);
            System.out.println(goods);
        }
    }
    

Ⅲ. JSON字符串实现

要求

  • 对象的属性名一致
  • 消息生产者

    @Service
    public class SendMsgService {
    
        @Autowired
        private AmqpTemplate amqpTemplate;
    
        public void sendMsg(Goods goods) throws JsonProcessingException {
            ObjectMapper mapper = new ObjectMapper();
            String message = mapper.writeValueAsString(goods);
            amqpTemplate.convertAndSend("queue1", message);
        }
    }
    
  • 消息消费者

    @Service
    @RabbitListener(queues = "queue1")
    public class ReviceMsgService {
    
        @RabbitHandler
        public void receiveMsg(String msg) throws JsonProcessingException {
            ObjectMapper mapper = new ObjectMapper();
            Goods goods = mapper.readValue(msg, Goods.class);
            System.out.println(goods);
        }
    }
    

11. 基于Java的交换机与队列创建

我们使用消息队列,消息队列和交换机可以通过管理系统完成创建,也可以在应用程序中通过Java代码来完成创建。

Ⅰ. 普通Maven项目

  • 队列的创建

    // 1. 声明队列-queue1
    // 参数1:queue - 指定队列的名称
    // 参数2:durable - 当前队列是否需要持久化(true)
    // 参数3:exclusive - 是否排外(conn.close() - 当前队列会被自动删除,当前队列只能被一个消费者消费)
    // 参数4:autoDelete - 如果这个队列没有消费者在消费,队列自动删除
    // 参数5:arguments - 指定当前队列的其他信息
    channel.queueDeclare("queue1", true, false, false, null);
    
  • 交换机的创建

    // 2. 创建exchange
    // 参数1:exchange的名称
    // 参数2:指定exchange的类型  FANOUT - pub/sub , DIRECT - Routing , TOPIC - Topics
    channel.exchangeDeclare("ex3", BuiltinExchangeType.FANOUT);
    channel.exchangeDeclare("ex4", BuiltinExchangeType.DIRECT);
    
  • 交换机绑定队列

    // 3.交换机绑定队列
    // 参数1:队列的名称
    // 参数2:交换机的名称
    // 参数3:如果为""则说明是订阅模式,有值(key)则说明是路由模式
    channel.queueBind("pubsub-queue1", "ex3", "");
    channel.queueBind("pubsub-queue2", "ex4", "a");
    

Ⅱ. SpringBoot配置

  • 配置RabbitMQ创建队列(Quence)

    import org.springframework.amqp.core.*;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class RabbitMQConfiguration {
    
        /**
         * 声明队列
         */
        @Bean
        public Queue queue() {
            Queue queue = new Queue("wfx-quence");
            // 设置队列属性
            // queue.xx()
            return queue;
        }
    
        @Bean
        public Queue fanoutQuence() {
            return new Queue("wfx-fanout-quence");
        }
        /**
         * 声明交换机,fanout(订阅)类型
         */
        @Bean
        public FanoutExchange fanoutExchange() {
            FanoutExchange fanoutExchange = new FanoutExchange("fanoutExchange");
            return fanoutExchange;
        }
        /**
         * 将队列和交换机绑定
         * 参数中的队列和交换机可以通过创建的方法名进行标识
         */
        @Bean
        public Binding bindingFanoutExchange(Queue fanoutQuence, FanoutExchange fanoutExchange) {
            return BindingBuilder.bind(fanoutQuence).to(fanoutExchange);
        }
    
        @Bean
        public Queue directQuence1() {
            return new Queue("wfx-direct-quence1");
        }
        @Bean
        public Queue directQuence2() {
            return new Queue("wfx-direct-quence2");
        }
        /**
         * 声明交换机,direct(路由)类型
         */
        @Bean
        public DirectExchange directExchange() {
            DirectExchange directExchange = new DirectExchange("directExchange");
            return directExchange;
        }
        /**
         * 将队列和交换机绑定
         * 参数中的队列和交换机可以通过创建的方法名进行标识
         */
        @Bean
        public Binding bindingDirectExchange(Queue directQuence1, DirectExchange directExchange) {
            return BindingBuilder.bind(directQuence1).to(directExchange).with("rk1"); // .with():key
        }
    
        @Bean
        public Binding bindingDirectExchange2(Queue directQuence2, DirectExchange directExchange) {
            return BindingBuilder.bind(directQuence2).to(directExchange).with("rk2"); // .with():key
        }
    }
    

一般不使用临时Java代码创建队列,大部分直接使用管理系统创建队列。

12. RabbitMQ的消息可靠性

消息生产者和消息消费者并不是直接通信,而是通过RabbitMQ进行交互,但我们要确保消息生产者发送的消息 能够被消息消费者收到。

所以我们要保证中间件RabbitMQ是可靠的,也就是我们要保证队列的可靠,怎么保证?

  • 确保消息生产者的消息发送到了RabbitMQ
  • 确保RabbitMQ把消息发送到了消息消费者

RabbitMQ提供了两种保障机制,保障消息在RabbitMQ的安全性:

  • 事务
  • 消息确认和return机制

Ⅰ. RabbitMQ事务

事务几乎不用。

RabbitMQ事务非常消耗资源,添加了事务之后,消息处理的效率会降级几十倍甚至上百倍。

// 1.开启事务
channel.txSelect();

// 2.提交事务
channel.txCommit();

// 3.事务回滚
channel.txRollback();

在代码中使用

channel.txSelect(); // 开启事务
try {
    channel.basicPublish("", "queue1", null, msg.getBytes());
    channel.txCommit(); // 提交事务
} catch (Exception e) {
    channel.txRollback(); // 回滚事务
}

Ⅱ. 消息确认和return机制

因为RabbitMQ的事务效率太低,所以RabbitMQ又提供了第二种保障机制:消息确认和return机制

  • 消息确认机制:确认消息生产者是否成功发送消息到交换机
  • return机制:确认消息是否成功的从交换机分发到队列

image-20220916142231951

消息确认如果失败,就不会进行return校验;如果消息确认成功,return机制可能成功也有可能失败。

1️⃣ 普通Maven项目的消息确认和return机制
  • 普通confirm方式

    Connection connection = ConnectionUtil.getConnection();
    Channel channel = connection.createChannel();
    String message = "Hello World!";
    
    // 1.发送消息之前开启消息确认
    channel.confirmSelect();
    // 2.发送消息
    channel.basicPublish("ex2", "c", null, message.getBytes());
    // 3.获取确认
    boolean b = channel.waitForConfirms();
    System.out.println("消息发送" + (b ? "成功" : "失败"));
    
    channel.close();
    connection.close();
    
  • 批量confirm方式

    Connection connection = ConnectionUtil.getConnection();
    Channel channel = connection.createChannel();
    String message = "Hello World!";
    
    // 1.开启消息确认
    channel.confirmSelect();
    // 2.发送消息
    for (int i = 1; i <= 10; i++) {
        message += i;
        channel.basicPublish("ex2", "c", null, message.getBytes());
    }
    // 3.批量确认:发送的所有消息中有一个失败就直接全部失败,抛出IO异常
    boolean b = channel.waitForConfirms();
    
    channel.close();
    connection.close();
    
  • 异步confirm方式

    假如上述发消息需要10s,底下的代码waitForConfirms()会进入阻塞状态,不会往下执行;

    但是在某些应用场景 我并不想让它等待,消息发送后继续往下执行,你给我的确认结果晚一点无所谓。

    Connection connection = ConnectionUtil.getConnection();
    Channel channel = connection.createChannel();
    String message = "Hello World!";
    
    // 1.开启消息确认
    channel.confirmSelect();
    // 2.发送消息
    for (int i = 1; i <= 10; i++) {
        message += i;
        channel.basicPublish("ex2", "c", null, message.getBytes());
    }
    
    // 会阻塞
    //boolean b = channel.waitForConfirms();
    
    // 3.开启异步confirm 监听器 相当于启动了另外一个线程专门用于等待消息确认结果(ack) 可以放到发送消息之前
    channel.addConfirmListener(new ConfirmListener() {
        // 参数1:Long l 表示返回的消息标识
        // 参数2:boolean b 表示是否为批量confirm
        public void handleAck(long l, boolean b) throws IOException {
            System.out.println("----消息发送成功");
        }
        public void handleNack(long l, boolean b) throws IOException {
            System.out.println("----消息发送失败");
        }
    });
    
    // 异步confirm就不能关闭连接和通道了 因为代码会继续往下执行
    //channel.close();
    //connection.close();
    
  • return机制

    ➢ 发送消息之前开启return机制

    ➢ 发送消息时指定mandatory参数为true

    ➢ 由于return机制是异步处理,所以在发送消息之后不关闭channel

    Connection connection = ConnectionUtil.getConnection();
    Channel channel = connection.createChannel();
    String message = "Hello World!";
    
    // return机制:监控交换机是否将消息分发到队列
    channel.addReturnListener(new ReturnListener() {
        // 如果交换机分发消息到队列失败 则会执行此方法(用来处理交换机分发消息到队列失败的情况)
        // 参数3:String s1:目标交换机的名称
        // 参数4:String s2:指定队列的key
        public void handleReturn(int i, String s, String s1, String s2, AMQP.BasicProperties basicProperties,
                                 byte[] bytes) throws IOException {
            System.out.println("消息未分发到队列中");
        }
    });
    
    //channel.basicPublish("", "queue1", null, message.getBytes());
    // 因为key="c"不存在 所以执行handleReturn() 参数mandatory要设置为true(开启return机制的业务处理)
    // 如果有这个true,则监听消息确认和return机制 如果没有,则只监听消息确认机制
    channel.basicPublish("ex2", "c", true, null, message.getBytes());
    
    // 同样也不能close()。因为return是异步执行的
    //channel.close();
    //connection.close();
    
  • addConfirmListener()用来监听生产者是否成功发送消息到交换机
  • addReturnListener()用来监听交换机是否成功分发消息到队列
2️⃣ SpringBoot应用消息确认和return机制
  • 配置application.yml

    开启消息确认和return机制(其他配置省略)

    spring:
      rabbitmq:
        publisher-confirm-type: simple # 开启消息确认机制
        publisher-returns: true        # 开启return机制
    
  • 开启confirm和return监听

    实现两个内部类的接口:RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback

    image-20220916153757839

    RabbitTemplateAmqpTemplate接口的实现类。

    package com.qfedu.mq_producer.utils;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.amqp.core.Message;
    import org.springframework.amqp.rabbit.connection.CorrelationData;
    import org.springframework.amqp.rabbit.core.RabbitTemplate;
    import org.springframework.stereotype.Component;
    
    import javax.annotation.PostConstruct;
    import javax.annotation.Resource;
    
    @Component
    public class PublisherConfireAndReturnConfig implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {
    
        Logger logger = LoggerFactory.getLogger(PublisherConfireAndReturnConfig.class);
    
        @Resource
        private RabbitTemplate rabbitTemplate; // Spring提供的即时消息模板
    
        // 声明当前自己这个类 就是RabbitMQ的消息确认机制类以及return机制类
        @PostConstruct
        public void initMethod(){
            rabbitTemplate.setConfirmCallback(this);
            rabbitTemplate.setReturnCallback(this);
        }
    
        // 此方法用于监听消息确认结果(消息是否发送到了交换机)
        @Override
        public void confirm(CorrelationData correlationData, boolean ack, String s) {
            if (ack) {
                logger.info("--------消息发送(到交换机)成功");
            } else {
                logger.warn("--------消息发送(到交换机)失败");
            }
        }
    
        // 此方法用于return监听(当交换机分发消息到队列失败时执行)
        @Override
        public void returnedMessage(Message message, int i, String s, String s1, String s2) {
            logger.info("~~~~~~~~消息发送到交换机但未分发到队列!!!");
        }
    }
    

Ⅲ. 避免消息重复消费

重复消费消息,会对非幂等行操作造成问题。
重复消费消息的原因是,消费者没有给RabbitMQ一个ack。

为了解决消息重复消费的问题,可以采用Redis,在消费者消费消息之前,现将消息的id放到Redis中,

id-0(正在执行业务)

id-1(执行业务成功)

如果ack失败,在RabbitMQ将消息交给其他的消费者时,先执行setnx,如果key已经存在,获取它的值,如果是0,当前消费者就什么都不做,如果是1,直接ack。

极端情况:第一个消费者在执行业务时,出现了死锁,在setnx的基础上,再给key设置一个生存时间。

1️⃣ 普通Maven项目避免重复消费
  • 生产者,发送消息时,指定messageId

    AMQP.BasicProperties properties = new AMQP.BasicProperties().builder()
        .deliveryMode(1)     // 指定消息书否需要持久化 1 - 需要持久化  2 - 不需要持久化
        .messageId(UUID.randomUUID().toString())
        .build();
    String msg = "Hello-World!";
    channel.basicPublish("", "HelloWorld", true, properties, msg.getBytes());
    
  • 消费者,在消费消息时,根据具体业务逻辑去操作redis

    DefaultConsumer consume = new DefaultConsumer(channel) {
        @Override
        public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
            Jedis jedis = new Jedis("192.168.199.109",6379);
            String messageId = properties.getMessageId();
            // 1.setnx到Redis中,默认指定value-0
            String result = jedis.set(messageId, "0", "NX", "EX", 10);
            if (result != null && result.equalsIgnoreCase("OK")) {
                System.out.println("接收到消息:" + new String(body, "UTF-8"));
                // 2.消费成功,set messageId 1
                jedis.set(messageId,"1");
                channel.basicAck(envelope.getDeliveryTag(), false);
            } else {
                // 3.如果1中的setnx失败,获取key对应的value,如果是0,return,如果是1
                String s = jedis.get(messageId);
                if ("1".equalsIgnoreCase(s)) {
                    channel.basicAck(envelope.getDeliveryTag(), false);
                }
            }
        }
    };
    
2️⃣ SpringBoot应用避免重复消费
  • 导入依赖

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
  • 编写配置文件

    spring:
      redis:
        host: 47.96.11.185
        port: 6379
    
  • 修改生产者

    @Test
    void contextLoads() throws IOException {
        CorrelationData messageId = new CorrelationData(UUID.randomUUID().toString());
        rabbitTemplate.convertAndSend("boot-topic-exchange", "slow.red.dog", "红色大狼狗!!", messageId);
        System.in.read();
    }
    
  • 修改消费者

    @Autowired
    private StringRedisTemplate redisTemplate;
    
    
    @RabbitListener(queues = "boot-queue")
    public void getMessage(String msg, Channel channel, Message message) throws IOException {
        // 0.获取MessageId
        String messageId = message.getMessageProperties().getHeader("spring_returned_message_correlation");
        // 1.设置key到Redis
        if (redisTemplate.opsForValue().setIfAbsent(messageId, "0", 10, TimeUnit.SECONDS)) {
            // 2.消费消息
            System.out.println("接收到消息:" + msg);
            // 3.设置key的value为1
            redisTemplate.opsForValue().set(messageId,"1", 10, TimeUnit.SECONDS);
            // 4.手动ack
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } else {
            // 5.获取Redis中的value即可 如果是1,手动ack
            if ("1".equalsIgnoreCase(redisTemplate.opsForValue().get(messageId))){
                channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            }
        }
    }
    

13. RabbitMQ延迟机制(TTL:死信队列)

假设当前有一个场景,A服务负责订单的保存,B服务负责检查订单的支付状态;

如下图所示:

image-20220916162038016

B服务需要承担的任务太重了,需要不停的检查订单状态,所以我们可以考虑 让A给B传递信息,使B在30分钟后再来查询支付状态。

此时我们想到使用RabbitMQ来实现服务的交互,但消息的传递是即时的,假如A保存完订单,会直接给B发送消息,此时顾客可能还未支付订单,B就检查订单支付状态为 “未支付”,直接把订单取消了,这是肯定不可以的;

现在我们的目标是:A保存完订单后 立马把订单发送到消息队列中,让消息在队列中停留30分钟,30分钟之后B服务才可以接收到消息,再进行检查:

  • 延迟队列

image-20221211175040788

Ⅰ. 延迟队列

  • 什么是延迟队列

    延迟队列存储的对象肯定是对应的延时消息,所谓”延时消息”是指当消息被发送以后,并不想让消费者立即拿到消息,而是等待指定时间后,消费者才拿到这个消息进行消费。

  • RabbitMQ如何实现延迟队列?

    AMQP协议和RabbitMQ队列本身没有直接支持延迟队列功能,但是可以通过TTL(Time To Live)特性模拟出延迟队列的功能。

  • 消息的TTL(Time To Live)

    消息的TTL就是消息的存活时间。RabbitMQ可以对队列和消息分别设置TTL。对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置。超过了这个时间,我们认为这个消息就死了,称之为死信。可以通过设置消息的expiration字段或者x-message-ttl属性来设置时间。

    ➢ 在创建队列的时候可以设置队列的存活时间,当消息进入到队列并且在存活时间内没有消费者消费,则此消息就会从当前队列被移除;

    image-20220916173244373

    ➢ 创建消息队列没有设置TTL,但是消息设置了TTL,那么当消息的存活时间结束,也会被移除;

    image-20220916173424800

    但是像上图,消息 1 1 1 的超时时间为 10 s 10s 10s,消息 3 3 3 的超时时间为 5 s 5s 5s,这可能会使得消息 3 3 3 的设置失效,因为消息队列也是队列,也必须遵守队列的先进先出的原则,所以队列的消息也会依次消费消息。所以延迟时间的设置一般建议是相等的,或者前小后大也可以。

    当队列设置了超时时间,消息也设置了超时时间,一般以短的为准,谁短谁就生效。

  • 实现延迟队列

    延迟任务通过消息的TTL来实现。我们需要建立2个队列,一个用于发送消息,一个用于消息过期后的转发目标队列。

    image-20220916163122524

    生产者输出消息到Queue1,并且这个消息是设置有有效时间的,比如60s。消息会在Queue1中等待60s,如果没有消费者收掉的话,它就是被转发到Queue2,Queue2有消费者,收到处理延迟任务。

    没有消费者的队列,而且设置了超时时间,这个队列就称为**死信队列**。

    A服务把消息发送到queue1,但此时的queue1没有消费者 且设置了TTL,所以queue1为死信队列,过了TTL这个时间,就会把消息过期转发到queue2中,而queue2有消费者 B服务,可以即时消费,实现了延迟队列

    image-20220916184653911

Ⅱ. 创建延迟交换机

  • 创建路由交换机

    image-20220916163154383

  • 创建死信队列

    image-20220916163203057

  • 创建死信转发队列

    image-20220916163212825

  • 交换机队列绑定

    image-20220916163219800

  • 测试

    ➢ 消息生产者

    消息应该发送到死信队列。

    channel.basicPublish("delay_exchange", "k1", null, msg.getBytes());
    

    ➢ 消息消费者

    由另外一个队列接受消息。

    channel.basicConsume("delay_queue2", true, consumer);
    

    发送消息,通过控制台的日志可以看到是 10 s 10s 10s 后才收到消息:

    image-20220916191856805

Ⅲ. SpringBoot实现延迟队列

  • 添加MQ依赖

    <dependency>
    	<groupId>org.springframework.boot</groupId>
    	<artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.amqp</groupId>
        <artifactId>spring-rabbit-test</artifactId>
        <scope>test</scope>
    </dependency>
    
  • 在application.yml配置RabbitMQ服务器连接属性

    spring:
      application:
        name: mq-sender-demo
      rabbitmq:
        host: 47.96.11.185
        port: 5672
        username: ytao
        password: admin123
        virtual-host: wfx_host
        # 手动ACK 不开启自动ACK模式,目的是防止报错后未正确处理消息丢失 默认 为 none
        listener:
          simple:
            acknowledge-mode: manual
    
  • 生产者

    @Component
    @Slf4j
    public class RabbitProduct{
    
        @Autowired
        private RabbitTemplate rabbitTemplate;
        
        public void sendDelayMessage(List<Integer> list) {
            // 这里的消息可以是任意对象,无需额外配置,直接传即可
            log.info("===============延时队列生产消息====================");
            log.info("发送时间:{},发送内容:{}", LocalDateTime.now(), list.toString());
            this.rabbitTemplate.convertAndSend(
                    "delay_exchange",
                    "delay_key",
                    list,
                    message -> {
                        // 注意这里时间要是字符串形式
                        message.getMessageProperties().setExpiration("60000");
                        return message;
                    }
            );
            log.info("{}ms后执行", 60000);
        }
    }
    
  • 消费者

    @Component
    @Slf4j
    public class RabbitConsumer {
        @Autowired
        private CcqCustomerCfgService ccqCustomerCfgService;
    
        /**
         * 默认情况下,如果没有配置手动ACK, 那么 Spring Data AMQP 会在消息消费完毕后自动帮我们去ACK
         * 存在问题:如果报错了,消息不会丢失,但是会无限循环消费,一直报错,如果开启了错误日志很容易就把磁盘空间耗完
         * 解决方案:手动ACK,或者try-catch 然后在 catch 里面将错误的消息转移到其它的系列中去
         * spring.rabbitmq.listener.simple.acknowledge-mode = manual
         * @param list 监听的内容
         */
        @RabbitListener(queues = "receive_queue")
        public void cfgUserReceiveDealy(List<Integer> list, Message message, Channel channel) throws IOException {
            log.info("===============接收队列接收消息====================");
            log.info("接收时间:{},接受内容:{}", LocalDateTime.now(), list.toString());
            // 通知 MQ 消息已被接收,可以ACK(从队列中删除)了
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            try {
                // dosomething...
            } catch (Exception e) {
                log.error("============消费失败,尝试消息补发再次消费!==============");
                log.error(e.getMessage());
                /**
                 * basicRecover方法是进行补发操作,
                 * 其中的参数如果为true是把消息退回到queue但是有可能被其它的consumer(集群)接收到,
                 * 设置为false是只补发给当前的consumer
                 */
                channel.basicRecover(false);
            }
        }
    }
    
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐