在软件开发中,队列系统对可靠性非常重要,但却很难构建。关于如何构建队列系统有很多争论--Postgres 和 SKIP LOCKED 经常被提及。

我们花了很长时间研究和构建我们自己的分布式队列系统,在这篇文章中,我们将解释其中的挑战、为什么我们需要构建它以及它是如何工作的。

构建队列系统的挑战

我们将 Inngest 作为现代应用程序的可靠性层来构建。它将持久执行、事件流和队列结合到一个无服务器平台中,使开发人员能够在代码中编写声明式步骤函数。

Inngest 在平台内管理事件流、队列和状态。为代码库的每个分支创建环境并自动部署功能。团队可能会在数十个分支中向 Inngest 部署数百个函数,从而导致数千个函数实时(约 10-100ms 内)对事件做出反应。

当我们的用户数量扩展到数千人,并触发数十亿个函数运行时,我们了解到开发基于队列的平台所面临的巨大挑战。

如果试图使用任何现成的队列系统来构建这样一个系统,很快就会陷入困境。以下是您将遇到的一些关键挑战:公平性。每秒发送 5000 个事件。这并不难管理,但在一个队列中,你的工作会阻碍其他用户的工作运行。这是不公平的。一个用户不应该阻止另一个用户的工作。大多数队列都以 "工作者 "模式运行:你可以指定工作者应监听哪些队列,以及工作者可处理的作业量。为每个功能创建新的 Worker 既乏味又麻烦。由于开发分支是短暂的、突发性的,因此情况更糟。由于数以千计的客户在运行数以千计的包含并行步骤的步骤函数,因此在任何时候都有数以百万计的工作可供选择。在典型的队列系统中,工人们都会争抢最早的工作,导致大量的旋转和精力浪费。在其他队列系统中,无法在分布式工人之间的功能级别上定制并发性。通常,并发管理是在 Worker 轮询逻辑中实现的。在 Inngest 中,只需一行代码就能在单个函数上设置多个并发设置。这就为管理运行创建了虚拟队列。每秒处理数以万计的作业会导致每秒数十万次请求:入库、"锁"(我们将讨论这个问题)、出库都会给运行队列的后备基础架构带来沉重负担。队列需要可靠和容错,这意味着我们需要内置分布式系统(这也会影响延迟)。大多数队列都不透明,开箱即用,几乎没有可观察性。很难为积压之外的步骤函数建立实时可观察性:例如,等待其他事件的函数数量,或墙时间与执行时间的柱状图。为基本队列系统扩展高级功能非常困难,而且耗费大量时间。定制功能包括:逐个函数更改回退、根据作业数据取消积压、批处理、去抖、智能索引和步进函数并行性。

为了解决所有这些问题,我们从头开始为 Inngest 开发了一个全新的队列。这是一个公平、低延迟、多租户队列,由多个无共享的工作者以(几乎)无竞争的方式执行作业。

在本系列博文中,我们将介绍队列系统的工作原理以及解决这些问题的设计决策。

可靠性层如何使用队列

为了设置上下文,让我们通过一个使用我们的 TS SDK(也有 Go 和 Python SDK,更多即将推出)配置函数的示例,来了解 Inngest 如何使用队列:

tsxinngest.createFunction( { id: "update-user",// Unique function ID concurrency:15,// Concurrency controls per function (https://innge.st/concurrency) debounce:{ // 去抖动管理 (https://innge.st/debounce) period: "5s", timeout: "20s", }}, { event: "clerk/user.created" },async ({ event, step }) => {constuser=awaitstep.run("Load user info",async () => {returnawaitclerk.users.getUser(event.data.userId); });returnawaitstep.run("Update DB",async () => {constupdates=userFields(user); // grab fields from clerkreturnawaitdb.users.where({ clerk_id:user.id }).update(updates); }); });If you're familiar with step functions, you probably already see what's happening here.这段代码创建了一个函数,当

clerk/user.created 事件时自动运行的函数。这是一个步骤函数,它使用持久执行来可靠地运行代码。这意味着每个步骤都是一个代码级事务,由队列中自己的作业支持。如果步骤失败,它会自动重试。从步骤返回的任何数据都会自动捕获到函数运行的状态中,并在每次步骤调用时注入。

还有一些额外的细微差别。函数指定了自己的并发限制和缓冲。并发限制可防止同时运行超过 15 个步骤。缓冲安排函数运行 5 秒钟,如果在此期间收到任何其他匹配事件,Inngest 会重新安排函数在 5 秒后运行(直到超过最大超时时间)。

所有这些都是基于我们的队列系统自动完成的,无需提供队列或基础设施。具体情况如下:

当接收到一个事件时,我们会以empempotently方式排入一个调用函数处理程序的任务。这必须是empotent的,因为事件流至少有一次,我们希望一个事件只运行一个函数。

去抖动会使函数的运行复杂化:作业会有 5 秒钟的延迟(或函数配置中的任何延迟),任何其他事件都会再次延迟作业。

当任务变得 "可见 "时,我们会确保函数没有超出并发限制,并通过调用接收到的事件数据开始工作。

一旦函数运行了一个步骤,我们就会将该步骤的结果存储在函数运行状态中,并(在最坏的情况下)为下一个步骤启用另一个作业。对于长期运行的服务器来说,这里有一些细微差别,不过我们会在执行深度剖析中单独讨论。

从本质上讲,一切都会对队列产生影响。可靠地运行函数必须使用队列。并发、去抖和批处理都会影响队列在单个作业级别的工作方式。