消息中间件详解

191 阅读21分钟

1. 什么是消息队列?

消息队列(Message queue,简称MQ),是指利用高效可靠的消息传递机制进行与平台无关的数据交流,并基于数据通信来进行分布式系统的集成。消息生产者将消息发送到消息队列,至于谁要用这个参数,怎么用,生产者不管,对这个数据感兴趣的系统自己去取用即可,各个系统之间就实现了解耦。而且解耦后,增加吞吐量。

2. 消息队列分类?

  • ActiveMQ:老牌的消息中间件,国内很多公司过去运用的还是非常广泛的,功能很强大。 但是问题在于ActiveMQ在高并发、高负载以及高吞吐的复杂场景表现不够好,国内公司使用较少。
  • RabbitMQ:支撑高并发、高吞吐、性能很高,同时有非常完善便捷的后台管理界面可以使用,支持集群化、高可用部署架构、消息高可靠支持,功能较为完善。但是RabbitMQ也有一点缺陷,就是他自身是基于erlang语言开发的,所以导致较为难以分析里面的源码,也较难进行深层次的源码定制和改造,毕竟需要较为扎实的erlang语言功底才可以。
  • RocketMQ:阿里开源的,经过阿里的生产环境的超高并发、高吞吐的考验,性能卓越,同时还支持分布式事务等特殊场景。而且RocketMQ是基于Java语言开发的,适合深入阅读源码,有需要可以站在源码层面解决线上生产问题,包括源码的二次开发和改造,国内公司使用较多。
  • Kafka:专为超高吞吐量的实时日志采集、实时数据同步、实时数据计算等场景来设计。但是在传统的MQ中间件使用场景中较少采用。

然后结合公司项目情况,说下公司项目中使用的消息中间件:kafka

3. 为什么要引入消息中间件?

一般使用消息中间件,主要为了解决以下三个问题:
  • 系统解耦:假设你有个系统A,这个系统A会产出一个核心数据,现在下游有系统B和系统C需要这个数据。那简单,系统A就是直接调用系统B和系统C的接口发送数据给他们就好了。但是现在要是来了系统D、系统E、系统F、系统G,等等,几十个其他系统都需要这份核心数据呢?,需要调用多个系统的接口,或者某个系统宕机了,定位问题费事费力,系统耦合度太严重。如下图所示。

因此在上述系统架构中,就可以采用MQ中间件来实现系统解耦。 系统A就把自己的一份核心数据发到MQ里,下游哪个系统感兴趣自己去消费即可,不需要了就取消数据的消费,如下图所示。 image.png

  • 异步调用:假设你有一个系统调用链路,是系统A调用系统B,一般耗时20ms;系统B调用系统C,一般耗时200ms;系统C调用系统D,一般耗时2s,如下图所示。

现在最大的问题就是:用户一个请求过来巨慢无比,因为走完一个链路,需要耗费20ms + 200ms + 2000ms(2s) = 2220ms,也就是2秒多的时间。 但是实际上,链路中的系统A调用系统B,系统B调用系统C,这两个步骤起来也就220ms。 就因为引入了系统C调用系统D这个步骤,导致最终链路执行时间是2秒多,直接将链路调用性能降低了10倍,那此时我们可以思考一下,是不是可以将系统D从链路中抽离出去做成异步调用呢?其实很多的业务场景是可以允许异步调用的。优化之后如下: image.png

  • 流量削峰:假设你有一个系统,平时正常的时候每秒可能就几百个请求,系统部署在8核16G的机器的上,正常处理都是ok的,每秒几百请求是可以轻松抗住的。

但是如下图所示,在高峰期一下子来了每秒钟几千请求,瞬时出现了流量高峰,此时你的选择是要搞10台机器,抗住每秒几千请求的瞬时高峰吗?

那如果瞬时高峰每天就那么半个小时,接着直接就降低为了每秒就几百请求,如果你线上部署了很多台机器,那么每台机器就处理每秒几十个请求就可以了,这不是有点浪费机器资源吗?

大部分时候,每秒几百请求,一台机器就足够了,但是为了抗那每天瞬时的高峰,硬是部署了10台机器,每天就那半个小时有用,别的时候都是浪费资源的。

