Hans-Jörg Schnedlitz Hans-Jörg Schnedlitz 发表于 2025年5月7日 An Introduction to Solid Queue for Ruby on Rails

本页内容

分享这篇文章

您的应用程序是否出现故障?AppSignal 会通知您。 AppSignal 监控

Rails 8 最令人兴奋的新增功能之一无疑是 Solid Queue,这是一个用于处理后台任务的新库。

您可能认为这没什么大不了的。毕竟,市面上有很多其他的队列系统。如果您使用 Rails,您可能了解 Sidekiq 和 Resque,它们都具有出色的性能和可靠性。还有 GoodJob 和历史悠久的 DelayedJob。在所有这些可用选项中,我们真的需要另一个队列系统吗?

让我们一起来看看。在这个由两部分组成的系列中,我们将深入研究 Solid Queue 的内部结构,发现它的独特之处,并更多地了解它最初创建的原因。

为什么选择 Ruby on Rails 的 Solid Queue?

自 Rails 7 以来,37Signals 团队一直在努力减少启动新 Rails 应用程序所需的运营开销。作为其中的一部分,他们将 SQLite 作为 Rails 应用程序的新默认数据库,即使在生产环境中也是如此。此外,他们开始努力消除额外的基础设施依赖项,以充分利用这个新的默认设置。

37Signals 之前一直使用 Resque,而 Resque 需要 Redis 才能运行。Sidekiq 也是如此。为了摆脱 Redis,他们必须创建一个仅依赖于数据库的队列系统,而这个队列系统最终变成了 Solid Queue

这就是它的主要卖点:没有额外的依赖项;只需使用您的数据库。非常好!但是,与任何队列系统一样,尤其是作为新的 Rails 默认队列系统,Solid Queue 需要满足一些严格的要求。

它必须提供 Rails 开发人员从其他后台任务系统中习惯的所有功能。作为 Rails 的默认队列,它必须支持 Rails 可以使用的所有数据库。显然,它需要满足标准的安全性要求,即绝不能丢失任务。最后但同样重要的是,它必须足够快,才能成为大型生产系统的可行选择。

这是一个相当高的要求!那么,Solid Queue 如何满足所有这些要求呢?

Solid Queue 概览

有很多细节需要考虑,但让我们从一个高层次的架构概述开始。您需要了解两个重要的组成部分:Jobs 和 Workers。

Job 是一个 ActiveRecord 模型,也是用户与之交互的对象。请注意,对于其他 ActiveJob 后端而言,情况不一定如此,这只是 SolidQueue 实现后台任务的方式。如果您需要创建一个新的后台任务,那么这就是您需要继承的类。Job 还定义了使您能够将任务入队的方法,例如 Job.perform_later

Ruby

# app/jobs/my_job.rb
class MyJob < ApplicationJob
 queue_as :default
 def perform
  # Do something later
 end
end

Workers,顾名思义,是执行实际工作的元素。这些通常不是由程序员直接创建的,而是根据您配置应用程序的方式自动创建的。例如,要让您的应用程序分别生成两个监听所有队列和两个特定队列的 workers,您可以使用以下配置文件:

YAML

# config/queue.yml
production:
 workers:
  - queues: "*"
  - queues: [default, critical]

Workers 作为进程生成,在后台运行,等待任务分配给它们。您可能已经猜到,您的数据库是任务和 workers 之间的缺失环节。每当 Solid Queue 执行任何操作时,都会涉及一个或多个数据库表。SolidQueue 会做很多事情,因此需要 很多表

Ruby

# lib/generators/solid_queue/install/templates/db/queue_schema.rb
ActiveRecord::Schema[7.1].define(version: 1) do
 create_table "solid_queue_jobs", force: :cascade do |t|
  # ...
 end
 create_table "solid_queue_ready_executions", force: :cascade do |t|
  # ...
 end
 create_table "solid_queue_scheduled_executions", force: :cascade do |t|
  # ...
 end
 create_table "solid_queue_claimed_executions", force: :cascade do |t|
  # ...
 end
 create_table "solid_queue_blocked_executions", force: :cascade do |t|
  # ...
 end
 create_table "solid_queue_failed_executions", force: :cascade do |t|
  #...
 end
 # Lots more tables below...
end

↓ 文章继续

Left squiggle

您的应用程序是否出现故障或速度缓慢?AppSignal 会通知您。

AppSignal 监控 → Right squiggle

SOLID Job 的生与死

为了了解所有这些表的作用以及它们如何与 Solid Queue 的各种功能相关联,让我们看一下 job 的生命周期。当用户将 job 排队以供稍后执行时(例如 MyJob),会在 solid_queue_jobs 表中创建一个记录。该记录包含执行 job 所需的所有数据,包括参数、名称、放入的队列等等。如果该 job 被排队以便尽快运行(而不是计划在稍后的某个时间点运行),则会在 solid_queue_ready_executions 中写入另一个记录。

例如,运行 MyJob.perform_later 会导致以下 SQL:

SQL

INSERT INTO "solid_queue_jobs" ("queue_name", "class_name", "arguments", "priority", "active_job_id", "scheduled_at", "finished_at", "concurrency_key", "created_at", "updated_at")
 VALUES ('default', 'MyJob', '{"job_class": "MyJob","...",}', 0, '...', '2024-12-01 14:00:00', NULL, NULL, '2024-12-01 14:00:00', '2024-12-01 14:00:00')
 RETURNING "id"
INSERT INTO "solid_queue_ready_executions" ("job_id", "queue_name", "priority", "created_at")
 VALUES (1, 'default', 0, '2024-12-01 14:00:00')
 RETURNING "id"

您的 workers 轮询此表以获取新记录。找到新记录的 worker 进程将首先通过在 solid_queue_claimed_executions 表中写入另一个记录来声明它,我们稍后将了解为什么这是必要的。只有这样,worker 才会实际执行 job。以下是一些经过大量编辑的代码,用于说明正在发生的事情(实际代码中发生的事情要多得多)。如果您对具体细节感到好奇,我强烈建议您 查看原始源代码

Ruby

class Worker
 def run
  loop do
   break if shutting_down?
   unless poll > 0
    # Polling interval is configurable and defaults to 1ms
    sleep(polling_interval)
   end
  end
 end
 def poll
  # Claim jobs and then execute claimed jobs.
  claim_executions.then do |executions|
   executions.each do |execution|
    # Actually execute the job
   end
  end
 end
 def claim_executions
  # Query the ready executions table and claim a job for execution.
  with_polling_volume do
   SolidQueue::ReadyExecution.claim
  end
 end
end

一旦 worker 完成一个 job,它会从 solid_queue_jobssolid_queue_ready_executionssolid_queue_claimed_executions 表中删除相应的记录。这就是全部内容,只是轮询一些表,创建和删除记录。不是那么棘手,对吧?如果没有需要考虑的关键非功能性需求,那将会是这样。

关于性能

为了实现生产就绪的性能,Solid Queue 使用了巧妙的数据库设计。您可能想知道为什么 workers 轮询 solid_queue_ready_executions 而不是 solid_queue_jobs。乍一看,额外的表似乎是多余的。

请考虑,solid_queue_jobs 可能包含数千甚至数百万条记录,而查询这些数据需要时间。相比之下,solid_queue_ready_executions 非常小,因为它只包含必须立即执行的 job 的记录!这会带来一些严重的加速。

引入额外的表也简化了查询。Workers 仅使用两个不同的查询进行轮询。它们要么轮询所有队列,要么轮询特定队列。反过来,这允许一些不错的 覆盖索引

SQL

SELECT job_id
 FROM solid_queue_ready_executions
 WHERE queue_name = "default"
 ORDER BY priority ASC, job_id ASC
 LIMIT 4
 FOR UPDATE SKIP LOCKED

Ruby

# Indices for polling solid queue ready executions
create_table "solid_queue_ready_executions", force: :cascade do |t|
 t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
 t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
end

所有这些仍然不足以实现真正出色的性能。传统上,依赖于轮询表的队列系统存在一个重要问题。一个 worker 会在查询和更新轮询表时阻止所有其他 worker。

让我们看看为什么。考虑以下查询:

SQL

SELECT id
 FROM jobs
 WHERE queue = "default"
 AND claimed = 0
 ORDER_BY priority, id
 LIMIT 2
 FOR UPDATE;

FOR UPDATE 语句锁定查询选择的行。这是必要的,以避免出现糟糕的竞争条件,例如多个 workers 抢夺同一 job。但这也意味着运行此查询的任何 worker 都会阻止对表的读取访问。因此,其他 workers 将不得不等待该查询完成。轮询表成为阻碍快速 job 执行的瓶颈。

幸运的是,现代数据库(PostgreSQL >= 9.5, MySQL >= 8.0)解决了这个问题。SKIP LOCKED 语句允许数据库仅锁定正在更新的记录。表的其余部分保持解锁状态,可以自由地并发轮询。

SQLite 不支持 SKIP LOCKED,因此 worker 进程必须排队。在大多数情况下,这不应该是一个问题。由于数据库存在于磁盘上,因此 SQLite 写入速度很快。即便如此,这是一个您应该注意的限制。

无论您使用的是 SQLite 还是其他数据库,AppSignal 都提供了开箱即用的 Solid Queue 性能监控!在本系列的第二部分中,我们将详细讨论这一点。

安全第一

我们花了一些时间讨论 solid_queue_ready_executions,但另一个表对于确保 Solid Queue 可靠运行至关重要。任何队列系统的一个关键要求是,任何正在排队的 job 至少执行一次。换句话说,job 绝不能丢失,我们已经在介绍中暗示了这一点。

如果没有额外的安全措施,这可能会很快发生。想象一下,一个 worker 开始处理一个 job,并在这样做时更新相应的 job 记录以声明它。当然,这是必要的,以避免多个 workers 同时运行一个 job。

想象一下,突然之间,这个 worker 进程在没有完成执行的情况下死亡。您的机器可能会崩溃,并且操作系统可能会杀死 worker 以使其消耗过多的内存,您知道,事故会发生。它声明的 job 将永远卡住,因为没有其他 workers 可以获取它。因此,它将永远不会被执行,并且您的用户会感到悲伤和愤怒。结束。

也就是说,除非我们添加额外的安全措施。Solid Queue 通过引入更多的表来解决这个问题,即 solid_queue_claimed_executionssolid_queue_processes

Ruby

ActiveRecord::Schema[7.1].define(version: 1) do
 create_table "solid_queue_claimed_executions", force: :cascade do |t|
  t.bigint "job_id", null: false
  t.bigint "process_id"
  # ...
 end
 create_table "solid_queue_processes", force: :cascade do |t|
  t.datetime "last_heartbeat_at", null: false
  t.integer "pid", null: false
  # ...
 end
 # ...
end

我们已经提到了 solid_queue_claimed_executions。让我们看看当 worker 声明一个 job 时会发生什么。首先,它在 solid_queue_jobs 表中设置 claimed 标志。此外,还在 solid_queue_claimed_executions 中创建一个记录。此记录包含被声明的 job 的 job_id 和发出声明的 worker 进程的 ID。

那么,solid_queue_processes 表有什么用呢?任何 worker 进程都会通过设置 last_heartbeat_at 在此表中创建并定期更新一条记录。当然,仅靠这一点并不能解决我们的问题。

我们需要另一个进程来跟踪正在运行的进程:所谓的 supervisor。此进程在后台运行并定期检查 solid_queue_processeslast_heartbeat_at 早于阈值(默认为 5 分钟)的记录表示相应的 worker 已经遭遇了不幸的命运。

如果找到这样的记录,supervisor 就会立即采取行动。首先,它从 solid_queue_processes 中删除该记录。然后,它将先前由现在已故的 worker 声明的任何 job 标记为可供争抢。因此,其他 workers 可以声明它们,从而避免出现 stuck-job 的情况。

Solid Queue 的更多发现

在这篇文章中,我们介绍了 Solid Queue 的很多内部结构。我们了解了它的高层架构,以及它的最基本功能(将 job 排队和执行 job)如何在底层工作。我们还了解了 FOR UPDATE SKIP LOCKED 在性能方面的关键作用。最后,我们了解了 supervisor 进程如何帮助避免 stuck jobs。

但还有更多值得发现。Solid Queue 提供了许多我们尚未触及的功能,例如调度重复性和顺序性 job。请继续关注,我们将在本系列的第二部分中继续深入研究。

想知道接下来可以做什么?

读完这篇文章了吗?您可以执行以下操作:

最受欢迎的 Ruby 文章

Hans-Jörg Schnedlitz

Hans-Jörg Schnedlitz

我们的客座作者 Hans 是来自奥地利维也纳的 Rails 工程师。他大部分时间都在编写代码或阅读有关编码的内容,有时甚至在他的博客上写有关编码的内容!当他不坐在屏幕前时,您可能会在外面找到他,攀登一些山峰。 Hans-Jörg Schnedlitz 的所有文章 成为我们的下一位作者! 了解更多

AppSignal 监控您的应用程序

AppSignal 为 Ruby, Rails, Elixir, Phoenix, Node.js, Express 以及许多其他框架和库提供见解。我们位于美丽的阿姆斯特丹。我们喜欢 stroopwafels。如果您也这样做,请告诉我们。我们可能会给您寄一些! 发现 AppSignal AppSignal monitors your apps AppSignal monitors your apps

功能

资源

比较

支持

您是否需要帮助,有功能要求或只是需要有人一起编程?请与我们的工程师联系。

关于我们

AppSignal 位于美丽的荷兰。我们喜欢 stroopwafels。如果您也这样做,请告诉我们。我们可能会给您寄一些!

语言