中间件篇,本章主要介绍有关消息中间件的知识,主要使用的消息中间件是RabbitMQ。本章主要围绕RabbitMQ讲解有关消息不丢失、重复消费、消息堆积、延迟队列、死信队列等内容。现在很多项目中为了性能和吞吐量都会选择使用消息中间件,使用消息中间件可以实现异步通信、削峰填谷等功能,大幅提高系统性能。

本章所有知识都基于RabbitMQ讲解,其他消息中间件如 Kafka 等因为其底层模型有所不同所以答案不一定适用。

需要使用消息中间件的场景有很多,例如异步发送(验证码、邮件、短信)、削峰填谷、分布式事务等。因此学习有关消息中间件的知识是很有必要的。

如何保证消息不丢失

保证消息不丢失一个消息中间件应该具备的基本功能,只要涉及到消息队列,保证消息不丢失是一定绕不开的。

消息的丢失可能发生在任何环节,消息可能还没到达交换机之前就丢失了,也有可能还没来得及到达队列丢失,也有可能还没到达消费者,消息还存放在队列中时MQ就宕机了,这些都会导致消息的丢失。

生产者确认机制

RabbitMQ提供了publisher confirm(生产者确认)机制来确保消息能够正常到达MQ,消息发送到MQ之后,会返回一个ACK结果给发送者,表示消息是否处理成功。

消息未到达交换机、未到达队列、成功到达MQ,返回的结果都是不同的,生产者可以根据不同的错误信息执行不同的逻辑。一般来说消息失败之后可以通过回调方法及时重发信息,或记录日志,人工干预等。

消息持久化

消息到达队列之后,会返回ACK给生产者,但此时仍有消息丢失风险,因为MQ默认是内存存储消息,如果MQ在此时宕机,那么队列中的消息还是会丢失,所以开启持久化功能可以保证缓存在M中的消息不丢失。

消息的持久化主要包含三个方面:

  • 交换机持久化。

  • 队列持久化。

  • 消息持久化。

这三种持久化都可以在代码中创建bean的时候通过设置一些配置项来开启。

消费者确认机制

RabbitMQ还提供了consumer confirm(消费者确认)机制,消费者成功处理完消息之后会向MQ发送ACK回执,MQ收到ACK回执之后才会删除这条信息,SpringAMQP提供了三种确认模式:

  • manual:手动ACK,需要在业务代码结束之后显式调用api发送ACK回执。

  • auto:自动ACK,由spring监听listener代码是否出现异常,没有异常则向MQ返回ACK;抛出异常则返回NACK。

  • none:关闭ACK,MQ假定消费者收到消息后都会成功处理,在投出消息后会立即删除这条消息。

Spring还提供了retry机制,消费者处理消息出现异常后首先会在本地重试,当达到最大重试次数后如果依然失败,则会将消息投递到异常交换机,异常交换机会将消息投递到异常队列交由人工处理。

消息重复消费问题

上一个问题提到,为了确保消息不丢失,我们开启了消费者确认机制,即消费者成功处理完消息之后会向MQ返回一个ACK回执,MQ收到回执之后才会从队列中删除这条消息,否则会定期尝试重发消息。

但是消费者确认机制又带来了新的问题:假设因为网络抖动或其他原因,消费者成功处理了消息,但是发送的ACK回执没能到达MQ,此时MQ会认为消费者并没有成功处理消息,尝试重新发送消息,这样就造成了重复消费问题。

重复消费的问题本质上就是幂等性问题,微服务篇已经提到过有关幂等性的解决方法,包括分布式缓存幂等拦截、唯一标识等。

  • 唯一标识:每条消息携带全局唯一业务id,并且在数据库中给该字段建立唯一索引,当重复插入时数据库会抛出唯一索引冲突异常,可以在代码中捕获到,直接忽略,不执行后续逻辑。缺点是只能用于插入操作。

  • 分布式缓存幂等拦截:前端在提交信息之前先向后端申请一个全局唯一token并存入redis,后续提交业务时携带这个token,后端校验redis中是否存在这个token,如果存在就删除然后处理业务;如果不存在这个token说明这个消息已经被消费过了,直接丢弃即可。

死信交换机(延迟队列)的实现

在某些场景下我们需要进行消息的延迟发送,例如定时RabbitMQ是不支持原生延迟队列功能的,但是可以通过其提供的死信交换机来间接实现延迟队列。

当一个队列中的消息满足以下条件之一时,就会成为死信:

  • 消费者返回NACK声明消费失败,并且消息的requeue(重新入队)参数设置为false,即不再让消息回到队列中。

  • 消息是一个过期消息(消息可以设置TTL),超时无人消费。

  • 要投递的消息队列信息满了,最早的消息可能成为死信。

如果该队列配置了dead-letter-exchange属性,指定了一个交换机,那么队列中的死信就会被投递到这个交换机中,这个交换机就被称为死信交换机(Dead Letter Exchage)。

使用死信交换机实现延迟队列的思路是:设置一个没有消费者监听的队列,同时为这个队列指死信交换机,生产者在向其发送消息时设置TTL,因为没有消费者所以所有消息最终都会过期,此时消息便会被投递到死信交换机中,并转发到死信交换机绑定的队列中,消费者监听这个死信队列即可实现延时处理消息。

RabbitMQ官方也提供了rabbitmq-delayed-message-exchange插件来实现延迟队列,安装这个插件之后可以直接在代码中声明delay属性直接实现延迟队列的功能,不过该插件已经停止维护了,不推荐使用。

消息堆积如何解决

当消费者消费消息的速度小于生产者生产消息的速度时,就会导致消息在队列中不断堆积,直到达到队列容量上限,此时新到达的消息就可能成为死信或被抛弃,这就是消息堆积问题。

解决消息堆积问题有三种思路:

  • 增加更多消费者,加快消息处理速度。

  • 在消费者内部开启线程池加快消息处理速度(上限取决于消费者CPU)。

  • 采用惰性队列,扩大队列容积,提高堆积上限。

惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存。

  • 消费者要消费消息时才会从磁盘读取并加载到内存。

  • 由于磁盘空间很大,惰性队列可以支持数百万条的消息存储。

  • 缺点是消息存储在磁盘中,受限于磁盘IO,性能相比内存存储会有所降低。

RabbitMQ的高可用机制

提到高可用机制,一般指的就是搭建集群,在生产环境中都会搭建集群来保证高可用性。RabbitMQ提供了三种集群来保证高可用:普通集群、镜像集群、仲裁队列。

普通集群

普通集群,也叫做标准集群(classic cluster),具备以下特征:

  • 会在集群的各个节点之间共享数据,包括交换机、队列的元信息。不包含队列的其他信息。

  • 当访问集群中的某个节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回。

  • 由于每个节点保存的只是队列的元信息,如果队列真正所在的节点宕机,队列中的消息就会丢失。

普通集群中每个队列实际上还是只在一个队列中保存,如果一个节点宕机了,其队列中的信息还是会丢失,并不能保证高可用性,因此在实际生产中一般不使用标准集群。

镜像集群

镜像集群本质上是主从模式,具备以下特征:

  • 交换机、队列、队列中的信息会在各个镜像节点之间备份。

  • 创建队列的节点叫做该队列的主节点,备份到该队列信息的其他节点叫做该队列的从节点。

  • 一个队列的主节点可能是另一个队列的镜像节点。

  • 所有操作都由对应的主节点完成,然后同步给镜像节点。

  • 当主节点宕机之后,会有一个镜像节点升级为新的主节点。

镜像集群图示(镜像节点保存的是同步数据)

即使已经做了镜像集群,但是依然存在一个问题,如果当主节点操作完成后,还没来得及同步到镜像节点就宕机了,或者因为网络原因同步失败了该怎么办呢?这就是仲裁队列的用武之地了。

仲裁队列

仲裁队列是RabbitMQ3.8之后才有的功能,用来替代镜像集群,具有以下特征:

  • 与镜像集群一样,都是主从模式吗,支持主从同步。

  • 使用简单,没有复杂的配置。

  • 主从同步基于Raft 协议,Raft协议是一种强一致性的协议,可用保证主从同步的一致性。