RabbitMQ硅谷笔记


文章目录

RabbitMQ

代码仓库:https://github.com/GhostQinMo/RabbitMQ_practice/tree/master

MQ的相关概念

什么是MQ

​ MQ(message queue),从字面意思上看,本质是个队列,FIFO先入先出,只不过队列中存放的内容是message而已,还是一种跨进程的通信机制,用于上下游传递消息。在互联网架构中,MQ是一种非常常见的上下游**“逻辑解耦+物理解耦”**的消息通信服务。使用了MQ之后,消息发送上游只需要依赖MQ,不用依赖其他服务。

为什么用MQ

  1. 流量消峰

​ 举个例子,如果订单系统最多能处理一万次订单,这个处理能力应付正常时段的下单时绰绰有余,正常时段我们下单一秒后就能返回结果。但是在高峰期,如果有两万次下单操作系统是处理不了的,只能限制订单超过一万后不允许用户下单。使用消息队列做缓冲,我们可以取消这个限制,把一秒内下的订单分散成一段时间来处理,这时有些用户可能在下单十几秒后才能收到下单成功的操作,但是比不能下单的体验要好。

  1. 应用解耦

​ 以电商应用为例,应用中有订单系统、库存系统、物流系统、支付系统。用户创建订单后,如果耦合调用库存系统、物流系统、支付系统,任何一个子系统出了故障,都会造成下单操作异常。当转变成基于消息队列的方式后,系统间调用的问题会减少很多,比如物流系统因为发生故障,需要几分钟来修复。在这几分钟的时间里,物流系统要处理的内存被缓存在消息队列中,用户的下单操作可以正常完成。当物流系统恢复后,继续处理订单信息即可,中单用户感受不到物流系统的故障,提升系统的可用性。如果一个子系统出现故障,消息队列监督恢复故障的子系统继续完成业务处理

image-20230327094900451

  1. 异步处理

​ 有些服务间调用是异步的,例如A调用B,B需要花费很长时间执行,但是A需要知道B什么时候可以执行完,以前一般有两种方式,A过一段时间去调用B的查询api查询。或者A提供一个calback api,B执行完之后调用api通知A服务。这两种方式都不是很优雅,使用消息总线,可以很方便解决这个问题,A调用B服务后,只需要监听B处理完成的消息,当B处理完成后,会发送一条消息给MQ,MQ会将此消息转发给A服务。这样A服务既不用循环调用B的查询api,也不用提供callback api。同样B服务也不用做这些操作。A服务还能及时的得到异步处理成功的消息。

​ [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xt1zEvmy-1680343586610)(null)]

MQ的分类

  • ActiveMQ

    • 优点:单机吞吐量万级,时效性 ms 级,可用性高,基于主从架构实现高可用性,消息可靠性较低的概率丢失数据
    • 缺点: 官方社区现在对 ActiveMQ 5.x 维护越来越少, 高吞吐量场景较少使用。
  • Kafka:大数据的杀手锏,谈到大数据领域内的消息传输,则绕不开 Kafka,这款为大数据而生的消息中间件,以其百万级TPS的吞吐量名声大噪,迅速成为大数据领域的宠儿,在数据采集、传输、存储的过程中发挥着举足轻重的作用。目前已经被LinkedIn,Uber,Twitter,Netflix等大公司所采纳。(Scale语言编写)

    • 优点:性能卓越,单机写入TPS约在百万条/秒,最大的优点,就是吞吐量高。时效性ms级可用性非常高,kafka是分布式的,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用,消费者采用Pull方式获取消息,消息有序,通过控制能够保证所有消息被消费且仅被消费一次;有优秀的第三方Kafka Web管理界面Kafka-Manager;在日志领域比较成熟,被多家公司和多个开源项目使用;功能支持:功能较为简单,主要支持简单的MQ功能,在大数据领域的实时计算以及日志采集被大规模使用
    • 缺点: Kafka单机超过64个队列/分区,Load 会发生明显的飙高现象(这里指CPU飙高),队列越多,load越高,发送消息响应时间变长,使用短轮询方式,实时性取决于轮询间隔时间,消费失败不支持重试;支持消息顺序,但是一台代理宕机后,就会产生消息乱序,社区更新较慢;
  • RocketMQ:RocketMQ 出自阿里巴巴的开源产品,用 Java 语言实现,在设计时参考了 Kafka,并做出了自己的一些改进。被阿里巴巴广泛应用在订单,交易,充值,流计算,消息推送,日志流式处理, binglog 分发等场景

    • 优点:单机吞吐量十万级,可用性非常高,分布式架构,消息可以做到 0 丢失,MQ 功能较为完善,还是分布式的,扩展性好,支持 10 亿级别的消息堆积,不会因为堆积导致性能下降,源码是 java 我们可以自己阅读源码,定制自己公司的 MQ
    • 缺点:支持的客户端语言不多,目前是 java 及 c++,其中 c++不成熟;社区活跃度一般,没有在 MQ核心中去实现 JMS 等接口,有些系统要迁移需要修改大量代码
  • rabbitmQ:2007 年发布,是一个在 AMQP(高级消息队列协议)基础上完成的,可复用的企业消息系统,是当前最主流的消息中间件之一。

    • 优点:由于 erlang 语言的高并发特性,性能较好; 吞吐量到万级, MQ 功能比较完备,健壮、稳定、易用、跨平台、 支持多种语言 如: Python、 Ruby、 .NET、 Java、 JMS、 C、 PHP、 ActionScript、 XMPP、 STOMP等,支持 AJAX 文档齐全;开源提供的管理界面非常棒,用起来很好用,社区活跃度高; 更新频率相当高
    • 缺点:商业版需要收费

MQ的选择

  • kafka

    Kafka 主要特点是基于 Pull 的模式来处理消息消费,追求高吞吐量,一开始的目的就是用于日志收集和传输,适合产生大量数据的互联网服务的数据收集业务。 大型公司建议可以选用,如果有日志采集功能,肯定是首选 kafka 了。

  • RocketMQ

    天生为金融互联网领域而生,对于可靠性要求很高的场景,尤其是电商里面的订单扣款, 以及业务削峰,在大量交易涌入时,后端可能无法及时处理的情况。 RoketMQ 在稳定性上可能更值得信赖,这些业务场景在阿里双 11 已经经历了多次考验,如果你的业务有上述并发场景,建议可以选择 RocketMQ。

  • RabbitMQ

    结合 erlang 语言本身的并发优势,性能好时效性微秒级, 社区活跃度也比较高,管理界面用起来十分方便, 如果你的数据量没有那么大, 中小型公司优先选择功能比较完备的 RabbitMQ。

RabbitMQ

RabbitMQ概念

​ RabbitMQ 是一个消息中间件:它接受并转发消息。你可以把它当做一个快递站点,当你要发送一个包裹时,你把你的包裹放到快递站,快递员最终会把你的快递送到收件人那里,按照这种逻辑 RabbitMQ 是一个快递站,一个快递员帮你传递快件。 RabbitMQ 与快递站的主要区别在于,它不处理快件而是接收,存储和转发消息数据。

RabbitMQ四大核心概念

image-20230327225442386

生产者

​ 产生数据发送消息的程序是生产者

交换机

​ 交换机是RabbitMQ非常重要的一个部件,一方面它接收来自生产者的消息,另一方面它将消息推送到队列中。交换机必须确切知道如何处理它接收到的消息,是将这些消息推送到特定队列还是推送到多个队列,亦或者是把消息丢弃,这个得有交换机类型决定

队列

​ 队列是RabbitMQ内部使用的一种数据结构,尽管消息流经RabbitMQ和应用程序,但它们只能存储在队列中。队列仅受主机的内存和磁盘限制的约束,本质上是一个大的消息缓冲区。许多生产者可以将消息发送到一个队列,许多消费者可以尝试从一个队列接收数据。这就是我们使用队列的方式

消费者

​ 消费与接收具有相似的含义。消费者大多时候是一个等待接收消息的程序。请注意生产者,消费者和消息中间件很多时候并不在同一机器上。同一个应用程序既可以是生产者又是可以是消费者。

RabbitMQ基本功能

2023/3/27最新版3.11-management

image-20230327225153823

教程安装

  • 官网地址:Downloading and Installing RabbitMQ — RabbitMQ

  • 安装RabbitMQ的依赖环境

    //由于RabbitMQ是erlang语言写的,需要安装对应版本的erlang环境
     erl -version          //查看本机的erlang版本
     rpm -ivh erlang-21.3-1.el7.x86_64.rpm
    
    //RabbitMQ环境需要
    yum install socat -y 
    
    //安装RabbitMQ
    rpm -ivh rabbitmq-server-3.8.8-1.el7.noarch.rpm
    

    通过安装包可以看版本是不是对应的

    image-20230319155655581

  • 设置Rabbitmq-server 开启自启动

    //设为自启动,和systemctl enabel rabbitmq-server.service功能
    chkconfig rabbitmq-server on 
    

    image-20230319163103010

  • 启动服务(二选一)

    • /sbin/service rabbitmq-server start
    • systemctl start rabbitmq-server
  • 查看服务状态(二选一)

    • /sbin/service rabbitmq-server status
    • systemctl status rabbitmq-server
  • 停止服务(二选一)

    • /sbin/service rabbitmq-server stop
    • systemctl stop rabbitmq-server
  • 安装web管理插件(更方便管理MQ)

    • 安装web管理插件需要关闭MQ服务

    • rabbitmq-plugins enable rabbitmq_management

    • 安装插件遇到的问题:

      image-20230319170833169

    • 解决方案:

      //在命令行中输入
      hostname //查看 transient hostname 的值
      
      //修改hosts文件,在文件中添加一行
      vim /etc/hosts  
      在hosts文件中添加  192.168.222.11 Contos7   //前面为自己的ip地址,后面为hostname值
      

    参考博客地址:[(1条消息) linux(centos8)中安装rabbitmq_centos8安装rabbitmq_郑…方…醒的博客-CSDN博客](https://blog.csdn.net/weixin_42351206/article/details/125444026?ops_request_misc=%7B%22request%5Fid%22%3A%22167921655116800227419931%22%2C%22scm%22%3A%2220140713.130102334.pc%5Fall.%22%7D&request_id=167921655116800227419931&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allfirst_rank_ecpm_v1~rank_v31_ecpm-1-125444026-null-null.142v74control,201v4add_ask,239v2insert_chatgpt&utm_term=rabbitmq-plugins enable rabbitmq_management报{%3Aquery%2C %3Arabbit%40FairyQin%2C {%3Abadrpc%2C %3Atimeout}}&spm=1018.2226.3001.4187)

  • 默认账户密码都是(guest)访问地址为http://localhost:15672

    image-20230319171725497

    • 使用账号guest 密码 guest登入出现权限问题

      image-20230319171855550
    • 问题原因:guset用户访问权限不够

    • 解决方案:新增用户并未其添加权限

      //创建账户 账户为admin  密码为123
      rabbitmqctl add_user admin 123     
          
      //设置用户角色
      rabbitmqctl set_user_tags admin administrator  //赋予管理员权限
          
      //设置用户权限
         rabbitmqctl  set_permissions [-p <vhostpath>]<user><conf><write> <read>
      //例如:用户user_admin具有/vhost1这个virtual host所有资源的配置、读写权限
          rabbitmqctl set_permissions -p "/" admin ".*" ".*" ".*"
      

      image-20230319172842672

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Yl1N1fqG-1680343587351)(null)]

  • 查看当前所有用户和角色

    • rabbitmqctl list_users
  • 使用新建用户admin登入

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cJuDruTX-1680343581162)(null)]

  • 重置命令

    • rabbitmqctl stop_app //关闭应用的命令,不关闭erlang运行环境

    • rabbitmqctl reset //清除的命令

    • rabbimqctl start_app //重新rabbitmq应用,但是不

    • [(1条消息) RabbitMQ常用命令(三)_rabbitmqctl reset_擦肩而过的博客-CSDN博客](https://blog.csdn.net/zwj1030711290/article/details/117370464?ops_request_misc=%7B%22request%5Fid%22%3A%22167922049616800225545641%22%2C%22scm%22%3A%2220140713.130102334…%22%7D&request_id=167922049616800225545641&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduend~default-1-117370464-null-null.142v74control,201v4add_ask,239v2insert_chatgpt&utm_term=rabbitmqctl reset&spm=1018.2226.3001.4187)

官网Docker安装

#这里是安装官的最新版(这里命令后面没有/bin/bash)
docker run -it --rm --name rabbitmq3.11 -p 9672:5672 -p 45672:15672 rabbitmq:3.11-management

image-20230326194126339

  • 这个版本的rabbitmq在启动页面的最后面会显示已经启动了rabbitmq_management插件了,所以可以直接访问UI控制台了

image-20230326194158934

  • UI界面(默认用户为guest 密码也是guest)

image-20230326194343337

Docker安装中出现的一些错误

报错——Error getting repository data for epel, repository not found

报错原因

  • 系统中未启用 RHEL/CentOS 7.x/6.x/5.x 的 EPEL 存储库。
    • EPEL 代表企业 Linux 及其在线分布式的额外软件包,可以由 yum 程序自动下载和安装。只要启用了此选项,错误就会解决

解决方案:

  1. yum install wget
  2. wget http://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
  3. rpm -ivh epel-release-latest-7.noarch.rpm

参考博客:[(1条消息) RabbitMQ 最好安装教程_General的个人博客的博客-CSDN博客](https://blog.csdn.net/qq_42986416/article/details/112171367?ops_request_misc=%7B%22request%5Fid%22%3A%22167921382516800184138715%22%2C%22scm%22%3A%2220140713.130102334.pc%5Fall.%22%7D&request_id=167921382516800184138715&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allfirst_rank_ecpm_v1~rank_v31_ecpm-2-112171367-null-null.142v74control,201v4add_ask,239v2insert_chatgpt&utm_term=Centos7 Error getting repository data for rabbitmq_erlang%2C repository not found&spm=1018.2226.3001.4187)

入门案例

​ 在下图中,“P”是我们的生产者,“C”是我们的消费者。中间的框是一个队列-RabbitMQ代表使用者保留的消息缓冲区

image-20230319181110681

搭建基础环境

  • 创建maven工程并添加相关依赖

     <dependencies>
            <!--rabbitmq 依赖客户端-->
            <dependency>
                <groupId>com.rabbitmq</groupId>
                <artifactId>amqp-client</artifactId>
                <version>5.8.0</version>
            </dependency>
            <!--操作文件流的一个依赖-->
            <dependency>
                <groupId>commons-io</groupId>
                <artifactId>commons-io</artifactId>
                <version>2.6</version>
            </dependency>
        </dependencies>
        <!--指定 jdk 编译版本-->
        <build>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.8.1</version>
                    <configuration>
                        <source>8</source>
                        <target>8</target>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    

编写代码

  • 消息生产者

    @Slf4j
    public class MessageProduct {
        private final static String QUEUE_NAME="hello_word";
        public static void main(String[] args) throws Exception{
            //创建一个创建连接的工厂
            ConnectionFactory factory=new ConnectionFactory();
            //设置连接工厂的一些配置信息包括 ip username passowrd
            factory.setHost("192.168.241.128");
            factory.setUsername("admin");
            factory.setPassword("123");
            //创建连接
            Connection connection = factory.newConnection();
            //获取信道(channel)
            Channel channel = connection.createChannel();
            /**
             * 1.队列名称
             * 2.队列里面的消息是否持久化 默认消息存储在内存中
             * 3.该队列是否只供一个消费者进行消费 是否进行共享 fase可以多个消费者消费
             * 4.是否自动删除 最后一个消费者端开连接以后 该队列是否自动删除 true自动删除
             * 5.其他参数
             */
            channel.queueDeclare(QUEUE_NAME,true,false ,true,null);
            String message="welcome RabbitMQ";
            /**
             发送一个消息
             * 1.发送到那个交换机
             * 2.routingkey是哪个
             * 3.其他的参数信息
             * 4.发送消息的消息体
            */
            //使用默认的交换机,当使用默认的交换机的时候可以用队列名来代替routingkey
            channel.basicPublish("", QUEUE_NAME,null,message.getBytes(StandardCharsets.UTF_8));
            log.info("生产者往{}队列中发送一条消息,消息的内容为=>>>{}",QUEUE_NAME,message);
        }
    }
    
  • 消息消费者

    @Slf4j
    public class Consume {
        private final static String QUEUE_NAME = "hello_word";
    
        public static void main(String[] args) throws Exception {
            //创建  创建连接的工厂
            ConnectionFactory connectionFactory = new ConnectionFactory();
            //配置连接工厂的配置信息
            connectionFactory.setHost("192.168.241.128");
            connectionFactory.setUsername("admin");
            connectionFactory.setPassword("123");
            //创建连接
            Connection connection = connectionFactory.newConnection();
            Channel channel = connection.createChannel();
            AMQP.Queue.DeclareOk declareOk = channel.queueDeclare(QUEUE_NAME, true, false, true, null);
            //等待接收消息
            //接收消息回调的方法
            log.info("等待接收消息");
            DeliverCallback deliverCallback = (consumerTag, message) -> {
                System.out.println("接收到的消息的标签为" + consumerTag);
                System.out.println("接收到的消息为===>" + new String(message.getBody()));
            };
    
            //未成功接收消息回调的方法
            CancelCallback cancelCallback = new CancelCallback() {
                @Override
                public void handle(String consumerTag) throws IOException {
                    System.out.println("消息未被正确接收");
                }
            };
    
            //设置消费者自动应答
            channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
        }
    }
    

Work Queues

工作队列模式:分配任务的工人之间(相互竞争的消费者模式 )

​ 工作队列(又称任务队列)的主要思想是避免立即执行资源密集型任务,我们把任务封装为消息并将其发送到队列。在后台运行的工作进程将弹出任务并最终执行作业。当有多个工作线程时,这些工作线程将一起处理这些任务。

轮询分发消费

负载均衡的一种方法

把获取连接封装成工具类

public class ChannelInstance {
    public static Channel getInstance() throws  Exception{
        ConnectionFactory connectionFactory=new ConnectionFactory();
        connectionFactory.setHost("192.168.241.128");
        connectionFactory.setUsername("admin");
        connectionFactory.setPassword("123");
        Connection connection = connectionFactory.newConnection();
        Channel channel = connection.createChannel();
        return channel;
    }
}

》注意:这里需要先启动两个消费者,在启动生产者

启动多个工作线程处理消息

@Slf4j
public class WorkConsume {
    private final static String QUEUE_NAME = "work_queue";

    public static void main(String[] args) throws Exception {
        Channel instance = ChannelInstance.getInstance();
        //声明队列 ,如果队列不存在会创建队列  这里可以不用声明队列了
        // TODO 也可以把声明队列也进行封装
   // 注意:这里需要设置一个队列中的是非exclusive的(非独享的)    
        instance.queueDeclare(QUEUE_NAME, true, true, true, null);
        //获取队列中的任务进行消费
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("consume1接收到的任务是>>" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumeTag) -> {
            log.error("标签为{}的任务未被正确消费", consumeTag);
        };
        log.info("等待被分配消息");
        instance.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }
}
  • 开启允许多实例运行

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-opQoVwXu-1680343573305)(https://cdn.staticaly.com/gh/GhostQinMo/ImageBed@master/RabbitMQ/image-20230319213137243.png)]

image-20230319213305707

启动一个发送线程

@Slf4j
public class WorkerProduct {
    private final static String QUEUE_NAME = "work_queue";

    public static void main(String[] args) throws Exception {
        //通过工具类获取连接
        Channel instance = ChannelInstance.getInstance();
        //声明队列 ,如果队列不存在会创建队列 注意:这里需要设置一个队列中的是非exclusive的(非独享的)
        // TODO 也可以把声明队列也进行封装
        instance.queueDeclare(QUEUE_NAME, true, false, true, null);
        //从控制台中发送消息
        Scanner scanner = new Scanner(System.in);
        log.info("请输入消息:");
        while (scanner.hasNext()) {
            String s = scanner.nextLine();
            //这里还是使用默认交换机
            instance.basicPublish("", QUEUE_NAME, null, s.getBytes(StandardCharsets.UTF_8));
        }
    }
}
  • 默认采用轮询分发任务

    image-20230319214212405

消息应答

概念

​ 消费者完成一个任务可能需要一段时间,如果其中一个消费者处理一个长的任务并仅只完成了部分突然它挂掉了,会发生什么情况。RabbitMQ一旦向消费者传递了一条消息,便立即将该消息标记为删除。在这种情况下,突然有个消费者挂掉了,我们将丢失正在处理的消息。以及后续发送给该消费这的消息,因为它无法接收到。

​ 为了保证消息在发送过程中不丢失,rabbitmq引入消息应答机制,消息应答就是:消费者在接收到消息并且处理该消息之后,告诉rabbitmq它已经处理了,rabbitmq可以把该消息删除了。

自动应答

消息发送后立即被认为已经传送成功,这种模式需要在高吞吐量和数据传输安全性方面做权衡,因为这种模式如果消息在接收到之前,消费者那边出现连接或者channel关闭,那么消息就丢失了,当然另一方面这种模式消费者那边可以传递过载的消息,没有对传递的消息数量进行限制,当然这样有可能使得消费者这边由于接收太多还来不及处理的消息,导致这些消息的积压,最终使得内存耗尽,最终这些消费者线程被操作系统杀死,所以这种模式仅适用在消费者可以高效并以某种速率能够处理这些消息的情况下使用。

总结

​ 自动应答需要系统处于一个良好的环境。消费者接收到消息立刻应答而不是等消息处理完成

手动消息应答的方法
  • Channel.basicAck() //用于可定确定,通过设置参数multiple可以分为
    • multiple= false 一个一个应答
    • multiple=true 批量应答
  • Channel.basicNack() //用于否定确定,通过设置参数multiple可以分为批量否定和按序否定
  • Channel.basicRejec() //用于否定确定,不处理该消息直接拒绝,也可以将其丢弃,但是没有multiple参数
Multipe的解释
  • 手动应答的好处是可以批量应答并且减少网络拥堵

image-20230319220443766

multiple 的true和 false 代表不同意思

  • true 代表批量应答channel上未应答的消息
    • 比如说channel上有传送tag的消息5,6,7,8当前tag是8那么此时5-8的这些还未应答的消息都会被确认收到消息应答
    • image-20230319220603856
  • false 同上面相比
    • 只会应答tag=8的消息5,6,7这三个消息依然不会被确认收到消息应答
    • image-20230319220614314
消息自动重新入队(案例)

​ 如果消费者由于某些原因失去连接(其通道已关闭,连接已关闭或TCP连接丢失),导致消息未发送ACK确认,RabbitMQ将了解到消息未完全处理,并将对其重新排队。如果此时其他消费者可以处理,它将很快将其重新分发给另一个消费者。这样,即使某个消费者偶尔死亡,也可以确保不会丢失任何消息。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yyIEd3cZ-1680343579509)(null)]

image-20230312183451459

  • 问题:消息自动重新入队有没有可能导致消息被重复消费呢?
  • :消息自动重新入队不会导致消息重复消费,关于MQ的其他的情况可能导致消息被重复消费,但是可以通过第三方辅助防止消息重复消费,例如结合redis,redis天生具有安全性
消息手动应答案例

默认消息采用的是自动应答,所以我们要想实现消息消费过程中不丢失,需要把自动应答改为手动应答。实现消息自动重新入队

消费者:

@Slf4j
public class WorkConsume {
    private final static String UNAUTOACK_QUEUE = "UnAutoAck_queue";

    public static void main(String[] args) throws Exception {
        Channel instance = ChannelInstance.getInstance();
        //声明队列 ,如果队列不存在会创建队列  这里可以不用声明队列了 注意:这里需要设置一个队列中的是非exclusive的(非独享的)
        // TODO 也可以把声明队列也进行封装
//        instance.queueDeclare(UNAUTOACK_QUEUE, true, false, true, null);
        //获取队列中的任务进行消费
        //consumerTag表示与消费者关联的标签  message 表示传递的消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            //假设消息处理需要一定的时间
            try {
                //通过添加虚拟机参数 设置可以多实例运行
//                TimeUnit.SECONDS.sleep(1);
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("consume接收到的任务是>>" + new String(message.getBody()));
            //手动应答消息处理完成  通过messagek(传递的消息)可以获取的消息的标签
            instance.basicAck(message.getEnvelope().getDeliveryTag(), false);
        };
        CancelCallback cancelCallback = (consumeTag) -> {
            log.error("标签为{}的任务未被正确消费", consumeTag);
        };
        log.info("等待被分配消息");
        //关闭自动应答,改为手动应答
        Boolean auto = false;
        instance.basicConsume(UNAUTOACK_QUEUE, auto, deliverCallback, cancelCallback);
    }
}

消息生产者:

@Slf4j
public class WorkerProduct {
    private final static String UNAUTOACK_QUEUE="UnAutoAck_queue";

    public static void main(String[] args) throws Exception {
        //通过工具类获取连接
        Channel instance = ChannelInstance.getInstance();
        //声明队列 ,如果队列不存在会创建队列 注意:这里需要设置一个队列中的是非exclusive的(非独享的)
        // TODO 也可以把声明队列也进行封装
        instance.queueDeclare(UNAUTOACK_QUEUE, true, false, true, null);
        //从控制台中发送消息
        Scanner scanner = new Scanner(System.in);
        log.info("请输入消息:");
        while (scanner.hasNext()) {
            String s = scanner.nextLine();
            //这里还是使用默认交换机
            instance.basicPublish("", UNAUTOACK_QUEUE, null, s.getBytes(StandardCharsets.UTF_8));
        }
    }
}

手动应答效果演示

在发送者发送消息dd,发出消息之后的把C2消费者停掉,按理说该C2来处理该消息,但是由于它处理时间较长,在还未处理完,也就是说c2还没有执行ack代码的时候,c2被停掉了,此时会看到消息被C1接收到了,说明消息dd被重新入队,然后分配给能处理消息的C1处理了

image-20230319225710454

RabbitMQ持久化

概念

​ 如何保障当RabbitMQ服务停掉以后消息生产者发送过来的消息不丢失。默认情况下RabbitMQ退出或由于某种原因崩溃时,它忽视队列和消息,除非告知它不要这样做。确保消息不丢失需要保证:队列持久化消息持久化

队列持久化

队列是MQ当中的一个组件,队列与消息是两个概念,所以RabbitMQ的持久化包括队列持久化和消息持久化

​ 通过在声明队列的时候把durable参数设置为持久化,这样创建的队列就是持久化的。

​ 注意:如果之前声明的队列不是持久化的,需要把原来的声明的队列先删除或者重新创建一个不重名的持久化队列,不然就会出现下面的错误

image-20230320093416534

例如:创建持久化队列

image-20230320093711056

在控制台显示如下,这样创建的队列即使重启rabbitmq队列也依然存在

image-20230320093955870

消息持久化

队列和消息是两个概念,队列持久化了但并代表消息持久化了,消息持久化需要生产者在发送消息的时候告诉MQ这个消息需要进行持久化操作

​ 消息持久化需要在生产者发布消息的时候添加属性PERSISTENT_TEXT_PLAIN来告知该消息需要持久化

例如:

image-20230320094634075

​ 将消息标记为持久化并不能完全保证不会丢失消息。尽管它告诉RabbitMQ将消息保存到磁盘,但是这里依然存在当消息刚准备存储在磁盘的时候但是还没有存储完,消息还在缓存的一个间隔点。此时并没有真正写入磁盘。持久性保证并不强,但是对于我们的简单任务队列而言,这已经绰绰有余了。如果需要更强有力的持久化策略,参考后边课件发布确认章节。

不公平分发

在消费者方设置不公平分发(按需获取消息,能者多劳)

​ 在最开始的时候我们学习到RabbitMQ分发消息采用的轮训分发,但是在某种场景下这种策略并不是很好,比方说有两个消费者在处理任务,其中有个消费者1处理任务的速度非常快,而另外一个消费者2处理速度却很慢,这个时候我们还是采用轮训分发的化就会到这处理速度快的这个消费者很大一部分时间处于空闲状态,而处理慢的那个消费者一直在干活,这种分配方式在这种情况下其实就不太好,但是RabbitMQ并不知道这种情况它依然很公平的进行分发。

消费者通过channel.basiQos(1)1设置不公平分发

  • 不公平分发好像需要设置在手动应答上面 需要
//关闭自动应答,改为手动应答
        Boolean auto = false;
        //参数的取值1是为不公平分发,其实其他参数也可以,范围是0-255,0表示不受限制
        instance.basicQos(1);
        instance.basicConsume(UNAUTOACK_QUEUE, auto, deliverCallback, cancelCallback);

image-20230320095316403

​ 意思就是如果这个任务我还没有处理完或者我还没有应答你,你先别分配给我,我目前只能处理一个任务,然后rabbitmq就会把该任务分配给没有那么忙的那个空闲消费者,当然如果所有的消费者都没有完成手上任务,队列还在不停的添加新任务,队列有可能就会遇到队列被撑满的情况,这个时候就只能添加新的worker 或者改变其他存储任务的策略。

控制台显示:

image-20230320100526580

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mKCxkMAi-1680343573313)(https://cdn.staticaly.com/gh/GhostQinMo/ImageBed@master/RabbitMQ/image-20230320100839114.png)]

预取值

  • 预取值需要配置手动应答使用 需要

​ 本身消息的发送就是异步发送的,所以在任何时候,channel上肯定不止只有一个消息另外来自消费者的手动确认本质上也是异步的。因此这里就存在一个未确认的消息缓冲区,因此希望开发人员能限制此缓冲区的大小,以避免缓冲区里面无限制的未确认消息问题。这个时候就可以通过使用basic.qos方法设置“预取计数”值来完成的。**该值定义通道上允许的未确认消息的最大数量。**一旦数量达到配置的数量,RabbitMQ将停止在通道上传递更多消息,除非至少有一个未处理的消息被确认,例如,假设在通道上有未确认的消息5、6、7,8,并且通道的预取计数设置为4,此时RabbitMQ将不会在该通道上再传递任何消息,除非至少有一个未应答的消息被ack。比方说tag=6这个消息刚刚被确认ACK,RabbitMQ将会感知这个情况到并再发送一条消息。消息应答和QoS预取值对用户吞吐量有重大影响。通常,增加预取将提高向消费者传递消息的速度。**虽然自动应答传输消息速率是最佳的,但是,在这种情况下已传递但尚未处理的消息的数量也会增加,从而增加了消费者的RAM消耗(随机存取存储器)**应该小心使用具有无限预处理的自动确认模式或手动确认模式,消费者消费了大量的消息如果没有确认的话,会导致消费者连接节点的内存消耗变大,所以找到合适的预取值是一个反复试验的过程,不同的负载该值取值也不同100到300范围内的值通常可提供最佳的吞吐量,并且不会给消费者带来太大的风险。预取值为1是最保守的。当然这将使吞吐量变得很低,特别是消费者连接延迟很严重的情况下,特别是在消费者连接等待时间较长的环境中。对于大多数应用来说,稍微高一点的值将是最佳的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2bWhuLVr-1680343578742)(null)]

image-20230312200025683

注意:预取值需要配置手动应答使用,而且需要消息队列中积压一定的未应答的消息才能体现出来预取值的好处。

总结:如果预取值可以设置在队列中缓存指定的待处理的消息,但是如果该消费者宕机了,消息还是会自动重新入队,给其他的消费者处理

测试结果:(预取值为0的结果)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-stTTm9ZK-1680343584691)(null)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3OQDcmrw-1680343583681)(null)]

image-20230320105216617

发布确认

可以解决消息丢失

发布确认原理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-us8UxzLg-1680343579989)(null)]

​ 生产者将信道设置成confirm模式,一旦信道进入confirm模式,所有在该信道上面发布的消息都将会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后,==broker(MQ服务器)就会发送一个确认给生产者(包含消息的唯一ID),这就使得生产者知道消息已经正确到达目的队列了,==如果消息和队列是可持久化的,那么确认消息会在将消息写入磁盘之后发出,broker回传给生产者的确认消息中delivery-tag域包含了确认消息的序列号,此外 broker也可以设置basic.ack 的multiple域,表示到这个序列号之前的所有消息都已经得到了处理。

​ confirm模式最大的好处在于他是异步的,**一旦发布一条消息,生产者应用程序就可以在等信道返回确认的同时继续发送下一条消息,**当消息最终得到确认之后,生产者应用便可以通过回调方法来处理该确认消息,如果RabbitMQ因为自身内部错误导致消息丢失,就会发送一条nack消息,生产者应用程序同样可以在回调方法中处理该nack消息。

总结:

  • 设置队列持久化
  • 设置持久化
  • 设置发布确认(信道开启发布确认模式)

注意:发布确认是指发布者发布消息等待确认,而是RabbitMQ服务器确认,而不是消费者确认

发布确认的策略

开启发布确认的方法

​ 发布确认默认是没有开启的,如果要开启需要调用方法confirmSelect,每当你要想使用发布确认,都需要在channel 上调用该方法

	//消息发布者在发送消息之间开启发布确认模式
	Channel channel =connection.createChannel();
	//开启发布确认模式
	channel.confireSelect();
单个发布确认

​ 这是一种简单的确认方式,它是一种同步确认发布的方式,也就是发布一个消息之后只有它被确认发布,后续的消息才能继续发布, waitForConfirmsOrDie(long)这个方法只有在消息被确认的时候才返回,如果在指定时间范围内这个消息没有被确认那么它将抛出异常。

​ 这种确认方式有一个最大的缺点就是:发布速度特别的慢,因为如果没有确认发布的消息就会阻塞所有后续消息的发布,这种方式最多提供每秒不超过数百条发布消息的吞吐量。当然对于某些应用程序来说这可能已经足够了。

消息发布者:

//因为这里是MQ服务器给消息发布者发送确认信息,所以测试中不需要消费者
//单个发布确认
    public static void SinglePublish_Confirm() throws Exception {
        //获取channel
        Channel channel = ChannelInstance.getInstance();
        //只要有了信道(channel)就可以开启发布确认模式
        AMQP.Confirm.SelectOk selectOk = channel.confirmSelect();
        //声明持久化队列
        boolean isDurable = true;
        channel.queueDeclare(CONFIRM_QUEUE, isDurable, false, true, null);
        //发布消息
        log.info("发布者发布消息:\n");
        long start = System.currentTimeMillis();
        for (int i = 0; i < MESSAGECOUNT; i++) {
            String s = "发布者发布的消息" + i;
            //还是使用默认的交换机
            //设置消息持久化
            channel.basicPublish("", CONFIRM_QUEUE, MessageProperties.PERSISTENT_TEXT_PLAIN, s.getBytes(StandardCharsets.UTF_8));
            //waitForConfirms()必须在开启发布确认的channel中使用
            boolean b = channel.waitForConfirms();
            if (b) {
                log.info("{} 发布确认成功", s);
            }
        }
        long end = System.currentTimeMillis();
        log.info("单个发布确认模式1000条消息所有的时间为{}ms", end - start);
    }
批量确认发布

​ 上面那种方式非常慢,与单个等待确认消息相比,先发布一批消息然后一起确认可以极大地提高吞吐量,当然这种方式的缺点就是:当发生故障导致发布出现问题时,不知道是哪个消息出现问题了,我们必须将整个批处理保存在内存中,以记录重要的信息而后重新发布消息。当然这种方案仍然是同步的,也一样阻塞消息的发布。

生产者代码:

//批量发布确认模式  (同步)
    public static void MultiPublish_Confirm() throws Exception {
        //获取channel
        Channel channel = ChannelInstance.getInstance();
        //只要有了信道(channel)就可以开启发布确认模式
        AMQP.Confirm.SelectOk selectOk = channel.confirmSelect();
        //声明持久化队列
        boolean isDurable = true;
        channel.queueDeclare(CONFIRM_QUEUE, isDurable, false, true, null);
        //发布消息
        log.info("发布者发布消息:\n");
        long start = System.currentTimeMillis();
        //批量提交的数目
        int max_multi = 100;
        //记录当前未确认的消息数目
        int count = 0;
        for (int i = 0; i < MESSAGECOUNT; i++) {
            //计数
            count++;
            String s = "发布者发布的消息" + i;
            //还是使用默认的交换机
            //设置消息持久化
            channel.basicPublish("", CONFIRM_QUEUE, MessageProperties.PERSISTENT_TEXT_PLAIN, s.getBytes(StandardCharsets.UTF_8));
            //waitForConfirms()必须在开启发布确认的channel中使用
            if (count == max_multi) {
                boolean b = channel.waitForConfirms();
                if (b) {
                    log.info("{} 发布确认成功", s);
                }
                count = 0;
            }
        }
        if (count >= 0) {
            //如果最后的数目不是100,也需要返回确认信息
            channel.waitForConfirms();
        }
        long end = System.currentTimeMillis();
        log.info("批量发布确认模式1000条消息所有的时间为{}ms", end - start);
    }
异步确认发布

​ 异步确认虽然编程逻辑比上两个要复杂,但是性价比最高,无论是可靠性还是效率都没得说,他是利用回调函数来达到消息可靠性传递的,这个中间件也是通过函数回调来保证是否投递成功,下面就让我们来详细讲解异步确认是怎么实现的。

image-20230320175027640

代码

//异步发布确认模式
    public static void PublishConfirmModeAsync()throws Exception{
        //创建记录为被正确确认的消息的容器
        /*
        * 1. 支持并发访问,线程安全
        * 2. 无消息重复被记录
        **/
        ConcurrentSkipListMap<Long ,String> container=new ConcurrentSkipListMap<>();
        //获取channel
        Channel channel =ChannelInstance.getInstance();
        //开启发布确认
        AMQP.Confirm.SelectOk selectOk = channel.confirmSelect();
        System.out.println(selectOk.protocolMethodName());
        //声明队列,并且持久化队列
        boolean isdurable=true;
        channel.queueDeclare(CONFIRM_QUEUE,isdurable,false,true,null);
        //为信道(channel)添加监听器(listener)
        //成功接收接受消息的确认回调函数
        /**
         * 确认收到消息的一个回调
         * 1.消息序列号
         * 2.true 可以确认小于等于当前序列号的消息
         * false 确认当前序列号消息
         */
        ConfirmCallback ackCallback=(deliveryTag,multiple)->{
            //这里由于是异步操作,所以后面的消息可能会提前调用这个回调方法,
            //multiple 为true表示批量处理,因为消息是按照序列从0开始发送的,开始的时候讲过,会为每一个消息添加一个序列号(从0开始)
            if (multiple){
                //返回的是一个序列号严格消息指定值的原映射的视图(map),在这个视图上的更改会影响原视图,反之亦然
                ConcurrentNavigableMap<Long, String> longStringConcurrentNavigableMap =
                        container.headMap(deliveryTag);
                //从未确认的map中清除这些消息(这个map是线程安全的)
                longStringConcurrentNavigableMap.clear();
                log.info(">>>>>>>>>小于等于{}号的消息被确认发布",deliveryTag);
            } else{
                //检查是否有这个键(因为是异步操作,很可能这些键以及被清除了)
                boolean b = container.containsKey(deliveryTag);
                if (b){
                    container.remove(deliveryTag);
                }

                log.info(">>>>>>>>>>>>>消息id为{}的消息被确认发布",deliveryTag);
            }
        };
        //消息为被正确确认的回调函数
        ConfirmCallback nackCallback =(deliveryTag,multiple)->{
            log.error("消息序列号为{} 的消息未被正确发布确认",deliveryTag);
        };
        /**
         * 添加一个异步确认的监听器
         * 1.确认收到消息的回调
         * 2.未收到消息的回调
         */
        channel.addConfirmListener(ackCallback,nackCallback);
        //开始时间
        long start = System.currentTimeMillis();
        //发送消息
        for (int i=0;i<MESSAGECOUNT;i++){
            //代发的消息
            String message="message to you from fairyqin"+i;
            //发送之前得到代发消息的序列号
            long nextPublishSeqNo = channel.getNextPublishSeqNo();
            //还是使用默认交换机,使用队列名代替routingkey
            //设置消息持久化
            channel.basicPublish("",CONFIRM_QUEUE,
                    MessageProperties.PERSISTENT_TEXT_PLAIN,
                    message.getBytes(StandardCharsets.UTF_8));
            container.put(nextPublishSeqNo,message);
            log.info("序列号为{}的消息发送,消息体为{}",nextPublishSeqNo,message);
        }
        long end = System.currentTimeMillis();
        log.info("》》》》》》》》》》》》》》》》》{}条消息发送成功,用时为{}ms",MESSAGECOUNT,end-start);
        log.info("》》》》》》》》》》》》》》》》》》》由于是异步操作,所以这里可以执行一些其他的操作");
    }