但是如果你就部署一台机器,那会导致瞬时高峰时,一下子压垮你的系统,因为绝对无法抗住每秒几千的请求高峰。此时我们就可以用MQ中间件来进行流量削峰。所有机器前面部署一层MQ,平时每秒几百请求大家都可以轻松接收消息。

一旦到了瞬时高峰期,一下涌入每秒几千的请求,就可以积压在MQ里面,然后那一台机器慢慢的处理和消费。

等高峰期过了,再消费一段时间,MQ里积压的数据就消费完毕了。

4. 常用消息队列

当前使用较多的消息队列有RabbitMQ、RocketMQ、ActiveMQ、Kafka、ZeroMQ、MetaMQ等,而部分数据库如Redis、MySQL也可实现消息队列的功能。

4.1 消息队列组成

  • Broker:消息服务器,作为server提供消息核心服务。
  • Producer:消息生产者,业务的发起方,负责生产消息传输给broker。
  • Consumer:消息消费者,业务的处理方,负责从broker获取消息并进行业务逻辑处理。
  • Topic:主题,发布订阅模式下的消息统一汇集地,不同生产者向topic发送消息,由MQ服务器分发到不同的订阅者,实现消息的广播。
  • Queue:队列,PTP(point-to-point:点对点)模式下,特定生产者向特定queue发送消息,消费者订阅特定的queue完成指定消息的接收。
  • Message:消息体,根据不同通信协议定义的固定格式进行编码的数据包,来封装业务数据,实现消息的传输。

4.2 消息传送模型

  1. 点对点模型(Point to Point) 使用队列(Queue)作为消息通信载体,满足生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。

点对点消息传递的一些特征如下:

  • 每条消息只有一个消费者
  • 消息的发送方和接收方没有时间依赖性。无论客户端发送消息时它是否正在运行,接收方都可以获取消息
  • 接收方确认消息并回应
  1. 发布订阅模型(Pub/Sub) 使用主题作为消息通信载体,类似于广播模式,发布者发布一条消息,该消息通过主题传递给所有的订阅者,在一条消息广播之后才订阅的用户则是收不到该条消息的。

发布/订阅消息传递具有以下特征i:

  • 每条消息可以有多个消费者。
  • 消费者要订阅指定Topic
  • 发布者和订阅者具有时间依赖性。订阅Topic的客户端只能消费在客户端创建订阅后发布的消息,并且订阅者必须继续处于活动状态才能消费消息。

4.3 消息消费方式 JMS的消息本质上是异步的:消息的生产和消费之间没有基本的时间依赖性。但是,JMS 规范在更精确的意义可以通过以下两种方式之一消费:

  • 同步: 订阅者或接收者通过调用接收方法显式地从目的地获取消息。该接收方法可以被阻塞,直到消息到达或可能超时。
  • 异步:客户端可以向消费者注册消息监听器。每当消息到达目的地时,JMS 提供者通过调用监听器的onMessage()方法来传递消息。

5 常见消息队列工作原理**

5.1 ActiveMQ

  • ActiveMQ:Apache下的一个子项目,它非常快速,支持多种语言的客户端和协议,而且可以非常容易的嵌入到企业的应用环境中,并有许多高级功能。 image.png 消息生产者
public class JmsProduce {
    private static final String ACTIVEMQ_URL = "tcp://localhost:61616";
    private static final String QUEUE_NAME = "welcome_into_activemq_world";
    public static void main(String[] args) throws JMSException {
        //1.创建连接工厂、按照给定得url地址、采用默认用户名和编码
        ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactory(ACTIVEMQ_URL);
        //2.通过连接工厂、获得连接connection并启动访问
        Connection connection = connectionFactory.createConnection();
        connection.start();
        //3.创建会话session,    第一个叫事务/第二个叫签收
        Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
        //4 创建目的地(具体是队列Queue还是主题Topic)
        Queue queue = session.createQueue(QUEUE_NAME);
        //5.创建消息生产者
        MessageProducer messageProducer = session.createProducer(queue);
        messageProducer.setDeliveryMode(DeliveryMode.PERSISTENT);
        //6.通过使用消息生产者producer发送消息
        for (int i = 0; i < 3; i++) {
            TextMessage message = session.createTextMessage("msg----瑶瑶攻击---hp-" + i);
            messageProducer.send(message);
        }
        //7.关闭资源
        messageProducer.close();
        session.close();
        connection.close();
    }
}

