1 RabbitMQ的选择
1.1 什么是消息队列
"消息队列(Message Queue)"是在消息的传输过程中保存消息的容器。
在消息队列中,通常有生产者和消费者两个角色。生产者只负责发送数据到消息队列,它不关心谁从消息队列中取出消息并消费(处理)。消费者只负责从消息队列中取出消息并进行处理,它不关心这是谁生产的数据。
举个例子,现在有一家无人超市(消息队列),供货商将商品放在超市中(将消息发送给消息队列),顾客进入超市购买商品(消费者消费消息)。在这个过程中供货商和顾客是互相不可见的。
1.2 为什么使用消息队列
使用消息队列主要有以下三个作用:
- 解耦。解耦,顾名思义就是解除耦合,软件设计的最终目标都是"高内聚,低耦合"。在实际使用中,消息队列通常用来将上下游程序之间的耦合降低,正如上面提到的,生产者、消费者之间互相无强关联性,无论是替换掉生产者还是消费者,对整个处理逻辑都无影响。
- 异步。异步,异步的设计对于一些要求实时性、连贯性不高的程序时尤为适用,例如发送通知(短信、邮件等)。在实际使用时,由于消息队列的存在,消息可以存在一段时间(可以永久保存),在这段时间内,只要有消费者读取到消息就可以进行消费。
- 削峰。削峰,削减峰值,应用在并发请求较大的场景中。现代软件设计的数据终点通常是数据库,如果并发请求过大,数据库就处理不过来,数据库一崩溃,系统就会崩溃。所以在设计过程中就需要考虑准备一个缓冲的地方,消息队列本身可以承载一定的消息数,就可以将部分请求保留在消息队列中,然后下游程序再根据处理能力逐量处理。
1.3 为什么选择RabbitMQ
市面的消息队列其实很多种,这里笔者就只对比ActiveMQ、Kafka、RocketMQ和RabbitMQ了。
- ActiveMQ
- ⬆︎功能支持完备,MQ领域的功能极其完备
- ⬆︎可用性高,支持主从
- ⬇️社区维护度低,较少在大吞吐场景使用
- Kafka
- ⬆︎性能卓越,吞吐量高
- ⬆︎可用性高,支持分布式
- ⬇️kafka不遵循AMQP(高级消息队列)协议
- RocketMQ
- ⬆︎性能卓越,吞吐量高
- ⬆︎可用性高,支持分布式
- ⬆︎消息可靠性:经过参数优化配置,消息可以做到0丢失
- ⬇️支持的客户端语言不多,主要支持java
- RabbitMQ
- ⬆︎性能较好,高并发
- ⬆︎健壮、稳定、易用、跨平台、支持多种语言、文档齐全
- ⬆︎自带管理界面
- ⬆︎插件机制,可以通过插件进行扩展,也可以编写自己的插件
- ⬇️erlang开发,不利于做二次开发和维护
选择RabbitMQ是因为项目数据量没有那么大,所以优先选择功能比较完备且自带管理界面的RabbitMQ。
1.4 AMQP
在使用RabbitMQ前,我们需要来了解一下它所使用的协议,以免使用的时候对exchange、bingding、queue等名词一头雾水。
AMQP全称是Advanced Message Queuing Protocol(高级消息队列协议),标准模型如下图所示。 从图中我们可以看出,Exchange、 Binding和 Queue 构成了 AMQP 协议的核心
- Producer:消息生产者,即产生消息并将消息投递至消息队列的程序。
- Broker:消息队列服务器实体。
- Exchange:消息交换机,它指定消息按什么规则,路由到哪个队列。
- Binding:绑定,它的作用就是把 Exchange 和 Queue 按照路由规则绑定起来。
- Queue:消息队列载体,每个消息都会被投入到一个或多个队列。
- Consumer:消息消费者,即接受消息的程序。
1.4.1 Exchange
AMQP 协议中的核心思想就是生产者和消费者的解耦,生产者从不直接将消息发送给队列。生产者通常不知道一个消息是否会被发送到队列中,只是将消息发送到一个交换机。先由 Exchange 来接收,然后 Exchange 按照特定的策略转发到 Queue 进行存储。Exchange 就类似于一个交换机,将各个消息分发到相应的队列中。
对应到springboot项目中的实践就是需要在生产端配置Exchange的bean。
1.4.2 Bingding和RoutingKey
在 Exchange 收到消息后,它是如何知道需要发送至哪些 Queue 呢?这里就需要了解 Binding 和 RoutingKey 的概念:
Binding 表示 Exchange 与 Queue 之间的关系,我们也可以简单的认为队列对该交换机上的消息感兴趣,绑定可以附带一个额外的参数 RoutingKey。Exchange 就是根据这个 RoutingKey 和当前 Exchange 所有绑定的 Binding 做匹配,如果满足匹配,就往 Exchange 所绑定的 Queue 发送消息,这样就可以实现我们向 RabbitMQ 发送一次消息,不同的 Queue 都会收到这条消息。不过在实践中,RoutingKey 的意义依赖于交换机的类型。
下面就来了解一下 Exchange 的三种主要类型:Direct
、Fanout
和 Topic
。
1.4.2 Direct Exchange
Direct Exchange(直连交换机)是 RabbitMQ 默认的 Exchange,完全根据 RoutingKey 来路由消息。设置 Exchange 和 Queue 的 Binding 时需指定 RoutingKey(一般为 Queue Name),发消息时也指定一样的 RoutingKey,消息就会被路由到对应的Queue。
这种Exchange适合简单的消息分发,在业务不复杂的场景中可以减少一部分的配置。
1.4.3 Fanout Exchange
Fanout Exchange(发布订阅交换机)会忽略 RoutingKey 的设置,直接将 Message 广播到所有绑定的 Queue 中。
这种Exchange类似于设计模式中的发布订阅模式,数个Queue订阅了Exchange,只要Exchange收到消息,就会给每个订阅了它的Queue发送一份消息,适用于一些多处理源的情况,比如日志发送至两台Consumer,两台Consumer分别负责将Info和Error日志写入文件和数据库中,也可以将其作为负载来使用,不过要考虑到并发的问题。
1.4.4 Topic Exchange
Topic Exchange(通配符交换机)和 Direct Exchange 类似,也需要通过 RoutingKey 来路由消息,区别在于Direct Exchange 对 RoutingKey 是精确匹配,而 Topic Exchange 支持模糊匹配。分别支持*
和#
通配符,*
表示匹配一个单词,#
则表示匹配没有或者多个单词。
这种Exchange提供了更灵活的分发方式,我们可以根据业务内容设定对应的RoutingKey,例如设定一个统一通知队列
#.notice
来接收所有需要发消息的业务,再增设*.frontend.notice
队列来处理需要在前台显示消息的业务,这样就能实现更灵活的消息分流。
1.4.5 Headers Exchange和Default Exchange
Headers Exchange(请求头交换机)会忽略 RoutingKey 而根据消息中的 Headers 和创建绑定关系时指定的 Arguments 来匹配决定路由到哪些 Queue。
Headers Exchange 的性能比较差,而且 Direct Exchange 完全可以代替它,所以不建议使用。
Default Exchange(默认交换机)是一种特殊的 Direct Exchange。当你手动创建一个队列时,后台会自动将这个队列绑定到一个名称为空的 Direct Exchange 上,绑定 RoutingKey 与队列名称相同。有了这个默认的交换机和绑定,使我们只关心队列这一层即可,这个比较适合做一些简单的应用。
相较于前三种,Headers Exchange和Default Exchange的使用场景比较少,所以这里不再赘述
2 RabbitMQ的使用
2.1 安装RabbitMQ
RabbitMQ的安装教程网络上也有很多,这里就简单说明一下
- windows安装
- 安装erLang语言,配置环境变量
- 安装RabbitMQ服务端
- 在安装目录下打开命令行窗口,运行
rabbitmq-plugins enable rabbitmq_management
命令安装管理页面的插件 - 双击rabbitmq-server.bat启动脚本
- linux安装(docker方式)
- 安装镜像,
docker pull rabbitmq
- 运行MQ,
docker run -d --name rabbit -p 15672:15672 -p 5672:5672 --restart always rabbitmq
- 查看容器的id,
docker ps -a
- 进入容器,
docker exec -it 容器id /bin/bash
, - 开启管理页面,
rabbitmq-plugins enable rabbitmq_management
- 安装镜像,
打开浏览器输入http://localhost:15672,账号密码默认是:guest/guest,进入即可看到RabbitMQ的管理界面
2.2 RabbitMQ在Springboot中的应用
本文的篇幅有限,所以只演示了Direct Exchange的应用,其他两种常用的Exchange的使用可以参考超详细的Rabbitmq入门,看这篇就够了!
- 在IDEA里面新建三个模块,分别是common(公告模块,包含配置和依赖,比如队列主题,交换机名称,路由匹配键名称)、consumer(消费者模块)、producer(生产者模块)
- 首先在common模块先引入RabbitMQ依赖并创建配置类,示例还用到了lombok和hutool,所以在这里统一引入,版本根据读者自己的项目环境进行配置,可以用mvnrepository查找自己想使用的依赖有哪些版本
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
java
public class RabbitMQConfig {
/**
*RabbitMQ的队列主题名称
*/
public static final String RABBITMQ_DEMO_TOPIC = "rabbitmqDemoTopic";
/**
*RabbitMQ的DIRECT交换机名称
*/
public static final String RABBITMQ_DEMO_DIRECT_EXCHANGE ="rabbitmqDemoDirectExchange";
/**
*RabbitMQ的DIRECT交换机和队列绑定的匹配 DirectRouting
*/
public static final String RABBITMQ_DEMO_DIRECT_ROUTING = "rabbitmqDemoDirectRouting";
(可选)创建消息类用来生成消息ID并记录消息发送时间
java
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RabbitMQMessage {
private String id;
private String sendTime;
private String msg;
public RabbitMQMessage(String jsonStr) {
JSONObject jsonObject = JSONUtil.parseObj(jsonStr);
this.id = jsonObject.get("id", String.class);
this.sendTime = jsonObject.get("sendTime", String.class);
this.msg = jsonObject.get("msg", String.class);
}
public static RabbitMQMessage createMsg(String msg) {
// 32位uuid
String msgId = IdUtil.fastSimpleUUID();
// 发送时间
String sendTime = DateUtil.date().toString();
return RabbitMQMessage.builder()
.id(msgId)
.sendTime(sendTime)
.msg(msg)
.build();
}
public String toJsonStr() {
return JSONUtil.toJsonStr(this);
}
}
- 其次在consumer和producer模块引入common模块,并在各自的application.yaml配置文件中加上RabbitMQ的配置信息
yaml
spring:
rabbitmq:
host: 127.0.0.1
port: 5672
username: guest
password: guest
- 在生产者这边创建一个Direct交换机以及队列的配置类
java
@Configuration
public class DirectRabbitConfig {
@Bean
public Queue rabbitmqDemoDirectQueue() {
/**
* 1、name:队列名称
* 2、durable:是否持久化
* 3、exclusive:是否独享、排外的。如果设置为true,定义为排他队列。则只有创建者可以使用此队列。也就是private私有的。
* 4、autoDelete:是否自动删除。也就是临时队列。当最后一个消费者断开连接后,会自动删除。
* */
return new Queue(RabbitMQConfig.RABBITMQ_DEMO_TOPIC, true, false, false);
}
@Bean
public DirectExchange rabbitmqDemoDirectExchange() {
// Direct交换机
return new DirectExchange(RabbitMQConfig.RABBITMQ_DEMO_DIRECT_EXCHANGE, true, false);
}
@Bean
public Binding bindDirect() {
//链式写法,绑定交换机和队列,并设置匹配键
return BindingBuilder
//绑定队列
.bind(rabbitmqDemoDirectQueue())
//到交换机
.to(rabbitmqDemoDirectExchange())
//并设置匹配键
.with(RabbitMQConfig.RABBITMQ_DEMO_DIRECT_ROUTING);
}
}
- 再创建一个发送消息的Service类
java
@Service
public class RabbitMQServiceImpl implements RabbitMQService {
//日期格式化
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@Resource
private RabbitTemplate rabbitTemplate;
@Override
public String sendMsg(String msg) throws Exception {
try {
String message = RabbitMQMessage.createMsg(msg).toJsonStr();
rabbitTemplate.convertAndSend(RabbitMQConfig.RABBITMQ_DEMO_DIRECT_EXCHANGE, RabbitMQConfig.RABBITMQ_DEMO_DIRECT_ROUTING, message);
return "ok";
} catch (Exception e) {
e.printStackTrace();
return "error";
}
}
}
- 创建一个Controller来进行接收web请求然后发送消息至消息队列
java
@RestController
@RequestMapping("/mall/rabbitmq")
@RequiredArgsConstructor
public class RabbitMQController {
private final RabbitMQService rabbitMQService;
/**
* 发送消息
*/
@PostMapping("/sendMsg")
public String sendMsg(@RequestParam(name = "msg") String msg) throws Exception {
return rabbitMQService.sendMsg(msg);
}
}
- 在消费者这边创建一个接收端
java
@Component
@RabbitListener(queues = {RabbitMQConfig.RABBITMQ_DEMO_TOPIC})
public class RabbitDemoConsumer {
@RabbitHandler
public void process(String msg) {
System.out.println("消费者RabbitDemoConsumer从RabbitMQ服务端消费消息:" + msg);
}
}
- 启动时需要先启动生产者并发送一条消息,让队列在消息队列中建立,才能启动生产者
2.3 设置消费者重试机制
RabbitMQ的机制是阅后即焚,就是当RabbitMQ确认消息被消费者消费后就会立即删除,确认消息被消费是通过消费者返回回执来确认的,在正常的生产消费环节中,消费者获取消息后,应该向RabbitMQ发送ACK回执,表明自己已经处理消息。
在网络传输过程中,可能出现各种异常情况,比如消费者收到消息后突然宕机导致无法处理消息,所以消费者返回ACK回执的时机很重要。
在SpringbootAMQP启动器中预设了三种确认模式
- manual:手动ack,需要在业务代码结束后,调用api发送ack
- auto:自动ack,由spring监测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回nack
- none:关闭ack,MQ假定消费者获取消息后会成功处理,因此消息投递后立即被删除(未设置时的默认策略)
可以看出来none模式是不建议采用的,manual模式需要手动,较为麻烦(手动确认有三种方式,读者需要使用的话建议参照必知必会!RabbitMQ消息确认机制)
yaml
# 在消费端配置文件配置
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto
需要注意的是使用auto模式有这几种情况
- 如果消费者在消费的过程中没有抛出异常,则自动确认
- 当消费者消费的过程中抛出
AmqpRejectAndDontRequeueException
异常的时候,则消息会被拒绝,且该消息不会重回队列 - 当抛出
ImmediateAcknowledgeAmqpException
异常,消息会被确认 - 如果抛出其他的异常,则消息会被拒绝,但是与前两个不同的是,该消息会重回队列,如果此时只有一个消费者监听该队列,那么该消息重回队列后又会推送给该消费者,会造成死循环的情况(为预防这种情况,可以设立死信队列,下文会讲到)
在确认消费者正常收到消息且有正常的处理能力之后,还要考虑到程序的处理往往会有异常情况,比如参数异常,机器异常等情况,所以我们要考虑到消费者消费失败的情况,在这种情况下,我们就需要预设重试机制。
2.3.1 本地重试
我们可以利用Spring的retry机制,在消费者出现异常时利用本地重试,而不是直接将消息返回到消息队列中,在消费者的application.yml文件的基础上添加以下内容
yaml
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000ms # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
将消费者接收端的代码改为
java
@Component
@RabbitListener(queues = {RabbitMQConfig.RABBITMQ_DEMO_TOPIC})
public class RabbitDemoConsumer {
@RabbitHandler
public void process(String msg) {
System.out.println("消费者RabbitDemoConsumer从RabbitMQ服务端消费消息:" + msg);
int i = 1 / 0;
}
}
重启消费端服务,发送web请求重新让生产者发送一条消息,可以发现如下现象:
- 在重试3次后,SpringAMQP会抛出异常AmqpRejectAndDontRequeueException,说明本地重试触发了
- 查看RabbitMQ控制台,发现消息被删除了,说明最后SpringAMQP返回的是ack,mq删除消息了
如何查看RabbitMQ控制台中的消息:点击queues --> Get Message(s) --> Payload里查看内容,图例见使用RabbitMQ控制台查看和发送消息
我们由此可以得知:
- 开启本地重试时,消息处理过程中抛出异常,不会将消息返还给消息队列,而是在消费者本地重试
- 重试达到最大次数后,Spring会自动返回ack,消息相当于被丢弃
2.3.2 失败策略
在上面的测试中,本地重试达到最大重试次数后,消息会被丢弃,这是由Spring内部机制决定的。
在开启本地重试模式后,重试次数耗尽,如果消息依然失败,则会由MessageRecovery接口来处理,它包含三种不同的实现:
- RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
- ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
- RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机
可以看出第三种实现是比较符合实际需求的,在本地重试失败之后,我们可以将消息投递至一个专门存放异常消息的队列,后续由人工来进行处理。这个队列可以使用后续要介绍的死信队列,此处先用一个error队列来示例。
- 在consumer服务中定义处理失败消息的交换机和队列
java
@Bean("error_direct")
public DirectExchange errorMessageExchange(){
return new DirectExchange("error_direct");
}
@Bean("error_queue")
public Queue errorQueue(){
return new Queue("error_queue",true);
}
@Bean
public Binding bindingerror(DirectExchange error_direct,Queue error_queue){
return BindingBuilder.bind(error_queue).to(error_direct).with("error");
}
- 定义一个RepublishMessageRecoverer,关联队列和交换机
java
@Bean
public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate,"error_direct","error");
}
再次测试后可以发现消息进入了error队列中。
2.4 死信队列
死信,顾名思义就是无法正常处理的消息。因为各种特殊原因导致消息队列中的部分消息无法被消费,这种消息如果没有后续处理,很明显不合理而且会变成一个很大的程序漏洞。所以定义一个死信队列来存放此类消息,等待之后人工处理或者二次重试。
2.4.1 消息成为死信的条件
一个消息如果满足下列条件之一,会进入到死信路由(注意是路由,不是队列,一个路由可以对应多个队列):
- 消息的
TTL
到了,消息过期了仍然没有被消费 - 消息被consumer拒收了(手动确认中调用basicReject或者basicNack),并且拒收方法的requeue参数是false,也就是说不会重新入队被其他消费者使用
- 队列的长度限制满了,排在前面的消息会被丢弃或者进入死信路由
只要某个队列中有消息满足了成为死信的条件,如果该队列设置了死信交换机(Dead Letter Exchange)和死信路由键,那么满足死信条件的消息就会发送至死信交换机,死信交换机会根据死信路由键将死信消息投递到对应的死信队列。(具体到项目中就是配置了对应的死信队列之后,只要出现上述三种情况,消息就会自动被消息队列转发至死信队列中)
死信交换机本质上就是一个普通的交换机,只是因为队列设置了参数指定了死信交换机,这个普通的交换机才成为了死信的接收者。
2.4.1.1 消息的TTL到了
TTL(time to live)指消息的存活时间,如果消息从进入队列开始,直到达到TTL
仍然没有被任何消费者消费,那么这个消息将成为死信。
RabbitMQ可以对队列和消息分别设置TTL
。对队列设置TTL对队列中的所有消息都生效。如果队列和消息同时设置了TTL,那么会取两者间TTL更小的生效。可以通过设置消息的expiration字段或者队列的x-message-ttl属性来设置TTL。
接下来的例子中,我们设立normal和dead.letter来演示项目中的正常队列处理和死信队列的处理过程。
在生产者这边创建新的配置类RabbitConfig,配置正常和死信的交换机以及队列等
java
@Configuration
@Slf4j
public class RabbitConfig {
// 添加json格式序列化器
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
// 创建普通交换机
@Bean
public Exchange normalExchange(){
return ExchangeBuilder.directExchange("normal.exchange").durable(true).build();
}
// 创建普通队列,设置ttl为5秒,绑定死信交换机
@Bean
public Queue normalQueue(){
return QueueBuilder.durable("normal.queue").ttl(5000)
.deadLetterExchange("dead.letter.exchange").deadLetterRoutingKey("dead").build();
}
// 创建普通交换机和普通队列的绑定关系
@Bean
public Binding normalBinding(@Qualifier("normalExchange") Exchange exchange, @Qualifier("normalQueue") Queue queue){
return BindingBuilder.bind(queue).to(exchange).with("normal").noargs();
}
// 创建死信交换机
@Bean
public Exchange deadLetterExchange(){
return ExchangeBuilder.directExchange("dead.letter.exchange").durable(true).build();
}
// 创建死信队列
@Bean
public Queue deadLetterQueue(){
return QueueBuilder.durable("dead.letter.queue").build();
}
// 创建普通交换机和普通队列的绑定关系
@Bean
public Binding deadLetterBinding(@Qualifier("deadLetterExchange") Exchange exchange, @Qualifier("deadLetterQueue") Queue queue){
return BindingBuilder.bind(queue).to(exchange).with("dead").noargs();
}
}
在生产者处新建一个controller,测试normal消息的发送
java
@RestController
@RequestMapping("/normal")
@RequiredArgsConstructor
public class NormalController {
private final RabbitMQService rabbitMQService;
/**
* 发送消息
*/
@GetMapping("/test")
public void test() throws Exception {
String message = RabbitMQMessage.createMsg("test").toJsonStr();
rabbitTemplate.convertAndSend("normal.exchange", "normal", message);
}
}
在管理界面可以观察到消息一开始出现在普通队列中 5秒之后TTL到期,消息会全部转移到死信队列中
测试可以多触发发送几条消息
2.4.1.2 消息被consumer拒收
沿用上一小节的架构,只是在创建普通队列时去掉TTL设置
注意:由于修改了普通队列的设置,所以在后续启动程序之前要先在控制台删掉原来的普通队列由程序重新创建,否则会报错
然后将消费端的ack确认方式改为手动确认
yaml
# 在消费端配置文件配置
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual
再将普通队列消费者改为拒收消息
java
import lombok.extern.slf4j.Slf4j;
import com.rabbitmq.client.Channel;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
@Slf4j
public class NormalConsumer {
@RabbitListener(queues = "normal.queue")
public void handleUserMessage(Message message, Channel channel) throws IOException {
// 拒收消息,不重新入队,让消息成为死信
channel.basicReject(message.getMessageProperties().getDeliveryTag(), false);
}
}
此时发送消息之后再启动消费者,消息将会全部转移到死信队列中
2.4.1.3 队列达到最大长度
继续沿用之前的架构,先将生产消费者程序全部关闭,再在创建普通队列时添加x-max-length参数,指定队列的最大长度
java
// 创建普通队列
@Bean
public Queue normalQueue(){
return QueueBuilder.durable("normal.queue").maxLength(5) // 指定最大长度为5
.deadLetterExchange("dead.letter.exchange").deadLetterRoutingKey("dead").build();
}
启动之后发送8条消息给普通队列,最终会发现普通队列只有5条消息,另外3条消息被转移到了死信队列