RabbitMQ高可靠队列的实现

1. 背景

  RabbitMQ和Kafka是业界较具代表性的开源消息队列。RabbitMQ遵循AMQP协议,源于金融系统通讯需求,用在对可靠性要求较高的消息传递上。Kafka是LinkedIn为互联网服务开发的发布-订阅消息系统,用于高吞吐量流式数据传递。

  本文将重点介绍RabbitMQ高可靠性的实现、与Kafka的对比,以及腾讯云分布式高可靠消息队列(CMQ)所做的优化。

  RabbitMQ队列模型实现:https://my.oschina.net/hackandhike/blog/796063
  RabbitMQ高可用性实现:https://my.oschina.net/hackandhike/blog/800334

2. 可靠交付 (Reliable Delivery)

2.1 消息持久化(persistent)

  AMQP中交换机、队列和消息均包括两种属性:持久化,broker重启后数据仍然存在;自删除(transient),broker重启后交换机/队列/消息被删除。持久化消息只有在持久化队列中才有意义,否则自删除队列在broker重启后被删除,队列中的持久化消息也就没有了。

  对持久化队列上的持久化消息,broker每收到一条生产/消费消息都会写文件;Transient消息则不用写磁盘,消息直接存在内存中。

2.2 确认机制(acknowledgement)

  网络的不确定性和应用失败的可能性,使消息的确认回执(acknowledgement)变的十分重要。确认回执意味着消息已被验证并处理完毕。


图1  确认机制

  AMQP定义了消费者确认机制(message ack),如果一个消费者应用崩溃掉(此时连接会断掉,broker会得知),但是broker尚未获得ack,那么消息会被重新放入队列。所以AMQP提供的是“至少一次交付”(at-least-once delivery),异常情况下,消息会被重复消费,此时业务要实现幂等性(重复消息处理)。

  对于生产者,AMQP定义了事务(tx transaction)来确保生产消息被broker接收并成功入队。TX事务是阻塞调用,生产者需等待broker写磁盘后返回的确认,之后才能继续发送消息。事务提交失败时(如broker宕机场景),broker并不保证提交的消息全部入队。

  TX的阻塞调用使broker的性能非常差,RabbitMQ使用confirm机制来优化生产消息的确认。Confirm模式下,生产者可以持续发送消息,broker将消息批量写磁盘后回复确认,生产者通过确认消息的ID来确定哪些已发送消息被成功接收。Confirm模式下生产者发送消息和接受确认是异步流程,生产者需要缓存未确认的消息以便出错时重新发送。

3. 存储模型

  在3.5之前版本中,消息的存储由msg_store进程处理,队列进程记录消息的索引(queue_index)。所有队列的持久化消息均由同一个msg_store进程写入同一个文件中(可以有多个分片),每个队列进程单独记录消息ID到消息存储位置的映射。


图2  进程结构

  3.5及之后版本,引入了将小消息(<4KB)直接存储在队列进程索引queue_index中,省去了查找和读磁盘操作,同时也简化了交互的复杂度。本节介绍持久化队列中持久化消息的存储,且消息直接存储于索引文件中(msg_store的实现也是类似的)。

3.1 索引文件

  从图1可以看出,已入队的消息可能处于以下三种状态:published、delivered、acked。Published是消息已被broker接收;delivered是消息已发送给消费者、未收到ack确认;acked是消息已被成功消费。

  队列中消息ID是递增的,队列进程会更新下一个收到消息应有的ID。索引文件包括多个分片(segment),每个分片最多保存相同数量(segment_entry_count)的published、delivered、acked消息。对于一个确定的ID,可以知道其存储在哪个分片中(id / segment_entry_count)。

  为提高文件写性能,消息状态写入分片文件是顺序的append。Published状态写入时包含消息自身,delivered和acked状态写入时只包含消息ID。

published记录二进制数据结构(Erlang):
<<?PUB_PREFIX:?PREFIX_BITS, is_persistent:1, msg_id:?SEQ_BITS, msgbin_len:?SIZE_BITS, msgbin>>

delivered、acked记录二进制数据结构
<<?DEL_ACK:?PERFIX_BITS, msg_id:?SEQ_BITS>>

   在将消息状态写入分片文件的同时,队列进程在内存中也更新分片的信息。内存中分片信息(segment_record)包括:分片号、文件路径、未确认的消息数量(unacked)。当分片写入一个published状态时,unacked + 1;当分片写入一个acked时,unacked - 1;当unacked = 0时,说明分片中消息已全部被消费,直接删除这个分片文件。

3.2 日志文件

  受消费者数量和预取值(prefetch_count)影响,一个队列可能同时处理多个分片,同时写多个文件,这样写磁盘变为随机写,性能下降。

  RabbitMQ使用日志文件来解决该问题,消息状态不再直接写入分片文件,而是append顺序写入一个日志文件(write,写OS文件系统缓存)。当日志文件中消息达到一定数量(max_journal_entries)或一定时间周期后,再按分片依次顺序写入并刷盘(fsync,落磁盘存储)。这里同样在内存中记录消息状态:segment_record中记录并更新自上次flush后收到的每条消息状态(journal_entries),如果自上次flush后一条新消息被publish、deliver、ack,再次flush时,该消息并不写入分片文件。同时在flush时,直接将journal_entries写入分片文件,成功后清空日志文件和journal_entries记录。

segment_record结构(Erlang):
{num,              %% segment id
 path,             %% segment对应的分片文件路径
 journal_entries,  %% array或dict字典(节省内存),key/value对应msg_id/binary记录
 unacked
}

  可以看出,日志文件用于确保消息正确性,正常流程中只被顺序写入或删除。

