由浅入深了解RabbitMQ

简介

RabbitMQ是流行的开源消息队列系统。RabbitMQ是AMQP(高级消息队列协议)的标准实现。支持多种客户端,如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP等,支持AJAX,持久化。用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。是使用Erlang编写的一个开源的消息队列,本身支持很多的协议:AMQP,XMPP, SMTP, STOMP,也正是如此,使的它变的非常重量级,更适合于企业级的开发。同时实现了一个Broker构架,这意味着消息在发送给客户端时先在中心队列排队。对路由(Routing),负载均衡(Load balance)或者数据持久化都有很好的支持。
其主要特点如下:

  • 可靠性
  • 灵活的路由
  • 扩展性
  • 高可用性
  • 多种协议
  • 多语言客户端
  • 管理界面
  • 插件机制

概念

RabbitMQ从整体上来看是一个典型的生产者消费者模型,主要负责接收、存储和转发消息。其整体模型架构如下图所示:

我们先来看一个RabbitMQ的运转流程,稍后会对这个流程中所涉及到的一些概念进行详细的解释。

运转流程

先介绍几个概念:

  • Producer:生产者,即消息投递者一方。
  • 消息:消息一般分两个部分:消息体(payload)和标签。标签用来描述这条消息,如:一个交换器的名称或者一个路由Key,Rabbit通过解析标签来确定消息的去向,payload是消息内容可以使一个json,数组等等。
  • Consumer:消费者,就是接收消息的一方。消费者订阅RabbitMQ的队列,当消费者消费一条消息时,只是消费消息的消息体。在消息路由的过程中,会丢弃标签,存入到队列中的只有消息体。
  • Broker:消息中间件的服务节点。

生产者流程:

(1)生产者连接到RabbitMQ Broker,建立一个连接( Connection)开启一个信道(Channel)
(2)生产者声明一个交换器,并设置相关属性,比如交换机类型、是否持久化等
(3)生产者声明一个队列井设置相关属性,比如是否排他、是否持久化、是否自动删除等
(4)生产者通过路由键将交换器和队列绑定起来
(5)生产者发送消息至RabbitMQ Broker,其中包含路由键、交换器等信息。
(6)相应的交换器根据接收到的路由键查找相匹配的队列。
(7)如果找到,则将从生产者发送过来的消息存入相应的队列中。
(8)如果没有找到,则根据生产者配置的属性选择丢弃还是回退给生产者
(9)关闭信道
(10)关闭连接

消费者流程:

(1)消费者连接到RabbitMQ Broker ,建立一个连接(Connection),开启一个信道(Channel) 。
(2)消费者向RabbitMQ Broker 请求消费相应队列中的消息,可能会设置相应的回调函数,
(3)等待RabbitMQ Broker 回应并投递相应队列中的消息,消费者接收消息。
(4)消费者确认(ack) 接收到的消息。
(5)RabbitMQ 从队列中删除相应己经被确认的消息。
(6)关闭信道
(7)关闭连接

信道

这里我们主要讨论两个问题:

  • 为何要有信道?
    主要原因还是在于TCP连接的"昂贵"性。无论是生产者还是消费者,都需要和RabbitMQ Broker 建立连接,这个连接就是一条TCP 连接。而操作系统对于TCP连接的创建与销毁是非常昂贵的开销。假设消费者要消费消息,并根据服务需求合理调度线程,若只进行TCP连接,那么当高并发的时候,每秒可能都有成千上万的TCP连接,不仅仅是对TCP连接的浪费,也很快会超过操作系统每秒所能建立连接的数量。如果能在一条TCP连接上操作,又能保证各个线程之间的私密性就完美了,于是信道的概念出现了。
  • 信道为何?
    信道是建立在Connection 之上的虚拟连接。当应用程序与Rabbit Broker建立TCP连接的时候,客户端紧接着可以创建一个AMQP 信道(Channel) ,每个信道都会被指派一个唯一的ID。RabbitMQ 处理的每条AMQP 指令都是通过信道完成的。信道就像电缆里的光纤束。一条电缆内含有许多光纤束,允许所有的连接通过多条光线束进行传输和接收。

队列、交换器、路由key、绑定

从RabbitMQ的运转流程我们可以知道生产者的消息是发布到交换器上的。而消费者则是从队列上获取消息的。那么消息到底是如何从交换器到队列的呢?我们先具体了解一下这几个概念。

  • Queue:队列
    是RabbitMQ的内部对象,用于存储消息。RabbitMQ中消息只能存储在队列中。生产者投递消息到队列,消费者从队列中获取消息并消费。多个消费者可以订阅同一个队列,这时队列中的消息会被平均分摊(轮询)给多个消费者进行消费,而不是每个消费者都收到所有的消息进行消费。(注意:RabbitMQ不支持队列层面的广播消费,如果需要广播消费,可以采用一个交换器通过路由Key绑定多个队列,由多个消费者来订阅这些队列的方式。)
  • Exchange:交换器
    在RabbitMQ中,生产者并非直接将消息投递到队列中。真实情况是,生产者将消息发送到Exchange(交换器),由交换器将消息路由到一个或多个队列中。如果路由不到,或返回给生产者,或直接丢弃,或做其它处理。
  • RoutingKey:路由Key
    生产者将消息发送给交换器的时候,一般会指定一个RoutingKey,用来指定这个消息的路由规则。这个路由Key需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。在交换器类型和绑定键固定的情况下,生产者可以在发送消息给交换器时通过指定RoutingKey来决定消息流向哪里。
  • Binding:绑定
    RabbitMQ通过绑定将交换器和队列关联起来,在绑定的时候一般会指定一个绑定键,这样RabbitMQ就可以指定如何正确的路由到队列了。
    从这里我们可以看到在RabbitMQ中交换器和队列实际上可以是一对多,也可以是多对多关系。交换器和队列就像我们关系数据库中的两张表。他们同归BindingKey做关联(多对多关系表)。在我们投递消息时,可以通过Exchange和RoutingKey(对应BindingKey)就可以找到相对应的队列。

RabbitMQ主要有四种类型的交换器:

  • fanout:扇形交换器,它会把发送到该交换器的消息路由到所有与该交换器绑定的队列中。如果使用扇形交换器,则不会匹配路由Key。
  • direct:direct交换器,会把消息路由到RoutingKey与BindingKey完全匹配的队列中。
  • topic:完全匹配BindingKey和RoutingKey的direct交换器 有些时候并不能满足实际业务的需求。topic 类型的交换器在匹配规则上进行了扩展,它与direct 类型的交换器相似,也是将消息路由到BindingKey 和RoutingKey 相匹配的队
    列中,但这里的匹配规则有些不同,它约定:
    1.RoutingKey 为一个点号"."分隔的字符串(被点号"."分隔开的每一段独立的字符串称为一个单词)λ,如"hs.rabbitmq.client","com.rabbit.client"等。
    2.BindingKey 和RoutingKey 一样也是点号"."分隔的字符串;
    3.BindingKey 中可以存在两种特殊字符串""和"#",用于做模糊匹配,其中""用于匹配一个单词,"#"用于匹配多规格单词(可以是零个)。
  • header:headers 类型的交换器不依赖于路由键的匹配规则来路由消息,而是根据发送的消息内容中的headers 属性进行匹配。在绑定队列和交换器时制定一组键值对, 当发送消息到交换器时,RabbitMQ 会获取到该消息的headers (也是一个键值对的形式) ,对比其中的键值对是否完全匹配队列和交换器绑定时指定的键值对,如果完全匹配则消息会路由到该队列,否则不会路由到该队列。(注:该交换器类型性能较差且不实用,因此一般不会用到)。了解了上面的概念,我们再来思考消息是如何从交换器到队列的。首先Rabbit在接收到消息时,会解析消息的标签从而得到消息的交换器与路由key信息。然后根据交换器的类型、路由key以及该交换器和队列的绑定关系来决定消息最终投递到哪个队列里面。

mandatory参数

mandatory参数是channel.BasicPublish方法中的参数。其主要功能是消息传递过程中不可达目的地时将消息返回给生产者。当mandatory 参数设为true 时,交换器无法根据自身的类型和路由键找到一个符合条件的队列,那么RabbitMQ 会调用BasicReturn 命令将消息返回给生产者。当mandatory 参数设置为false 时。则消息直接被丢弃。

备份交换器 Altemate Exchange

当消息不能路由到队列时,通过mandatory设置参数,我们可以将消息返回给生产者处理。但这样会有一个问题,就是生产者需要开一个回调的函数来处理不能路由到的消息,这无疑会增加生产者的处理逻辑。备份交换器(Altemate Exchange)则提供了另一种方式来处理不能路由的消息。备份交换器可以将未被路由的消息存储在RabbitMQ中,在需要的时候去处理这些消息。

备份交换器和普通交换器没什么区别,为了方便使用,建议设置为fanout类型,若设置为direct 或者topic的类型。需要注意的是,消息被重新发送到备份交换器时的路由键和从生产者发出的路由键是一样的。考虑这样一种情况,如果备份交换器的类型是direct,并且有一个与其绑定的队列,假设绑定的路由键是key1,当某条携带路由键为key2 的消息被转发到这个备份交换器的时候,备份交换器没有匹配到合适的队列,则消息丢失。如果消息携带的路由键为keyl,则可以存储到队列中。
对于备份交换器,有以下几种特殊情况:

  • 如果设置的备份交换器不存在,客户端和RabbitMQ 服务端都不会有异常出现,此时消息会丢失。
  • 如果备份交换器没有绑定任何队列,客户端和RabbitMQ 服务端都不会有异常出现,此时消息会丢失。
  • 如果备份交换器没有任何匹配的队列,客户端和RabbitMQ 服务端都不会有异常出现,此时消息会丢失。
  • 如果备份交换器和mandatory参数一起使用,那么mandatory参数无效。

过期时间TTL

设置消息的TTL

目前有两种方法可以设置消息的TTL。第一种方法是通过队列属性设置,队列中所有消息都有相同的过期时间。第二种方法是对消息本身进行单独设置,每条消息的TTL可以不同。如果两种方法一起使用,则消息的TTL 以两者之间较小的那个数值为准。消息在队列中的生存时间一旦超过设置的TTL值时,就会变成"死信" (Dead Message) ,消费者将无法再收到该消息。(有关死信队列请往下看)
通过队列属性设置消息TTL的方法是在channel.QueueDeclare方法中加入x-message-ttl参数实现的,这个参数的单位是毫秒。示例代码下:

IDictionary<string, object> args = new Dictionary<string, object>();
args.Add("x-message-ttl", 6000);
channel.QueueDeclare("ttlQueue", true, false, false, args);

