【.NET Core微服务架构】-- 消息队列RabbitMQ

1,355 阅读6分钟

什么是消息队列?

很好理解,既是一个存放消息的容器,那肯定就会有一个发送消息的程序和一个处理消息的程序和一个存放消息的数据队列。

生产者

生产者就是发送消息的程序

队列

队列就是存放所有消息的地方,就像个邮筒一样。队列仅受主机的内存和磁盘限制的约束,它本质上是一个大的消息缓冲区。许多创建者可以发送转到一个队列的消息,许多使用者可以尝试从一个队列接收数据。

消费者

消费者就是等待接收消息的程序


生产者、消费者和代理不必在同一台主机上。应用程序也可以既是生产者又是消费者。


为什么要使用消息队列?

削峰填谷

在并发量大的时候,在后台启动若干个队列处理程序,消费消息队列中的消息,再执行业务逻辑。因为只有有限个队列处理线程在执行,所以落入后端数据库上的并发请求是有限的。而请求是可以在消息队列中被短暂地堆积,当库存被消耗完之后,消息队列中堆积的请求就可以被丢弃了。

异步处理

如果原本一条逻辑处理时间很长,但是有些业务可以不用实时的处理,这就可以使用消息队列把这部分业务拆分出去,让前端的请求快速的响应,后面不影响返回的业务就单独处理,可以整体的提升系统的性能。

解耦合

一个业务场景可能涉及到多种业务的处理,比如订单和库存,传统做法是在订单处理完后去调用库存的方法,这种就是强耦合关系,使用消息队列可以把库存操作单独处理,订单处理完后发送消息到消息队列中,库存再去接收消息处理,这就实现了解耦。

安装

我们使用Docker进行安装

首先拉取RabbitMQ镜像

docker pull rabbitmq:management 

Docker中运行RabbitMQ

docker run -d -p 15672:15672  -p  5672:5672  -e RABBITMQ_DEFAULT_USER=admin -e RABBITMQ_DEFAULT_PASS=admin123456 --name rabbitmq --hostname=rabbitmqhostone  rabbitmq:management

RABBITMQ_DEFAULT_USER=admin -e RABBITMQ_DEFAULT_PASS=admin123456

这是指定账号密码的,不输入的话默认账号密码都是gust,也可以自行创建

访问http://localhost:15672就能看到RabbitMQ的控制台了

各种工作模式

官网教程:RabbitMQ getstarted

简单的消息队列

先看看效果:

这里我们启动了两个程序,一个生产者,一个消费者,当在生产者中发送消息的时候,消费者马上就能接收到消息。

连接RabbitMQ

    public static class RabbitMQHelper
    {
        public static IConnection GetConnection()
        {
            var factory = new ConnectionFactory()
            {
                HostName = "localhost", //IP
                Port = 5672,            //端口
                UserName = "admin",     //账户
                Password = "zy123456",  //密码
                VirtualHost = "/"       //虚拟主机
            };
            return factory.CreateConnection();
        }
    }

生产者代码

    public class Send
    {
        static string QueueName = "normal";
        static IConnection connection=RabbitMQHelper.GetConnection();
        public static void Run()
        {
            using (var channel = connection.CreateModel())
            {
                while (true)
                {
                    channel.QueueDeclare(queue: QueueName,
                                         durable: true,
                                         exclusive: false,
                                         autoDelete: false,
                                         arguments: null);
                    Console.Write("请输入传输的值:");
                    string read = Console.ReadLine();
                    var message = GetMessage(read);
                    var body = Encoding.UTF8.GetBytes(message);

                    var properties = channel.CreateBasicProperties();
                    properties.Persistent = true;

                    channel.BasicPublish(exchange: "",//交换机,不填写就是默认的
                                         routingKey: QueueName,//路由key,默认和队列名称一致
                                         basicProperties: properties,
                                         body: body);
                    Console.WriteLine($"生产者发送消息{QueueName}{message}");
                }
            }
        }

        private static string GetMessage(string args)
        {
            return $"Hello World! {DateTime.Now.ToString("HH:mm:ss")} " + args;
        }
    }

消费者代码

        static void Main(string[] args)
        {
            //消费者消费的是队列中的消息
            string QueueName = "normal";
            var connection = RabbitMQHelper.GetConnection();
            {
                var channel = connection.CreateModel();
                var consumer = new EventingBasicConsumer(channel);
                consumer.Received += (model, ea) =>
                {
                    var message = Encoding.UTF8.GetString(ea.Body.ToArray());
                    Console.WriteLine($"消费者接收数据{QueueName}{message}");
                };
                channel.BasicConsume(QueueName, true, consumer);
                Console.ReadLine();
            }
        }

工作队列模式

工作队列的意思其实就是一个生产者多个消费者。

先来看看效果

可以看到,我们启动了三个消费者,生产者循环发送30条消息,消费者接收消息的是按照轮询的方式接收。这个可以通过设置权重来更改。

生产者代码

生产者代码变化不大

    public class WorkerSend
    {
        static string QueueName = "Worker";
        static IConnection connection = RabbitMQHelper.GetConnection();
        public static void Run()
        {
            using (var channel = connection.CreateModel())
            {
                channel.QueueDeclare(queue: QueueName,
                                     durable: true,
                                     exclusive: false,
                                     autoDelete: false,
                                     arguments: null);
                var properties = channel.CreateBasicProperties();
                properties.Persistent = true;
                for (int i = 0; i < 30; i++)
                {
                    var message = GetMessage($"测试Worker工作模式 {i+1}");
                    var body = Encoding.UTF8.GetBytes(message);
                    channel.BasicPublish(exchange: "",
                                         routingKey: QueueName,
                                         basicProperties: properties,
                                         body: body);
                    Console.WriteLine($"生产者发送消息{QueueName}{message}");
                    Thread.Sleep(500);
                }
            }
        }

        private static string GetMessage(string args)
        {
            return $"Worker! {DateTime.Now.ToString("HH:mm:ss")} " + args;
        }
    }

消费者代码

    public static class Worker
    {
        public static void Receive()
        {
            //消费者消费的是队列中的消息
            string queueName = "Worker";
            var connection = RabbitMQHelper.GetConnection();
            {
                var channel = connection.CreateModel();
                channel.BasicQos(prefetchSize: 0, prefetchCount:1, global: false);
                var consumer = new EventingBasicConsumer(channel);
                consumer.Received += (model, ea) =>
                {
                    var message = Encoding.UTF8.GetString(ea.Body.ToArray());
                    Console.WriteLine($"消费者接收数据{queueName}{message}");
                };
                channel.BasicConsume(queueName, true, consumer);
            }
        }
    }

消费者比较重要的就是这行代码,prefetchSize参数,用于限制分发内容的大小上限,默认值0代表无限制,而prefetchCount的取值范围是[0,65535],取值为0也是代表无限制。global参数是不是针对整个Connection的,因为一个Connection可以有多个Channel,如果是false则说明只是针对于这个Channel的

channel.BasicQos(prefetchSize: 0, prefetchCount:1, global: false);

交换机

Exchange:交换机,很好理解,熟悉网络的应该知道网络交换机的作用,这里的交换机作用也是一样的,消息先发送到交换机,队列绑定到交换机上,再有交换机转发出去,当然转发的策略也有不同,下面就介绍下三种交换机的工作模式。

Fanout扇形工作模式

先来看看整体的流程图,生产者把消息先发送到交换机,而交换机会把消息推送给所有绑定了他的队列中,最后再由消费者去消费队列中的消息。

看下具体的效果如下

生产者代码

    public class FanoutSend
    {
        static string ExchangeName = "Fanout_Exchange";
        static IConnection connection = RabbitMQHelper.GetConnection();
        public static void Run()
        {
            using (var channel = connection.CreateModel())
            {
                //声明交换机
                channel.ExchangeDeclare(ExchangeName, "fanout");
                //创建队列
                string QueueName1 = " Fanout_QueueName1";
                string QueueName2 = " Fanout_QueueName2";
                string QueueName3 = " Fanout_QueueName3";
                channel.QueueDeclare(queue: QueueName1, durable: true, exclusive: false, autoDelete: false, arguments: null);
                channel.QueueDeclare(queue: QueueName2, durable: true, exclusive: false, autoDelete: false, arguments: null);
                channel.QueueDeclare(queue: QueueName3, durable: true, exclusive: false, autoDelete: false, arguments: null);
                //绑定到交换机
                channel.QueueBind(queue: QueueName1, exchange: ExchangeName, routingKey: "");
                channel.QueueBind(queue: QueueName2, exchange: ExchangeName, routingKey: "");
                channel.QueueBind(queue: QueueName3, exchange: ExchangeName, routingKey: "");
                var properties = channel.CreateBasicProperties();
                properties.Persistent = true;
                for (int i = 0; i < 30; i++)
                {
                    var message = GetMessage($"测试Fanout工作模式 {i + 1}");
                    var body = Encoding.UTF8.GetBytes(message);
                    channel.BasicPublish(exchange: ExchangeName,
                                         routingKey: "",
                                         basicProperties: properties,
                                         body: body);
                    Console.WriteLine($"生产者发送消息交换机{ExchangeName}{message}");
                    Thread.Sleep(500);
                }
            }
        }

        private static string GetMessage(string args)
        {
            return $"Worker! {DateTime.Now.ToString("HH:mm:ss")} " + args;
        }
    }