如何处理异步为确认消息

​ 最好的解决的解决方案就是把未确认的消息放到一个基于内存的能被发布线程访问的队列,比如说用ConcurrentLinkedQueue这个队列在confirm callbacks 与发布线程之间进行消息的传递。上面案例中使用的是JUC中的一个并发map

总结
  • 单独发布消息
    • 同步等待确认,简单,但吞吐量非常有限
  • 批量发布消息
    • 批量同步等待确认,简单,合理的吞吐量,一旦出现问题但很难推断出是那条消息出现了问题。
  • 异步处理
    • 最佳性能和资源使用,在出现错误的情况下可以很好地控制,但是实现起来稍微难些

交换机

前面讲的类似于消费者模式,及一个消息只给一个消费者,这里讲的是发布订阅模式,谁订阅了消息就需要发给谁

​ 在上一节中,我们创建了一个工作队列。我们假设的是工作队列背后,每个任务都恰好交付给一个消费者(工作进程)。在这一部分中,我们将做一些完全不同的事情-我们将消息传达给多个消费者。这种模式称为“发布/订阅".

​ 为了说明这种模式,我们将构建一个简单的日志系统。它将由两个程序组成:第一个程序将发出日志消息,第二个程序是消费者。其中我们会启动两个消费者,其中一个消费者接收到消息后把日志存储在磁盘,另外一个消费者接收到消息后把消息打印在屏幕上,事实上第一个程序发出的日志消息将广播给所有消费者

Exchanges

概念

​ RabbitMQ消息传递模型的核心思想是:生产者生产的消息从不会直接发送到队列。实际上,通常生产者甚至都不知道这些消息传递传递到了哪些队列中。
​ 相反,生产者只能将消息发送到交换机(exchange),交换机工作的内容非常简单,一方面它接收来自生产者的消息,另一方面将它们推入队列。交换机必须确切知道如何处理收到的消息。是应该把这些消息放到特定队列还是说把他们到许多队列中还是说应该丢弃它们。这就的由交换机的类型来决定。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mvkblv2l-1680343587682)(null)]

Exchange的类型

总共有4中类型内置的交换机,分别是

  • 直接类型(direct),也称为路由类型的交换机
  • 主题类型(topic)
  • 标题类型(headers),不常用
  • 扇出类型(fanout),也称为发布订阅模式
无名Exchange

​ 在本教程的前面部分我们对exchange一无所知,但仍然能够将消息发送到队列。之前能实现的原因是因为我们使用的是默认交换,我们通过空字符串(“”)进行标识。

​ 第一个参数是交换机的名称。空字符串表示默认或无名称交换机:消息能路由发送到队列中其实是由routingKey(bindingkey)绑定key指定的,如果它存在的话

channel.basicPublish("","Queue_name",null,message.getBytes());
临时队列

​ 之前的章节我们使用的是具有特定名称的队列(还记得hello和ack_queue吗? )。队列的名称我们来说至关重要-我们需要指定我们的消费者去消费哪个队列的消息。

​ 每当我们连接到Rabbit时,我们都需要一个全新的空队列,为此我们可以创建一个具有随机名称的队列,或者能让服务器为我们选择一个随机队列名称那就更好了。其次一旦我们断开了消费者的连接,队列将被自动删除。

创建临时队列:

//返回值为临时队列的名称
String queueName=channel.queueDeclare().getQueue();

image-20230320200530461

绑定(bindings)

​ 什么是bingding 呢,binding其实是exchange和queue之间的桥梁,它告诉我们exchange和那个队列进行了绑定关系。比如说下面这张图告诉我们的就是X与Q1和Q2进行了绑定

image-20230320200611721

创建一个队列

image-20230318150607269

创建一个交换机

image-20230318150710367

交换机绑定队列

image-20230318150849405

扇出(Fanout)

扇出又称发布订阅模式

​ Fanout这种类型非常简单。正如从名称中猜到的那样,它是将接收到的所有消息广播到它知道的所有队列中。系统中默认有些exchange类型

image-20230320200757504

Fanout案例

​ 它将由两个程序组成:第一个程序将发出日志消息,第二个程序是消费者。其中我们会启动两个消费者,其中一个消费者接收到消息后把日志存储在磁盘,另外一个消费者接收到消息后把消息打印在屏幕上,事实上第一个程序发出的日志消息将广播给所有消费者

image-20230320200837646

日志生成器

@Slf4j
public class EmitLog {
    public  final static  String FANOUT_EXCHANGE="FANOUT_EXCHANGE";
    public final  static  String QUEUE="logs";
    public static void main(String[] args)  throws Exception{
        Channel channel = ChannelInstance.getInstance();
        //声明fanout类型的交换机
        /**
         * 声明一个 exchange
         * 1.exchange 的名称
         * 2.exchange 的类型
         */
        channel.exchangeDeclare(FANOUT_EXCHANGE, BuiltinExchangeType.FANOUT);
        //往交换机中发布消息
        log.info("发布消息:");
        Scanner scanner=new Scanner(System.in);
        while (scanner.hasNext()){
            String message = scanner.nextLine();
            //消息持久化,以""为routingkey
            channel.basicPublish(FANOUT_EXCHANGE,"", MessageProperties.PERSISTENT_TEXT_PLAIN,message.getBytes(StandardCharsets.UTF_8));
        }
    }
}

ReceiveLogs01

@Slf4j
public class ReceiveLogs01 {
    public static void main(String[] args)throws Exception {
        Channel channel = ChannelInstance.getInstance();
        //创建随机队列
        String queue = channel.queueDeclare().getQueue();
        //绑定交换机 队列与交换机通过routingkey绑定,这里的routingkey为”“
        channel.queueBind(queue, EmitLog.FANOUT_EXCHANGE,"");
        //接收消息
        channel.basicQos(1);
        log.info("准备接收日志");
        DeliverCallback deliverCallback=(consumeTag,delivery)->{
            log.info("消息id为{}的消息体为{}",consumeTag,new String(delivery.getBody(), StandardCharsets.UTF_8));
        };
        CancelCallback cancelCallback=(consumeTag)->{
        };
        channel.basicConsume(queue,true,deliverCallback,cancelCallback);
    }
}

ReceiveLogs02

@Slf4j
public class ReceiveLogs02 {
    public static void main(String[] args)throws Exception {
        Channel channel = ChannelInstance.getInstance();
        //创建随机队列
        String queue = channel.queueDeclare().getQueue();
        //绑定交换机 队列与交换机通过routingkey绑定,这里的routingkey为”“
        channel.queueBind(queue, EmitLog.FANOUT_EXCHANGE,"");
        //接收消息
        channel.basicQos(1);
        log.info("准备接收日志");
        //这里需要使用绝对路径,相对路径找不到
        File file=new File("D:\\FairyHomeWorkSpace\\RabbitMQ_practice\\FanoutMode\\src\\main\\resources\\logs.txt");
        DeliverCallback deliverCallback=(consumeTag,delivery)->{
            //这里使用了apache的io工具报
            FileUtils.writeStringToFile(file,new String(delivery.getBody()),StandardCharsets.UTF_8,true);
            log.info("消息id为{}的消息体为{}",consumeTag,new String(delivery.getBody(), StandardCharsets.UTF_8));
        };
        CancelCallback cancelCallback=(consumeTag)->{
        };
        channel.basicConsume(queue,true,deliverCallback,cancelCallback);
    }
}

路由模式 (Direct exchange)

回顾:

在上一节中,我们构建了一个简单的日志记录系统。我们能够向许多接收者广播日志消息。在本节我们将向其中添加一些特别的功能-比方说我们只让某个消费者订阅发布的部分消息。例如我们只把严重错误消息定向存储到日志文件(以节省磁盘空间),同时仍然能够在控制台上打印所有日志消息。
我们再次来回顾一下什么是 bindings,绑定是交换机和队列之间的桥梁关系。也可以这么理解:队列只对它绑定的交换机的消息感兴趣。绑定用参数: routingKey来表示也可称该参数为binding key,创建绑定我们用代码:channel.queueBind(queueName,EXCHANGE_NAME,“routingKey”);绑定之后的意义由其交换类型决定。

Direct Exchange 简介

​ 上一节中的我们的日志系统将所有消息广播给所有消费者,对此我们想做一些改变,例如我们希望将日志消息写入磁盘的程序仅接收严重错误(erros),而不存储哪些警告(warning)或信息(info)日志消息避免浪费磁盘空间。Fanout这种交换类型并不能给我们带来很大的灵活性-它只能进行无意识的广播,在这里我们将使用direct这种类型来进行替换,这种类型的工作方式是,消息只去到它绑定的routingKey 队列中去。

image-20230320212914460

​ 在上面这张图中,我们可以看到×绑定了两个队列,绑定类型是direct。队列Q1绑定键为orange,队列Q2绑定键有两个:一个绑定键为black,另一个绑定键为green.

​ 在这种绑定情况下,生产者发布消息到exchange上,绑定键为orange的消息会被发布到队列Q1。绑定键为blackgreen和的消息会被发布到队列Q2,其他消息类型的消息将被丢弃。

多重绑定

​ 当然如果exchange的绑定类型是direct,但是它绑定的多个队列的key如果都相同,在这种情况下虽然绑定类型是direct但是它表现的就和fanout有点类似了,就跟广播差不多,如图所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UJIKC0Vg-1680343579081)(null)]

案例分析

结构图:

image-20230320213227055

​ 一个队列可以设置多个routingkey,direct exchange也可以绑定多个routingkey,direct exhcange类型的交换机但是在同一次发送中只能指定一个routingkey

日志生产者代码

@Slf4j
public class ReceiveLogs02 {
    private  static  final  String DISK_QUEUE="disk";
    public static void main(String[] args)throws Exception {
        Channel channel = ChannelInstance.getInstance();
        //创建console队列
        channel.queueDeclare(DISK_QUEUE,false,false,true,null);
        //绑定交换机 队列与交换机通过routingkey绑定,
        channel.queueBind(DISK_QUEUE, EmitLog.DIRECT_EXCHANGE,EmitLog.info_routingkey);
        channel.queueBind(DISK_QUEUE, EmitLog.DIRECT_EXCHANGE,EmitLog.warning_routingkey);
        //接收消息
        channel.basicQos(1);
        log.info("{}准备接收日志",DISK_QUEUE);
        //这里需要使用绝对路径,相对路径找不到
        File file=new File("D:\\FairyHomeWorkSpace\\RabbitMQ_practice\\DirectExchangeMode\\src\\main\\resources\\logs.txt");
        DeliverCallback deliverCallback=(consumeTag,delivery)->{
            //这里使用了apache的io工具报
            FileUtils.writeStringToFile(file,new String(delivery.getBody())+"\r\n",StandardCharsets.UTF_8,true);
            log.info("消息id为{}的消息体为{}",consumeTag,new String(delivery.getBody(), StandardCharsets.UTF_8));
        };
        CancelCallback cancelCallback=(consumeTag)->{
        };
        //自动应答
        boolean isAutoAck=true;
        channel.basicConsume(DISK_QUEUE,isAutoAck,deliverCallback,cancelCallback);
    }
}

ReceiveLogs01

@Slf4j
public class ReceiveLogs01 {
    private  static  final  String CONSOLE_QUEUE="console";
    public static void main(String[] args)throws Exception {
        Channel channel = ChannelInstance.getInstance();
        //创建console队列
        channel.queueDeclare(CONSOLE_QUEUE,false,false,true,null);
        //绑定交换机 队列与交换机通过routingkey绑定,
        channel.queueBind(CONSOLE_QUEUE, EmitLog.DIRECT_EXCHANGE,EmitLog.error_routingkey);
        //接收消息
        channel.basicQos(1);
        log.info("{}准备接收日志",CONSOLE_QUEUE);
        DeliverCallback deliverCallback=(consumeTag,delivery)->{
            log.info("消息id为{}的消息体为{}",consumeTag,new String(delivery.getBody(),StandardCharsets.UTF_8));
        };
        CancelCallback cancelCallback=(consumeTag)->{
        };
        //自动应答
        channel.basicConsume(CONSOLE_QUEUE,true,deliverCallback,cancelCallback);
    }
}

ReceiveLogs02

@Slf4j
public class ReceiveLogs02 {
    private  static  final  String DISK_QUEUE="disk";
    public static void main(String[] args)throws Exception {
        Channel channel = ChannelInstance.getInstance();
        //创建console队列
        channel.queueDeclare(DISK_QUEUE,false,false,true,null);
        //绑定交换机 队列与交换机通过routingkey绑定,
        channel.queueBind(DISK_QUEUE, EmitLog.DIRECT_EXCHANGE,EmitLog.info_routingkey);
        channel.queueBind(DISK_QUEUE, EmitLog.DIRECT_EXCHANGE,EmitLog.warning_routingkey);
        //接收消息
        channel.basicQos(1);
        log.info("{}准备接收日志",DISK_QUEUE);
        //这里需要使用绝对路径,相对路径找不到
        File file=new File("D:\\FairyHomeWorkSpace\\RabbitMQ_practice\\DirectExchangeMode\\src\\main\\resources\\logs.txt");
        DeliverCallback deliverCallback=(consumeTag,delivery)->{
            //这里使用了apache的io工具报
            FileUtils.writeStringToFile(file,new String(delivery.getBody())+"\r\n",StandardCharsets.UTF_8,true);
            log.info("消息id为{}的消息体为{}",consumeTag,new String(delivery.getBody(), StandardCharsets.UTF_8));
        };
        CancelCallback cancelCallback=(consumeTag)->{
        };
        //自动应答
        boolean isAutoAck=true;
        channel.basicConsume(DISK_QUEUE,isAutoAck,deliverCallback,cancelCallback);
    }
}

控制台绑定结果和运行结果

image-20230320215929308

image-20230320220038295

主题模式(Topics Exchange)

​ 尽管使用direct交换机改进了我们的系统,但是它仍然存在局限性-比方说我们想接收的日志类型有info.base和info.advantage,某个队列只想info.base的消息,那这个时候direct就办不到了。这个时候就只能使用topic类型

Topic的要求

​ 发送到类型是topic交换机的消息的routing_key 不能随意写,必须满足一定的要求,它必须是一个单词列表,以点号分隔开。这些单词可以是任意单词,比如说: “stock.usd.nyse” , “nyse.vmw”,
“quick.orange.rabbit”.这种类型的。当然这个单词列表最多不能超过255个字节

  • 在这个规则列表中,其中有两个替换符是大家需要注意的
    • * (星号)可以代替一个单词
    • #(井号)可以代替零个或多个单词
案例分析

​ 绑定关系如图所示:

image-20230320221230298

注意点:

  • 当一个队列绑定键是# ,那么这个队列将接收所有数据,就有点像fanout 了
  • 如果队列绑定键当中没有#和*出现,那么该队列绑定类型就是direct 了

消息发布者:

@Slf4j
public class EmitLog {
    public  final static  String TOPIC_EXCHANGE="TOPIC_EXCHANGE";
    public final  static  String orange_routingkey="*.orange.*";
    public final  static  String rabbit_routingkey="*.*.rabbit";
    public final  static  String lazy_routingkey="lazy.#";
    public static void main(String[] args)  throws Exception{
        Channel channel = ChannelInstance.getInstance();
        //声明Topics_EXCHANGE类型的交换机
        /**
         * 声明一个 exchange
         * 1.exchange 的名称
         * 2.exchange 的类型
         */
        channel.exchangeDeclare(TOPIC_EXCHANGE, BuiltinExchangeType.TOPIC);
        /**
         * Q1-->绑定的是
         * 中间带 orange 带 3 个单词的字符串(*.orange.*)
         * Q2-->绑定的是
         * 最后一个单词是 rabbit 的 3 个单词(*.*.rabbit)
         * 第一个单词是 lazy 的多个单词(lazy.#)
         */
        Map<String, String> bindingKeyMap = new HashMap<>();
        bindingKeyMap.put("quick.orange.rabbit","被队列 Q1Q2 接收到");
        bindingKeyMap.put("lazy.orange.elephant","被队列 Q1Q2 接收到");
        bindingKeyMap.put("quick.orange.fox","被队列 Q1 接收到");
        bindingKeyMap.put("lazy.brown.fox","被队列 Q2 接收到");bindingKeyMap.put("lazy.pink.rabbit","虽然满足两个绑定但只被队列 Q2 接收一次");
        bindingKeyMap.put("quick.brown.fox","不匹配任何绑定不会被任何队列接收到会被丢弃");
        bindingKeyMap.put("quick.orange.male.rabbit","是四个单词不匹配任何绑定会被丢弃");
        bindingKeyMap.put("lazy.orange.male.rabbit","是四个单词但匹配 Q2");
        //往交换机中发布消息
        log.info("发布消息:");
        //更具匹配的主题发送
        for (Map.Entry<String,String> temp: bindingKeyMap.entrySet()) {
            channel.basicPublish(TOPIC_EXCHANGE,
                    temp.getKey(),
                    MessageProperties.PERSISTENT_TEXT_PLAIN,
                    temp.getValue().getBytes(StandardCharsets.UTF_8));
            log.info("routingkey为{}的队列发送的消息为{}",temp.getKey(),temp.getValue());
        }

    }
}

ReceiveLogs01

@Slf4j
public class ReceiveLogs01 {
    private  static  final  String Q1_QUEUE="Q1";
    public static void main(String[] args)throws Exception {
        Channel channel = ChannelInstance.getInstance();
        //创建console队列
        channel.queueDeclare(Q1_QUEUE,false,false,true,null);
        //绑定交换机 队列与交换机通过routingkey绑定,
        channel.queueBind(Q1_QUEUE,EmitLog.TOPIC_EXCHANGE,EmitLog.orange_routingkey,null);
        //接收消息
        channel.basicQos(1);
        log.info("{}准备接收日志",Q1_QUEUE);
        DeliverCallback deliverCallback=(consumeTag,delivery)->{
            log.info("接收routingkey为{}的消息体为{}",delivery.getEnvelope().getRoutingKey(),new String(delivery.getBody(),StandardCharsets.UTF_8));
        };
        CancelCallback cancelCallback=(consumeTag)->{
        };
        //自动应答
        channel.basicConsume(Q1_QUEUE,true,deliverCallback,cancelCallback);
    }
}

ReceiveLogs02

@Slf4j
public class ReceiveLogs02 {
    private  static  final  String Q2_QUEUE="Q2";
    public static void main(String[] args)throws Exception {
        Channel channel = ChannelInstance.getInstance();
        //创建console队列
        channel.queueDeclare(Q2_QUEUE,false,false,true,null);
        //绑定交换机 队列与交换机通过routingkey绑定,
        channel.queueBind(Q2_QUEUE, EmitLog.TOPIC_EXCHANGE,EmitLog.rabbit_routingkey,null);
        channel.queueBind(Q2_QUEUE, EmitLog.TOPIC_EXCHANGE,EmitLog.lazy_routingkey,null);
        //接收消息
        channel.basicQos(1);
        log.info("{}准备接收日志",Q2_QUEUE);
        DeliverCallback deliverCallback=(consumeTag, delivery)->{
            log.info("接收routingkey为{}的消息体为{}",delivery.getEnvelope().getRoutingKey(),new String(delivery.getBody(), StandardCharsets.UTF_8));
        };
        CancelCallback cancelCallback=(consumeTag)->{
        };
        //自动应答
        channel.basicConsume(Q2_QUEUE,true,deliverCallback,cancelCallback);
    }
}

注意上述案例如果先启动消息发布者,然后再启动消息接收者,是接收不到消息的,消息会丢失

测试结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PQpR0IVh-1680343578915)(null)]

image-20230320225044709

Remote Procedure Call(RPC)

image-20230328111545950

案例说明:

​ 我们将使用RabbitMQ来构建一个RPC系统:客户端和可扩展的RPC服务器,这里将创建一个虚拟的RPC返回斐波那契数的服务。客户请求远程过程服务,虚拟服务器(server)实现数据处理,然后把响应回调队列中,然后客户端通过比对CorrelationId取出属于自己的响应结果

关键属性介绍:

这里介绍的是AMQP o-9-1协议为message定义的14个属性中的常用的是个属性

属性名作用
diliveryMode表示消息是否持久化
contentType表示消息是什么MIME类型,例如:application/json
replyTo表示回调队列的名字
correlationID标识该消息的通过RPC之后响应的唯一性,客户端可以通过这个属性取回自己的响应

编写代码时需要注意的点:

  • 获取channel的通道是方法是自己写的工具类
  • 服务器端和客户端多需要设置消息的correlationID(它是建立通信的关键)
  • 必须先启动服务器,在启动客户端,因为服务器端没每次启动多会使用channel.queuePurge(Rpc_queue)清空队列,如果先启动客户端,这样就会造成无限的阻塞

服务器端代码

@Slf4j
public class RPCServer {
    public static final String RPC_QUEUE="rpc_queue";
        public static Integer getfibonacci(Integer n){
            if(n==0) {
                return 1;
            }
            if(n==1) {
                return 1;
            }
            return  getfibonacci(n-1)+getfibonacci(n-2);
        }
    public static void main(String[] args) throws Exception{
        /*1. 从Rpc_queue中接收任务
        * 2. 处理任务(使用斐波那契数列来模拟处理过程)
        * 3. 根据客户端提供的回调队列(replyTo)返回响应
        * */
        //1.从Rpc_queue接收任务
        log.info("从Rpc_queue接收任务");
        Channel instance = ChannelInstance.getInstance();
        //清空测试的rep_queue
        instance.queuePurge(RPC_QUEUE);
        //设置不公平分发
        instance.basicQos(1);
        instance.queueDeclare(RPC_QUEUE,true,false,false,null);
        //2.处理任务+返回响应数据
        DeliverCallback deliverCallback=(consumerTag,delivery)->{
            log.info("正在处理任务之中");
            //获取要求的斐波那契数列的数
            String s = new String(delivery.getBody(),StandardCharsets.UTF_8);
            log.info("服务器接收的数据是:{}",s);
            int n = Integer.parseInt(s);
            Integer getfibonacci = getfibonacci(n);
            String response=String.valueOf(getfibonacci);
            //返回响应数据 使用默认交换机(直接类型)
            //服务端需要设置AMQP的14个属性中的CorrelationId,不然客户端那边接收消息的时候会报空指针异常
            AMQP.BasicProperties basicProperties=new AMQP.BasicProperties().builder()
                    .correlationId(delivery.getProperties().getCorrelationId())
                    .build();
            instance.basicPublish("",delivery.getProperties().getReplyTo(),
                    basicProperties,response.getBytes(StandardCharsets.UTF_8));
            //手动应答,并且不批量应答
            instance.basicAck(delivery.getEnvelope().getDeliveryTag(),false);
            log.info("处理了回调队列为{}的响应",delivery.getProperties().getReplyTo());
        };
        //改为手动应答
        boolean isAutoAck=false;
        instance.basicConsume(RPC_QUEUE,isAutoAck,deliverCallback,consumerTag -> {});
    }
}

客户端代码

@Slf4j
public class RPCClient {
    /*
     * 1. 发送请求到Rpc_queueh中
     * 2. 等待接收响应 */

    public static Channel instance;

    public static void main(String[] args) throws Exception {
        //获取连接发送请求
        log.info("客户端开启请求RPC服务.......");
        instance = ChannelInstance.getInstance();
        for (int i = 0; i < 32; i++) {
            String Task_correlationId = UUID.randomUUID().toString();
            //声明回调队列
            String replyqueuename = instance.queueDeclare().getQueue();
            AMQP.BasicProperties properties = new AMQP.BasicProperties().builder()
                    .replyTo(replyqueuename)
                    .correlationId(Task_correlationId)
                    .build();
            //发送请求
            log.info("客户端发送的数据是:{}", i);
            instance.basicPublish("", RPCServer.RPC_QUEUE, properties, String.valueOf(i).getBytes(StandardCharsets.UTF_8));
            final CompletableFuture<String> completableFuture = new CompletableFuture<>();
            ;
            //接收响应,需要启动自动应答才行,不然会因为手动应答阻塞
            String consumerTag = instance.basicConsume(replyqueuename, true, (messageTag, delivery) -> {
                //使用completableFuture来实现异步并行计算,提高效率 ,这里只是演示,实际上不推荐使用CompletableFuture的无参数构造方法
                if (delivery.getProperties().getCorrelationId().equals(Task_correlationId)) {
                    boolean complete = completableFuture.complete(new String(delivery.getBody(), StandardCharsets.UTF_8));
                    log.info("队列的该阶段的任务是否转换为完成{}", complete);
                }
            }, consumerTag01 -> {
            });
            //接收处理的结果
            String s = completableFuture.get();
            log.info("{}队列接收到的rpc服务器处理的结果为{}", replyqueuename, s);
            //更具consumerTag(标识)取消队列的订阅
            instance.basicCancel(consumerTag);
        }
       /* //关闭channel
        instance.close();*/
    }
}

结果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kyx9gUmR-1680343573324)(https://cdn.staticaly.com/gh/GhostQinMo/ImageBed@master/RabbitMQ/image-20230330154248284.png)]

image-20230330154332237

死信队列

概念

​ 先从概念解释上搞清楚这个定义,死信,顾名思义就是无法被消费的消息,字面意思可以这样理解,一般来说,producer将消息投递到 broker或者直接到queue里了,consumer 从queue取出消息进行消费,但某些时候由于特定的**原因导致queue中的某些消息无法被消费,**这样的消息如果没有后续的处理,就变成了死信,有死信自然就有了死信队列。
​ 应用场景:为了保证订单业务的消息数据不丢失,需要使用到RabbitMQ的死信队列机制,当消息消费发生异常时,将消息投入死信队列中.还有比如说:用户在商城下单成功并点击去支付后在指定时间未支付时自动失效。例如:客户下单以后,把订单放到死信队列中,如果半个小时内用户没有支付的话,移除该订单。

死信的三大来源

  • 消息TTL过期
  • 队列达到最大长度(消息过多,导致 队列满了,无法再添加数据到mq中)
  • 消息被拒绝(basic.reject或basic.nack)并且requeue=false
    • requeue=false的意思是不将拒绝的消息放回队列中,而是放到死信队列中

案例分析

架构图与代码实现

image-20230321084834153

造成死信的三种原理
消息TTL过期

这里获取的关于下一个发布消息消息的序列号总是0,而在如何处理异步为确认消息中long nextPublishSeqNo = channel.getNextPublishSeqNo();获取的却是递增的数

消息发布者:

@Slf4j
public class producer {
    public static final  String NORMAL_EXCHANGE="normal_exchange";
    private static final  String NORMAL_ROUTINGKEY="zhangsan";
    public static void main(String[] args)throws Exception{
        //获取连接
        Channel channelInstance = ChannelFactory.getChannelInstance();
        // 声明direct类型的交换机,
        channelInstance.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);
        //发布消息
        log.info("即将发送消息:");
        //设置消息TTL过期时间(这里是设置每个消息的过期时间为定值)
        //TODO 这里AMQP的注释文档没有找到
        //expiration的参数是一个String类型的单位为ms的过期时间
        AMQP.BasicProperties properties=new AMQP.BasicProperties().builder().expiration("10000").build();
        for (int i = 0; i < 10; i++) {
            String  message="message000000000dfa"+i;
            //TODO 这里获取的下一个消息的发布序列总是0啊,但是在如何处理异步为确认消息 获取序列是变化的
            long nextPublishSeqNo = channelInstance.getNextPublishSeqNo();
            log.info("下一个消息发布的消息序列》{},消息体为》{}的消息",nextPublishSeqNo,message);
            //消息持久化
            channelInstance.basicPublish(NORMAL_EXCHANGE,
                                        NORMAL_ROUTINGKEY,
                                        properties,
                    message.getBytes(StandardCharsets.UTF_8));
        }
    }
}

ConsumeC1

@Slf4j
public class ConsumeC1 {
    private static final String NORMAL_ROUTINGKEY = "zhangsan";
    public static final String DEAD_ROUTINGKEY = "lisi";
    private static final String NORMAL_QUEUE = "normal_queue";
    public static final String DEAD_QUEUE = "dead_queue";
    private static final String DEAD_EXCHANGE = "dead_exchange";

    public static void main(String[] args) throws Exception {
        //获取channel
        Channel channelInstance = ChannelFactory.getChannelInstance();
        //创建死信处理队列,并设置队列绑定到私信交换机
        //1. 创建direct类型的死信交换机
        channelInstance.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);

        //这个需要在ConsumeC1中声明才行,且需要在binding之前声明
        boolean isDurable=false;
        channelInstance.queueDeclare(ConsumeC1.DEAD_QUEUE,isDurable,false,false,null);

        //2. 死信交换机绑定对死信处理队列(但是为先声明DEAD_QUEUE)
        channelInstance.queueBind(DEAD_QUEUE, DEAD_EXCHANGE, DEAD_ROUTINGKEY);

        //创建队列并为正常队列绑定到死信交换机
        // 这里很多参数的key是固定的,需要上官网查
        HashMap<String, Object> arguments = new HashMap<>(3);
        /*因为这些全部在正常队列中设置
        1. 正常队列设置死信交换机 参数key是固定值
        * 2. 设置消息过期时间,但是不建议再这里设置
        * 3. 正常队列设置死信routing-key,参数key是固定值
        */
        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        //过期时间,单位为ms
        //arguments.put("x-message-ttl",100000);
        arguments.put("x-dead-letter-routing-key", DEAD_ROUTINGKEY);

        channelInstance.queueDeclare(NORMAL_QUEUE, isDurable, false, false, arguments);
        //normal_queue(正常队列)绑定到正常交换机上
        channelInstance.queueBind(NORMAL_QUEUE, producer.NORMAL_EXCHANGE, NORMAL_ROUTINGKEY);
        //接收消息
        //消息正常处理的回调函数
        log.info("Consume1等待接收消息:");
        DeliverCallback deliverCallback = (consumerTag, delivery) -> {
//            log.info("与消息相关的标签为{}", consumerTag);
            System.out.println("接收到的消息为》" + new String(delivery.getBody(), StandardCharsets.UTF_8) +
                    "消息的tag为》" + delivery.getEnvelope().getDeliveryTag());
        };
        //消息未正常接收的回调函数
        CancelCallback cancelCallback = (consumeTag) -> {
            log.warn("与消费者关联的tag为{}", consumeTag);
        };
        channelInstance.basicConsume(NORMAL_QUEUE, deliverCallback, cancelCallback);
    }
}

ConsumeC2

@Slf4j
public class ConsumeC2 {
    public static void main(String[] args)  throws Exception{
        Channel channelInstance = ChannelFactory.getChannelInstance();
        //死信交换机在Consumec1中声明过了
        //声明死死信队列(这个需要在ConsumeC1中声明才行)
        boolean isDurable=false;
        channelInstance.queueDeclare(ConsumeC1.DEAD_QUEUE,isDurable,false,false,null);
        //接收消息
        //自动应答
        DeliverCallback deliverCallback= (consumerTag,delivery)->{
            log.info("与消息相关的标签为{}", consumerTag);
            System.out.println("接收到的消息为》" + new String(delivery.getBody(), StandardCharsets.UTF_8) +
                    "消息的tag为》" + delivery.getEnvelope().getDeliveryTag());
        };
        channelInstance.basicConsume(ConsumeC1.DEAD_QUEUE,true,deliverCallback,(consumerTag)->{
            log.warn("与消费者关联的tag为{}", consumerTag);
        });
        log.info("一些其他的处理");
    }
}

实例结果:

实例启动步骤:先启动ConsumerC1,让其在MQ中创建相应的队列和交换机,然后关闭ConsumerC1,然后再启动producer,等待过期时间就会得到下图,然后启动ConsumeC2处理死信

image-20230321114026707

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1HdkgoSZ-1680343577678)(null)]

消息溢出队列

先要删除上面测试TTL生成的队列

  1. 再消息生产者代码中去掉消息的TTL属性

    image-20230321115227379

  2. ConsumerC1消费者中添加队列属性

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M6IrZ7NB-1680343579324)(null)]

  3. 测试启动顺序和上述TTL案例一样。

案例测试结果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gjqfQViJ-1680343585461)(null)]

消息被拒

需要关闭消息自动应答才能做消息拒绝处理

  1. 消息生产者代码不变
  2. ConsumerC1关闭自动应答,对指定消息做拒绝处理
@Slf4j
public class ConsumeC1 {
    private static final String NORMAL_ROUTINGKEY = "zhangsan";
    public static final String DEAD_ROUTINGKEY = "lisi";
    private static final String NORMAL_QUEUE = "normal_queue";
    public static final String DEAD_QUEUE = "dead_queue";
    private static final String DEAD_EXCHANGE = "dead_exchange";

    public static void main(String[] args) throws Exception {
        //获取channel
        Channel channelInstance = ChannelFactory.getChannelInstance();
        //创建死信处理队列,并设置队列绑定到私信交换机
        //1. 创建direct类型的死信交换机
        channelInstance.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);

        //这个需要在ConsumeC1中声明才行,且需要在binding之前声明
        boolean isDurable=false;
        channelInstance.queueDeclare(ConsumeC1.DEAD_QUEUE,isDurable,false,false,null);

        //2. 死信交换机绑定对死信处理队列(但是为先声明DEAD_QUEUE)
        channelInstance.queueBind(DEAD_QUEUE, DEAD_EXCHANGE, DEAD_ROUTINGKEY);

        //创建队列并为正常队列绑定到死信交换机
        // 这里很多参数的key是固定的,需要上官网查
        HashMap<String, Object> arguments = new HashMap<>(3);
        /*因为这些全部在正常队列中设置
        * 1. 正常队列设置死信交换机 参数key是固定值
        * 2. 设置消息过期时间,但是不建议再这里设置
        * 3. 正常队列设置死信routing-key,参数key是固定值
        * 4. 设置正常队列的最大缓冲值
        */
        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        //过期时间,单位为ms
        //arguments.put("x-message-ttl",100000);
        arguments.put("x-dead-letter-routing-key", DEAD_ROUTINGKEY);
        //设置缓冲值 测试拒绝策略是关闭限制缓冲
//        arguments.put("x-max-length",6);
        channelInstance.queueDeclare(NORMAL_QUEUE, isDurable, false, false, arguments);
        //normal_queue(正常队列)绑定到正常交换机上
        channelInstance.exchangeDeclare(producer.NORMAL_EXCHANGE,BuiltinExchangeType.DIRECT);
        channelInstance.queueBind(NORMAL_QUEUE, producer.NORMAL_EXCHANGE, NORMAL_ROUTINGKEY);
        //接收消息
        //消息正常处理的回调函数
        log.info("Consume1等待接收消息:");
        DeliverCallback deliverCallback = (consumerTag, delivery) -> {
            String message =new String(delivery.getBody(),StandardCharsets.UTF_8);
            //拒绝消息队列中的第5条消息
            if("消息体5".equals(message)){
                log.info(">>>>>>>>>>>>>>>>>>>>拒绝消息Tag为{},消息体为>>>{}",
                        delivery.getEnvelope().getDeliveryTag(),
                        new String(delivery.getBody(),StandardCharsets.UTF_8));
                //requeue 设置为 false 代表拒绝重新入队 该队列如果配置了死信交换机将发送到死信队列中
                channelInstance.basicReject(delivery.getEnvelope().getDeliveryTag(),false);
            }else{
            //log.info("与消息相关的标签为{}", consumerTag);
                System.out.println("接收到的消息为》" + new String(delivery.getBody(), StandardCharsets.UTF_8) +
                        "消息的tag为》" + delivery.getEnvelope().getDeliveryTag());
                channelInstance.basicAck(delivery.getEnvelope().getDeliveryTag(),false);
            }
        };
        //消息未正常接收的回调函数
        CancelCallback cancelCallback = (consumeTag) -> {
            log.warn("与消费者关联的tag为{}", consumeTag);
        };
        //测试决绝策略,必须关闭自动应答
        boolean isAutoAck=false;
        channelInstance.basicConsume(NORMAL_QUEUE, isAutoAck,deliverCallback, cancelCallback);
    }
}
  1. ConsumerC2代码不变
  2. 启动顺序为启动consumerC1,然后启动消息生产者,然后启动consumerC2

案例结果:

image-20230321162540163

image-20230321162527103

延迟队列

这里讲的延迟队列就是死信队列中的TTL过期的模式

概念

​ 延时队列,队列内部是有序的,最重要的特性就体现在它的延时属性上,延时队列中的元素是希望在指定时间到了以后或之前取出和处理,简单来说,延时队列就是用来存放需要在指定时间被处理的元素的队列。

队列内部是有序的在后面所有体现,队列中的消息是排队了 前面的延时长的消息会阻塞延时短的消息

使用场景

  1. 订单在十分钟之内未支付则自动取消
  2. 新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒。
  3. 用户注册成功后,如果三天内没有登陆则进行短信提醒。
  4. 用户发起退款,如果三天内没有得到处理则通知相关运营人员。
  5. 预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议

​ 这些场景都有一个特点,需要在某个事件发生之后或者之前的指定时间点完成某一项任务,如:发生订单生成事件,在十分钟之后检查该订单支付状态,然后将未支付的订单进行关闭;看起来似乎使用定时任务,一直轮询数据,每秒查一次,取出需要被处理的数据,然后处理不就完事了吗?如果数据量比较少,确实可以这样做,比如:对于“如果账单一周内未支付则进行自动结算”这样的需求,如果对于时间不是严格限制,而是宽松意义上的一周,那么每天晚上跑个定时任务检查一下所有未支付的账单,确实也是一个可行的方案。但对于数据量比较大,并且时效性较强的场景,如:“订单十分钟内未支付则关闭“,短期内未支付的订单数据可能会有很多,活动期间甚至会达到百万甚至千万级别,对这么庞大的数据量仍旧使用轮询的方式显然是不可取的,很可能在一秒内无法完成所有订单的检查,同时会给数据库带来很大压力,无法满足业务要求而且性能低下。

image-20230321163707172

RabbitMQ中的TTL

​ TTL是什么呢?TTL是RabbitMQ中一个消息或者队列的属性,表明一条消息或者该队列中的所有消息的最大存活时间,

​ 单位是毫秒。换句话说,如果一条消息设置了TTL属性或者进入了设置TTL属性的队列,那么这条消息如果在TTL设置的时间内没有被消费,则会成为"死信"。如果同时配置了队列的TTL和消息的TTL,那么较小的那个值将会被使用,下面介绍两种方式设置TTL。

消息设设置TTL

针对每一条消息设置独立的过期时间

image-20230321164151045

队列设置TTL

对每一条进入该队列的消息统一设置一个过期时间

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eI3hDCf0-1680343583257)(null)]

两者的区别

​ 如果设置了队列的TTL属性,那么一旦消息过期,就会被队列丢弃(如果配置了死信队列被丢到死信队列中),而为每个消息单独设置TTL,消息即使过期,也不一定会被马上丢弃,因为消息是否过期是在即将投递到消费者之前判定的,如果当前队列有严重的消息积压情况,则已过期的消息也许还能存活较长时间;另外,还需要注意的一点是,如果不设置TTL,表示消息永远不会过期,如果将TTL设置为0,则表示除非此时可以直接投递该消息到消费者,否则该消息将会被丢弃。

提醒两点:

  • 如果消息是单独设置TTL的话,消息是需要再投递给消费者之前才判定该消息是否过期的(见下图和测试结果2)

    • image-20230321205320408
  • 如果消息是单独设置TTL的话,将TTL设置为0时,如果不是再消费者再发送消息后立刻投递该消息到消费者,就会立即将该消息丢弃(前提时所有的消息要全部设置为0,如果第一条消息TTL很长,会阻塞后面为TTL为0的消息)(见测试结果1)

