消息队列,如何选择适合你程序的呢?

133 阅读20分钟

消息队列是重要的分布式系统组件,在高性能、高可用、低耦合等系统架构中扮演着重要作用。可用于异步通信、削峰填谷、解耦系统、数据缓存等多种业务场景。本文是关于消息队列(MQ)选型和常见问题的精心整理。在这篇文章中,我们将详细介绍消息队列的概念、作用以及如何选择适合自己需求的消息队列系统。

1、概述

消息队列在分布式系统中扮演着至关重要的角色,它为系统架构提供了高性能、高可用性和低耦合的特性。通过利用消息队列的能力,分布式系统可以轻松实现以下功能:

  1. 解耦:将一个流程的上下游拆解开,上游专注于生产消息,下游专注于处理消息;
  2. 广播:上游生产的消息可以轻松被多个下游服务处理;
  3. 缓冲:应对突发流量,消息队列扮演缓冲器的作用,保护下游服务,使其可以根据自身的实际消费能力处理消息;
  4. 异步:上游发送消息后可以马上返回,下游可以异步处理消息;
  5. 冗余:保留历史消息,处理失败或当出现异常时可以进行重试或者回溯,防止丢失;

2、常见消息队列

  • Kafka
  • RabbitMQ

Kafka

Kafaka 系统框架

image

image

一个 Kafka 集群由多个 Broker 和一个 ZooKeeper 集群构成,其中 Broker 是 Kafka 节点的服务器。一个消息主题 Topic 可以由多个分区 Partition 组成,这些分区物理存储在 Broker 上。为了负载均衡,同一个 Topic 的多个分区存储在多个不同的 Broker 上。为了提高可靠性,每个分区在不同的 Broker 上会存在副本。ZooKeeper 是一个分布式开源的应用程序协调服务,可以实现统一命名服务、状态同步服务、集群管理、分布式应用配置项的管理等工作。在 Kafka 中,ZooKeeper 主要有以下几个作用:

  • 注册 Broker 的过程能够确保集群在某个 Broker 出现故障时迅速获得通知。
  • 对于 Topic 的注册,它涉及到跟踪每个分区的副本所在的 Broker 节点,并记录它们作为 leader 或 follower 的身份。
  • 至于 Consumer 的注册,它负责记录消费者组的偏移量,并管理消费者与分区的对应关系,以此来平衡消费负载。

Kafka 基本术语

Producer:消息生产者。通常,消息会被发送至特定的主题。默认情况下,生产者通过轮询机制将消息均衡写入不同分区。生产者也可以根据消息的 key 值来指定写入特定分区。分区的数据分布越均匀,Kafka 的性能表现越佳。

Topic:Topic 是一个抽象概念,代表一类消息的集合。在一个 Kafka 集群中,可以有多个 Topic。生产者将消息发布到 Topic,消费者通过订阅 Topic 来接收消息。

Partition:Partition 是一个物理存储单位,每个 Topic 可以包含一个或多个 Partition。新消息以追加的方式存储在分区中,且在同一个 Partition 内部,消息保持有序。通过分区,Kafka 实现了消息的冗余、伸缩性以及并发的读写能力,显著提升了系统的吞吐量。

Replicas:每个 Partition 都有多个 Replicas(副本)。这些副本分布在不同的 Broker 上。每个 Broker 可能存储成百上千个不同 Topic 和 Partition 的副本。副本分为两种:master 副本(Leader),每个 Partition 有一个 master 副本,所有读写操作都通过 master 副本进行;follower 副本(Follower)仅同步 master 副本的内容,不处理客户端请求。如果 master 副本发生故障,一个 follower 副本将迅速被选举为新的 master。

Consumer:消息消费者。消费者通过订阅 Topic 来接收消息,并按照一定的顺序消费。Kafka 确保每个分区只能由一个消费者消费。

Offset:偏移量是一个递增的整数,用于标识消息在分区内的位置。Kafka 在消息写入时会自动添加偏移量。每个分区的偏移量是唯一的。消费者在消费过程中会记录最后处理的偏移量,即使消费者关闭,偏移量也不会丢失,重新启动后可以从上次的位置继续消费。

Broker:Broker 是独立的 Kafka 服务器。如果一个 Topic 有 N 个 Partition,而集群有 N 个 Broker,那么每个 Broker 将存储该 Topic 的一个 Partition。如果 Topic 有 N 个 Partition,而集群有 N+M 个 Broker,那么 N 个 Broker 存储该 Topic 的 Partition,剩下的 M 个 Broker 不存储。如果 Topic 的 Partition 数量超过 Broker 数量,可能会导致数据不均衡,这种情况在实际生产中应尽量避免。

RabbitMQ

RabbitMQ 系统框架

image

image

RabbitMQ 基于 AMQP 协议来实现,主要由 Exchange 和 Queue 两部分组成,然后通过 RoutingKey 关联起来,消息投递到 Exchange 然后通过 Queue 接收。

RabbitMQ 基本术语

经纪人(Broker) :负责接纳客户端的连接,并提供 AMQP 消息队列的管理以及路由功能。

虚拟主机(Virtual Host) :作为一种抽象的实体,它是进行权限管理的最小单元。每个虚拟主机内可以包含多个交换机和队列。

交换机(Exchange)

  • 负责接收来自消息生产者的消息。
  • 根据不同的交换机类型(ExchangeType)将消息转发到相应的队列。
  • 常见的交换机类型包括直接(direct)、广播(fanout)和主题(topic)。

消息队列(Message Queue)

  • 用于存储待消费的消息。

消息(Message)

  • 由头部(Header)和主体(Body)两部分构成。
  • 头部包含生产者设置的各种属性,如消息的持久化、目标队列、优先级等。
  • 主体包含消息的具体内容。

绑定(Binding)

  • 实现交换机与消息队列之间的连接。
  • 服务器运行时,会创建路由表,记录消息队列的条件和绑定键(BindingKey)。
  • 交换机收到消息后,会根据消息头部的 BindingKey 和路由表,以及交换机类型,将消息路由至正确的消息队列。

连接(Connection)

  • 指的是经纪人与客户端之间的 TCP 网络连接。

通道(Channel)

  • 为了传输消息,需要在连接上创建信道。
  • 根据 AMQP 协议,只有通过信道才能执行 AMQP 命令。
  • 一个连接可以包含多个信道。
  • 建立信道的目的是为了节约资源,避免每个客户端或线程都维护一个 TCP 连接。
  • 通常建议共享连接,但 RabbitMQ 建议不同客户端线程不要共享信道,以确保消息的串行发送。

命令(Command)

  • 客户端通过发送 AMQP 命令与 AMQP 服务器进行交互。

3、选型要点

3.1 选择标准要点

  1. 消息顺序性:确认消息队列是否能够保证消息在消费时的顺序与发送时一致。

  2. 可伸缩性:

    • 扩容能力:在面对性能瓶颈,如消息消费速度慢时,系统是否能够迅速进行扩容。
    • 缩容能力:当消费队列数量过多,造成资源浪费时,系统是否支持减少资源使用进行缩容。
  3. 消息保留:检查消息在成功消费后,是否还能在消息队列中继续保留。

  4. 容错机制:确保当消息消费失败时,存在机制能够保证消息最终能够成功消费,例如确保异步退款消息的准确消费。

  5. 消息可靠性:评估是否存在消息丢失的风险,例如在两个消息中,只有一条消息被消费,另一条消息丢失。

  6. 消息时序特性:

    • 消息存活时间:考虑消息在队列中的最长存活时间。
    • 延迟消息:考察消息队列是否支持延迟发送功能。
  7. 吞吐量:确定消息队列能够支持的最大并发处理数量。

  8. 消息路由订阅:根据设定规则,消费者是否只能订阅符合特定路由规则的消息,例如消费者可以选择仅订阅某一类消息,而忽略其他类型。

3.2、选型对比

d09d856baedc9bffa6061bdb3675be33

d09d856baedc9bffa6061bdb3675be33

4、功能剖析

4.1 消费模式差异

在消息队列领域,客户端获取消息的方式主要分为两种:拉取(Pull)和推送(Push)。Kafka 和 RocketMQ 采用长轮询的拉取方式来获取消息,而 RabbitMQ、Pulsar 和 NSQ 则使用推送方式。

拉取方式的消息队列,如 Kafka 和 RocketMQ,更适合于需要高吞吐量的应用场景。这种方式允许消费者根据自身的处理能力来控制消息的接收速度,从而实现流量的自我调节。

相比之下,推送方式的消息队列,例如 RabbitMQ、Pulsar 和 NSQ,提供了更好的实时性。但是,为了应对消费者处理能力不足的情况,它们需要具备有效的流量控制机制(例如 backpressure)。这种机制能够在消费者负载过高时,自动减少消息的推送频率,以防止消费者端系统过载。

4.2、延迟队列

消息的延迟投递功能允许在某些业务需求中,消费者不必立即接收到消息,而是在指定的延时之后才能获取并处理该消息。这种延迟投递通常分为两种类型:基于单条消息的延迟和基于整个队列的延迟。

基于单条消息的延迟投递是指为每条消息单独设置一个延迟时间。当新消息进入队列时,系统会根据这些延迟时间对消息进行排序。然而,这种方法可能会对系统性能产生较大的影响,因为需要不断地对消息进行排序处理。

另一种方法是基于队列的延迟投递,这种方法为整个队列设置统一的延迟级别。在这种模式下,队列中的所有消息都共享相同的延迟时间。这样做的好处是避免了因排序延迟时间而造成的性能损耗,系统可以通过定期扫描队列来投递那些已经达到延迟时间的消息。

延迟消息的使用场景比如异常检测重试,订单超时取消等,例如:

  • 服务请求异常,需要将异常请求放到单独的队列,隔 5 分钟后进行重试;
  • 用户购买商品,但一直处于未支付状态,需要定期提醒用户支付,超时则关闭订单;
  • 面试或者会议预约,在面试或者会议开始前半小时,发送通知再次提醒。

Kafka 目前不提供延迟消息的功能。而 Pulsar 则支持以秒为单位的延迟消息功能。在 Pulsar 中,所有需要延迟投递的消息都会被 Delayed Message Tracker 记录其索引位置。

当消费者准备消费消息时,它会首先查询 Delayed Message Tracker,检查是否有已经达到延迟时间的消息需要投递。如果存在已到期的消息,消费者会根据 Tracker 提供的索引信息,从消息队列中提取相应的消息进行处理。如果所有延迟消息都还未到期,则消费者将继续处理常规消息。

对于需要长时间延迟的消息,Pulsar 会将这些消息存储在磁盘上,以减少内存占用。当这些消息的延迟时间即将结束时,系统会将它们预先加载到内存中,以便及时进行投递。

image

image

RabbitMQ 本身不自带延迟消息功能,但可以通过安装名为 rabbitmq_delayed_message_exchange 的插件来实现延迟消息投递。安装此插件后,RabbitMQ 将支持以下功能:

  1. 延迟交换:创建一个特殊的交换器,用于处理带有延迟属性的消息。
  2. 消息延迟:允许用户为消息设置一个延迟时间,消息在这个时间内不会被投递给消费者。
  3. 到期处理:当消息的延迟时间到期后,RabbitMQ 会自动将消息从延迟队列移动到正常的交换器和队列中,然后投递给消费者。

安装插件后,用户可以利用这个特性来实现业务场景中对延迟消息的需求。例如,可以用于订单超时未支付自动取消、定时任务调度等场景。

4.3 死信队列

image

image

当消息因为某些原因无法成功投递时,为防止消息丢失,通常会将其放入一个特定的队列中,这个队列被称为死信队列(DLQ)。与此同时,还有一个与之相关的“回退队列”概念。

设想一下,如果消费者在处理消息时遇到异常,它将不会对这次消费进行确认(Ack)。这意味着消息处理失败,并且消息会被重新放回队列的前端,导致消息不断被重新处理和回滚,从而形成无限循环。