在RabbitMQ控制台中能够查看到交换机绑定的队列

当我们发送消息的时候,是发送到交换机的,这三个队列都会收到消息。可以在控制台中查看Total,我们发送了10条消息,三个队列都受到了10条消息。

消费者代码

    public static class Fanout
    {
        public static void Receive(string queueName)
        {
            //消费者消费的是队列中的消息
            var connection = RabbitMQHelper.GetConnection();
            {
                var channel = connection.CreateModel();
                var consumer = new EventingBasicConsumer(channel);
                consumer.Received += (model, ea) =>
                {
                    var message = Encoding.UTF8.GetString(ea.Body.ToArray());
                    Console.WriteLine($"消费者接收数据交换机{ea.Exchange},队列{queueName}{message}");
                };
                channel.BasicConsume(queueName, true, consumer);
                Console.ReadLine();
            }
        }
    }

Direct完全匹配模式

前面的Fanout工作模式的代码,可以发现我们把队列绑定到交换机的时候没有指定routingKey,也就是路由键,而Direct模式呢,就是根据路由去发送消息的。

看下具体的效果如下

生产者代码

    public static class DirectSend
    {
        static string ExchangeName = "Direct_Exchange";
        static IConnection connection = RabbitMQHelper.GetConnection();
        public static void Run()
        {
            using (var channel = connection.CreateModel())
            {
                //声明交换机
                channel.ExchangeDeclare(ExchangeName, "direct");
                //创建队列
                string QueueName1 = "Direct_QueueName1";
                string QueueName2 = "Direct_QueueName2";
                string QueueName3 = "Direct_QueueName3";
                channel.QueueDeclare(queue: QueueName1, durable: true, exclusive: false, autoDelete: false, arguments: null);
                channel.QueueDeclare(queue: QueueName2, durable: true, exclusive: false, autoDelete: false, arguments: null);
                channel.QueueDeclare(queue: QueueName3, durable: true, exclusive: false, autoDelete: false, arguments: null);
                //绑定到交换机
                channel.QueueBind(queue: QueueName1, exchange: ExchangeName, routingKey: "one");
                channel.QueueBind(queue: QueueName2, exchange: ExchangeName, routingKey: "two");
                channel.QueueBind(queue: QueueName3, exchange: ExchangeName, routingKey: "one");
                var properties = channel.CreateBasicProperties();
                properties.Persistent = true;
                for (int i = 0; i < 10; i++)
                {
                    var message = GetMessage($"测试Direct工作模式one {i + 1}");
                    var body = Encoding.UTF8.GetBytes(message);
                    channel.BasicPublish(exchange: ExchangeName,
                                         routingKey: "one",
                                         basicProperties: properties,
                                         body: body);
                    Console.WriteLine($"生产者发送消息交换机{ExchangeName}{message}");
                    Thread.Sleep(500);
                }
                for (int i = 0; i < 10; i++)
                {
                    var message = GetMessage($"测试Direct工作模式two {i + 1}");
                    var body = Encoding.UTF8.GetBytes(message);
                    channel.BasicPublish(exchange: ExchangeName,
                                         routingKey: "two",
                                         basicProperties: properties,
                                         body: body);
                    Console.WriteLine($"生产者发送消息交换机{ExchangeName}{message}");
                    Thread.Sleep(500);
                }
            }
        }

        private static string GetMessage(string args)
        {
            return $"Direct! {DateTime.Now.ToString("HH:mm:ss")} " + args;
        }
    }

我们绑定交换机的时候指定了RoutingKey,但是QueueName1和QueueName3绑定了相同的RoutingKey。

发送消息的时候也是直接发送到交换机并且指定了RoutingKey。可以看到三个队列都收到了10条消息,说明我们通过RoutingKey发送消息时成功的。

