1. RabbitMQ 基础
简介
RabbitMQ 使用场景:先执行紧要的操作,之后将消息发送到队列,由其他相关服务读取并慢慢执行。
优势:
- 减少用户等待时间:先执行紧要的操作
- 流量削峰:支付消息再多也会放到队列中,逐步地被读取
- 功能解耦,可拓展性强:需要添加功能时,监听队列即可
缺点:
- 时效性差,不能立即得到调用结果
- 不确定下游业务执行是否成功
- 业务安全依赖于消息代理(Broker)的可靠性
RabbitMQ 的整体架构及核心概念:
- virtual-host:虚拟主机,作用是数据隔离。不同项目使用同一套 RabbitMQ 服务时不会冲突,因为不同 virtual-host 有各自的 exchange 和 queue
- publisher:消息发送者
- consumer:消息的消费者
- queue:队列,存储消息
- exchange:交换机,负责路由消息
在 RabbitMQ 控制台配置用户和对应的 exchange、queue:
SpringAMQP 如何收发消息?
-
引入spring-boot-starter-amqp 依赖
-
配置 rabbitmq 服务端信息
-
利用 RabbitTemplate 发送消息
-
利用 @RabbitListener 注解声明要监听的队列,监听消息
Work 模型
让多个消费者绑定到一个队列,共同消费队列中的消息,加快消息处理速度。同一条消息只会被一个消费者处理。
默认情况下,RabbitMQ 会依次轮询,将消息投递给绑定在队列上的每个消费者。但这并没有考虑到消费者是否已经处理完消息,可能出现消息堆积。解决方案是:修改 application.yml,设置 preFetch 值为 1。
真正生产环境都会经过exchange来发送消息,而不是直接发送到队列,交换机的类型有三种:Fanout(广播)、Direct(定向)、Topic(话题)
Fanout 交换机
Fanout Exchange 会将接收到的消息广播到每一个跟其绑定的 queue,所以也叫广播模式。
Direct 交换机
Direct Exchange 会将接收到的消息根据规则路由到指定的Queue,因此称为定向路由。
每一个 Queue 都与 Exchange 设置一个 BindingKey,发布者发送消息时,指定消息的 RoutingKey。
Exchange 将消息路由到 BindingKey 与消息 RoutingKey 一致的队列。
Topic 交换机
TopicExchange 与 DirectExchange 类似,区别在于 routingKey 可以是多个单词的列表,并且以.
分割。BindingKey 可以使用通配符:#
表示 0 个或多个单词;*
表示 1 个单词
在代码中声明队列和交换机
声明队列和交换机------方式 1
SpringAMQP 提供了几个类,用来声明队列、交换机及其绑定关系:
- Queue:用于声明队列,也可以用工厂类 QueueBuilder 构建队列
- Exchange:用于声明交换机,也可以用工厂类 ExchangeBuilder 构建交换机
- Binding:用于声明队列和交换机的绑定关系,也可以用工厂类 BindingBuilder 构建绑定关系
例如,声明一个 Fanout 类型的交换机,并且创建队列与其绑定:
这种声明和绑定方式有缺点,如 direct 类型的队列指定 routingkey 很繁琐:
声明队列和交换机------方式 2
SpringAMQP 还提供了基于 @RabbitListener 注解来声明队列和交换机的方式:
消息转换器
Spring 的对消息对象的处理是由 org.springframework.amgp.support.converter.MessageConverter 来处理的。而默认实现是 SimpleMessageConverter,基于 JDK 的 ObjectOutputStream 完成序列化。
存在下列问题:JDK 的序列化有安全风险,转换后的消息太长且可读性差。
建议采用 JSON 序列化代替默认的 JDK 序列化,要做两件事情:在 publisher 和 consumer 中都要引入 jackson 依赖:
在 publisher 和 consumer 中(可以在启动类中)都要配置 Messageconverter:
配置好之后,发的什么类型的消息就用什么类型接收:
在项目中使用 RabbitMQ
-
在 pom 文件中引入 amqp 依赖
-
在 ymal 文件中配置 mq 地址
-
SpringMVC 自带 jackson 依赖,所以无需引入,直接配置消息转换器
-
编写监听器(消费者)
-
编写消息发送方(生产者),也要引入 amqp 依赖并配置 mq 地址
2. RabbitMQ 高级
2.1 消息的可靠性
消息丢失的几种情况:向消息队列中发送消息时出现网络故障、消息队列本身出现故障、交易服务抛出异常
为了保证消息可靠性,就要保证发送者的可靠性、MQ 的可靠性、消费者的可靠性,并用延迟消息作为兜底。
2.1.1 生产者重连
有时,由于网络波动,可能会出现客户端连接 MQ 失败的情况。可以开启连接失败后的重连机制:
当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率。不过 SpringAMQP 提供的重试机制是阻塞式的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的,会影响业务性能。
如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。
2.1.2 生产者确认
RabbitMQ 有 Publisher Confirm 和 Publisher Return 两种确认机制。开启确机制认后,在 MQ 成功收到消息后会返回确认消息给生产者。返回的结果有以下几种情况:
- 消息投递到了 MQ,但是路由失败。此时会通过 PublisherReturn 返回路由异常原因,然后返回ACK,告知投递成功
- 临时消息投递到了 MQ,并且入队成功,返回 ACK,告知投递成功
- 持久消息投递到了 MQ,并且入队完成持久化,返回 ACK,告知投递成功
- 其它情况都会返回 NACK,告知投递失败
SpringAMQP 实现生产者确认
(1)在生产者的微服务 application.yml 中添加配置:
这里 publisher-confirm-type 有三种模式可选:
- none:关闭 confirm 机制
- simple:同步阻塞等待 MQ 的回执消息
- correlated:异步等待 MQ 的回执消息
(2)编写回调函数:每个 RabbitTemplate 只能配置一个 ReturnCallback,因此需要在项目启动过程中配置
(3)发送消息,指定消息ID、消息 ConfirmCallback(每个消息发送时都单独指定)
如何处理生产者的确认消息?
- 生产者确认需要额外的网络和系统资源开销,尽量不要使用
- 如果一定要使用,无需开启 Publisher-Return 机制,因为一般路由失败是自己业务问题
- 对于 nack 消息可以有限次数重试,依然失败则记录异常消息
如何保证生产者发送消息的可靠性?
首先可以通过配置实现生产者的重连机制,当出现网络波动时尝试重新连接 MQ。
如果其他原因导致失败,可以开启生产者确认机制,当发送消息到 MQ 时,MQ 就会给出回执。若回执是 ACK 则发送成功;若回执是 NACK 则发送失败,此时可以重发消息。通过以上手段,就能基本保证生产者消息的可靠性,但是会增加系统开销。因此,除非对消息可靠性有较高要求,否则基本不采用。
2.2 MQ 的可靠性
在默认情况下,RabbitMQ 会将接收到的信息保存在内存(内存快,降低消息收发的延迟)。这样会导致两个问题:
- 一旦 MQ 宕机,内存中的消息会丢失
- 内存空间有限,当消费者故障或处理过慢时,会导致 MQ 中消息积压
保证 MQ 的可靠性有两种方式:数据持久化 和 Lazy Queue,MQ 3.6 之前采用前者,之后有了后者。
2.2.1 数据持久化
RabbitMQ 实现数据持久化包括 3 个方面:
(1)交换机持久化
(2)队列持久化
在 Spring 中创建交换机和队列时会默认创建持久化的交换机和队列。
(3)消息的持久化
如果没有消息持久化,当 MQ 中消息过多时还是会将消息放入磁盘,此时 MQ 是阻塞的,无法处理消息。
2.2.2 Lazy Queue
从 RabbitMQ 的 3.6.0 版本开始,就增加了 Lazy Queue 的概念,也就是惰性队列。惰性队列的特征如下:
- 接收到消息后直接存入磁盘(内存中只保留最近的消息,默认 2048 条)
- 消费者要消费消息时才会从磁盘中读取并加载到内存
- 支持数百万条的消息存储
在 3.12 版本后,所有队列都是 Lazy Queue 模式,无法更改。
要设置一个队列为惰性队列,只需要在声明队列时,指定 x-queue-mode 属性为 lazy 即可:
RabbitMQ 如何保证消息的可靠性?
- 首先通过配置可以让交换机、队列、以及发送的消息都持久化。这样队列中的消息会持久化到磁盘,MQ重启消息依然存在;
- RabbitMQ 在 3.6 版本引入了 LazyQueue,并且在 3.12 版本后成为队列的默认模式。LazyQueue 会将所有消息都持久化;
- 开启持久化和生产者确认时,RabbitMQ 只有 在消息持久化完成后才会给生产者返回 ACK 回执。
2.3 消费者的可靠性
2.3.1 消费者确认机制
为了确认消费者是否成功处理消息,RabbitMQ 提供了消费者确认机制。当消费者处理消息结束后,应该向 RabbitMQ 发送一个回执,告知 RabbitMQ 自己的消息处理状态。回执有三种可选值:
- ack:成功处理消息,RabbitMQ 从队列中删除该消息
- nack:消息处理失败,RabbitMQ 需要再次投递消息
- reject:消息处理失败并拒绝该消息(如:消息格式错误),RabbitMQ 从队列中删除该消息
SpringAMQP 已经实现了消息确认功能。并允许我们通过配置文件选择 ACK 处理方式,有三种方式:
- none:不处理。即消息投递给消费者后立刻 ack,消息会立刻从 MQ 删除。非常不安全,不建议使用
- manual:手动模式。需要自己在业务代码中调用 api,发送 ack 或 reject,存在业务入侵,但更灵活
- auto(常用) :自动模式。SpringAMQP 利用 AOP 对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回 ack。当业务出现异常时,根据异常判断返回不同结果:
- 如果是业务异常,会自动返回 nack
- 如果是消息处理或校验异常(如:消息格式错误),自动返回 reject
2.3.2 消费失败处理
当消费者出现异常后,消息会不断 requeue(重新入队)到队列,再重新发送给消费者,然后再次异常, 再次 requeue,无限循环,导致 mq 的消息处理飙升,带来不必要的压力。
可以利用 Spring 的 retry 机制,在消费者出现异常时利用本地重试,而不是无限制的 requeue 到 mq 队列:
失败消息处理策略:在开启重试模式后,重试次数耗尽时消息依然失败,则需要有MessageRecoverer 接口来处理,它包含三种不同的实现:
- RejectAndDontRequeueRecoverer:重试耗尽后,直接 reject,丢弃消息。默认就是这种方式
- ImmediateRequeueMessageRecoverer:重试耗尽后,返回 nack,消息重新入队
- RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机(最优)
消费者如何保证消息一定被消费?
- 开启消费者确认机制为 auto,由 spring 确认消息处理成功后返回 ack,异常时返回 nack
- 开启消费者失败重试机制,并设置 MessageRecoverer 多次重试失败后将消息投递到异常交换机,交由人工处理
2.3.3 业务幂等性
如果一个消息被消费者消费之后,将要返回 ack 时发生了网络波动,那么就可能导致消息重发,消息被消费多次。这显然不合理。
幂等是一个数学概念,用函数表达式来描述是这样的:f(x) = f(f(x))。在程序开发中,则是指同一个业务,执行一次或多次对业务状态的影响是一致的。
方案一:唯一消息 id:给每个消息都设置一个唯一 id,利用 id 区分是否是重复消息:
- 每一条消息都生成一个唯一的 id,与消息一起投递给消费者。
- 消费者接收到消息后处理自己的业务,业务处理成功后将消息 ID 保存到数据库。
- 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。
方案二:基于业务判断:结合业务逻辑,基于业务本身做判断。以支付业务为例:我们要在支付后修改订单状态为已支付,应该在修改订单状态前先查询订单状态,判断状态是否是未支付。只有未支付订单才需要修改,其它状态不做处理:
如何保证支付服务与交易服务之间的订单状态一致性?
- 首先,支付服务会正在用户支付成功以后利用 MQ 消息通知交易服务完成订单状态同步。
- 其次,为了保证 MQ 消息的可靠性,采用了生产者确认机制、消费者确认、消费者失败重试等策略,确保消息投递和处理的可靠性。同时也开启了 MQ 的持久化,避免因服务宕机导致消息丢失。
- 最后,还在交易服务更新订单状态时做了业务幂等判断,避免因消息重复消费导致订单状态异常。
如果交易服务消息处理失败,有没有什么兜底方案?
可以在交易服务中设置定时任务,定期查询订单支付状态。这样即便 MQ 通知失败,还可以利用定时任务作为兜底方案,确保订单支付状态的最终一致性。
2.4 延迟消息
延迟消息:生产者发送消息时指定一个时间,消费者不会立刻收到消息,而是在指定时间之后才收到消息。
延时消息有多种实现方案:死信交换机、延迟消息插件
2.4.1 死信交换机
当一个队列中的消息满足下列情况之一时,就会成为死信(dead letter):
- 消费者使用 basic.reject 或 basic.nack 声明消费失败,并且消息的 requeue 参数设置为 false
- 消息是一个过期消息(达到了队列或消息本身设置的过期时间),超时无人消费
- 要投递的队列消息堆积满了,最早的消息可能成为死信
如果队列通过 dead-letter-exchange 属性指定了一个交换机,那么该队列中的死信就会投递到这个交换机中。这个交换机称为死信交换机(Dead Letter Exchange,简称 DLX)。
死信交换机的方式实现起来比较繁琐。
2.4.2 延迟消息插件
RabbitMQ 的官方也推出了一个插件,支持延迟消息功能。该插件的原理是设计了一种支持延迟消息功能的交换机,当消息投递到交换机后可以暂存一定时间,到期后再投递到队列。
发送消息时需要通过消息头 x-delay 来设置延迟时间:
使用延迟消息插件会使服务有一定的性能损耗,因为设置了延迟时间,CPU 要不断计算。这种方案适用于延迟时间较短的情况。