启动消费者

public class JmsConsumer {
    private static final String ACTIVEMQ_URL = "tcp://localhost:61616";
    private static final String QUEUE_NAME = "welcome_into_activemq_world";
    public static void main(String[] args) throws Exception{
        ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactory(ACTIVEMQ_URL);
        //2.通过连接工厂、获得连接connection并启动访问
        Connection connection = connectionFactory.createConnection();
        connection.start();
        //3.创建会话session,    第一个叫事务/第二个叫签收
        Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
        //4 创建目的地(具体是队列Queue还是主题Topic)
        Queue queue = session.createQueue(QUEUE_NAME);
        //5.创建消费者
        MessageConsumer consumer = session.createConsumer(queue);
        while (true){
            TextMessage message = (TextMessage) consumer.receive();
            if (message != null){
                System.out.println("*****消费者接收到消息:"+message.getText());
            }else {
                break;
            }
        }
        //7.关闭资源
        consumer.close();
        session.close();
        connection.close();

    }
}

5.2 RabbitMQ

是一个在AMQP(高级消息队列协议)基础上完成的,可复用的企业消息系统,是当前最主流的消息中间件之一。本身支持很多的协议:AMQP,XMPP, SMTP,STOMP,也正是如此,使的它变的非常重量级,更适合于企业级的开发。

RabbitMQ整体架构

image.png 5.2.1 基本概念

  • Broker: 简单来说就是消息队列服务器实体
  • Exchange: 消息交换机,它指定消息按什么规则,路由到哪个队列
  • Queue: 消息队列载体,每个消息都会被投入到一个或多个队列
  • Binding: 绑定,它的作用就是把exchange和queue按照路由规则绑定起来
  • Routing Key: 路由关键字,exchange根据这个关键字进行消息投递
  • VHost: vhost 可以理解为虚拟 broker ,即 mini-RabbitMQ server。其内部均含有独立的 queue、exchange 和 binding 等,但最最重要的是,其拥有独立的权限系统,可以做到 vhost 范围的用户控制。当然,从 RabbitMQ 的全局角度,vhost 可以作为不同权限隔离的手段(一个典型的例子就是不同的应用可以跑在不同的 vhost 中)。
  • Producer: 消息生产者,就是投递消息的程序
  • Consumer: 消息消费者,就是接受消息的程序
  • Channel: 消息通道,在客户端的每个连接里,可建立多个channel,每个channel代表一个会话任务 由Exchange、Queue、RoutingKey三个才能决定一个从Exchange到Queue的唯一的线路。 5.2 RabbitMQ工作模式

5.2.1publish/subscribe发布订阅(共享资源) 在这里插入图片描述

  1. 每个消费者监听自己的队列;
  2. 生产者将消息发给broker,由交换机将消息转发到绑定此交换机的每个队列,每个绑定交换机的队列都将接收到消息。

5.2.2 routing路由模式

在这里插入图片描述

  1. 消息生产者将消息发送给交换机按照路由判断,路由是字符串(info) 当前产生的消息携带路由字符(对象的方法),交换机根据路由的key,只能匹配上路由key对应的消息队列,对应的消费者才能消费消息;
  2. 根据业务功能定义路由字符串
  3. 从系统的代码逻辑中获取对应的功能字符串,将消息任务扔到对应的队列中。 5.2.3 topic 主题模式(路由模式的一种)

在这里插入图片描述

  1. 星号井号代表通配符
  2. 星号代表多个单词,井号代表一个单词
  3. 路由功能添加模糊匹配
  4. 消息产生者产生消息,把消息交给交换机
  5. 交换机根据key的规则模糊匹配到对应的队列,由队列的监听消费者接收消息消费 5.3 RabbitMQ消息可靠性 RabbitMQ 发送消息是这样的:

image.png 消息被生产者发到指定的交换机根据路由规则路由到绑定的队列,然后推送给消费者。在这个过程中有可能会发生消息出问题的场景:

  • 生产者消息没到交换机,相当于生产者弄丢消息**
  • 交换机没有把消息路由到队列,相当于生产者弄丢消息**
  • RabbitMQ 宕机导致队列、队列中的消息丢失,相当于 RabbitMQ 弄丢消息**
  • 消费者消费出现异常,业务没执行,相当于消费者弄丢消息** 5.3.1 生产者弄丢消息 RabbitMQ 提供了确认和回退机制,有一个异步监听机制,每次发送消息,如果成功/未成功发送到交换机都可以触发一个监听,从交换机路由到队列失败也会有一个监听。只需要开启这两个监听机制即可,以 SpringBoot 整合 RabbitMQ 为例 然后编写监听回调
less
复制代码
@Configuration
@Slf4j
public class RabbitMQConfig {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void enableConfirmCallback() {
        //confirm 监听,当消息成功发到交换机 ack = true,没有发送到交换机 ack = false
        //correlationData 可在发送时指定消息唯一 id
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            if(!ack){
                //记录日志、发送邮件通知、落库定时任务扫描重发
            }
        });
        
        //当消息成功发送到交换机没有路由到队列触发此监听
        rabbitTemplate.setReturnsCallback(returned -> {
            //记录日志、发送邮件通知、落库定时任务扫描重发
        });
    }
}

实际上据我了解一些企业并不会在这两个监听里面去做重发,为什么呢?成本太高了......首先 RabbitMQ 本身丢失的可能性就非常低,其次如果这里需要落库再用定时任务扫描重发还要开发一堆代码,分布式定时任务......再其次定时任务扫描肯定会增加消息延迟,不是很有必要。真实业务场景是记录一下日志就行了,方便问题回溯,顺便发个邮件给相关人员,如果真的极其罕见的是生产者弄丢消息,那么开发往数据库补数据就行了。

5.3.2 RabbitMQ 弄丢消息

不开启持久化的情况下 RabbitMQ 重启之后所有队列和消息都会消失,所以我们创建队列时设置持久化,发送消息时再设置消息的持久化即可(设置 deliveryMode 为 2 就行了)。一般来说在实际业务中持久化是必须开的。

5.3.3消费者弄丢消息

所谓消费端弄丢消息就是消费端执行业务代码报错了,RabbitMQ 只要把消息推送到消费者就会认为消息已经被消费,就从队列中删除了,这样就相当于消息变相丢失了。RabbitMQ 给我们提供了消费者应答(ack)机制,默认情况下这个机制是自动应答,只要消息推送到消费者就会自动 ack ,然后 RabbitMQ 删除队列中的消息。启用手动应答之后我们在消费端调用 API 手动 ack 确认之后,RabbitMQ 才会从队列删除这条消息。

首先在配置文件中开启手动 ack

yaml
复制代码
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual #手动应答

然后在消费端代码中手动应答签收消息

typescript
复制代码
    @RabbitListener(queues = "queue")
    public void listen(String object, Message message, Channel channel) {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        log.info("消费成功:{},消息内容:{}", deliveryTag, object);
        try {
            /**
             * 执行业务代码...
             * */
            channel.basicAck(deliveryTag, false);
        } catch (IOException e) {
            log.error("签收失败", e);
            try {
                channel.basicNack(deliveryTag, false, true);
            } catch (IOException exception) {
                log.error("拒签失败", exception);
            }
        }
    }

5.4 消息顺序性

5.4.1 单个消费者实例

其实队列本身是有顺序的,但是生产环境服务实例一般都是集群,当消费者是多个实例时,队列中的消息会分发到所有实例进行消费(同一个消息只能发给一个消费者实例),这样就不能保证消息顺序的消费,因为你不能确保哪台机器执行消费端业务代码的速度快

image.png

所以对于需要保证顺序消费的业务,我们可以只部署一个消费者实例,然后设置 RabbitMQ 每次只推送一个消息

5.4.2 多个消费者实例

RabbitMQ 多消费实例情况下要想保证消息的顺序性,非常困难,细节非常多,一句话:我不会......

5.5 消息重复消费(幂等性)

