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对比如下:
RabbitMQ | Kafka | |
设计初衷 | 消息可靠交付 | 吞吐量 |
开发语言 | Erlang | Scala |
开发时间 | 2007 | 2010 |
多协议支持 | AMQP、MQTT、STOMP、 WebSockets、JSON-RPC、XMPP | Binary Protocol over TCP、 WebSocket Interface |
JMS | yes | no |
可靠交付 | yes | 部分 |
按序交付 | yes(channel级别) | 局部有序(partition有序) |
消费模型 | Get(pull)、Consume(push) | pull |
存储模型 | 磁盘文件+内存队列 | 磁盘文件 |
事务 | tx本地事务 | no |
集群特性 | 集群实现对应用透明,可以动态扩容 | partition对生产者不透明 |
HA高可用 | mirror queue,同时保证高可靠 | raplica,无法保证高可靠 |
商业支持 | Pivotal | Confluent |
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/s | 10w/s |
1个实例非持久化 | 4.5w/s | -- |
2个实例持久化 | 3-4w/s | 10w/s |
2个实例非持久化 | 9w/s | -- |
4个实例持久化 | 7-8w/s | 10w/s |
4个实例非持久化 | 10w/s | -- |
6个实例持久化 | 10w/s | 10w/s |
6个实例非持久化 | 10w/s | -- |
5. 总结
从上文分析中可以看到,RabbitMQ高可靠性涵盖了大部分异常场景,在文中描述的所有场景下,均能够保证不丢消息。在与Kafka的比较中,可以看到RabbitMQ虽然单队列吞吐量和Kafka相差较大,但经过优化后,吞吐量明显增加。
CMQ实现了文中提到的对Kafka的优化,既做到了高性能,又做到了高可靠,还通过实现Raft算法做到高可用。通过整合RabbitMQ和Kafka,CMQ既支持RabbitMQ主要特性(如 topic exchange、延迟消息),还支持Kafka回溯队列(rewind/seek)等特性。