消息队列| RabbitMQ| RocketMQ| Kafka
1. 如何选择消息队列
几个比较常见的开源的消息队列中间件,选择消息队列产品的基本标准
首先,必须是开源的产品;
其次,这个产品必须是近年来比较流行并且有一定社区活跃度的产品,流行的产品与周边生态系统会有一个比较好的集成和兼容,比如, Kafka 和 Flink 就有比较好的兼容性,Flink 内置了 Kafka 的 Data Source,使用 Kafka 就 很容易作为 Flink 的数据源开发流计算应用。
作为一款及格的消息队列产品,必须具备的几个特性包括:
消息的可靠传递:确保不丢消息; Cluster:支持集群,确保不会因为某个节点宕机导致服务不可用,当然也不能丢消息; 性能:具备足够好的性能,能满足绝大多数场景的性能要求。
1. RabbitMQ
老牌儿消息队列 RabbitMQ,俗称兔子 MQ。RabbitMQ 是使用一种比较小众的编程语言:Erlang 语言编写的,它最早是为电信行业系统之间的可靠通信设计 的,也是少数几个支持 AMQP 协议的消息队列之一。它的特点是:轻量级、迅捷、Messaging that just works “开箱即用的消息 队列”
一个比较有特色的功能是支持非常灵活的路由配置,和其他消息队列不同的 是,它在生产者(Producer)和队列(Queue)之间增加了一个 Exchange 模块,你可以 理解为交换机。
这个 Exchange 模块的作用和交换机也非常相似,根据配置的路由规则将生产者发出的消 息分发到不同的队列中。路由的规则也非常灵活,甚至你可以自己来实现路由规则。基于这 个 Exchange,可以产生很多的玩儿法。
RabbitMQ 的客户端支持的编程语言大概是所有消息队列中最多的,如果你的系统是用某 种冷门语言开发的,那你多半可以找到对应的 RabbitMQ 客户端。
RabbitMQ 的几个问题。
第一个问题是,RabbitMQ 对消息堆积的支持并不好,在它的设计理念里面,消息队列是 一个管道,大量的消息积压是一种不正常的情况,应当尽量去避免。当大量消息积压的时 候,会导致 RabbitMQ 的性能
急剧下降。
第二个问题是,RabbitMQ 的性能是这几个消息队列中最差的,根据官方给出 的测试数据综合我们日常使用的经验,依据硬件配置的不同,它大概每秒钟可以处理几万到 十几万条消息。其实,这个
性能也足够支撑绝大多数的应用场景了,不过,如果你的应用对 消息队列的性能要求非常高,那不要选择 RabbitMQ。
最后一个问题是 RabbitMQ 使用的编程语言 Erlang,这个编程语言不仅是非常小众的语言。
对消息队列功能和性能都没有很高 的要求,只需要一个开箱即用易于维护的产品,我建议你使用 RabbitMQ。
2. RocketMQ
RocketMQ 是阿里巴巴在 2012 年开源的消息队列产品,后来捐赠给 Apache 软件基金 会,2017 正式毕业,成为 Apache 的顶级项目。阿里内部也是使用 RocketMQ 作为支撑 其业务的消息队列,经历过多次
“双十一”考验,它的性能、稳定性和可靠性都是值得信赖 的。
不错的性能,稳定性和可靠性,具备一个现 代的消息队列应该有的几乎全部功能和特性。 RocketMQ 有非常活跃的中文社区。
另外,RocketMQ 使用 Java 语言开发,它的贡献者大多数都是中国 人,源代码相对也比较容易读懂,很容易对 RocketMQ 进行扩展或者二次开发。
RocketMQ 对在线业务的响应时延做了很多的优化,大多数情况下可以做到毫秒级的响应,如果你的应用场景很在意响应时延,那应该选择使用 RocketMQ。 RocketMQ 的性能比 RabbitMQ 要高一个数量级,每秒钟大概能处理几十万条消息。
RocketMQ 的一个劣势是,作为国产的消息队列,相比国外的比较流行的同类产品,在国 际上还没有那么流行,与周边生态系统的集成和兼容程度要略逊一筹。
场景是处理在线业务,比如在交易系统中用消息队列传递订 单,那 RocketMQ 的低延迟和金融级的稳定性是你需要的。
3. Kafka
Kafka 最早是由 LinkedIn 开发,目前也是 Apache 的顶级项目。 Kafka 最初的设计目的是用于处理海量的日志。 在早期的版本中,为了获得极致的性能,在设计方面做了很多的牺牲,比如不保证消息的可 靠
性,可能会丢失消息,也不支持集群,功能上也比较简陋,这些牺牲对于处理海量日志这 个特定的场景都是可以接受的。这个时期的 Kafka 甚至不能称之为一个合格的消息队列。 但是随后的几年 Kafka 逐步补
齐了这些短板。当下的 Kafka 已经发展为一个非常成熟的消息队列产品,无论在数据可靠性、稳定性和功能特性等 方面都可以满足绝大多数场景的需求。 Kafka 与周边生态系统的兼容性是最好的没有之一,尤
其在大数据和流计算领域,几乎所有的相关开源软件系统都会优先支持 Kafka。
Kafka 使用 Scala 和 Java 语言开发,设计上大量使用了批量和异步的思想,这种设计使得 Kafka 能做到超高的性能。Kafka 的性能,尤其是异步收发的性能,是三者中最好的,但与 RocketMQ 并没有量级上的
差异,大约每秒钟可以处理几十万条消息。
使用配置比较好的服务器对 Kafka 进行过压测,在有足够的客户端并发进行异步批 量发送,并且开启压缩的情况下,Kafka 的极限处理能力可以超过每秒 2000 万条消息。 但是 Kafka 这种异步批量的设计带来
的问题是,它的同步收发消息的响应时延比较高,因为当客户端发送一条消息的时候,Kafka 并不会立即发送出去,而是要等一会儿攒一批再发 送,在它的 Broker 中,很多地方都会使用这种“先攒一波再一起处
理”的设计。当你的业务场景中,每秒钟消息数量没有那么多的时候,Kafka 的时延反而会比较高。所以,Kafka 不太适合在线业务场景。
处理海量的消息,像收集日志、监控信息或是前端的埋点这类数据,或是你的应 用场景大量使用了大数据、流计算相关的开源产品,那 Kafka 是最适合你的消息队列。
其他第二梯度的消息队列
ActiveMQ 是最老牌的开源消息队列,是十年前唯一可供选择的开源消 息队列,目前已进入老年期,社区不活跃。无论是功能还是性能方面,ActiveMQ 都与现 代的消息队列存在明显的差距。
ZeroMQ,严格来说 ZeroMQ 并不能称之为一个消息队列,而是一个基于消息队列的多线程网络库,如果你的需求是将消息队列的功能集成到你的系统进程中,可以考虑使用 ZeroMQ。
Pulsar 是一个新兴的开源消息队列 产品,最早是由 Yahoo 开发,目前处于成长期,流行度和成熟度相对没有那么高。与其他 消息队列最大的不同是,Pulsar 采用存储和计算分离的设计,它有可能会引领未来消息队列的一个发展方向。
2. 消息模型:主题和队列的区别
好的 架构不是设计出来的,而是演进出来的。
最初的消息队列,就是一个严格意义上的队列。
(先进先出(FIFO, First-In-First-Out)的线性表(Linear List),在具 体应用中通常用链表或者数组来实现。队列只允许在后端(称为 rear)进行 插入操作,在前端(称为 front)进行删除操作。)
几个关键点,第一个是先进先出,这里面隐含着的一个要求是,在消息入 队出队过程中,需要保证这些消息严格有序,按照什么顺序写进队列,必须按照同样的顺序 从队列中读出来。不过,队列是没有“读”这个操作的,“读”就是出队,也就是从队列 中“删除”这条消息。
早期的消息队列,就是按照“队列”的数据结构来设计的。我们一起看下这个图,生产者 (Producer)发消息就是入队操作,消费者(Consumer)收消息就是出队也就是删除操 作,服务端存放消息的容器自然就称为“队列”。 这就是最初的一种消息模型:队列模型。
发布-订阅模型的来源
如果有多个生产者往同一个队列里面发送消息,这个队列中可以消费到的消息,就是这些生产者生产的所有消息的合集。消息的顺序就是这些生产者发送消息的自然顺序。如果有多个消费者接收同一个队列的消息,这些消费者之间实际上是竞争的关系,每个消费者只能收到队列中的一部分消息,也就是说任何一条消息只能被其中的一个消费者收到。
如果需要将一份消息数据分发给多个消费者,要求每个消费者都能收到全量的消息,例如,对于一份订单数据,风控系统、分析系统、支付系统等都需要接收消息。这个时候,单个队列就满足不了需求,一个可行的解决方式是,为每个消费者创建一个单独的队列,让生产者发送多份。
显然这是个比较蠢的做法,同样的一份消息数据被复制到多个队列中会浪费资源,更重要的是,生产者必须知道有多少个消费者。为每个消费者单独发送一份消息,这实际上违背了消息队列“解耦”这个设计初衷。
为了解决这个问题,演化出了另外一种消息模型:“发布 - 订阅模型(Publish-Subscribe Pattern)”。
在发布 - 订阅模型中,消息的发送方称为发布者(Publisher),消息的接收方称为订阅者(Subscriber),服务端存放消息的容器称为主题(Topic)。发布者将消息发送到主题中,订阅者在接收消息之前需要先“订阅主题”。“订阅”在这里既是一个动作,同时还可以认为是主题在消费时的一个逻辑副本,每份订阅中,订阅者都可以接收到主题的所有消息。
在消息领域的历史上很长的一段时间,队列模式和发布 - 订阅模式是并存的,有些消息队列同时支持这两种消息模型,比如 ActiveMQ。我们仔细对比一下这两种模型,生产者就是发布者,消费者就是订阅者,队列就是主题,并没有本质的区别。它们最大的区别其实就是,一份消息数据能不能被消费多次的问题。
实际上,在这种发布 - 订阅模型中,如果只有一个订阅者,那它和队列模型就基本是一样的了。也就是说,发布 - 订阅模型在功能层面上是可以兼容队列模型的。
现代的消息队列产品使用的消息模型大多是这种发布 - 订阅模型,,当然也有例外。
RabbitMQ 的消息模型
这个例外就是 RabbitMQ,它是少数依然坚持使用队列模型的产品之一。那它是怎么解决多个消费者的问题呢?在 RabbitMQ 中,Exchange 位于生产者和队列之间,生产者并不关心将消息发送给哪个队列,而
是将消息发送给 Exchange,由 Exchange 上配置的策略来决定将消息投递到哪些队列中。
同一份消息如果需要被多个消费者来消费,需要配置 Exchange 将消息发送到多个队列,每个队列中都存放一份完整的消息数据,可以为一个消费者提供消费服务。这也可以变相地实现新发布 - 订阅模型中,
“一份消息数据可以被多个订阅者来多次消费”这样的功能。具体的配置你可以参考 RabbitMQ 官方教程,其中一个章节专门是讲如何实现发布订阅的。
RocketMQ 的消息模型
RocketMQ 使用的消息模型是标准的发布 - 订阅模型,在 RocketMQ 的术语表中,生产者、消费者和主题与发布 - 订阅模型中的概念是完全一样的。
但是,在 RocketMQ 也有队列(Queue)这个概念,并且队列在 RocketMQ 中是一个非常重要的概念,那队列在 RocketMQ 中的作用是什么呢?这就要从消息队列的消费机制说起。
几乎所有的消息队列产品都使用一种非常朴素的“请求 - 确认”机制,确保消息不会在传递过程中由于网络或服务器故障丢失。具体的做法也非常简单。在生产端,生产者先将消息发送给服务端,也就是 Broker,
服务端在收到消息并将消息写入主题或者队列中后,会给生产者发送确认的响应。
如果生产者没有收到服务端的确认或者收到失败的响应,则会重新发送消息;在消费端,消费者在收到消息并完成自己的消费业务逻辑(比如,将数据保存到数据库中)后,也会给服务端发送消费成功的确认,
服务端只有收到消费确认后,才认为一条消息被成功消费,否则它会给消费者重新发送这条消息,直到收到对应的消费成功确认。
这个确认机制很好地保证了消息传递过程中的可靠性,但是,引入这个机制在消费端带来了一个不小的问题。什么问题呢?为了确保消息的有序性,在某一条消息被成功消费之前,下一条消息是不能被消费的,
否则就会出现消息空洞,违背了有序性这个原则。
也就是说,每个主题在任意时刻,至多只能有一个消费者实例在进行消费,那就没法通过水平扩展消费者的数量来提升消费端总体的消费性能。为了解决这个问题,RocketMQ 在主题下面增加了队列的概念。
每个主题包含多个队列,通过多个队列来实现多实例并行生产和消费。需要注意的是,RocketMQ 只在队列上保证消息的有序性,主题层面是无法保证消息的严格顺序的。
RocketMQ 中,订阅者的概念是通过消费组(Consumer Group)来体现的。每个消费组都消费主题中一份完整的消息,不同消费组之间消费进度彼此不受影响,也就是说,一条消息被 Consumer Group1 消费
过,也会再给 Consumer Group2 消费。
消费组中包含多个消费者,同一个组内的消费者是竞争消费的关系,每个消费者负责消费组内的一部分消息。如果一条消息被消费者 Consumer1 消费了,那同组的其他消费者就不会再收到这条消息。
在 Topic 的消费过程中,由于消息需要被不同的组进行多次消费,所以消费完的消息并不会立即被删除,这就需要 RocketMQ 为每个消费组在每个队列上维护一个消费位置(Consumer Offset),这个位置之前的消息都被消费过,之后的消息都没有被消费过,每成功消费一条消息,消费位置就加一。这个消费位置是非常重要的概念,我们在使用消息队列的时候,丢消息的原因大多是由于消费位置处理不当导致的。
Kafka 的消息模型
我们再来看看另一种常见的消息队列 Kafka,Kafka 的消息模型和 RocketMQ 是完全一样的,我刚刚讲的所有 RocketMQ 中对应的概念,和生产消费过程中的确认机制,都完全适用于 Kafka。唯一的区别是,在 Kafka 中,队列这个概念的名称不一样,Kafka 中对应的名称是“分区(Partition)”,含义和功能是没有任何区别的。
队列和主题的区别,背后对应着两种不同的消息模型:队列模型和发布 - 订阅模型,这两种消息模型其实并没有本质上的区别,都可以通过一些扩展或者变化来互相替代。
RabbitMQ 采用的是队列模型,但是它一样可以实现发布 - 订阅的功能。RocketMQ 和 Kafka 采用的是发布 - 订阅模型,并且二者的消息模型是基本一致的。
消费位置,每个消费组内部维护自己的一组消费位置,每个队列对应一个消费位置。消费位置在服务端保存,并且,消费位置和消费者是没有关系的。每个消费位置一般就是一个整数,记录这个消费组中,这个
队列消费到哪个位置了,这个位置之前的消息都成功消费了,之后的消息都没有消费或者正在消费。
事务
日常使用事务的场景,绝大部分都是在操作数据库的时候。像 MySQL、Oracle 这些主流的关系型数据库,也都提供了完整的事务实现。那消息队列为什么也需要事务呢?
其实很多场景下,我们“发消息”这个过程,目的往往是通知另外一个系统或者模块去更新数据,消息队列中的“事务”,主要解决的是消息生产者和消息消费者的数据一致性问题。
分布式事务
如果我们需要对若干数据进行更新操作,为了保证这些数据的完整性和一致性,我们希望这些更新操作要么都成功,要么都失败。更新的数据,不只局限于数据库中的数据,可以是磁盘上的一个文件,也可以是远端的一个服务,或者以其他形式存储的数据。
事务应该具有 4 个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为 ACID 特性。
原子性,是指一个事务操作不可分割,要么成功,要么失败,不能有一半成功一半失败的情况。
一致性,是指这些数据在事务执行完成这个时间点之前,读到的一定是更新前的数据,之后读到的一定是更新后的数据,不应该存在一个时刻,让用户读到更新过程中的数据。
隔离性,是指一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对正在进行的其他事务是隔离的,并发执行的各个事务之间不能互相干扰,这个有点儿像我们打网游中的副本,我们在副本中打的怪和掉的装备,与其他副本没有任何关联也不会互相影响。
持久性,是指一个事务一旦完成提交,后续的其他操作和故障都不会对事务的结果产生任何影响。
大部分传统的单体关系型数据库都完整的实现了 ACID,但是,对于分布式系统来说,严格的实现 ACID 这四个特性几乎是不可能的,或者说实现的代价太大,大到我们无法接受。
分布式事务就是要在分布式系统中的实现事务。在分布式系统中,在保证可用性和不严重牺牲性能的前提下,光是要实现数据的一致性就已经非常困难了,所以出现了很多“残血版”的一致性,比如顺序一致性、最终一致性等等。
显然实现严格的分布式事务是更加不可能完成的任务。所以,目前大家所说的分布式事务,更多情况下,是在分布式系统中事务的不完整实现。在不同的应用场景中,有不同的实现,目的都是通过一些妥协来解决实际问题。
在实际应用中,比较常见的分布式事务实现有 2PC(Two-phase Commit,也叫二阶段提交)、TCC(Try-Confirm-Cancel) 和事务消息。每一种实现都有其特定的使用场景,也有各自的问题,都不是完美的解决方案。
事务消息适用的场景主要是那些需要异步更新数据,并且对数据实时性要求不太高的场景。比如我们在开始时提到的那个例子,在创建订单后,如果出现短暂的几秒,购物车里的商品没有被及时清空,也不是完全不可接受的,只要最终购物车的数据和订单数据保持一致就可以了。
消息队列是如何实现分布式事务的?
事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。
如何用消息队列来实现分布式事务。
首先,订单系统在消息队列上开启一个事务。然后订单系统给消息服务器发送一个“半消息”,这个半消息不是说消息内容不完整,它包含的内容就是完整的消息内容,半消息和普通消息的唯一区别是,在事务提交之前,对于消费者来说,这个消息是不可见的。
半消息发送成功后,订单系统就可以执行本地事务了,在订单库中创建一条订单记录,并提交订单库的数据库事务。然后根据本地事务的执行结果决定提交或者回滚事务消息。如果订单创建成功,那就提交事务消息,购物车系统就可以消费到这条消息继续后续的流程。如果订单创建失败,那就回滚事务消息,购物车系统就不会收到这条消息。这样就基本实现了“要么都成功,要么都失败”的一致性要求。
如果你足够细心,可能已经发现了,这个实现过程中,有一个问题是没有解决的。如果在第四步提交事务消息时失败了怎么办?对于这个问题,Kafka 和 RocketMQ 给出了 2 种不同的解决方案。
Kafka 的解决方案比较简单粗暴,直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。RocketMQ 则给出了另外一种解决方案。
RocketMQ 中的分布式事务实现
在 RocketMQ 中的事务实现中,增加了事务反查的机制来解决事务消息提交失败的问题。如果 Producer 也就是订单系统,在提交或者回滚事务消息时发生网络异常,RocketMQ 的 Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。
为了支撑这个事务反查机制,我们的业务代码需要实现一个反查本地事务状态的接口,告知 RocketMQ 本地事务是成功还是失败。
在我们这个例子中,反查本地事务的逻辑也很简单,我们只要根据消息中的订单 ID,在订单库中查询这个订单是否存在即可,如果订单存在则返回成功,否则返回失败。RocketMQ 会自动根据事务反查的结果提交或者回滚事务消息。
这个反查本地事务的实现,并不依赖消息的发送方,也就是订单服务的某个实例节点上的任何数据。这种情况下,即使是发送事务消息的那个订单服务节点宕机了,RocketMQ 依然可以通过其他订单服务的节点来执行反查,确保事务的完整性。
综合上面讲的通用事务消息的实现和 RocketMQ 的事务反查机制,使用 RocketMQ 事务消息功能实现分布式事务的流程如下图: