一、消息队列技术介绍
1.1、 消息队列概述
消息队列中间件是分布式系统中重要的组件,主要解决应用耦合、异步消息、流量削锋等问题。实现高性能、高可用、可伸缩和最终一致性架构。是大型分布式系统不可缺少的中间件。
目前在生产环境,使用较多的消息队列有ActiveMQ、RabbitMQ、ZeroMQ、Kafka、MetaMQ、RocketMQ等。
1.2、 消息队列应用场景
下面详细介绍一下消息队列在实际应用中常用的使用场景。场景分为异步处理、应用解耦、流量削锋和消息通讯四个场景。
1.2.1 异步处理
场景说明:用户注册后,需要发送注册邮件和发送注册信息,传统的做法有两种:串行方式、并行方式
串行方式
将注册信息写入数据库成功后,发送注册邮件,然后发送注册短信,而所有任务执行完成后,返回信息给客户端
并行方式
将注册信息写入数据库成功后,同时进行发送注册邮件和发送注册短信的操作。而所有任务执行完成后,返回信息给客户端。同串行方式相比,并行方式可以提高执行效率,减少执行时间。
上面的比较可以发现,假设三个操作均需要50ms的执行时间,排除网络因素,则最终执行完成,串行方式需要150ms,而并行方式需要100ms。
因为cpu在单位时间内处理的请求数量是一致的,假设:CPU每1秒吞吐量是100此,则串行方式1秒内可执行的请求量为1000/150,不到7次;并行方式1秒内可执行的请求量为1000/100,为10次。
由上可以看出,传统串行和并行的方式会受到系统性能的局限,那么如何解决这个问题? 我们需要引入消息队列,将不是必须的业务逻辑,异步进行处理,由此改造出来的流程为
根据上述的流程,用户的响应时间基本相当于将用户数据写入数据库的时间,发送注册邮件、发送注册短信的消息在写入消息队列后,即可返回执行结果,写入消息队列的时间很快,几乎可以忽略,也有此可以将系统吞吐量提升至20QPS,比串行方式提升近3倍,比并行方式提升2倍。
1.2.2 应用解耦
场景说明:用户下单后,订单系统需要通知库存系统。
传统的做法为:订单系统调用库存系统的接口。如下图所示:
传统方式具有如下缺点: -1. 假设库存系统访问失败,则订单减少库存失败,导致订单创建失败 -2. 订单系统同库存系统过度耦合
如何解决上述的缺点呢?需要引入消息队列,引入消息队列后的架构如下图所示:
- 订单系统:用户下单后,订单系统进行数据持久化处理,然后将消息写入消息队列,返回订单创建成功
- 库存系统:使用拉/推的方式,获取下单信息,库存系统根据订单信息,进行库存操作。
假如在下单时库存系统不能正常使用。也不影响正常下单,因为下单后,订单系统写入消息队列就不再关心其后续操作了。由此实现了订单系统与库存系统的应用解耦。
1.2.3 流量削锋
流量削锋也是消息队列中的常用场景,一般在秒杀或团抢活动中使用广泛。
应用场景:秒杀活动,一般会因为流量过大,导致流量暴增,应用挂掉。为解决这个问题,一般需要在应用前端加入消息队列。
- 可以控制参与活动的人数;
- 可以缓解短时间内高流量对应用的巨大压力;
流量削锋处理方式系统图如下:
- 服务器在接收到用户请求后,首先写入消息队列。这时如果消息队列中消息数量超过最大数量,则直接拒绝用户请求或返回跳转到错误页面;
- 秒杀业务根据秒杀规则读取消息队列中的请求信息,进行后续处理。
1.2.4 日志处理
日志处理是指将消息队列用在日志处理中,比如Kafka的应用,解决大量日志传输的问题。架构简化如下:
- 日志采集客户端:负责日志数据采集,定时写受写入Kafka队列;
- Kafka消息队列:负责日志数据的接收,存储和转发;
- 日志处理应用:订阅并消费kafka队列中的日志数据;
1.3、RabbitMQ历史及术语介绍
1.3.1. 消息队列的历史
了解一件事情的来龙去脉,将不会对它感到神秘。让我们来看看消息队列(Message Queue)这项技术的发展历史。
Message Queue的需求由来已久,80年代最早在金融交易中,高盛等公司采用Teknekron公司的产品,当时的Message queuing软件叫做:the information bus(TIB)。 TIB被电信和通讯公司采用,路透社收购了Teknekron公司。之后,IBM开发了MQSeries,微软开发了Microsoft Message Queue(MSMQ)。这些商业MQ供应商的问题是厂商锁定,价格高昂。2001年,Java Message queuing试图解决锁定和交互性的问题,但对应用来说反而更加麻烦了。
于是2004年,摩根大通和iMatrix开始着手Advanced Message Queuing Protocol (AMQP)开放标准的开发。2006年,AMQP规范发布。2007年,Rabbit技术公司基于AMQP标准开发的RabbitMQ 1.0 发布。
目前RabbitMQ的最新版本为3.5.7,基于AMQP 0-9-1。
RabbitMQ采用Erlang语言开发。Erlang语言由Ericson设计,专门为开发concurrent和distribution系统的一种语言,在电信领域使用广泛。OTP(Open Telecom Platform)作为Erlang语言的一部分,包含了很多基于Erlang开发的中间件/库/工具,如mnesia/SASL,极大方便了Erlang应用的开发。OTP就类似于Python语言中众多的module,用户借助这些module可以很方便的开发应用。
1.3.2. AMQP messaging 中的基本概念
基本概念
- 消息(Message):由有效载荷(playload)和标签(label)组成。其中有效载荷既传输的数据。
- 生产者(producer):创建消息,发布到代理服务器(Message Broker)。
- 代理服务器(Message Broker):接收和分发消息的应用,RabbitMQ Server就是消息代理服务器,其中包含概念很多,以RabbitMQ 为例:信道(channel)、队列(queue)、交换器(exchange)、路由键(routing key)、绑定(binding key)、虚拟主机(vhost)等。
- 消费者(consumer):连接到代理服务器,并订阅到队列(queue)上,代理服务器将发送消息给一个订阅的/监听的消费者,消费者其只能接收消息的一部分:有效载荷(playload)。
术语介绍
- Broker: 接收和分发消息的应用,RabbitMQ Server就是Message Broker。
- Virtual host: 出于多租户和安全因素设计的,把AMQP的基本组件划分到一个虚拟的分组中,类似于网络中的namespace概念。当多个不同的用户使用同一个RabbitMQ server提供的服务时,可以划分出多个vhost,每个用户在自己的vhost创建exchange/queue等。
- Connection: publisher/consumer和broker之间的TCP连接。断开连接的操作只会在client端进行,Broker不会断开连接,除非出现网络故障或broker服务出现问题。
- Channel: 如果每一次访问RabbitMQ都建立一个Connection,在消息量大的时候建立TCP Connection的开销将是巨大的,效率也较低。Channel是在connection内部建立的逻辑连接,如果应用程序支持多线程,通常每个thread创建单独的channel进行通讯,AMQP method包含了channel id帮助客户端和message broker识别channel,所以channel之间是完全隔离的。Channel作为轻量级的Connection极大减少了操作系统建立TCP connection的开销。
- Exchange: message到达broker的第一站,根据分发规则,匹配查询表中的routing key,分发消息到queue中去。常用的类型有:direct (point-to-point), topic (publish-subscribe) and fanout (multicast)。
- Queue: 消息最终被送到这里等待consumer取走。一个message可以被同时拷贝到多个queue中。
- Binding: exchange和queue之间的虚拟连接,binding中可以包含routing key。Binding信息被保存到exchange中的查询表中,用于message的分发依据。
1.4、典型的“生产/消费”消息模型
生产者发送消息到broker server(RabbitMQ)。在Broker内部,用户创建Exchange/Queue,通过Binding规则将两者联系在一起。Exchange分发消息,根据类型/binding的不同分发策略有区别。消息最后来到Queue中,等待消费者取走。
二、RabbitMQ代码实现(C#)
安装nuget包,RabbitMQ.Client
2.1、简单模式
RabbitMQ 是一个消息代理:它接收并转发消息。您可以将其想象成一个邮局:当您将要邮寄的邮件放入邮箱时,您可以确信邮递员最终会将邮件递送给收件人。在这个比喻中,RabbitMQ 是一个邮箱、一个邮局和一个邮递员。
RabbitMQ 与邮局之间的主要区别在于它不处理纸张,而是接受、存储和转发二进制数据块(消息)。
RabbitMQ 以及一般的消息传递功能使用了一些术语。
- 生产无非就是发送。发送消息的程序就是生产者:
- 队列是 RabbitMQ 中邮箱的名称。尽管消息通过 RabbitMQ 和您的应用程序流动,但它们只能存储在队列中 。队列仅受主机的内存和磁盘限制,它本质上是一个大型消息缓冲区。
多个生产者可以发送消息到一个队列,多个 消费者可以尝试从一个队列接收数据。
这就是我们表示队列的方式: - 消费与接收含义类似。消费者是一个大部分时间等待接收消息的程序:
请注意,生产者、消费者和代理不必位于同一主机上;事实上,在大多数应用程序中,它们都不是。应用程序也可以同时是生产者和消费者。
在下图中,“P”是我们的生成者,“C”是我们的消费者。中间的方框是一个队列 - RabbitMQ 代表消费者保存的消息缓冲区。
生产者
对以下队列声明:
因为autoDelete设置为true,如果只启动生产端,不发送任何消费,生产端停止后,队列不会自动删除,只有当启动一个消费端,并停止后,队列才会自动删除;
上面的示例中,即使durable持久化队列为true,仍然会保持上面的规范,即当消费者断开连接后,队列自动删除;如下示例:
消费者
2.2、工作队列
2.2.1手动应答
默认情况下,RabbitMQ 会按顺序将每条消息发送给下一个消费者。平均而言,每个消费者都会收到相同数量的消息。这种分发消息的方式称为轮询。
执行一个任务可能会耗时好几秒钟。你也许会好奇如果一个消费者在执行一个耗时任务时只完成了部分工作就挂掉的情况下会发生什么。在我们当前代码下,一旦RabbitMQ将消息投送给消费者后,它会立即将消息标示为删除状态。这个案例中,如果你结束掉RabbitMQ的应用程序的话,会丢失它正在处理的消息。
为了确保消息永不丢失,RabbitMQ支持 消息确认。消费者回送一个确认信号——ack(nowledgement)给RabbitMQ,告诉它一条指定的消息已经接收到并且处理完毕,可以选择将消息删除掉了。
核心代码如下:
上面代码中,我们将autoAck设置为false,这时如果一个消费者在没有回送确认信号的情况下挂掉了(消费者的信道关闭,连接关闭或者TCP连接已经丢失),RabbitMQ会理解为此条消息没有被处理完成,并且重新将其放入队列。如果恰时有其他消费者在线,这条消息会立即被投送给其他的消费者。通过这种方式,你可以确定即使有工作者由于事故挂掉,也不会发生消息丢失的情况。
手动进行消息确认 默认为开启状态。上个例子中,我们明确地通过将autoAck ("自动确认模式")设置为true将其关闭掉了。这次我们会移除掉这个标志,一旦任务完成,手动从工作者当中发送合适的确认标志。
上面代码,在消费消息时增加一个延时5秒处理,autoAck为false,同时打开3个消费端,如果某个消费端停止,会自动把没有接收到的消息转发到其他消费,避免消息不丢失。
2.2.2MQ消息持久化
我们已经学会了如何确保即使消费者死亡,任务也不会丢失。但如果 RabbitMQ 服务器停止,我们的任务仍然会丢失。
当RabbitMQ退出或崩溃的时候会导致MQ Broker中的数据丢失,为保证在MQ崩溃重启后消息仍然存在,需要将队列的durable属性设为true,同时需要将IBasicProperties.Persistent
设置为true
。
或者将DeliveryMode设置为2,properties.DeliveryMode = 2;
仅设置队列的durable属性,则只保证队列不丢失,但队列中的消息还是在重启后丢失,如果想保证队列中的消息持久化,就需要配合另外的参数,如Persistent或DeliveryMode 。
2.2.3消费端限流
2.2.3.1为什么要对消费端限流 #
假设一个场景,首先,我们 Rabbitmq 服务器积压了有上万条未处理的消息,我们随便打开一个消费者客户端,会出现这样情况: 巨量的消息瞬间全部推送过来,但是我们单个客户端无法同时处理这么多数据!
当数据量特别大的时候,我们对生产端限流肯定是不科学的,因为有时候并发量就是特别大,有时候并发量又特别少,我们无法约束生产端,这是用户的行为。所以我们应该对消费端限流,用于保持消费端的稳定,当消息数量激增的时候很有可能造成资源耗尽,以及影响服务的性能,导致系统的卡顿甚至直接崩溃。
2.2.3.2 限流的 api 讲解 #
RabbitMQ 提供了一种 qos (服务质量保证)功能,即在非自动确认消息的前提下,如果一定数目的消息(通过基于 consume 或者 channel 设置 Qos 的值)未被确认前,不进行消费新的消息。
- prefetchSize:0,单条消息大小限制,0代表不限制
- prefetchCount:一次性消费的消息数量。会告诉 RabbitMQ 不要同时给一个消费者推送多于 N 个消息,即一旦有 N 个消息还没有 ack,则该 consumer 将 block 掉,直到有消息 ack。
- global:true、false 是否将上面设置应用于 channel,简单点说,就是上面限制是 channel 级别的还是 consumer 级别。当我们设置为 false 的时候生效,设置为 true 的时候没有了限流功能,因为 channel 级别尚未实现。
- 注意:prefetchSize 和 global 这两项,rabbitmq 没有实现,暂且不研究。特别注意一点,prefetchCount 在 no_ask=false 的情况下才生效,即在自动应答的情况下这两个值是不生效的。
生产者代码
消费者代码
2.3、发布/订阅模式
在 上一篇教程中,我们创建了一个工作队列,并实现每个任务只发送给一个消费者(轮询方式)。在本部分中,我们将做一些完全不同的事情——我们将向多个消费者发送一条消息。这种模式称为“发布/订阅”。
为了说明该模式,我们将构建一个简单的日志系统。它将由两个程序组成 - 第一个程序将发出日志消息,第二个程序将接收并打印它们。
在我们的日志系统中,每个正在运行的消费者都将收到消息。这样,我们就可以运行一个消费者并将日志直接发送到磁盘;同时,我们还可以运行另一个消费者并在屏幕上查看日志。
本质上,已发布的日志消息将被广播给所有接收者。
2.3.1 Fanout Elxchange(广播交换器)
在本教程的前面部分中,我们向队列发送消息,并从队列接收消息。现在是时候介绍 Rabbit 中的完整消息传递模型了。
让我们快速回顾一下前面的教程中讲过的内容:
- 生产者是发送消息的用户应用程序。
- 队列是存储消息的缓冲区。
- 消费者是接收消息的用户应用程序。
RabbitMQ 消息模型的核心思想是生产者永远不会直接将消息发送到队列。实际上,生产者通常甚至不知道消息是否会被发送到任何队列。
相反,生产者只能将消息发送到*交换机。交换机非常简单。一方面,它从生产者那里接收消息,另一方面,它将消息推送到队列。交换机必须确切地知道如何处理它收到的消息。*比如是将消息发送到指定队列,还是发送到多个队列,还是要丢弃消息,这些规则都是由Exchange的类型定义。
有几种可用的交换器类型:direct
、topic
、headers
和fanout
。我们将重点介绍最后一种交换器——fanout
。让我们创建一个这种类型的交换器,并将其命名logs
:
fanout
交换器非常简单。从名称中你大概可以猜到,它只是将收到的所有消息广播到它知道的所有队列。这正是logger所需要的。
2.3.2 列出所有交换器
要列出服务器上的所有交换器,您可以运行以下命令rabbitmqctl
:
执行结果中会包含一些类似amq.*
的交换器或者默认(未命名)交换器。这些都是默认创建的,但目前不太可能需要使用它们。
2.3.3 默认交换器
在本教程的前几部分中,我们对交换器一无所知,但仍然能够将消息发送到队列。这是可能的,因为我们使用的是默认交换器,我们通过空字符串 ( ""
) 来识别它。
回想一下我们之前发布过一条消息:
第一个参数是交换器的名称,空字符串表示默认交换器的或未命名的交换器:消息会按照routingKey指定的路由key,路由到具有由routingKey指定的名称的队列。也就是说,routingKey的值和队列中的name值是相同的。此时消息会发送到与routingKey相同名称的队列上。
现在,我们可以发布到我们上面命名的logs
交换器,注意,此时routingKey为空:
2.3.4 临时队列
您可能还记得,我们之前使用的是具有特定名称的队列(还记的hello和task_queue吗?)我们需要将workers指向同一个队列。当您想在生产者和消费者之间共享队列时,给队列命名很重要。
但对于我们的logger来说情况并非如此。我们希望所有的消费端接收到所有日志消息。
首先,每当我们连接到 RabbitMQ时,我们都需要一个新的空队列。为此,我们可以创建一个具有随机名称的队列,或者更好的是 - 让服务器为我们选择一个随机队列名称。
其次,一旦我们断开消费者的连接,队列就应该被自动删除。
在 .NET 客户端中,当我们不提供任何参数时,QueueDeclare()
我们将创建一个具有生成名称的非持久、独占、自动删除的队列:
此时queueName
包含一个随机队列名称。例如,它可能看起来像amq.gen-JzTY20BRgKO-HjmUJj0wLg
我们已经创建了fanout
交换器和临时队列。现在我们需要告诉交换器将消息发送到我们的队列。交换器和队列之间的关系称为绑定。
与生产者对应,routingKey的值是空的。
从现在开始,logs
交换器将会把消息发送到我们的队列中。
2.3.5 列出绑定
你可以使用以下方法列出现有的绑定:
2.3.6 完整代码
发出日志消息的生产者程序与上一个教程看起来没有太大区别。最重要的变化是我们现在想要将消息发布到我们的logs
交换器而不是未命名(空的)的交换器。当发送时我们需要提供routingKey
的值,但fanout
交换器会忽略它的值。以下是文件EmitLog.cs
的代码:
消息是直接发送到fanout
交换器上,并不是直接发送到队列上,当有消费者连接并绑定到交换器时,消息会自动发送,因此,生产端不需要声明队列;
上面代码中,我们在建立连接后声明了交换器exchange
,这是必须声明的,消息禁止发送到不存在的交换器上。
如果尚未将队列绑定到交换机,则消息将会丢失(没有启动消费者),但这对我们来说没问题;如果尚未有消费者在监听,我们就可以安全地丢弃该消息。
ReceiveLogs.cs
代码:
2.4、路由模式
在前面的例子中,我们已经创建了绑定。你可能还记得这样的代码:
绑定是交换器和队列之间的关系。可以简单地理解为:队列对来自此交换器的消息感兴趣。
绑定可以接受额外的routingKey
参数。为了避免与参数混淆,BasicPublish
我们将其称为绑定键:binding key
。以下是我们可以使用键创建绑定的方法:
绑定键的含义取决于交换器类型。 fanout
我们之前使用的交换器会忽略其值。
2.4.1 Direct exchange(直连式交换器)
上一个教程中的日志系统将所有消息广播给所有消费者。我们希望对其进行扩展,以允许根据消息的严重性进行过滤。例如,我们可能希望写入磁盘的日志中仅接收严重级别的错误,而不会向磁盘空间写入警告或信息级别的日志,以避免浪费磁盘空间。
我们之前使用的是fanout
交换机,但它只能进行无意识的广播,并没有给我们带来太多的灵活性。(所有订阅者都会收到同样的消息)
我们将改用direct
交换器替换之前的fanout
交换器。direct
交换器背后的路由算法很简单 - 消息被发送到其绑定键binding key
与消息的路由键routing key
完全匹配的队列。
为了说明这一点,请考虑以下设置:
在这个设置中,我们可以看到直连式交换器X
绑定了两个队列,第1个队列使用绑定键orange
进行队列绑定,第二个队列有两个绑定键,分别是black
和green
;
因此上面的设置中,基于路由键orange
发布到交换器的消息将会被路由到Q1
队列,使用路由键black
或green
发布的消息将会路由到Q2
队列,其他消息将会被丢弃。
2.4.2 多重绑定
使用相同的绑定键绑定多个队列是完全合法的。在我们的示例中,我们可以在交换顺X
和Q1
之间添加绑定键black
,在这个案例中,direct
交换器会像fanout
交换器一样,将消息广播到所有匹配的队列。路由键为black
的消息将同时传递给Q1
和Q2
。
2.4.3 发送日志
我们将在日志系统中使用这个模型,做为fanout
的替换,我们将使用direct
交换器进行消息发送。我们使用日志严重级别作为routing key
。这样,接收的程序将能够选择它想要接收的严重性。
让我们首先关注日志发送
和之前一样,我们首先需要创建一个交换器,注意此时的交换器类型为Direct:
接下来发送一条消息:
为了简化问题,我们假设日志严重级别可以是“info”、“warning”、“error”中的一个。
2.4.4 订阅消息
接收消息的工作方式与前一个教程中一样,但有一个例外——我们将为每个感兴趣的严重性创建一个新的绑定。
2.4.5 完整代码
EmitLogDirect.cs
文件代码:
ReceiveLogsDirect.cs
文件代码:
像往常一样创建项目(参见教程1
)
如果您只想将“warning”和“error”(而不是“info”)日志消息保存到文件中,只需打开控制台并输入:
如果你想在你的屏幕上看到所有的日志信息,打开一个新的终端,然后输入:
如果想发送"error"级别的日志消息,请输入:
2.5、主题模式
2.5.1 Topic
发送到topic
交换器的消息不能有任何routing_key
,它必须是一个用英文.
分隔的单词列表。单词没有限制,可以是任意单词,但通常指定与消息相关的一些功能。一些有效的routing key
示例,比如:stock.usd.nyse
、nyse.vmw
、quick.orange.rabbit
。路由键中可以有任意多的单词,最多不超过255字节。
绑定键也必须是相同的形式,topic
交换器的逻辑与direct
交换器类似——使用特定路由键发送的消息将被传递到使用匹配绑定键绑定的所有队列。但是,绑定键有两种重要的特殊情况:
*
星号代表一个单词。#
井号代表零个或多个单词。
用一个例子来解释这一点最简单
在这个例子中,我们将发送描述动物的消息。这些消息将使用由三个单词(两个点)组成的路由键发送。路由键中的第一个单词将描述速度,第二个单词将描述颜色,第三个单词将描述物种:<speed>.<colour>.<species>
。
我们创建了3个绑定:Q1
绑定路由键*.orange.*
,Q2
绑定路由键*.*.rabbit
和lazy.#
。
这些绑定可以总结为:
- Q1匹配3个英文单词,并且中间单词是
orange
,其他两个单词任意 - Q2匹配3个英文单词,并且最后单词是
rabbit
,其他两个单词任意,或者以lazy
开头的,后边0个或多个单词的匹配
路由键设置为“ quick.orange.rabbit
”的消息将被传送到两个队列,消息“ lazy.orange.elephant
”也将同时发送到这两个队列。另一方面,“ quick.orange.fox
”将只发送到第一个队列,“ lazy.brown.fox
”将只发送到第二个队列。
lazy.pink.rabbit
”将只被传送到第二个队列一次,即使它匹配两个绑定。“ quick.brown.fox
”不匹配任何绑定,因此将被丢弃。
如果我们违反规定,发送一个或四个单词,比如orange
或quick.orange.new.rabbit
,会怎样匹配呢?实际结果是这些消息将不匹配任何绑定,并将丢失。
还有一个情况:lazy.orange.new.rabbit
,即使有四个单词,也会与最后一个绑定匹配,并被传递到第二个队列。
2.5.2 Topic exchange
topic
交换器功能强大,可以像其他交换器一样运行。
当队列用#
号绑定时,它将接收所有的消息,而不考虑路由键routing key
——就像fanout
交换一样。
当绑定键中没有*
或#
时,topic
交换器就像direct
交换器一样。
2.5.3 综合
我们将在日志系统中使用topic
交换器,我们首先假设日志的路由键有两个词:<facility>.<severity>
。
EmitLogTopic.cs
文件代码:
ReceiveLogsTopic.cs
文件代码:
运行:
要接收所有日志:
从kern
开头接收日志:
只接收critical
结尾的日志:
您可以创建多个绑定:
基于路由键kern.critical
发送日志:
2.6、远程过程调用RPC
在本教程中,我们将使用 RabbitMQ 构建一个 RPC 系统:一个客户端和一个可扩展的 RPC 服务器。由于我们没有任何值得分配的耗时任务,我们将创建一个返回斐波那契数列虚拟 RPC 服务。
2.6.1 客户端接口
为了说明如何使用 RPC 服务,我们将创建一个简单的客户端类。它将暴露一个名为CallAsync
的方法,该方法发送RPC请求并阻塞,直到收到答案:
2.6.2 回调队列
一般来说,通过 RabbitMQ 进行 RPC 很容易。客户端发送请求消息,服务器回复响应消息。为了收到响应,我们需要在请求中发送一个回调队列地址:
2.6.3 消息属性:
AMQP 0-9-1协议预定义了14个与消息一起使用的属性。大多数属性都很少使用,除了以下几个:
Persistent
:将消息标记为持久消息(true)或瞬时消息(任何其他值)。看看第二个教程。
DeliveryMode
:熟悉该协议的人可能会选择使用此属性来代替Persistent
。它们控制的是同一件事。
ContentType
:用于描述编码的 mime 类型。例如,对于常用的 JSON 编码,最好将此属性设置为:application/json
。
ReplyTo
:通常用于命名回调队列。
CorrelationId
:有助于将 RPC 响应与请求关联起来(关联ID)。
2.6.4 Correlation Id
在上面介绍的方法中,我们建议为每个 RPC 请求创建一个回调队列。这非常低效,但幸运的是,还有更好的方法 - 让我们为每个客户端创建一个回调队列。
这引发了一个新问题,在队列中收到响应后,我们不清楚该响应属于哪个请求。这时就需要用到 CorrelationId
该属性。我们将为每个请求将其设置为一个唯一值。稍后,当我们在回调队列中收到一条消息时,我们将查看此属性,并在此基础上将响应与请求进行匹配。如果我们看到一个未知 CorrelationId
值,我们可以安全地丢弃该消息 - 它不属于我们的请求。
您可能会问,为什么我们应该忽略回调队列中的未知消息,而不是出现错误?这是因为服务器端可能存在竞争条件。虽然可能性不大,但 RPC 服务器可能会在向我们发送答案之后、但在发送请求的确认消息之前死机。如果发生这种情况,重新启动的 RPC 服务器将再次处理该请求。这就是为什么在客户端我们必须妥善处理重复响应,并且 RPC 理想情况下应该是幂等的。
2.6.5 Summary
我们的 RPC 将像这样工作:
- 当客户端启动时,它会创建一个匿名的独占回调队列。
- 对于 RPC 请求,客户端发送一条具有两个属性的消息:
ReplyTo
,设置为回调队列,以及CorrelationId
,为每个请求设置一个唯一值。 - 请求被发送到
rpc_queue
队列。 - RPC工作器(又名:服务器)正在该队列上等待请求。当出现请求时,它完成任务,并使用
ReplyTo
属性中的队列将带有结果的消息发送回客户端。 - 客户端等待回调队列上的数据。当出现消息时,它会检查属性
CorrelationId
。如果它与请求中的值匹配,它会将响应返回给应用程序。
2.6.6 综合
斐波那契数列:
我们声明了斐波那契函数。它仅假设有效的正整数输入。(不要指望这个函数能处理大数字,它可能是最慢的递归实现)。
RPCServer.cs
代码:
服务器代码相当简单:
- 像往常一样,我们首先建立连接、通道并声明队列。
- 我们可能需要运行多个服务器进程。为了将负载均匀分布到多个服务器上,我们需要在
channel.BasicQos
中设置prefetchCount
。 - 我们用
BasicConsume
来访问队列。然后我们注册一个交付处理程序,在其中完成工作并返回响应。
RPCClient.cs
代码:
客户端代码稍微复杂一些:
- 我们建立一个连接和通道,并声明一个专门用于回复的“回调”队列。
- 我们订阅“回调”队列,以便我们可以接收 RPC 响应。
- 我们的
Call
方法发出实际的 RPC 请求。 - 在这里,我们首先生成一个唯一的
CorrelationId
编号并保存它,以便在响应到达时识别适当的响应。 - 接下来我们发布请求消息,它有两个属性:
ReplyTo
和CorrelationId
。 - 此时,我们可以坐下来等待正确的回应。
- 对于每条响应消息,客户端都会检查它是否
CorrelationId
是我们正在寻找的消息。如果是,它会保存响应。 - 最后我们将响应返回给用户。
发出客户端请求:
我们的 RPC 服务现已准备就绪。我们可以启动服务器:
要请求斐波那契数列,请运行客户端:
2.7、发布者确认
基于Channel
实现发布者确认:
发布者确认是AMQP 0.9.1协议的RabbitMQ扩展,所以它们在默认情况下是不启用的。发布者确认在通道级别使用ConfirmSelect
方法启用:
必须在期望使用发布者确认的每个通道上调用此方法。确认应该只启用一次,而不是对每条发布的消息都启用。
2.7.1 策略一:单条发布消息确认
让我们从最简单的使用确认发布的方法开始,即发布一条消息并同步等待其确认:
在上例中,我们照常发布一条消息,然后使用该Channel#WaitForConfirmsOrDie(TimeSpan)
方法等待确认。消息确认后,该方法立即返回。如果消息在超时时间内未得到确认,或者消息被拒绝确认(意味着代理因某种原因无法处理该消息),该方法将抛出异常。异常的处理通常包括记录错误消息和/或重试发送消息。
这种技术非常简单,但也有一个主要缺点:它显著减慢了发布速度,因为消息的确认会阻止所有后续消息的发布。这种方法的吞吐量不会超过每秒几百条已发布的消息。不过,这对于某些应用程序来说已经足够好了。
2.7.2 策略二:批量发布消息确认
为了改进我们之前的示例,我们可以发布一批消息并等待整批消息得到确认。以下示例使用 100 条消息:
等待一批消息被确认比等待单个消息被确认可以大大提高吞吐量(使用远程 RabbitMQ 节点时最高可达 20-30 倍)。一个缺点是,如果发生故障,我们不知道到底出了什么问题,因此我们可能不得不将整个批次保存在内存中以记录有意义的内容或重新发布消息。而且此解决方案仍然是同步的,因此它会阻止消息的发布。
2.7.3 策略三:异步发布者确认
broker异步确认发布的消息,只需要在客户端上注册一个回调就可以收到这些确认的通知:
有2个回调:一个用于确认消息,另一个用于nack消息(broker认为已丢失的消息)。
两个回调函数都有一个对应的EventArgs参数(ea),其中包含:
delivery tag:
标识已确认或已删除消息的序列号。我们将很快看到如何将其与发布的消息关联起来。
multiple
:这是一个布尔值。如果为false,则只确认或否认一条消息;如果为true,则所有序列号较低或相等的消息都被确认或否认。
序列号可以在发布前通过Channel#NextPublishSeqNo
获得:
将消息与序列号关联的一种简单方法是使用字典。假设我们想要发布字符串,因为它们很容易转换为字节数组以供发布。以下是使用字典将发布序列号与消息的字符串主体关联的代码示例:
发布代码现在使用字典来跟踪出站消息。我们需要在确认到达时清理此字典,并在消息被否定确认时执行一些操作,例如记录警告:
上一个示例包含一个回调,用于在确认到达时清理字典。请注意,此回调可处理单个和多个确认。此回调在确认到达时使用(Channel#BasicAcks
)。未确认消息的回调会检索消息正文并发出警告。然后,它重新使用上一个回调来清理未完成确认的字典(无论消息是已确认还是未确认,都必须删除字典中相应的条目。)
总结一下,异步处理发布者确认通常需要以下步骤:
- 提供一种将发布序列号与消息关联的方法。
- 在通道上注册确认监听器,以便在发布者确认/否定确认到达时收到通知,以执行适当的操作,例如记录或重新发布否定确认的消息。序列号与消息关联机制可能也需要在此步骤中进行一些清理。
- 在发布消息之前跟踪发布序列号。
2.7.4 综合
PublisherConfirms.cs
代码:
三、常见面试题
3.1、RabbitMQ应用场景
见第2部分
3.2、RabbitMQ根据消息传递方式分几种?
3.2.1 点对点模式
- 定义:在点对点模式中,消息从一个生产者发送到一个消费者,消息队列确保每条消息只被一个消费者处理。
- 特点:
- 每条消息只能被一个消费者消费。
- 适用于一对一的通信场景,比如任务分发。
- 消息的顺序性通常能够得到保证。
3.2.2 发布/订阅模式
- 定义:在发布/订阅模式中,消息从一个生产者发送到多个消费者,消费者可以选择订阅特定的消息。
- 特点:
- 每条消息可以被多个消费者同时消费。
- 适用于一对多的通信场景,比如广播消息。
- 消息的处理顺序可能会有所不同,因为多个消费者可以并行处理。
3.3、RabbitMQ交换器分几种类型
3.3.1 Direct Exchange(直连交换器)**:
将消息路由到与交换器绑定的队列,路由键完全匹配时,消息会被发送到相应的队列。适用于一对一的消息传递场景。
3.3.2 Fanout Exchange(扇出交换器):
将接收到的消息广播到所有绑定到该交换器的队列,不考虑路由键。适合于一对多的场景,比如广播消息。
3.3.3 Topic Exchange(主题交换器):
通过路由键的模式匹配,将消息路由到一个或多个队列。支持使用通配符(如 *
和 #
)进行复杂的路由规则,适合于复杂的消息过滤场景。
3.3.4 Headers Exchange(头交换器):
根据消息的头部属性进行路由,而不是使用路由键。可以根据多个属性进行匹配,适合需要根据多个条件进行路由的场景。
3.4、RabbitMQ如何实现消费端限流
RabbitMQ 提供了一种 qos (服务质量保证)功能,即在非自动确认消息的前提下,如果一定数目的消息(通过基于 consume 或者 channel 设置 Qos 的值)未被确认前,不进行消费新的消息。
- prefetchSize:0,单条消息大小限制,0代表不限制
- prefetchCount:一次性消费的消息数量。会告诉 RabbitMQ 不要同时给一个消费者推送多于 N 个消息,即一旦有 N 个消息还没有 ack,则该 consumer 将 block 掉,直到有消息 ack。
- global:true、false 是否将上面设置应用于 channel,简单点说,就是上面限制是 channel 级别的还是 consumer 级别。当我们设置为 false 的时候生效,设置为 true 的时候没有了限流功能,因为 channel 级别尚未实现。
- 注意:prefetchSize 和 global 这两项,rabbitmq 没有实现,暂且不研究。特别注意一点,prefetchCount 在 no_ask=false 的情况下才生效,即在自动应答的情况下这两个值是不生效的。
3.5、RabbitMQ如何保证消费端消息不丢失?
简单回答:开启手动ack模式,在业务处理完成后再进行ack,并且需要保证幂等。
多个消费者同时收取消息,比如消息接收到一半的时候,一个消费者死掉了(逻辑复杂时间太长,超时了或者消费被停机或者网络断开链接),如何保证消息不丢?
使用rabbitmq提供的ack机制,服务端首先关闭rabbitmq的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack。才把消息从内存删除。
这样就解决了,即使一个消费者出了问题,但不会同步消息给服务端,会有其他的消费端去消费,保证了消息不丢的case。
3.6、RabbitMQ如何保证服务端消息不丢失?
当RabbitMQ退出或崩溃的时候会导致MQ Broker中的数据丢失,为保证在MQ崩溃重启后消息仍然存在,需要将队列的durable属性设为true,同时需要将IBasicProperties.Persistent
设置为true
。
或者将DeliveryMode设置为2,properties.DeliveryMode = 2;
3.7、RabbitMQ如何保证生产端消息不丢失?
3.7.1 方案1 :开启RabbitMQ事务
可以选择用 RabbitMQ 提供的事务功能,就是生产者发送数据之前开启 RabbitMQ 事务channel.txSelect,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit。
缺点: RabbitMQ 事务机制是同步的,你提交一个事务之后会阻塞在那儿,采用这种方式基本上吞吐量会下来,因为太耗性能。
3.7.2 方案2: 使用confirm机制
事务机制和 confirm 机制最大的不同在于,事务机制是同步的,你提交一个事务之后会阻塞在那儿,但是 confirm 机制是异步的
在生产者开启了confirm模式之后,每次写的消息都会分配一个唯一的id,然后如果写入了rabbitmq之中,rabbitmq会给你回传一个ack消息,告诉你这个消息发送OK了;如果rabbitmq没能处理这个消息,会回调你一个nack接口,告诉你这个消息失败了,你可以进行重试。而且你可以结合这个机制知道自己在内存里维护每个消息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发。
3.8、RabbitMQ如何保证消息不被重复消费?
消息去重:在消费者端,可以通过维护一个消息ID的集合来实现消息去重。消费者在处理消息时,先检查消息ID是否已经存在,如果已经存在,则表示该消息已经被消费过,可以直接忽略。如果消息ID不存在,则进行消息处理,并将消息ID加入到集合中。这样可以确保同一消息不会被重复消费。
幂等性处理:在消费者端,可以通过设计幂等性的处理逻辑来解决重复消费问题。幂等性指的是对于相同的输入,无论执行多少次,结果都是一样的。在消息处理过程中,可以通过检查消息的唯一标识符或者业务关键字段,判断是否已经处理过该消息。如果已经处理过,则直接返回结果,不再执行重复操作。
消息确认机制:RabbitMQ提供了消息确认机制,可以确保消息被消费者正确处理。消费者在处理消息时,可以发送确认消息给RabbitMQ服务器,告知服务器消息已经成功处理。RabbitMQ服务器在收到确认消息后,会将该消息标记为已消费,不会再次发送给消费者。通过使用消息确认机制,可以避免消息重复消费的问题。
消息过期时间设置:在生产者发送消息时,可以设置消息的过期时间。如果消息在指定的时间内没有被消费者消费,则会被服务器丢弃。通过设置合适的过期时间,可以确保消息在一定时间内被消费,避免重复消费的问题。
消息持久化:在生产者发送消息时,可以将消息标记为持久化。这样即使RabbitMQ服务器重启,消息也会被保留下来,不会丢失。通过消息持久化,可以确保即使消息被重复消费,也不会丢失数据。
消费者幂等性注册:在消费者启动时,可以向一个注册中心注册消费者的幂等性信息。注册中心记录了每个消费者已经处理过的消息ID或者处理结果。当消费者接收到消息时,先向注册中心查询该消息是否已经被处理过。如果已经处理过,则直接忽略。通过注册中心的幂等性注册,可以避免消息重复消费。
消费者状态机:在消费者端,可以设计一个状态机来管理消息的消费状态。状态机记录了每个消息的消费状态,包括已消费、未消费、正在消费等。在处理消息时,先检查消息的消费状态,如果已经消费,则直接忽略。通过状态机的管理,可以确保消息不会被重复消费。
3.9、什么是死信队列?如何实现?
死信队列:没有被及时消费的消息存放的队列
消息没有被及时消费的原因:
- a.消息被拒绝(basic.reject/ basic.nack)并且不再重新投递 requeue=false
- b.TTL(time-to-live) 消息超时未消费
- c.达到最大队列长度
3.9.1 TTL(time-to-live) 消息超时未消费测试代码:
生产端:
3.9.2 消息被拒绝(basic.reject/ basic.nack)并且不再重新投递 requeue=false
生产者代码:
消费者代码:
相关参考网址: