RabbitMQ、后备存储、数据库和磁盘
有时,在我们的邮件列表和其他地方,会有人提出在 RabbitMQ 中使用不同的后备存储的想法。后备存储负责将消息写入磁盘(消息可能由于多种原因而写入磁盘),并且经常有人建议看看如果 RabbitMQ 的后备存储被替换为其他存储系统会是什么样子。
这样的更改将允许目前无法实现的功能,例如带外队列浏览或分布式存储,但消息代理(如 RabbitMQ)与通用数据库之间的数据存储和访问模式的本质存在根本区别。实际上,RabbitMQ 故意不将消息存储在这样的数据库中。
首先,我们需要讨论 RabbitMQ 本身对任何后备存储的期望属性。RabbitMQ 在两种情况下将消息写入磁盘:要么消息以必须写入磁盘的方式发布(例如,使用delivery_mode = 2
发布),要么内存压力导致 RabbitMQ 开始耗尽 RAM,因此它将消息推送到磁盘以释放 RAM。在第一种情况下,仅仅因为我们将消息写入磁盘,并不意味着我们会忘记 RAM 中的消息:如果内存充足,就没有理由承担后续磁盘读取的成本。
在第二种情况下,这意味着任何始终将所有内容都保存在 RAM 中的后备存储都不适合:RabbitMQ 将消息写入磁盘以释放 RAM,因此,如果“写入磁盘”部分实际上只是将消息从 RAM 的一个区域移动到另一个区域而没有释放 RAM,那么就没有任何收获。使用这样的后备存储可能有效,并且可能实现所需的功能改进,但这样的更改将对 RabbitMQ 的可扩展性产生重大影响:它将不再能够吸收超过 RAM 容量的消息,而这正是导致 RabbitMQ 当前默认后备存储的新持久化工作存在的理由之一。
一些数据库或键值存储通过最初写入其整个数据集的快照,然后向该数据集写入增量来写入磁盘内容。一段时间后,根据时间或增量的数量,或增量与快照大小的比率,将写入新的快照,然后可以丢弃以前的快照及其所有增量。这就是 RabbitMQ 的旧持久化的工作方式。问题在于,它会反复导致大量数据被不必要地重写。假设您有两个队列,其中一个队列完全是静态的:没有人向其发布消息,也没有人从中消费消息,它只是在那里,但它包含数百万条消息,所有这些消息都已写入磁盘。另一个队列几乎总是空的,但移动速度非常快——每秒都有数千条消息被发布和消费。发送到该队列的每条消息都必须写入磁盘,但它们都在写入磁盘后立即被消费。考虑此场景对后备存储的影响:第二个队列将导致快速增量流,但每当重写快照时,它也会导致第一个队列的整个内容被重写即使该队列的内容没有发生任何更改。因此,同样,以这种方式将消息写入磁盘的后备存储可能不适合 RabbitMQ 的需求。
因此,合适的后备存储(假设需要保留 RabbitMQ 所需的性能和可扩展性属性:在所有情况下这绝非确定)将能够存储仅受磁盘大小而非 RAM 限制的数据量,并且还具有相当复杂的方法将数据存储在磁盘上,以便不会无限期地重写未更改的数据。
RabbitMQ 的默认后备存储还有几个方面值得一提。队列本身决定何时以及是否将消息写入磁盘。但是一条消息可以发送到多个队列,并且显然有利于确保每条消息仅写入磁盘一次。但是,这里有两条不同的信息:首先,消息内容本身。这在消息已发送到的每个队列中都是相同的,并且无论发送到的队列数量如何,都应仅写入磁盘一次;请注意,后续写入此内容不需要进行值比较:如果后备存储知道消息的 ID,则消息正文将与磁盘上已有的内容匹配——消息内容永远不会被代理更改。第二条信息是消息在每个队列中的存在:它在队列中的位置、它的邻居是什么,以及它的特定于队列的状态是什么。这第二条信息允许 RabbitMQ 启动、从磁盘恢复消息和队列,并确保每个队列中的消息与 RabbitMQ 关闭时的顺序相同。
因此,RabbitMQ 的默认后备存储由一个节点全局的消息存储组成,该存储只关心将消息内容写入磁盘;以及每个队列的队列索引,它使用非常不同的格式将每个消息每个队列的数据写入磁盘。由于这两种需求非常具体,因此可以应用很多优化(而且我们已经应用了!)。
通用数据库基准测试通常表明读取性能远远超过写入性能。如果不是这样,那么通常意味着写入实际上没有进入磁盘(使用fsync
),或者存在导致读取性能下降的错误。实际上,数据库历来都是针对读密集型工作负载进行优化的。这与其一般用例相匹配:存在一个缓慢扩展的数据集,必须以各种不同的方式查询该数据集。删除操作往往很少见:如果您考虑在关系数据库之上的典型网站购物车,那么除非客户删除其帐户,否则几乎没有理由发出删除操作——即使产品停产,您也可能只会设置该产品行的标志,否则您可能会阻止客户查看其订单历史记录(假设它是规范化的)。
因此,大多数数据库中的大量数据是相当静态的。这与消息代理中的数据完全相反:对于我们来说,读取数据是最罕见的操作,而写入和删除数据是常见的情况。理想情况下,如果 RabbitMQ 在大量内存中运行,则根本不会有任何来自磁盘的读取操作。只有对于以必须写入磁盘的方式发布的消息才会进行写入,即使那样,只要我们能够足够快地将消息发送给消费者,也有很多方法可以优化这些写入。我们只有在内存压力迫使我们把消息写入磁盘并忘记 RAM 中的消息时才会读取数据。读取性能当然很重要:我们努力确保 RabbitMQ 尽快摆脱数据(不使用/dev/null
),并且能够快速从磁盘读取消息是其中的一部分。但首先避免写入才是目标。
实际上,就消息代理而言,最好将 RAM 视为磁盘的一个大型写回缓存,然后任务就是优化此缓存的管理,以通过尽可能延迟写入来最大限度地减少写入,希望在写入真正进入磁盘之前发生相应的删除。这显然与普通数据库非常不同,普通数据库不会试图从数据的生命周期如此之短中获益,就像它在消息代理中经常出现的那样。
这并不是为了阻止人们努力使 RabbitMQ 与替代后备存储一起工作,而仅仅是为了解释为什么我们在为 RabbitMQ 编写新持久化(首次随 RabbitMQ 2.0.0 版发布)时决定自己做而不是使用现成的存储。它解释了为什么直接在普通数据库之上构建高性能消息代理最多很棘手,以及为什么消息代理中的数据性质与数据库中的数据性质非常不同。