分享

后端通用教程(五)

 夜猫速读 2022-05-05 发布于湖北

1. 前言

除了计算机网络、操作系统等基础知识的考察,各种流行的中间件也深受面试官的青睐。之前的章节已经对缓存中间件的代表 Redis 的面试题进行了分析,本章节将介绍常用的消息中间件,即 RabbitMQ 的基础定义以及使用原因。

2. 消息队列使用场景

面试官提问: 为什么要使用消息队列?能说说消息队列解决了什么问题?

题目解析:

任何工具都有诞生的背景,例如非关系型数据库是为了解决性能以及扩展性问题产生。

常见的消息队列有 RabbitMQ、RocketMQ、Kafka 等,消息队列也是针对特定问题有不同的使用场景,可以抽象为异步处理、应用解耦、流量削峰三种场景。

2.1 异步处理 & 应用解耦

以最常见的在网站注册新用户场景为例,如果经过了基本的业务逻辑之后,要通过短信和邮件的方式验证是否用户本人注册,每个流程的请求响应耗时为 100ms,在同步的方式下总共需要耗时 300ms。

                                              (同步处理场景)

其中发送验证短信以及发送验证邮件两个步骤并没有强制的先后依赖关系,所以同步请求的效率相对较低,使用消息队列可以将验证短信和邮件的模块拆开,经过消息队列中转分发请求,假设消息队列的读写时间为 20ms,总流程的耗时被优化到 220ms。

(消息队列异步处理场景)

上述异步请求的过程本质上也是应用解耦的过程,最基础的应用架构中可以将短信注册模块和邮件注册模块都可以耦合在注册业务逻辑中,但是如果有其他的服务也需要使用短信注册功能,就只能调用注册业务的短信模块接口。此时,程序的鲁棒性相对较差,当注册业务模块的服务器宕机之后,会造成所有服务的短信模块都不可用,所以需要将短信模块解耦出来,同理,邮件模块也需要被拆分为单独的服务。候选人需要注意一点,这种拆分本质上都是为了应用服务的高可用。

2.2 流量削峰

互联网存在很多高并发场景,例如在 12306 抢购春运火车票,或者阿里淘宝的双十一秒杀活动,系统服务在短时间收到大量的用户请求,如果数据库不能抗住相对日常的 N 倍流量被打垮,会导致服务不可用。为了避免这种情况发生,有熔断、降级、以及流量削峰等多种解决方案,消息队列是最常见的流量削峰方案。

(消息队列流量削峰)

还是以用户注册的例子,例如在双十一凌晨时间,大量新用户通过活动链接进入了网站的注册页面,在收到用户请后后,首先将请求写入消息队列,如果请求数量超过消息队列的容量,那么多余的请求直接放弃并且跳转到错误页面,这也是常用的降级方案。

业务代码从消息队列中拿到用户请求,再进行后续的业务逻辑。消息队列在用户和业务逻辑中之间作为中间件模块,防止大量流量直接打到底层数据库。

3. 常用消息队列

面试官提问: 常用的消息队列有哪些?

题目解析:

最常见的消息队列有 ActiveMQ、RabbitMQ、RocketMQ 以及 Kafka,我们一般关注的是 RabbitMQ 以及 Kafka,我们关注基本定义以及两者之间的特性差别。

  • RabbitMQ:基于 Erlang 语言开发的消息发布和订阅系统,基于 AMQP 协议实现。AMQP 协议一般在对数据一致性要求高、对性能要求较低的场景使用;

  • Kafka:LinkedIn 公司开源的消息发布和订阅系统,一般在对数据一致性要求较低、海量数据的处理场景中使用。

下面对比分析下两种消息队列的特性差异。

3.1 吞吐量对比

  • RabbitMQ:单机吞吐量在万级别,比 Kafka 低一个数量级;

  • Kafka:单机吞吐量在十万级别,数据的存储和读取都是依靠本地硬盘的顺序读写,处理效率高。