消费者代码

    public static class Direct
    {
        public static void Receive(string queueName)
        {
            //消费者消费的是队列中的消息
            var connection = RabbitMQHelper.GetConnection();
            {
                var channel = connection.CreateModel();
                var consumer = new EventingBasicConsumer(channel);
                consumer.Received += (model, ea) =>
                {
                    var message = Encoding.UTF8.GetString(ea.Body.ToArray());
                    Console.WriteLine($"消费者接收数据交换机{ea.Exchange},队列{queueName}{message}");
                };
                channel.BasicConsume(queueName, true, consumer);
                Console.ReadLine();
            }
        }
    }

Topic模糊匹配模式

前面的Direct工作模式的代码,我们是把队列绑定到交换机的时候没有指定routingKey,而Topic模式呢同样也是通过路由去匹配的,只不过多了一个模糊匹配的机制,有点类似于数据库查询时候的like,但是有有点不同,两个模糊匹配符号,#表示匹配多个单词,*便是单个单词,多个单词之间用点隔开。

先看看效果

生产者代码

    public static class TopicSend
    {

        static string ExchangeName = "Topic_Exchange";
        static IConnection connection = RabbitMQHelper.GetConnection();
        public static void Run()
        {
            using (var channel = connection.CreateModel())
            {
                //声明交换机
                channel.ExchangeDeclare(ExchangeName, "topic");
                //创建队列
                string QueueName1 = "Topic_QueueName1";
                string QueueName2 = "Topic_QueueName2";
                string QueueName3 = "Topic_QueueName3";
                channel.QueueDeclare(queue: QueueName1, durable: true, exclusive: false, autoDelete: false, arguments: null);
                channel.QueueDeclare(queue: QueueName2, durable: true, exclusive: false, autoDelete: false, arguments: null);
                channel.QueueDeclare(queue: QueueName3, durable: true, exclusive: false, autoDelete: false, arguments: null);
                //绑定到交换机
                channel.QueueBind(queue: QueueName1, exchange: ExchangeName, routingKey: "Queues.Test");
                channel.QueueBind(queue: QueueName2, exchange: ExchangeName, routingKey: "*.Test");
                channel.QueueBind(queue: QueueName3, exchange: ExchangeName, routingKey: "Queues.Test.#");
                var properties = channel.CreateBasicProperties();
                properties.Persistent = true;
                for (int i = 0; i < 10; i++)
                {
                    var message = GetMessage($"测试Direct工作模式*.Test {i + 1}");
                    var body = Encoding.UTF8.GetBytes(message);
                    channel.BasicPublish(exchange: ExchangeName,
                                         routingKey: "Queues.Test",
                                         basicProperties: properties,
                                         body: body);
                    Console.WriteLine($"生产者发送消息交换机{ExchangeName}{message}");
                    Thread.Sleep(500);
                }
                for (int i = 0; i < 10; i++)
                {
                    var message = GetMessage($"测试Direct工作模式Queues.Test.# {i + 1}");
                    var body = Encoding.UTF8.GetBytes(message);
                    channel.BasicPublish(exchange: ExchangeName,
                                         routingKey: "Queues.Test.One.Two",
                                         basicProperties: properties,
                                         body: body);
                    Console.WriteLine($"生产者发送消息交换机{ExchangeName}{message}");
                    Thread.Sleep(500);
                }
            }
        }

        private static string GetMessage(string args)
        {
            return $"Topic! {DateTime.Now.ToString("HH:mm:ss")} " + args;
        }
    }

上面的代码我们同样给交换机绑定了三个队列,并且指定了RoutingKey,但是我们绑定的RoutingKey是带匹配符号的,那效果是怎么样的呢

可以看到我们发送了RoutingKey为Queues.Test和Queues.Test.One.Two的消息各10条,*.Test满足了Queues.Test的路由,所有第二个队列中也发送了10条小心进去,而Queues.Test.#两条记录都满足,所以发送了20条消息。

消费者代码

    public static class Topic
    {
        public static void Receive(string queueName)
        {
            //消费者消费的是队列中的消息
            var connection = RabbitMQHelper.GetConnection();
            {
                var channel = connection.CreateModel();
                var consumer = new EventingBasicConsumer(channel);
                consumer.Received += (model, ea) =>
                {
                    var message = Encoding.UTF8.GetString(ea.Body.ToArray());
                    Console.WriteLine($"消费者接收数据交换机{ea.Exchange},队列{queueName}{message}");
                };
                channel.BasicConsume(queueName, true, consumer);
                Console.ReadLine();
            }
        }
    }

\

消息可靠性

顾名思义,消息持久化就是为了保证消息的可靠性。RabbitMQ的消息都是存储在内存中的,而如果这时候RabbitMQ服务挂掉了,那没有处理完的消息则会丢失,所以避免RabbitMQ关闭或者异常时数据丢失的情况发生,消息持久化会把消息写到磁盘中,以确保消息的可靠性。