测试结果1

image-20230321170058385

测试结果2

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UKbAURdF-1680343582507)(null)]

​ 前一小节我们介绍了死信队列,刚刚又介绍了TTL,至此利用RabbitMQ实现延时队列的两大要素已经集齐,接下来只需要将它们进行融合,再加入一点点调味料,延时队列就可以新鲜出炉了。想想看,延时队列,不就是想要消息延迟多久被处理吗,TTL则刚好能让消息在延迟多久之后成为死信,另一方面,成为死信的消息都会被投递到死信队列里,这样只需要消费者一直消费死信队列里的消息就完事了,因为里面的消息都是希望被立即处理的消息。

整合SpringBoot

创建项目添加依赖
<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
<dependencies>
        <!--RabbitMQ 依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.47</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-simple -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.36</version>
        </dependency>
        <!--swagger-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
        <!--RabbitMQ 测试依赖-->
        <dependency>
            <groupId>org.springframework.amqp</groupId>
            <artifactId>spring-rabbit-test</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
添加连接MQ的配置文件
spring:
  rabbitmq:
    host: 192.168.241.128
    username: admin
    password: 123
    port: 5672
server:
  port: 8080
添加Swagger2配置类
//其实这个测试中没有使用到
package fairyqin.homelove.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Configuration
@EnableSwagger2
public class SwaggerConfig {
    @Bean
    public Docket webApiConfig() {
        return new Docket(DocumentationType.SWAGGER_2)
                .groupName("webApi")
                .apiInfo(webApiInfo())
                .select().build();
    }

    private ApiInfo webApiInfo() {
        return new ApiInfoBuilder()
                .title("rabbitmq 接口文档")
                .description("本文档描述了 rabbitmq 微服务接口定义")
                .version("1.0")
                .contact(new Contact("fairyqin", "http://Ghost.com",
                        "1551388580@qq.com"))
                .build();
    }
}

TTL案例

代码架构图

​ 创建两个队列QA和QB,两者队列TTL分别设置为10S和40S,然后在创建一个交换机X和死信交换机Y,它们的类型都是direct,创建一个死信队列QD,它们的绑定关系如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-blQ9evM6-1680343583438)(null)]

相关配置类
@Configuration
public class TTLconfig {
    //所有的关于案例中的常量信息全部声明在此类中
    //队列TTL为10s
    public static final String QUEUEA = "QA";
    public static final Integer QATTL = 10000;
    //队列TTL为40s
    public static final String QUEUEB = "QB";
    public static final Integer QBTTL = 40000;
    //死信处理队列
    public static final String DEAD_QUEUE = "QD";
    //正常交换机
    public static final String NORMAL_EXCHANGE = "X";
    //死信交换机
    public static final String DEAD_EXCHANGE = "Y";
    //正常交换机与QUEUEA的routingkey
    public static final String QA_BINDING_NORNAL_EXCHANGE_ROUTINGKEY = "XA";
    //正常交换机与QUEUEB的routingkey
    public static final String QB_BINDING_NORNAL_EXCHANGE_ROUTINGKEY = "XB";
    //相关队列与死信交换机的绑定的routingkey
    public static final String DEAD_EXCHAGE_ROUTING_KEY = "QD";

    //创建两个交换机
    @Bean(value = "NORMAL_EXCHANGE")
    public DirectExchange NORMAL_EXCHANGE() {
        //这里有自动开箱操作
        return ExchangeBuilder.directExchange(NORMAL_EXCHANGE)
                .durable(true)
                .build();

    }

    @Bean(value = "DEAD_EXCHANGE")
    public DirectExchange DEAD_EXCHANGE() {
        //这里有自动开箱操作
        return ExchangeBuilder.directExchange(DEAD_EXCHANGE)
                .durable(true)
                .build();

    }

    //创建两个正常队列,并绑定交换
    @Bean(value = "QUEUEA")
    public Queue QUEUEA() {
        //绑定死信交换机的参数
        HashMap<String, Object> arguments = new HashMap<>(3);
        //参数的key为固定值
        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        arguments.put("x-dead-letter-routing-key", DEAD_EXCHAGE_ROUTING_KEY);
        arguments.put("x-message-ttl", QATTL);
        return QueueBuilder.durable(QUEUEA)
                .withArguments(arguments)
                .build();
    }

    @Bean(value = "QUEUEB")
    public Queue QUEUEB() {
        //绑定死信交换机的参数
        HashMap<String, Object> arguments = new HashMap<>(3);
        //参数的key为固定值
        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        arguments.put("x-dead-letter-routing-key", DEAD_EXCHAGE_ROUTING_KEY);
        arguments.put("x-message-ttl", QBTTL);
        return QueueBuilder.durable(QUEUEB)
                .withArguments(arguments)
                .build();
    }

    //创建normal_exchange与正常队列的绑定时间
    @Bean("XA")
    public Binding XA(@Qualifier("NORMAL_EXCHANGE") DirectExchange NORMAL_EXCHANGE,
                      @Qualifier("QUEUEA") Queue QUEUEA) {
        Binding with = BindingBuilder.bind(QUEUEA).to(NORMAL_EXCHANGE).with(QA_BINDING_NORNAL_EXCHANGE_ROUTINGKEY);
        return with;
    }

    @Bean("XB")
    public Binding XB(@Qualifier("NORMAL_EXCHANGE") DirectExchange NORMAL_EXCHANGE,
                      @Qualifier("QUEUEB") Queue QUEUEA) {
        Binding with = BindingBuilder.bind(QUEUEA).to(NORMAL_EXCHANGE).with(QB_BINDING_NORNAL_EXCHANGE_ROUTINGKEY);
        return with;
    }

    //创建死信处理队列
    @Bean("DEAD_QUEUE")
    public Queue DEAD_QUEUE() {
        Queue build = QueueBuilder.durable(DEAD_QUEUE).build();
        return build;
    }

    //为死信队列绑定到死信交换机上
    @Bean("YD")
    public Binding YD(
            @Qualifier("DEAD_QUEUE") Queue queue,
            @Qualifier("DEAD_EXCHANGE") DirectExchange directExchange
    ) {
        Binding with = BindingBuilder.bind(queue).to(directExchange).with(DEAD_EXCHAGE_ROUTING_KEY);
        return with;
    }
}
消息生产者代码
@Slf4j
@RestController
@RequestMapping("/fairy")
public class MessageProductController {
    @Autowired
    RabbitTemplate rabbitTemplate;

    @RequestMapping("/sendMessage/{routingkey}/{message}")
    public void sendMessage(@PathVariable("message") String message,
                            @PathVariable("routingkey") String routingkey){
        log.info("消息发送者:当前时间{},向绑定routingkey为{}的队列发送消息,消息体为>{}",
                new Date(),
                routingkey,message);
        rabbitTemplate.convertAndSend(TTLconfig.NORMAL_EXCHANGE,routingkey,message);
    }
}
消息消费者代码
@Slf4j
@Component
public class DeadQueue {
    //添加监听器
    @RabbitListener(queues = {"QD"})
    //参数返回 message 和 channel
    public void Dead_letter_Queue(Message message, Channel channel){
        String s = new String(message.getBody(), StandardCharsets.UTF_8);
        log.info("死信队列:当前时间为{} ,死信队列收到的消息为> {}",new Date().toString(),message);
    }
}

案例结果:

image-20230322003544325

image-20230322002857791

​ 第一条消息在10S后变成了死信消息,然后被消费者消费掉,第二条消息在40S之后变成了死信消息,然后被消费掉,这样一个延时队列就打造完成了。
​ 不过,如果这样使用的话,岂不是每增加一个新的时间需求,就要新增一个队列,这里只有10S和40S两个时间选项,如果需要一个小时后处理,那么就需要增加TTL为一个小时的队列,如果是预定会议室然后提前通知这样的场景,岂不是要增加无数个队列才能满足需求?

延时队列优化

代码架构图

​ 在这里新增了一个队列QC,绑定关系如下,该队列不设置TTL时间

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g7h9PzrQ-1680343586281)(null)]

配置文件类代码
public static final String QUEUE_C = "QC";
    public static final String QC_binding_X_routingkey = "XC";

    //声明队列 C 死信交换机
    @Bean("queueC")
    public Queue queueC() {
        Map<String, Object> args = new HashMap<>(3);
    //声明当前队列绑定的死信交换机
        args.put("x-dead-letter-exchange", DEAD_EXCHANGE);
    //声明当前队列的死信路由 key
        args.put("x-dead-letter-routing-key", DEAD_EXCHAGE_ROUTING_KEY);
    //没有声明 TTL 属性
        return QueueBuilder.durable(QUEUE_C).withArguments(args).build();
    }

    //声明队列 QC绑定 X 交换机
    @Bean
    public Binding queuecBindingX(@Qualifier("queueC") Queue queueC,
                                  @Qualifier("NORMAL_EXCHANGE") DirectExchange NORMAL_EXCHANGE) {
        return BindingBuilder.bind(queueC).to(NORMAL_EXCHANGE).with(QC_binding_X_routingkey);
    }
消息生产者代码
 @RequestMapping("sendMessageAutoTTL/{ttl}/{message}")
    public void sendMessage(@PathVariable("message") String message,
                            @PathVariable("ttl") long ttl) {
        log.info("消息发送者:当前时间{},向绑定routingkey为{}的队列发送消息,消息体为>{},消息的TTL为{}",
                new Date(), TTLconfig.QC_binding_X_routingkey
                , message, ttl);
        MessagePostProcessor messagePostProcessor = (swap_message) -> {
            swap_message.getMessageProperties().setExpiration(String.valueOf(ttl));
            return swap_message;
        };
        rabbitTemplate.convertAndSend(TTLconfig.NORMAL_EXCHANGE, TTLconfig.QC_binding_X_routingkey, message, messagePostProcessor);
    }

结果1

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TjqWYsVs-1680343582600)(null)]

结果2

image-20230322012252880

​ 看起来似乎没什么问题,但是在最开始的时候,就介绍过如果使用在消息属性上设置TTL的方式,消息可能并不会按时“死亡“,因为RabbitMQ只会检查第一个消息是否过期,如果过期则丢到死信队列,如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行。

RabbitMQ插件实现延迟队列

基于插件的延时队列,没有死信交换机了 ,而是在交换机上设置延时规则

image-20230322135234272

安装延时队列插件
  • 插件官网:https://www.rabbitmq.com/community-plugins.html

  • 插件名字:rabbitmq_delayed_message_exchange

安装方法

  1. 将插件解压到RabbitMQ的插件目录
    • contos7的安装路径:/usr/lib/rabbitmq/lib/rabbitmq_server-3.8.8
  2. 进入RabbitMQ的安装目录下的plugins目录,执行下面的命令,然后重启RabbitMQ
  3. rabbitmq-plugins enable rabbimq_delayed_message_exchange //安装插件命令

插件的版本需要和RabbitMQ的版本最好一样,这里使用的RabbitMQ的版本是3.8.8,而插件的版本是3.8.0的,但是兼容就行

image-20230322135228223

  1. systemctl restart rabbitmq-server.service //重启服务

image-20230322135220520

代码架构图

​ 在这里新增了一个队列delayed.queue,一个自定义交换机delayed.exchange,绑定关系如下:

image-20230322155218316

配置类

​ 在我们自定义的交换机中,插件提供的是一种新的交换类型,该类型消息支持延迟投递机制消息传递后并不会立即投递到目标队列中,而是存储在mnesia(一个分布式数据系统)3表中,当达到投递时间时,才投递到目标队列中。也就是基于第三方实现延迟队列

@Configuration
public class delayed_config {
    //使用插件完成延迟操作
   /* * DELAYED_EXCHANGE 延迟交换机
     DELAYED_ROUTINGKEY routingkey
     * DELAYED_QUEUE  处理队列名字
     * */
    public  static  final String DELAYED_EXCHANGE="delayed_exchange";
    public  static  final String DELAYED_ROUTINGKEY="delayed_routingkey";
    public  static  final String DELAYED_QUEUE="delayed_queue";

    //声明延迟队列
    @Bean(value="delayed_queue")
    public Queue delayed_queue(){
        return new Queue(DELAYED_QUEUE);
    }
    //声明自定义交换机
    @Bean(value="delay_exchange")
    public CustomExchange delay_exchange(){
        //为什么自定义交换机是直接类型,因为这里使用的routingkey是一个固定值(delayed_routingkey)而不是路由
        HashMap<String,Object> arguments=new HashMap<>(3);
        //key的指是固定的
        arguments.put("x-delayed-type", "direct");
        //第二个参数自己添加的交换机的类型
        //为什么这里又是自定义类型,而参数设置又是直接交换机(Direct),我的理解是带有延迟设置的直接交换机
        CustomExchange customExchange   =new CustomExchange(DELAYED_EXCHANGE,
                "x-delayed-message",
                true,false,arguments);
        return  customExchange;
    }

    //创建绑定关系
    @Bean
    public Binding  Delayed_binding(
            @Qualifier("delay_exchange") CustomExchange customExchange,
            @Qualifier("delayed_queue") Queue queue
            ){
        return BindingBuilder.bind(queue).to(customExchange).with(DELAYED_ROUTINGKEY).noargs();
    }
}
消息生产者代码
@RestController
@Slf4j
@RequestMapping("/fairy")
public class delayedPluginController {
    @Autowired
    RabbitTemplate rabbitTemplate;
    //使用插件实现TTL
    //localhost:9090/fairy/sendDelayedMessage/30000/message消息延迟30000ms
    //localhost:9090/fairy/sendDelayedMessage/10000/message消息延迟10000ms
    @RequestMapping("sendDelayedMessage/{TTL}/{message}")
    public void  sendDelayedMessage(
            @PathVariable("TTL") Integer ttl,
            @PathVariable("message") String message
    ){
        log.info("消息发送者:当前时间{},向绑定routingkey为{}的队列发送消息,消息体为>{},消息的TTL为{}",
                new Date(), delayed_config.DELAYED_ROUTINGKEY
                , message, ttl);
        //消息的后置处理器
        MessagePostProcessor messagePostProcessor1=(ProcessMessage)->{
            //这里不是设置过期时间,而是设置延迟时间
            ProcessMessage.getMessageProperties().setDelay(ttl);
            return ProcessMessage;
        };
        rabbitTemplate.convertAndSend(delayed_config.DELAYED_EXCHANGE
                , delayed_config.DELAYED_ROUTINGKEY
                , message,messagePostProcessor1);
    }
}
消息消费者代码
@Slf4j
@Component
public class DeadQueue {
    //添加监听器
    //参数返回 message 和 channel
    @RabbitListener(queues = {"QD"})
    public void Dead_letter_Queue(Message message, Channel channel){
        String s = new String(message.getBody(), StandardCharsets.UTF_8);
        log.info("死信队列:当前时间为{} ,死信队列收到的消息为> {}",new Date().toString(),message);
    }
}

案例结果:

