1.3 消息队列对比
在Pulsar出现之前开源社区中已有多种消息队列,Pulsar的设计必然会站在“前人”的肩膀上,参考前一代消息队列的功能与设计。本节结合其他几种消息队列,对Pulsar消息队列的性能、可靠性及功能进行介绍和对比。
1.3.1 消息队列简介
目前有多种开源消息队列,其中比较典型的有2007年发布的RabbitMQ,2010年诞生的Kafka,2011年推出的RocketMQ,以及2016年成为Apache顶级项目的Pulsar。
1.2.2节已经对Kafka的发展历史和基本架构进行了简单介绍,本节将介绍另外两种较为流行的开源消息队列。
1. RabbitMQ
RabbitMQ是采用Erlang语言实现的AMQP消息中间件,它起源于金融系统,用于在分布式系统中存储与转发消息。AMQP是一种用于异步消息传递的应用层协议,AMQP客户端能够无视消息来源,任意发送和接收消息,服务端负责提供消息路由、队列等功能。
RabbitMQ的服务端节点称为Broker。Broker主要由交换器(Exchange)和队列(Queue)组成。交换器负责接收与转发消息。队列负责存储消息,提供持久化等功能。AMQP客户端通过AMQP信道(Channel)与Broker通信,通过该信道生产者与消费者完成数据发送与接收。
2. RocketMQ
RocketMQ是一个分布式消息系统,具有低延迟、高性能、高可靠性、万亿级容量和灵活的可扩展性等特点。RocketMQ是2012年阿里巴巴开源的分布式消息中间件。2016年11月21日,阿里巴巴向Apache软件基金会捐赠了RocketMQ。2017年2月20日,Apache软件基金会宣布Apache RocketMQ成为顶级项目。
RocketMQ的架构可以分为4个部分—Broker、NameServer、Producer、Consumer。RocketMQ的服务端节点称为Broker,Broker负责管理消息存储分发、主备数据同步、为消息建立索引、提供消息查询等。NameServer主要用来管理所有的Broker节点及路由信息。Producer与Consumer负责数据发送与接收。
RocketMQ Broker依靠主备同步实现高可用,消息到达主服务器后,需要同步到备用服务器上,默认情况下RocketMQ会优先选择从主服务器拉取消息。如果主服务器宕机,消费者可从备用服务器拉取消息。备用服务器会通过定时任务从主服务器定时同步路由信息、消息消费进度、延迟队列处理进度、消费组订阅信息等。
RocketMQ 5.0版本在架构上进行了存储与计算的分离改造。它引入无状态的Proxy集群来承担计算职责,原Broker节点逐步演化为以存储为核心的有状态集群。在不同场景下,可以根据应用场景和部署环境(公有云或私有云)为RocketMQ选择存储与计算一体化或者分离的使用方式。
1.3.2 性能与可靠性
高性能和高可靠性是消息队列涉及的两个主要话题,本节将从这两个角度对多种开源消息队列进行讨论。
1. 副本与存储结构
在分布式系统中,集群的高可用一般通过多副本机制来保障。Pulsar、Kafka、RabbitMQ与RocketMQ都依赖副本或备份来保障高可用。Kafka以分区维度进行高可用保障,每个分区的数据会保存多个副本。在多个副本中会有一个被选为主副本并负责数据的读取与写入。与此同时,主副本还负责将数据同步至其他副本。在集群视角下,各个主副本会分布在不同的节点上,从全局来看,每个服务端的负载是相对均衡的。为确保负载均衡和高可用性,当新的服务端节点加入集群的时候,Kafka中部分副本可以被移动到新的节点上。
RocketMQ依赖主从复制机制来实现数据的多副本,从而保证服务的可靠性。不同于Kafka采用物理分区方式(每个分区对应一个真实的日志文件),RocketMQ采用逻辑分区的方式。RocketMQ消息的存储由逻辑队列和物理日志一同实现,其中物理日志负责将消息存储在物理存储介质中,而消息的逻辑队列里存储对应消息的物理存储地址。在物理存储部分,RabbitMQ也采用类似的主从复制机制来保障高可用。
Pulsar通过BookKeeper实现了数据的高可靠。在BookKeeper中Ledger是基本的持久化存储单元。Pulsar的每个主题的数据都会在逻辑上映射为多个Ledger。每个Ledger在服务端会存储多个副本。为了灵活地控制存储时的一致性,BookKeeper在存储时提供了3个关键的参数—数据存储的副本数(Ensemble Size,直译为集合数量)、最大写入副本数(Write Quorum Size,直译为法定写入数量)、最小写入副本数(Ack Quorum Size,直译为法定确认数量)。
在上述几种消息队列中,不同的副本方案和数据存储方案决定了其使用场景。Kafka在大数据场景下可以取得极高的吞吐量,但是在单节点分区数很多的情况下,受物理分区设计的影响,在使用机械磁盘时Kafka的性能会受到很大影响。RocketMQ的存储模型决定了主题的个数不会成为其性能瓶颈。RocketMQ通过逻辑分区的机制可以轻松拓展主题数量。也因为这种逻辑分区的机制,在同等场景下RocketMQ的吞吐量达不到Kafka的水平。
Pulsar采用另一种综合方案,在Broker端每个主题都是逻辑主题,这使其可以轻松支持海量主题。而在每个存储分块内部,由于采用了BookKeeper读写分离机制和顺序读写存储机制,在全局情况下Pulsar可以获得不低于Kafka的吞吐量。当然,凡事都有代价,在存储与计算分离的情况下,Pulsar势必会占用更多的网络I/O。为了获取更好的性能,BookKeeper客户端在写入多副本时,也是由客户端完成多副本写入操作的,而不是采用服务端复制的方式,这进一步加大了Broker与Bookie之间的网络资源消耗量。不过,当磁盘I/O比网络I/O更容易成为性能瓶颈时,这种消耗是值得的。
当前版本的Pulsar(截至本书完稿时)还是一个强依赖Zookeeper的系统,每个主题与Ledger之间的元数据信息都需要存储在Zookeeper中,这必将对Zookeeper造成比较大的压力。社区正在积极解决这个问题。瑕不掩瑜,在综合考量存储设计的情况下Pulsar解决了上一代消息队列的很多问题。
目前消息队列在存储方面大都采用多副本的机制来保障可靠性,此时单副本是否可靠决定了消息队列的存储是否可靠。在存储组件收到写入操作时,数据刷盘根据行为的不同可以分为同步刷盘与异步刷盘。同步刷盘是增强一个组件可靠性的有效方式,存储组件会在收到写入请求的同时进行写入操作,然后返回写入成功的响应;而异步刷盘会优先保障写入性能,并以异步的方式写入存储设备。Pulsar、Kafka和RabbitMQ都支持单副本上的同步刷盘与异步刷盘。
2. 语义支持与一致性级别
在很多情况下,高性能与高可靠是相悖的。根据CAP定理,在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)三者不能同时满足。因此在进行分布式架构设计时必须做出取舍。分布式系统中的可靠性代表着多个副本之间的一致性,而提高一致性势必要对可用性和分区容错性进行取舍,从而造成功能或性能的下降。
消息在生产者和消费者之间进行传输的方式有3种—至多一次(At most once)、至少一次(At least once)、精确一次(Exactly once,又称精准一次)。下面我们分别看看Kafka、Pulsar、RocketMQ在这方面的表现。
(1)Kafka
我们先来看看Kafka,其具有幂等性和事务功能。Kafka的幂等性是指单个生产者对于单分区单会话的幂等,而事务可以保证消息原子性地写入多个分区,即消息写入多个分区要么全部成功,要么全部回滚。这两个功能加起来可以让Kafka具备精确一次语义的能力。
(2)Pulsar
Pulsar可以通过幂等生产者在单个分区上写入数据,并保证其可靠性。通过客户端的自增序列ID、重试机制与服务端的去重机制,幂等生产者可以保证发送到单个分区的每条消息只会被持久化一次,且不会丢失数据。
另外,Pulsar事务中的所有生产或消费操作都作为一个单元提交。一个事务中的所有操作要么全部提交,要么全部失败。Pulsar保障每条消息都只被写入或处理一次,且即使发生故障数据也不会丢失或重复。如果事务中止,则该事务中的所有写入和确认操作都将自动回滚。综上可以发现,Kafka与Pulsar的事务功能都是为了支持精确一次语义的。
(3)RocketMQ
RocketMQ也提供了精确一次语义。RocketMQ中的精确一次语义适用于接收消息,处理消息,将结果持久化到数据库的流程中,从而保证每一条消息消费的最终处理结果有且仅有一次写入数据库。也就是说,RocketMQ可以保证消息消费的幂等性。
RocketMQ的事务流程被分为正常事务消息的发送和提交以及事务消息的补偿两个阶段。在消息发送过程中,生产者将消息发送到服务端后,若服务端未收到生产者对该消息的二次确认,则该消息会被标记成不可用状态。处于不可用状态的消息称为半事务消息,此时消费者无法正常消费这条消息。另外,若发生网络闪断、生产者应用重启等情况,导致某条消息的二次确认丢失,那么RocketMQ服务端需要主动向消息生产者询问该消息的最终状态(Commit或Rollback),该询问过程即消息回查。
RocketMQ中的事务在业务系统中会有更多表现能力。RocketMQ中事务的作用是确保执行本地事务和发消息这两个操作要么都成功,要么都失败。RocketMQ还增加了一个事务反查机制,以尽量提高事务执行的成功率和数据一致性。而Kafka与Pulsar中的事务的作用是确保多个主题之间的精确一次语义,即确保在一个事务中发送的多条消息要么都成功,要么都失败。
3. 扩展能力
当消息量突然上涨,消息队列集群到达瓶颈的时候,需要对集群进行扩容。扩容一般分为水平扩容和垂直扩容两种方式:水平扩容指的是往集群中增加节点,垂直扩容指的是把集群中部分节点的配置调高以增加其处理能力。在分布式系统中,大家更加期待能够发挥分布式集群的水平扩容能力。
Kafka是一个存储与计算混合的消息队列。由于Kafka集群采用主题物理分区设计,数据会存储在服务端节点上,而新加入集群的节点并没有存储分区,所以无法马上对外提供服务。因此需要把一些主题的分区分配到新加入的节点,此时需要运维人员介入。在分区消息均衡的过程中,需要将某些分区的数据复制到新节点上,并在扩缩容前评估好所需容量。在针对大规模集群进行维护的过程中,若某个主题流量剧增,此时也需要运维人员介入,手动进行负载均衡。
由于采用了主备设计,RocketMQ的服务端扩展能力比较强,只要将主备设备新增到集群中即可。但是需要在扩容完毕后,在新增的服务端节点创建对应的主题和订阅组信息。RocketMQ服务端具备读、写权限控制能力,可以针对单个主题的单个队列进行读写控制,这非常便于进行运维操作。Kafka的分区是在不同的物理机器上实现的,而RocketMQ采用的是逻辑分区,因此不存在消息均衡的情况。
Pulsar的服务端的Broker负责接入、计算、分发消息等职能,Bookie负责消息存储,两者均可以按需动态地进行扩缩容处理。服务端会周期性地获取各个Broker节点的负载情况,并根据负载情况进行负载均衡,即每次扩容后都可以自动进行负载均衡。
因为Bookie是有状态的服务端节点,任一主题相关的消息都不会与特定存储节点进行捆绑,因此可以轻松替换存储节点或对其所在集群进行扩缩容。集群中最小或最慢的节点不会成为存储或带宽的短板。Bookie集群扩容后,再写入新消息的时候会选用新加入的、负载低的节点作为候选节点。在存量数据不受影响,并且无须手动进行负载均衡的情况下,Pulsar会将新增消息写入新扩容的节点。
1.3.3 功能特性对比
性能是进行消息中间件选型时要参考的重要维度,但并不是唯一的维度,在选型过程中还要考虑消息队列能否满足业务需求,即考虑功能特性。本节将从多个角度对消息队列的功能特性进行讨论。
1. 消息模式
消息队列一般有两种消息读取模式—点对点(Point to Point, P2P)模式和发布订阅模式。在点对点模式下,某条消息被消费以后,消息队列不会再推送该消息。虽然消息队列可以支持多个消费者共同消费消息,但是同一条消息只会被一个消费者消费。发布订阅模式定义了如何向一个内容节点发布和订阅消息,这个内容节点称为主题。主题可以认为是消息传递的中介,消息发布者将消息发布到某个主题,而消息订阅者则从主题中订阅消息。
主题使得消息的订阅者与发布者互相保持独立,不需要接触即可完成消息的传递。发布订阅模式在一对多广播消息时采用。RabbitMQ采用的是点对点模式,而Kafka、Pulsar与RocketMQ采用的是发布订阅模式。不过在RabbitMQ中可以通过设置交换器类型实现发布订阅模式以达到广播消费的效果,在发布订阅模式中也能以点对点的形式进行消息消费。
在消息队列中,有时需要保障消息的有序性。如Kafka、RocketMQ、Pulsar等消息队列会将消息按照分组关键字(或对应的Key)分类,将同一Key的顺序消息分发到同一个分区中,借此实现单分区内消息的有序性。消费时,每个分区与消费组保持一对一的关系,通过简单的处理即可保证消费的有序性。
消息的可回溯性也是消息队列的重要特性。一般消息在消费完成之后就被处理了,之后再也不能消费该条消息。通过消息回溯可在消息被消费完成之后,再次消费该消息。在一些事务性场景中,如果消息中间件本身具备消息回溯功能,那么可以通过回溯已被消费的消息来满足一些特殊的业务需求。Kafka、Pulsar、RocketMQ都支持消息回溯,可以根据时间戳或指定消费位置,重置消费组的偏移量使对应消息可以被重复消费。RabbitMQ不支持回溯,消息一旦被标记确认就会被删除。
对于业务场景中对消息队列的使用需求,我们称为传统的消息队列应用场景。消息队列的主要应用场景包括低延迟订阅服务、流量削峰、异步请求处理等。在这些应用场景下,对消息的可靠性要求比较高。
在大数据系统中,消息队列是流数据的存储介质,是连接实时计算的基础组件,为大数据系统提供缓存与部分存储能力。在这种场景下,高吞吐量是最先被考虑的指标。例如,目前大数据的流处理系统事实标准Kafka就用了诸多设计来保障高吞吐量。首先,Kafka使用了物理分区的设计(每个分区对应独立的存储文件),这使我们可以利用磁盘的顺序写入特性来增加吞吐量;其次,Kafka使用了页缓存与零拷贝的底层技术,这也增加了消息队列的吞吐量。
Pulsar是一个分布式可扩展的流式存储系统。它在存储系统的基础上构建了消息队列和流服务的统一模型,这让它不仅具有传统消息队列(类似偏向业务系统的ActiveMQ、RocketMQ)的功能,比如事务性和高一致性,从而完成业务方面的需求,还让它成为一个大数据流模型(类似Kafka),可利用高吞吐量、低延迟的大数据特性完成大数据分析与计算需求。注意,Pulsar在这两方面的能力不是完全隔离的,只是在业务场景上有些区别。
2. 多租户
多租户是一种软件架构技术,主要用来实现多用户的环境下共用相同的系统或程序组件,并确保各用户的数据具有一定的隔离性。RabbitMQ支持多租户技术,每一个租户为一个虚拟主机(vhost),本质上是一个独立的小型RabbitMQ服务器,具有自己独立的队列、交换器、绑定关系及权限等。vhost就像物理机中的虚拟机一样,为各个实例提供逻辑上的分离,为不同程序提供安全、保密访问数据的功能。它既能将同一个RabbitMQ中的众多租户区分开,又可以避免队列和交换器等的命名冲突。
官方原生的Kafka没有完善的体系化多租户功能,但是包含一些配额管理与用户管理功能。基于Kafka协议的部分商业版消息队列支持多租户功能。例如CKafka(Cloud Kafka)是一个具有分布式、高吞吐量、高可扩展等特性的消息系统,完全兼容开源Kafka API 0.9.0至2.8.0版本。CKafka也是一款集成了租户隔离、限流、鉴权、安全、数据监控告警、故障快速切换、跨可用区容灾等一系列特性的,历经大流量检验的,可靠的公有云Kafka集群[14]。
Pulsar是天生支持多租户的消息队列。Pulsar租户可以分布在多个集群中,并且每个租户都可以应用自己的身份验证和授权方案。命名空间是租户内的独立管理单元。在命名空间上设置的配置策略适用于在该命名空间中创建的所有主题。
3. 优先级队列
优先级队列不同于先进先出队列,优先级高的消息具备优先被消费的特权,这样可以为下游提供不同消息级别。优先级队列在消费速度小于生产速度时才有意义,因为只有这样才可以保证高优先级消息总是被消费。但是当消费速度大于生产速度,并且消息中间件服务器中没有消息堆积时,因为服务端中所有消息都会被及时消费,所以消息优先级是没有什么意义的。
RabbitMQ支持优先级队列,使用客户端提供的可选参数即可为任何队列设定优先级。Kafka、RocketMQ、Pulsar皆不支持原生的优先级队列,若想在这3类消息队列中使用优先级队列功能,需要用户通过不同主题或分区在业务层进行优先级划分。
4. 延迟队列
在一般的消息队列中,消息一旦入队就会被马上消费,而进入延迟队列的消息会被延迟消费。延迟队列存储的是延迟消息。所谓延迟消息是指消息被发送以后,并不想让消费者立刻拿到,而是等到特定时间消费者才能拿到的消息。例如在网上购物场景下,需要消费者在30分钟之内付款,否则订单会自动取消,这个就是延迟队列的一种典型应用。
下面对主流的具有延迟队列功能的消息队列产品进行对比。
1)RabbitMQ:在3.6版本后,RabbitMQ官方提供了延迟队列的插件。RabbitMQ需要在服务端插件目录中安装rabbitmq_delayed_message_exchange插件才能使用延迟队列功能。该插件将延迟时间设置在消息上。指定为延迟类型的交换机在收到消息后不会立即将消息投递至目标队列,而是存储在内部数据库中,在达到设置的消息延迟时间时才将其投递至目标队列。
2)Kafka:Kafka基于时间轮(TimingWheel)自定义了一个用于实现延迟功能的定时器。但是该定时器无法被用户使用,仅用于实现内部的延时操作,比如延时请求和延时删除等。因此Kafka不支持用户使用延迟队列。
3)RocketMQ开源版:RocketMQ将延迟消息临时存储在一个内部主题中,不支持任意时间精度,支持特定的延迟级别,如5s、10s、1min等。RocketMQ发送延迟消息时,会在写入存储数据前将消息按照设置的延迟时间发送到指定的定时队列中。每个定时队列对应一个定时器。RocketMQ通过定时器对定时队列进行轮询,并查看消息是否到期。若消息到期,RocketMQ会将这条消息写入存储。
4)Pulsar:支持秒级的延迟消息,所有延迟投递的消息都会被内部组件跟踪,消费组在消费消息时,会先去延迟消息追踪器中检查,以明确是否有到期需要投递的消息。如果有到期的消息,则根据追踪器找到对应的消息进行消费;如果没有到期的消息,则直接消费非延迟的正常消息。延迟时间长的消息会被存储在磁盘中,当快到延迟时间时才被加载到内存里。
RabbitMQ依赖于第三方数据库存储系统Mnesia来实现延迟队列功能,所以它在性能方面会受到限制。RocketMQ的延迟方案对延迟消息的时间控制精度不高,不能精确地控制延迟消费,故其使用有很大的局限性。Pulsar的延迟消息只支持共享消费模式,不支持独占和灾备模式,消息以轮询的方式发给其中的任意一个消费者,在存在多个消费者的情况下无法保证有序性。三种方案都有各自的优劣势及应用场景,用户在使用时可以根据自己的业务场景进行选择。
5. 重试队列与死信队列
如果消费者在消费消息时发生了异常,那么就不会对当前消息进行确认。在提供消息不丢失保障功能的消息队列中,这条消息就可能会被不断处理,从而导致消息队列陷入死循环。为了解决这个问题,消息队列系统可以为需要重试的消息提供一个重试队列,由重试队列进行消息重试。
在消息队列中,当由于某些原因导致消息多次重试,仍无法被正确投递时,为了确保消息不被无故丢弃,一般将其置于一个特殊角色的队列,这个队列一般称为死信队列(Dead-Letter Queue)。
RabbitMQ支持消息重试,可以对最大重试次数、重试间隔时间等进行设置。RabbitMQ也支持死信队列。当队列中的消息超出重试次数或生存时间时,如果RabbitMQ配置了死信队列,那么这些应该被丢弃的消息会被放入死信队列中。
RocketMQ中每个消费组都有一个重试队列,并且消息重试超过一定次数后就会被放入死信队列中。
Kafka暂不支持死信队列。
Pulsar也支持死信队列。在Pulsar中某些消息可能会被多次重新传递,甚至可能永远都在重试中。通过使用死信队列,可让消息具有最大重新传递次数。当实际传递次数超过最大重新传递次数时,对应的消息会被发送到死信主题并自动确认。