任务队列看似简单实则棘手
摘要
一篇探讨任务队列隐藏复杂性的技术博客文章,分析了它们为何看似简单实则棘手,并提供了系统设计的有用视角,如警惕队列、限制和故障模型。
<p><a href="https://lobste.rs/s/k3frwc/job_queues_are_deceptively_tricky">评论</a></p>
查看缓存全文
缓存时间: 2026/07/14 08:14
# 作业队列看似简单,实则暗藏玄机
来源:https://typesanitizer.com/blog/job-queues.html
作为程序员,有趣的一点在于:当我深入探究那些原本不甚了解的系统时,表面上看似简单的系统往往会暴露出底层复杂性的有趣层面。换句话说,现实拥有惊人的细节(https://johnsalvatier.org/blog/2017/reality-has-a-surprising-amount-of-detail)。在这篇文章中,我想聊聊作业队列——过去几天我一直在思考它(在脑海中起草本文时,我意识到之前在上一份工作时也思考过它,但远没有现在这么清晰)。
我所说的“作业队列”指的是什么?我指的是一个系统,其中涉及提交批处理作业、调度它们以及运行它们的某种概念。通常,系统预期是 FIFO 或类似 FIFO 的顺序,但这并非强制要求。通常,队列会捆绑一种原生的方式来定期调度作业——这允许你使用配置文件(例如 JSON 或 YAML)来指定提交内容。
在各种吞吐量要求高但延迟要求不那么高的场景中,作业队列自然会出现。持续集成是一个常见的例子。另一个例子是用于数据分析的摘要。
在过去一两年中,我发现对系统设计思考有用的几个视角是:
- **警惕队列**:队列通常不是接近满就是接近空(https://lmax-exchange.github.io/disruptor/disruptor.html%3Csub%3Ethe_problems_of_queues),需要认真考虑合适的容量。我还从 Marc Brooker 的博客中学到了关于队列延迟的一些反直觉行为。在我读过的关于队列的博客文章中,重点通常放在延迟上,而不是吞吐量上——我认为这也是为什么我之前从未在心理上将“队列”和“作业队列”联系起来的部分原因。
- **限制**:这基于 TigerBeetle 团队编写的 Tiger Style(https://github.com/tigerbeetle/tigerbeetle/blob/main/docs/TIGER_STYLE.md)指南,其中讨论了对各种事物的显式限制。推广开来,这引出了容差和模块化推理预算的概念。有趣的是,在 Apple 工作之初,我记得看到某些团队讨论他们需要争取多少内存预算时(尤其是底层代码),曾感到有些困惑。随着时间的推移,我对这种方法越来越尊重。
- **故障模型**:大致来说,故障模型描述了你对依赖项中错误和可靠性的假设。我从 TigerBeetle CEO Joran Dirk Greef 的演讲和推文以及 Alex Miller(https://transactional.blog/)的文章中学习了很多关于这个主题的知识。稍后我会在本文中尝试展示这些视角如何应用于系统设计。
## 当前问题
在`$WORK`,我们有一个后台作业用于打包“引用仓库”。引用仓库是一个经过激进重新打包的 git 仓库——如果你不熟悉重新打包,可以将其理解为“更激进的压缩”。这些仓库存储在对象存储中,新仓库可以通过下载引用仓库并获取默认分支顶部的增量变更来在机器上设置。对于非常大的仓库,与更典型的`git clone`操作相比,这种方法有助于减少下载者的延迟。
关于 git 如何执行激进重新打包的具体细节与本文关系不大。真正重要的是,大致有两种形式的重新打包可用:
- **全面重新打包**:这会导致 git 忽略有关打包方式的任何基线信息,并从头开始重新计算所有内容。假设对于正在测试的仓库,这需要 7 小时。如果你对 git 操作需要数小时感到惊讶,那么(1)你应该庆幸自己没有这个问题,(2)这里的一些子操作似乎是单线程的,无论仓库大小如何。
- **增量重新打包**:这会导致 git 重用仓库中已存储的关于打包的基线信息,因此只重新打包已更改的内容。所以,如果你执行了`clone`,然后执行了`fetch`,那么只有`fetch`带来的新更改会被重新打包,并且只会对`clone`操作接收到的历史进行一些粗略检查。假设这需要 2 小时。
好了,这就是背景。我们可以选择更昂贵的方式,耗时 7 小时,但能获得更小的引用仓库,这意味着更快的下载、更快的解包和更低的磁盘使用量。大致来说,全面重新打包的仓库可能比增量打包的仓库小 50-60%。我们也可以选择更便宜的方式,耗时 2 小时,但能获得更新的引用仓库,这意味着如果下载者仍然关心获取最新更改,那么随后获取的增量会更小,因此速度更快,对服务器的负载也更小。但是,如果额外的磁盘使用量达到数百 MB 或 GB,那就不太好了。
另外需要注意的是,由于这是一个代码仓库,下游消费者在工作日比周末活跃得多。
## 两全其美?
看到上述二分法后,一个自然的下一步是提出以下建议:为什么不在周末进行全面重新打包,而在工作日进行增量重新打包呢?这似乎可以保证工作日(消费者更活跃)的更新性,而周末的全面重新打包可以确保引用仓库大小的增长更缓慢。为简单起见,假设我们用形如`-\.tar`的键将引用仓库写入某个存储桶。为了保持增量打包仓库的较小体积,可以采取以下两种方式之一:
- 使用相同的键方案写入全面重新打包的仓库,并让增量重新打包作业从上次成功的重新打包(无论是全面还是增量)引导,而不是从`clone`操作开始。
- 使用修改后的键方案写入全面重新打包的仓库,例如`\--packed\.tar`,并从该键位置引导增量重新打包。然而,这可能会产生更新其他消费者的需求,以避免他们在周末(假设周末不运行增量重新打包)落后太多。
假设我们选择第一种方式,因为它看起来更简单。那么现在的问题是,我们如何处理这个调度?
典型的作业队列出于充分理由只向客户端暴露有限的调度控制——提供大量旋钮会增加出现意外调度决策的风险。当我在上面写“自然的下一步”时,我本质上是在从编写控制循环的角度思考。但当使用作业队列时,我(顾名思义)无法访问控制循环——是别人编写了这个循环,我需要查看有哪些可用的配置旋钮来自定义行为。现在,假设队列暴露了两个配置旋钮:
- **调度间隔**:作业按照此间隔配置的定时器启动。
- **并发限制**:特定配置的最大运行作业数。
在我们开始这个优化旅程之前,假设这些配置旋钮设置如下:
- 调度间隔:9 小时,为作业的 7 小时运行时间留出一些余量。
- 并发限制:1,因为并发执行相同的作业没什么意义。
同样,一个自然的下一步可能是思考:“9 小时间隔对于 7 小时运行时间的作业来说足够了。由于工作日的增量重新打包作业需要 2 小时,让我们将间隔设为 3 小时。在周末,当全面重新打包作业运行时,它在 3 小时内不会完成,因此即使定时器再次触发,并发限制也会阻止任何其他作业运行,所以应该没问题。”
不幸的是,亲爱的读者,这种简单的推理行不通。
## 可能的语义集合
暂时让我们停止思考如何*使用*作业队列。相反,让我们思考如何*实现*一个作业队列。假设有一个基于配置`J`运行的作业`J1`。在调度间隔过去后,假设我们想创建一个新作业`J2`,而`J1`尚未完成。我们应该如何处理`J2`?
基本上,只有四种选择:
- **并行生成**:如果并发限制高于 2,那么我们可以直接开始运行`J2`。
- 如果并发限制为 1,那么我们有三种选择:
- **偏好新作业**:停止`J1`并启动`J2`。
- **等待**:等待`J1`完成,然后启动`J2`。
- **偏好旧作业**:自动取消`J2`,让`J1`继续运行。
稍停片刻,问问自己,对于以下问题的直观答案是什么:这些可能的语义中哪些值得实现,或者可以称为合理?
如果你和我一样,你可能认为并行生成、偏好新作业和等待可能是合理的,而偏好旧作业感觉很奇怪/倒退。特别是对于偏好新作业与偏好旧作业,你可能用以下推理为自己辩护:“如果这种情况发生,作业所有者可能更关心较新的结果而不是较旧的结果,因此支持偏好新作业是有道理的,但支持偏好旧作业似乎很奇怪,谁会想要*那个*呢?”
---
回想一下之前的视角:警惕队列、限制和故障模型。
因此,首先要指出的是,**等待**选项需要一个(逻辑上的)按作业配置的队列。该队列可能应该是有界的(根据限制)。好的,那么限制应该如何决定?当达到限制时会发生什么?在什么利用率水平下我们应该开始告警(即队列大小的*软*限制是多少)?队列是否应该严格遵循 FIFO?这些都是要问的好问题!没有放之四海而皆准的答案,但重要的是要意识到,如果支持**等待**语义,这些问题是*应该*被问及的。
如果我们考虑限制,**偏好新作业**语义意味着调度间隔也是一个硬限制。从概念上讲,这些是不同的东西——你可以想象如果后者作为单独的配置旋钮提供,可以独立地改变它们。
最后,这些语义在什么样的故障模型下才有意义?
- **偏好新作业**:由于某些非确定性原因(例如遇到速率限制),`J1`的运行时间超过了调度间隔。我们很乐观,因此可能`J2`不会遇到同样的问题。换句话说,我们期望作业能尽快再次成功。
- **等待**:同上。此外,还有一个非功能性需求(或假设!),即丢弃作业不是首选。
- **偏好旧作业**:假设`J1`没有挂起,那么如果给它更多时间,它很可能会完成。你仍然希望有一个独立于调度间隔的硬限制,以防止资源使用失控。我们很悲观,因此假设`J2`的运行时间也会超过调度间隔。所以最明智的做法是取消`J2`,这样至少`J1`有机会完成。
## 我只是想做一件简单的事
为了唤起你的记忆,我们试图做的是:
- 我们希望在同一个作业上运行快速路径(工作日,耗时 2 小时)和慢速路径(周末,耗时 7 小时)。
- 我们有一个调度间隔的配置旋钮。我们曾考虑将其设置为 3 小时,以便在工作日获得更新的结果。
- 我们有一个并发限制为 1,以避免同时运行多个作业。
让我们尝试看看这样的配置在周末对于不同语义的表现如何:为简单起见,假设调度操作是瞬时的。
- **偏好新作业**:当 7 小时作业达到 3 小时标记时,它会被取消。一个新的 7 小时作业会被触发,并在 3 小时后再次被取消。基本上,我们将在 16 个作业中浪费 48 小时的计算能力,因为它们中的任何一个都不会完成。
- **等待**:在周末的 48 小时内,我们会触发 16 个作业,总计算需求为 16×7 = 112 小时。如果队列限制高于 (112-48)/7 = 64/7 = 9.15,那么周末结束时我们将有大约 64 小时的待处理工作。然而,工作日仅提供 (24×5×(3-2)/3) = 40 小时的空闲时间,因此在下一个周末开始之前我们无法排空这个队列。
- **偏好旧作业**:在 48 小时内,我们几乎可以完成七个 7 小时作业,剩下约 1 小时的待处理工作。我们将取消 9/16 的作业。周一我们就能很快恢复正常调度,因为周末只留下了 1 小时的额外工作。
因此,看起来**偏好旧作业**是最合适的选择。是的,这仍然可能看起来反直觉;我们稍后会涉及到这一点。
另一个额外有趣的事情是,如果你使用**等待**策略,很容易在实际上没有帮助的情况下掩盖问题。也许你在周末因为队列长时间非空而被寻呼。于是你决定增加并发数。你选了一个好整数,比如 4。新的运行者开始处理作业,太好了!现在,这些昂贵的作业运行 7 小时,但它们的大多数结果在周末只对大约 3 小时有用,然后就会被取代。😬
回到**偏好旧作业**的适用性,让我们再看看我们在故障模型中写的内容:
> - **偏好新作业**:[……]我们期望作业能尽快再次成功。
> - **等待**:同上。此外,还有一个非功能性需求(或假设!),即丢弃作业不是首选。
> - **偏好旧作业**:[……]我们假设`J2`本身也会超过调度间隔。[……]
对于我们试图在周末拥有的调度(3 小时间隔的 7 小时作业),它与**偏好旧作业**的假设非常吻合,但直接违背了**偏好新作业**和**等待**的假设。此外,我们的周末工作负载完全可以接受在作业正在运行时丢弃作业。但**等待**语义会让我们为我们并不关心的东西付出高昂代价,并且可能需要在上面采取更昂贵的解决方案。
如果不提供**偏好旧作业**语义,你就无法真正用常规调度和限制并发这两个原始操作来模拟它。对于上述工作负载,如果你有一个更丰富的原语,比如类似 cron 的调度,你可以将工作拆分为两个独立的作业——一个仅在工作日运行,另一个仅在周末运行。
## 事情可能更糟
之前,我们在考虑故障模型。这挺有意思的,对吧?想象一个与我们一直在讨论的系统不同的独立系统。有一个全局并发限制来控制成本。你有一堆不同的作业进入,大多数在后台启动。所有内容都进入一个 FIFO 队列。没有人设置全局大小限制。每隔几个月,会有客户尝试与作业队列相关的功能。他们手动启动一些新作业,看到队列位置是 10 万以上(甚至 100 万以上)。他们会说“呃,这个功能似乎不起作用?”然后升级给支持工程师。它变成了你看板上的一个工单。一位工程师不情愿地手动运行`DELETE`清除了队列。新经理问:“我们如何才能从系统层面解决这个问题?”你花了几天时间分析问题。你查看运行时间。你查看队列增长图。啊,很难预测需要多长时间……
相似文章
为什么队列不能解决过载(以及应该怎么做)
解释了为什么无界队列是软件系统中的一个bug,利用利特尔法则和浴缸类比说明队列只能吸收波动,而非持续负载。讨论了延迟死亡螺旋,并主张改用背压机制。
使用 Postgres 作为作业队列的潜在后果
文章分析了使用 PostgreSQL 作为作业队列的可扩展性限制,特别强调了高并发下 MultiXact SLRU 争用导致的性能瓶颈。文章解释了为什么这种架构在开发环境中表现良好,但在生产环境中却会失败,并建议考虑替代方案。
队列无法解决过载问题 (2014)
一篇解释为何队列不是处理系统过载的有效解决方案,并探讨更好方法的文章。
The Elm Architecture中的批量任务
一篇详细的博客文章,关于使用elm-run在The Elm Architecture中实现批量任务,重点关注状态机和类型安全转换。
负载均衡系统的惊人经济学
一篇博客文章分析了M/M/c队列模型,并表明在负载均衡系统中增加服务器数量,在恒定每服务器负载下可以改善延迟,这是云经济学中一个有益且有些违反直觉的结果。