总结:解决队列阻塞的问题,但是在时间好像不是很准确

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K8g6owUa-1680343573336)(https://cdn.staticaly.com/gh/GhostQinMo/ImageBed@master/RabbitMQ/image-20230322205401138.png)]

总结:

​ 延时队列在需要延时处理的场景下非常有用,使用RabbitMQ来实现延时队列可以很好的利用
RabbitMQ的特性,如:消息可靠发送、消息可靠投递、死信队列来保障消息至少被消费一次以及未被正确处理的消息不会被丢弃。另外,通过RabbitMQ集群的特性,可以很好的解决单点故障问题,不会因为单个节点挂掉导致延时队列不可用或者消息丢失。

​ 其他一些关于延时队列的解决方案,java自带的DelayQueue(不能保证消息不丢失)、Redis的zset,利用Quartz4或者使用kafka的时间轮,更具使用场景使用

发布确认高级

​ 在生产环境中由于一些不明原因,导致rabbitmq重启,在RabbitMQ重启期间生产者消息投递失败,导致消息丢失,需要手动处理和恢复。于是,我们开始思考,如何才能进行RabbitMQ的消息可靠投递呢?持别是在这样比较极端的情况,RabbitMQ集群不可用的时候,无法投递的消息该如何处理呢:

发布确认springboot版本

确认机制解决方案

要是消息不丢失需要解决两个问题:

  • 交换机接收不到消息怎么办?消息存放到那里?

    • 解决方案:交换机确认收到消息在删除缓存
  • 队列接收不到消息怎么办?

    • 解决方案:队列确认收到消息在删除缓存

image-20230322211727340

代码架构图:

image-20230322211937874

案例构建过程

SpringBoot中关于RabbitMQ的配置类spring-boot/RabbitProperties.java at v2.7.9 · spring-projects/spring-boot (github.com)

配置文件

​ 需要在配置文件中添加发布确认使用的类型,总共有三种类型,这三种类型对一个枚举类ConfirmType

value功能
NONE禁用发布确认模式,是默认值
CORRELATED发布消息成功到交换器后会触发回调方法
SIMPLE有两种效果,其一和CORRELATED值一样会触发回调方法,其二是在发消息成功后使用rabbitTemlate调用waitForConfirms或waitForConfirmsOrDie方法等待borker节点返回发送结果(同步操作),根据返回结果来判断下一步的逻辑,需要注意的是waitForConfirmOrDie方法如果返回false则会关闭channel,则接下来无法发送消息到broker

添加配置属性

spring.rabbitmq.publisher-confirm-type= correlated
相关配置类
@Configuration
@Slf4j
public class SpringbooPublisher_confirm_Config {
    //直接类型的交换机名称
    public static final String CONFIRM_EXCHANGE = "confirm_exchange";
    //确认队列名称
    public static final String CONFIRM_QUEUE = "confirm_queue";
    //routingkey
    public static final String PUBLISHER_CONFIRM_ROUTINGKEY = "key1";

    //注入交换机
    @Bean(value = "confirm_exchange")
    public DirectExchange confirm_exchange() {
        DirectExchange build = (DirectExchange) ExchangeBuilder.directExchange(CONFIRM_EXCHANGE).durable(true).build();
        return build;
    }

    //注入队列
    @Bean("confirm_queue")
    public Queue confirm_queue() {
        Queue build = QueueBuilder.durable(CONFIRM_QUEUE).build();
        return build;
    }

    //绑定
    @Bean
    public Binding CreateBinding(
            @Qualifier("confirm_exchange") DirectExchange confirmexchange,
            @Qualifier("confirm_queue") Queue confirmqueue
    ) {
        Binding with = BindingBuilder.bind(confirmqueue).to(confirmexchange).with(PUBLISHER_CONFIRM_ROUTINGKEY);
        return with;
    }

    //注入交换机接收消息的回调方法
    //这里只是为测试没有处理分离出去
    @Bean("confirmCallback")
    public RabbitTemplate.ConfirmCallback inputConfirmCallBack() {
        /**交换机确认回调方法
         1.发消息交换机接收到了回调
         1.1 correlationData保存回调消息的ID及相关信息*
         1.2交换机收到消息ack = true
         1.3 causenull
         2.发消息交换机接收失败了回调
         2.1 correlationData保存回调消息的ID及相关信息
         2.2交换机收到消息ack = false
         2.3 cause失败的原因
         */
//        void confirm(@Nullable CorrelationData correlationData, boolean ack, @Nullable String cause);
        return (correlationData, ack, cause) -> {
            //注意了这里需要判断correlationData是否为空指针问题,如果忘记了,调用correlationData.getId()时会有warning
            //参数二为错误信息
            String id = correlationData != null ? correlationData.getId() : null;
            if (ack) {
                log.info("交换机接收到消息,消息的id为:{}", id);
                if (id == "100") {
                    log.info("返回消息的内容为:{}", new String(correlationData.getReturnedMessage().getBody()).toString());
                }
            } else {
                log.info("还未接收到id为{}的消息,为接收到的原因是 {}", id, cause);
            }
        };
    }
}
消息生产者
@RestController
@Slf4j
@RequestMapping("/confirm")
public class confirmmessagePublisher {
    @Autowired
    RabbitTemplate rabbitTemplate;
    @Autowired
    RabbitTemplate.ConfirmCallback confirmeCallback;

    //需要设置交换机确认返回回调接口
    @PostConstruct
    public void init() {
        rabbitTemplate.setConfirmCallback(confirmeCallback);
    }

    //localhost:8081/confirm/resendMessage/welcometofairyhom,message01
    //消息提供者
    @RequestMapping("/resendMessage/{message}")
    public void publusherConfirm(@PathVariable("message") String message) {
        //这里的correlationData就是在交换机调用ConfirmCallback函数式接口使用的数据,如果这里不提供的话,交换机确认回调函数返回就没有数据
        CorrelationData correlationData1 = new CorrelationData("100");
        String returnStr = "routingkey为key1";
        Message message1 = new Message(returnStr.getBytes(StandardCharsets.UTF_8), null);
        correlationData1.setReturnedMessage(message1);
        //给key1发送消息
        rabbitTemplate.convertAndSend(SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE
                , SpringbooPublisher_confirm_Config.PUBLISHER_CONFIRM_ROUTINGKEY, message, correlationData1);
        log.info("发送消息给交换机:{},消息体为:{},routingkey为:{}", SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE, message
                , SpringbooPublisher_confirm_Config.PUBLISHER_CONFIRM_ROUTINGKEY);
        //给不存在routingkey发送消息
        CorrelationData correlationData2 = new CorrelationData("200");
        rabbitTemplate.convertAndSend(SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE, "key2", message, correlationData2);
        log.info("发送消息给交换机:{},消息体为:{},routingkey为:{}", SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE, message, "key2");
    }
}
消息队列
@Component
@Slf4j
public class confirmConsume {
    //添加监听器
    @RabbitListener(queues = "confirm_queue")
    public void ConfirmListener(Message message, Channel channel) {
        //该案例是交换机收到消息
        log.info("从confirm_queue队列中接受的到的消息为:{},消息的id为:{}", new String(message.getBody(), StandardCharsets.UTF_8)
                , message.getMessageProperties().getMessageId());
    }
}
案例结果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MxpSdH7A-1680343573337)(https://cdn.staticaly.com/gh/GhostQinMo/ImageBed@master/RabbitMQ/image-20230323133540587.png)]

​ 可以看到,发送了两条消息,第一条消息的 RoutingKey为"key1",第二条消息的RoutingKey为"key2",两条消息都成功被交换机接收,也收到了交换机的确认回调,但消费者只收到了一条消息,因为第二条消息的 RoutingKey与队列的 BindingKey不一致,也没有其它队列能接收这个消息,所有第二条消息被直接丢弃了。

回退消息

Mandatory参数

在仅开启了生产者确认机制的情况下,交换机接收到消息后,会直接给消息生产者发送确认消息,如果发现该消息不可路由,那么消息会被直接丢弃,此时生产者是不知道消息被丢弃这个事件的。那么如何让无法被路由的消息帮我想办法处理一下?最起码通知我一声,我好自己处理啊。通过设置mandatory参数可以在当消息传递过程中不可达目的地时将消息返回给生产者。

注意点:

  • 消息不可以路由:指的是交换机不能将消息发送给队列

消息发布、确认、退回代码

其他的代码不需要改变

@RestController
@Slf4j
@RequestMapping("/confirm")
public class confirmmessagePublisher implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {
    @Autowired
    RabbitTemplate rabbitTemplate;
    @Autowired
    RabbitTemplate.ConfirmCallback confirmeCallback;

    //需要设置交换机确认返回回调接口
    @PostConstruct
    public void init() {
//        rabbitTemplate.setConfirmCallback(confirmeCallback);
        rabbitTemplate.setConfirmCallback(this);
        //开启交换机的Mandatory,有义务将未路由到队列的消息回退给消费者
        rabbitTemplate.setMandatory(true);
        rabbitTemplate.setReturnCallback(this);
    }

    //重写RabbitTemplate内部接口ConfirmCallback和ReturnCallback
    //交换机确认接收信息的回调方法
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        Assert.notNull(correlationData, "correlaionData为null");
        if (ack) {
            log.info("交换机接收到的消息id为:{},绑定的routingkey为{},消息的内容为:{}", correlationData.getId(),
                    correlationData.getReturnedMessage().getMessageProperties().getReceivedRoutingKey()
                    , new String(correlationData.getReturnedMessage().getBody(), StandardCharsets.UTF_8));
        } else {
            log.info("交换机没有接收到消息id为{}的消息,导致的原因是{}", correlationData.getId(), cause);
        }
    }

    //交换机未成功发送消息给路由队列,将消息退回给发送者的调用的回调函数,只有消息不可达目的是才会调用这个回调函数
    @Override
    public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
        log.error("交换机退回消息:{} 未将消息id为:{} 消息内容为:{} 发送到routingkey为:{}的队列",
                exchange,
                message.getMessageProperties().getMessageId(),
                new String(message.getBody(), StandardCharsets.UTF_8),
                routingKey);
        log.info("replyCode为{}", replyCode);
        log.info("replyText为{}", replyText);
    }


    //localhost:8081/confirm/resendMessage/welcometofairyhom,message01
    //消息提供者
    @RequestMapping("/resendMessage/{message}")
    public void publusherConfirm(@PathVariable("message") String message) {
        //这里的correlationData就是在交换机调用ConfirmCallback函数式接口使用的数据,如果这里不提供的话,交换机确认回调函数返回就没有数据
        //这个id不是消息的id,而是 交换机验证是使用的id
        CorrelationData correlationData1 = new CorrelationData("100");
        String returnkey1 = "routingkey为key1";

        MessageProperties messageProperties = new MessageProperties();
        messageProperties.setReceivedRoutingKey("key1");
        Message message1 = new Message(returnkey1.getBytes(StandardCharsets.UTF_8), messageProperties);
        correlationData1.setReturnedMessage(message1);

        //给key1发送消息
        rabbitTemplate.convertAndSend(SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE
                , SpringbooPublisher_confirm_Config.PUBLISHER_CONFIRM_ROUTINGKEY, message, correlationData1);
        log.info("发送消息给交换机:{},消息体为:{},routingkey为:{}", SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE, message
                , SpringbooPublisher_confirm_Config.PUBLISHER_CONFIRM_ROUTINGKEY);

        //给不存在routingkey=key2发送消息
        CorrelationData correlationData2 = new CorrelationData("200");
        String returnkey2 = "routingkey为key2";

        MessageProperties messageProperties2 = new MessageProperties();
        messageProperties2.setReceivedRoutingKey("key2");
        Message message2 = new Message(returnkey2.getBytes(StandardCharsets.UTF_8), messageProperties2);
        correlationData2.setReturnedMessage(message2);

        rabbitTemplate.convertAndSend(SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE, "key2", message, correlationData2);
        log.info("发送消息给交换机:{},消息体为:{},routingkey为:{}", SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE, message, "key2");
    }
}

测试结果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VS6XQQZy-1680343573337)(https://cdn.staticaly.com/gh/GhostQinMo/ImageBed@master/RabbitMQ/image-20230323145913292.png)]

备份交换机

​ 有了mandatory参数和回退消息,我们获得了对无法投递消息的感知能力,有机会在生产者的消息无法被投递时发现并处理。但有时候,我们并不知道该如何处理这些无法路由的消息,最多打个日志,然后触发报警,再来手动处理。而通过日志来处理这些无法路由的消息是很不优雅的做法,特别是当生产者所在的服务有多台机器的时候,手动复制日志会更加麻烦而且容易出错。而且设置mandatory 参数会增加生产者的复杂性,需要添加处理这些被退回的消息的逻辑。如果既不想丢失消息,又不想增加生产者的复杂性,该怎么做呢?前面在设置死信队列的文章中,我们提到,可以为队列设置死信交换机来存储那些处理失败的消息,可是这些不可路由消息根本没有机会进入到队列,因此无法使用死信队列来保存消息。在RabbitMQ中,有一种备份交换机的机制存在,可以很好的应对这个问题。什么是备份交换机呢?备份交换机可以理解为RabbitMQ中交换机的“备胎”,当我们为某一个交换机声明一个对应的备份交换机时,就是为它创建一个备胎,当交换机接收到一条不可路由消息时,将会把这条消息转发到备份交换机中,由备份交换机来进行转发和处理,通常备份交换机的类型为Fanout,这样就能把所有消息都投递到与其绑定的队列中,然后我们在备份交换机下绑定一个队列,这样所有那些原交换机无法被路由的消息,就会都进入这个队列了。当然,我们还可以建立一个报警队列,用独立的消费者来进行监测和报警。

代码架构图

image-20230323150421719

案例代码

配置文件类

 //备份交换机案例
    /*
    * 1. 需要把前面消息退回案例中使用的confirm_exchange删除
    * 2. 需要为confirm_exchange绑定*/
//注入交换机
    @Bean(value = "confirm_exchange")
    public DirectExchange confirm_exchange() {
        //只需要根据交换机的名字来绑定就可以了,不需要注入back_exchange
        ExchangeBuilder exchangeBuilder = ExchangeBuilder.directExchange(CONFIRM_EXCHANGE)
                .durable(true)
                .withArgument("alternate-exchange", BACKUP_EXCHANGE);
        Exchange build = exchangeBuilder.build();
      //        build.addArgument("alternate-exchange", BACKUP_EXCHANGE);  //这样也可以

        return (DirectExchange) build;
    }
----------------------------------------------

    public  static  final String BACKUP_EXCHANGE="backup_exchange";
    public  static  final String BACKUP_QUEUE="backup_queue";
    public  static  final String WARNING_QUEUE="warning_queue";

    //创建交换机和绑定关系
    @Bean("backup_exchange")
    public FanoutExchange backup_exchange(){
        return new FanoutExchange(BACKUP_EXCHANGE);
    }
    @Bean("back_queue")
    public Queue back_queue(){
        return new Queue(BACKUP_QUEUE);
    }
    @Bean("warning_queue")
    public Queue warning_queue(){
        return new Queue(WARNING_QUEUE);
    }

    @Bean
    public Binding backup_queue_to_backup_exchange(
            @Qualifier("back_queue") Queue back_queue,
            @Qualifier("backup_exchange") FanoutExchange backup_exchange
    ){
        //这里为什么没有with了,因为这里的交换机是fanout(扇出),订阅模式
        return BindingBuilder.bind(back_queue).to(backup_exchange);

    }
    @Bean
    public Binding warning_queue_to_backup_exchange(
            @Qualifier("warning_queue") Queue warn_queue,
            @Qualifier("backup_exchange") FanoutExchange backup_exchange
    ){
        //这里为什么没有with了,因为这里的交换机是fanout(扇出),订阅模式
        return BindingBuilder.bind(warn_queue).to(backup_exchange);
    }

备份队列

@Component
@Slf4j
public class backupExchangeModeConsumer {
    //监听backup.queue
    @RabbitListener(queues = SpringbooPublisher_confirm_Config.BACKUP_QUEUE)
    //注意这里的第二参数是String类型的,不是channel类型的了,不然会报转换Error
    public void backupConsumer(Message message, String messagebody){
        log.info("back.queue队列从备份交换机接收到的消息为 {},消息的ReceivedRoutingKey为 {}",
                new String(message.getBody(), StandardCharsets.UTF_8)
        ,message.getMessageProperties().getReceivedRoutingKey());

    }
    //监听backup.queue
    @RabbitListener(queues =SpringbooPublisher_confirm_Config.WARNING_QUEUE)
    public void warningConsumer(Message message,String string){
        log.warn("warning.queue队列从备份队列中接收的消息为 {}",new String(message.getBody(),StandardCharsets.UTF_8));
        log.info("接收到的string类型的属性为 {}",string);
    }
}

测试结果:

image-20230323171453612

**总结:**mandatory参数与备份交换机可以一起使用的时候,如果两者同时开启,备份交换机优先级高

RabbitMQ其他要点

幂等性

在ElasticSearch中讲过,简单理解是同样的请求响应结果不变

概念

​ 用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱了,流水记录也变成了两条。在以前的单应用系统中,我们只需要把数据操作放入事务中即可,发生错误立即回滚,但是再响应客户端的时候也有可能出现网络中断或者异常等等

消息重复消费

​ 消费者在消费MQ中的消息时,MQ已把消息发送给消费者,消费者在给MQ返回ack时网络中断,故MQ未收到确认信息,该条消息会重新发给其他的消费者,或者在网络重连后再次发送给该消费者,但实际上该消费者已成功消费了该条消息,造成消费者消费了重复的消息。

解决方案

​ MQ消费者的幂等性的解决一般使用全局ID或者写个唯一标识比如时间戳或者UUID或者订单消费者消费MQ中的消息也可利用MQ的该id来判断,或者可按自己的规则生成一个全局唯一id(这个id是MQ为每个消息生成的),每次消费消息时用该id 先判断该消息是否已消费过。

消费端的幂等性保障

​ 在海量订单生成的业务高峰期,生产端有可能就会重复发生了消息,这时候消费端就要实现幂等性,这就意味着我们的消息永远不会被消费多次,即使我们收到了一样的消息。业界主流的幂等性有两种操作:

  • a.唯一ID+指纹码机制,利用数据库主键去重
    • 指纹码:我们的一些规则或者时间戳加别的服务给到的唯一信息码,它并不一定是我们系统生成的,基本都是由我们的业务规则拼接而来,但是一定要保证唯一性,然后就利用查询语句进行判断这个id是否存在数据库中,优势就是实现简单就一个拼接,然后查询判断是否重复;劣势就是在高并发时,如果是单个数据库就会有写入性能瓶颈当然也可以采用分库分表提升性能,但也不是我们最推荐的方式。
  • b.利用redis的原子性去实现(推荐使用)
    • 利用 redis 执行 setnx 命令,天然具有幂等性。从而实现不重复消费

优先级队列

使用场景

​ 在我们系统中有一个订单催付的场景,我们的客户在天猫下的订单,淘宝会及时将订单推送给我们,如果在用户设定的时间内未付款那么就会给用户推送一条短信提醒,很简单的一个功能对吧,但是,tmall商家对我们来说,肯定是要分大客户和小客户的对吧,比如像苹果,小米这样大商家一年起码能给我们创造很大的利润,所以理应当然,他们的订单必须得到优先处理,而曾经我们的后端系统是使用redis来存放的定时轮询,大家都知道redis只能用List做一个简简单单的消息队列,并不能实现一个优先级的场景,所以订单量大了后采用RabbitMQ进行改造和优化,如果发现是大客户的订单给一个相对比较高的优先级,否则就是默认优先级。使用redis的zset也可以实现

image-20230319125451920

控制台添加优先级队列

优先级的设置范围为一个字节(0-255),数字越大优先级越高,不建议将优先级设置很大

image-20230323171442250

image-20230323171639696

代码中添加优先级队列

队列创建优先级

属性key为x-max-priority

// 在配置文件中添加
//声明优先级队列
    public final  static   String  PRIORITYQUEUE="priorityqueue";
    @Bean
    public Queue createPriorityQueue(){
        Queue build = QueueBuilder.durable(PRIORITYQUEUE).withArgument("x-max-priority", 10).build();
        return build;
    }

image-20230323172728501

SpringBoot中设置消息的优先级
//springboot中设置消息的优先级

//localhost:8081/confirm/resendMessage/welcometofairyhom,message01
    //消息提供者
    @RequestMapping("/resendMessage/{message}")
    public void publusherConfirm(@PathVariable("message") String message) {
        //这里的correlationData就是在交换机调用ConfirmCallback函数式接口使用的数据,如果这里不提供的话,交换机确认回调函数返回就没有数据
        //这个id不是消息的id,而是 交换机验证是使用的id
        CorrelationData correlationData1 = new CorrelationData("100");
        String returnkey1 = "routingkey为key1";
        MessageProperties messageProperties = new MessageProperties();
        messageProperties.setReceivedRoutingKey("key1");
        messageProperties.setMessageId("ID1");
        
        messageProperties.setPriority(5);//设置消息的优先级为5
       
        Message message1 = new Message(returnkey1.getBytes(StandardCharsets.UTF_8), messageProperties);
        correlationData1.setReturnedMessage(message1);
        //给key1发送消息
        rabbitTemplate.convertAndSend(SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE
                , SpringbooPublisher_confirm_Config.PUBLISHER_CONFIRM_ROUTINGKEY, message, correlationData1);
        log.info("发送消息给交换机:{},消息体为:{},routingkey为:{}", SpringbooPublisher_confirm_Config.CONFIRM_EXCHANGE, message
                , SpringbooPublisher_confirm_Config.PUBLISHER_CONFIRM_ROUTINGKEY);
基本的设置消息的优先级
//设置消息的优先级为5
AMQP.BasicProperties properties=new AMQP.BasicProperties().builder().priority(5).build();
注意事项

要让队列实现优先级需要做的事情有如下几点:

  • 队列需要设置为优先级队列
  • 消息需要设置消息的优先级
  • 消费者需要等待消息已经发送到队列中才去消费(需要消息的并发量高)
优先级代码

消息生产者

@Slf4j
public class PriorityProvider {
    public static final String PRIORITYQUEUE = "priorityqueue";
    public static void main(String[] args) throws Exception {
        Channel channelInstance = ChannelInstance.getChannelInstance();
        AMQP.BasicProperties basicProperties = new AMQP.BasicProperties().builder().priority(2).build();
        log.info("开始发送消息");
        for (int i = 0; i < 10; i++) {
            String s = "消息体welcome to fairyqin " + i;
            //使用默认交换机
            if (i == 6) {
                AMQP.BasicProperties basicProperties9 = new AMQP.BasicProperties().builder().priority(9).build();
                channelInstance.basicPublish("", PRIORITYQUEUE, basicProperties9, s.getBytes(StandardCharsets.UTF_8));
                log.info("发送的消息为{},消息的优先级为{},是第{}个消息",s,basicProperties9.getPriority(),i);
            } else {
                channelInstance.basicPublish("", PRIORITYQUEUE, basicProperties, s.getBytes(StandardCharsets.UTF_8));
                log.info("发送的消息为{},消息的优先级为{},是第{}个消息",s,basicProperties.getPriority(),i);
            }

        }
    }
}

消息消费者

@Slf4j
public class PriorityMessageConsume {
    public static void main(String[] args) throws Exception {
        //获取channel
        Channel channelInstance = ChannelInstance.getChannelInstance();
        //创建队列,设置优先级队列
        HashMap<String, Object> arguments = new HashMap<>(1);
        //设置队列的最大优先级为10,范围为(0-255)不建议设置很大,影响性能
        arguments.put("x-max-priority", 10);
        channelInstance.queueDeclare(PriorityProvider.PRIORITYQUEUE, true, false, false, arguments);
        //接收消息
        log.info("等待接收消息:");
        DeliverCallback deliverCallback = (ConsumerTag, delivery) -> {
            log.info("{} 队列接收到的消息体为:{},消息的标识为{},该消息与consumer相关的Tag为{},消息优先级为{}",
                    PriorityProvider.PRIORITYQUEUE, new String(delivery.getBody()),
                    delivery.getEnvelope().getDeliveryTag(), ConsumerTag
            ,delivery.getProperties().getPriority());
        };
        channelInstance.basicConsume(PriorityProvider.PRIORITYQUEUE, true, deliverCallback, (messageTag) -> {
        });

    }
}

惰性队列

使用场景

​ RabbitMQ从3.6.0版本开始引入了惰性队列的概念。惰性队列会尽可能的将消息存入磁盘中,而在消费者消费到相应的消息时才会被加载到内存中,它的一个重要的设计目标是能够支持更长的队列,即支持更多的消息存储。当消费者由于各种各样的原因(比如消费者下线、宕机亦或者是由于维护而关闭等)而致使长时间内不能消费消息造成堆积时,惰性队列就很有必要了。

​ 默认情况下,当生产者将消息发送到RabbitMQ的时候,队列中的消息会尽可能的存储在内存之中,这样可以更加快速的将消息发送给消费者。即使是持久化的消息,在被写入磁盘的同时也会在内存中驻留一份备份。当RabbitMQ需要释放内存的时候,会将内存中的消息换页至磁盘中,这个操作会耗费较长的时间,也会阻塞队列的操作,进而无法接收新的消息。虽然RabbitMQ的开发者们一直在升级相关的算法,但是效果始终不太理想,尤其是在消息量特别大的时候。

Classic队列的两种模式

  • 有两种类型队列

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NUv7RM8t-1680343586717)(null)]

  • 但是Classic类型的队列可以工作在两种模式下,default模式和lazy模式,但是官网解释Classic队列将会在未来的版本被移除

    image-20230323225117242