为了避免这种情况,可以为每个队列设置一个回退队列。回退队列和死信队列一样,都是为了处理异常情况而设计的机制。在实际操作中,回退队列的功能通常由死信队列和重试队列共同承担。

  • 死信队列:用于存储那些无法正常处理的消息,通常是因为消息过期或无法被消费。
  • 重试队列:用于存储那些因为消费者处理异常而需要重新处理的消息。

通过这样的设计,可以确保消息不会因为消费者的错误而被无限期地阻塞在队列中,同时也为消息的最终处理提供了保障。

Kafka 没有死信队列,通过 Offset 的方式记录当前消费的偏移量。 RabbitMQ 是利用类似于延迟队列的形式实现死信队列。

4.4、优先级队列

有一些业务场景下,我们需要优先处理一些消息,比如银行里面的金卡客户、银卡客户优先级高于普通客户,他们的业务需要优先处理。如下图:

image

image

优先级队列与标准的先进先出(FIFO)队列不同,它允许具有更高优先级的消息优先被消费者处理,从而为不同重要性的消息提供差异化的服务保障。然而,这种优先级机制的有效性有一个前提条件:

如果消费者处理消息的速度超过了生产者发送消息的速度,并且消息中间件服务器(通常简称为 Broker)中没有积压的消息,那么为消息设置优先级实际上就没有太大的意义。这是因为生产者一旦发送了一条消息,它几乎立刻就会被消费者处理掉。在这种情况下,Broker 中几乎总是只有一条消息在等待处理,对于这样单一的消息,优先级的概念变得无关紧要。

因此,优先级队列在消息处理速度远低于生产速度,或者 Broker 中存在消息积压的情况下更为有效。在这些情况下,优先级可以帮助确保更重要的消息得到及时处理,而不是简单地按照它们到达的顺序。

Kafka不支持优先级队列,可以通过不同的队列来实现消息优先级。 **RabbitMQ **支持优先级消息。

4.5、消息回溯

通常情况下,一条消息在被消费者处理之后,就会被标记为已消费,消费者无法再次获取到这条消息。然而,消息回溯功能提供了一种机制,使得消费者在处理完消息之后,仍然能够重新访问并消费之前已经消费过的消息。

消息丢失是一个常见问题,其原因可能是消息中间件的缺陷,也可能是使用方的错误操作,通常难以确定。如果消息中间件支持消息回溯功能,那么可以通过回溯消费来重现“丢失”的消息,从而帮助定位问题的根源。

消息回溯的应用不仅限于解决消息丢失的问题。它还可以用于:

  • 索引恢复:在索引损坏或需要重建的情况下,通过回溯可以重新构建索引。
  • 本地缓存重建:当本地缓存失效时,可以通过回溯消费来恢复缓存数据。
  • 业务补偿:在某些业务流程中,如果需要对之前的操作进行补偿或修正,可以通过回溯消费来实现。

总的来说,消息回溯功能为消息处理提供了灵活性和容错能力,有助于提高系统的稳定性和可靠性。

Kafka 支持消息回溯,可以根据时间戳或指定 Offset,重置 Consumer 的 Offset 使其可以重复消费。

RabbitMQ 不支持回溯,消息一旦标记确认就会被标记删除。

4.6、消息持久化

流量削峰是消息队列系统核心功能之一,它主要依赖于消息队列的缓冲能力。可以说,如果一个消息队列系统没有能力缓冲消息,那么它就无法被视为一个成熟的解决方案。消息缓冲可以分为两种类型:内存缓冲和磁盘缓冲。

通常,磁盘的存储容量远大于内存,因此,采用磁盘缓冲时,其缓冲能力理论上可以等同于整个磁盘的容量。此外,消息缓冲还为消息队列系统提供了数据冗余存储的优势。

  • 内存缓冲:提供快速的消息处理能力,但受限于物理内存的大小。
  • 磁盘缓冲:提供更大的存储空间,适用于处理大规模数据,但可能在处理速度上不如内存缓冲。

消息缓冲不仅有助于平滑流量高峰,减少系统压力,还能在出现故障时提供数据恢复的能力,确保消息的持久性和系统的可靠性。

Kafka直接将消息刷入磁盘文件中进行持久化,所有的消息都存储在磁盘中。只要磁盘容量够,可以做到无限消息堆积。