queues持久化

在声明队列的时候设置durable=true

channel.QueueDeclare(queue: "QueueName", durable: true);

queueDeclare方法说明

//
// 摘要:
//     声明队列。请参阅《队列指南》了解更多信息。
//
// 参数:
//   queue:
//     队列的名称。
//
//   durable:
//     此队列是否应该在代理重新启动后继续存在?
//
//   exclusive:
//     是否应将此队列的使用限制为其声明连接?这样的队列将在其声明连接关闭时被删除。
//
//   autoDelete:
//     当最后一个消费者(如果有)取消订阅时,是否应自动删除此队列?
//
//   arguments:
//     可选择的其他队列参数,例如“x-queue-type”
[AmqpMethodDoNotImplement(null)]
QueueDeclareOk QueueDeclare(string queue, bool durable, bool exclusive, bool autoDelete, IDictionary<string, object> arguments);

消息的持久化

如过将queue的持久化标识durable设置为true,则代表是一个持久的队列,那么在服务重启之后,也会存在,因为服务会把持久化的queue存放在硬盘上,当服务重启的时候,会重新什么之前被持久化的queue。队列是可以被持久化,但是里面的消息是否为持久化那还要看消息的持久化设置。也就是说,重启之前那个queue里面还没有发出去的消息的话,重启之后那队列里面是不是还存在原来的消息,这个就要取决于发生着在发送消息时对消息的设置了。如果要在重启后保持消息的持久化必须设置消息是持久化的标识。

通过设置BasicProperties的Persistent = true或者DeliveryMode = 2都可以实现持久化

var properties = channel.CreateBasicProperties();
//消息持久化,二选一都行
properties.Persistent = true;
properties.DeliveryMode = 2;
channel.BasicPublish(exchange: "",routingKey: QueueName,basicProperties: properties,body: body);

BasicPropertie属性字段

  1. contentType:消息的内容类型,如:text/plain
  2. contentEncoding:消息内容编码
  3. headers:设置消息的header,类型为Map<String,Object>
  4. deliveryMode:1(nopersistent)非持久化,2(persistent)持久化
  5. priority:消息的优先级
  6. correlationId:关联ID
  7. replyTo:用于指定回复的队列的名称
  8. expiration:消息的失效时间
  9. messageId:消息ID
  10. timestamp:消息的时间戳
  11. type:类型
  12. userId:用户ID
  13. appId:应用程序ID
  14. custerId:集群ID

exchange的持久化

上面阐述了队列的持久化和消息的持久化,如果不设置exchange的持久化对消息的可靠性来说没有什么影响。但是也说一下,跟queues的设置方法差不多,声明交换机的时候设置durable=true就可以了。

channel.ExchangeDeclare(exchange: ExchangeName,type: "fanout",durable:true);

消息确认

当消费者消费消息的时候默认是自动签收的,也就是还没有开始处理消息的时候,只要接收到了消息就会自动签收,但是这时候如果处理消息的时候异常了,此条消息也没法再重复处理了,所以就需要手动签收。

public static void Receive()
{
    //消费者消费的是队列中的消息
    string queueName = "normal";
    var connection = RabbitMQHelper.GetConnection();
    {
        var channel = connection.CreateModel();
        var consumer = new EventingBasicConsumer(channel);
        consumer.Received += (model, ea) =>
        {
            var message = Encoding.UTF8.GetString(ea.Body.ToArray());
            Console.WriteLine($"{ea.DeliveryTag}消费者接收数据{queueName}{message}");
            //DeliveryTag:消息唯一键
            //multiple:是否批量处理.true:将一次性ack所有小于DeliveryTag的消息
            channel.BasicAck(deliveryTag: ea.DeliveryTag,multiple: false);
        };
        channel.BasicConsume(queue: queueName,autoAck: false,consumer: consumer);
        Console.ReadLine();
    }
}

多说几句

即使我们都设置了持久化,但是,我们的消息也并不是百分百不丢失的,因为消息存入RabbitMQ后,还需要再存入磁盘,这中间同样会有个时间差,如果RabbitMQ发生异常或者关闭了,同样会造成消息的丢失,也可以使用镜像队列来保证消息的可靠性,当然如果全挂了那也会造成数据的丢失,当然这种可能性很小。而且消息确认机制也是需要的,避免消息消费异常,同时消息事务也能实现,但是对于性能就会有损失,推荐还是使用消息确认。