官网关于懒惰队列的介绍Lazy Queues — RabbitMQ,官网介绍推荐使用 policy(策略)来配置信息

​ 队列具备两种模式: default和lazy。默认的为default模式,在3.6.0之前的版本无需做任何变更。lazy模式即为惰性队列的模式,可以通过调用channel.queueDeclare方法的时候在参数中设置,也可以通过Policy的方式设置,如果一个队列同时使用这两种方式设置的话,那么Policy的方式具备更高的优先级。如果要通过声明的方式改变已有队列的模式的话,那么只能先删除队列,然后再重新声明一个新的。

​ 在队列声明的时候可以通过"x-queue-mode"参数来设置队列的模式,取值为"default"和"lazy"。下面示例中演示了一个惰性队列的声明细节:

//处理可以通过queueDeclare来决定Classic类型队列处于那种模式,还可以通过policy来设置Classic类型的队列处于那种模式
Map<String, Object> args = new HashMap<String,Object>();
args.put("x-queue-mode" , "lazy");
channel.queueDeclare("myqueue", false, false, false, args);

使用策略设置Classic队列的模式

image-20230323225742769

内存开销队列

这里是比较Classic类型的队列在default模式和lazy模式下的对比

image-20230323225210174

​ 在发送1百万条消息,每条消息大概占1KB的情况下,普通队列占用内存是1.2GB,而惰性队列仅仅占用1.5MB。1.5MB是指在内存中存放了消息的索引,消费消息通过索引区磁盘中找到消息在消费而default模式下的Classic队列是把消息全部放到内存中的

RabbitMQ集群

使用集群的原因

​ 最开始我们介绍了如何安装及运行RabbitMQ服务,不过这些是单机版的,无法满足目前真实应用的要求。如果RabbitMQ服务器遇到内存崩溃、机器掉电或者主板故障等情况,该怎么办?单台RabbitMQ服务器可以满足每秒1000条消息的吞吐量,那么如果应用需要RabbitMQ服务满足每秒10万条消息的吞吐量呢?购买昂贵的服务器来增强单机 RabbitMQ务的性能显得捉襟见肘,搭建一个RabbitMQ集群才是解决实际问题的关键.

RabbitMQ集群的分类

​ RabbitMQ 是一个开源消息代理软件(有时称为面向消息的中间件),最初实现了高级消息队列协议 (AMQP),后来通过插件架构进行了扩展,以支持面向流式文本的消息传递协议 (STOMP)、MQ 遥测传输 (MQTT) 和其他协议。

​ RabbitMQ 服务器是用 Erlang 编写的,它建立在开放电信平台框架之上,用于集群和故障转移。用于与代理交互的客户端库可用于所有主要编程语言。Erlang又是一个面向并发的语言,天然支持集群模式

  • 标准集群:是一种分布式集群,将队列分散到集群的各个节点,从而提高整个集群的并发能力。
  • 镜像集群:是一种主从集群,标准集群的基础上,添加了主从备份功能(非强一致性),提高集群的数据可用性。
  • 仲裁队列: 是3.8版本以后才有的新功能,用来替代镜像集群,也是一种主从集群,主从同步基于Raft协议,强一致。

​ 镜像集群虽然支持主从,但主从同步并不是强一致的,某些情况下可能有数据丢失的风险。因此在RabbitMQ的3.8版本以后,推出了新的功能:仲裁队列来代替镜像集群,底层采用Raft协议确保主从的数据一致性。

标准集群
  • 标准模式集群不进行数据同步。
  • 会在集群的各个节点间共享部分数据,包括:交换机、队列元信息。不包含队列中的消息。
  • 当访问集群某节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回。
  • 队列所在节点宕机,队列中的消息就会丢失。
  • 比如:我们有2个MQ:mq1和mq2,如果你的消息在mq1,而你连接到了mq2,那么mq2会去mq1拉取消息,然后返回给你。如果mq1宕机,消息就会丢失。

镜像集群

镜像集群:本质是主从模式,具备下面的特征:

  • 交换机、队列、队列中的消息会在各个mq的镜像节点之间同步备份。
  • 创建队列的节点被称为该队列的主节点, 备份到的其它节点叫做该队列的镜像节点。
  • 一个队列的主节点可能是另一个队列的镜像节点。
  • 所有操作都是主节点完成,然后同步给镜像节点。
  • 主节点宕机后,镜像节点会替代成新的主节点。

例如下图中的每一个队列多有一个镜像队列在其他的节点中

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3rKd2R2j-1680343579836)(null)]

仲裁队列( quorum queues

Quorum queues(仲裁队列)RabbitQM以后的趋势,Classic(经典)队列将会在原来的版本中被抛弃。使用仲裁队列来代替镜像队列(3.9中被抛弃)能更好的实现队列的复制功能等。3.8.0开始使用的。仲裁队列采用不同的复制 和共识协议,并放弃对某些“瞬态”性质特征的支持

  • 与镜像队列一样,都是主从模式,支持主从数据同步
  • 使用非常简单,没有复杂的配置
  • 主从同步基于Raft5协议,强一致性
  • 仲裁队列针对数据安全性提供绝对的保证(raft一致性算法)
  • 专注于构建数据安全和可预测的恢复,并且不支持某些功能

这里先补充两个知识点:Stream和SuperStream,也可以直接跳过直接看仲裁队列

Stream简介

​ 官网地址:流 — 兔子MQ (rabbitmq.com)

​ Stream是一种新的持久和复制的数据结构,建议通过相关客户端(UI)使用,客户端提供了对所用特定于流的功能的访问,吞吐量性能的展示。Stream是不可变的仅追加磁盘日志实现,这意味着日志将无限增长,知道磁盘使用完,但是通过设置x-max-age (Stream的最大期限)参数可以解决这种问题

​ Stream适合的经典场景有下类4个案例,这里案例使用其他的队列来实现多存在缺点

  • 大型扇出

    流将允许任务数量的消费者消费者以非破坏性的方式从同一队列中获取相同的消息,从而消除了为每一个消费者绑定专用的队列,而且流可以允许使用者从集群的副本中读取消息

  • Replay / Time-travelling(回访)

    由于所有当前的RabbitMQ队列类型都具有破坏性消费行为,即消息.当消费者完成它们时,从队列中删除,它不是可以重新读取已使用的消息。流将允许使用者在日志中的任何点附加并从那里读取。

  • 吞吐量性能

    使用Stream可以比以前的队列提供更好的吞吐量

  • 大量的日志

    Stream可以存储使用最小的内存存储大量的日志

使用Stream的方式
创建Stream

​ 使用Stream就像使用一起的队列一样,使用前必须先声明Stream,但是这个需要rabbitmq的版本为3.9及以上

​ 在声明的时候需要设置x-queue-type=stream,这个参数默认为classic类型,这个参数必须有客户端在声明队列时提供,这个参数不能由policy(策略)来设置或更改,应为队列是不能定义好了就不能更改了的

 /*
     * Stream支持三个动态设置的参数
     * arguments.put("x-queue-type", "stream");
     *arguments.put("x-max-length-bytes", 20_000_000_000); // maximum stream size: 20 GB
     * x-max-age  //Stream的最大期限 默认不需要设置
     * arguments.put("x-stream-max-segment-size-bytes", 100_000_000); // size of segment files: 100 MB
     * */
    @Test
    public void ArgumentTest() throws Exception{
        log.info("测试创建Stream ");
        Channel instance = ChannelInstance.getInstance();
        Map<String,Object > argument=new HashMap<>(3);
        argument.put("x-queue-type","stream");  //这里的key和value是一样的
        argument.put("x-max-length-bytes",2000000000);
        argument.put("x-stream-max-segment-size-bytes",100_000_000);   //单位为字节
        //使用Stream必须 声明Stream,像声明队列一
        instance.queueDeclare("Stream_queue", true, false, false,argument);
    }

image-20230401094534480

  • Stream支持三个参数,最好使用policy为Stream添加这个三个参数
参数描述
x-max-length-bytesStream的最大大小
x-max-ageStream的最大期限
x-stream-max-segment-size-bytes流在磁盘上划分为固定大小的段文件,由该参数控制默认值为500T

在使用Stream的时候出现了这个错误:

image-20230401092232401

原因是:Stream到3.9才出现的,使用docker快速开启一个最新的进行测试,也可以使用SpringCloudStream来使用

image-20230401094101093

image-20230401092051023

image-20230401092115913

客户端操作Stream

​ 由于Stream永远不会删除任何消息,因此任何消费者都可以开始读取/消费从日志中的任何点。这由 x-stream-offset参数来设置读取的偏移量。如果未指定,则使用者将从写入的下一个偏移量开始读取到使用者启动后的日志。x-stream-offset可以选择的值

optionaldscription
first从日志中的第一个可用消息开始
last从最后写入的消息"块"(块是Stream种使用的存储单元)
next默认值

​ 关于偏移量(x-stream-offset)是指附加到日志的确切偏移量的数值,消费者可以指定这个偏移量来获取指定索引的消息,如果不指定,偏移量分别固定到日志的开始或结束。如果指定的是一个时间戳,将获取最近的偏移量,如果指定的时间戳超出日志的开始或者结束的范围,他将以开始或结束为标志,如果需要指定相对于当前时间的一个时间间隔来当偏移量,使用的格式和x-max-age是一样的

获取first偏移量消息

@Slf4j
public class StreamConsumer {
    /*
    * 1.获取Stream中的第一条日志(消息)*/
    @Test
    public void getStreamFirst()throws Exception{
        //注意这里的rabbitmq的版本必须在3.9及以上
        Channel instance = ChannelInstance.getInstance();
        instance.basicQos(1);
        log.info("开始获取Stream中的第一个可用的消息");
        //Stream中的第一条日志
        boolean isAutoAck=false;
        instance.basicConsume(StreamTest.STREAM_QUEUE,
                isAutoAck,
                Collections.singletonMap("x-stream-offset","first"),
                (consumerTag,delivery)->{
                log.info("Stream中的第一个可用的日志的内容是{}", new String(delivery.getBody(), StandardCharsets.UTF_8));
                instance.basicAck(delivery.getEnvelope().getDeliveryTag(),false);
                },consumerTag -> {});
        TimeUnit.SECONDS.sleep(100000000);  //需要阻塞,因为消费者是异步的,类似于新开的线程是守护线程
    }
}

结果

image-20230401104901824

而且队列中的消息不会被删除(及时消费了该消息)

image-20230401105030569

获取指定偏移量的消息及该索引以后的消息

@Test
    public void getStreamCustom()throws Exception{
        //注意这里的rabbitmq的版本必须在3.9及以上
        Channel instance = ChannelInstance.getInstance();
        instance.basicQos(1); //预取值必须设置,不然很容易宕机
        log.info("开始获取Stream中的第一个可用的消息");
        //Stream中的第一条日志
        boolean isAutoAck=false;
        instance.basicConsume(StreamTest.STREAM_QUEUE,
                isAutoAck,
                Collections.singletonMap("x-stream-offset",11),
                (consumerTag,delivery)->{
                    log.info("Stream中的第一个可用的日志的内容是{}", new String(delivery.getBody(), StandardCharsets.UTF_8));
                    instance.basicAck(delivery.getEnvelope().getDeliveryTag(),false);
                },consumerTag -> {});
        TimeUnit.SECONDS.sleep(100000000);
    }

获取指定时间戳的消息及该索引以后的消息

​ 这里需要注意一点,消息发布的消息使用的是世界标准时间(格林时间),与你所在地区存在时间差,例如你在北京与你存入的记录的时间是差8个小时的,所以你在查询的时候要多减去8个小时。但是你是开启消费者等待,然后再去启动消息的发布者的话,设置了这个时间也没用,消息一样的会被接收,因为你的时间戳是溢出的话(超出了当前时间),它会以当前时间为标准,所以你发布的消息一样的可以被接收

//从相对于当前时间来查找消息
    @Test
    public void getStreamTime()throws Exception{
        //注意这里的rabbitmq的版本必须在3.9及以上
        Channel instance = ChannelInstance.getInstance();
        instance.basicQos(100); //预取值必须设置,不然很容易宕机
        Date date=new Date(System.currentTimeMillis()-1000*60*60*(8+5)); //因为我是5小时前存放的数据
        log.info(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>{}",date);
        log.info("开始获取Stream中的第一个可用的消息");
        //Stream中的第一条日志
        boolean isAutoAck=false;
        instance.basicConsume(StreamTest.STREAM_QUEUE,
                isAutoAck,
                Collections.singletonMap("x-stream-offset",date),
                (consumerTag,delivery)->{
                    log.info("Stream中的第一个可用的日志的内容是{}", new String(delivery.getBody(), StandardCharsets.UTF_8));
                    instance.basicAck(delivery.getEnvelope().getDeliveryTag(),false);
                },consumerTag -> {});
        TimeUnit.SECONDS.sleep(100000000);  //需要阻塞
    }
单一活跃消费者

官网参考地址:RabbitMQ 3.11 功能预览:流的单个活动使用者 |兔子MQ - 博客

​ 流的单个活动使用者是 RabbitMQ 3.11 及更高版本中提供的一项功能。 它在流上提供独占消耗和消耗连续性。 当多个消费者使用同一个Stream的时候,一次只有一个实例处于活动状态来接收消息, 其他实例将处于空闲状态。使用单个消费者的好处

  • 消息按顺序处理:一次只有一个消费者
  • 保证消息的连续性:如果活跃的消费者停止或崩溃,组中的消费者将接管(这里体现了消费者组的概念)

image-20230401151348889

image-20230401151413386

关于具体的使用,你可以参考上面提供的地址,应为需要安装3.11版本,你如果需要测试,你可以使用docker快速开启一个最新的rabbitmq实例来测试,这里可以提供演示的代码https://github.com/acogoluegnes/rabbitmq-stream-single-active-consumer

超级流(Super Streams)

**注意:**超级流需要RabbtiMQ3.11及更高的版本

Super Streams简介

​ 超级流是由单个常规流组成的逻辑流,是Rabbitmq流横向扩展的一种解决方案,它将大型的逻辑流划分为分区流,拆分集群节点上的存储和流量,而且能保证分区内消息的顺序,超级流还是一个逻辑实体,对外展示为一个整体。

Super Streams使用

​ 通过插件使用rabbitmq-streams add_super_stream命令可以很容易超级流。其他的创建超级流的方法见官网

//创建具有3个分区的超级流
rabbitmq-streams add_super_stream invoices --partitions 3
// rabbitmq-streams add_super_stream --help 了解有关该命令的更多信息

超级流需要在数据量很大的时候才会使用

经典队列&仲裁队列

image-20230401160902186

不适用使用冲裁队列的情况
  • 临时性队列:暂时性或独占队列
  • 需要低延时的应用:由于数据安全,底层共识算法有更好的延时
  • 当数据安全不是优先考虑的
  • 队列存存在很长的积压(流可以解决这种问题)
声明仲裁队列

在客户端使用x-queue-type=quorum进行声明为仲裁队列,该参数的默认值为classic,仲裁队列不能通过policy进行声明,因为队列是队列的类型是不能通过策略动态修改的。默认声明的仲裁队列最多有5个副本,如果集群中的节点不大于5个,每个节点一个副本,如果大于5个,默认也只有5个节点中有副本,多余的节点不托管任何副本,仲裁队列一样的可以绑定交换机

  • 可以通过x-quorum-initial-group-size来设置初始副本的大小(建议设置等于集群中节点数目,因为太多了也没有)
//声明仲裁队列
@Slf4j
public class Quorum_declare {
    //这里的编译前异常可以通过构造方法抛出去
    final Channel instance = ChannelInstance.getInstance();

    public Quorum_declare() throws Exception {
    }

    //这里不是在集群中,仅仅是声明一个仲裁队列而且,一个节点没有分区容忍性
    public static void main(String[] args) throws Exception {
        try (Channel channel = new Quorum_declare().instance) {
            log.info("声明仲裁队列 。。。");
//            x-queue-type=quorum 设置仲裁队列
            channel.queueDeclare("quorum_queue", true, false, false,
                    Collections.singletonMap("x-queue-type", "quorum"));
        }
    }
}

image-20230401173001899

使用仲裁队列需要注意点

  • 不能声明全局的Qos(服务质量),只能为每个服务者设置大单独设置Qos预取值

通过下面三个选项可以对哪个副本可以称为队列的leader(主节点)

  • 通过policy设置key为queue-leader-locator(推荐使用)
  • 在配置文件中定义queue-leader-locator(推荐使用)
  • 在声明队列是使用optinal参数x-queue-leader-locator

queue-leader-locator的值可以为两个

  • client-local :选择声明队列的客户端连接到的节点。这是默认值。
  • balanced:如果队列总数少于 1000 个(经典队列、仲裁队列和流), 选择托管最少数量的仲裁队列领导者的节点。 如果队列总数超过 1000 个,请选择一个随机节点。

其他的看官网仲裁队列 — 兔子MQ (rabbitmq.com)

Raft强一致性算法的参考博客:(第一个地址和第二个地址的内容是相同的)

Raft 算法(详细版) - 简书 (jianshu.com)

(3条消息) Raft 算法简介(转载)_努力工作中的博客-CSDN博客

(5条消息) 分布式共识算法 —— Raft详解_raft共识_shenmingik的博客-CSDN博客

主机搭建

​ 不需要启动三台虚拟机也是可以搭建测试集群的,3.11.11版本中如果是多个节点运行在一台主机上,必须使用不同的前缀,例如:rabbit1@hostname和rabbit2hostname

image-20230324134610864

下面介绍启动三台虚拟机搭建集群

  • 修改3台机器的主机名称

    vim /etc/hostname

  • .配置各个节点的 hosts 文件,让各个节点都能互相识别对方

    #为三台主机配置域名
    vim /etc/hosts
    10.211.55.74 node1
    10.211.55.75 node1
    10.211.55.76 node1
    
  • 要求集群中的每个节点的cookie必须是相同的值

    .erlang.cookie是个隐藏文件,使用ll -al可以看到

    image-20230324233248380

    在 node1 上执行远程操作命令
    scp /var/lib/rabbitmq/.erlang.cookie root@node2:/var/lib/rabbitmq/.erlang.cookie
    scp /var/lib/rabbitmq/.erlang.cookie root@node3:/var/lib/rabbitmq/.erlang.cookie
    
  • 启动RabitMQ服务,顺带启动Erlang虚拟机和RabbitMQ应用服务(所有节点上执行)

    • rabbitmq-server -detached //后台启动一个节点,并且节点的pid不会写入pid文件中

      image-20230324134438736

  • 在节点2、节点3执行

    • rabbitmqctl — tool for managing RabbitMQ nodes (管理一个rabbitmq节点)

    • rabbitmqctl stop_app

      optionfunction
      stop_app停止RabbitMQ应用,保持运行时(Erlang VM)运行。
    • rabbitmqctl stop //会将Erlang虚拟机关闭

    • rabbitmqctl reset //强制重启RabbitMQ应用

    • rabbitmqctl join_cluster rabbit@node1 // 加入集群

    • rabbitmqctl start_app //启动rabbitmq应用服务

  • 查看集群状态

    • rabbitmqctl cluster_status
  • 需要重新设置用户

    • 创建账户
      • rabbimqctl add_user admin 123
    • 设置用户角色
      • rabbimqctl set_user_tags admin administrator
    • 设置用户权限
      • rabbimqctl set_permissions -p “/” admin “.*” “.*” “.*”
  • 解除集群节点(node2和node3机器分别执行)(这些执行全要root用户权限)

    • rabbitmqctl stop_app
    • rabbitmqctl reset
    • rabbimqctl start_app
    • rabbimqctl cluster_status
    • rabbitmqctl forget_cluster_node rabbit@node2 //在node1上执行

Docker搭建

注意:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xijXa68L-1680343588627)(null)]

​ 这里只介绍使用docker通过Rabbitmq配置文件方式搭建集群,而且是在一台服务器中搭建3个几节点的普通集群(使用的不是仲裁队列)

  • 在rabbitmq集群部署时,集群中的节点标识默认都是:rabbit@[hostname]
  • 三个节点在不同机器上,为了方便主机名称分别为mq1、mq2、mq3,节点也进行相应的映射(只需要与配置文件相对应即可)
  • 再次提醒:下面是在一台服务器上搭建三个节点的标准集群
创建目录
#这里文件的位置只需要与下面容器实例启动时的数据容器卷相匹配即可
mkdir -p /root/docker/rabbitmq-cluster/mq1[2|3]
设置hosts

注意:如果是在同一台服务器上,搭建不同的docker实例,则不需要进行设置hosts文件

  • 分别为三台不同的机器上的hosts文件中添加下列配置
#这里不是更改本机的hosts文件,而是为docker实例添加挂载
# mq1
vim  /root/docker/rabbitmq-cluster/mq1/hosts
# mq2
vim  /root/docker/rabbitmq-cluster/mq2/hosts
# mq3
vim  /root/docker/rabbitmq-cluster/mq3/hosts
  • 添加的内容如下
# 配置hosts映射
111.229.160.173   mq1
111.229.160.174   mq2
111.229.160.175   mq3
设置cookie

​ RabbitIMQ底层依赖于Erlang一,而Erlang虚拟机就是一个面向分布式的语言,默认就支持集群模式。集群模式中的每个RabbitMQ节点使用cookie来确定它们是否被允许相互通信。
​ 要使两个节点能够通信,它们必须具有相同的共享密钥,称为Erlang cookie,cookie是一串最多255个字符的任意字母数字字符。

#.erlang.cookie配置
vim /root/docker/rabbitmq-cluster/mq1/.erlang.cookie

# 配置内容-cookie值(任意值)
UDCUIBNPHPETOIURAHRF

# 修改cookie文件的权限(必须是600(可读可写))
chmod 600 /root/docker/rabbitmq-cluster/mq1/.erlang.cookie

设置配置文件
# mq1
vim /root/docker/rabbitmq-cluster/mq1/rabbitmq.conf
#rabbitmq.conf的内容
# 配置内容
loopback_users.guest = false
listeners.tcp.default = 5672
cluster_formation.peer_discovery_backend = rabbit_peer_discovery_classic_config
cluster_formation.classic_config.nodes.1 = rabbit@mq1
cluster_formation.classic_config.nodes.2 = rabbit@mq2
cluster_formation.classic_config.nodes.3 = rabbit@mq3
#注意这里的mq1、mq2和mq3需要和容器实例的hostname一样
拷贝目录
# 将mq1目录拷贝为mq2、mq3
cp /root/docker/rabbitmq-cluster/mq1 /root/docker/rabbitmq-cluster/mq2 -r

cp /root/docker/rabbitmq-cluster/mq1 /root/docker/rabbitmq-cluster/mq3 -r
启动容器

注意:如果是在同一台机器上,搭建不同的docker实例,则可以创建一个网络环境

#创建网络(默认使用桥接模式即可)
docker network create mq_net

启动容器实例的模板

docker run -d \
# 如果是同一台机器则需要指定net
--net #  RabbitmqNetwork \  # RabbitmqNetwork是自己创建的网络(默认模式是网桥)
# 如果是不同机器,则需要挂载hosts文件,如果是同一台机器则不配做hosts挂载
-v /root/docker/rabbitmq-cluster/xxx/hosts:/etc/hosts \
-v /root/docker/rabbitmq-cluster/xxx/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \
-v /root/docker/rabbitmq-cluster/xxx/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \
-e RABBITMQ_DEFAULT_USER=xxx\
-e RABBITMQ_DEFAULT_PASS=xxx\
--name xxx \
--hostname xxx \
-p xxxx:5672 \    #AMQP协议的默认端口
-p xxxx:15672 \	 #RabbitMQ管理界面
rabbitmq:3.8.27-management   #仓库名和TAG(版本)

启动mq1容器实例(这里使用的是自己创建的镜像,用其他的镜像也是可以的,版本是rabbitmq:3.8.27-management)

//最好改为前台启动,不然容器实例可能会自动下线或者不会启动rabbit服务,改为前台启动可以看到启动日志情况
docker run --rm \
--net RabbitmqNetwork \
-v /root/docker/rabbitmq-cluster/mq1/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \
-v /root/docker/rabbitmq-cluster/mq1/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \
--privileged=true \
-e RABBITMQ_DEFAULT_USER=admin \
-e RABBITMQ_DEFAULT_PASS=admin \
--name mq1 --hostname mq1 \
-p 9071:5672 -p 9081:15672 \
-it rabbimq_fairyqin:1.0 

​ 启动会有警告,但是没有关系,在启动日志的最后面会有该rabbitmq的服务启动了哪些插件,其中就有控制台插件rabbitmq_management,所以无需再次启动控制台插件

image-20230326202649363

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CuzrOHRc-1680343584015)(null)]

启动mq2容器实例

docker run --rm \
--net RabbitmqNetwork \
-v /root/docker/rabbitmq-cluster/mq2/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \
-v /root/docker/rabbitmq-cluster/mq2/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \
--privileged=true \
-e RABBITMQ_DEFAULT_USER=admin \
-e RABBITMQ_DEFAULT_PASS=admin \
--name mq2 --hostname mq2 \
-p 9171:5672 -p 9181:15672 \
-it rabbimq_fairyqin:1.0 

启动mq3容器实例

docker run --rm \
--net RabbitmqNetwork \
-v /root/docker/rabbitmq-cluster/mq3/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \
-v /root/docker/rabbitmq-cluster/mq3/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \
--privileged=true \
-e RABBITMQ_DEFAULT_USER=admin \
-e RABBITMQ_DEFAULT_PASS=admin \
--name mq3 --hostname mq3 \
-p 9271:5672 -p 9281:15672 \
-it rabbimq_fairyqin:1.0

查看启动信息

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Lkg7BvYv-1680343583856)(null)]

搭建中的遇到的错误
  • 启动服务的配置信息写错

image-20230327091647777

  • 用户权限不够(使用授权的用户等进入)

image-20230326200231067

查看集群

  • 登入控制台界面

image-20230327092830199

  • 进入集群中的某个节点

    rabbitctl cluster_status  //查看集群状态
    

image-20230327093331102

参考博客:(3条消息) Docker搭建RabbitMQ集群_docker rabbitmq集群_一恍过去的博客-CSDN博客

集群测试
  • 随便找一个节点登入,添加一个classci的队列,不要添加Quorum(仲裁)类型的队列

image-20230327101752350

  • 停止添加了队列的服务器(上面是3号机)

    docker stop mq3
    
  • 访问其他在线的服务器,这时候会变得很卡,队列已经不可以用了

image-20230327102107664

镜像队列

为什么需要使用镜像

​ 如果RabbitMQ集群中只有一个Broker节点,那么该节点的失效将导致整体服务的临时性不可用,并且也可能会导致消息的丢失。可以将所有消息都设置为持久化,并且对应队列的durable属性也设置为true,但是这样仍然无法避免由于缓存导致的问题:因为消息在发送之后和被写入磁盘井执行刷盘动作之间存在一个短暂却会产生问题的时间窗。通过publisherconfirm机制能够确保客户端知道哪些消息己经存入磁盘,尽管如此,一般不希望遇到因单点故障导致的服务不可用。

​ 因为上面搭建的集群,在哪个节点创建的队列只在创建的节点中存在,在其他节点不存在副本,如当前节点宕机,队列将会无法提供数据的查询。

​ 引入镜像队列(Mirror Queue)的机制,可以将队列镜像到集群中的其他Broker节点之上,如果集群中的一个节点失效了,队列能自动地切换到镜像中的另一个节点上以保证服务的可用性。

搭建步骤

  1. 启动三台集群节点(docker启动)

image-20230327095300275

  1. 随便找一个节点使用控制台添加policy(可以通过http请求来配置policy)

image-20230327103634803


image-20230327104118053

  1. 在node3上创建的队列发送消息

创建队列的队列

image-20230327104450870

队列的详细信息

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kLarUBzM-1680343587874)(null)]

  1. 测试,停止mq3,再次查看队列信息

image-20230327104918338

image-20230327105015622

  1. 就算整个集群只剩下一台机器了依然能消费队列里面的消息,说明队列里面的消息被镜像队列传递到相应节点上了

一个坏消息是镜像队列会被仲裁队列给代替

Haproxy+Keeplive实现高可用负载均衡

整体架构图

image-20230327105407529

Haporxy实现负载均衡

​ HAProxy提供高可用性、负载均衡及基于TCP HTTP应用的代理,支持虚拟主机,它是免费、快速并且可靠的一种解决方案,包括Twitter,Reddit,StackOverflow,GitHub在内的多家知名互联网公司在使用。HAProxy实现了一种事件驱动、单一进程模型,此模型支持非常大的井发连接数。

扩展 nginx,lvs,haproxy 之间的区别: http://www.ha97.com/5646.html

例如:

  • 即使搭建了rabbitmq的集群,但是在代码中只写了一个节点的IP,如这个节点宕机了,代码中的ip是不会变更的,导致客户端不能访问到rabbitmq集群提供的服务,但是集群中的其他节点可以是可以提供服务的,但是代码它不知道的,使用反向代理可以解决

image-20230327123221213

image-20230327123336730

搭建步骤

主机搭建的集群中的操作步骤(略过,直接看docker版)

  1. 下载haproxy(在node1和node2)

    yum -y -v install haproxy   //centos发行版
    apt-get install haproxy   	//ubuntu发行版
    

    关于ubuntu一个命令的使用的详细介绍官网地址apt(8) — apt — Debian jessie — Debian Manpages

  2. 修改nod1和node2的haporxy.cfg

    vim /etch/haproxy/paproxy.cfg
    需要修改红色ip为当前机器ip
    

    image-20230327122148579

  3. 在两台节点启动haproxy

    haproxy -f /etc/haproxy/haproxy.cfg
    ps -ef |grep haproxy   //查看进程是否启动
    
  4. 访问地址

    http://10.211.55.71:8888/stats
    

docker搭建的集群的操作步骤

  1. 拉去Haproxy镜像

    docker  pull haproxy
    
  2. 在/home/haproxy/路径下,创建haproxy.cfg配置文件(文件内容如下)

#文件内容
global
  #日志
  log 127.0.0.1 local0 info
  maxconn 10240
  daemon

defaults
  log global
  mode http
  timeout connect 3000  #连接超时
  timeout client 3000   #客户端超时
  timeout server 3000   #服务器超时
  timeout check 2000    #心跳检测超时

listen  admin_stats
    #监控界面的访问的IP和端口
    bind  0.0.0.0:8089
    #访问协议
    mode        http
    #URI相对地址
    stats uri   /stats
    #统计报告格式
    stats realm     Global\ statistics
    #登陆帐户信息
    stats auth  admin:admin

listen rabbitmq_admin#rabbit的监控页面
    bind    0.0.0.0:8088
    server  rabbit_admin1 192.168.241:9081
    server  rabbit_admin2 192.168.241:9181
    server  rabbit_admin3 192.168.241:9281

listen haproxy #这里配置集群负载均衡
  bind 0.0.0.0:5666 #对外提供的端口,访问这个端口就会根据配置的规则对集群下的节点进行相应的负载均衡
  option tcplog
  mode tcp
  balance roundrobin #这是轮询算法,如有需要可以换成其他
  #每次换一个节点进行使用,这里的端口是服务端口
  server rabbit1 192.168.241.128:9071 check inter 5000 rise 2 fall 2
  server rabbit2 192.168.241.128:9171 check inter 5000 rise 2 fall 2
  server rabbit3 192.168.241.128:9271 check inter 5000 rise 2 fall 2
  1. 运行容器实例

    docker run -it --rm --name haproxy_rabbitmq -p 8089:8089 -p 5666:5666 -p 8088:8088 --privileged=true -v /home/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg haproxy:latest
    
  2. 访问Haproxy界面ip:8089/stats (账户和密码是上面文件中设置的,成功将会显示下面的界面)

image-20230327175327495

  1. 出现错误通过haproxy访问rabbitmq服务(具体原因不知),学完haproxy和keepalived再来(这里我学习的是nginx,没有学这个实现负载均衡 2023/3/27)
Keepalived 实现双机(主备)热备

​ 试想如果前面配置的HAProxy主机突然宕机或者网卡失效,那么虽然RbbitNQ集群没有任何故障但是对于外界的客户端来说所有的连接都会被断开结果将是灾难性的为了确保负载均衡服务的可靠性同样显得十分重要,这里就要引入Keepalived它能够通过自身健康检查、资源接管功能做高可用(双机热备),实现故障转移.

搭建步骤

主机搭建的集群中的操作步骤(略过)

  1. 下载keeplived(在node1和node2节点上)
yum -y install keepalived  //centos发行版
apt-get install keepalived   //ubuntu发行版
  1. node1节点修改配置文件

    vim /etc/keepalived/keepalived.conf
    把资料里面的keepalived.conf修改之后替换
    
  2. node2配置文件

    需要修改global_defs的router_id,如:nodeB
    其次要修改vrrp_instance_VI中state为"BACKUP";
    最后要将priority设置为小于100的值
    
  3. 添加haproxy_chk.sh

    (为了防止 HAProxy 服务挂掉之后 Keepalived 还在正常工作而没有切换到 Backup 上,所以这里需要编写一个脚本来检测 HAProxy 务的状态,当 HAProxy 服务挂掉之后该脚本会自动重启 HAProxy 的服务,如果不成功则关闭 Keepalived 服务,这样便可以切换到 Backup 继续工作)

    vim /etc/keepalived/haproxy_chk.sh(可以直接上传文件)
    修改权限chmod 777 /etc/keepalived/haproxy_chk.sh
    
  4. 启动keepalive命令(node1和node2启动)

    systemctl start keepalived
    
  5. 查看keepalived的日志

    tail -f /var/log/messages -n 200
    
  6. 观察最新添加的vip(虚拟ip)

    id add show 
    
  7. node1模拟keepalived关闭状态

    systemctl stop keepalived
    
  8. 使用vip地址来访问rabbitmq集群

Federation Exchange

Fedeartion Exchange 联邦交换机 ,用于解决超远距离(网络延迟)带来的MQ节点数据同步问题

使用联邦交换机的原因
image-20230327183812375

​ 如上图:(broker北京),(broker深圳)彼此之间相距甚远,网络延迟是一个不得不面对的问题。有一个在北京的业务(Client北京)需要连接(broker北京),向其中的交换器exchangeA发送消息,此时的网络延迟很小,(Client北京)可以迅速将消息发送至exchangeA中,就算在开启了publisherconfirm机制或者事务机制的情况下,也可以迅速收到确认信息。此时又有个在深圳的业务(Client深圳)需要向exchangeA发送消息,那么(Client深圳)(broker北京)之间有很大的网络延迟,(Client深圳)将发送消息至exchangeA 会经历一定的延迟,尤其是在开启了publisherconfirm机制或者事务机制的情况下,(Client深圳)会等待很长的延迟时间来接收(broker北京)的确认信息,进而必然造成这条发送线程的性能降低,甚至造成一定程度上的阻塞。

搭建步骤
  1. 需要保证每个节点单独运行(不是在一个集群中)

  2. 在每个节点上开启federation相关插件

    //插件是自带的,只是没有开启
    rabbitmq-plugins enable rabbimq_federation //联邦插件
    rabbitmq-plugins enable rabbitmq_federation_management //联邦管理插件
    

    image-20230327185123767

    image-20230327185416113

  3. 原理图(先运行consumer在node2创建一个联邦交换机(例如:fed_exchange)名字随便取)数据同步一般是上游同步下游

    image-20230327193245954

    #在下游(node2)创建一个fed_exchange(直接类型的交换机就行)
    channel.exchangeDeclare("fed_exchage",Bu
    r),并创建队列绑定到交换机
    
    @Slf4j
    public class downStream_FederationExchange {
        public static final String DOWNFEDERATIONEXCHANGE="federation_exchange";
        public static final String FEDERATION_QUEUE="federation_queue";
        public static final String FEDERATION_ROUTINGKE = "fed";
        public static void main(String[] args) throws Exception {
            log.info("创建联邦交换机");
            //获取下游的节点的连接
            Channel channelDownStream = getChannelDownStream.getChannelDownStream();
            //由于federation exchange模式中,下游的必须先创建联邦交换机
            channelDownStream.exchangeDeclare(DOWNFEDERATIONEXCHANGE, BuiltinExchangeType.DIRECT);
            //声明队列,绑定队列
            channelDownStream.queueDeclare(FEDERATION_QUEUE,true,false,false,null);
            channelDownStream.queueBind(FEDERATION_QUEUE, DOWNFEDERATIONEXCHANGE, FEDERATION_ROUTINGKE);
            log.info("下游基本配置基本完成");
        }
    }
    

    image-20230327195307903

  4. 在downstream(node2)配置upStream(node1)

    注意:这里是在下游的节点的控制台界面配置

image-20230327214243310

  1. 在下游节点上添加策略(policy)

image-20230327214449528

  1. 查看结果

image-20230327214536826

如果出现下面的问题,仔细检查自己的端口是不是错了,我就是写错了端口才出现了这个错误如果guest用户不行,试着可以创建一个新的用户

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b9MreDve-1680343586448)(null)]

Federation Queue

Federation Exchange 和 Federation Queue都可以是不同地区的消息同步

使用它的原因

​ 联邦队列可以在多个Broker节点(或者集群)之间为单个队列提供均衡负载的功能。一个联邦队列可以连接一个或者多个上游队列(upstream queue),并从这些上游队列中获取消息以满足本地消费者消费消息的需求。

搭建步骤

原理图

将消息从node1同步到node2中,但是是通过node2中的队列联邦到node1

image-20230327215504837

  1. 添加node2添加上游节点(upstream)(这一个和联邦队列一样,所以联邦队列只需要更改策略即可)

    ^fed.*表示为前缀为fed.的队列创建联邦队列

    image-20230327215906549

  2. 测试结果(测试前提是现有fed前缀的队列存在)

    image-20230327220414706

Shovel

使用Shovel的原因

​ Federation具备的数据转发功能类似,Shovel够可靠、持续地从一个Broker中的队列(作为源端,即source)拉取数据并转发至另一个Broker中的交换器(作为目的端,即destination)。作为源端的队列和作为目的端的交换器可以同时位于同一个Broker,也可以位于不同的Broker上。Shovel可以翻译为"铲子",,是一种比较形象的比喻,这个"铲子"可以将消息从一方"铲子"另一方。Shovel行为就像优秀的客户端应用程序能够负责连接源和目的地、负责消息的读写及负责连接失败问题的处理。

搭建步骤
  1. 开启Shovel插件(rabbitmq自带的)(需要使用shovel的节点多需要开启)

    rabbitmq-plugins enable rabbitmq_shovel   //shovel插件
    rabbitmq-plugins enable rabbitmq_shovel_management //shovel管理插件
    

    image-20230327221052117

  2. 原理图(在源头发送的消息直接会进入目的队列)

    image-20230327221522760

  3. 添加shovel源和目的地

image-20230327221923024

image-20230327221906135

  1. 查看结果

img


  1. Qos: quality of service 服务质量 ↩︎

  2. 根据在代码中使用自定义的注解来生成接口文档,其优点有号称最流程的API框架,接口文档在线生成,避免同步的麻烦,支持在线对接口测试,支持多种语言 ↩︎

  3. Mnesia是一个分布式数据库管理系统(DBMS),适合于电信和其它需要持续运行和具备软实时特性的Erlang应用,是构建电信应用的控制系统平台开放式电信平台(OTP)的一部分。 ↩︎

  4. Quartz是一个流行的Java定时任务框架,可以在应用程序中执行各种定时任务,如定期备份、数据抽取和数据分析等。而Spring Boot是一个快速开发框架,提供了方便的集成Quartz的方式。 ↩︎

  5. Raft是一个强一致性算法,相比于著名的一致性算法Paxos更容易理解,Zookeeper采用的ZAB(Zookeeper Atomic Broadcast)协议也是基于Paxos的,但是Zookeeper在此基础上进行很多改进和优化,ZAB协议主要用户构建一个高可用的分布式数据主备系统(AP) ↩︎

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值