消息队列中间件——消息模型

278 阅读25分钟

本篇分享的内容,来源于工作中的经验总结和学习时的知识归纳。不少东西是业务层面的内容,具体实现方式,不同的产品,会有不同的实现方式,比较琐碎。所以,本篇点到即止。

简述

消息队列,是中国现代技术圈市场上最能赚钱的分布式中间件,没有之一。

这也是为什么现代消息队列产品多的原因之一,每家公司可能都想去分一杯,所以造了很多很多轮子。市面上,可能听说过名字的:

  1. 老牌经典的 ActiveMQ,数十年前的唯一选择。
  2. 轻量迅捷的 RabbitMQ,号称是世界上使用最广泛的开源消息队列,客户端支持的最多。
  3. 大名鼎鼎的 Kafka,生态最好,没有之一。
  4. 品学兼优的 RocketMQ,阿里开源,国内品牌。
  5. 新兴产品的 Pulsar,还在成长,与其他消息队列最大的不同是,Pulsar 采用存储和计算分离的设计。

这些消息队列,都有哪些异同?如果要造个轮子,业务模型,以及基本思路有哪些?在技术方案中适合哪些场景?我们工作中使用的 AWS SQS 和 SNS 与这些又有哪些类似的地方?

这就是本次分享的主要内容。

使用场景

软件工程中没有银弹,不存在可以解决一切问题的设计、架构或软件,每一个软件系统,它都是独一无二的,除非,砍需求。

比如,都是 RPC 和消息队列都可以解决远程通讯,一些场景可能 RPC 更加合适,一些场景可能消息队列更加合适。那具体使用那种方式,就需要精心对比需求:

  1. 异步处理。不“在意”返回结果的,又想请求必达的,更适合消息队列。比如邮件发送,短信通知,统计数据的更新。
  2. 流量控制。通常会说是“削峰填谷”,消息队列作为流量水库,来控制下游的正常服务水平。
  3. 系统解耦。比如说,一个订单过来之后:
  4. 支付系统需要发起支付流程;
  5. 风控系统需要审核订单的合法性;
  6. 客服系统需要给用户发短信告知用户;
  7. 经营分析系统需要更新统计数据;
  8. ......

当然,关于上述的第 3 点,可能还需要考虑,如果订单不成功怎么办?也就是如何保证事务?这些就需要根据具体需求细节,进行消息队列的选型。

消息队列的选型

技术选型,一般这些方向上的制定,也要具体问题,具体分析。

  1. 公司有自己的轮子。此时,选其他任何产品,都是对公司轮子的背叛。
  2. 有配套的服务体系,那就选配套的服务体系。

抛开上述两种情况,如果要在已知的市面流行的消息队列中选择,至少要有一些约束:

  1. 首先,产品必须开源。开源意味着,如果有一天遇到了一个影响系统业务的 Bug,至少还有机会通过修改源代码来迅速修复或规避这个 Bug,解决系统火烧眉毛的问题,而不是束手无策地等待开发者随机发布的下一个版本来解决。
  2. 产品必须是近年来比较流行并且有一定社区活跃度。流行的好处是,只要使用场景不太冷门,遇到 Bug 的概率会非常低,因为大部分可能遇到的 Bug,其他人早就搞定了。
  3. 消息的可靠传递:确保不丢消息。
  4. 支持集群,确保不会因为某个节点宕机导致服务不可用。
  5. 性能:具备足够好的性能,能满足绝大多数场景的性能要求。

也有一些直接的建议:

  • 处理在线业务,比如在交易系统中用消息队列传递订单,RocketMQ 的低延迟和金融级的稳定性很有保障。
  • 处理海量的消息,像收集日志、监控信息或是前端的埋点这类数据,或是应用场景大量使用了大数据、流计算相关的开源产品比如 Flink 之类,那 Kafka 最为适合。

如果以上内容考虑完,和针对的场景还不符合,不能清楚的知道怎么选,直接选择“最熟悉的”,用的顺手的,因为现代消息队列,几乎都可以满足大部分的业务场景。

当然,如果要准确的选最合适产品,了解这些产品的设计思想和实现思路,以及这些产品底层的一些技术原理,是相当重要的一部分内容。

消息业务模型

模型,是产品设计和实现的起点。

