目录
一、应用场景
1.1 系统间解耦
1.2 异步提升效率
1.3 流量削峰
1.4 统一通信标准
二、什么是消息队列?
三、RabbitMQ
3.1 介绍
3.2 RabbitMQ中的重要术语
3.2.1 Exchange
3.2.2 routing key
3.2.3 Binding key
3.3 RabbitMQ运转流程
生产者发送消息
消费者接收消息的过程
3.4 RabbitMQ 重要对象
3.5 RabbitMQ中的重要机制
3.5.1 Message acknowledgment(消息回执)
3.5.2 Message durability(消息持久化)
3.5.3 Prefetch count(预载数量)
3.5.4 RPC(远程过程调用)
3.6 Exchange Types
3.6.1 direct
3.6.2 topic
3.6.3 fanout
四、Spring boot 集成 RabbitMQ
4.1 引入maven依赖
4.2 生产消息
4.3 消费消息
五、其他
5.1 vhost 是什么?起什么作用?
5.2 如何确保消息接收方消费了消息?
5.3 如何避免消息重复投递或重复消费?
5.4 如何解决丢数据的问题?
5.5 如何确保消息正确地发送至RabbitMQ? 如何确保消息接收方消费了消息?
我们遇到过这样的需求:一个商品销售系统,用户下单后,订单系统需要通知库存系统;或者一个运营后台系统新建单位或者用户时,通知对应的业务平台初始化该单位/用户的配置。遇到类似这两种需求的时候,传统的做法是订单系统调用库存系统的接口或者运营系统调用业务平台的接口,这种“直调”的方式完成功能的同时还有一些缺陷:
这时候可以引入消息队列作为第三方媒介,用户下单后,订单系统完成持久化处理,将消息写入消息队列,返回用户订单下单成功;库存系统订阅下单的消息,采用拉/推的方式,获取下单信息,库存系统根据下单信息,进行库存操作。加上消息队列的好处就是在下单时库存系统不能正常使用,也不影响正常下单,因为下单后,订单系统写入消息队列就不再关心其他的后续操作了。实现订单系统与库存系统的应用解耦。
(ps:消息队列和RPC的区别与比较:RPC: 异步调用,及时获得调用结果,具有强一致性结果,关心业务调用处理结果。消息队列:两次异步RPC调用,将调用内容在队列中进行转储,并选择合适的时机进行投递(错峰流控))
我们也会遇到这样的场景:系统注册新的用户后,给这个用户发确认邮件或者短信,所有都完成了返回一个注册成功的消息。无论是串行实现还是并行实现,都会影响效率,因为发短信和邮件并不是主体业务功能,真正的主体业务还要等一些非必要的功能运行完才能最终实现。这时可以引入消息对列,邮件和信息订阅对应的队列消息,进行异步处理。
我们也会遇到这样的场景:高并发流量大的场景,例如整点秒杀、大型直播。
大流量时MySQL可能忙不过来,比如每秒MySQL只能处理6000个请求,但是现在一秒钟来了1万个请求,服务器可能就宕机了,那就可以将过多的处理先放到消息队列(消息队列可以应付的过来),然后系统再慢慢地从队列中按照顺序消费这些信息,直到消费完所有消息。在我们秒杀抢购商品的时候,系统会提醒我们稍等排队中,而不是像几年前一样页面卡死或报错给用户。
不同进程(process)之间传递消息时,可以通过消息队列实现标准化,将消息的格式规范化。
说了这么久都在讲Mq的好处,实际上它也有槽点:
消息队列是一种十分复杂的架构,引入它有很多好处,但是也得针对它带来的坏处做各种额外的技术方案和架构来规避。引入MQ系统复杂度提升了一个数量级,但是在有些场景下,就是复杂十倍百倍,还是需要使用MQ。
消息队列(Message Queue,简称MQ),从字面意思上看,本质是个队列,FIFO先入先出,只不过队列中存放的内容是message而已。详细的队列数据结构可以查看下面的文章:
https://blog.csdn.net/weixin_41231928/article/details/104840335
MQ框架非常之多,比较流行的有RabbitMq、ActiveMq、ZeroMq、kafka,以及阿里开源的RocketMQ。本文主要介绍RabbitMq。下面是各MQ框架技术选型对比:
1.从社区活跃度
按照目前网络上的资料,RabbitMQ
、activeM
、ZeroMQ
三者中,综合来看,RabbitMQ
是首选。
2.持久化消息比较
ZeroMq
不支持,ActiveMq
和RabbitMq
都支持。持久化消息主要是指我们机器在不可抗力因素等情况下挂掉了,消息不会丢 失的机制。
3.综合技术实现
可靠性、灵活的路由、集群、事务、高可用的队列、消息排序、问题追踪、可视化管理工具、插件系统等等。
RabbitMq
/ Kafka
最好,ActiveMq
次之,ZeroMq
最差。当然ZeroMq
也可以做到,不过自己必须手动写代码实现,代码量不小。尤其是可靠性中的:持久性、投递确认、发布者证实和高可用性。
4.高并发
毋庸置疑,RabbitMQ
最高,原因是它的实现语言是天生具备高并发高可用的erlang
语言。
5.比较关注的比较, RabbitMQ 和 Kafka
RabbitMq
比Kafka
成熟,在可用性上,稳定性上,可靠性上, RabbitMq 胜于 Kafka (理论上)。
另外,Kafka
的定位主要在日志等方面, 因为Kafka
设计的初衷就是处理日志的,可以看做是一个日志(消息)系统一个重要组件,针对性很强,所以 如果业务方面还是建议选择 RabbitMq
。
还有就是,Kafka
的性能(吞吐量、TPS
)比RabbitMq
要高出来很多。
RabbitMQ是一个实现了AMQP(Advanced Message Queuing Protocol)高级消息队列协议的消息队列服务,Erlang语言编写。
AMQP,即Advanced Message Queuing Protocol,高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。消息中间件主要用于组件之间的解耦,消息的发送者无需知道消息使用者的存在,反之亦然。 AMQP的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。
RabbitMQ支持多种客户端,如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP等,支持AJAX。用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面有很好表现。
生产者将消息发送到Exchange(交换器,下图中的X),由Exchange将消息路由到一个或多个Queue中(或者丢弃)。
消息可以认为由payload+ routing key组成,payload就是消息内容数据,routing key来指定这个消息的路由规则。这个routing key需要与Exchange Type及binding key联合使用才能最终生效。 在Exchange Type与binding key固定的情况下(在正常使用时一般这些内容都是固定配置好的),我们的生产者就可以在发送消息给Exchange时,通过指定routing key来决定消息流向哪里。 RabbitMQ为routing key设定的长度限制为255 bytes。
RabbitMQ中通过Binding将Exchange与Queue关联起来,这样RabbitMQ就知道如何正确地将消息路由到指定的Queue了。
在绑定(Binding)Exchange与Queue的同时,一般会指定一个binding key;消费者将消息发送给Exchange时,一般会指定一个routing key;当binding key与routing key相匹配时,消息将会被路由到对应的Queue中。在绑定多个Queue到同一个Exchange的时候,这些Binding允许使用相同的binding key。 binding key 并不是在所有情况下都生效,它依赖于Exchange Type,比如fanout类型的Exchange就会无视binding key,而是将消息路由到所有绑定到该Exchange的Queue。
上面的各部分组成了RabbitMQ的整个流程:
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、关闭连接
从概念上来说,消息路由必须有三部分:交换器、路由、绑定。生产者把消息发布到交换器上;绑定决定了消息如何从路由器路由到特定的队列;消息最终到达队列,并被消费者接收。
消息发布到交换器时,消息将拥有一个路由键(routing key),在消息创建时设定。
通过队列路由键,可以把队列绑定到交换器上。
消息到达交换器后,RabbitMQ会将消息的路由键与队列的路由键进行匹配(针对不同的交换器有不同的路由规则)。如果能够匹配到队列,则消息会投递到相应队列中;如果不能匹配到任何队列,消息将进入 “黑洞”。
ConnectionFactory、Connection、Channel都是RabbitMQ对外提供的API中最基本的对象。
在实际应用中,可能会发生消费者收到Queue中的消息,但没有处理完成就宕机(或出现其他意外)的情况,这种情况下就可能会导致消息丢失。为了避免这种情况发生,我们可以要求消费者在消费完消息后发送一个回执给RabbitMQ,RabbitMQ收到消息回执(Message acknowledgment)后才将该消息从Queue中移除;如果RabbitMQ没有收到回执并检测到消费者的RabbitMQ连接断开,则RabbitMQ会将该消息发送给其他消费者(如果存在多个消费者)进行处理。这里不存在timeout概念,一个消费者处理消息时间再长也不会导致该消息被发送给其他消费者,除非它的RabbitMQ连接断开。 这里会产生另外一个问题,如果我们的开发人员在处理完业务逻辑后,忘记发送回执给RabbitMQ,这将会导致严重的bug——Queue中堆积的消息会越来越多;消费者重启后会重复消费这些消息并重复执行业务逻。另外pub message是没有ack的。
如果我们希望即使在RabbitMQ服务重启的情况下,也不会丢失消息,我们可以将Queue与Message都设置为可持久化的(durable),这样可以保证绝大部分情况下我们的RabbitMQ消息不会丢失。
如果有多个消费者同时订阅同一个Queue中的消息,Queue中的消息会被平摊给多个消费者。这时如果每个消息的处理时间不同,就有可能会导致某些消费者一直在忙,而另外一些消费者很快就处理完手头工作并一直空闲的情况。我们可以通过设置prefetchCount来限制Queue每次发送给每个消费者的消息数,比如我们设置prefetchCount=1,则Queue每次给每个消费者发送一条消息;消费者处理完这条消息后Queue会再给该消费者发送一条消息。
MQ本身是基于异步的消息处理,前面的示例中所有的生产者(P)将消息发送到RabbitMQ后不会知道消费者(C)处理成功或者失败(甚至连有没有消费者来处理这条消息都不知道)。 但实际的应用场景中,我们很可能需要一些同步处理,需要同步等待服务端将我的消息处理完成后再进行下一步处理。这相当于RPC(Remote Procedure Call,远程过程调用)。在RabbitMQ中也支持RPC。
RabbitMQ 中实现 RPC 的机制是:
MessageProperties
,在AMQP
协议中定义了14中properties
,这些属性会随着消息一起发送)中设置两个值replyTo
(一个Queue
名称,用于告诉服务器处理完成后将通知我的消息发送到这个Queue
中)和correlationId
(此次请求的标识号,服务器处理完成后需要将此属性返还,客户端将根据这个id了解哪条请求被成功执行了或执行失败)replyTo
指定的Queue
,同时带上correlationId
属性replyTo
指定的Queue
,从中收到服务器的应答消息后,根据其中的correlationId
属性分析哪条请求被执行了,根据执行结果进行后续业务处理RabbitMQ常用的Exchange Type有fanout、direct、topic这3种。
binding key与routing key完全匹配的才路由。
以routingKey=”error”发送消息到Exchange,则消息会路由到Queue1(amqp.gen-S9b…,这是由RabbitMQ自动生成的Queue名称)和Queue2(amqp.gen-Agl…);如果我们以routingKey=”info”或routingKey=”warning”来发送消息,则消息只会路由到Queue2。如果我们以其他routingKey发送消息,则消息不会路由到这两个Queue中。
direct类型的Exchange路由规则是完全匹配binding key与routing key,但这种严格的匹配方式在很多情况下不能满足实际业务需求。topic类型的Exchange在匹配规则上进行了扩展,它与direct类型的Exchage相似,也是将消息路由到binding key与routing key相匹配的Queue中,但这里的匹配规则有些不同,它约定:
以上图中的配置为例,routingKey=”quick.orange.rabbit”的消息会同时路由到Q1与Q2,routingKey=”lazy.orange.fox”的消息会路由到Q1与Q2,routingKey=”lazy.brown.fox”的消息会路由到Q2,routingKey=”lazy.pink.rabbit”的消息会路由到Q2(只会投递给Q2一次,虽然这个routingKey与Q2的两个bindingKey都匹配);routingKey=”quick.brown.fox”、routingKey=”orange”、routingKey=”quick.orange.male.rabbit”的消息将会被丢弃,因为它们没有匹配任何bindingKey。
fanout类型的Exchange路由规则会把所有发送到该Exchange的消息路由到所有与它绑定的Queue中。
生产者(P)发送到Exchange(X)的所有消息都会路由到图中的两个Queue,并最终被两个消费者(C1与C2)消费。
这里是我们私服的依赖
@RestController
@Slf4j
public class DirectPublish {
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping("/userAdd")
public String publish() {
// 要发送的数据
User user = getUser();
// 交换机类型是fanout,路由到所有队列,因此不需要指定routing key
rabbitTemplate.convertAndSend(FanoutRabbitConfig.GOV_UC_USER_ADD_EXCHANGE, null, user);
log.info(user.toString());
return "ok";
}
private User getUser() {
Random random = new Random();
return User.builder()
.id("-6666666")
.name("葛锋" + random.nextInt(100))
.loginName("zk")
.orgAccountId("670869647114347")
.build();
}
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = "GOV_THIRDPARTY", durable = "true"),
exchange = @Exchange(name = "GOV_THIRDPARTY_EXCHANGE"),
key = "GOV_THIRDPARTY_SEEYOU"))
@Component
public class listener {
@RabbitHandler
public void handle(@Payload User user, AmqpMessageHeaderAccessor messageHeaderAccessor, Channel channel) throws IOException {
System.out.println("消费队列===>" + user.toString());
}
}
Rabbit的集成使用还是很简单的,简单注解即可。
这里RabbitTemplate已经封装了ConnectionFactory、Channel等。
真实的过程如下:
生产消息:
public class Send {
private final static String EXCHANGE_NAME = "test_exchange_fanout";
public static void main(String[] argv) throws Exception {
// 获取到连接以及mq通道
Connection connection = ConnectionUtil.getConnection();
Channel channel = connection.createChannel();
// 声明exchange
channel.exchangeDeclare(EXCHANGE_NAME, "fanout");
// 消息内容
String message = user.toString();
channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes());
System.out.println(" [x] Sent '" + message + "'");
channel.close();
connection.close();
}
}
消费消息:
public class Recv {
private final static String QUEUE_NAME = "queue_work1";
private final static String EXCHANGE_NAME = "test_exchange_fanout";
public static void main(String[] argv) throws Exception {
// 获取到连接以及mq通道
Connection connection = ConnectionUtil.getConnection();
Channel channel = connection.createChannel();
// 声明队列
channel.queueDeclare(QUEUE_NAME, false, false, false, null);
// 绑定队列到交换机
channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "");
// 同一时刻服务器只会发一条消息给消费者
channel.basicQos(1);
// 定义队列的消费者
QueueingConsumer consumer = new QueueingConsumer(channel);
// 监听队列,手动返回完成
channel.basicConsume(QUEUE_NAME, false, consumer);
// 获取消息
while (true) {
QueueingConsumer.Delivery delivery = consumer.nextDelivery();
String message = new String(delivery.getBody());
System.out.println("消息内容: " + message + "'");
Thread.sleep(10);
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
}
}
}
vhost 可以理解为虚拟 broker ,即 mini-RabbitMQ server。其内部均含有独立的 queue、exchange 和 binding 等,但最最重要的是,其拥有独立的权限系统,可以做到 vhost 范围的用户控制。当然,从 RabbitMQ 的全局角度,vhost 可以作为不同权限隔离的手段(一个典型的例子就是不同的应用可以跑在不同的 vhost 中)。
接收方消息确认机制:消费者接收每一条消息后都必须进行确认(消息接收和消息确认是两个不同操作)。只有消费者确认了消息,RabbitMQ才能安全地把消息从队列中删除。这里并没有用到超时机制,RabbitMQ仅通过Consumer的连接中断来确认是否需要重新发送消息。也就是说,只要连接不中断,RabbitMQ给了Consumer足够长的时间来处理消息。
下面罗列几种特殊情况:
在消息生产时,MQ内部针对每条生产者发送的消息生成一个inner-msg-id,作为去重和幂等的依据(消息投递失败并重传),避免重复的消息进入队列;在消息消费时,要求消息体中必须要有一个bizId(对于同一业务全局唯一,如支付ID、订单ID、帖子ID等)作为去重和幂等的依据,避免同一条消息被重复消费。
1.生产者丢数据
从生产者弄丢数据这个角度来看,RabbitMQ提供transaction和confirm模式来确保生产者不丢消息。
transaction机制:发送消息前,开启事物(channel.txSelect()),然后发送消息,如果发送过程中出现什么异常,事物就会回滚(channel.txRollback()),如果发送成功则提交事物(channel.txCommit())。然而缺点就是吞吐量下降了。
confirm模式:一旦channel进入confirm模式,所有在该信道上面发布的消息都将会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后,rabbitMQ就会发送一个Ack给生产者(包含消息的唯一ID),这就使得生产者知道消息已经正确到达目的队列了.如果rabiitMQ没能处理该消息,则会发送一个Nack消息给你,生产者可进行重试操作。
2.消息队列丢数据
开启持久化磁盘的配置可以防止队列丢失数据。这个持久化配置可以和confirm机制配合使用,可以在消息持久化磁盘后,再给生产者发送一个Ack信号。这样,如果消息持久化磁盘之前,rabbitMQ阵亡了,那么生产者收不到Ack信号,生产者会自动重发。
那么如何持久化呢,就下面两步
①、将queue的持久化标识durable设置为true,则代表是一个持久的队列
②、发送消息的时候将deliveryMode=2
这样设置以后,rabbitMQ就算挂了,重启后也能恢复数据。在消息还没有持久化到硬盘时,可能服务已经死掉,这种情况可以通过引入mirrored-queue即镜像队列,但也不能保证消息百分百不丢失(整个集群都挂掉)
3.消费者丢数据
启用手动确认模式可以解决这个问题
①自动确认模式,消费者挂掉,待ack的消息回归到队列中。消费者抛出异常,消息会不断的被重发,直到处理成功。不会丢失消息,即便服务挂掉,没有处理完成的消息会重回队列,但是异常会让消息不断重试。
②手动确认模式,如果消费者来不及处理就死掉时,没有响应ack时会重复发送一条信息给其他消费者;如果监听程序处理异常了,且未对异常进行捕获,会一直重复接收消息,然后一直抛异常;如果对异常进行了捕获,但是没有在finally里ack,也会一直重复发送消息(重试机制)。
③不确认模式,acknowledge="none" 不使用确认机制,只要消息发送完成会立即在队列移除,无论客户端异常还是断开,只要发送完就移除,不会重发。
将信道设置成confirm模式(发送方确认模式),则所有在信道上发布的消息都会被指派一个唯一的ID。
一旦消息被投递到目的队列后,或者消息被写入磁盘后(可持久化的消息),信道会发送一个确认给生产者(包含消息唯一ID)。
如果RabbitMQ发生内部错误从而导致消息丢失,会发送一条nack(not acknowledged,未确认)消息。
发送方确认模式是异步的,生产者应用程序在等待确认的同时,可以继续发送消息。当确认消息到达生产者应用程序,生产者应用程序的回调方法就会被触发来处理确认消息。
接收方消息确认机制:消费者接收每一条消息后都必须进行确认(消息接收和消息确认是两个不同操作)。只有消费者确认了消息,RabbitMQ才能安全地把消息从队列中删除。
这里并没有用到超时机制,RabbitMQ仅通过Consumer的连接中断来确认是否需要重新发送消息。也就是说,只要连接不中断,RabbitMQ给了Consumer足够长的时间来处理消息。保证数据的最终一致性;