3.3 Confirm的实现

  Confirm机制是在日志记录flush到分片文件后,将写入分片的最大消息ID返回给生产者。如果消息还未flush到分片文件(或flush未执行完成)时broker异常退出,下次启动时,可以通过读取分片文件和日志文件来重建消息状态并恢复数据。异常时日志文件可能没有完全刷盘,未confirm的生产消息可能丢失,生产者需要重新发送,导致部分消息重复生产;其他的deliver和ack消息也可能丢失,导致部分消息重复消费。

图3  镜像队列

  在RabbitMQ高可用队列的实现中,channel进程会记录其发给镜像队列的消息,镜像队列中各队列进程分别向channel返回confirm,当channel进程收到所有队列的确认后,再向生产者回复confirm,并删除该记录。处于同步状态的镜像队列,当一台机器宕机后,该机器上对应的channel可能没将confirm消息发给生产者,生产者就需要重复发送;其他机器的channel进程通过进程监控可以获悉队列退出,只用等待剩余的镜像队列返回确认。(宕机节点恢复后,处于未同步状态,需要手动同步或等待未同步消息被全部消费。)

4. 与Kafka的对比

  RabbitMQ和Kafka对比如下:

 RabbitMQKafka
设计初衷消息可靠交付吞吐量
开发语言ErlangScala
开发时间20072010
多协议支持   AMQP、MQTT、STOMP、
WebSockets、JSON-RPC、XMPP
Binary Protocol over TCP、
WebSocket Interface
JMSyesno
可靠交付yes部分
按序交付yes(channel级别)局部有序(partition有序)      
消费模型Get(pull)、Consume(push)pull
存储模型磁盘文件+内存队列磁盘文件
事务tx本地事务no
集群特性集群实现对应用透明,可以动态扩容  partition对生产者不透明
HA高可用mirror queue,同时保证高可靠raplica,无法保证高可靠
商业支持PivotalConfluent
   

  Kafka的设计有明确的介绍:http://kafka.apache.org/documentation.html#design。应对场景:消息持久化、吞吐量是第一要求、状态由客户端维护、必须是分布式的。Kafka认为broker不应该阻塞生产者,高效的磁盘顺序读写能够和网络IO一样快,同时依赖现代OS文件系统特性,写入持久化文件时并不调用flush,仅写入OS pagecache,后续由OS flush。

  这些特性决定了Kafka没有做如2.2节的“确认机制”,而是直接将生产消息顺序写入文件、消息消费后不删除(避免文件更新),该实现充分利用了磁盘IO,能够达到较高的吞吐量。代价是消费者要依赖zookeeper记录队列消费位置、处理同步问题以及如2.2节所描述的异常。没有消费确认机制,还导致了Kafka无法了解消费者速度,不能采用push模型以合理的速度向消费者推送数据,只能利用pull模型由消费者来拉消息(消费者承担额外的轮询开销)。

  如果在Kafka中引入消费者确认机制,就需要broker维护消息消费状态,要做到高可靠就需要写文件持久化并与生产消息同步,这将急剧降低Kafka的性能,这种设计也极类似RabbitMQ。如果不改变Kafka的实现,而是在Kafka和消费者之间做一层封装,还是需要实现一套类似RabbitMQ的消费确认和持久化机制。

  由于broker未调用同步刷盘,系统崩溃时未落盘的数据会丢失。这里即使有同步刷盘,没有生产消息确认机制,在3.2节的场景下broker在没来得及将收到的消息刷盘就崩溃时,还是会丢失消息。在高可用方面,Replication机制也是在Follower收到消息后未刷盘时就向Leader返回确认,这里也存在丢失消息的风险。

  在Kafka中引入生产消息确认,可以效仿RabbitMQ批量刷盘后confirm的机制。

图4  Partition + Replication

  在单个消息1K大小,机器为单SATA磁盘、1G网卡的场景下测试单队列吞吐量,Kafka可达到10w/s的写速度,RabbitMQ是2w/s。然而从第3节“存储实现”的描述和RabbitMQ队列实现一文的验证可知:RabbitMQ涉及大量内存操作,且单队列只有一个进程(不能有效利用多核),系统瓶颈在CPU。这里可以通过单机多实例来做虚拟化(多个Vhost、多个Erlang VM、容器隔离等),用多个实例来模拟Kafka的多partition。

  测试中RabbitMQ吞吐量同样可以达到网卡极限。测试数据如下:

单机单队列RabbitMQ      Kafka          
1个实例持久化   2w/s10w/s
1个实例非持久化    4.5w/s--
2个实例持久化3-4w/s10w/s
2个实例非持久化9w/s--
4个实例持久化7-8w/s10w/s
4个实例非持久化10w/s--
6个实例持久化10w/s10w/s
6个实例非持久化10w/s--

5. 总结

  从上文分析中可以看到,RabbitMQ高可靠性涵盖了大部分异常场景,在文中描述的所有场景下,均能够保证不丢消息。在与Kafka的比较中,可以看到RabbitMQ虽然单队列吞吐量和Kafka相差较大,但经过优化后,吞吐量明显增加。

  CMQ实现了文中提到的对Kafka的优化,既做到了高性能,又做到了高可靠,还通过实现Raft算法做到高可用。通过整合RabbitMQ和Kafka,CMQ既支持RabbitMQ主要特性(如 topic exchange、延迟消息),还支持Kafka回溯队列(rewind/seek)等特性。

转载于:https://my.oschina.net/hackandhike/blog/883850

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值