工科里面,无论是软件,还是硬件,还是偏理论的数学,都经常提到:模型。比如数据库,E-R 模型图是什么;硬件内存,电路模型是什么?消息队列也一样,不同的产品,有不同的消息模型。

通常,现代消息模型中,包括两类:队列模型发布订阅模型

这两类模型不是一开始就是这么设计的,说大一点,也是架构演进出来的。但是在演进的过程中,出现了非常多的名词,这对初学者,或者不追问底层原理的工程师来讲,很让人模糊。包括,但不限于:队列,主题,分区,Broker,Producer,Consumer,Publisher,Subscriber......

为什么会出现这么多名词?没有标准,然后好多人都想自己来定义一套标准,结果名词一大堆,结果意思差不多。曾经也有一些国际组织尝试制定过消息相关的标准,比如早期的 JMS 和 AMQP。但标准的进化跟不上消息队列的演进速度,这些标准实际上已经被废弃了,当然,一些“爷爷辈”级别的项目或者系统,还是承认这些标准的。

这一部分内容,通过两种不同模型的区分,来理解这些名词。

队列模型

数据结构中,队列是先进先出(FIFO, First-In-First-Out)的线性表(Linear List)。在具体应用中通常用链表或者数组来实现。队列只允许在后端(称为 rear)进行插入操作,在前端(称为 front)进行删除操作。

最初的消息队列,就是一个严格意义上的队列。要求是,在消息入队出队过程中,需要保证这些消息严格有序。早期的消息队列,就是按照“队列”的数据结构来设计的。

如上图,生产者(Producer)发消息就是入队操作,消费者(Consumer)收消息就是出队也就是删除操作,服务端存放消息的容器自然就称为“队列”。

队列模型的特点:

  1. 如果有多个生产者往同一个队列里面发送消息,这个队列中可以消费到的消息,就是这些生产者生产的所有消息的合集。消息的顺序就是这些生产者发送消息的自然顺序。
  2. 如果有多个消费者接收同一个队列的消息,这些消费者之间实际上是竞争的关系,每个消费者只能收到队列中的一部分消息,也就是说任何一条消息只能被其中的一个消费者收到。

队列模型的不足:

  1. 如果需要将一份消息数据分发给多个消费者,要求每个消费者都能收到全量的消息,例如,对于一份订单数据,风控系统、分析系统、支付系统等都需要接收消息。这个时候,单个队列就满足不了需求。

如何解决呢?一个可行的解决方式是,为每个消费者创建一个单独的队列,让生产者发送多份。显然有一点“蠢”,(蠢的做法,一般都比较清晰易懂,比如 RabbitMQ)同样的一份消息数据被复制到多个队列中:

  1. 浪费资源。
  2. 生产者必须知道有多少个消费者。为每个消费者单独发送一份消息,这实际上违背了消息队列“解耦”这个设计初衷。

采取这样方式的,就有:RabbitMQ, 在 RabbitMQ 中,Exchange 位于生产者和队列之间,生产者并不关心将消息发送给哪个队列,而是将消息发送给 Exchange,由 Exchange 上配置的策略来决定将消息投递到哪些队列中。

同一份消息如果需要被多个消费者来消费,需要配置 Exchange 将消息发送到多个队列,每个队列中都存放一份完整的消息数据,可以为一个消费者提供消费服务。

为了解决“蠢”这个问题,演化出了另外一种消息模型:“发布 - 订阅模型(Publish-Subscribe Pattern) ”。

发布订阅模型

在发布 - 订阅模型中,消息的发送方称为发布者(Publisher),消息的接收方称为订阅者(Subscriber),服务端存放消息的容器称为主题(Topic)。

发布者将消息发送到主题中,订阅者在接收消息之前需要先“订阅主题”。“订阅”在这里既是一个动作,同时还可以认为是主题在消费时的一个逻辑副本,每份订阅中,订阅者都可以接收到主题的所有消息。