如果不设置TTL.则表示此消息不会过期;如果将TTL设置为0 ,则表示除非此时可以直接将消息投递到消费者,否则该消息会被立即丢弃(或由死信队列来处理)。
针对每条消息设置TTL的方法是在channel.BasicPublish方法中加入Expiration的属性参数,单位为毫秒。关键代码如下:

 BasicProperties properties = new BasicProperties()
            {
                Expiration = "20000",//设置TTL为20000毫秒
            };
 var message = Encoding.UTF8.GetBytes("TestMsg");
 channel.BasicPublish("normalExchange", "NormalRoutingKey", true, properties, message);

注意:对于第一种设置队列TTL属性的方法,一旦消息过期,就会从队列中抹去,而在第二种方法中,即使消息过期,也不会马上从队列中抹去,因为每条消息是否过期是在即将投递到消费者之前判定的。Why?在第一种方法里,队列中己过期的消息肯定在队列头部, RabbitMQ 只要定期从队头开始扫描是否有过期的消息即可。而第二种方法里,每条消息的过期时间不同,如果要删除所有过期消息势必要扫描整个队列,所以不如等到此消息即将被消费时再判定是否过期,如果过期再进行删除即可。

设置队列的TTL

注意,这里和上述通过队列设置消息的TTL不同。上面删除的是消息,而这里删除的是队列。通过channel.QueueDeclare 方法中的x-expires参数可以控制队列被自动删除前处于未使用状态的时间。这个未使用的意思是队列上没有任何的消费者,队列也没有被重新声明,并且在过期时间段内也未调用过channel.BasicGet命令。设置队列里的TTL可以应用于类似RPC方式的回复队列,在RPC中,许多队列会被创建出来,但是却是未被使用的(有关RabbitMQ实现RPC请往下看)。RabbitMQ会确保在过期时间到达后将队列删除,但是不保障删除的动作有多及时。在RabbitMQ 重启后, 持久化的队列的过期时间会被重新计算。用于表示过期时间的x-expires参数以毫秒为单位, 井且服从和x-message-ttl一样的约束条件,不同的是它不能设置为0(会报错)。
示例代码如下:

 IDictionary<string, object> args = new Dictionary<string, object>();
 args.Add("x-expires", 6000);
 channel.QueueDeclare("ttlQueue", false, false, false, args);

死信队列

DLX(Dead-Letter-Exchange)死信交换器,当消息在一个队列中变成死信之后,它能被重新被发送到另一个交换器中,这个交换器就是DLX ,绑定DLX的队列就称之为死信队列。
消息变成死信主要有以下几种情况:

  • 消息被拒绝(BasicReject/BasicNack) ,井且设置requeue 参数为false;(消费者确认机制将会在下一篇文章中涉及)
  • 消息过期;
  • 队列达到最大长度。
    DLX也是一个正常的交换器,和一般的交换器没有区别,它能在任何的队列上被指定,实际上就是设置某个队列的属性。当这个队列中存在死信时,RabbitMQ 就会自动地将这个消息重新发布到设置的DLX上去,进而被路由到另一个队列,即死信队列。可以监听这个队列中的消息、以进行相应的处理。
    通过在channel.QueueDeclare 方法中设置x-dead-letter-exchange参数来为这个队列添加DLX。其示例代码如下:
 channel.ExchangeDeclare("exchange.dlx", "direct", true);//定义死信交换器
 channel.ExchangeDeclare("exchange.normal", "direct", true);//定义普通交换器
 IDictionary<String, Object> args = new Dictionary<String, Object>();
 args.Add("x-message-ttl",10000);//定义消息过期时间为10000毫秒
 args.Add("x-dead-letter-exchange", "exchange.dlx");//定义exchange.dlx为死信交换器
 args.Add("x-dead-letter-routing-key", "routingkey");//定义死信交换器的绑定key,这里也可以不指定,则默认使用原队列的路由key

 channel.QueueDeclare("queue.normal", true, false, false, args);//定义普通队列
 channel.QueueBind("queue.normal", "exchange.normal", "normalKey");//普通队列交换器绑定

 channel.QueueDeclare("queue.dlx", true, false, false, null);//定义死信队列
 channel.QueueBind("queue.dlx", "exchange.dlx", "routingkey");//死信队列交换器绑定,若上方为制定死信队列路由key则这里需要使用原队列的路由key
 //发布消息
 var message = Encoding.UTF8.GetBytes("TestMsg");
 channel.BasicPublish("exchange.normal", "normalKey", null, message) ;

死信队列的运转流程:

延迟队列

RabbitMQ本身并未提供延迟队列的功能。延迟队列是一个逻辑上的概念,可以通过过期时间+死信队列来模拟它的实现。延迟队列的逻辑架构大致如下:

生产者将消息发送到过期时间为n的队列中,这个队列并未有消费者来消费消息,当过期时间到达时,消息会通过死信交换器被转发到死信队列中。而消费者从死信队列中消费消息。这个时候就达到了生产者发布了消息在讲过了n时间后消费者消费了消息,起到了延迟消费的作用。
延迟队列在我们的项目中可以应用于很多场景,如:下单后两个消息取消订单,七天自动收货,七天自动好评,密码冻结后24小时解冻,以及在分布式系统中消息补偿机制(1s后补偿,10s后补偿,5m后补偿......)。

优先级队列

就像我们生活中的“特殊”人士一样,我们的业务上也存在一些“特殊”消息,可能需要优先进行处理,在生活上我们可能会对这部分特殊人士开辟一套VIP通道,而Rabbit同样也有这样的VIP通道(前提是在3.5的版本以后),即优先级队列,队列中的消息会有优先级优先级高的消息具备优先被消费的特权。针对这些VIP消息,我们只需做两件事:

  • 将队列声明为优先级队列,即在创建队列的时候添加参数 x-max-priority 以指定最大的优先级,值为0-255(整数)。
  • 为优先级消息添加优先级。
    其示例代码如下:
channel.ExchangeDeclare("exchange.priority", "direct", true);//定义交换器
IDictionary<String, Object> args = new Dictionary<String, Object>();
args.Add("x-max-priority", 10);//定义优先级队列的最大优先级为10
channel.QueueDeclare("queue.priority", true, false, false, args);//定义优先级队列
channel.QueueBind("queue.priority", "exchange.priority", "priorityKey");//队列交换器绑定
BasicProperties properties = new BasicProperties()
{
    Priority =8,//设置消息优先级为8
};
var message = Encoding.UTF8.GetBytes("TestMsg8");
//发布消息
channel.BasicPublish("exchange.priority", "priorityKey", properties, message);

注意:没有指定优先级的消息会将优先级以0对待。 对于超过优先级队列所定最大优先级的消息,优先级以最大优先级对待。对于相同优先级的消息,后进的排在前面。如果在消费者的消费速度大于生产者的速度且Broker 中没有消息堆积的情况下, 对发送的消息设置优先级也就没有什么实际意义。因为生产者刚发送完一条消息就被消费者消费了,那么就相当于Broker 中至多只有一条消息,对于单条消息来说优先级是没有什么意义的。
关于优先级队列,好像违背了队列这种数据结构先进先出的原则,其具体是怎么实现的在这里就不过多讨论。有兴趣的可以自己研究研究。后续可能也会有相关的文章来分析其原理。

RPC实现

RPC,是Remote Procedure Call 的简称,即远程过程调用。它是一种通过网络从远程计算机上请求服务,而不需要了解底层网络的技术。RPC 的主要功用是让构建分布式计算更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。
这里我们主要介绍RabbitMQ如何实现RPC。RabbitMQ 可以实现很简单的RPC。客户端发送请求消息,服务端回复响应的消息,为了接收响应的消息,我们需要在请求消息中发送一个回调队列(可以使用默认的队列)。其服务器端实现代码如下:

      static void Main(string[] args)
        {
            ConnectionFactory factory = new ConnectionFactory();
            factory.UserName = "admin";
            factory.Password = "admin";
            factory.HostName = "192.168.121.205";
            IConnection conn = factory.CreateConnection();
            IModel channel = conn.CreateModel();
            channel.QueueDeclare("RpcQueue", true, false, false, null);
            SimpleRpcServer rpc = new MySimpRpcServer(new Subscription(channel, "RpcQueue"));
            rpc.MainLoop();
        }
  public class MySimpRpcServer: SimpleRpcServer
    {
        public MySimpRpcServer(Subscription subscription) : base(subscription)
        {
        }

        /// <summary>
        /// 执行完成后进行回调
        /// </summary>   
        public override byte[] HandleSimpleCall(bool isRedelivered, IBasicProperties requestProperties, byte[] body, out IBasicProperties replyProperties)
        {
            replyProperties = null;
            return Encoding.UTF8.GetBytes("我收到了!");
        }
        
        /// <summary>
        /// 进行处理
        /// </summary>
        /// <param name="evt"></param>
        public override void ProcessRequest(BasicDeliverEventArgs evt)
        {
            // todo.....
            base.ProcessRequest(evt);
        }
    }

客户端实现代码如下:

  ConnectionFactory factory = new ConnectionFactory();
  factory.UserName = "admin";
  factory.Password = "admin";
  factory.HostName = "192.168.121.205";
  IConnection conn = factory.CreateConnection();
  IModel channel = conn.CreateModel();
   
  SimpleRpcClient client = new SimpleRpcClient(channel, "RpcQueue");
  var message = Encoding.UTF8.GetBytes("TestMsg8");
  var result = client.Call(message);
  //do somethings...

以上是Rabbit客户端自己帮我们封装好的Rpc客户端与服务端的逻辑。当然我们也可以自己实现,主要是借助于BasicProperties的两个参数。

  • ReplyTo: 通常用来设置一个回调队列。
  • CorrelationId : 用来关联请求(request) 和其调用RPC 之后的回复(response) 。
    其处理流程如下:
  • 当客户端启动时,创建一个匿名的回调队列。
  • 客户端为RPC 请求设置2个属性: ReplyTo用来告知RPC 服务端回复请求时的目的队列,即回调队列; Correlationld 用来标记一个请求。
  • 请求被发送到RpcQueue队列中。
  • RPC 服务端监听RpcQueue队列中的请求,当请求到来时,服务端会处理并且把带有结果的消息发送给客户端。接收的队列就是ReplyTo设定的回调队列。
  • 客户端监昕回调队列,当有消息时,检查Correlationld 属性,如果与请求匹配,那就是结果了。

思考

何时创建队列

RabbitMQ可以选择在生产者创建队列,也可以在消费者端创建队列,也可以提前创建好队列,而生产者消费者直接使用即可。
RabbitMQ的消息存储在队列中,交换器的使用并不真正耗费服务器的性能,而队列会。如在实际业务应用中,需要对所创建的队列的流量、内存占用及网卡占用有一个清晰的认知,预估其平均值和峰值,以便在固定硬件资源的情况下能够进行合理有效的分配。
按照RabbitMQ官方建议,生产者和消费者都应该尝试创建(这里指声明操作)队列。这虽然是一个很好的建议,但是在我看来这个时间上没有最好的方案,只有最适合的方案。我们往往需要结合业务、资源等方面在各种方案里面选择一个最适合我们的方案。

如果业务本身在架构设计之初己经充分地预估了队列的使用情况,完全可以在业务程序上线之前在服务器上创建好(比如通过页面管理、RabbitMQ命令或者更好的是从配置中心下发),这样业务程序也可以免去声明的过程,直接使用即可。预先创建好资源还有一个好处是,可以确保交换器和队列之间正确地绑定匹配。很多时候,由于人为因素、代码缺陷等,发送消息的交换器并没有绑定任何队列,那么消息将会丢失:或者交换器绑定了某个队列,但是发送消息时的路由键无法与现存的队列匹配,那么消息也会丢失。当然可以配合mandatory参数或者备份交换器(关于mandatory参数的使用详细可参考我的上一篇文章) 来提高程序的健壮性。与此同时,预估好队列的使用情况非常重要,如果在后期运行过程中超过预定的阈值,可以根据实际情况对当前集群进行扩容或者将相应的队列迁移到其他集群。迁移的过程也可以对业务程序完全透明。此种方法也更有利于开发和运维分工,便于相应资源的管理。如果集群资源充足,而即将使用的队列所占用的资源又在可控的范围之内,为了增加业务程序的灵活性,也完全可以在业务程序中声明队列。至于是使用预先分配创建资源的静态方式还是动态的创建方式,需要从业务逻辑本身、公司运维体系和公司硬件资源等方面考虑。

持久化及策略

作为一个内存中间件,在保证了速度的情况下,不可避免存在如内存数据库同样的问题,即丢失问题。持久化可以提高RabbitMQ 的可靠性,以防在异常情况(重启、关闭、宕机等)下的数据丢失。RabbitMQ的持久化分为三个部分:交换器的持久化、队列的持久化和消息的持久化。

  • 交换器的持久化
    交换器的持久化是通过在声明队列是将durable 参数置为true 实现的(该参数默认为false)。如果交换器不设置持久化,那么在RabbitMQ 服务重启之后,相关的交换器元数据会丢失,不过消息不会丢失,只是不能将消息发送到这个交换器中了。对一个长期使用的交换器来说,建议将其置为持久化的。

  • 队列的持久化
    队列的持久化是通过在声明队列时将durable 参数置为true 实现的(该参数默认为false),如果队列不设置持久化,那么在RabbitMQ 服务重启之后,相关队列的元数据会丢失,此时数据也会丢失。正所谓"皮之不存,毛将焉附",队列都没有了,消息又能存在哪里呢?

  • 消息的持久化
    队列的持久化能保证其本身的元数据不会因异常情况而丢失,但是并不能保证内部所存储的消息不会丢失。要确保消息不会丢失,需要将其设置为持久化。通过将消息的投递模式(BasicProperties中的DeliveryMode属性)设置为2即可实现消息的持久化。

因此,消息如果要想在Rabbit重启、关闭、宕机时能够恢复,需要做到以下三点:

  • 把消息的投递模式设置为2
  • 发送到持久化的交换器
  • 到达持久化的队列
    注意:RabbitMQ 确保持久化消息能从服务器重启中恢复的方式是将它们写入磁盘上的一个持久化日志文件中。当发布一条持久化消息到持久化交换器时,Rabbit会在日志提交到日志文件后才发送响应(开启生产者确认机制)。之后,如果消息到了非持久化队列,它会自动从日志文件中删除,并且无法在服务器重启后恢复。因此单单只设置队列持久化,重启之后消息会丢失;单单只设置消息的持久化,重启之后队列消失,继而消息也丢失。单单设置消息持久化而不设置队列的持久化是毫无意义的。当从持久化队列中消费了消息后(并且确认后),RabbitMQ会在持久化日志中把这条消息标记为等待垃圾收集。而在消费持久化消息之前,若RabbitMQ服务器重启,会自动重建交换器、队列以及绑定,重播持久化日志文件中的消息到合适的队列或者交换器上(取决于宕机时,消息处在路由的哪个环节)。

为了保障消息不会丢失,也许我们可以简单粗暴的将所有的消息标记为持久化,但这样我们会付出性能的代价。写入磁盘的速度比写入内存的速度慢得不只一点点。对于可靠性不是那么高的消息可以不采用持久化处理以提高整体的吞吐量。在选择是否要将消息持久化时,需要在可靠性和吐吞量之间做一个权衡。

将交换器、队列、消息都设置了持久化之后就能百分之百保证数据不丢失了吗?

从消费者来说,如果在订阅消费队列时将noAck参数设置为true ,那么当消费者接收到相关消息之后,还没来得及处理就宕机了,这样也算数据丢失。
在持久化的消息正确存入RabbitMQ 之后,还需要有一段时间(虽然很短,但是不可忽视〉才能存入磁盘之中。RabbitMQ 并不会为每条消息都进行同步存盘的处理,可能仅仅保存到操作系统缓存之中而不是物理磁盘之中。如果在这段时间内RabbitMQ 服务节点发生了岩机、重启等异常情况,消息保存还没来得及落盘,那么这些消息将会丢失。
关于第一个问题,可以通过消费者确认机制来解决。而第二个问题可以通过生产者确认机制来解决,也可以使用镜像队列机制(镜像队列机制,将在运维篇总结)。生产者确认消费者确认请往下看。

生产者确认

上文我们知道,在使用RabbitMQ的时候,可以通过消息持久化操作来解决因为服务器的异常崩溃而导致的消息丢失,除此之外,我们还会遇到一个问题,当消息的生产者将消息发送出去之后,消息到底有没有正确地到达服务器呢?如果不进行特殊配置,默认情况下发送消息的操作是不会返回任何信息给生产者的,也就是默认情况下生产者是不知道消息有没有正确地到达服务器。如果在消息到达服务器之前己经丢失,持久化操作也解决不了这个问题,因为消息根本没有到达服务器,何谈持久化?
RabbitMQ针对这个问题,提供了两种解决方式:

  • 通过事务机制实现:
    通过发送方确认(publisher confirm)机制实现。

RabbitMQ 事务机制

RabbitMQ 客户端中与事务机制相关的方法有三个:channel.TxSelect(用于将当前信道设置为事务模式);channel.TxCommit(用于提交事务),channel.TxRollback(用于回滚事务)。在通过channel.TxSelect方法开启事务之后,我们便可以发布消息给RabbitMQ了,如果事务提交成功,则消息一定到达了RabbitMQ 中,如果在事务提交执行之前由于RabbitMQ异常崩溃或者其他原因抛出异常,这个时候我们便可以将其捕获,进而通过执行channel.TxRollback方法来实现事务回滚。示例代码如下所示:

  channel.TxSelect();//将信道设置为事务模式
  try
  {
      //do something
      var message = Encoding.UTF8.GetBytes("TestMsg");
      channel.BasicPublish("normalExchange", "NormalRoutingKey", true, null, message);
      //do something
      channel.TxCommit();//提交事务
  }
  catch (Exception ex)
  {
      //log(ex);
      channel.TxRollback();
  }

事务确实能够解决消息发送方和RabbitMQ之间消息确认的问题,只有消息成功被RabbitMQ接收,事务才能提交成功,否则便可在捕获异常之后进行事务回滚,与此同时可以进行消息重发。但是使用事务同样会带来一些问题。

  • 会阻塞,发布者必须等待broker处理每个消息。
  • 事务是重量级的,每次提交都需要fsync(),需要耗费大量的时间
  • 事务非常耗性能,会降低RabbitMQ的消息吞吐量。

发送方确认机制

前面介绍了RabbitMQ可能会遇到的一个问题,即消息发送方(生产者〉并不知道消息是否真正地到达了RabbitMQ。随后了解到在AMQP协议层面提供了事务机制来解决这个问题,但是采用事务机制实现会严重降低RabbitMQ的消息吞吐量,这里就引入了一种轻量级的方式一发送方确认(publisher confirm)机制。生产者将信道设置成confirm确认)模式,一旦信道进入confirm模式,所有在该信道上面发布的消息都会被指派一个唯一的ID( 从1开始),一旦消息被投递到所有匹配的队列之后,RabbitMQ就会发送一个确认(BasicAck) 给生产者(包含消息的唯一ID),这就使得生产者知晓消息已经正确到达了目的地了。如果消息和队列是可持久化的,那么确认消息会在消息写入磁盘之后发出。
发送方确认模式,示例代码如下:

 //示例1--同步等待
 channel.ConfirmSelect();//开启确认模式
 var message = Encoding.UTF8.GetBytes("TestMsg");
 channel.ExchangeDeclare("normalExchange", "direct", true, false, null);
 channel.QueueDeclare("normalQueue", true, false, false, null);
 channel.QueueBind("normalQueue", "normalExchange", "NormalRoutingKey");
 channel.BasicPublish("normalExchange", "NormalRoutingKey", true, null, message);
 //var result=channel.WaitForConfirmsOrDie(Timeout); 
 //WaitForConfirmsOrDie 使用WaitForConfirmsOrDie 在Rabbit发送Nack命令或超时时会抛出一个异常
 var result = channel.WaitForConfirms();//等待该信道所有未确认的消息结果
 if(!result){
     //send message failed;
 }
 //示例2--异步通知
 channel.ConfirmSelect();//开启确认模式
 var message = Encoding.UTF8.GetBytes("TestMsg");
 channel.ExchangeDeclare("normalExchange", "direct", true, false, null);
 channel.QueueDeclare("normalQueue", true, false, false, null);
 channel.QueueBind("normalQueue", "normalExchange", "NormalRoutingKey");
 channel.BasicPublish("normalExchange", "NormalRoutingKey", true, null, message);
 channel.BasicAcks += (model, ea) =>
 {
     //消息被投递到所有匹配的队列之后,RabbitMQ就会发送一个确认(Basic.Ack)给生产者(包含消息的唯一ID)
     //ea.Multiple为True代表 ea.DeliveryTag编号之前的消息均已被确认。
    //do something;
 };
 channel.BasicNacks += (model, ea) =>
 {
     //如果RabbitMQ 因为自身内部错误导致消息丢失,就会发送一条nack(BasicNack) 命令
    //do something;
 };

关于生产者确认机制同样会有一些问题,broker不能保证消息会被confirm,只知道将会进行confirm。这样如果broker与生产者之间的连接断开,导致生产者不能收到确认消息,可能会重复进行发布。总之,生产者确认模式给客户端提供了一种较为轻量级的方式,能够跟踪哪些消息被broker处理,哪些可能因为broker宕掉或者网络失败的情况而重新发布。

注意:事务机制和publisher confirm机制两者是互斥的,不能共存。如果企图将已开启事务模式的信道再设置为publisher confmn模式, RabbitMQ会报错,或者如果企图将已开启publisher confirm模式的信道设置为事务模式, RabbitMQ也会报错。在性能上来看,而到底应该选择事务机制还是Confirm机制,则需要结合我们的业务场景。

消费者确认

为了保证消息从队列可靠地达到消费者,RabbitMQ提供了消息确认机制(message acknowledgement)。消费者在订阅队列时,可以指定noAck参数,当noAck等于false时,RabbitMQ会等待消费者显式地回复确认信号后才从内存(或者磁盘)中移去消息(实质上是先打上删除标记,之后再删除)。当noAck等于true时,RabbitMQ会自动把发送出去的消息置为确认,然后从内存(或者磁盘)中删除,而不管消费者是否真正地消费到了这些消息。
采用消息确认机制后,只要设置noAck参数为false,消费者就有足够的时间处理消息(任务),不用担心处理消息过程中消费者进程挂掉后消息丢失的问题,因为RabbitMQ会一直等待持有消息直到消费者显式调用BasicAck命令为止。
当noAck参数置为false,对于RabbitMQ服务端而言,队列中的消息分成了两个部分:一部分是等待投递给消费者的消息:一部分是己经投递给消费者,但是还没有收到消费者确认信号的消息。如果RabbitMQ 一直没有收到消费者的确认信号,并且消费此消息的消费者己经断开连接,则RabbitMQ会安排该消息重新进入队列,等待投递给下一个消费者,当然也有可能还是原来的那个消费者。
RabbitMQ不会为未确认的消息设置过期时间,它判断此消息是否需要重新投递给消费者的唯一依据是消费该消息的消费者连接是否己经断开,这么设计的原因是RabbitMQ 允许消费者消费一条消息的时间可以很久很久。
关于RabbitMQ消费者确认机制示例代码如下:

  //推模式
  EventingBasicConsumer consumer = new EventingBasicConsumer(channel);
  //定义消费者回调事件
  consumer.Received += (model, ea) =>
  {
      //do someting;
      //channel.BasicReject(ea.DeliveryTag, requeue: true);//拒绝
      //requeue参数为true会重新将这条消息存入队列,以便可以发送给下一个订阅的消费者
      channel.BasicAck(ea.DeliveryTag, multiple: false);//确认
      //若:multiple参数为true,则确认DeliverTag这个编号之前的消息
  };
  channel.BasicConsume(queue: "queueName",
                      noAck: false,
                     consumer: consumer);

  //拉模式
  BasicGetResult result = channel.BasicGet("queueName", noAck: false);
  //确认
  channel.BasicAck(result.DeliveryTag, multiple: false);

如上,消费者在消费消息的同时,Rabbit会同步给予消费者一个DeliveryTag,这个DeliveryTag就像我们数据库中的主键,消费者在消费完毕后拿着这个DeliveryTag去Rabbit确认或拒绝这个消息。

void BasicAck(ulong deliveryTag, bool multiple);

void BasicReject(ulong deliveryTag, bool requeue);

void BasicNack(ulong deliveryTag, bool multiple, bool requeue);
  • deliveryTag:可以看作消息的编号,它是一个64位的长整型值,最大值是9223372036854775807。
  • requeue:如果requeue 参数设置为true,则RabbitMQ会重新将这条消息存入队列,以便可以发送给下一个订阅的消费者;如果requeue 参数设置为false,则RabbitMQ立即会把消息从队列中移除,而不会把它发送给新的消费者。
  • BasicReject命令一次只能拒绝一条消息,如果想要批量拒绝消息,则可以使用Basic.Nack这个命令。
  • multiple:在BasicAck中,multiple 参数设置为true 则表示确认deliveryTag编号之前所有已被当前消费者确认的消息。在BasicNack中,multiple 参数设置为true 则表示拒绝deliveryTag 编号之前所有未被当前消费者确认的消息。
    说明:将channel.BasicReject 或者channel.BasicNack中的requeue设置为false ,可以启用"死信队列"的功能。
    上述requeue,都会将消息重新存入队列发送给下一个消费者(也有可能是其它消费者)。关于requeue还有下面一种用法。可以选择是否补发给当前的consumer。
//补发消息 true退回到queue中 /false只补发给当前的consumer
channel.BasicRecover(true);

注意:RabbitMQ仅仅通过Consumer的连接中断来确认该Message并没有被正确处理。也就是说,RabbitMQ给了Consumer足够长的时间来做数据处理。如果忘记了ack,那么后果很严重。当Consumer退出时,Message会重新分发。然后RabbitMQ会占用越来越多的内存,由于RabbitMQ会长时间运行,这个“内存泄漏”是致命的。

消息分发与顺序

消息分发

当RabbitMQ 队列拥有多个消费者时,队列收到的消息将以轮询(round-robin)的分发方式发送给消费者。每条消息只会发送给订阅列表里的一个消费者。这种方式非常适合扩展,而且它是专门为并发程序设计的。如果现在负载加重,那么只需要创建更多的消费者来消费处理消息即可。
很多时候轮询的分发机制也不是那么优雅。默认情况下,如果有n个消费者,那么RabbitMQ会将第m条消息分发给第m%n (取余的方式)个消费者, RabbitMQ 不管消费者是否消费并己经确认了消息。试想一下,如果某些消费者任务繁重,来不及消费那么多的消息,而某些其他消费者由于某些原因(比如业务逻辑简单、机器性能卓越等)很快地处理完了所分配到的消息,进而进程空闲,这样就会造成整体应用吞吐量的下降。那么该如何处理这种情况呢?这里就要用到channel.BasicQos(int prefetchCount)这个方法,channel.BasicQos方法允许限制信道上的消费者所能保持的最大未确认消息的数量。
举例说明,在订阅消费队列之前,消费端程序调用了channel.BasicQos(5),之后订阅了某个队列进行消费。RabbitMQ 会保存一个消费者的列表,每发送一条消息都会为对应的消费者计数,如果达到了所设定的上限,那么RabbitMQ 就不会向这个消费者再发送任何消息。直到消费者确认了某条消息之后, RabbitMQ 将相应的计数减1,之后消费者可以继续接收消息,直到再次到达计数上限。
注意:Basic.Qos 的使用对于拉模式的消费方式无效.

void BasicQos(uint prefetchSize, ushort prefetchCount, bool global);
  • prefetchCount:允许限制信道上的消费者所能保持的最大未确认消息的数量,设置为0表示没有上限。
  • prefetchSize:消费者所能接收未确认消息的总体大小的上限,单位为B,设置为0表示没有上限。
  • global:对于一个信道来说,它可以同时消费多个队列,当设置了prefetchCount 大于0 时,这个信道需要和各个队列协调以确保发送的消息都没有超过所限定的prefetchCount 的值,这样会使RabbitMQ 的性能降低,尤其是这些队列分散在集群中的多个- - Broker节点之中。RabbitMQ 为了提升相关的性能,在AMQPO-9-1 协议之上重新定义了global这个参数。如下表所示:
    global参数|AMQP 0-9-1|RabbitMQ
    --|:--😐--:
    false|信道上所有的消费者都需要遵从prefetchCount 的限信道上新的消费者需要遵从prefetchCount 的限定值|信道上新的消费者需要遵从prefetchCount 的限定值
    true|当前通信链路( Connection) 上所有的消费者都需信道上所有的消费者都需要遵从prefetchCount的限定值|信道上所有的消费者需要遵从prefetchCount 的限定值
    注意:对于同一个信道上的多个消费者而言,如果设置了prefetchCount 的值,那么都会生效。
//伪代码
Consumer consumer1 = ...;
Consumer consumer2 = ...;
channel.BasicQos(10) ; 
channel.BasicConsume("my-queue1" , false , consumer1);
channel.BasicConsume("my-queue2" , false , consumer2);
//两个消费者各自的能接收到的未确认消息的上限都为10 。

如果在订阅消息之前,既设置了global 为true 的限制,又设置了global为false的限制,RabbitMQ 会确保两者都会生效。但会增加RabbitMQ的负载因为RabbitMQ 需要更多的资源来协调完成这些限制。

//伪代码
Channel channel = ...;
Consumer consumerl = ...;
Consumer consumer2 = ...;
channel.BasicQos(3 , false); 
channel.BasicQos(5 , true); 
channel.BasicConsume("queuel" , false , consumerl) ;
channel.BasicConsume("queue2" , false , consumer2) ;
//这里每个消费者最多只能收到3个未确认的消息,两个消费者能收到的未确认的消息个数之和的上限为5

消息顺序

消息的顺序性是指消费者消费到的消息和发送者发布的消息的顺序是一致的。举个例子,不考虑消息重复的情况,如果生产者发布的消息分别为msgl、msg2、msg3,那么消费者必然也是按照msgl、msg2、msg3的顺序进行消费的。
目前很多资料显示RabbitMQ的消息能够保障顺序性,这是不正确的,或者说这个观点有很大的局限性。在不使用任何RabbitMQ的高级特性,也没有消息丢失、网络故障之类异常的情况发生,并且只有一个消费者的情况下,最好也只有一个生产者的情况下可以保证消息的顺序性。如果有多个生产者同时发送消息,无法确定消息到达Broker 的前后顺序,也就无法验证消息的顺序性。
那么哪些情况下RabbitMQ 的消息顺序性会被打破呢?下面介绍几种常见的情形。

如果生产者使用了事务机制,在发送消息之后遇到异常进行了事务回滚,那么需要重新补偿发送这条消息,如果补偿发送是在另一个线程实现的,那么消息在生产者这个源头就出现了错序。同样,如果启用publisher confirm时,在发生超时、中断,又或者是收到RabbitMQ的BasicNack命令时,那么同样需要补偿发送,结果与事务机制一样会错序。或者这种说法有些牵强,我们可以固执地认为消息的顺序性保障是从存入队列之后开始的,而不是在发迭的时候开始的。

考虑另一种情形,如果生产者发送的消息设置了不同的超时时间,井且也设置了死信队列,整体上来说相当于一个延迟队列,那么消费者在消费这个延迟队列的时候,消息的顺序必然不会和生产者发送消息的顺序一致。

如果消息设置了优先级,那么消费者消费到的消息也必然不是顺序性的。

如果一个队列按照前后顺序分有msg1, msg2、msg3、msg4这4 个消息,同时有ConsumerA和ConsumerB 这两个消费者同时订阅了这个队列。队列中的消息轮询分发到各个消费者之中,ConsumerA 中的消息为msg1和msg3,ConsumerB中的消息为msg2、msg4。ConsumerA收到消息msg1之后并不想处理而调用了BasicNack/BasicReject将消息拒绝,与此同时将requeue设置为true,这样这条消息就可以重新存入队列中。消息msg1之后被发送到了ConsumerB中,此时ConsumerB己经消费了msg2、msg4,之后再消费msg1.这样消息顺序性也就错乱了。

包括但不仅限于以上几种情形会使RabbitMQ 消息错序。如果要保证消息的顺序性,需要业务方使用的时候做进一步的处理。如在消息体内添加全局有序标识等。

posted @ 2019-12-20 15:29  weigen-  阅读(531)  评论(0编辑  收藏  举报