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),或者存在一个正在扼杀读取性能的 bug。事实上,数据库在历史上一直针对读密集型工作负载进行了优化。这符合它们的通用用例:存在一个缓慢增长的数据集,必须以各种不同的方式进行查询。删除通常很少发生:如果您考虑基于关系数据库的典型网站购物车,那么除非客户删除他们的帐户,否则几乎没有理由发出删除操作——即使产品已停产,您也可能只是在该产品行上设置一个标志,因为否则您可能会阻止客户查看他们的订单历史记录(假设它已规范化)。
因此,大多数数据库中的海量数据相对是静态的。这与消息代理中的数据完全相反:对我们来说,读取 数据是最罕见的操作,而 写入 和 删除 数据是常见情况。理想情况下,如果 RabbitMQ 在内存充足的情况下运行,将永远不会有任何磁盘读取。只有在消息以必须写入磁盘的方式发布时才会写入,即使如此,只要我们能足够快地将消息发送给消费者,也有许多方法可以优化这些写入。我们只在内存压力迫使我们将消息写入磁盘然后从 RAM 中忘记消息时才读取数据。读取性能当然很重要:我们努力确保 RabbitMQ 尽快处理数据(不使用 /dev/null),并且能够快速从磁盘读取消息是其中的一部分。但首先避免写入才是目标。
事实上,就消息代理而言,最好将 RAM 视为磁盘的大型写回缓存,然后任务就是优化此缓存的管理,以最大限度地减少写入,通过尽可能延迟写入,希望能在大约在写入实际到磁盘之前发生相应的删除。这显然与普通数据库非常不同,普通数据库不会试图从数据生命周期如此之短中获益,而这在消息代理中经常发生。
这一切都无意阻止 RabbitMQ 与替代后端存储配合工作的努力,而只是为了解释为什么在为 RabbitMQ 编写 新持久化器(首次随 RabbitMQ 2.0.0 版本发布)时,我们决定自己动手,而不是使用现成的持久化数据存储。它解释了为什么直接在普通数据库之上构建高性能消息代理充其量是困难的,以及为什么消息代理中的数据性质与数据库中的数据性质非常不同。