RabbitMQ高级特性笔记
前文介绍了RabbitMQ的几种 工作模式 以及 在SpringBoot中的使用 。
下面通过在SpringBoot项目中演示RabbitMQ的一些高级特性。
在开始之前,先搭建一个SpringBoot项目 boot-mq-level
,在项目中演示:
配置文件如下所示:
spring:
#RabbitMQ配置
rabbitmq:
# 设置mq服务器连接地址
host: 192.168.6.200
# 设置用户名
username: soberw
# 设置密码
password: 123456
# 设置连接端口
port: 5672
# 设置虚拟主机名称
virtual-host: /
消息的可靠传递
在使用 RabbitMQ 的时候,作为消息发送方希望杜绝任何消息丢失或者投递失败场景,即希望信息可靠的传递。RabbitMQ 为我们提供了两种方式用来控制消息的投递可靠性模式。
-
confirm 确认模式
-
return 退回模式
rabbitmq 整个消息投递的路径为:
producer—>rabbitmq broker—>exchange—>queue—>consumer
-
消息从 producer 到 exchange 则会返回一个 confirmCallback 。
-
消息从 exchange–>queue 投递失败则会返回一个 returnCallback 。
我们将利用这两个 callback 控制消息的可靠性投递。
需要注意的是:
确认模式
(confirm):无论是否找到匹配的交换机或者消息是否投递,都会执行退回模式
(return):只有交换机找不到匹配的队列即当消息投递失败的时候才会执行
要想实现消息的可靠性,需要在配置文件中添加以下两个配置:
spring:
#RabbitMQ配置
rabbitmq:
publisher-returns: true # 回退模式 return
publisher-confirm-type: correlated # 确认模式confirm
生产者代码
confirm模式
确认模式
(confirm):无论是否找到匹配的交换机或者消息是否投递,都会执行
在配置类中添加配置,创建队列以及交换机,并绑定,这里以direct模式作为演示:
package com.soberw.bootmqlevel.config;
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author soberw
* @Classname RabbitConfig
* @Description
* @Date 2022-06-04 17:46
*/
@Configuration
public class RabbitConfig {
/**
* 声明对列
*/
public static final String QUEUE_CONFIRM_NAME = "test_queue_confirm";
/**
* 声明交换机
*/
public static final String EXCHANGE_CONFIRM_NAME = "test_exchange_confirm";
/**
* 声明routing key
*/
public static final String CONFIRM_ROUTING_KEY = "confirm";
/**
* 创建队列
*/
@Bean(name = "confirmQueue")
public Queue confirmQueue() {
return QueueBuilder.durable(QUEUE_CONFIRM_NAME).build();
}
/**
* 创建交换机
*/
@Bean(name = "confirmExchange")
public Exchange confirmExchange() {
return ExchangeBuilder.directExchange(EXCHANGE_CONFIRM_NAME).durable(true).build();
}
/**
* 绑定交换机和队列
*/
@Bean
public Binding confirmBinding(@Qualifier("confirmQueue") Queue queue,
@Qualifier("confirmExchange") Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(CONFIRM_ROUTING_KEY).noargs();
}
}
投递成功
下面进行测试,先测试发送成功的情况:
添加测试方法:
package com.soberw.bootmqlevel;
import com.soberw.bootmqlevel.config.RabbitConfig;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class BootMqLevelApplicationTests {
@Autowired(required = false)
RabbitTemplate rabbitTemplate;
/**
* 实现可靠性投递消息:
* 1、开启配置:publisher-confirm-type: correlated
* 2、发消息时先注册回调函数
* 3、confirm方法:不管消息是否投递到交换机,都会执行这个方法回调
*/
@Test
void testConfirm() {
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
System.out.println("correlationData = " + correlationData);
if (ack) {
//如果投递到交换机成功
System.out.println("消息投递成功!");
System.out.println("cause1 = " + cause);
} else {
//投递到交换机失败,如果投递失败,可以做出处理,例如记录日志,发送短息等...
System.out.println("消息投递失败!");
System.out.println("cause2 = " + cause);
}
});
rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE_CONFIRM_NAME, RabbitConfig.CONFIRM_ROUTING_KEY, "message confirm ......");
}
}
点击运行:
投递失败
测试一下投递失败的情况:
//rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE_CONFIRM_NAME, RabbitConfig.CONFIRM_ROUTING_KEY, "message confirm ......");
//发送给一个不存在的交换机
rabbitTemplate.convertAndSend("unknown", RabbitConfig.CONFIRM_ROUTING_KEY, "message confirm error......");
有了这个投递回调,可以让我们在投递失败的时候做出业务处理,比如记录失败原因、发送短信告知用户等…
return模式
退回模式
(return):只有交换机找不到匹配的队列即当消息投递失败的时候才会执行
/**
* 可靠性投递,回退模式:
* 1、开启 publisher-returns: true
* 2、发送消息前先注册回调函数
* 3、注册 setReturnCallback回调函数,投递到队列失败才执行,如果投递成功到队列则不会执行
*/
@Test
void testReturn() {
//注意,在设置returnCallback的时候,必须要设置交换机处理失败消息的模式,设置为 true
rabbitTemplate.setMandatory(true);
rabbitTemplate.setReturnsCallback(returned -> {
/*
* returned为回退的信息,包括以下几点:
* message 消息对象
* replyCode 错误码
* replyText 错误信息
* exchange 交换机
* routingKey 路由键
*/
System.out.println("message = " + returned.getMessage());
System.out.println("exchange = " + returned.getExchange());
System.out.println("replyText = " + returned.getReplyText());
System.out.println("routingKey = " + returned.getRoutingKey());
System.out.println("replyCode = " + returned.getReplyCode());
});
}
此模式针对的是发送到队列时是否成功的模式。
先模拟一下发送成功的:
//当消息发送成功时,returnCallback是不会执行的
rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE_CONFIRM_NAME, RabbitConfig.CONFIRM_ROUTING_KEY, "message return...");
发送给一个不存在的队列,此时回调才会执行:
在仅开启了生产者确认机制的情况下,交换机接收到消息后,会直接给消息生产者发送确认消息,如果发现该消息不可路由,那么消息会被直接丢弃,此时生产者是不知道消息被丢弃这个事件的。那么如何处理无法被路由的消息?通过设置 mandatory 参数可以在当消息传递过程中不可达目的地时将消息返回给生产者。
注意:一定要必须要设置交换机处理失败消息的模式,要不然失败了也不会执行!!!
两种设置方式:
- 在配置文件中全局设置
- 在发送消息时单个设置
rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE_CONFIRM_NAME, "unknown", "message return...");
小结
-
设置
publisher-confirm-type: correlated
开启 确认模式。 -
使用
rabbitTemplate.setConfirmCallback
设置回调函数。当消息发送到exchange
后回调confirm
方法。在方法中判断ack
,如果为true
,则发送成功,如果为false
,则发送失败,需要处理。 -
设置
publisher-returns: true
开启 退回模式。 -
使用
rabbitTemplate.setReturnCallback
设置退回函数,当消息从exchange
路由到queue
失败后,如果设置了rabbitTemplate.setMandatory(true)
参数,则会将消息退回给producer
并执行回调函数returnedMessage
消费者代码
Ack确认方式
Ack指Acknowledge,确认。 表示消费端收到消息后的确认方式。
有两种确认方式:
spring:
#RabbitMQ配置
rabbitmq:
# 设置mq服务器连接地址
host: 192.168.6.200
# 设置用户名
username: soberw
# 设置密码
password: 123456
# 设置连接端口
port: 5672
# 设置虚拟主机名称
virtual-host: /
listener:
direct:
# acknowledge-mode: auto 自动确认默认开启
acknowledge-mode: manual # 手动确认
-
自动确认(默认)
通过设置 spring.rabbitmq.listener.direct.acknowledge-mode = auto
-
手动确认
通过设置 spring.rabbitmq.listener.direct.acknowledge-mode = manual
自动确认(默认)
自动确认很简单,当消费者从队列中取出消息时,会自动确认,然后队列将对应的消息删除。
package com.soberw.listener;
import com.rabbitmq.client.Channel;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* @author soberw
* @Classname AckListener
* @Description
* @Date 2022-06-04 23:47
*/
@Component
public class AckListener {
/**
* 测试自动确认消息
* @param message
*/
@RabbitListener(queues = "test_queue_confirm")
public void testAuto(Message message){
System.out.println("message = " + message);
}
}
此时生产者发送消息:
手动确认
在配置文件中开启手动确认的配置后,就可实现手动确认了。
在开启手动确认后,如果程序中没有对应的确认消息的方法调用的话,消息一般是不会确认的,会一直处于未确认状态:
而在接收到消息后,我们就可以利用消息去处理一些业务逻辑了,如操作数据库、发送短信等、、、
而执行业务逻辑处理的结果往往是不确定的,如果发生异常,一定要确保,消息不会滞留,即要么正常接收,要么拒绝。因此需要用到异常处理机制:
/**
* Consumer Ack机制:
* 1、设置手动签收 acknowledge = manual
* 2、让监听器实现ChannelAwareMessageListener接口或者使用 @RabbitListener注解
* 3、如果消息成功处理,则调用 channel.basicAck() 签收
* 4、如果消息处理失败,则调用 channel.basicNack() 拒绝签收,broker重新发送给consumer
*
* @param message
* @param channel
*/
@SneakyThrows
@RabbitListener(queues = "test_queue_confirm")
public void testManual(Message message, Channel channel) {
Thread.sleep(1000);
//获取消息传递的标记
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
//接收消息
System.out.println(new String(message.getBody()));
//处理业务逻辑
//例如,操作数据库,发送短信,等等...
System.out.println("处理业务逻辑中...");
/*
* 手动签收:
* 第一个参数:表示收到的标签
* 第二个参数:表示可以签收
*/
channel.basicAck(deliveryTag, true);
System.out.println("签收成功...");
} catch (Exception e) {
System.out.println(e.getMessage());
//如果处理业务逻辑的时候发生了错误,则要拒绝签收
//requeue = true 表示消息拒收,重新放回到队列中,并且一般放置在队列头部的位置
// 如果为false,则不会放回到队列中,如果有死信队列则放入到死信队列,没有则丢弃
channel.basicNack(deliveryTag, true, true);
System.out.println("拒绝签收...");
}
}
如上,消息正常接收,正常签收:
但如果是消息在处理的过程中发生了异常,也一定要做出处理,一般是拒绝签收:
下面模拟一个异常:
//模拟一个异常
int i = 10 / 0;
当程序报错,程序会拒绝签收,直到修改错误,修改上面的监听器,注释 除 0 错误 ,重新运行程序
小结
在spring.rabbitmq.listener.direct.acknowledge-mode
标签中设置acknowledge
属性,设置ack方式 none:自动确认,manual:手动确认
-
如果在消费端没有出现异常,则调用
channel.basicAck(deliveryTag,true);
方法确认签收消息 -
如果出现异常,则在catch中调用
basicNack
,拒绝消息,让MQ重新发送消息。
消费端限流
Rabbitmq在某一时刻一个消费者能接收处理消息的数量也是有限的。
例如,一个MQ服务器每秒接收了5000个请求,而某队列每秒最大只能处理1000个请求,此时如果我们不加以限制,一股脑的全部发给队列去处理,那么肯定会崩…
因此我们可以加以限制,即消费端限流,设置一个消费者最多可处理的nack消息的数量。
在配置上,我们需要设置spring.rabbitmq.listener.direct.prefetch
属性,实际上,这与工作队列模式中的设置预抓取值是一样的,官网也是这样解释的:
在消费端开启配置:
spring:
#RabbitMQ配置
rabbitmq:
listener:
direct:
prefetch: 1 # 设置一个消费者最多可处理的nack消息的数量
生产者代码
先创建队列以及交换机:
/**
* 声明对列
*/
public static final String QUEUE_QOS_NAME = "test_queue_qos";
/**
* 声明交换机
*/
public static final String EXCHANGE_QOS_NAME = "test_exchange_qos";
/**
* 声明routing key
*/
public static final String QOS_ROUTING_KEY = "qos";
/**
* 创建队列
*/
@Bean(name = "qosQueue")
public Queue qosQueue() {
return QueueBuilder.durable(QUEUE_QOS_NAME).build();
}
/**
* 创建交换机
*/
@Bean(name = "qosExchange")
public Exchange qosExchange() {
return ExchangeBuilder.directExchange(EXCHANGE_QOS_NAME).durable(true).build();
}
/**
* 绑定交换机和队列
*/
@Bean
public Binding qosBinding(@Qualifier("qosQueue") Queue queue,
@Qualifier("qosExchange") Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(QOS_ROUTING_KEY).noargs();
}
模拟一次发送十条消息:
@Test
void testQos() {
for (int i = 1; i <= 10; i++) {
rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE_QOS_NAME, RabbitConfig.QOS_ROUTING_KEY, i + ") : message qos ...");
}
}
消费者代码
设置限流数量为1,并配置监听类:
package com.soberw.listener;
import com.rabbitmq.client.Channel;
import lombok.SneakyThrows;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* @author soberw
* @Classname QosListener
* @Description
* @Date 2022-06-05 21:27
*/
@Component
public class QosListener {
/**
* Consumer 限流机制
* * 1. 确保消息被确认。不确认是不继续处理其他消息的
* * 2. 配置属性
* * prefetch = 1,表示消费端每次从mq拉去一条消息来消费,直到手动确认消费完毕后,才会继续拉取下一条消息。
* @param message
* @param channel
*/
@SneakyThrows
@RabbitListener(queues = "test_queue_qos")
public void testQos(Message message, Channel channel){
Thread.sleep(1000);
//获取消息传递的标记
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
//接收消息
System.out.println(new String(message.getBody()));
//处理业务逻辑
//例如,操作数据库,发送短信,等等...
System.out.println("处理业务逻辑中...");
/*
* 手动签收:
* 第一个参数:表示收到的标签
* 第二个参数:表示可以一次签收多个
*/
channel.basicAck(deliveryTag, true);
System.out.println("签收成功...");
} catch (Exception e) {
System.out.println(e.getMessage());
//如果处理业务逻辑的时候发生了错误,则要拒绝签收
//requeue = true 表示消息拒收,重新放回到队列中,并且一般放置在队列头部的位置
// 如果为false,则不会放回到队列中,如果有死信队列则放入到死信队列,没有则丢弃
// multiple = true 确认多个或者拒绝多个
channel.basicNack(deliveryTag, true, true);
System.out.println("拒绝签收...");
}
}
}
下面断点执行,在确认之前打断点,观察运行流程:
消费者从队列中取出一条消息:
对于确认或者拒绝时的一个很主要的属性 multiple
,即一次可以确认或者批量处理多条消息,以达到高效的目的:
小结
-
配置
spring.rabbitmq.listener.direct.prefetc
属性设置消费端一次拉取多少条消息 -
消费端的必须确认才会继续处理其他消息
TTL
对于一条消息而言,长时间占据在队列中是不合理的,如果一条消息长时间未被处理,合理的做法是根据消息的权重不同,如果消息比较重要,可以暂放在死信队列中,如果不重要,则直接从队列中抹除。
TTL 全称 Time To Live(存活时间/过期时间)。
当消息到达存活时间后,还没有被消费,会被自动清除。
RabbitMQ可以对消息设置过期时间,也可以对整个队列(Queue)设置过期时间。
例如,在订单系统中,当用户下单后,会有30分钟的订单支付时间,此时就可以给订单设置一个过期时间,如果30分钟后用户还未支付,就可以取消订单,用户此时需要重新下单:
控制后台演示消息过期
-
修改管理后台界面,增加队列
参数:表示过期时间,单位为毫秒,10000表示10秒
- 增加交换机
- 绑定队列
-
发送消息
Delivery mode:2-Persistent 表示需要进行持久化
- 查看消息,可以看到消息,但是10秒之后,消息自动消失了,因为设置了10秒的过期时间
代码演示
队列统一过期
给队列设置消息过期时间后,队列中所有的消息都有同样的过期时间。
给一个队列设置过期时间,需要我们在创建队列的时候就设置好:
package com.soberw.bootmqlevel.config;
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author soberw
* @Classname TTLConfig
* @Description
* @Date 2022-06-06 9:17
*/
@Configuration
public class TTLConfig {
/**
* 声明对列
*/
public static final String QUEUE_TTL_NAME = "test_queue_ttl";
/**
* 声明交换机
*/
public static final String EXCHANGE_TTL_NAME = "test_exchange_ttl";
/**
* 声明routing key
*/
public static final String TTL_ROUTING_KEY = "ttl";
/**
* 创建队列
*/
@Bean(name = "ttlQueue")
public Queue confirmQueue() {
//在创建队列的时候,需要同时设置过期时间
//设置的参数名字必须为 x-message-ttl 后面为时间,单位为毫秒
return QueueBuilder.durable(QUEUE_TTL_NAME).withArgument("x-message-ttl", 10000).build();
}
/**
* 创建交换机
*/
@Bean(name = "ttlExchange")
public Exchange confirmExchange() {
return ExchangeBuilder.directExchange(EXCHANGE_TTL_NAME).durable(true).build();
}
/**
* 绑定交换机和队列
*/
@Bean
public Binding confirmBinding(@Qualifier("ttlQueue") Queue queue,
@Qualifier("ttlExchange") Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(TTL_ROUTING_KEY).noargs();
}
}
测试:
/**
* 测试队列过期时间
*/
@Test
void testTTL() {
rabbitTemplate.convertAndSend(TTLConfig.EXCHANGE_TTL_NAME, TTLConfig.TTL_ROUTING_KEY, "hello ttl ...");
}
此条消息会有10秒的过期时间,10秒钟后,再查看,消息已经被抹除了:
消息过期
除了指定队列的过期时间,我们也可以指定某条消息的过期时间。
如果同时设置了消息的过期时间,也设置了队列的过期时间,那么将以时间段的为准。
设置消息的过期时间, 有两种方式:
/**
* 方式一:重写MessagePostProcessor接口方法
*/
@Test
void testTTLMessage1() {
MessagePostProcessor processor = message -> {
//设置message的信息,5秒后过期
message.getMessageProperties().setExpiration("5000");
return message;
};
//发送消息,此消息会独立过期
rabbitTemplate.convertAndSend(TTLConfig.EXCHANGE_TTL_NAME, TTLConfig.TTL_ROUTING_KEY, "message ttl ...", processor);
}
/**
* 方式二:
*/
@Test
void testTTLMessage2() {
MessageProperties mps = new MessageProperties();
mps.setExpiration("5000");
/*
* 这个参数见是用来做消息的唯一标识的
* 发布消息时使用,存储在消息的headers中
*/
CorrelationData cdata = new CorrelationData(UUID.randomUUID().toString());
Message message = new Message("message ttl ...".getBytes(), mps);
rabbitTemplate.convertAndSend(TTLConfig.EXCHANGE_TTL_NAME, TTLConfig.TTL_ROUTING_KEY, message, cdata);
}
测试一下:
因为设置的消息过期时间比队列的过期时间要短,因此此消息会在 5 秒后消息,而并非 10 秒:
RabbitMQ只会过期淘汰队列头部的消息。如果单独给一条消息设置ttl,先入队列的消息过期时间如果设置比较长,后入队列的设置时间比较短,那么消息就不会及时的被淘汰,会导致消息的堆积问题。
死信队列
死信队列
,英文缩写:DLX 。DeadLetter Exchange(死信交换机
),当消息成为Dead message后,可以被重新发送到另一个交换机,这个交换机就是DLX。因为交换机是不存储消息的,只是作为转发的载体,最终消息还是存放在队列中,因此称之为死信队列。
概念
什么是死信队列?
先从概念解释上搞清楚这个定义,死信,顾名思义就是无法被消费的消息,字面意思可以这样理解,一般来说,producer将消息投递到broker或者直接到queue里了,consumer从queue取出消息进行消费,但某些时候由于特定的原因导致queue中的某些消息无法被消费,这样的消息如果没有后续的处理,就变成了死信,有死信,自然就有了死信队列;
消息成为死信的三种情况:
- 队列消息数量到达限制;比如队列最大只能存储10条消息,而发了11条消息,根据先进先出,最先发的消息会进入死信队列。
- 消费者拒接消费消息,basicNack/basicReject,并且不把消息重新放入原目标队列,requeue=false;
- 原队列存在消息过期设置,消息到达超时时间未被消费;
死信的处理方式
死信的产生既然不可避免,那么就需要从实际的业务角度和场景出发,对这些死信进行后续的处理,常见的处理方式大致有下面几种,
① 丢弃,如果不是很重要,可以选择丢弃
② 记录死信入库,然后做后续的业务分析或处理
③ 通过死信队列,由负责监听死信的应用程序进行处理
综合来看,更常用的做法是第三种,即通过死信队列,将产生的死信通过程序的配置路由到指定的死信队列,然后应用监听死信队列,对接收到的死信做后续的处理,
队列绑定死信交换机:
给队列设置参数: x-dead-letter-exchange 和 x-dead-letter-routing-key
代码演示
死信队列本质上还是一个队列,因此其声明方式与普通队列并无很大不同,只是需要额外指定一个标识。
过期时间以及长度限制都是设置在生产者端的,因此添加一个配置类,声明普通队列以及死信队列、交换机并绑定。
package com.soberw.bootmqlevel.config;
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* @author soberw
* @Classname TTLConfig
* @Description
* @Date 2022-06-06 9:17
*/
@Configuration
public class DLXConfig {
/**
* 声明对列
*/
public static final String QUEUE_DLX_NAME = "test_queue_dlx";
/**
* 声明死信队列
*/
public static final String QUEUE_DLX = "queue_dlx";
/**
* 声明交换机
*/
public static final String EXCHANGE_DLX_NAME = "test_exchange_dlx";
/**
* 声明死信交换机
*/
public static final String EXCHANGE_DLX = "exchange_dlx";
/**
* 声明routing key
*/
public static final String DLX_ROUTING_KEY = "test.dlx.#";
/**
* 声明死信队列routing key
*/
public static final String DLX_KEY = "dlx.#";
/**
* 创建普通队列,在创建普通队列的时候,需要与死信队列进行绑定
*/
@Bean(name = "testDlxQueue")
public Queue testDlxQueue() {
/*
* 在创建普通队列的时候,需要同时绑定死信交换机
* 需要两个参数:
* 1、 x-dead-letter-exchange 死信交换机名称
* 2、 x-dead-letter-routing-key 发送给死信交换机的routingKey 必须匹配上死信队列的routingKey
*/
Map<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", EXCHANGE_DLX);
arguments.put("x-dead-letter-routing-key", "dlx.test");
//设置队列过期时间
arguments.put("x-message-ttl", 10000);
//设置队列的长度限制
arguments.put("x-max-length", 10);
return QueueBuilder.durable(QUEUE_DLX_NAME).withArguments(arguments).build();
}
/**
* 创建普通交换机
*/
@Bean(name = "testDlxExchange")
public Exchange testDlxExchange() {
return ExchangeBuilder.topicExchange(EXCHANGE_DLX_NAME).durable(true).build();
}
/**
* 绑定普通交换机和队列
*/
@Bean
public Binding testDlxBinding(@Qualifier("testDlxQueue") Queue queue,
@Qualifier("testDlxExchange") Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(DLX_ROUTING_KEY).noargs();
}
/**
* 创建死信队列
*/
@Bean(name = "dlxQueue")
public Queue dlxQueue() {
//在创建队列的时候,需要同时设置过期时间
//设置的参数名字必须为 x-message-ttl 后面为时间,单位为毫秒
return QueueBuilder.durable(QUEUE_DLX).build();
}
/**
* 创建死信交换机
*/
@Bean(name = "dlxExchange")
public Exchange dlxExchange() {
return ExchangeBuilder.topicExchange(EXCHANGE_DLX).durable(true).build();
}
/**
* 绑定死信交换机和队列
*/
@Bean
public Binding dlxBinding(@Qualifier("dlxQueue") Queue queue,
@Qualifier("dlxExchange") Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(DLX_KEY).noargs();
}
}
过期时间代码实现
在上面的配置中,我们设置了队列的过期时间是 10 秒,因此如果10秒内未处理消息,按照以往的惯例是直接将 消息丢弃,而现在我们绑定了死信队列,那么消息就会被死信队列接收:
/**
* 测试死信队列三种情况:队列超时
*/
@Test
void testDlxTTL(){
rabbitTemplate.convertAndSend(DLXConfig.EXCHANGE_DLX_NAME,"test.dlx.info","message test_dlx ... ");
}
10秒钟后,消息过期,此时会被放入死信队列中:
长度限制代码实现
我们上面设置了队列的最大长度限制,即消息队列中最多存放的消息数量,当超出长度限制时,根据队列的先进先出的特性,最先发送的消息会先进入死信队列:
/**
* 测试死信队列三种情况:超出长度限制
*/
@Test
void testDlxMaxLength() {
//发送 11 条消息,这显然超出了长度限制
for (int i = 1; i <= 11; i++) {
rabbitTemplate.convertAndSend(DLXConfig.EXCHANGE_DLX_NAME, "test.dlx.info", i + " = message test_dlx ... ");
}
}
10 秒钟之后,所有的消息因为都未处理,因此全都被放入死信队列:
消息拒收代码实现
消息拒收需要在消费者端实现:
一般消息正常处理是不会放入死信队列的,但是当执行业务逻辑时出现错误,但是消息又非常重要,就可以放入死信队列:
package com.soberw.listener;
import com.rabbitmq.client.Channel;
import lombok.SneakyThrows;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* @author soberw
* @Classname DlxListener
* @Description
* @Date 2022-06-06 11:43
*/
@Component
public class DlxListener {
/**
* 测试死信队列三种情况:消费端拒收
*
* @param message
* @param channel
*/
@SneakyThrows
@RabbitListener(queues = "test_queue_dlx")
public void testDlxNAck(Message message, Channel channel) {
Thread.sleep(1000);
//获取消息传递的标记
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
//接收消息
System.out.println(new String(message.getBody()));
//处理业务逻辑
//例如,操作数据库,发送短信,等等...
System.out.println("处理业务逻辑中...");
//模拟一个异常
int i = 10 / 0;
/*
* 手动签收:
* 第一个参数:表示收到的标签
* 第二个参数:表示可以签收
*/
channel.basicAck(deliveryTag, true);
System.out.println("签收成功...");
} catch (Exception e) {
System.out.println(e.getMessage());
//如果处理业务逻辑的时候发生了错误,则要拒绝签收
//requeue = true 表示消息拒收,重新放回到队列中,并且一般放置在队列头部的位置
// 如果为false,则不会放回到队列中,如果有死信队列则放入到死信队列,没有则丢弃
channel.basicNack(deliveryTag, true, false);
System.out.println("拒绝签收,放入死信队列中...");
}
}
}
启动消费者程序,生产者发送一条消息:
/**
* 测试死信队列三种情况:拒绝签收
*/
@Test
void testDlxNack() {
rabbitTemplate.convertAndSend(DLXConfig.EXCHANGE_DLX_NAME, "test.dlx.info", " = message test_dlx ... ");
}
补充:
当消息被拒收后,会有三种处理方式:
- 放入死信队列
- 直接丢弃
- 退回到队列头重新排队
延迟队列
延迟队列存储的对象肯定是对应的延时消息,所谓”延时消息”是指当消息被发送以后,并不想让消费者立即拿到消息,而是等待指定时间后,消费者才拿到这个消息进行消费。
场景:在订单系统中,一个用户下单之后通常有30分钟的时间进行支付,如果30分钟之内没有支付成功,那么这个订单将进行取消处理。这时就可以使用延时队列将订单信息发送到延时队列。
需求:
- 下单后,30分钟未支付,取消订单,回滚库存。
- 新用户注册成功30分钟后,发送短信问候。
实现方式:
- 延迟队列
很可惜,在RabbitMQ中并未提供延迟队列功能。
- 但是可以使用:
TTL+死信队列
组合实现延迟队列的效果。
代码实现
下面就模拟订单代码实现:
生产者
package com.soberw.bootmqlevel.config;
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* @author soberw
* @Classname DelayConfig
* @Description
* @Date 2022-06-06 12:48
*/
@Configuration
public class DelayConfig {
/**
* 声明对列
*/
public static final String ORDER_QUEUE_NAME = "order_queue";
/**
* 声明死信队列
*/
public static final String ORDER_QUEUE_DLX_NAME = "order_queue_dlx";
/**
* 声明交换机
*/
public static final String ORDER_EXCHANGE_NAME = "order_exchange";
/**
* 声明死信交换机
*/
public static final String ORDER_EXCHANGE_DLX_NAME = "order_exchange_dlx";
/**
* 声明routing key
*/
public static final String ROUTING_KEY = "order.#";
/**
* 声明死信队列routing key
*/
public static final String DLX_ROUTING_KEY = "dlx.order.#";
/**
* 创建普通队列,在创建普通队列的时候,需要与死信队列进行绑定
*/
@Bean(name = "orderQueue")
public Queue testDlxQueue() {
/*
* 在创建普通队列的时候,需要同时绑定死信交换机
* 需要两个参数:
* 1、 x-dead-letter-exchange 死信交换机名称
* 2、 x-dead-letter-routing-key 发送给死信交换机的routingKey 必须匹配上死信队列的routingKey
*/
Map<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", ORDER_EXCHANGE_DLX_NAME);
arguments.put("x-dead-letter-routing-key", "dlx.order.cancel");
//设置队列过期时间
arguments.put("x-message-ttl", 10000);
return QueueBuilder.durable(ORDER_QUEUE_NAME).withArguments(arguments).build();
}
/**
* 创建普通交换机
*/
@Bean(name = "orderExchange")
public Exchange testDlxExchange() {
return ExchangeBuilder.topicExchange(ORDER_EXCHANGE_NAME).durable(true).build();
}
/**
* 绑定普通交换机和队列
*/
@Bean
public Binding testDlxBinding(@Qualifier("orderQueue") Queue queue,
@Qualifier("orderExchange") Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(ROUTING_KEY).noargs();
}
/**
* 创建死信队列
*/
@Bean(name = "dlxOrderQueue")
public Queue dlxQueue() {
return QueueBuilder.durable(ORDER_QUEUE_DLX_NAME).build();
}
/**
* 创建死信交换机
*/
@Bean(name = "dlxOrderExchange")
public Exchange dlxExchange() {
return ExchangeBuilder.topicExchange(ORDER_EXCHANGE_DLX_NAME).durable(true).build();
}
/**
* 绑定死信交换机和队列
*/
@Bean
public Binding dlxBinding(@Qualifier("dlxOrderQueue") Queue queue,
@Qualifier("dlxOrderExchange") Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(DLX_ROUTING_KEY).noargs();
}
}
/**
* 测试延时队列
*/
@Test
@SneakyThrows
void testDelayed() {
//1.发送订单消息。 将来是在订单系统中,下单成功后,发送消息
rabbitTemplate.convertAndSend(DelayConfig.ORDER_EXCHANGE_NAME, "order.info", "订单信息:id=1001,time=2022年6月6日 13:00:00");
//2.打印倒计时10秒
for (int i = 10; i > 0; i--) {
System.out.println(i + "...");
Thread.sleep(1000);
}
}
运行程序创建订单延时队列。
消费者
package com.soberw.listener;
import com.rabbitmq.client.Channel;
import lombok.SneakyThrows;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* @author soberw
* @Classname OrderListener
* @Description
* @Date 2022-06-06 13:03
*/
@Component
public class OrderListener {
@SneakyThrows
@RabbitListener(queues = "order_queue_dlx")
public void testDelayed(Message message, Channel channel) {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
//1.接收转换消息
System.out.println(new String(message.getBody()));
//2. 处理业务逻辑
System.out.println("处理业务逻辑...");
System.out.println("根据订单id查询其状态...");
System.out.println("判断状态是否为支付成功");
System.out.println("取消订单,回滚库存....");
//3. 手动签收
channel.basicAck(deliveryTag, true);
} catch (Exception e) {
//e.printStackTrace();
System.out.println("出现异常,拒绝接受");
//4.拒绝签收,不重回队列 requeue=false
channel.basicNack(deliveryTag, true, false);
}
}
}
备份交换机
有了 mandatory 参数和回退消息,我们获得了对无法投递消息的感知能力,有机会在生产者的消息 无法被投递时发现并处理。
但有时候,我们并不知道该如何处理这些无法路由的消息,最多打个日志,然后触发报警,再来手动处理。
而通过日志来处理这些无法路由的消息是很不优雅的做法,特别是当生产者 所在的服务有多台机器的时候,手动复制日志会更加麻烦而且容易出错。而且设置 mandatory 参数会增 加生产者的复杂性,需要添加处理这些被退回的消息的逻辑。
如果既不想丢失消息,又不想增加生产者的复杂性,该怎么做呢?前面在设置死信队列的文章中,我们提到,可以为队列设置死信交换机来存储那些处理失败的消息,可是这些不可路由消息根本没有机会进入到队列,因此无法使用死信队列来保存消息。
在 RabbitMQ 中,有一种备份交换机
的机制存在,可以很好的应对这个问题。什么是备份交换机呢?
备份交换机可以理解为 RabbitMQ 中交换机的“备胎”,当我们为某一个交换机声明一个对应的备份交换机时, 就是为它创建一个备胎,当交换机接收到一条不可路由消息时,将会把这条消息转发到备份交换机中,由 备份交换机来进行转发和处理,通常备份交换机的类型为 Fanout
,这样就能把所有消息都投递到与其绑定的队列中,然后我们在备份交换机下绑定一个队列,这样所有那些原交换机无法被路由的消息,就会都进入这个队列了。
当然,我们还可以建立一个报警队列,用独立的消费者来进行监测
和报警
。
代码实现
启用了备份交换机后,如果收到一条不可路由消息,则会将消息转到备份交换机中,下面就报警做出演示:
在生产者端:
需要配置两个交换机,其中一个为正常交换机,另一个为备份交换机
以及三个队列,一个为正常队列,一个为处理备份的队列,一个为处理警告的队列:
package com.soberw.listener;
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author soberw
* @Classname BackConfig
* @Description
* @Date 2022-06-06 14:31
*/
@Configuration
public class BackConfig {
public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
public static final String CONFIRM_QUEUE_NAME = "confirm.queue";
public static final String BACKUP_EXCHANGE_NAME = "backup.exchange";
public static final String BACKUP_QUEUE_NAME = "backup.queue";
public static final String WARNING_QUEUE_NAME = "warning.queue";
// 声明确认队列
@Bean("confirm_queue")
public Queue confirm_queue() {
return QueueBuilder.durable(CONFIRM_QUEUE_NAME).build();
}
//声明确认队列绑定关系
@Bean
public Binding queue_binding(@Qualifier("confirm_queue") Queue queue,
@Qualifier("confirm_exchange") DirectExchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with("key1");
}
//声明备份 Exchange
@Bean("backupExchange")
public FanoutExchange backupExchange() {
return new FanoutExchange(BACKUP_EXCHANGE_NAME);
}
//声明确认 Exchange 交换机的备份交换机
@Bean("confirm_exchange")
public DirectExchange confirmExchange() {
return ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME)
.durable(true)
//设置该交换机的备份交换机
.withArgument("alternate-exchange", BACKUP_EXCHANGE_NAME)
.build();
}
// 声明警告队列
@Bean("warningQueue")
public Queue warningQueue() {
return QueueBuilder.durable(WARNING_QUEUE_NAME).build();
}
// 声明报警队列绑定关系
@Bean
public Binding warningBinding(@Qualifier("warningQueue") Queue queue,
@Qualifier("backupExchange") FanoutExchange
backupExchange) {
return BindingBuilder.bind(queue).to(backupExchange);
}
// 声明备份队列
@Bean("backQueue")
public Queue backQueue() {
return QueueBuilder.durable(BACKUP_QUEUE_NAME).build();
}
// 声明备份队列绑定关系
@Bean
public Binding backupBinding(@Qualifier("backQueue") Queue queue,
@Qualifier("backupExchange") FanoutExchange backupExchange) {
return BindingBuilder.bind(queue).to(backupExchange);
}
}
这里以接收报警消息作为演示:
生产一条消息,发送给不可投递的路由,即不会被路由接收的消息:
/**
* 测试发送一条不可路由消息
*/
@Test
@SneakyThrows
void testWarning() {
rabbitTemplate.setMandatory(true);
rabbitTemplate.setReturnsCallback(returned -> {
System.out.printf("消息被退回了....因为交换机 %s 上不存在routingKey:%s", returned.getExchange(), returned.getRoutingKey());
});
rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE_CONFIRM_NAME, "unknown", "warning test ... ");
}
测试一下:
/**
* @author soberw
* @Classname WarningListener
* @Description
* @Date 2022-06-06 14:44
*/
@Component
public class WarningListener {
/**
* 接收报警消息
*
* @param message
*/
@SneakyThrows
@RabbitListener(queues = "warning.queue")
public void testWarning(Message message) {
String msg = new String(message.getBody());
System.out.printf("报警!!发现了不可路由消息:{%s}" + msg);
}
}
上面的例子中,我们 mandatory 参数与备份交换机一起使用,如果两者同时开启,消息究竟何去何从?
运行程序:
发现消息是被备份交换机拿到了。因此证明:
mandatory 参数与备份交换机可以一起使用的时候,如果两者同时开启,备份交换机优先级高。
优先级队列
在我们系统中有一个订单催付
的场景,我们的客户在天猫下的订单,淘宝会及时将订单推送给我们,如 果在用户设定的时间内未付款那么就会给用户推送一条短信提醒,很简单的一个功能对吧,但是,tmall 商家对我们来说,肯定是要分大客户和小客户的对吧,比如像苹果,小米这样大商家一年起码能给我们创 造很大的利润,所以理应当然,他们的订单必须得到优先处理,而曾经我们的后端系统是使用 redis 来存 放的定时轮询,大家都知道 redis 只能用 List 做一个简简单单的消息队列,并不能实现一个优先级的场景, 所以订单量大了后采用 RabbitMQ 进行改造和优化,如果发现是大客户的订单给一个相对比较高的优先级, 否则就是默认优先级
。
添加方式
有两种添加方式:
在控制台页面添加
在代码中添加
要让队列实现优先级需要做的事情有如下事情:
- 队列需要设置为优先级队列
- 消息需要设置消息的优先级
- 消费者需要等待消息已经发送到队列中才去消费。因为,这样才有机会对消息进行排序
生产者端:
package com.soberw.bootmqlevel.config;
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author soberw
* @Classname PriorityConfig
* @Description
* @Date 2022-06-06 16:56
*/
@Configuration
public class PriorityConfig {
public static final String PRIORITY_QUEUE_NAME = "test_queue_priority";
public static final String PRIORITY_EXCHANGE_NAME = "test_exchange_priority";
@Bean(name = "priorityQueue")
public Queue priorityQueue() {
//官方允许设置的优先级范围是 0 ~ 255 之间,此处设置为 10 即允许优先级范围是 0 ~ 10,不要设置过大,浪费内存
return QueueBuilder.durable(PRIORITY_QUEUE_NAME).maxPriority(10).build();
}
@Bean(name = "priorityExchange")
public FanoutExchange priorityExchange() {
return ExchangeBuilder.fanoutExchange(PRIORITY_EXCHANGE_NAME).durable(true).build();
}
@Bean
public Binding priorityBinding(@Qualifier("priorityQueue") Queue queue,
@Qualifier("priorityExchange") FanoutExchange
backupExchange) {
return BindingBuilder.bind(queue).to(backupExchange);
}
}
/**
* 测试优先级队列
*/
@Test
void testPriority() {
for (int i = 0; i < 10; i++) {
String msg = "info " + i;
if (i == 5) {
MessagePostProcessor messagePostProcessor = message -> {
//设置消息优先级,值越大,越先消费
message.getMessageProperties().setPriority(5);
return message;
};
rabbitTemplate.convertAndSend(PriorityConfig.PRIORITY_EXCHANGE_NAME, "", msg, messagePostProcessor);
} else {
rabbitTemplate.convertAndSend(PriorityConfig.PRIORITY_EXCHANGE_NAME, "", msg);
}
}
}
消费者端:
package com.soberw.listener;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* @author soberw
* @Classname PriorityListener
* @Description
* @Date 2022-06-06 17:15
*/
@Component
public class PriorityListener {
@RabbitListener(queues = "test_queue_priority")
public void testPriority(Message message){
System.out.println(new String(message.getBody()));
}
}
惰性队列
RabbitMQ 从 3.6.0 版本开始引入了惰性队列的概念。惰性队列会尽可能的将消息存入磁盘中,而在消 费者消费到相应的消息时才会被加载到内存中,它的一个重要的设计目标是能够支持更长的队列,即支持 更多的消息存储。当消费者由于各种各样的原因(比如消费者下线、宕机亦或者是由于维护而关闭等)而致 使长时间内不能消费消息造成堆积时,惰性队列就很有必要了。 默认情况下,当生产者将消息发送到 RabbitMQ 的时候,队列中的消息会尽可能的存储在内存之中, 这样可以更加快速的将消息发送给消费者。即使是持久化的消息,在被写入磁盘的同时也会在内存中驻留 一份备份。当 RabbitMQ 需要释放内存的时候,会将内存中的消息换页至磁盘中,这个操作会耗费较长的 时间,也会阻塞队列的操作,进而无法接收新的消息。虽然 RabbitMQ 的开发者们一直在升级相关的算法, 但是效果始终不太理想,尤其是在消息量特别大的时候
队列两种模式
队列具备两种模式:default 和 lazy。
默认的为 default 模式,在 3.6.0 之前的版本无需做任何变更。lazy 模式即为惰性队列的模式,可以通过调用 channel.queueDeclare 方法的时候在参数中设置,也可以通过 Policy 的方式设置,如果一个队列同时使用这两种方式设置的话,那么 Policy 的方式具备更高的优先级。 如果要通过声明的方式改变已有队列的模式的话,那么只能先删除队列,然后再重新声明一个新的。 在队列声明的时候可以通过“x-queue-mode”参数来设置队列的模式,取值为“default”和“lazy”。下面示 例中演示了一个惰性队列的声明细节:
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-queue-mode", "lazy");
channel.queueDeclare("myqueue", false, false, false, args);
在发送 1 百万条消息,每条消息大概占 1KB 的情况下,普通队列占用内存是 1.2GB,而惰性队列仅仅占用 1.5MB!
消息百分百成功投递
谈到消息的可靠性投递,无法避免的,在实际的工作中会经常碰到,比如一些核心业务需要保障消息不丢失,接下来我们看一个可靠性投递的流程图,说明可靠性投递的概念:
-
Step 1: 首先把消息信息(业务数据)存储到数据库中,紧接着,我们再把这个消息记录也存储到一张消息记录表里(或者另外一个同源数据库的消息记录表)
-
Step 2:发送消息到MQ Broker节点(采用confirm方式发送,会有异步的返回结果)
-
Step 3、4:生产者端接受MQ Broker节点返回的Confirm确认消息结果,然后进行更新消息记录表里的消息状态。比如默认Status = 0 当收到消息确认成功后,更新为1即可!
-
Step 5:但是在消息确认这个过程中可能由于网络闪断、MQ Broker端异常等原因导致 回送消息失败或者异常。这个时候就需要发送方(生产者)对消息进行可靠性投递了,保障消息不丢失,100%的投递成功!(有一种极限情况是闪断,Broker返回的成功确认消息,但是生产端由于网络闪断没收到,这个时候重新投递可能会造成消息重复,需要消费端去做幂等处理)所以我们需要有一个定时任务,(比如每5分钟拉取一下处于中间状态的消息,当然这个消息可以设置一个超时时间,比如超过1分钟 Status = 0 ,也就说明了1分钟这个时间窗口内,我们的消息没有被确认,那么会被定时任务拉取出来)
-
Step 6:接下来我们把中间状态的消息进行重新投递 retry send,继续发送消息到MQ ,当然也可能有多种原因导致发送失败
-
Step 7:我们可以采用设置最大努力尝试次数,比如投递了3次,还是失败,那么我们可以将最终状态设置为Status = 2 ,最后 交由人工解决处理此类问题(或者把消息转储到失败表中)。
对应的数据库文件举例:
-- ----------------------------
-- Table structure for broker_message_log
-- ----------------------------
DROP TABLE IF EXISTS `broker_message_log`;
CREATE TABLE `broker_message_log` (
`message_id` varchar(255) NOT NULL COMMENT '消息唯一ID',
`message` varchar(4000) NOT NULL COMMENT '消息内容',
`try_count` int(4) DEFAULT '0' COMMENT '重试次数',
`status` varchar(10) DEFAULT '' COMMENT '消息投递状态 0投递中,1投递成功,2投递失败',
`next_retry` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP COMMENT '下一次重试时间',
`create_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP,
`update_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`message_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ----------------------------
-- Table structure for t_order
-- ----------------------------
DROP TABLE IF EXISTS `t_order`;
CREATE TABLE `t_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`message_id` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2022060611 DEFAULT CHARSET=utf8;
消息幂等性保障
幂等性指一次和多次请求某一个资源,对于资源本身应该具有同样的结果。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
概念
简单来说,就是要保障用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常, 此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱 了,流水记录也变成了两条。在以前的单应用系统中,我们只需要把数据操作放入事务中即可,发生错误 立即回滚,但是再响应客户端的时候也有可能出现网络中断或者异常等
消息重复消费
消费者在消费 MQ 中的消息时,MQ 已把消息发送给消费者,消费者在给 MQ 返回 ack 时网络中断, 故 MQ 未收到确认信息,该条消息会重新发给其他的消费者,或者在网络重连后再次发送给该消费者,但 实际上该消费者已成功消费了该条消息,造成消费者消费了重复的消息。
解决思路
MQ 消费者的幂等性的解决一般使用全局 ID 或者写个唯一标识比如时间戳 或者 UUID 或者订单消费者消费 MQ 中的消息也可利用 MQ 的该 id 来判断,或者可按自己的规则生成一个全局唯一 id,每次消费消 息时用该 id 先判断该消息是否已消费过。
亦或者,可以借助于乐观锁的机制,即设置一个状态标签。
在海量订单生成的业务高峰期,生产端有可能就会重复发生了消息,这时候消费端就要实现幂等性, 这就意味着我们的消息永远不会被消费多次,即使我们收到了一样的消息。实现幂等性保障有三种操作:
-
唯一 ID+指纹码机制,利用数据库主键去重,
-
利用 乐观锁机制 实现
-
利用 redis 的原子性去实现(推荐)
唯一ID + 指纹码机制
指纹码:我们的一些规则或者时间戳加别的服务给到的唯一信息码,它并不一定是我们系统生成的,基 本都是由我们的业务规则拼接而来,但是一定要保证唯一性,然后就利用查询语句进行判断这个 id 是否存 在数据库中,优势就是实现简单就一个拼接,然后查询判断是否重复;劣势就是在高并发时,如果是单个数 据库就会有写入性能瓶颈当然也可以采用分库分表提升性能,但也不是我们最推荐的方式。
基于乐观锁机制
设置一个标签例如: version ,在发送消息的时候携带上
生产者发送消息:
id=1,money=500,version=1
消费者接收到重复消息
id=1,money=500,version=1
id=1,money=500,version=1
消费者需要保证幂等性:第一次执行SQL语句
第一次执行:version=1
update account set money = money - 500 , version = version + 1
where id = 1 and version = 1
消费者需要保证幂等性:第二次执行SQL语句
第二次执行:version=2
update account set money = money - 500 , version = version + 1
where id = 1 and version = 1
因为此时对应的 version 已经变为了 2 ,因此第二次执行语句时不会产生任何结果,从而达到了幂等性保障。
Redis原子性
利用 redis 执行 setnx 命令,天然具有幂等性。从而实现不重复消费。