RabbitMQ 是典型的内存式堆积,但这并非绝对,在某些条件触发后会有换页动作来将内存中的消息换页到磁盘(换页动作会影响吞吐),或者直接使用惰性队列来将消息直接持久化至磁盘中。

4.7、消息确认机制

消息队列系统必须跟踪消费者的消费状态,以确保每条消息都被正确处理。在使用推送(push)模式的消息队列中,通常采用对单条消息进行确认的机制。

当消费者成功处理了一条消息后,它会向消息队列发送一个确认信号。如果消费者未能成功处理消息,或者在处理过程中遇到问题而未能发送确认信号,消息队列将认为该消息未被确认。

212adc65abb5b61d2fdbdb372e5402b3

212adc65abb5b61d2fdbdb372e5402b3

对于这些未确认的消息,消息队列会采取以下措施之一:

  1. 延迟重新投递:消息队列会将未确认的消息暂时搁置,经过一定的延迟时间后再次尝试投递给消费者。
  2. 进入死信队列:如果消息在多次尝试投递后仍未被确认,它将被转移到死信队列。死信队列用于存储无法正常处理的消息,以便进行后续的分析或特殊处理。

这种确认机制有助于确保消息的可靠性和系统的健壮性,防止消息在处理过程中丢失或被错误地处理。

Kafka 通过偏移量(Offset)来实现消息的确认机制。具体来说,它包括以下几个方面:

  1. 发送方确认机制

    • ack=0:生产者不等待任何确认,直接发送消息,不关心消息是否成功写入。
    • ack=1:生产者在消息成功写入首领分区(Leader Replica)后,会收到一个成功的确认。
    • ack=all:生产者在消息成功写入所有分区副本(ISR)后,才会收到成功的确认。
  2. 接收方确认机制

    • 消费者可以通过自动或手动的方式提交偏移量,以确认消息已被处理。
    • 在早期版本的 Kafka 中,偏移量信息是提交给 Zookeeper 的,这会增加 Zookeeper 的负载。
    • 而在更新版本的 Kafka 中,偏移量信息直接提交给 Kafka 服务器自身管理,不再依赖 Zookeeper 来管理偏移量,从而提高了集群的性能和稳定性。

通过这种方式,Kafka 能够确保消息的可靠性和系统的高效性。

RabbitMQ 通过以下机制来确保消息的可靠传递

  1. 发送方确认机制

    • 当消息成功投递到所有符合条件的队列时,发送方会收到一个成功的确认。
    • 如果消息和队列都被设置为持久化,那么在消息写入磁盘之后,发送方会收到成功的确认。
    • RabbitMQ 支持批量确认和异步确认,以提高消息处理的效率。
  2. 接收方确认机制

    • 如果 autoAck 设置为 false,则消费者需要显式地确认每一条消息,否则消息不会被认为已处理。
    • 如果 autoAck 设置为 true,则消息在被推送给消费者时自动确认。

autoAckfalse 时,RabbitMQ 队列会区分两种类型的消息:

  • 等待投递给消费者的消息。
  • 已经投递给消费者但尚未收到确认的消息。

如果消费者在没有发送确认信号的情况下断开连接,RabbitMQ 会将未确认的消息重新放入队列中,等待再次投递给原来的消费者或其他消费者。

未确认的消息不会设置过期时间。只要消费者没有断开连接,RabbitMQ 会持续等待确认信号。这意味着消费者可以有很长的处理时间来处理每条消息,确保消息能够被正确处理。

通过这种方式,RabbitMQ 能够灵活地处理各种消息传递场景,同时确保消息的可靠性和系统的稳定性。

那么选择什么呢?

Kafka和MQ各有优势,开发者应根据实际业务场景和需求进行选择。以下是一些建议:

  1. 如果您的应用场景需要处理大规模数据,建议选择Kafka。
  2. 如果您的应用场景更注重数据可靠性和事务性,可以考虑使用MQ。
  3. 在云原生和微服务架构中,Kafka具有更好的适应性。
  4. 对于中小型企业,MQ的易用性和功能丰富性可能更适合。