logo

使用公平队列防止多租户服务中的吵闹邻居问题

Published on

Broccoli queueing illustration 图 1: 消息被瓶颈化的插图。

那是一个深夜,在 Trieve 办公室,我们刚刚将迄今为止最大的客户接入平台。这时,我们的手机开始亮起,Slack 和 Discord 频道涌入投诉。客户报告他们的文档已经几个小时无法索引。

罪魁祸首是我们最新的客户,他们将数百万文档倾倒到我们的服务中,完全堵塞了我们的摄取管道。我们尝试增加更多工人来解决问题,但没有帮助。他们都忙于处理这个庞大的任务,而其他人的数据则在无尽的队列中等待。

我们需要一种更好的方式来管理队列,并确保所有客户得到公平对待。

“吵闹邻居”问题的剖析

我们所看到的现象有一个名字:“吵闹邻居”问题。在多租户系统中,一个贪婪的租户可以饿死其他人。

想象你是一个 Cursor,正在索引数百万代码库。每个仓库被分解成数千个文件,每个文件都需要通过摄取管道——分词、向量化、分块和嵌入。

然后,一个新客户注册了一个包含数千万文件的巨型单仓库。一旦他们的任务进入管道,你只能惊恐地看着其他客户的索引请求停滞不前。在接下来的几个小时里,你的系统被单个租户劫持。

FIFO queue illustration 图 2: 在传统的 FIFO 队列中,小任务被困在大任务后面等待。

这是建立在传统先入先出 (FIFO) 消息队列系统中的根本缺陷。FIFO 只关心谁先到,而不是谁最需要服务。

为什么传统修复方案不足

在处理吵闹邻居时,有几个常见解决方案浮现在脑海中。

A. 节流客户端

第一个本能是限制客户发送消息的速度。但这种方法基本上是说“你的突发流量现在是你自己的问题来解决。”

客户最终不得不构建复杂的重试逻辑,实现自己的队列,并处理管理背压的操作难题。同时,你陷入构建复杂的全局速率限制系统,这会增加基础设施的复杂性。

B. 专用工人队列

下一个想法听起来合理:为大客户提供他们自己的专用资源。我们实际上在 Trieve 尝试过,为我们最大的客户启动单独的工人舰队。

现实远非理想。这些客户会发送海量突发工作,比如在一小时内上传数百 GB 的数据,之后是长时间的沉默,让昂贵的工人实例完全闲置。

更糟糕的是,我们发现小客户偶尔会有自己的流量峰值,迫使我们陷入噩梦场景:手动从共享队列中提取他们的消息并迁移到专用基础设施。操作开销是不可持续的。

解决方案:公平队列

在这些传统方法碰壁后,我们意识到我们面对的不是基础设施问题,而是逻辑问题。我们需要一个更智能的队列。

传统队列就像在 DMV 有一个巨大的队列。每个人按照他们到达的顺序得到服务,这听起来公平,直到有人带着 100 辆车的文书工作出现。

公平队列不同。它就像为每个客户提供单独的队列,但有一个接待员轮流从每个队列中叫一个人。

Fair queue illustration 图 3: 公平队列确保每个客户都有机会,防止饥饿。

忙碌的客户保持忙碌,但他们不能饿死安静的客户。如果客户 A 有 1000 条消息,客户 B 有 1 条,客户 B 不必等待客户 A 的所有 1000 条消息清除。系统只是交替处理它们:A, B, A, A, A… 每个人都在进步。

Broccoli 如何实现公平性

我构建了 Broccoli 来实现这个概念。核心架构很简单,建立在仅两个主要组件上:每个客户端的专用队列和单个轮询调度器。这种设计使其可靠且易于调试,这对关键基础设施至关重要。

以下是公平队列的实现方式:

Fairness algorithm illustration 图 4: 公平算法的伪代码。

当消息到达时(插入操作):

  1. 我们将消息存储在专属于该特定客户端的队列中
  2. 我们检查该客户端的 ID 是否已经在我们的轮询调度器中
  • 如果他们已经在轮转中,我们就完成了。他们会得到他们的机会
  • 如果他们是新的,我们将他们的客户端 ID 添加到轮询队列的末尾

当工人准备好处理时(读取操作):

  1. 我们从轮询调度器中获取下一个客户端 ID
  2. 我们转到该客户端的专用队列并获取他们的一条消息
  3. 处理后,我们检查他们是否有更多工作等待
  • 如果他们的队列为空,他们自然地从轮转中退出
  • 如果他们仍有消息,我们将他们的客户端 ID 放回队列的末尾

这种方法的美丽之处在于它是完全自平衡的。忙碌的客户端留在轮转中,安静的客户端自动退出,无论他们排了多少工作,每个人都公平获得处理时间。