文章目录
- 1.生产者可靠性
- 生产者重连
- 生产者确认
- 小结
- 2. MQ的可靠性
- 数据持久化
- LazyQueue
- 小结
- 3. 消费者的可靠性
- 消费者确认机制
- 消费者失败处理方案
- 业务幂等性
- 唯一消息ID
- 业务判断
- 兜底方案
- 业务判断
- 兜底方案
1.生产者可靠性
生产者重连
在某些场景下由于网络波动,可能就会出现客户端连接MQ失败的情况,,此时我们可以在Spring配置文件中开启连接失败后的重连机制:
spring:
rabbitmq:
connection-timeout: 1s # 设置MQ的连接超时时间
template:
retry:
enabled: true # 开启超时重试机制
initial-interval: 1000ms # 失败后的初始等待时间
multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
max-attempts: 3 # 最大重试次数
需要注意的是,虽然这个重试机制在网络不稳定的时候可以有效提高消息的发送成功率。而SpringAMQP提供的重试机制是阻塞式的重试,也就是说这个操作并不是异步的会导致主线程被阻塞,从而影响业务性能。
如果对于业务性能有要求,建议禁用重试机制。如果非要使用,一定要合理配置等待时长和重试次数,或者采用异步线程来处理发送消息的代码
生产者确认
RabbitMQ提供了生产者消息确认机制,包括Publisher Confirm
和Publisher Return
两种,在MQ成功收到消息后会返回确认消息给生产者。而返回的结果又一下几种情况:
- 消息投递到了MQ,但是路由失败,此时会通过
Publisher Return
返回路由异常原因,然后返回ACK,并return一个路由失败的消息- 路由失败一般不会出现,路由失败可能是这个路由并没有绑定队列,或者说代码写的有问题
- 所以说路由失败这种情况只要开发配置和代码没有问题,几乎不可能出现
- 第二种场景就是临时消息投递到了MQ,并且入队成功,返回ACK,告诉发送者消息投递成功
- 第三种场景就是持久化消息投递到了MQ,并且消息持久化到磁盘后,才返回ACK告诉发送者消息投递成功
除了以上三种场景消息发送成功会返回ACK,其它的情况都会返回NACK,比如说持久化到磁盘失败了,内存满了导致内存丢失。
其中ack
和nack
属于Publisher Confirm机制,ack
是投递成功;nack
是投递失败。而return
则属于Publisher Return机制。
通过以上机制基本能保证消息发生成功,只要失败就进行重发
代码实现生产者确认机制:
在Spring配置文件中添加对应配置文件:
spring:
rabbitmq:
publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型
publisher-returns: true # 开启publisher return机制
这里publisher-confirm-type
有三种模式可选:
none
:关闭confirm机制simple
:同步阻塞等待MQ的回执correlated
:MQ异步回调返回回执
一般我们推荐使用correlated
,回调机制。
定义ReturnCallback
每个RabbitTemplate
只能配置一个ReturnCallback
,因此我们可以在配置类中统一设置。我们在publisher模块定义一个配置类:
@Configuration
@Slf4j
public class MqConfig implements ApplicationContextAware {
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
@Override
public void returnedMessage(ReturnedMessage returnedMessage) {
log.debug("收到return callback消息:exchange:{},code:{},replyText:{},returnMsg:{}",
returnedMessage.getExchange(),returnedMessage.getReplyCode(),returnedMessage.getReplyText(),returnedMessage.getMessage());
}
});
}
}
定义ConfirmCallback
由于每个消息发送时的处理逻辑不一定相同,因此ConfirmCallback需要在每次发消息时定义。具体来说,是在调用RabbitTemplate中的convertAndSend方法时,多传递一个参数:
@Test
void confirmCallback() throws InterruptedException {
// 1.创建CorrelationData
CorrelationData correlationData = new CorrelationData();
// 2. 给Future添加confirmCallback
correlationData.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
@Override
public void onFailure(Throwable ex) {
// 该异常是Spring AMQP的异常,和RabbitMQ的无关
log.error("future消息发送失败:{}",ex);
}
@Override
public void onSuccess(CorrelationData.Confirm result) {
if (result.isAck()){
log.debug("消息发送成功,收到确认ACK");
} else {
log.error("消息发送失败,收到NACK,原因: {}",result.getReason());
}
}
});
// 3. 发送消息
rabbitTemplate.convertAndSend("test.direct", "red", "hello, confirm callback", correlationData);
}
这里的CorrelationData中包含两个核心的东西:
-
id
:消息的唯一标示,MQ对不同的消息的回执以此做判断,避免混淆(默认构造方法使用了UUID)public CorrelationData() { this.id = UUID.randomUUID().toString(); }
-
SettableListenableFuture
:回执结果的Future对象,利用回调函数异步处理确认消息
注意事项:
开启生产者确认也是比价消耗MQ的性能的,一般也是不建议开启的,生产者确认消息主要有以下几种场景:
- 一种是路由失败,一般是RoutingKey失败导致的,还有就是交换机名称错误,这也就是编码问题
- MQ内部故障,这种场景出现概率比价低除非对消息可靠性要求较高的业务才需要开启,并且只要开启
ConfirmCallback
处理NACK就可以了
因为生产者确认机制需要额外的网络何系统资源开销,所以如果非要使用生产者确认机制,只需要开启Publisher Confirm
机制,并且只是处理NACK消息,进行指定次数的重试,如果依然失败记录异常消息即可。
像Publisher Return
这种一般由于配置或者编码出现问题的根本不需要关心,因为路由出现问题一般是业务代码的问题
小结
如何保证生产发送消息的可靠性?
- 首先我们可以在配置文件中开启生产者重连机制,避免网络波动场景下客户端连接MQ失败
- 如果是其他原因导致的失败,RabbitMQ还支持生产者确认机制,接着开启生产者确认机制,根据返回的ACK和NAC来确认是否重发消息
- 通过以上手段基本可以保证生产者发送消息的可靠性
- 但是因为生产者重连和确认机制会增加网络和系统资源的开销,所以在大多数场景下无需开启确认和重连机制,除非对消息可靠性要求较高
2. MQ的可靠性
生产者在发送的消息到达MQ之后,如果MQ不能及时保存,也会导致消息丢失,所以MQ的可靠性我们也要非常关注。
- 一旦MQ宕机,内存中的消息会丢失
- 内存空间有限,当消费者故障或处理过慢时,会导致消息积压,引发MQ阻塞
如果采用的是非持久化的消息(纯内存),当不断往MQ中发送消息,MQ的队列存储不下的时候就会发生PageOut,将存放不下的一部老消息存放到磁盘中,在PageOut的那一瞬间是无法处理消息的,也就是阻塞状态。可能就会导致消息堆积,影响性能。
数据持久化
为了提升MQ的性能,默认情况MQ的数据都是在内存中临时存储的,只要一重启就会丢失,为了保证数据的可靠性就必须去配置数据持久化,持久化又包括:
- 交换机的持久化
- 队列的持久化
- 消息的持久化
如下图在创建队列的时候就能指定创建持久化队列
设置为Durable
就是持久化模式,Transient
就是临时模式。
在发送消息的时候也需要指定消息为持久化的消息,而SpringAMQP提供的发送消息的方法默认就是发送的持久化的消息。
使用持久化的消息,将每次发送的消息备份到磁盘中,过一段时间清空内存中已经持久化的消息,保证消息的一个安全性,也就说某一时刻内存和磁盘都会存在同一份消息,只是后续内存会做一个清空,在清空内存的时候会有一定的性能下降,但并不会出现像存内存那样的PageOut直接阻塞无法处理消息
LazyQueue
从RabbitMQ的3.6.0版本开始,就增加了Lazy Queues的模式,也就是惰性队列。惰性队列的特征如下:
- 接收到消息后直接存入磁盘而非内存(内存中指保留最近的一部分消息)
- 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载)
- 支持数百万条的消息存储
在3.12版本后,所有队列都是Lazy Queue模式,无法更改
在添加队列的时候,添加x-queue-mod=lazy
参数即可设置队列为Lazy模式:
代码配置Lazy模式
@Bean
public Queue lazyQueue(){
return QueueBuilder
.durable("lazy.queue")
.lazy() // 开启Lazy模式
.build();
}
使用声明式注解
@RabbitListener(queuesToDeclare = @Queue(
name = "lazy.queue",
durable = "true",
arguments = @Argument(name = "x-queue-mode", value = "lazy")
))
public void listenLazyQueue(String msg){
log.info("接收到 lazy.queue的消息:{}", msg);
}
小结
RabbitMQ如何保证消息的可靠性
- 首先通过配置可以让交换机、队列、以及发送的消息都持久化。这样队列中的消息会持久化到磁盘,MQ重启消息依然存在。
- RabbitMQ在3.6版本引入了LazyQueue,并且在3.12版本后会称为队列的默认模式。LazyQueue会将所有消息都持久化。
- 开启持久化和生产者确认时, RabbitMQ只有在消息持久化完成后才会给生产者返回ACK回执
3. 消费者的可靠性
当RabbitMQ向消费者投递消息以后,需要知道消费者处理消息的状态如何,因为不能保证消息投递给消费者并不代表就一定被正确消费了,因为消费者也会出现很多的异常情况,比如说:
- 消息在投递过程中出现了网络波动
- 消费者收到了消息,但是消费者机器突然宕机了
- 消费者接受到消息后,没有正确处理消息导致异常
除了上诉还有其他的异常情况,从而导致消息丢失,因此RabbitMQ还需要知道消费者的处理状态,消费者处理失败就可以进行重新投递消息
消费者确认机制
为了确认消费者是否成功处理消息,RabbitMQ提供了消费者确认机制(Consumer Acknowledgement)。即:当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态。回执有三种可选值:
- ack:成功处理消息,RabbitMQ从队列中删除该消息
- nack:消息处理失败,RabbitMQ需要再次投递消息
- reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息
一般使用reject
这种方式情况比较少,消息处理失败比如说消息格式有问题啥的(消息转换异常),而这种情况一般使用try/chtch机制捕获,消息处理成功返回ACK,失败就返回nack。
由于消息回执的处理代码比较统一,因此SpringAMQP帮我们实现了消息确认。并允许我们通过配置文件设置ACK处理方式,有三种模式:
none
:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用manual
:手动模式。需要自己在业务代码中调用api,发送ack
或reject
,存在业务入侵,但更灵活auto
:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack
. 当业务出现异常时,根据异常判断返回不同结果:- 如果是业务异常,会自动返回
nack
; - 如果是消息处理或校验异常,自动返回
reject
;
- 如果是业务异常,会自动返回
返回Reject的常见异常有:
Starting with version 1.3.2, the default ErrorHandler is now a ConditionalRejectingErrorHandler that rejects (and does not requeue) messages that fail with an irrecoverable error. Specifically, it rejects messages that fail with the following errors:
- o.s.amqp…MessageConversionException: Can be thrown when converting the incoming message payload using a MessageConverter.
- o.s.messaging…MessageConversionException: Can be thrown by the conversion service if additional conversion is required when mapping to a @RabbitListener method.
- o.s.messaging…MethodArgumentNotValidException: Can be thrown if validation (for example, @Valid) is used in the listener and the validation fails.
- o.s.messaging…MethodArgumentTypeMismatchException: Can be thrown if the inbound message was converted to a type that is not correct for the target method. For example, the parameter is declared as Message but Message is received.
- java.lang.NoSuchMethodException: Added in version 1.6.3.
- java.lang.ClassCastException: Added in version 1.6.3.
通过Spring的配置文件,可以配置SpringAMQP的ACK处理方式()
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto # 自动ack
消费者失败处理方案
根据上诉的消费者处理机制,消费者出现异常后消息会不断重新进入到队列会哦只能,再次重新尝试发送给消费者,那么此时消费者再次消费消息又报错,消息又会重新入队,知道消息处理完成。
假设极端情况消费者就是无法处理该消息,那么消息就会无限循环,导致mq的消息处理表现造成不小的系统开销,影响系统性能。
不过出现这种极端情况的概率是非常低的,针对上诉情况Spring又为开发者提供了消费者失败重试机制,在消费者出现异常的时候利用本地重试,而不是不断的将消息发送到mq的队列中,
修改Spring的配置文件
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000ms # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
- 消费者在失败后消息没有重新回到MQ无限重新投递,而是在本地重试了3次
- 本地重试3次以后,抛出了
AmqpRejectAndDontRequeueException
异常。查看RabbitMQ控制台,发现消息被删除了,说明最后SpringAMQP返回的是reject
结论:
- 开启本地重试时,消息处理过程中抛出异常,不会requeue到队列,而是在消费者本地重试
- 重试达到最大次数后,Spring会返回reject,消息会被丢弃
业务幂等性
在程序开发中业务幂等性指的是同一个业务,执行一次或者执行多次对业务状态的影响是一致的,也就执行一次或者多次是没有什么区别的。
比如:
- 使用id来删除一条数据
- 使用id来查询一条数据
数据的删除和查询操作通常都是幂等的,但是数据的更新操作往往不是幂等的,如果重复执行就可能出现不可预期的结果。比如说:
- 取消订单:取消订单在恢复库存的时候,如果用户点击多次就可能出现库存重复增加的情况
- 退款业务:重复的退款操作会对商家操作不小的损失。
所以在编写业务代码的时候要尽可能的避免业务被重复执行,然后实际业务中有很多业务场景都会被重复提交,比如说:
- 用户网络卡顿频繁提交表单
- 多个服务之间的调用重试
- MQ的将同一条消息重复投递
因此,我们必须想办法保证消息处理的幂等性。这里给出两种方案:
- 唯一消息ID
- 业务状态判断
唯一消息ID
唯一消息ID其实就是个每一条消息一个UUID,然后将消息投递给消费者。
- 消费者接收到消息将消息处理后,将消息ID保存到数据库
- 如果下次又处理相同的消息,去数据库中查询是否存在即可
SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可。
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jjmc.setCreateMessageIds(true);
return jjmc;
}
该方案需要增加一个查和一个写数据库的操作对性能上有一定影响,还有一个需要保存消息ID也增加了耦合性,对业务有一定侵入
业务判断
我们还可以通过业务逻辑的判断来保证业务的幂等性。假设有一个支付场景,有已支付和未支付状态,在修改前判断是否已经支付,支付了就放弃本次操作。
通过MySQL的行锁即可实现,只有当订单为未支付的时候SQL才能执行修改成已支付状态
UPDATE `order` SET status = ? WHERE id = ? AND status = 1
兜底方案
MQ能保证99%的可能,但并不是百分之百,所以在某些时候我们可以做一些兜底方案,保证多个服务之间的订单状态一致。
比如说支付服务支付完成后MQ消息通知失败,就可以通过定时任务主动去查询支付状态来去更新订单状态
案需要增加一个查和一个写数据库的操作对性能上有一定影响,还有一个需要保存消息ID也增加了耦合性,对业务有一定侵入
业务判断
我们还可以通过业务逻辑的判断来保证业务的幂等性。假设有一个支付场景,有已支付和未支付状态,在修改前判断是否已经支付,支付了就放弃本次操作。
通过MySQL的行锁即可实现,只有当订单为未支付的时候SQL才能执行修改成已支付状态
UPDATE `order` SET status = ? WHERE id = ? AND status = 1
兜底方案
MQ能保证99%的可能,但并不是百分之百,所以在某些时候我们可以做一些兜底方案,保证多个服务之间的订单状态一致。
比如说支付服务支付完成后MQ消息通知失败,就可以通过定时任务主动去查询支付状态来去更新订单状态