这个也是生产环境业务中经常出现的场景,我的博客使用了 RabbitMQ ,就很奇怪经常日志上会显示消息被消费了两次。

我们解决消息重复消费有两种角度,第一种就是不让消费端执行两次,第二种是让它重复消费了,但是不会对我的业务数据造成影响就行了。

5.5.1 确保消费端只执行一次

一般来说消息重复消费都是在短暂的一瞬间消费多次,我们可以使用 redis 将消费过的消息唯一标识存储起来,然后在消费端业务执行之前判断 redis 中是否已经存在这个标识。举个例子,订单使用优惠券后,要通知优惠券系统,增加使用流水。这里可以用订单号 + 优惠券 id 做唯一标识。业务开始先判断 redis 是否已经存在这个标识,如果已经存在代表处理过了。不存在就放进 redis 设置过期时间,执行业务。

kotlin
复制代码
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("orderNo+couponId");
    //先检查这条消息是不是已经消费过了
    if (!Boolean.TRUE.equals(flag)) {
        return;
    }
    //执行业务...
    //消费过的标识存储到 Redis,10 秒过期
    stringRedisTemplate.opsForValue().set("orderNo+couponId","1", Duration.ofSeconds(10L));

5.5.2允许消费端执行多次,保证数据不受影响

  • 数据库唯一键约束

如果消费端业务是新增操作,我们可以利用数据库的唯一键约束,比如优惠券流水表的优惠券编号,如果重复消费将会插入两条相同的优惠券编号记录,数据库会给我们报错,可以保证数据库数据不会插入两条。

  • 数据库乐观锁思想

如果消费端业务是更新操作,可以给业务表加一个 version 字段,每次更新把 version 作为条件,更新之后 version + 1。由于 MySQL 的 innoDB 是行锁,当其中一个请求成功更新之后,另一个请求才能进来,由于版本号 version 已经变成 2,必定更新的 SQL 语句影响行数为 0,不会影响数据库数据。

消息(堆积)积压

所谓消息积压一般是由于消费端消费的速度远小于生产者发消息的速度,导致大量消息在 RabbitMQ 的队列中无法消费。

其实这玩意我也不知道为什么面试这么喜欢问.....既然消费者速度跟不上生产者,那么提高消费者的速度就行了呀!个人认为有以下几种思路

  • 对生产者发消息接口进行适当限流(不太推荐,影响用户体验)
  • 多部署几台消费者实例(推荐)
  • 适当增加 prefetch 的数量,让消费端一次多接受一些消息(推荐,可以和第二种方案一起用)

6 如何保证高可用的?RabbitMQ 的集群

  • RabbitMQ 是比较有代表性的,因为是基于主从(非分布式)做高可用性的,我们就以 RabbitMQ 为例子讲解第一种 MQ 的高可用性怎么实现。RabbitMQ 有三种模式:单机模式、普通集群模式、镜像集群模式。
  1. 单机模式,就是 Demo 级别的,一般就是你本地启动了玩玩儿的?,没人生产用单机模式

  2. 普通集群模式

    • 意思就是在多台机器上启动多个 RabbitMQ 实例,每个机器启动一个。
    • 你创建的 queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。
  3. 镜像集群模式

    • 这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。
    • 这样的好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。

7. 引入消息中间件之后会有什么好处以及坏处。

  • 系统可用性降低: 本来系统运行好好的,现在你非要加入个消息队列进去,那消息队列挂了,你的系统不是呵呵了。因此,系统可用性会降低;
  • 系统复杂度提高: 加入了消息队列,要多考虑很多方面的问题,比如:一致性问题、如何保证消息不被重复消费、如何保证消息可靠性传输等。因此,需要考虑的东西更多,复杂性增大。
  • 一致性问题: A 系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是 BCD 三个系统那里,BD 两个系统写库成功了,结果 C 系统写库失败了,咋整?你这数据就不一致了。

所以消息队列实际是一种非常复杂的架构,你引入它有很多好处,但是也得针对它带来的坏处做各种额外的技术方案和架构来规避掉,做好之后,你会发现,妈呀,系统复杂度提升了一个数量级,也许是复杂了 10 倍。但是关键时刻,用,还是得用的。`