(消费逻辑副本,这里的实现方式,可以是订阅者在消费的时候,由消费者或者 Broker 本身记录好消费的 OFFSET ,当然也有别的实现方式

队列模式和发布 - 订阅模式是并存的,有些消息队列同时支持这两种消息模型,比如 ActiveMQ。

这两种模型,生产者=发布者,消费者=订阅者,队列=主题,没有本质的区别。

最大的区别在于:一份消息数据能不能被消费多次的问题

在发布 - 订阅模型中,如果只有一个订阅者,那和队列模型就基本一样。所以发布 - 订阅模型在功能层面可以兼容队列模型

现代的消息队列产品使用的消息模型大多是这种发布 - 订阅模型。

RocketMQ 和 Kafka 的消息模型

RocketMQ,Kafka 是一样的消费模型,只是有些部分名字不一样,这里以 RocketMQ 为例,看一下它的发布-订阅模型的样子:

为什么它的发布订阅模型中,在 Topic 里面还有队列(Queue)的概念?队列在 RocketMQ 中的作用是什么呢?为了并行消费,提升消费效率(这其实牺牲了消息有序性)。

这个也与“消费-确认”机制有关,“消费-确认”机制虽然确保消息不会在传递过程中由于网络或服务器故障丢失。但是也带来一个问题:为了确保消息的有序性,在某一条消息被成功消费之前,下一条消息是不能被消费的,否则就会出现消息空洞,违背了有序性这个原则

这样的话,每个主题在任意时刻,至多只能有一个消费者实例在进行消费,那就没法通过水平扩展消费者的数量来提升消费端总体的消费性能。为了解决这个问题,RocketMQ 在主题下面增加了队列的概念。

如此以来,每个主题包含多个队列,通过多个队列来实现多实例并行生产和消费。但是,需要注意的是,这样做,消息就只能在队列上保证消息的有序性,主题层面无法保证消息的严格顺序。

而 Kafka 的消息模型一样,只是在主题中,Queue 的名字不叫队列,而是叫做 Partition,功能和含义,是没有任何区别的。

至此,Topic,Broker,Producer,Consumer,Publisher,Subscriber......等这些名词,也就都有了眉目。

消息可靠性

一般说消息可靠性,通常说的都是 2 个点:

  1. 服务如何不中断?
  2. 消息如何不丢失?

服务如何不中断

首先明确一点,大规模停电,那没办法。

其次,现代的分布式系统中,保证服务不中断的方法几乎是相通的,用道家思想讲就是:大道殊途同归。那就是:冗余。

比如: Redis 有哨兵集群,MySQL 有主从复制,包括国内常见的分布式系统,叫得出名字的厂,没有谁家的服务是单机的。消息队列也一样,也有 Broker 的副本主从机制,比如 Kafka 集群的 Controller 切换。

这些主从机制,通常有延伸到选举算法,常见的比如:Paxos,Raft 等等,根据算法,又会衍生出很多产品,比如 zookeeper。

消息尽量不丢失

一般采取就是消息确认机制

  • 对于生产者。从生产者到 Broker 需要进行发送成功确认,生产者没发送成功,那就重发。
  • 对于消费者。从消费者到 Broker 需要有消费成功确认。消费者没消费成功,那么 OFFSET 不变更,就重新消费。
  • 对于 broker本身。就是调整好刷磁盘的时机。这部分原理和 Redis ,MySQL 之类的几乎一样,都有持久化数据的时机,根据业务的重要程度以及成本,合理的制定持久化策略。

处理分布式事务

在实际应用中,比较常见的分布式事务实现有 2PC(Two-phase Commit,也叫二阶段提交)、TCC(Try-Confirm-Cancel) 和事务消息。

消息队列中,通常要判断这个产品是否支持事务消息。

但是要注意一点,事务消息适用的场景主要是那些需要异步更新数据,并且对数据实时性要求不太高的场景。 比如:

  1. 在创建订单后,如果出现短暂的几秒,购物车里的商品没有被及时清空,也不是完全不可接受的,只要最终购物车的数据和订单数据保持一致。
  2. 统计数据延迟。但是最终数据要一致。

事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。当然,不同的消息队列有不同的实现细节,通常,都是使用“半消息”这个思路,不过半消息,实际和两阶段提交,理论上没啥区别。

如上图,如果在第 4 步骤,出现问题,那么这个分布式事务的解决,那就不可靠了。RocketMQ 好一点,有事务反查机制,但是 Kafka,可就直接报异常了,用户自己处理。

当然,不同的厂,有不同的解决方案。

幂等消费

在 MQTT 协议(一种基于发布/订阅(publish/subscribe)模式的"轻量级"通讯协议)中,给出了三种传递消息时能够提供的服务质量标准,这三种服务质量从低到高依次是:

  • At most once: 至多一次。消息在传递时,最多会被送达一次。换一个说法就是,没什么消息可靠性保证,允许丢消息。一般都是一些对消息可靠性要求不太高的监控场景使用。
  • At least once: 至少一次。消息在传递时,至少会被送达一次。也就是说,不允许丢消息,但是允许有少量重复消息出现。
  • Exactly once: 恰好一次。消息在传递时,只会被送达一次,不允许丢失也不允许重复,这个是最高的等级。

现在常用的绝大部分消息队列提供的服务质量都是 At least once,包括 RocketMQ、RabbitMQ 和 Kafka 。也就是说,消息队列很难保证消息不重复。

Amazon SQS 也同样, 标准队列提供至少一次传送,因此每条消息至少会传送一次。 FIFO 队列提供一次性处理,因此每条消息仅传送一次,并且在使用器处理并删除它之前始终可用。 队列中不会引入重复消息。

所以,幂等,一定是在使用消息队列时要时刻注意的事情。消费者,一般都要实现幂等消费,实现幂等的方法:

  • redis 等基于请求 token 的检查
  • 数据库唯一键确认

消息积压

在使用消息队列遇到的问题中,消息积压是最常遇到的问题,并且,这个问题不太好解决。

因为不同类型的消息队列,其业务模型不同,实现细节不同,处理方式也就不同。

所以,这样的问题,通常需要有合理的思路。

消息积压的直接原因,一定是系统中的某个部分出现了性能问题,来不及处理上游发送的消息,才会导致消息积压。而这“某个部分”,通常就是消费端的逻辑。

消费端的性能优化

  1. 业务逻辑优化:同步改异步
  2. 扩容

但是通过水平扩容,增加消费端的并发数来提升总体的消费性能。需要注意的一点是,在扩容 Consumer 的实例数量的同时,必须同步扩容主题中的分区(也叫队列)数量,确保 Consumer 的实例数和分区数量是相等的。

这是因为,大多数的消息队列,通常都是一个消费者和队列,是有强映射关系的。比如 Kafka 的 Partition,如果只是扩容消费者的话,不能解决当前有积压问题的 Broker 的,此时还需要做 Partition 和消费者的 Rebalance,但是这样的 Rebalance 会影响到线上的消费情况。

所以,对于 Kafka 的消费积压,更多的做法是,新开一个 Topic,设置更多的 Partition 和更大的消费组。然后将积压的消息,在消费者端直接将消息传到新的 Topic 上,让更多的新消费者来处理积压的消息。

不过,在一些厂的自研消息队列中,会用自己的方式解决 Rebalance 带来的问题。比如 QMQ,在 Partition 和 Consumer Group 之间,加了一个中间层,做自动映射,这样就可以无伤扩容,达到效果。但是这样也会带来别的问题,比如中间层的维护问题。

AWS SQS 和 SNS 和上面这些消息队列有什么异同?

SQS

消息模型

SQS 是队列模型,和下图几乎是一致

中间的这个队列 Queue 就是 SQS 用来维护消息的 broker。

队列类型

当然,SQS 提供了两种队列类型:

  • 标准类型:最大限度保证消息的顺序。
  • FIFO 类型:严格保证消息顺序,先进先出,跟数据结构中的队列特征保持一致。

消息可靠性

Q:服务如何不中断?

A:AWS 自动做好数据“冗余”的工作。如果真崩了,赔钱。

Q:消息怎么不丢失?

A:消息确认。尤其是消费的时候需要确认,通常在编码时候,有规定对应的返回,如果返回了异常消息 ID,那么队列会自动重复此消息,如果没有任何返回,那么队列默认消费确认成功,将进行消息删除。消息确认的前提就是,在 SQS 中,是会进行消息持久化的。

module.exports.handler = async (event) => {
  const failedMessageIds = [];
  await Bluebird.map(event.Records, async (record) => {
    const { messageId, receiptHandle } = record;
    const body = JSON.parse(record.body);
    try {
          // 业务逻辑
      );
    } catch (e) {
      logger.error({ body, type: 'sqs:error', e });
      failedMessageIds.push(messageId);
    }
  });
  return {
    batchItemFailures: failedMessageIds.map((id) => ({
      itemIdentifier: id
    }))
  };
};

事务消息

目前从文档还没有看到有这部分的支持。不过知道这种“半消息”的原理,我们是可以使用 SQS 自己做一个这样的事务。

比如:用一个 SQS 用来保存半消息,标记为 A,用一个 SQS 保存消息,标记为 B。生产时,投递半消息到 SQS(A),A 的接受者要去进行事务确认,事务成功,则将消息发送的 SQS(B),下游消费 SQS(B)。

幂等消费

SQS 提供了两种队列标准:

标准队列FIFO 队列
无限制吞吐量 – 标准队列支持每个 API 操作(SendMessage、ReceiveMessage 或 DeleteMessage)每秒几乎无限次的 API 调用。至少一次传递 – 消息至少传送一次,但偶尔会传送消息的多个副本。最大努力排序 – 消息偶尔可能按不同于其发送时的顺序传送。高吞吐量 - 如果您使用批处理,则 FIFO 队列支持每个 API 方法(SendMessageBatch、ReceiveMessage 或 DeleteMessageBatch)每秒最多 3000 条消息。每秒 3000 个事务代表 300 次 API 调用,每次调用带有包含 10 条消息的一个批处理。要申请提高配额,请提交支持请求。在不使用批处理的情况下,FIFO 队列的每个 API 方法(SendMessage、ReceiveMessage 或 DeleteMessage)每秒最多支持 300 个 API 调用。仅处理一次 – 消息传递一次并在使用者处理并删除它之前保持可用。不会将重复项引入到队列中。先进先出传递 – 严格保持消息的发送和接收顺序。
  1. 在标准队列中,消息服务质量为:至少传递一次。那就意味着,需要用户自己做消息幂等的处理。
  2. FIFO 队列中,消息服务质量为:仅处理一次。从文档上得到的信息是可以不用做消息幂等。

消息积压

问题原因

从官方文档可以得到的信息是,在以下两种情况下会有消息积压:

  • 生产者发送消息的速度超过使用消息的速度。
  • 消费者未在可见性超时期限内删除消息。轮询 SQS 队列时,消息重新出现在队列中。
解决办法

标准和 FIFO SQS 队列

  • 设置 SQS 队列最佳可见性超时,以允许消费者在可见性超时期限内处理消息后将其删除。如果不知道处理消息需要多长时间,那么为消费者流程创建检测信号。指定初始可见性超时 (例如,2 分钟)。然后,如果消费者需要更多时间来处理消息,请使用 ChangeMessageVisibility API 调用继续增加可见性超时。
  • 当您进行 ReceiveMessage API 调用时,增加批处理大小。将 MaxNumberOfMessages 参数值设置为大于 1,最多设置为 10。
  • 监控 SQS 队列指标“可见消息的大致数量”。此指标可让您了解生产者是否开始以高于消费者使用消息的速度产生消息。要横向扩展,请增加使用 SQS 队列的消费者或客户端的数量,或者增加轮询队列的线程数。

FIFO SQS 队列

扩展消息组

属于同一消息组的消息按照相对于消息组的顺序逐个处理。当接收具有多个消息组 ID 的消息时,Amazon SQS 会首先尝试返回具有相同消息组 ID 的尽可能多消息。这可让其他消费者处理具有不同消息组 ID 的消息。当属于特定消息组 ID 的消息不可见时,任何其他消费者都不能处理具有相同消息组 ID 的消息。但是,消费者可以处理来自其他消息组的消息。尝试增加顺序不重要的消息组中的数量。

批量消费的配置 Example

SQS 中,提供批量消费,每次消费者消费的时候,可以配置每次消费获取多少消息。

queue:
    handler: src/handlers/queue.handler
    environment:
      TZ: XXXXX
    events:
      - sqs:
          batchSize: 10
          functionResponseType: ReportBatchItemFailures
          arn:
            Fn::GetAtt:
              - QueueName
              - Arn

上述片段中,batchSize:10就是每次消费条目数.

注意事项

SQS 一个队列,只能对应一个消费者。不过,生产者可以随意,谁都可以往队列里面扔东西。

所以在使用SQS的时候:

  1. 每一个SQS,最好只对应一个业务。也就是一类业务消息,放到一个队列里面。往往在SQS里面发生的消息积压,都是生产者太繁多了。
  2. 消费者端,最好是批量获取消息,5-10个都可以。因为每触发一个消息,是一个lambda,lambda本身是有数量瓶颈的,所以每一个lambda,尽可能的,物尽其用。
  3. 做好异常处理。将有异常的消息,返回给SQS,这样才能进行消息重试。
  4. 每一个SQS队列,最好都搭配放置一个死信队列。这样至少确保消息都成功消费了,即使一直失败,也能够追溯到是哪些消息失败了。

SNS

SNS 是属于标准的发布-订阅模型。它牛的一点在于,它不仅可以 Application to Application,还可以 Application to Person。

这里借用官方文档的图:

消息模型

SNS 的消息模型,和下图的标准“发布 - 订阅模型”一样。只不过,订阅者可以有更多的选择。

主题类型
  • 标准主题:不严格保证消息顺序
  • FIFO 主题:确保严格的消息顺序、定义消息组并防止消息重复。

消息可靠性

Q:服务如何不中断?

A:AWS 自动做好数据“冗余”的工作。发布的消息存储在多个地理位置分散的服务器和数据中心。如果真崩了,赔钱。

Q:消息怎么不丢失?

A:

  • 官方回答是:如果订阅的终端节点不可用,Amazon SNS 将执行消息传输重试策略。要保留在传输重试策略结束之前未发送的任何邮件,您可以创建死信队列。您还可以将 Amazon Kinesis Data Firehose 传输流订阅到 SNS 主题,这样就可以将消息发送到持久终端节点,例如 Amazon S3 存储桶或 Amazon Redshift 表。
  • 翻译过来就是:从持久化方面来说,SNS 本身不进行消息的持久化。 那就是说,需要用到别的可以持久化的组件,来配合 SNS 实现消息持久化。所以,在SNS中,不存在持久性。无法保证消息一定会送达。如果消费者不可用,则消息将不会被传递。
  • 不过,对于Amazon SNS FIFO 主题,支持消息存档和重播为无代码、就地消息存档,允许主题所有者在其主题中存储(或存档)消息。然后,主题订阅用户可以将归档的消息检索(或重播)回订阅的端点。有关更多信息,请参阅FIFO 主题的消息归档与重播功能

事务消息

因为不存在持久化,所以对于事务消息这个功能来讲,也就不具备此功能。

消息积压

没有持久化,也就不存在消息积压。

对 SNS 这种设计的思考

  1. 功能简单。它就是个传话的,然而传的话保存不保存,由消费者自己决定。
  2. 维护容易。不持久化,那就说明它本身是一个“无状态”的节点。对于无状态的 broker,维护起来相对容易,将来扩展起来灵活度也是足够的。
  3. 数据的存储和计算分离。有点类似新兴的消息队列:Pulsar,数据存储用类似 HDFS 思想的东西去存储,数据计算分发由 Broker 决定。

SNS 和 SQS 为什么会配合起来使用?

很大一个原因是:SNS 本身不进行数据的持久化,使用 SQS 进行消息的持久化,以及消费确认,让消息更有迹可循。

当然也要分场景,一个警报将会被触发,你想要向 10 个不同的电子邮件地址发送消息,并向一些手机发送短信。持久性、批处理和重试并不重要,此时,当然就不需要 SQS。

image.png

但是,如果业务场景,需要追溯到消息的消费情况,那么此时,SQS 作为 SNS 的消费者当然是更合适的,后续 SQS 自己的消费者进行业务处理。

比如,如果是一个论坛系统,发出去一个帖子,然后对于发布的每个帖子,可能需要采取多项操作的地方,此时 SNS 搭配 SQS 更合适。

image.png

而第二种场景中,SNS 就和 RabbitMQ 中的 Exchange 模块很类似。如下图:

参考

  1. 工作经验总结
  2. 极客时间《消息队列高手课》
  3. RocketMQ 官方文档
  4. Kafka 官方文档
  5. RabbitMQ 文档
  6. AWS SQS SNS 相关文档内容
  7. CSDN [ 云计算 | AWS ] 对比分析:Amazon SNS 与 SQS 消息服务的异同与选择_aws sqs sns-CSDN博客