3.2 应用场景对比

  • RabbitMQ:企业内部微服务,例如内部人员管理系统的消息通讯场景。因为基于 Erlang 开发语言,对小型企业来说,开发维护成本相对较高;

  • Kafka:大数据系统中常用,例如日志处理以及数据实时分析场景,目前 Kafka 几乎是日志采集场景的首选消息队列。

4. 小结

本章节介绍了消息队列的基本使用场景,需要理解消息队列的核心是异步处理以及解耦能力。我们对比了 RabbitMQ 和 Kafka 两种消息队列的特性,在后续的章节主要会对 RabbitMQ 的常见题目进行分析。

1. 前言

因为生产环境存在不同的消息分发要求,例如对于注册流程,同一条注册消息需要发送到短信模块和邮箱模块,而对于请求削峰场景,同一条消息只需要发送到业务服务后端即可。我们可以通过配置 RabbitMQ 工作模式来决定发送单个队列还是多个队列,以及配置特定的路由规则。

2. RabbitMQ 工作模式

面试官提问:RabbitMQ 有哪些发布订阅模式?有什么区别?

题目解析

这里主要考察候选人对消息队列不同发布/订阅架构模型的了解程度,我们首先给出RabbitMQ队列模型中不同模块的定义:

  • 生产者(Producer):发送消息到队列的模块;

  • 队列(Queue):存储消息的一段空间,作为消息的缓存模块;

  • 消费者(Consumer):从队列中接受消息的模块;

  • 交换机(Exchange):消息不直接发到队列,首先发到 Exchange 模块,再根据路由规则转发到定制化的队列。

其次,候选人需要画出五种工作模式的图示,并且说明对应特点。

2.1 简单队列

(简单队列模型图,引用自官方文档)

简单队列模型的定义:只有一个生产者、一个消息队列、一个消费者。

优点是不需要配置复杂的路由规则,缺点是只支持点对点通信,不适合大部分需要分发消息的路由场景。

2.2 工作队列

(工作队列模型图,引用自官方文档)

工作队列(Work Queue)模型的定义:一个生产者,拥有往多个消费者发送消息的能力,但是一条消息只能被一个消费者消费。

工作队列的应用场景是需要将流量打散到多个消费者模块的场景,例如电商秒杀的前置削峰消息队列。

2.3 发布/订阅队列

(发布/订阅消息模型图,引用自官方文档)

发布/订阅(Publish/Subscribe)队列模型的定义:生产者只能往交换机(Exchange)模块发送消息,交换机绑定了多个队列,所有绑定该交换机的队列都会收到交换机中的所有消息。

交换机存在四种路由方式:direct、topic、headers和fanout模式。

发布/订阅模型是比较常用的队列模型,例如注册流程中,同一个用户请求需要往短信模块和邮箱模块发送消息,可以使用该模型。

2.4 路由队列

                       (路由队列模型图,引用自官方文档)

路由(Routing)队列模型的定义:生产者将消息发送到 direct 模式的交换机,交换机和队列绑定的时候限制了路由 Key。当生产者发送一条消息的时候,会指定一条路由 Key,这条消息只会发送到对应的队列中。例如上图中指定key=orange的消息,只有 Q1 能收听到该条消息;指定key=black或者key=green的消息,只有 Q2 队列能收到到该消息。

路由队列的优势是能够定制化发送消息,消费者选择性收听消息,适合灵活变通的应用场景。

2.5 主题队列

(主题队列模型图,引用自官方文档)

主题(Topics)队列模型的定义:实际上是基于路由队列的定制化配置,支持了key的通配符匹配,简单理解就是正则表达式+路由key。例如对于上图,*.orange只能匹配到a.orange类似的key,但是lazy.#可以匹配到lazy.a或者lazy.a.b类似的key。

因为定义基本相同,主题队列的应用场景类似路由队列。

3. 小结

本章节介绍了 RabbitMQ 的生产者、队列、消费者、交换机基本组件,以及五种工作队列模型,需要候选人能够画出每种队列的结构示意图,并且能够清晰阐述不同队列的使用特点以及能够适用的实战场景。

1. 前言

RabbitMQ经常被用于服务模块之间的解耦以及高并发削峰场景,之前的章节讨论了不同服务模式的特点,但是在生产环境中,因为机器以及网络设备的不可靠,保证消息的可靠是待解决的问题。在特定场景下消息可能存在丢失风险,本文将介绍如何预防这类的风险。

2. RabbitMQ消息丢失的场景

面试官提问:RabbitMQ 消息队列,在哪些场景下可能会丢失消息?

题目解析

我们可以将 RabbitMQ 消息处理的过程分为三个步骤:

(1)生产阶段:生产者生产消息并且发送到消息队列;(2)储存阶段:消息队列存储和处理消息;(3)消费阶段:消息队列将消息转发到消费者。

上述每个步骤都有消息丢失的风险,候选人需要按顺序分别解释不同场景可能丢失的原因以及解决方案。

2.1 生产者生产消息并且发送到消息队列

可能发生消息丢失的场景:网络故障。网络环境的不可靠导致消息发送失败,例如网络丢包、网络故障。数据在网络中传输会经过诸多网络设备,只要其中一个网络链接在数据抵达前已经流量满载,新到的数据将会阻塞一段时间段。另外比较少见的例子是施工挖断光纤或者其他原因导致硬件层面的长时间不可用。

参考解决方案是使用AMQP协议的事务机制。生产者在发出消息之后,消息是否到达RabbitMQ服务器是默认不可知的,所以在生产者发送消息之前,调用channel.txSelect 语句开启事务,如果消息发送失败,那么调用channel.txRollback回滚事务,重新发送一条消息;如果消息发送成功,那么调用channel.txCommit提交事务。

采用事务的缺点是增加耗时,会降低RabbitMQ的吞吐性能。

所以RabbitMQ还有一种性能改进方案,即Confirm机制,步骤如下:

(1)生产者调用channel.confirmSelect将通信方式设置为confirm模式;(2)生产者发送的所有消息都会被分配一个唯一 ID;(3)当生产者发送的消息成功投递到队列之后,RabbitMQ会发送一个确认给生产者,生产者即得知这条消息已经成功发送。

2.2 消息队列存储和处理消息

可能发生消息丢失的场景:服务器宕机。消息存储在 RabbitMQ 队列中,如果队列没有持久化,RabbitMQ 服务器重启会导致消息丢失。

参考解决方案是对消息队列持久化,分为三个步骤:

(1)Exchange 持久化:以 Direct 模式为例,将 durable 参数设置为 true。示例:

@Bean
DirectExchange testExchange() {
    return new DirectExchange(Constants.EXCHANGE_NAME, true, false);
}

(2)Queue 持久化:将 durable 参数设置为 true,但是这样只能保证持久化 Queue 的元数据,但是不会持久化 Queue 里存储的消息。示例:

@Bean
public Queue testQueue() {
    return new Queue(Constants.QUEUE_NAME);
}

(3)消息持久化:发送消息的时候将deliveryMode设置为2,SpringBoot中的rabbitTemplate默认设置消息是持久化,所以我们不需要手动配置,具体原因可参考源码,示例:

public enum MessageDeliveryMode {
    NON_PERSISTENT,
    PERSISTENT;
    private MessageDeliveryMode() {
      
    }
    public static int toInt(MessageDeliveryMode mode) {
        switch(mode) {
            //非持久化模式
                case NON_PERSISTENT:
                return 1;
            //持久化模式
                case PERSISTENT:
                return 2;
                default:
                return -1;
        }
    }

2.3 消息队列将消息转发到消费者

可能发生消息丢失的场景:消费者在收到消息之后,还没来得及处理消息的消费逻辑,所在机器就宕机了,导致内存中的消息丢失。

参考解决方案是在消费端开启手动 ACK 模式。RabbitMQ 默认采用自动 ACK 机制,在没有处理业务逻辑之前,消费者就会告知消息队列已经成功收到消息,这种方式并不符合我们的预期。

以 SpringBoot 的配置方式为例,有两种配置手动 ACK 的方式:

(1)yml文件修改全局确认模式,示例:

spring.rabbitmq.listener.simple.acknowledge-mode=manual

(2)在自动注入 RabbitListenerContainerFactory 时开启手动ACK,示例:

@Bean
public RabbitListenerContainerFactory<?> rabbitListenerContainerFactory(ConnectionFactory connectionFactory){
    //1. 创建工厂
    SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
    factory.setConnectionFactory(connectionFactory);
    factory.setMessageConverter(new Jackson2JsonMessageConverter());
    //2. 设置手动ACK模式,即AcknowledgeMode.MANUAL
    factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);             
    return factory;
}

3. 小结

本章节介绍了 RabbitMQ 作为消息队列,容易产生消息丢失的三种场景,以及针对每种场景的关键解决方案,从性质上可以分为持久化和消息确认机制。抛开题目本身来说,建议候选人通过本地环境实战来体验每种解决方案的具体编码,加强对方案的理解。

1. 前言

上个小节中我们介绍了 RabbitMQ 中如何防止消息丢失,即保证消息发送的 At Least Once 性质,除此之外,如何防止消息被重复消费,即保证消息消费的 Exactly Once 性质,也是业务逻辑中需要考虑的问题。

2. 消息消费顺序

面试官提问:业务中使用了 RabbitMQ 消息队列,如何保证消息的顺序消费?

题目解析

保证消息的顺序消费是业务场景下经常面临的挑战,可能在面试中会涉及到一些实战场景,例如电商的下单逻辑,在用户下单之后,会发送创建订单和扣减库存的消息,我们需要保证扣减库存在创建订单之后执行。

在MQ层面支持消息的顺序消费是一件开销很大的操作,例如使用事务,所以除非特定场景,一般不在 RabbitMQ 消息传输底层支持顺序。在上层即应用层处理业务逻辑是常规操作,有两种通用解决方案:

(1)同步发送消息:将消息发送从异步模式切换为同步模式,例如先发送创建订单消息,当创建订单的下游消费者发送ACK确认成功消费后,再发送扣减库存的消息;(2)消息实体增加冗余字段:例如增加 version(版本号)、 msg_id(消息id),保证在扣减库存时,对应 msg_id 的订单已经创建成功,实战中配合Redis等缓存协助判断。

3. 消息重复消费

面试官提问:RabbitMQ 如何保证消息不会被重复消费?

题目解析:

所有的消息队列都要保证同一条消息不会被重复消费,RabbitMQ 重复消费消息的可能场景主要有两种:

(1)生产者重复发送消息:生产者在往消息队列发送消息时,发生了网络抖动,生产者没有收到确认信号,但是实际上消息队列已经收到了消息,超过一定时间后生产者会重新发送消息,这时一条消息被发送了两次;(2)消费者重复接受消息:消费者成功消费消息后,发生了网络抖动,消息队列没有收到确认信号,超过一段时间后会重新给消费者投递相同的消息,同一条消息即存在被消费两次的可能。

通用解决方案是在消息实体中添加全局唯一的id,例如 msg_id(消息ID),在业务逻辑层保证消息的幂等性,具体参考步骤:

(1)消费者在收到消息之后,根据 msg_id 从缓存/数据库中查询是否存在已有消息;

(2)如果不存在已有消息,那么消费之后,将 msg_id 对应的消息实体或者序列化对象写入缓存/数据库;

(3)如果存在已有消息,说明这条消息已被消费过,丢弃消息并且打一条告警日志。

并且可以根据重复消费的容忍程度以及性能要求选择使用缓存还是使用数据库,如果对判断的速度要求高,可以使用 Redis 作为缓存;如果对判断的稳定性和鲁棒性要求高,使用数据库存储消息实体,同时将 msg_id 作为数据库表的唯一键,插入重复记录一定会抛出异常,避免数据库因为并发问题产生脏数据,保证了消息消费的不可重复性。

4. 小结

本章节介绍了 RabbitMQ 中最常见的重复发送消息的实际场景,并且给出了添加全局唯一 ID 的通用性解决方案,候选人需要理解通过全局 ID 解决重复消息的核心逻辑,准备时间充裕的情况可以在本地环境编码实现上述流程。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多